@afokapu/atdd-bun 0.10.68 → 0.10.70

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -62,7 +62,11 @@ for (const root of roots) {
62
62
  // EVERY binding header is judged, not only the first: a valid Acceptance: must not carry an unresolved Train:.
63
63
  const headers = [...head.matchAll(/^[ \t]*\/\/[ \t]*(Acceptance|WMBT|Train):[ \t]*(\S*)/gm)].map((m) => ({ kind: m[1], id: m[2], raw: m[0].trim(), line: lineAt(head, m.index) }));
64
64
  const bindings = headers.filter((h) => h.id.startsWith(PREFIX[h.kind]));
65
- tests.set(urn[1], { path, bindings });
65
+ // A test identity may be reused by equivalent smoke harnesses. Preserve every
66
+ // independently declared binding: replacing the earlier record made a valid
67
+ // acceptance disappear solely because a later file had the same test URN.
68
+ const prior = tests.get(urn[1]);
69
+ tests.set(urn[1], { path, bindings: [...(prior?.bindings ?? []), ...bindings] });
66
70
  if (!headers.length) add("traceability.test.binding-resolves", path, lineOf(head, urn[0]), "test has a URN but no Acceptance:, WMBT:, or Train: binding", urn[0]);
67
71
  for (const h of headers) if (!bindings.includes(h)) add("traceability.test.binding-resolves", path, h.line, `${h.kind}: ${h.id || "<empty>"} is not a ${PREFIX[h.kind]} identity`, h.raw);
68
72
  return;
@@ -47,25 +47,56 @@ function journeyRunnerLine(text) {
47
47
  return match ? lineOfIndex(text, match.index) : 1;
48
48
  }
49
49
 
50
+ // Declaration helpers that verifiably read AND parse the file named by their own parameter.
51
+ function declarationHelpers(masked) {
52
+ const names = [];
53
+ const helper = /\b(?:async\s+)?function\s+((?=[A-Za-z_$\w]*(?:declaration|journey))[A-Za-z_$][\w$]*)\s*\(\s*([A-Za-z_$][\w$]*)?[^)]*\)\s*(?::[^\{]+)?\{([\s\S]*?)\n\}/gi;
54
+ for (const match of masked.matchAll(helper)) {
55
+ const [, name, param, body] = match;
56
+ if (!param || !/\b(?:Bun\.YAML\.parse|JSON\.parse)\s*\(/.test(body)) continue;
57
+ // The read argument must reference the helper's own parameter, not a fixed path.
58
+ const paramName = param.replace(/[$]/g, "\\$");
59
+ if (!new RegExp(`\\b(?:Bun\\.file|readFile(?:Sync)?)\\s*\\(\\s*[^)]*\\b${paramName}\\b`).test(body)) continue;
60
+ // JourneyRunner must pass its own declared path to that helper.
61
+ const callee = name.replace(/[$]/g, "\\$");
62
+ if (new RegExp(`\\b${callee}\\s*\\(\\s*this\\s*\\.\\s*journeyYamlPath\\s*\\)`).test(masked)) names.push(name);
63
+ }
64
+ return names;
65
+ }
66
+
50
67
  function loadsJourneyDeclaration(text) {
51
68
  const masked = maskComments(text);
52
69
  const readsJourneyPath =
53
70
  /Bun\.file\s*\(\s*[^)]*(?:journey|_journeys)/i.test(masked) ||
54
71
  /\breadFile(?:Sync)?\s*\(\s*[^,)]*(?:journey|_journeys)/i.test(masked);
55
72
  const parsesDeclaration = /Bun\.YAML\.parse\s*\(|JSON\.parse\s*\(/.test(masked);
56
- return readsJourneyPath && parsesDeclaration;
73
+ if (readsJourneyPath && parsesDeclaration) return true;
74
+ // Production runners may centralize parsing and validation in a declaration helper.
75
+ return declarationHelpers(masked).length > 0;
57
76
  }
58
77
 
59
78
  function consumesDeclaredTopology(text) {
60
79
  const masked = maskComments(text);
61
- const declaration = /\b(?:const|let)\s+([A-Za-z_$][\w$]*)\s*=\s*Bun\.YAML\.parse\s*\(/.exec(masked);
62
- if (!declaration) return false;
63
- const name = declaration[1].replace(/[$]/g, "\\$");
80
+ // The topology variable must come from the original parse or from the one verified helper
81
+ // called with this.journeyYamlPath; a topology from any other call is not the declaration.
82
+ const escape = (value) => value.replace(/[$]/g, "\\$");
83
+ const sources = ["Bun\\.YAML\\.parse\\s*\\(", ...declarationHelpers(masked).map(helper =>
84
+ `(?:await\\s+)?${escape(helper)}\\s*\\(\\s*this\\s*\\.\\s*journeyYamlPath\\s*\\)`)];
85
+ const declarations = [...masked.matchAll(new RegExp("\\b(?:const|let)\\s+([A-Za-z_$][\\w$]*)\\s*=\\s*(?:" + sources.join("|") + ")", "g"))];
86
+ return declarations.some(declaration => consumesTopologyFrom(masked, declaration[1]));
87
+ }
88
+
89
+ function consumesTopologyFrom(masked, variable) {
90
+ const name = variable.replace(/[$]/g, "\\$");
64
91
  const reads = (field) => new RegExp("\\b" + name + "\\s*\\.\\s*" + field + "\\s*\\.\\s*find\\s*\\(").test(masked);
65
92
  // A runner must retain the interlocking result long enough to decide whether the
66
93
  // declared route continues or terminates. Returning the first execute() directly
67
94
  // is the characteristic skipped-continuation mutation.
68
- const retainsResolution = /\b(?:const|let)\s+[A-Za-z_$][\w$]*\s*=\s*await\s+(?:new\s+InterlockingRunner\s*\([^)]*\)|[A-Za-z_$][\w$]*)\s*\.\s*execute\s*\(/.test(masked);
95
+ const execution = "await\\s+(?:new\\s+InterlockingRunner\\s*\\([^)]*\\)|[A-Za-z_$][\\w$]*)\\s*\\.\\s*execute\\s*\\(";
96
+ // The result may also be declared first and assigned once inside a try that has a catch:
97
+ // `let run: T; try { run = await runner.execute(...) } catch { throw error }`.
98
+ const retainsResolution = new RegExp("\\b(?:const|let)\\s+[A-Za-z_$][\\w$]*\\s*=\\s*" + execution).test(masked) ||
99
+ new RegExp("\\blet\\s+([A-Za-z_$][\\w$]*)\\s*(?::[^=;]+)?;\\s*try\\s*\\{\\s*\\1\\s*=\\s*" + execution + "[\\s\\S]{0,400}?\\}\\s*catch\\s*(?:\\([^)]*\\))?\\s*\\{[^}]*\\b(?:throw|return)\\b").test(masked);
69
100
  const readsEntrypoint = new RegExp("\\b" + name + "\\s*\\.\\s*entrypoint\\b").test(masked);
70
101
  // Reading both lists is only discovery. A continuation must alter control flow to
71
102
  // another declared interlocking and a terminal must decide the returned outcome;
@@ -12,7 +12,7 @@
12
12
  // obligation, measured correctly, and only possible because the runtime is Bun.
13
13
  import { walk, readRoots, readExcludes, readText, emit } from "../../../lib/scan.mjs";
14
14
  import * as M from "../ts_metrics.mjs";
15
- import { basename, dirname, join, resolve } from "node:path";
15
+ import { basename, dirname, join, relative, resolve } from "node:path";
16
16
  import { existsSync } from "node:fs";
17
17
 
18
18
  const RULE = "coder.bun.dead-code-reachability";
@@ -64,6 +64,16 @@ for (const root of readRoots()) {
64
64
  if (!roots.size) continue;
65
65
 
66
66
  const forward = M.reachableFrom(roots, graph);
67
+ // Tests are executable entrypoints, but only for test-support modules (e2e/, tests/ ...). A
68
+ // production module imported solely by a test is still dead production code.
69
+ const SUPPORT_SEGMENTS = new Set(["e2e", "test", "tests", "__tests__", "spec", "fixtures"]);
70
+ const isSupport = (f) => relative(root, f).split(/[\\/]/).slice(0, -1).some(segment => SUPPORT_SEGMENTS.has(segment));
71
+ const queue = files.filter(M.isTestFile);
72
+ while (queue.length) {
73
+ for (const next of graph.get(queue.pop()) || []) {
74
+ if (isSupport(next) && !forward.has(next)) { forward.add(next); queue.push(next); }
75
+ }
76
+ }
67
77
  const backward = M.reachableFrom(roots, M.reverseGraph(graph));
68
78
 
69
79
  for (const f of files) {
package/integrity.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.10.68",
2
+ "version": "0.10.70",
3
3
  "files": {
4
4
  "HOOK_AUDIT.md": "5329d840db37671b1918f688ead26865473b87db73dbc75f7c8b2a8bbe8d6d43",
5
5
  "PLANNER_PORT.md": "fb5935bac8b7ac18994de21e43ace3a5ef8cd55f85b0e3349fca261280054f11",
@@ -191,7 +191,7 @@
191
191
  "detectors/atdd_topology/fixtures/dirty/src/wagons/orders/features/place-order/domain/order.ts": "6fdaad52671ef25b47d93f9b4e97e12c62eb78f5b4cde727435c3ac135a1c0e5",
192
192
  "detectors/atdd_topology/fixtures/dirty/tests/wagons/orders/features/place-order/unit/order.test.ts": "f117e6c8b034700e1f00889bea48cb1528f0003d699cfd3e279cd9164b264a38",
193
193
  "detectors/atdd_traceability_closure/atdd.implementation.yaml": "82e56cc85539476e42fc8d58e718bc998f1eff6c325a0cd240c0284625834d68",
194
- "detectors/atdd_traceability_closure/detect.mjs": "b3f5afbfa4d4970f56a2a361d3392652dd4f536d5018ac0f54aa7fc97c3ead11",
194
+ "detectors/atdd_traceability_closure/detect.mjs": "17582d256ee4b1555bd62ba4decfe2ff15b664400a243ff28b0e87604d3aa05d",
195
195
  "detectors/atdd_traceability_closure/fixtures/clean/plan/orders/E001.yaml": "0ddad928dde10c2ab690558259a6975125af540a1f30eca9aa0c615bf0ad69fb",
196
196
  "detectors/atdd_traceability_closure/fixtures/clean/src/order.ts": "efe6db7e3f723c7b6096abdf58dec9c88b27ec8978d434a10135b3e77bfce321",
197
197
  "detectors/atdd_traceability_closure/fixtures/clean/test/order.test.ts": "bee451198d472749616f85c1f1e97c0a06ad28255b9a6a2cc8701bd2e8d56562",
@@ -490,7 +490,7 @@
490
490
  "detectors/bun_interlocking_infrastructure/checks/interlocking_resolution_model.mjs": "e761495b19eed0af09fcb4db34126d0e2398e91f086e04f5ef84d0b5c1c3ec16",
491
491
  "detectors/bun_interlocking_infrastructure/checks/interlocking_runner_exists.mjs": "6b63d3f26a51f2dd27847edd27c4d9c9f15c1f02747dba611cebccfc76f13169",
492
492
  "detectors/bun_interlocking_infrastructure/checks/interlocking_station_routing.mjs": "f3870fb3bacee46a6c3e10238284ef4d359182030d40d4f33b0f0ad465f7a284",
493
- "detectors/bun_interlocking_infrastructure/checks/journey_runner_boundary.mjs": "ad74ebb76d63ef648202ca31316647221e152f79bcba6aa2d30be4f0464ac10e",
493
+ "detectors/bun_interlocking_infrastructure/checks/journey_runner_boundary.mjs": "6284a8532722e5a6bff7e5184e24c7f273c1e63f1331204502e0e3704db5c86b",
494
494
  "detectors/bun_interlocking_infrastructure/checks/journey_station_routing.mjs": "66c09cdc17b099a55fa0ca894bd85a44706742bdd29caa5347f10e6487b4e724",
495
495
  "detectors/bun_interlocking_infrastructure/checks/wagon_contract_honoured.mjs": "79b5f824e7510f50e4925a98c01ef263290025748f64a27a11555f608d8001dc",
496
496
  "detectors/bun_interlocking_infrastructure/detect.mjs": "6494579ec7b9d4680099401346337d73f4a52368ffb6b9d3b0fbca5dce2cde15",
@@ -717,7 +717,7 @@
717
717
  "detectors/bun_ts_metrics_detector/checks/ts_complexity_cyclomatic.mjs": "56640b3a0f565c45a73b7c174adb1d6b4b6ac24d286a907bc1426f013cd6039e",
718
718
  "detectors/bun_ts_metrics_detector/checks/ts_complexity_length.mjs": "e49fe93dbf2c52a91fe97b1e0b810d60f7d34d43581dfb96d4827640e6f5e3fd",
719
719
  "detectors/bun_ts_metrics_detector/checks/ts_complexity_nesting.mjs": "8d8f61ea1748f33b037beb3f3fe5c84acbce395575e24fb1d01432813762e4d1",
720
- "detectors/bun_ts_metrics_detector/checks/ts_dead_code.mjs": "154fdf7b8e896f73ae86d7a9609a953fcb8d33c1b61eabcb6d190b06d64d97b4",
720
+ "detectors/bun_ts_metrics_detector/checks/ts_dead_code.mjs": "8f2205274f5056993a888ea4141be4d0b64c287e4641d0a464d178530b5297d4",
721
721
  "detectors/bun_ts_metrics_detector/checks/ts_duplication.mjs": "12b5087c34741ce3fde573059d1afbaafc80ce1bfe8a235780113fd558dbbc7d",
722
722
  "detectors/bun_ts_metrics_detector/checks/ts_quality_comments.mjs": "872d71d2165eba4cb39c975b09365cf67a64f1db5c55c0df9e86ff411e02ea31",
723
723
  "detectors/bun_ts_metrics_detector/checks/ts_quality_mi.mjs": "69765d88441e98f4f38b7da0a416b7dab8b7f0072e562ea9d40d399802452067",
@@ -1034,7 +1034,7 @@
1034
1034
  "lib/frontend.mjs": "d6d9b29c3d7f2d162cda03bb34b20a3224a82887b6be3db7cab375510913c9a3",
1035
1035
  "lib/imports.mjs": "3db4979269488fa26f6e7aefded6ece9c646be6587a10e249821a5a208109962",
1036
1036
  "lib/interlocking.mjs": "d0803d080ac7d7477c51be70112df1e85213d339fd3c6b71f75f26145fa8accd",
1037
- "lib/journey-chain.mjs": "6db114d3c3f05b3276d5d9b258a6215a90f829ca4c67d61318922020ac3fb8c8",
1037
+ "lib/journey-chain.mjs": "aaa0e6acdd73f1a8cfeea9a523a86c6d3133c020c37f97e7ec61db00f957ec6a",
1038
1038
  "lib/scan.mjs": "02b0fa864cab24157a8f27b50a87a2bb66a875f90cf6632c753c4627d61456ef",
1039
1039
  "planner-nodes/ENFORCEMENT_SCOPE.yaml": "d8ec7039c219857f3d77af5d4d84612b533b69c1a5fcef935fbf46470adafed4",
1040
1040
  "planner-nodes/nodes/planner.acceptance.abstract-fields-required.convention.yaml": "bd565693f0ae861f0561ad2f7a91a8c55f9a209c3acd59c5750cfcffd96a85d8",
@@ -1261,7 +1261,7 @@
1261
1261
  "src/cli.ts": "995e41b3cb44c04f65f4d599f0fc51b93b17b7558b7c7dcfabed64de3d455250",
1262
1262
  "src/docs-capability.ts": "bdeeb414649049b1cd4fa8008cada3a576318be51c43bec91bfa46a8e250c270",
1263
1263
  "src/enforce.ts": "7e6b3a1fc7c90f8e1836ab3e4655778fb569a63be5e7c199de44024fc003e6be",
1264
- "src/hooks.ts": "6b579c030bb4772f08806e802f4f814444b1878bbac0d6c62bc10b4ee184e449",
1264
+ "src/hooks.ts": "06b71d74378c000e13339b7a9d2366e6d36993a1d9a8b84e8e8234de97d5a323",
1265
1265
  "src/index.ts": "4081f8b08d1d3ee9e15a9964143e53d9cfb94a5616ab3e58a180d24cc16dc381",
1266
1266
  "src/integrity.ts": "f5ad22759eec675454df82cdcf791b1f081951f08ca5084ebdcb8cd82aa1ecef",
1267
1267
  "src/journey-docs.ts": "994d229376244c84212cff951032823068a76565dd2fd427b9d44e341bd14853",
@@ -430,12 +430,40 @@ function liveReturns(fn) {
430
430
  // arms of a conditional, as a member/spread of it, an object property or array element, an
431
431
  // `Object.freeze` argument, or a `const` bound to it. Logical, comma, arithmetic, unary (`void`, `!`)
432
432
  // and calls to anything else replace or may skip the value, so they never carry it.
433
+ // A runner result may be declared before a try and assigned exactly once in the
434
+ // try body. The catch must itself exit (throw or return); otherwise a swallowed error would
435
+ // let the later return observe an unassigned value. Do not generalize this to ordinary
436
+ // `let` assignments: conditional or multiply-written values are not a proof chain.
437
+ function caughtSingleAssignment(ctx, identifier) {
438
+ const declaration = declarationOf(ctx, identifier);
439
+ if (!declaration || !ts.isVariableDeclaration(declaration) || declaration.initializer ||
440
+ !ts.isVariableDeclarationList(declaration.parent) || !(declaration.parent.flags & ts.NodeFlags.Let)) return null;
441
+ let fn = declaration.parent;
442
+ while (fn && !ts.isFunctionLike(fn)) fn = fn.parent;
443
+ if (!fn || !fn.body) return null;
444
+ const writes = [];
445
+ walk(fn.body, (node) => {
446
+ if (!ts.isBinaryExpression(node) || node.operatorToken.kind !== ts.SyntaxKind.EqualsToken ||
447
+ !ts.isIdentifier(unwrap(node.left)) || symbolOf(ctx, unwrap(node.left)) !== symbolOf(ctx, identifier) || dead(node, fn.body)) return;
448
+ let inCaughtTry = false;
449
+ for (let parent = node.parent; parent && parent !== fn.body; parent = parent.parent) {
450
+ if (ts.isTryStatement(parent) && parent.tryBlock && node.pos >= parent.tryBlock.pos && node.end <= parent.tryBlock.end && parent.catchClause &&
451
+ parent.catchClause.block.statements.some(statement => ts.isThrowStatement(statement) || ts.isReturnStatement(statement))) {
452
+ inCaughtTry = true;
453
+ break;
454
+ }
455
+ }
456
+ if (inCaughtTry) writes.push(node.right);
457
+ }, false);
458
+ return writes.length === 1 ? writes[0] : null;
459
+ }
460
+
433
461
  function carries(ctx, expression, matches, seen = new Set()) {
434
462
  const node = unwrap(expression);
435
463
  if (!node) return false;
436
464
  if (matches(node)) return true;
437
465
  if (ts.isIdentifier(node)) {
438
- const initializer = constInitializer(ctx, node);
466
+ const initializer = constInitializer(ctx, node) ?? caughtSingleAssignment(ctx, node);
439
467
  // `seen` guards cycles along this path only; sibling branches may resolve the same const.
440
468
  if (!initializer || seen.has(initializer)) return false;
441
469
  seen.add(initializer);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@afokapu/atdd-bun",
3
- "version": "0.10.68",
3
+ "version": "0.10.70",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/afokapu/atdd-bun.git"
package/src/hooks.ts CHANGED
@@ -118,7 +118,24 @@ async function strictPushGate(root: string, candidate: string, range: string) {
118
118
  finally { await git(root, ["worktree", "remove", "--force", candidateRoot]); await rm(candidateRoot, { recursive: true, force: true }); }
119
119
  }
120
120
 
121
- async function ratchetPushGate(root: string, base: string, candidate: string): Promise<{ ok: boolean; message: string } | null> {
121
+ /** A brand-new remote branch is ratcheted against the one protected remote-tracking tip that is an exact ancestor of the candidate. */
122
+ async function newBranchBase(root: string, candidate: string, protectedBranches: string[], remote: string | undefined): Promise<{ base: string } | { error: string }> {
123
+ const listed = await git(root, ["for-each-ref", "--format=%(refname) %(objectname)", "refs/remotes/"]);
124
+ if (listed.code) return { error: `could not list protected branch tips for the new remote branch: ${listed.err || listed.out}` };
125
+ const remotes = (await git(root, ["remote"])).out.split("\n").filter(Boolean), pushRemote = remote && remotes.includes(remote) ? remote : undefined;
126
+ const tips = listed.out.split("\n").filter(Boolean).flatMap(row => {
127
+ const [ref, sha] = row.split(" "), parts = ref!.split("/");
128
+ return parts[2] && (!pushRemote || parts[2] === pushRemote) && protectedBranches.includes(parts.slice(3).join("/")) && /^[0-9a-f]{40}$/i.test(sha ?? "") ? [sha!.toLowerCase()] : [];
129
+ });
130
+ const ancestors = new Set<string>();
131
+ for (const tip of new Set(tips)) if ((await git(root, ["merge-base", "--is-ancestor", tip, candidate])).code === 0) ancestors.add(tip);
132
+ if (!ancestors.size) return { error: "ratchet new remote branch requires a protected branch tip that is an exact ancestor of the pushed candidate; none found" };
133
+ if (ancestors.size > 1) return { error: `ratchet new remote branch base is ambiguous: ${ancestors.size} protected branch tips qualify with different commits` };
134
+ return { base: [...ancestors][0]! };
135
+ }
136
+
137
+ async function ratchetPushGate(root: string, remoteBase: string, candidate: string, protectedBranches: string[], remote: string | undefined): Promise<{ ok: boolean; message: string } | null> {
138
+ let base = remoteBase;
122
139
  if (!/^[0-9a-f]{40}$/i.test(candidate)) return bad("ratchet reject-new requires an exact local candidate commit");
123
140
  const resolvedCandidate = await git(root, ["rev-parse", "--verify", `${candidate}^{commit}`]);
124
141
  if (resolvedCandidate.code || resolvedCandidate.out.toLowerCase() !== candidate.toLowerCase()) return bad(`ratchet candidate ${candidate} is unavailable or not a full exact commit`);
@@ -130,6 +147,7 @@ async function ratchetPushGate(root: string, base: string, candidate: string): P
130
147
  try { policy = parseRatchetPolicy(candidateConfig); }
131
148
  catch (error) { return bad(`ratchet pushed candidate policy is invalid: ${String(error)}`); }
132
149
  if (!policy || policy.mode !== "reject-new") return null;
150
+ if (/^0+$/.test(remoteBase)) { const found = await newBranchBase(root, candidate, protectedBranches, remote); if ("error" in found) return bad(found.error); base = found.base; }
133
151
  if (!/^[0-9a-f]{40}$/i.test(base)) return bad("ratchet reject-new requires an exact non-zero remote commit base");
134
152
  const resolvedBase = await git(root, ["rev-parse", "--verify", `${base}^{commit}`]);
135
153
  if (resolvedBase.code || resolvedBase.out.toLowerCase() !== base.toLowerCase()) return bad(`ratchet remote base ${base} is unavailable or not a full exact commit`);
@@ -141,12 +159,14 @@ async function ratchetPushGate(root: string, base: string, candidate: string): P
141
159
  catch (error) { return bad(`ratchet remote base policy is invalid: ${String(error)}`); }
142
160
  let baseProfiles: string[], candidateProfiles: string[], policyProfiles: string[], basePolicyProfiles: string[] | null;
143
161
  try {
144
- baseProfiles = exactProfiles(baseConfig, "ratchet remote base");
162
+ baseProfiles = baseConfig.profiles === undefined ? stable(await enabledProfiles(baseRoot)) : exactProfiles(baseConfig, "ratchet remote base");
145
163
  candidateProfiles = exactProfiles(candidateConfig, "ratchet pushed candidate");
146
164
  policyProfiles = exactProfiles({ profiles: policy.profiles }, "ratchet policy");
147
165
  basePolicyProfiles = basePolicy ? exactProfiles({ profiles: basePolicy.profiles }, "ratchet remote base policy") : null;
148
166
  } catch (error) { return bad(`ratchet pushed range profile state is invalid: ${String(error)}`); }
149
- if (baseProfiles.join(",") !== candidateProfiles.join(",") || policyProfiles.join(",") !== candidateProfiles.join(",") || (basePolicyProfiles && basePolicyProfiles.join(",") !== baseProfiles.join(",")) || contextDigest(baseConfig, baseProfiles) !== contextDigest(candidateConfig, candidateProfiles)) return bad("ratchet remote base and pushed candidate profile/context differ");
167
+ const activation = isStrictProfileExpansion(baseProfiles, candidateProfiles) && activationContextDigest(baseConfig) === activationContextDigest(candidateConfig);
168
+ const unchanged = baseProfiles.join(",") === candidateProfiles.join(",") && (!basePolicyProfiles || basePolicyProfiles.join(",") === baseProfiles.join(",")) && contextDigest(baseConfig, baseProfiles) === contextDigest(candidateConfig, candidateProfiles);
169
+ if (policyProfiles.join(",") !== candidateProfiles.join(",") || (!activation && !unchanged)) return bad("ratchet remote base and pushed candidate profile/context differ");
150
170
  const normalize = (findings: Awaited<ReturnType<typeof enforce>>, directory: string) => findings.map(finding => ({ ...finding, file: canonicalFindingPath(directory, finding.file) }));
151
171
  const [baseFindings, candidateFindings] = await Promise.all([
152
172
  enforce({ root: baseRoot, profiles: candidateProfiles as any }),
@@ -174,7 +194,7 @@ export async function runHook(event: HookEvent, root = process.cwd(), args: stri
174
194
  if (["pre-commit", "pre-merge-commit"].includes(event)) { const violation = await worktreeCommitPolicy(root, cfg.worktrees); if (violation) return bad(violation); }
175
195
  if (event === "pre-commit") { const staged = await files(root, ["diff", "--cached", "--name-only"]), registries = staged.filter(path => isRegistry(cfg, path)), generated = staged.filter(isGeneratedView), counted = staged.length - registries.length - generated.length, lines = (await stagedLineStats(root)).filter(row => !isRegistry(cfg, row.path) && !isGeneratedView(row.path)).reduce((n, row) => n + row.added + row.removed, 0); if (counted > cfg.max_staged_files) return bad(`staged files ${counted} exceed ${cfg.max_staged_files}`); if (lines > cfg.max_staged_changed_lines) return bad(`staged changed lines ${lines} exceed ${cfg.max_staged_changed_lines}`); const ratchet = await ratchetCommitGate(root); if (ratchet) return ratchet; return cfg.require_traceability || registries.length || generated.length ? validation(root, staged, false, registries.length > 0) : { ok: true, message: "ok" }; }
176
196
  if (event === "commit-msg") { const deleted = (await files(root, ["diff", "--cached", "--name-only", "--diff-filter=D"])).length, stats = await stagedStats(root), lines = stats.reduce((n, row) => n + row.removed, 0), registryRemoved = stats.filter(row => isRegistry(cfg, row.path)).reduce((n, row) => n + row.removed - row.added, 0), message = args[0] && existsSync(args[0]) ? await readFile(args[0], "utf8") : "", approved = message.includes("[mass-delete-approved]"); if ((deleted > 50 || lines > 10_000) && !approved) return bad("mass delete requires [mass-delete-approved]"); return registryRemoved > cfg.max_registry_removed_lines && !approved ? bad(`registry removal of ${registryRemoved} net lines exceeds ${cfg.max_registry_removed_lines}; requires [mass-delete-approved]`) : { ok: true, message: "ok" }; }
177
- if (event === "pre-push") { const reports: string[] = []; for (const row of stdin.split("\n").filter(Boolean).map(row => row.split(/\s+/))) { const [localRef, local, remote, remoteSha] = row, target = remote?.replace("refs/heads/", ""); if (target && cfg.protected_branches.includes(target)) return bad(`protected branch ${target} is blocked`); if (!local) return bad("pre-push update has no local object id"); if (/^0+$/.test(local)) { const deletion = await deletionPushGate(root, remoteSha); if (!deletion.ok) return deletion; continue; } const range = !remoteSha || /^0+$/.test(remoteSha) ? `${local}^..${local}` : `${remoteSha}..${local}`, counted = await git(root, ["rev-list", "--count", range]); if (counted.code || !/^\d+$/.test(counted.out)) return bad(`could not count commits in pushed range ${range}: ${counted.err || counted.out}`); if (Number(counted.out) > cfg.max_commits_per_push) return bad(`commits per push ${counted.out} exceed ${cfg.max_commits_per_push}`); const ratchet = localRef?.startsWith("refs/heads/") ? await ratchetPushGate(root, remoteSha, local) : null; if (ratchet) { if (!ratchet.ok) return ratchet; reports.push(ratchet.message); continue; } const strict = await strictPushGate(root, local, range); if (!strict.ok) return strict; } return reports.length ? { ok: true, message: reports.length === 1 ? reports[0]! : `[${reports.join(",")}]` } : { ok: true, message: "validation clean" }; }
197
+ if (event === "pre-push") { const reports: string[] = []; for (const row of stdin.split("\n").filter(Boolean).map(row => row.split(/\s+/))) { const [localRef, local, remote, remoteSha] = row, target = remote?.replace("refs/heads/", ""); if (target && cfg.protected_branches.includes(target)) return bad(`protected branch ${target} is blocked`); if (!local) return bad("pre-push update has no local object id"); if (/^0+$/.test(local)) { const deletion = await deletionPushGate(root, remoteSha); if (!deletion.ok) return deletion; continue; } const range = !remoteSha || /^0+$/.test(remoteSha) ? `${local}^..${local}` : `${remoteSha}..${local}`, counted = await git(root, ["rev-list", "--count", range]); if (counted.code || !/^\d+$/.test(counted.out)) return bad(`could not count commits in pushed range ${range}: ${counted.err || counted.out}`); if (Number(counted.out) > cfg.max_commits_per_push) return bad(`commits per push ${counted.out} exceed ${cfg.max_commits_per_push}`); const ratchet = localRef?.startsWith("refs/heads/") ? await ratchetPushGate(root, remoteSha ?? "", local, cfg.protected_branches, args[0]) : null; if (ratchet) { if (!ratchet.ok) return ratchet; reports.push(ratchet.message); continue; } const strict = await strictPushGate(root, local, range); if (!strict.ok) return strict; } return reports.length ? { ok: true, message: reports.length === 1 ? reports[0]! : `[${reports.join(",")}]` } : { ok: true, message: "validation clean" }; }
178
198
  if (event === "post-commit") { const result = await validation(root, await files(root, ["show", "--pretty=format:", "--name-only", "HEAD"]), false); return { ok: true, message: result.ok ? result.message : `advisory: ${result.message}` }; }
179
199
  return { ok: true, message: "ok" };
180
200
  }