mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-02 15:04:37 +01:00
* feat(ship): review loop converges (frozen finding scope, 3-pass cap, NO FINDINGS first-class) and receipts carry across conflict-only merges
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test(ship): keep the Step 9 first-entry and Fix-First phrasing the skip/requeue contract asserts; fill the review record's pass, review_scope and range_base placeholders in the caller template test; narrow a test type
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: pre-landing review fixes (pass 1) and the review-loop budget wording
Two blocking findings from /ship's own pass 1, both reproduced in the QA smoke before the fix:
gstack-ship-receipt carry hashed the product diff with git patch-id --stable, which strips
whitespace, so a space moved in a reviewed shell line carried as "no product change"; and it
classed package.json and every lockfile as release metadata, so a dependency swap carried too.
The patch-id is now --verbatim, package.json and VERSION are metadata only while the change is a
version stamp (lib/pregate/stamp-only.ts), lockfiles are product unless the policy names them,
the test globs name code extensions, a dirty working tree refuses before anything is compared,
and a receipt carried twice keeps the reviewed head as carry.from.
gstack-review-scope: an unresolvable base is POLICY_SOURCE_UNAVAILABLE instead of a silent
working-tree read; --range-base is validated; git runs with core.quotePath=false; range_base is
null on pass 1; a blocking finding with no path is fix; category "testing" is a test finding
below the floor; defer searches once, files nothing on a failed search and exits 0 unfiled;
summary refuses zero matching passes and the receipt write is chained to it with &&; a finding
the diff causes elsewhere names the changed line in `cause` and classify follows it.
Review-loop budget wording (ideas from tstack's sol-loop and pstack's interrogate, rewritten;
NOTICE.md): the blocker / should / nit glossary and two stop rules in Step 9.4, the checklist
and docs/review-policy.md; a lead-judgment stage in the finding merge both /review and /ship
render. The core read diffs $RANGE_BASE from pass 2 on. Caps and pins re-measured with reasons.
Step 7 coverage audit: four tests added in existing files, the --review-loop case moved to its
own test (the shared test tripped bun's 5 s default).
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: pin the renumbered review-army stages and point the wording test at product code
CI at c3e27fc failed three free-tier tests, all this branch's own: the
workflow-required-steps fixture and shared-libs-rendering still pinned the
pre-Lead-judgment stage numbers, and test-of-test-ratchet counted
review-loop-budget-wording.test.ts as test-only code because it read only
generated markdown. The fixture and rendering test now pin stages 1-8, and the
wording test renders both armies through generateReviewArmy and reads the
authored ship template as well as the generated sections.
Pass-2 maintainability notes folded in: docs/troubleshooting.md names the two
new RECEIPT_CARRY_REFUSED causes (dirty tree, unreadable status) and the two new
REVIEW_SCOPE_INPUT causes (non-hex --range-base, summary with no matching
pass); gstack-review-scope's help states that classify reads the base branch's
policy and refuses an unresolvable base unless --policy is passed.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: date a review-log row from its binding start when it has no timestamp
The Step 9.4 template asks the writer to fill `timestamp`; the logger stamps
`review_binding.started_at` but never `timestamp`. `gstack-review-scope
summary` dropped such a row silently, so this PR's own pass 2 was missing from
REVIEW_LOOP (passes=1 for a two-pass loop). A row now dates from `timestamp`,
else from its binding start; a row with neither is still not counted.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: pass-4 review fixes (carry stamp rule, deleted-file locus, scanned defer, diff prefixes, row time)
Five findings at the blocking floor from the Step 11 outside voice (gpt-6.1-sol)
and the native adversarial pass, fixed as a fourth pass approved by GStack
Minder under the three-pass policy:
- carry: a path the policy names under stamp_files, stamp_paths or
release_outputs is metadata only while its change is a stamp (versionStampOnly
for a manifest kind, stampOnlyText otherwise), the rule the tree receipt
applies; a logic change in a stamped source file is product. Every
metadata-listed path is read at both heads and a non-stamp change drops its
globs from the product exclusion.
- review-scope: parseHunks keeps a deleted file as a whole changed location, so
a cause anchored in a removed export is the diff's to fix, not deferred.
- defer: issues are filed through lib/gstack-post's publication scan; a HIGH
finding in the title or body refuses and a MEDIUM one needs a human, both
recorded as unfiled with the reason; --repo names the destination.
- classify: both diffs pin --src-prefix=a/ --dst-prefix=b/ --no-textconv, so
diff.mnemonicPrefix cannot send every finding outside.
- summary: a row dates from review_binding.started_at before the writer-typed
timestamp, so a date-only value cannot drop a real pass.
Docs: ship-policy.md#receipt-carry, review-policy.md, the Step 9.4 template
and the CHANGELOG gate-integrity note record the rule and the pass.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test(review-army): assert the Red Team branch the run took in the N+1 case
Under the blocking floor the Performance specialist rates a 12-line N+1
INFORMATIONAL, so the conditional Red Team does not activate; the case
encoded the old severity (N+1 is CRITICAL, a Red Team always follows). It
now asserts the branch the run took: a Red Team that ran must follow the
Performance specialist; no Red Team is correct only when the report's own
count says 0 critical and its Red Team line names that reason. The N+1
detection assertion is unchanged. The free contract test gains the
informational-not-activated and informational-claimed controls and the
budget test's synthetic report carries the count line.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* v1.91.256.0 stamp
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
---------
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
318 lines
19 KiB
TypeScript
318 lines
19 KiB
TypeScript
/**
|
|
* ship-receipt — the handoff record, identifiers first (plan C5). Renders the
|
|
* ```gstack-ship-receipt``` block from the `ship-receipt` schema in
|
|
* lib/headless-artifacts.ts, puts it first in a PR body so truncation loses
|
|
* prose rather than identifiers, reads it back from a PR through `gh`, and
|
|
* computes the P7 adoption census over merged PRs (deduplicated by run and
|
|
* thread id against an owner-maintained thread cohort).
|
|
*/
|
|
import { ARTIFACT_SCHEMAS, checkSchema, type ShipReceipt, type ValidationError } from './headless-artifacts';
|
|
import { approvalSummary } from './approval-citations';
|
|
import { renderIssueLines } from './issue-links';
|
|
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> = [
|
|
'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',
|
|
];
|
|
|
|
/** Keys in the fixed order so identifiers lead; undefined values are dropped. */
|
|
export function orderReceipt(input: Partial<ShipReceipt>): ShipReceipt {
|
|
const out: Record<string, unknown> = {};
|
|
for (const key of KEY_ORDER) if (input[key] !== undefined) out[key] = input[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;
|
|
}
|
|
|
|
export function validateReceipt(receipt: unknown): ValidationError[] {
|
|
const errors: ValidationError[] = [];
|
|
checkSchema(receipt, ARTIFACT_SCHEMAS['ship-receipt'], '', errors, 'ship-receipt');
|
|
return errors;
|
|
}
|
|
|
|
export function renderReceiptBlock(receipt: ShipReceipt): string {
|
|
return '```' + RECEIPT_FENCE + '\n' + JSON.stringify(receipt, null, 2) + '\n```\n';
|
|
}
|
|
|
|
const BLOCK_RE = /```gstack-ship-receipt[ \t]*\r?\n([\s\S]*?)\r?\n```/;
|
|
|
|
/** The first receipt block in a PR body, parsed; `found: false` when absent. */
|
|
export function extractReceipt(body: string): { found: boolean; receipt?: ShipReceipt; raw?: string; errors: ValidationError[] } {
|
|
const m = BLOCK_RE.exec(body);
|
|
if (!m) return { found: false, errors: [] };
|
|
try {
|
|
const parsed = JSON.parse(m[1]!);
|
|
return { found: true, receipt: parsed, raw: m[1], errors: validateReceipt(parsed) };
|
|
} catch (e: any) {
|
|
return { found: true, raw: m[1], errors: [{ code: 'RECEIPT_INVALID', path: 'ship-receipt', message: `block is not JSON: ${e.message}` }] };
|
|
}
|
|
}
|
|
|
|
export type ReceiptBindingCode = 'QUEUE_RECEIPT_MISSING' | 'QUEUE_RECEIPT_STALE';
|
|
export type ReceiptBinding =
|
|
| { ok: true; receipt: ShipReceipt; head: string; line: string }
|
|
| { ok: false; code: ReceiptBindingCode; message: string; line: string };
|
|
|
|
/**
|
|
* The /ship receipt a PR body must carry before READY, train build or a train
|
|
* merge (CREW rule 22; docs/ship-policy.md#ship-before-ready): a valid block
|
|
* whose `head` is one of the accepted heads. `accepted` is the head being
|
|
* READY'd, or the slot's pinned head beside the current head when the current
|
|
* head is the pin's stamp commit (the stamp changes only stamp files, so the
|
|
* receipt written for the pin stays the binding one). `line` is the
|
|
* informational `SHIP_RECEIPT:` line the READY block prints either way.
|
|
*/
|
|
export function bindReceipt(body: string, pr: string | undefined, accepted: readonly string[]): ReceiptBinding {
|
|
const subject = pr === undefined ? "the receipt's" : `PR #${pr}'s`;
|
|
const ex = extractReceipt(body);
|
|
if (!ex.found) return { ok: false, code: 'QUEUE_RECEIPT_MISSING', message: `${subject} body carries no ship receipt`, line: 'SHIP_RECEIPT: none in the PR body' };
|
|
if (!ex.receipt || ex.errors.length) {
|
|
const why = ex.errors[0] ? oneLine(`${ex.errors[0].message} at ${ex.errors[0].path}`).slice(0, 200) : 'invalid';
|
|
return { ok: false, code: 'QUEUE_RECEIPT_MISSING', message: `${subject} ship receipt is invalid (${why})`, line: 'SHIP_RECEIPT: invalid in the PR body' };
|
|
}
|
|
const r = ex.receipt;
|
|
const line = receiptSummaryLine(r);
|
|
const head = accepted.find(h => h === r.head || (r.head.length >= 12 && h.startsWith(r.head)));
|
|
if (!head) {
|
|
const want = accepted.map(h => h.slice(0, 12)).join(' or ');
|
|
return { ok: false, code: 'QUEUE_RECEIPT_STALE', message: `${subject} ship receipt is for ${oneLine(r.head.slice(0, 12))}, not the head ${want} being merged`, line };
|
|
}
|
|
if (pr !== undefined && r.pr !== undefined && String(r.pr) !== pr) return { ok: false, code: 'QUEUE_RECEIPT_STALE', message: `${subject} ship receipt names pr=${r.pr}, not this PR`, line };
|
|
return { ok: true, receipt: r, head, line };
|
|
}
|
|
|
|
/** The block becomes the first section of the body: an existing block is replaced in place at the top, prose follows. */
|
|
export function placeReceiptFirst(body: string, receipt: ShipReceipt): string {
|
|
const block = renderReceiptBlock(receipt);
|
|
const stripped = body.replace(BLOCK_RE, '').replace(/^\s+/, '');
|
|
return block + (stripped ? '\n' + stripped : '');
|
|
}
|
|
|
|
/** The receipt's `issues` object from a gstack-issue-links --out or --json file (its other keys are preview-only). */
|
|
export function receiptIssuesFrom(file: Record<string, unknown>): ShipReceipt['issues'] {
|
|
const issues: Record<string, unknown> = {};
|
|
for (const key of ['fixes', 'refs', 'flakes', 'related']) if (file[key] !== undefined) issues[key] = file[key];
|
|
return issues as ShipReceipt['issues'];
|
|
}
|
|
|
|
/** Lines the receipt's issues imply that the body does not carry (a line may continue, e.g. `Refs #N (unreadable …)`). */
|
|
export function missingIssueLines(body: string, issues: NonNullable<ShipReceipt['issues']>): string[] {
|
|
const have = body.split(/\r?\n/).map(l => l.trim());
|
|
const expected = renderIssueLines({ fixes: issues.fixes ?? [], refs: issues.refs ?? [], flakes: issues.flakes ?? [], cross: [], unreadable: [], ...(issues.related ? { related: issues.related } : {}) });
|
|
return expected.filter(e => !have.some(l => l === e || l.startsWith(`${e} `)));
|
|
}
|
|
|
|
/** Receipt fields come from the PR body, so anything printed from one stays on its own line, control characters included. */
|
|
const oneLine = (s: string) => s.replace(/[\x00-\x1f\x7f\u0080-\u009f\u2028\u2029]+/g, ' ');
|
|
|
|
/** The first lines of the completion message to the coordinator: identifiers only. */
|
|
export function receiptSummaryLine(r: ShipReceipt): string {
|
|
const parts = [`pr=${r.pr ?? '-'}`, `head=${r.head.slice(0, 12)}`, `version=${r.version ?? '-'}`, `base=${r.base}`];
|
|
if (r.gated_tree) parts.push(`gated_tree=${r.gated_tree.slice(0, 12)}`);
|
|
if (r.tree) parts.push(`tree=${r.tree}`);
|
|
if (r.gate_reuse) parts.push(`gate-reuse=${r.gate_reuse}`);
|
|
if (r.predecessor) parts.push(`predecessor=${r.predecessor.slice(0, 12)}`);
|
|
if (r.session_kind) parts.push(`session_kind=${r.session_kind}`);
|
|
if (r.artifacts_consumed) parts.push(`artifacts_consumed=${r.artifacts_consumed}`);
|
|
if (r.approval) parts.push(`approval=${approvalSummary(r.approval)}`);
|
|
const loop = parseReviewLoop(r.review_loop);
|
|
if (loop) parts.push(`passes=${loop.passes} blocking_found=${loop.blocking_found} deferred=${loop.deferred}`);
|
|
if (r.carry && typeof r.carry.from === 'string') parts.push(`carried_from=${r.carry.from.slice(0, 12)}`);
|
|
return `SHIP_RECEIPT: ${oneLine(parts.join(' '))}`;
|
|
}
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// carry: the receipt follows a head whose product diff is unchanged
|
|
// ---------------------------------------------------------------------------
|
|
/**
|
|
* Paths whose change never invalidates a /ship verdict on its own: tests (the
|
|
* reviewed product code is what the verdict covers; CI at the new head reruns
|
|
* them, unreviewed) and release metadata (the version stamp, the changelog, the
|
|
* agents digest). A repo extends the lists through its ship policy
|
|
* (`stamp_files`, `stamp_paths`, `release_outputs`, `changelog`); lockfiles are
|
|
* product unless the policy names them, because a dependency change is a
|
|
* product change. The test globs name code extensions only: an API contract
|
|
* such as `openapi.spec.yaml` is product.
|
|
*/
|
|
const TEST_CODE_EXTENSIONS = ['ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs', 'mts', 'cts', 'rb', 'py', 'go', 'rs', 'java', 'kt', 'swift', 'dart', 'php', 'cs'];
|
|
export const CARRY_TEST_GLOBS: readonly string[] = [
|
|
'test/**', 'tests/**', 'spec/**', '__tests__/**', 'e2e/**', 'cypress/**', '**/__tests__/**',
|
|
...TEST_CODE_EXTENSIONS.flatMap(ext => [`**/*.test.${ext}`, `**/*.spec.${ext}`]),
|
|
'**/*_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/**'];
|
|
|
|
export type CarryClass = 'product' | 'test' | 'metadata';
|
|
export interface CarryPlanInput {
|
|
/** The reviewed head the receipt names. */
|
|
from: string; head: string;
|
|
/** Paths changed between from and head, classified. */
|
|
changed: Array<{ path: string; class: CarryClass }>;
|
|
/** Product patch-ids at both heads (empty string for no product diff). */
|
|
productPatchId: { from: string; head: string };
|
|
testPatchId: { from: string; head: string };
|
|
/** Product paths whose patch against the base differs between the two heads. */
|
|
changedProduct: string[];
|
|
mergeCommits: number;
|
|
rangeDiff: string;
|
|
}
|
|
export interface CarryRecord { from: string; reason: string; product_patch_id: string; changed_paths: string[]; merge_commits: number; range_diff: string; covers: string[]; reruns: string[] }
|
|
export type CarryPlan = { ok: true; carry: CarryRecord } | { ok: false; code: 'RECEIPT_CARRY_PRODUCT_CHANGED' | 'RECEIPT_CARRY_REFUSED'; message: string };
|
|
|
|
/** The file's text at the reviewed head and at HEAD, with the release version each head carries (from `VERSION`, else `package.json`). */
|
|
export interface CarryChange { before: string | null; after: string | null; oldVersion?: string | null; newVersion?: string | null }
|
|
|
|
/**
|
|
* A policy-named stamp path's change is a stamp when the new text reads as the
|
|
* old text once every new version in it is the old one: the rule the tree receipt
|
|
* applies to the same `stamp_paths`, `stamp_files` and `release_outputs` before
|
|
* the train accepts a stamp commit. No version change, or a file missing on
|
|
* either side, is not a stamp.
|
|
*/
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* One changed path's carry class. The changelog and the agents digest are
|
|
* metadata. A manifest (`package.json`, `VERSION`) is metadata only while its
|
|
* from..head change is a version stamp and nothing else (`versionStampOnly`): a
|
|
* dependency, a script or a new key is product. A path the policy names under
|
|
* `stamp_files`, `stamp_paths` or `release_outputs` is metadata only while its
|
|
* change is a stamp (`versionStampOnly` for a manifest kind, `stampOnlyText`
|
|
* otherwise): a logic change in a stamped source file is product, as it is for
|
|
* the tree receipt. `change` carries the texts; a caller without them gets the
|
|
* list-only answer.
|
|
*/
|
|
export function classifyCarryPath(rel: string, extra: { metadata?: readonly string[] } = {}, change?: CarryChange): CarryClass {
|
|
const stamp = () => !change || (stampFileKind(rel) ? versionStampOnly(rel, change.before, change.after).stampOnly : stampOnlyText(change));
|
|
if (matchesAny(CARRY_METADATA_GLOBS, rel)) return !change || !stampFileKind(rel) || versionStampOnly(rel, change.before, change.after).stampOnly ? 'metadata' : 'product';
|
|
if (matchesAny(extra.metadata ?? [], rel)) return stamp() ? 'metadata' : 'product';
|
|
if (matchesAny(CARRY_TEST_GLOBS, rel)) return 'test';
|
|
return 'product';
|
|
}
|
|
|
|
/**
|
|
* The carry decision: the receipt follows `head` when the product diff
|
|
* against the base has the same patch-id at both heads, which is what a
|
|
* conflict-only base merge, a test-only fix or a release-metadata change
|
|
* leaves behind. A product patch that differs is RECEIPT_CARRY_PRODUCT_CHANGED,
|
|
* whatever else changed.
|
|
*/
|
|
export function planCarry(input: CarryPlanInput): CarryPlan {
|
|
if (input.productPatchId.from !== input.productPatchId.head) {
|
|
const named = input.changedProduct.slice(0, 20);
|
|
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[] = [];
|
|
if (input.mergeCommits > 0) reasons.push(input.mergeCommits === 1 ? 'base merge' : `${input.mergeCommits} base merges`);
|
|
if (input.testPatchId.from !== input.testPatchId.head) reasons.push('test-only change');
|
|
if (input.changed.some(c => c.class === 'metadata')) reasons.push('release metadata');
|
|
if (!reasons.length) reasons.push(input.changed.length ? 'no product change' : 'no content change');
|
|
return {
|
|
ok: true,
|
|
carry: {
|
|
from: input.from, reason: reasons.join(' + '), product_patch_id: input.productPatchId.head || 'none',
|
|
changed_paths: input.changed.slice(0, 50).map(c => `${c.class}:${c.path}`), merge_commits: input.mergeCommits, range_diff: input.rangeDiff,
|
|
covers: ['pre-landing review', 'adversarial review', 'gate integrity', 'documentation audit'],
|
|
reruns: ['CI at head (gstack-ship-queue ready requires it); test and helper code changed since the reviewed head runs there unreviewed'],
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The carried receipt: the same evidence, bound to the new head, with the carry
|
|
* recorded. A receipt carried before keeps the reviewed head as `from` and adds
|
|
* this hop's merges and paths, so the record always names the head a review saw.
|
|
*/
|
|
export function carryReceipt(receipt: ShipReceipt, head: string, carry: CarryRecord): ShipReceipt {
|
|
const prior = receipt.carry;
|
|
const merged: CarryRecord = !prior ? carry : {
|
|
...carry, from: prior.from, reason: prior.reason === carry.reason ? carry.reason : `${prior.reason} + ${carry.reason}`,
|
|
merge_commits: prior.merge_commits + carry.merge_commits,
|
|
changed_paths: [...prior.changed_paths, ...carry.changed_paths.filter(p => !prior.changed_paths.includes(p))].slice(0, 50),
|
|
};
|
|
return orderReceipt({ ...receipt, head, carry: merged });
|
|
}
|
|
|
|
/** The loop counters the factory scorecard reads (passes, blocking_found, deferred), as `write --review-loop` records them. */
|
|
export type ReviewLoopReceipt = NonNullable<ShipReceipt['review_loop']>;
|
|
export function parseReviewLoop(value: unknown): ReviewLoopReceipt | undefined {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
|
|
const v = value as Record<string, unknown>;
|
|
const n = (k: string) => (typeof v[k] === 'number' && Number.isInteger(v[k]) && (v[k] as number) >= 0 ? (v[k] as number) : undefined);
|
|
const passes = n('passes'); const blocking = n('blocking_found'); const deferred = n('deferred');
|
|
if (passes === undefined || blocking === undefined || deferred === undefined) return undefined;
|
|
const out: ReviewLoopReceipt = { passes, blocking_found: blocking, deferred };
|
|
if (n('noted') !== undefined) out.noted = n('noted');
|
|
if (n('fixed') !== undefined) out.fixed = n('fixed');
|
|
if (typeof v.scope === 'string') out.scope = v.scope.slice(0, 20);
|
|
return out;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// census (P7)
|
|
// ---------------------------------------------------------------------------
|
|
export interface CensusPr { number: number; mergedAt: string; body: string; title?: string }
|
|
export interface Census {
|
|
repo: string; since: string; merged_prs: number; with_receipt: number; invalid_receipts: number;
|
|
unattended_prs: number; consumed_prs: number; runs: string[]; threads: string[]; consumed_threads: string[];
|
|
cohort: string[] | null; cohort_consumed: number; adoption: string; durable: 'yes'; by_session_kind: Record<string, number>;
|
|
prs: Array<{ number: number; session_kind: string | null; artifacts_consumed: string | null; run: string | null; thread: string | null; valid: boolean }>;
|
|
}
|
|
|
|
function consumedCount(value: string | undefined): number {
|
|
const m = /^(\d+)\s*\/\s*(\d+)$/.exec(value ?? '');
|
|
return m ? Number(m[1]) : 0;
|
|
}
|
|
|
|
/** Merged PRs → the adoption number: receipts deduplicated by run id and thread id, against the cohort when given. */
|
|
export function computeCensus(repo: string, since: string, prs: CensusPr[], cohort: string[] | null): Census {
|
|
const runs = new Set<string>();
|
|
const threads = new Set<string>();
|
|
const consumedThreads = new Set<string>();
|
|
const byKind: Record<string, number> = {};
|
|
const rows: Census['prs'] = [];
|
|
let withReceipt = 0;
|
|
let invalid = 0;
|
|
let unattended = 0;
|
|
let consumed = 0;
|
|
const seenRuns = new Set<string>();
|
|
for (const pr of prs) {
|
|
const ex = extractReceipt(pr.body ?? '');
|
|
if (!ex.found) continue;
|
|
withReceipt += 1;
|
|
if (ex.errors.length || !ex.receipt) { invalid += 1; rows.push({ number: pr.number, session_kind: null, artifacts_consumed: null, run: null, thread: null, valid: false }); continue; }
|
|
const r = ex.receipt;
|
|
rows.push({ number: pr.number, session_kind: r.session_kind ?? null, artifacts_consumed: r.artifacts_consumed ?? null, run: r.run ?? null, thread: r.thread ?? null, valid: true });
|
|
const kind = r.session_kind ?? 'unknown';
|
|
byKind[kind] = (byKind[kind] ?? 0) + 1;
|
|
if (r.run && seenRuns.has(r.run)) continue;
|
|
if (r.run) { seenRuns.add(r.run); runs.add(r.run); }
|
|
if (r.thread) threads.add(r.thread);
|
|
if (kind === 'unattended') unattended += 1;
|
|
if (consumedCount(r.artifacts_consumed) > 0) { consumed += 1; if (r.thread) consumedThreads.add(r.thread); }
|
|
}
|
|
const cohortConsumed = cohort ? cohort.filter(t => consumedThreads.has(t)).length : consumedThreads.size;
|
|
const denominator = cohort ? cohort.length : threads.size;
|
|
return {
|
|
repo, since, merged_prs: prs.length, with_receipt: withReceipt, invalid_receipts: invalid, unattended_prs: unattended, consumed_prs: consumed,
|
|
runs: [...runs], threads: [...threads], consumed_threads: [...consumedThreads], cohort, cohort_consumed: cohortConsumed,
|
|
adoption: `${cohortConsumed}/${denominator}${cohort ? ' (cohort)' : ' (threads seen; no cohort file)'}`, durable: 'yes', by_session_kind: byKind, prs: rows,
|
|
};
|
|
}
|
|
|
|
export function renderCensus(c: Census): string {
|
|
return [
|
|
`CENSUS: repo=${c.repo} since=${c.since} merged_prs=${c.merged_prs} with_receipt=${c.with_receipt} invalid=${c.invalid_receipts} unattended=${c.unattended_prs} consumed=${c.consumed_prs} runs=${c.runs.length} threads=${c.threads.length} adoption=${c.adoption} durable=${c.durable}`,
|
|
...Object.entries(c.by_session_kind).map(([k, n]) => ` session_kind ${k}: ${n}`),
|
|
...c.prs.map(p => ` #${p.number} ${p.valid ? `session_kind=${p.session_kind ?? '-'} artifacts_consumed=${p.artifacts_consumed ?? '-'} run=${p.run ?? '-'} thread=${p.thread ?? '-'}` : 'receipt invalid'}`),
|
|
].join('\n') + '\n';
|
|
}
|