mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-02 15:04:37 +01:00
feat(ship): version-less repos, docs-audit record and caps, the frozen-scope paid case, and the review-loop follow-ups
Follow-up to the review-loop convergence PR: Step 12 and the CHANGELOG step skip cleanly in a repository with no version source (receipt version "none"); the receipt records the docs audit outcome and the owner exception (DOCS_AUDIT_BLOCKED without one) and a policy may declare doc caps (gstack-ship-receipt docs-caps); docs/unattended.md names the READY evidence and the cross-family outside voice; the ship-frozen-scope-convergence behaviour case runs the generated Step 9.4 under review_scope frozen through the real binaries; carry probes git >= 2.39 (RECEIPT_GIT_TOO_OLD); defer passes the repository to gh repo view positionally and names its search limit; late defers count without being passes; reviewers diff from RANGE_BASE on pass 2+; the cap check runs before edits; --review-fanout batches specialists by free slots; read --check-prose compares quoted counters with the receipt (RECEIPT_PROSE_MISMATCH); checklist rule ids prereg-with-results and test-keyboard-count-loop; gstack-coauthor-trailers. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
576839cb8c
commit
2d73fadd56
47 files changed
+1909
-155
No files matched your search
@@ -229,4 +229,19 @@ export const RELEASE_RESULT_CODES = {
|
||||
summary: 'the product diff against the base changed between the receipt\'s head and HEAD (a product file differs beyond a base merge, a test-only fix or release metadata), so the reviewed verdict does not cover HEAD',
|
||||
fix: 'run /ship on the new head; the receipt carries only when every changed path is a test, release metadata or the base merge and the product patch-id is unchanged',
|
||||
},
|
||||
DOCS_AUDIT_BLOCKED: {
|
||||
anchor: 'docs-audit-blocked',
|
||||
summary: 'the documentation audit (/ship Step 14.5) is blocked and no owner exception is recorded, or a declared doc cap is over, so gstack-ship-receipt write refused to record the ship as audited',
|
||||
fix: 'repair the audit (or trim the doc named by the DOCS_CAP: over line) and rerun Step 14.5; when the owner accepts the named risk, record it as docs.exception { accepted_by, date, via, reason } and carry the same line in the PR body\'s Documentation section',
|
||||
},
|
||||
RECEIPT_PROSE_MISMATCH: {
|
||||
anchor: 'receipt-prose-mismatch',
|
||||
summary: 'the PR body quotes a counter the ship receipt also records (review passes, deferred findings, gate case counts) with a different number, so the prose and the receipt disagree about the same run',
|
||||
fix: 'correct the body to the receipt\'s number (the PROSE: line names each field with both values); when the receipt is the stale one, rerun /ship so the receipt is rewritten from the run',
|
||||
},
|
||||
RECEIPT_GIT_TOO_OLD: {
|
||||
anchor: 'receipt-git-too-old',
|
||||
summary: 'gstack-ship-receipt carry needs git 2.39 or later for `git patch-id --verbatim`; the installed git is older, so the product diff could not be compared',
|
||||
fix: 'upgrade git to 2.39 or later (the refusal names the installed version), then rerun carry; otherwise run /ship on the new head',
|
||||
},
|
||||
} as const satisfies Record<string, ResultCode>;
|
||||
+11
-3
@@ -216,7 +216,7 @@ export function renderDeferredSection(deferred: readonly DeferredRecord[], noted
|
||||
if (deferred.length) {
|
||||
out.push('## Deferred findings', '', 'Blocking findings in code this PR did not change (review scope: frozen); filed as issues and not closed by this PR.', '');
|
||||
for (const d of deferred) {
|
||||
const ref = d.issue ? `#${d.issue}` : `unfiled${d.reason ? ` (${oneLine(d.reason)})` : ''}`;
|
||||
const ref = d.issue ? `#${d.issue}${d.reason && d.reason !== 'already filed' ? ` (${oneLine(d.reason)})` : ''}` : `unfiled${d.reason ? ` (${oneLine(d.reason)})` : ''}`;
|
||||
out.push(`- Deferred: ${ref} — ${oneLine(d.severity)} \`${oneLine(d.path)}\` ${oneLine(d.summary)}`);
|
||||
}
|
||||
out.push('');
|
||||
@@ -249,16 +249,24 @@ function rowTime(row: Record<string, any>): number {
|
||||
return NaN;
|
||||
}
|
||||
|
||||
/**
|
||||
* A Step 11 late defer (a queued adversarial finding scoped `defer` after the
|
||||
* last Step 9 pass) is logged as a `review` row with this phase: its deferred
|
||||
* findings count, but it is not a pass.
|
||||
*/
|
||||
export const LATE_DEFER_PHASE = 'late-defer';
|
||||
|
||||
export function summarizeReviewLoop(rows: readonly Record<string, any>[], since: string): ReviewLoopSummary {
|
||||
const sinceMs = Date.parse(since);
|
||||
const passes = rows.filter(r => r && r.skill === 'review' && r.via === 'ship' && Number.isFinite(rowTime(r))
|
||||
const loopRows = rows.filter(r => r && r.skill === 'review' && r.via === 'ship' && Number.isFinite(rowTime(r))
|
||||
&& (!Number.isFinite(sinceMs) || rowTime(r) >= sinceMs));
|
||||
const passes = loopRows.filter(r => r.phase !== LATE_DEFER_PHASE);
|
||||
const blocking = new Set<string>();
|
||||
const deferred = new Set<string>();
|
||||
const noted = new Set<string>();
|
||||
const fixed = new Set<string>();
|
||||
let scope: ReviewLoopSummary['scope'] = 'unknown';
|
||||
for (const row of passes) {
|
||||
for (const row of loopRows) {
|
||||
if (REVIEW_SCOPE_POLICIES.includes(row.review_scope)) scope = row.review_scope;
|
||||
for (const f of Array.isArray(row.findings) ? row.findings : []) {
|
||||
if (!f || typeof f !== 'object') continue;
|
||||
|
||||
+24
-1
@@ -71,6 +71,8 @@ export interface ShipPolicy {
|
||||
min_gstack_version: string | null;
|
||||
/** Merge trains in gstack-ship-queue (docs/ship-policy.md#run-a-merge-train); null keeps the per-PR queue. */
|
||||
train: TrainPolicy | null;
|
||||
/** Documentation caps /ship Step 14.5 checks before the verification gate (`gstack-ship-receipt docs-caps`); null declares none. */
|
||||
docs: { caps: Array<{ path: string; max_bytes: number }> } | null;
|
||||
}
|
||||
|
||||
/** The `train` block: the gate workflow trains dispatch and how its verdict is read. */
|
||||
@@ -115,6 +117,7 @@ export const DEFAULT_POLICY: ShipPolicy = {
|
||||
full_e2e: false,
|
||||
min_gstack_version: null,
|
||||
train: null,
|
||||
docs: null,
|
||||
};
|
||||
|
||||
export type PolicyKey = Exclude<keyof ShipPolicy, '$schema'>;
|
||||
@@ -142,6 +145,7 @@ export const POLICY_EXPLANATIONS: Record<PolicyKey, string> = {
|
||||
gate_command: 'The repo’s full pre-merge gate command. gstack-ship-queue gate-ahead and ready print it for the caller to run (in the background or in CI); gstack never executes it. null when the repo has none.',
|
||||
full_e2e: 'true: every merge needs a passing full-gate result (gstack-ship-queue gate-ahead --record, or the lane\'s --gate-exit 0 --gate-log on the gated head) bound to a tree equal to the merging one modulo stamps; ready, stamp and verify refuse without one (QUEUE_GATE_MISSING).',
|
||||
train: '{ gate_workflow, free_workflow, full_gate_check, record_artifact, max_size, fill_hours, screen_selection, trial_reservation_usd } or null: turns on merge trains in gstack-ship-queue (train build|status|verdict|bisect|rebuild|prune, ready --screen|--no-screen, verify --train). null keeps today\'s per-PR queue unchanged.',
|
||||
docs: '{ caps: [{ path, max_bytes }] } or null: documentation files with a byte cap. /ship Step 14.5 runs `gstack-ship-receipt docs-caps` before the verification gate and prints one DOCS_CAP: line per cap; a file over its cap (or absent) blocks the ship until the doc is trimmed or the cap raised on the base branch. null declares none.',
|
||||
min_gstack_version: 'The oldest gstack that may read this file. Any key newer than 1.91.98.0 set to a non-default value requires it at that key’s introducing version (validate prints each); a gstack older than it refuses the file (POLICY_GSTACK_TOO_OLD).',
|
||||
};
|
||||
|
||||
@@ -156,12 +160,13 @@ export const POLICY_COMPAT_FLOOR = '1.91.98.0';
|
||||
export const SHIP_QUEUE_KEYS_VERSION = '1.91.108.0';
|
||||
export const TRAIN_KEYS_VERSION = '1.91.146.0';
|
||||
export const REVIEW_SCOPE_KEY_VERSION = '1.91.249.0';
|
||||
export const DOCS_CAPS_KEY_VERSION = '1.91.250.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',
|
||||
pr_count: '1.91.73.0', maintainers: '1.91.73.0', stamp_files: '1.91.98.0', stamp_patterns: '1.91.98.0', restamp_command: '1.91.98.0',
|
||||
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,
|
||||
train: TRAIN_KEYS_VERSION, review_scope: REVIEW_SCOPE_KEY_VERSION, docs: DOCS_CAPS_KEY_VERSION,
|
||||
};
|
||||
|
||||
const newer = (a: string, b: string): boolean => cmpVersion(parseVersion(a)!, parseVersion(b)!) > 0;
|
||||
@@ -339,6 +344,7 @@ export function validatePolicy(raw: unknown, opts: { gstackVersion?: string } =
|
||||
}
|
||||
if ('full_e2e' in obj && typeof obj.full_e2e !== 'boolean') errors.push({ code: 'POLICY_INVALID', path: 'full_e2e', message: 'must be true or false' });
|
||||
if ('train' in obj && obj.train !== null) trainErrors(obj.train, errors);
|
||||
if ('docs' in obj && obj.docs !== null) docsErrors(obj.docs, errors);
|
||||
if ('stamp_paths' in obj) listErrors('stamp_paths', obj.stamp_paths, errors, globError);
|
||||
for (const key of ['changelog', 'mirror'] as const) {
|
||||
if (!(key in obj) || obj[key] === null) continue;
|
||||
@@ -403,6 +409,23 @@ function mirrorPathError(p: string): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** `docs`: `{ caps: [{ path, max_bytes }] }`, every path contained, every cap a positive integer, at most 64 caps. */
|
||||
function docsErrors(raw: unknown, errors: PolicyError[]): void {
|
||||
const d = raw as Record<string, unknown> | null;
|
||||
if (!d || typeof d !== 'object' || Array.isArray(d)) { errors.push({ code: 'POLICY_INVALID', path: 'docs', message: 'must be { caps: [{ path, max_bytes }] } or null' }); return; }
|
||||
for (const k of Object.keys(d)) if (k !== 'caps') errors.push({ code: 'POLICY_INVALID', path: `docs.${k}`, message: 'unknown key' });
|
||||
if (!Array.isArray(d.caps)) { errors.push({ code: 'POLICY_INVALID', path: 'docs.caps', message: 'must be an array of { path, max_bytes }' }); return; }
|
||||
if (d.caps.length > MAX_LIST) errors.push({ code: 'POLICY_CONTAINMENT', path: 'docs.caps', message: `more than ${MAX_LIST} entries` });
|
||||
d.caps.forEach((cap, i) => {
|
||||
const c = cap as Record<string, unknown> | null;
|
||||
if (!c || typeof c !== 'object' || Array.isArray(c)) { errors.push({ code: 'POLICY_INVALID', path: `docs.caps[${i}]`, message: 'must be { path, max_bytes }' }); return; }
|
||||
for (const k of Object.keys(c)) if (!['path', 'max_bytes'].includes(k)) errors.push({ code: 'POLICY_INVALID', path: `docs.caps[${i}].${k}`, message: 'unknown key' });
|
||||
const why = typeof c.path === 'string' ? containedRelative(c.path) : 'must be a string';
|
||||
if (why) errors.push({ code: 'POLICY_CONTAINMENT', path: `docs.caps[${i}].path`, message: `${JSON.stringify(c.path)} ${why}` });
|
||||
if (!Number.isInteger(c.max_bytes) || (c.max_bytes as number) < 1) errors.push({ code: 'POLICY_INVALID', path: `docs.caps[${i}].max_bytes`, message: 'must be an integer >= 1' });
|
||||
});
|
||||
}
|
||||
|
||||
/** Merge a parsed file over the defaults, recording per-key provenance. */
|
||||
export function mergePolicy(raw: Record<string, unknown> | null, sourceLabel: string): { policy: ShipPolicy; values: Record<string, string> } {
|
||||
const policy: ShipPolicy = JSON.parse(JSON.stringify(DEFAULT_POLICY));
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* ship-receipt-docs — the receipt's `docs` record (what /ship Step 14.5's
|
||||
* documentation audit concluded, and the owner's exception when it stayed
|
||||
* blocked) and the doc caps a repository declares in its ship policy
|
||||
* (`docs.caps`, docs/ship-policy.md#ship-receipt). Kept beside
|
||||
* lib/ship-receipt.ts so that module stays under the size ratchet.
|
||||
*/
|
||||
export const DOCS_STATUSES = ['updated', 'current', 'blocked'] as const;
|
||||
export type DocsStatus = typeof DOCS_STATUSES[number];
|
||||
/** Only the owner accepts a documentation risk; the record names who, when, where and why, and the PR body repeats it word for word. */
|
||||
export interface DocsException { accepted_by: string; date: string; via: string; reason: string }
|
||||
export interface ReceiptDocs { status: DocsStatus; scope: string; exception?: DocsException }
|
||||
export type DocsParse = { ok: true; docs: ReceiptDocs } | { ok: false; code: 'RECEIPT_INVALID' | 'DOCS_AUDIT_BLOCKED'; message: string };
|
||||
|
||||
const EXCEPTION_KEYS = ['accepted_by', 'date', 'via', 'reason'] as const;
|
||||
const text = (v: unknown): v is string => typeof v === 'string' && v.trim().length > 0 && !/[\x00-\x1f\x7f]/.test(v);
|
||||
|
||||
/**
|
||||
* `write --docs` input: `{ status, scope, exception? }`. A blocked audit with
|
||||
* no exception is DOCS_AUDIT_BLOCKED, not a receipt: /ship Step 14.5 ends
|
||||
* blocked only when the owner has not accepted the named risk, and a receipt
|
||||
* written then would publish an unaudited release as audited.
|
||||
*/
|
||||
export function parseReceiptDocs(value: unknown): DocsParse {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return { ok: false, code: 'RECEIPT_INVALID', message: '--docs needs a JSON object { status, scope[, exception] }' };
|
||||
const v = value as Record<string, unknown>;
|
||||
for (const key of Object.keys(v)) if (!['status', 'scope', 'exception'].includes(key)) return { ok: false, code: 'RECEIPT_INVALID', message: `--docs: unknown key ${JSON.stringify(key)}` };
|
||||
if (!DOCS_STATUSES.includes(v.status as DocsStatus)) return { ok: false, code: 'RECEIPT_INVALID', message: `--docs status must be one of ${DOCS_STATUSES.join(' | ')}, got ${JSON.stringify(v.status)}` };
|
||||
if (!text(v.scope)) return { ok: false, code: 'RECEIPT_INVALID', message: '--docs scope must be a non-empty one-line string (the docs roots the audit covered)' };
|
||||
const docs: ReceiptDocs = { status: v.status as DocsStatus, scope: v.scope.trim() };
|
||||
if (v.exception !== undefined) {
|
||||
const e = v.exception as Record<string, unknown> | null;
|
||||
if (!e || typeof e !== 'object' || Array.isArray(e)) return { ok: false, code: 'RECEIPT_INVALID', message: '--docs exception must be { accepted_by, date, via, reason }' };
|
||||
for (const key of Object.keys(e)) if (!(EXCEPTION_KEYS as readonly string[]).includes(key)) return { ok: false, code: 'RECEIPT_INVALID', message: `--docs exception: unknown key ${JSON.stringify(key)}` };
|
||||
for (const key of EXCEPTION_KEYS) if (!text(e[key])) return { ok: false, code: 'RECEIPT_INVALID', message: `--docs exception.${key} must be a non-empty one-line string` };
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(e.date as string)) return { ok: false, code: 'RECEIPT_INVALID', message: '--docs exception.date must be YYYY-MM-DD' };
|
||||
docs.exception = { accepted_by: (e.accepted_by as string).trim(), date: e.date as string, via: (e.via as string).trim(), reason: (e.reason as string).trim() };
|
||||
}
|
||||
if (docs.status === 'blocked' && !docs.exception) return { ok: false, code: 'DOCS_AUDIT_BLOCKED', message: `the documentation audit is blocked (${docs.scope}) and no owner exception is recorded; repair the audit, or record the owner's acceptance as exception: { accepted_by, date, via, reason }` };
|
||||
return { ok: true, docs };
|
||||
}
|
||||
|
||||
/** The line the PR body's `## Documentation` section must carry when an exception stands: the exception, word for word. */
|
||||
export function docsExceptionLine(docs: ReceiptDocs): string | null {
|
||||
if (!docs.exception) return null;
|
||||
const e = docs.exception;
|
||||
return `Documentation exception: accepted by ${e.accepted_by} ${e.date} via ${e.via}: ${e.reason}`;
|
||||
}
|
||||
|
||||
/** The `docs` object a receipt carries, when it validates; receipts written before this record have none. */
|
||||
export function receiptDocs(receipt: Record<string, unknown>): ReceiptDocs | undefined {
|
||||
const parsed = parseReceiptDocs(receipt.docs);
|
||||
return parsed.ok ? parsed.docs : undefined;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// doc caps: `docs.caps` in .gstack/ship-policy.json
|
||||
// ---------------------------------------------------------------------------
|
||||
export interface DocsCap { path: string; max_bytes: number }
|
||||
export interface DocsCapResult { path: string; bytes: number | null; max: number; verdict: 'ok' | 'over' }
|
||||
|
||||
/** Every declared cap against the candidate tree; a path the tree lacks is `over` (a cap on nothing is a policy mistake, not a pass). */
|
||||
export function checkDocsCaps(caps: readonly DocsCap[], sizeOf: (rel: string) => number | null): DocsCapResult[] {
|
||||
return caps.map(cap => {
|
||||
const bytes = sizeOf(cap.path);
|
||||
return { path: cap.path, bytes, max: cap.max_bytes, verdict: bytes !== null && bytes <= cap.max_bytes ? 'ok' : 'over' };
|
||||
});
|
||||
}
|
||||
|
||||
export function docsCapLine(r: DocsCapResult): string {
|
||||
return `DOCS_CAP: ${r.path} ${r.bytes === null ? 'absent' : r.bytes}/${r.max} ${r.verdict}`;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* The receipt-versus-prose number check (/ship Step 19, docs/ship-policy.md#ship-receipt):
|
||||
* a PR body that quotes a counter the receipt also records (review passes,
|
||||
* deferred findings, gate case counts) must quote the receipt's number.
|
||||
* `gstack-ship-receipt read --check-prose` runs it; a disagreement is
|
||||
* RECEIPT_PROSE_MISMATCH. Prose that quotes no receipt counter is not a claim.
|
||||
*/
|
||||
import { RECEIPT_FENCE } from './ship-receipt';
|
||||
|
||||
export interface ProseClaim { field: string; prose: number; receipt: number }
|
||||
export interface ProseCheck { checked: number; mismatches: ProseClaim[] }
|
||||
|
||||
/** Integer leaves of the receipt under the counter-bearing keys, as `parent.key` → value. */
|
||||
export function receiptCounters(receipt: Record<string, unknown>): Map<string, number> {
|
||||
const out = new Map<string, number>();
|
||||
const walk = (value: unknown, prefix: string) => {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return;
|
||||
for (const [key, leaf] of Object.entries(value as Record<string, unknown>)) {
|
||||
if (Number.isInteger(leaf)) out.set(prefix ? `${prefix}.${key}` : key, leaf as number);
|
||||
else walk(leaf, prefix ? `${prefix}.${key}` : key);
|
||||
}
|
||||
};
|
||||
for (const key of ['review_loop', 'gate', 'ci']) walk(receipt[key], key);
|
||||
return out;
|
||||
}
|
||||
|
||||
const BLOCK_RE = new RegExp('```' + RECEIPT_FENCE + '[ \\t]*\\r?\\n[\\s\\S]*?\\r?\\n```', 'g');
|
||||
const ESCAPE = /[.*+?^${}()|[\]\\]/g;
|
||||
|
||||
/**
|
||||
* Every `<name>=<n>`, `<name>: <n>` and `<n> <name>` in the prose (the body
|
||||
* without its receipt block), where `<name>` is a counter's leaf key with `_`
|
||||
* matched as `_`, space or `-`, compared with the receipt. A leaf key shared
|
||||
* by several counters (gate.passed and ci.passed) is checked against each.
|
||||
*/
|
||||
export function checkReceiptProse(receipt: Record<string, unknown>, body: string): ProseCheck {
|
||||
const prose = body.replace(BLOCK_RE, '');
|
||||
const counters = receiptCounters(receipt);
|
||||
const mismatches: ProseClaim[] = [];
|
||||
let checked = 0;
|
||||
for (const [field, value] of counters) {
|
||||
const leaf = field.split('.').at(-1)!;
|
||||
const name = leaf.replace(ESCAPE, '\\$&').replaceAll('_', '[ _-]');
|
||||
const re = new RegExp(`(?:\\b${name}\\s*[=:]\\s*(\\d+)\\b|\\b(\\d+)\\s+${name}\\b)`, 'gi');
|
||||
for (const m of prose.matchAll(re)) {
|
||||
checked++;
|
||||
const n = Number(m[1] ?? m[2]);
|
||||
if (n !== value) mismatches.push({ field, prose: n, receipt: value });
|
||||
}
|
||||
}
|
||||
return { checked, mismatches };
|
||||
}
|
||||
|
||||
export function proseLine(check: ProseCheck): string {
|
||||
if (!check.checked) return 'PROSE: no receipt counter claims in the body';
|
||||
if (!check.mismatches.length) return `PROSE: ${check.checked} receipt counter claim${check.checked === 1 ? '' : 's'} in the body, all match`;
|
||||
return 'PROSE: mismatch ' + check.mismatches.map(m => `${m.field} body=${m.prose} receipt=${m.receipt}`).join('; ');
|
||||
}
|
||||
+10
-6
@@ -13,16 +13,16 @@ import { matchesAny } from './ship-policy';
|
||||
import { stampFileKind, versionStampOnly } from './pregate/stamp-only';
|
||||
|
||||
export const RECEIPT_FENCE = 'gstack-ship-receipt';
|
||||
const KEY_ORDER: Array<keyof ShipReceipt> = [
|
||||
const KEY_ORDER: Array<keyof ShipReceipt | 'docs'> = [
|
||||
'schema_version', 'pr', 'head', 'version', 'base', 'gated_tree', 'predecessor', 'tree', 'gate_reuse', 'gate', 'ci', 'spend_usd',
|
||||
'session_kind', 'artifacts_consumed', 'run', 'thread', 'policy', 'queue_mode', 'preregistration', 'history',
|
||||
'pregate', 'requires_remote', 'issues', 'approval', 'review_loop', 'carry',
|
||||
'pregate', 'requires_remote', 'issues', 'approval', 'docs', 'review_loop', 'carry',
|
||||
];
|
||||
|
||||
/** Keys in the fixed order so identifiers lead; undefined values are dropped. */
|
||||
export function orderReceipt(input: Partial<ShipReceipt>): ShipReceipt {
|
||||
export function orderReceipt(input: Partial<ShipReceipt> & { docs?: unknown }): ShipReceipt {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const key of KEY_ORDER) if (input[key] !== undefined) out[key] = input[key];
|
||||
for (const key of KEY_ORDER) if ((input as Record<string, unknown>)[key] !== undefined) out[key] = (input as Record<string, unknown>)[key];
|
||||
for (const key of Object.keys(input)) if (!(key in out) && (input as any)[key] !== undefined) out[key] = (input as any)[key];
|
||||
return { schema_version: 1, ...out } as ShipReceipt;
|
||||
}
|
||||
@@ -145,6 +145,8 @@ export const CARRY_TEST_GLOBS: readonly string[] = [
|
||||
'**/*_test.go', '**/test_*.py', '**/*_test.py', '**/*_spec.rb', '**/*Test.java', '**/*Tests.swift',
|
||||
];
|
||||
export const CARRY_METADATA_GLOBS: readonly string[] = ['VERSION', 'CHANGELOG.md', 'package.json', 'agents-digest/**'];
|
||||
/** How many differing product paths a RECEIPT_CARRY_PRODUCT_CHANGED refusal names, and how far the per-path patch-id walk goes. */
|
||||
export const CARRY_NAMED_PATHS = 20;
|
||||
|
||||
export type CarryClass = 'product' | 'test' | 'metadata';
|
||||
export interface CarryPlanInput {
|
||||
@@ -176,7 +178,9 @@ export interface CarryChange { before: string | null; after: string | null; oldV
|
||||
export function stampOnlyText(change: CarryChange): boolean {
|
||||
const { before, after, oldVersion, newVersion } = change;
|
||||
if (before === null || after === null || !oldVersion || !newVersion || oldVersion === newVersion) return false;
|
||||
return after.split(newVersion).join(oldVersion) === before;
|
||||
// Digit-bounded: replacing 1.2.3 inside 11.2.3 or 1.2.34 would read a different version's stamp as this one's.
|
||||
const escaped = newVersion.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
return after.replace(new RegExp(`(?<![0-9])${escaped}(?![0-9])`, 'g'), oldVersion) === before;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -207,7 +211,7 @@ export function classifyCarryPath(rel: string, extra: { metadata?: readonly stri
|
||||
*/
|
||||
export function planCarry(input: CarryPlanInput): CarryPlan {
|
||||
if (input.productPatchId.from !== input.productPatchId.head) {
|
||||
const named = input.changedProduct.slice(0, 20);
|
||||
const named = input.changedProduct.slice(0, CARRY_NAMED_PATHS);
|
||||
return { ok: false, code: 'RECEIPT_CARRY_PRODUCT_CHANGED', message: `product diff changed between ${input.from.slice(0, 12)} and ${input.head.slice(0, 12)}: ${named.join(', ') || 'patch-id differs'}${input.changedProduct.length > named.length ? ` (+${input.changedProduct.length - named.length} more)` : ''}` };
|
||||
}
|
||||
const reasons: string[] = [];
|
||||
|
||||
Reference in new issue
Block a user