@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.
- package/detectors/atdd_traceability_closure/detect.mjs +5 -1
- package/detectors/bun_interlocking_infrastructure/checks/journey_runner_boundary.mjs +36 -5
- package/detectors/bun_ts_metrics_detector/checks/ts_dead_code.mjs +11 -1
- package/integrity.json +6 -6
- package/lib/journey-chain.mjs +29 -1
- package/package.json +1 -1
- package/src/hooks.ts +24 -4
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
const
|
|
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
|
|
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.
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
1264
|
+
"src/hooks.ts": "06b71d74378c000e13339b7a9d2366e6d36993a1d9a8b84e8e8234de97d5a323",
|
|
1265
1265
|
"src/index.ts": "4081f8b08d1d3ee9e15a9964143e53d9cfb94a5616ab3e58a180d24cc16dc381",
|
|
1266
1266
|
"src/integrity.ts": "f5ad22759eec675454df82cdcf791b1f081951f08ca5084ebdcb8cd82aa1ecef",
|
|
1267
1267
|
"src/journey-docs.ts": "994d229376244c84212cff951032823068a76565dd2fd427b9d44e341bd14853",
|
package/lib/journey-chain.mjs
CHANGED
|
@@ -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
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
|
-
|
|
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
|
-
|
|
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
|
}
|