mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-01 22:45:02 +01:00
ship-policy: review_exclude_paths key and loadPolicyAt (GST-508)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
5b7b0950b8
commit
745c05bdb7
1 file changed
+38
-1
+38
-1
@@ -53,6 +53,8 @@ export interface ShipPolicy {
|
||||
stamp_sensitive_gates: string[];
|
||||
/** The /ship review loop's finding scope (docs/ship-policy.md#review-scope). */
|
||||
review_scope: ReviewScope;
|
||||
/** Git glob pathspecs (root-anchored) whose data, docs, binary and log files /ship's reviewers leave out; read from the base only (docs/ship-policy.md#review-exclude-paths). */
|
||||
review_exclude_paths: string[];
|
||||
/** Consumed by the plan reviews (D1). */
|
||||
pr_count: { default_pr_count: number; split_requires_owner: boolean };
|
||||
/** Maintainer logins for contributor-PR mode (E3); empty means "use push permission". */
|
||||
@@ -153,6 +155,7 @@ export const DEFAULT_POLICY: ShipPolicy = {
|
||||
preregistration_shas: [],
|
||||
stamp_sensitive_gates: [],
|
||||
review_scope: 'frozen',
|
||||
review_exclude_paths: [],
|
||||
pr_count: { default_pr_count: 1, split_requires_owner: true },
|
||||
maintainers: [],
|
||||
stamp_files: [],
|
||||
@@ -184,6 +187,7 @@ export const POLICY_EXPLANATIONS: Record<PolicyKey, string> = {
|
||||
preregistration_shas: 'Commits that must be ancestors of HEAD before push (preregistered evals); checked by `gstack-evidence ancestor`.',
|
||||
stamp_sensitive_gates: 'Evidence labels (gstack-evidence --label) that read VERSION or generated bytes; the tree receipt never marks them reusable.',
|
||||
review_scope: 'frozen | diff-plus-blocking | all. frozen (default): /ship Step 9 fixes findings in the diff on pass 1, files blocking findings in code the PR did not change as issues, and later passes read only the range since the previous pass and fix only blocking findings; diff-plus-blocking: the same, but a blocking finding in code the PR did not change is fixed in this PR instead of filed; all: every finding is fixed now (the pre-1.91.249 loop).',
|
||||
review_exclude_paths: 'Git glob pathspecs, root-anchored and case-sensitive (`receipts/**`, `**/*.jsonl`; at most 64, 256 characters each, no leading `:` or `!`): /ship\'s reviewers leave out the data, docs, binary and .log files they match. Read from the base branch only, so a branch cannot exclude its own files. Code, gate paths, agent prose, config, executables, .gstack/ and .github/ files, gate-tagged or secret-bearing files and files a kept code or test file names by full path stay reviewed (docs/ship-policy.md#review-exclude-paths).',
|
||||
pr_count: '{ default_pr_count, split_requires_owner }: the PR-count row in plan reviews.',
|
||||
maintainers: 'GitHub logins treated as maintainers by /review contributor mode; empty uses repository push permission.',
|
||||
stamp_files: 'The repo’s stamp rule: globs of the only files that may differ by a stamp (version file, manifests, plugin trees, the PR’s own since: rows). Empty keeps gstack’s per-file rules; set, it applies before every rule.',
|
||||
@@ -231,6 +235,7 @@ export const BUMP_NEVER_KEY_VERSION = '1.91.353.0';
|
||||
export const BASE_RED_KEY_VERSION = '1.91.374.0';
|
||||
/** `train.gates` and `train.land` are values of the older train key, so compatErrors checks them by value, as for bump: never. */
|
||||
export const TRAIN_GATES_KEY_VERSION = '1.92.0.0';
|
||||
export const REVIEW_EXCLUDE_KEY_VERSION = '1.93.0.0';
|
||||
export const POLICY_KEY_INTRODUCED: Record<PolicyKey, string> = {
|
||||
bump: '1.91.73.0', queue_mode: '1.91.73.0', release_tool: '1.91.73.0', release_outputs: '1.91.73.0', stamp_paths: '1.91.73.0',
|
||||
changelog: '1.91.73.0', mirror: '1.91.73.0', history: '1.91.73.0', preregistration_shas: '1.91.73.0', stamp_sensitive_gates: '1.91.73.0',
|
||||
@@ -238,7 +243,7 @@ export const POLICY_KEY_INTRODUCED: Record<PolicyKey, string> = {
|
||||
migrations: '1.91.98.0', gate_command: SHIP_QUEUE_KEYS_VERSION, full_e2e: SHIP_QUEUE_KEYS_VERSION, min_gstack_version: SHIP_QUEUE_KEYS_VERSION,
|
||||
train: TRAIN_KEYS_VERSION, review_scope: REVIEW_SCOPE_KEY_VERSION, docs: DOCS_CAPS_KEY_VERSION,
|
||||
changelog_fragments: CHANGELOG_FRAGMENTS_KEY_VERSION,
|
||||
base_red: BASE_RED_KEY_VERSION,
|
||||
base_red: BASE_RED_KEY_VERSION, review_exclude_paths: REVIEW_EXCLUDE_KEY_VERSION,
|
||||
};
|
||||
|
||||
const newer = (a: string, b: string): boolean => cmpVersion(parseVersion(a)!, parseVersion(b)!) > 0;
|
||||
@@ -327,6 +332,16 @@ function globError(g: string): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** `review_exclude_paths`: git glob pathspecs, each one repo-relative line with no pathspec magic of its own; every error is POLICY_INVALID at its index. */
|
||||
function excludeErrors(value: unknown, out: PolicyError[]): void {
|
||||
if (!Array.isArray(value)) { out.push({ code: 'POLICY_INVALID', path: 'review_exclude_paths', message: 'must be an array of git glob pathspecs' }); return; }
|
||||
if (value.length > MAX_LIST) out.push({ code: 'POLICY_INVALID', path: 'review_exclude_paths', message: `more than ${MAX_LIST} patterns` });
|
||||
value.forEach((p, i) => {
|
||||
const why = typeof p !== 'string' ? 'must be a string' : containedRelative(p) ?? (/^[:!]/.test(p) ? 'must not start with : or ! (no pathspec magic or negation)' : /[\x00-\x1f\x7f]/.test(p) ? 'contains a control character' : p.replace(/\/+$/, '') === '' || p === '.' ? 'must not name the whole repository' : null);
|
||||
if (why) out.push({ code: 'POLICY_INVALID', path: `review_exclude_paths[${i}]`, message: `${JSON.stringify(p)} ${why}` });
|
||||
});
|
||||
}
|
||||
|
||||
function templateErrors(value: unknown, out: PolicyError[]): void {
|
||||
if (!Array.isArray(value)) { out.push({ code: 'POLICY_INVALID', path: 'stamp_patterns', message: 'must be an array of literal templates' }); return; }
|
||||
if (value.length > MAX_TEMPLATES) out.push({ code: 'POLICY_CONTAINMENT', path: 'stamp_patterns', message: `more than ${MAX_TEMPLATES} templates` });
|
||||
@@ -471,6 +486,7 @@ export function validatePolicy(raw: unknown, opts: { gstackVersion?: string } =
|
||||
oneOf('queue_mode', ['claim-at-open', 'stamp-at-merge']);
|
||||
oneOf('history', ['merge-only', 'squash-ok']);
|
||||
oneOf('review_scope', ['frozen', 'diff-plus-blocking', 'all']);
|
||||
if ('review_exclude_paths' in obj) excludeErrors(obj.review_exclude_paths, errors);
|
||||
if ('release_tool' in obj && obj.release_tool !== null) {
|
||||
const t = obj.release_tool;
|
||||
if (typeof t !== 'string' || !t.trim()) errors.push({ code: 'POLICY_INVALID', path: 'release_tool', message: 'must be a non-empty string or null' });
|
||||
@@ -669,6 +685,26 @@ export function loadPolicyFromBase(repoRoot: string, base: string): PolicyLoad {
|
||||
return finish(show.ok ? show.out : null, source);
|
||||
}
|
||||
|
||||
/**
|
||||
* The policy committed at `sha` (a commit the caller already resolved, labeled `ref`): `cat-file -e` first, and only an
|
||||
* `ls-tree` that succeeds with no entry is a missing file (defaults); any git failure, including a `show` failure after
|
||||
* the file was found, is `unavailable`, never a silent default.
|
||||
*/
|
||||
export function loadPolicyAt(repoRoot: string, sha: string, ref: string): PolicyLoad {
|
||||
const label = `${ref}@${sha.slice(0, 12)}`;
|
||||
const source: PolicySource = { kind: 'base', ref, sha, label: `${label}:${SHIP_POLICY_PATH}` };
|
||||
const spec = `${sha}:${SHIP_POLICY_PATH}`;
|
||||
const base = ref.replace(/^origin\//, '');
|
||||
if (!git(repoRoot, ['cat-file', '-e', spec]).ok) {
|
||||
const tree = git(repoRoot, ['ls-tree', sha, '--', SHIP_POLICY_PATH]);
|
||||
if (!tree.ok) return { unavailable: true, base, detail: `git ls-tree ${sha.slice(0, 12)} failed: ${tree.err.split('\n')[0] || 'no detail'}` };
|
||||
if (!tree.out) return finish(null, { ...source, kind: 'none', label: `${label} (no ${SHIP_POLICY_PATH})` });
|
||||
}
|
||||
const show = git(repoRoot, ['show', spec]);
|
||||
if (!show.ok) return { unavailable: true, base, detail: `git show ${label}:${SHIP_POLICY_PATH} failed: ${show.err.split('\n')[0] || 'no detail'}` };
|
||||
return finish(show.out, source);
|
||||
}
|
||||
|
||||
/** The working-tree policy: unreviewed, labeled so in every receipt. */
|
||||
export function loadPolicyFromHead(repoRoot: string): LoadedPolicy {
|
||||
const file = path.join(repoRoot, SHIP_POLICY_PATH);
|
||||
@@ -733,6 +769,7 @@ export function shipConsequences(policy: ShipPolicy, base: string): string[] {
|
||||
: policy.review_scope === 'diff-plus-blocking'
|
||||
? 'review_scope diff-plus-blocking: Step 9.4 runs `gstack-review-scope classify --policy diff-plus-blocking` each pass; pass 1 fixes the diff and every blocking finding, in unchanged code too, later passes read only the range since the previous pass and fix only blocking findings; notes go to the PR body'
|
||||
: 'review_scope frozen: Step 9.4 runs `gstack-review-scope classify` each pass; pass 1 fixes the diff and defers blocking findings in unchanged code to issues, later passes read only the range since the previous pass and fix only blocking findings; notes go to the PR body');
|
||||
if (policy.review_exclude_paths.length) out.push(`review_exclude_paths ${policy.review_exclude_paths.join(' ')}: Step 9's reviewers leave out the data, docs, binary and .log files these git globs match on this branch; gstack-review-plan prints REVIEW_EXCLUDE: with the counts and the kept files, and every brief names what was left out (docs/ship-policy.md#review-exclude-paths)`);
|
||||
if (policy.preregistration_shas.length) out.push(`preregistration: \`gstack-evidence ancestor --from-policy --base ${base}\` must print only ANCESTOR: ok before push; the ship receipt records the result`);
|
||||
if (policy.release_tool) out.push(`release_tool: \`${policy.release_tool}\` runs only inside gstack-restamp (staging worktree, outputs confined to release_outputs)`);
|
||||
return out;
|
||||
|
||||
Reference in new issue
Block a user