@afokapu/atdd-bun 0.10.55 → 0.10.57

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.
@@ -364,7 +364,7 @@ export function productionExecutionProofs(text, file, croot) {
364
364
  const named = [...receivers].some((name) => receiver.trim().endsWith(name));
365
365
  if (!direct && !named) continue;
366
366
  const after = text.slice((m.index ?? 0) + whole.length);
367
- const execute = new RegExp(`\\b(?:const|let|var)\\s+(${ident})(?:\\s*:\\s*[^=;\\n]+)?\\s*=\\s*new\\s+${PROD_TRAIN}\\s*\\(\\s*${escaped(resolution)}\\.(?:trainId|selectedTrainId)\\s*\\)\\s*\\.execute\\s*\\(`).exec(after);
367
+ const execute = new RegExp(`\\b(?:const|let|var)\\s+(${ident})(?:\\s*:\\s*[^=;\\n]+)?\\s*=\\s*(?:await\\s+)?new\\s+${PROD_TRAIN}\\s*\\(\\s*${escaped(resolution)}\\.(?:trainId|selectedTrainId)\\s*\\)\\s*\\.execute\\s*\\(`).exec(after);
368
368
  const anonymous = new RegExp(`new\\s+${PROD_TRAIN}\\s*\\(\\s*${escaped(resolution)}\\.(?:trainId|selectedTrainId)\\s*\\)\\s*\\.execute\\s*\\(`).test(after);
369
369
  if (execute || anonymous) proofs.push({ resolution, execution: execute?.[1] ?? null });
370
370
  }
@@ -420,15 +420,64 @@ export function stationMasterExecutionProof(text, action) {
420
420
  return seen.has(PROD_INTERLOCKING) && seen.has(PROD_TRAIN) && assertsExpression(text, `${escaped(result)}(?:\\.[A-Za-z_$][\\w$]*)?`);
421
421
  }
422
422
 
423
- // HTTP/module entrypoints do not expose a StationMaster instance to the test. Their equivalent
424
- // witness is an asserted dispatch result plus a module body that resolves and executes the selected
425
- // train; this keeps an end-to-end smoke from having to pierce the public boundary for private fields.
426
- export function stationModuleExecutionProof(text, stationText) {
427
- if (!stationText || !new RegExp(`new\\s+${PROD_INTERLOCKING}\\b`).test(stationText) ||
428
- !/\.resolveTrain\s*\(/.test(stationText) || !new RegExp(`new\\s+${PROD_TRAIN}\\b`).test(stationText) ||
429
- !/\.execute\s*\(/.test(stationText)) return false;
430
- const call = new RegExp(`\\b(?:const|let|var)\\s+(${ident})(?:\\s*:\\s*[^=;\\n]+)?\\s*=\\s*await\\s+(?:${ident}\\.)?(?:dispatch|handleAction|executeAction)\\s*\\(`).exec(text);
431
- return Boolean(call && assertsExpression(text, escaped(call[1])));
423
+ function balancedBlock(text, open) {
424
+ let depth = 0, quote = null;
425
+ for (let i = open; i < text.length; i++) {
426
+ const c = text[i], next = text[i + 1];
427
+ if (quote) {
428
+ if (c === "\\") { i++; continue; }
429
+ if (c === quote) quote = null;
430
+ continue;
431
+ }
432
+ if (c === "'" || c === '"' || c === "`") { quote = c; continue; }
433
+ if (c === "/" && next === "/") { i = text.indexOf("\n", i + 2); if (i < 0) return null; continue; }
434
+ if (c === "/" && next === "*") { const end = text.indexOf("*/", i + 2); if (end < 0) return null; i = end + 1; continue; }
435
+ if (c === "{") depth++;
436
+ if (c === "}" && --depth === 0) return text.slice(open + 1, i);
437
+ }
438
+ return null;
439
+ }
440
+
441
+ function exportedActionBody(text, name) {
442
+ const n = escaped(name);
443
+ const headers = [
444
+ new RegExp(`\\bexport\\s+(?:async\\s+)?function\\s+${n}\\s*\\([^)]*\\)\\s*\\{`, "g"),
445
+ new RegExp(`\\bexport\\s+(?:const|let|var)\\s+${n}\\s*=\\s*(?:async\\s*)?\\([^)]*\\)\\s*=>\\s*\\{`, "g"),
446
+ ];
447
+ for (const header of headers) {
448
+ const match = header.exec(text);
449
+ if (match) return balancedBlock(text, (match.index ?? 0) + match[0].lastIndexOf("{"));
450
+ }
451
+ return null;
452
+ }
453
+
454
+ function exportedActionReturnsProductionExecution(stationText, action) {
455
+ const body = exportedActionBody(stationText, action);
456
+ if (!body) return false;
457
+ const receivers = productionReceivers(stationText);
458
+ const resolutions = new RegExp(`\\b(?:const|let|var)\\s+(${ident})(?:\\s*:\\s*[^=;\\n]+)?\\s*=\\s*([^\\n]{0,240}?)\\.resolveTrain\\s*\\(`, "g");
459
+ for (const m of body.matchAll(resolutions)) {
460
+ const [whole, resolution, receiver] = m;
461
+ const direct = new RegExp(`new\\s+${PROD_INTERLOCKING}\\b`).test(receiver);
462
+ const named = [...receivers].some((name) => receiver.trim().endsWith(name));
463
+ if (!direct && !named) continue;
464
+ const after = body.slice((m.index ?? 0) + whole.length);
465
+ const directReturn = new RegExp(`\\breturn\\s+(?:await\\s+)?new\\s+${PROD_TRAIN}\\s*\\(\\s*${escaped(resolution)}\\.(?:trainId|selectedTrainId)\\s*\\)\\s*\\.execute\\s*\\(`).test(after);
466
+ if (directReturn) return true;
467
+ const execution = new RegExp(`\\b(?:const|let|var)\\s+(${ident})(?:\\s*:\\s*[^=;\\n]+)?\\s*=\\s*(?:await\\s+)?new\\s+${PROD_TRAIN}\\s*\\(\\s*${escaped(resolution)}\\.(?:trainId|selectedTrainId)\\s*\\)\\s*\\.execute\\s*\\(`).exec(after)?.[1];
468
+ if (execution && new RegExp(`\\breturn\\s+(?:await\\s+)?${escaped(execution)}\\s*;?`).test(after)) return true;
469
+ }
470
+ return false;
471
+ }
472
+
473
+ // HTTP/module entrypoints do not expose a StationMaster instance to the test. The asserted result
474
+ // must therefore come from the SAME exported action the test invokes, and that action's own return
475
+ // path must resolve then execute the selected train. Module-wide runner tokens are not evidence.
476
+ export function stationModuleExecutionProof(text, stationText, action) {
477
+ if (!stationText) return false;
478
+ const call = new RegExp(`\\b(?:const|let|var)\\s+(${ident})(?:\\s*:\\s*[^=;\\n]+)?\\s*=\\s*await\\s+(?:${ident}\\.)?(dispatch|handleAction|executeAction)\\s*\\(\\s*["']${escaped(action)}["']`).exec(text);
479
+ return Boolean(call && assertsExpression(text, `${escaped(call[1])}(?:\\.[A-Za-z_$][\\w$]*)?`) &&
480
+ exportedActionReturnsProductionExecution(stationText, call[2]));
432
481
  }
433
482
 
434
483
  // Bun's native `expect` vocabulary provides a compact mutation witness: exact ordered equality
@@ -72,7 +72,7 @@ for (const scanRoot of roots) {
72
72
 
73
73
  const semantic = hasProductionExecutionProof(text, file, croot) ||
74
74
  records.some((rec) => rec.actions.some((action) => stationMasterExecutionProof(text, action))) ||
75
- (drivesStationMaster(text) && stationModuleExecutionProof(text, station));
75
+ (drivesStationMaster(text) && records.some((rec) => rec.actions.some((action) => stationModuleExecutionProof(text, station, action))));
76
76
  if (!semantic) {
77
77
  violations.push(
78
78
  mk(
@@ -21,10 +21,12 @@ import {
21
21
  PROD_INTERLOCKING,
22
22
  PROD_TRAIN,
23
23
  stationMasterExecutionProof,
24
+ stationModuleExecutionProof,
24
25
  rel,
25
26
  mk,
26
27
  writeReport,
27
28
  } from "../_shared/interlocking.mjs";
29
+ import { join } from "node:path";
28
30
 
29
31
  const RULE = "tester.bun.interlocking-smoke-coverage-for-station-master";
30
32
  const roots = parseJsonEnv("ATDD_SCAN_ROOTS", []);
@@ -34,10 +36,11 @@ const violations = [];
34
36
  // it under the smoke rules (no substituted collaborators, observable outcome). A local E2E that names the action beside
35
37
  // the runners is not a smoke and no longer clears the rule (FWS #TzSCGS5ajYhP).
36
38
  const SMOKE_PHASE = /^\s*\/\/\s*Phase:\s*SMOKE\b/m;
37
- function actionSmokeCovered(action, e2eFiles) {
39
+ function actionSmokeCovered(action, e2eFiles, stationModule) {
38
40
  return e2eFiles.some(
39
41
  ({ raw, text: t }) =>
40
- SMOKE_PHASE.test(raw) && tokenCovered(action, t) && STATION_MASTER.test(t) && t.includes(PROD_INTERLOCKING) && t.includes(PROD_TRAIN) && stationMasterExecutionProof(t, action),
42
+ SMOKE_PHASE.test(raw) && tokenCovered(action, t) && STATION_MASTER.test(t) && t.includes(PROD_INTERLOCKING) && t.includes(PROD_TRAIN) &&
43
+ (stationMasterExecutionProof(t, action) || stationModuleExecutionProof(t, stationModule, action)),
41
44
  );
42
45
  }
43
46
 
@@ -47,11 +50,14 @@ for (const scanRoot of roots) {
47
50
  .map((f) => ({ file: f, rec: parseInterlocking(readText(f)) }))
48
51
  .filter((x) => x.rec);
49
52
  const e2eTexts = e2eFiles(croot).map((f) => { const raw = readText(f); return { raw, text: maskComments(raw) }; });
53
+ const stationModule = ["server.ts", join("src", "server.ts")]
54
+ .map((name) => readText(join(croot, name)))
55
+ .find((text) => text && text.includes(PROD_INTERLOCKING) && text.includes(PROD_TRAIN));
50
56
 
51
57
  for (const { file, rec } of records) {
52
58
  if (!rec.exposed) continue;
53
59
  for (const action of rec.actions) {
54
- if (actionSmokeCovered(action, e2eTexts)) continue;
60
+ if (actionSmokeCovered(action, e2eTexts, stationModule)) continue;
55
61
  const [line, src] = lineOf(rec.rawText, new RegExp("^\\s*-\\s*['\"]?" + action + "['\"]?\\s*$"));
56
62
  violations.push(
57
63
  mk(
@@ -0,0 +1,12 @@
1
+ // Phase: SMOKE
2
+ import { expect, test } from "bun:test";
3
+ import { dispatch } from "../../src/server";
4
+ import { InterlockingRunner } from "../../src/trains/interlocking";
5
+ import { TrainRunner } from "../../src/trains/runner";
6
+
7
+ test("inert runner helper does not prove functional Station Master dispatch", async () => {
8
+ const result = await dispatch("resolve_match", { allPlayersVoted: true });
9
+ expect(result.selectedTrainId).toBe("train:match:match-resolution-standard");
10
+ expect(InterlockingRunner).toBeDefined();
11
+ expect(TrainRunner).toBeDefined();
12
+ });
@@ -0,0 +1,8 @@
1
+ interlocking_id: interlocking:match-resolution
2
+ entrypoint:
3
+ exposed: true
4
+ actions:
5
+ - resolve_match
6
+ routes:
7
+ - route_id: nominal-all-voted
8
+ train_id: train:match:match-resolution-standard
@@ -0,0 +1,13 @@
1
+ import { InterlockingRunner } from "./trains/interlocking";
2
+ import { TrainRunner } from "./trains/runner";
3
+
4
+ const runner = new InterlockingRunner("plan/_trains/_interlockings/match-resolution.yaml");
5
+
6
+ async function inertProductionHelper(action: string, inputs: object) {
7
+ const resolution = runner.resolveTrain(action, inputs);
8
+ return await new TrainRunner(resolution.trainId).execute({});
9
+ }
10
+
11
+ export async function dispatch(_action: string, _inputs: object) {
12
+ return { selectedTrainId: "train:match:match-resolution-standard" };
13
+ }
@@ -0,0 +1,12 @@
1
+ // Phase: SMOKE
2
+ import { expect, test } from "bun:test";
3
+ import { dispatch } from "../../src/server";
4
+ import { InterlockingRunner } from "../../src/trains/interlocking";
5
+ import { TrainRunner } from "../../src/trains/runner";
6
+
7
+ test("resolve_match target token cannot cover resolve_other dispatch", async () => {
8
+ const result = await dispatch("resolve_other", { allPlayersVoted: true });
9
+ expect(result.selectedTrainId).toBe("train:match:match-resolution-standard");
10
+ expect(InterlockingRunner).toBeDefined();
11
+ expect(TrainRunner).toBeDefined();
12
+ });
@@ -0,0 +1,8 @@
1
+ interlocking_id: interlocking:match-resolution
2
+ entrypoint:
3
+ exposed: true
4
+ actions:
5
+ - resolve_match
6
+ routes:
7
+ - route_id: nominal-all-voted
8
+ train_id: train:match:match-resolution-standard
@@ -0,0 +1,9 @@
1
+ import { InterlockingRunner } from "./trains/interlocking";
2
+ import { TrainRunner } from "./trains/runner";
3
+
4
+ const runner = new InterlockingRunner("plan/_trains/_interlockings/match-resolution.yaml");
5
+
6
+ export async function dispatch(action: string, inputs: object) {
7
+ const resolution = runner.resolveTrain(action, inputs);
8
+ return await new TrainRunner(resolution.trainId).execute({});
9
+ }
package/integrity.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.10.55",
2
+ "version": "0.10.57",
3
3
  "files": {
4
4
  "HOOK_AUDIT.md": "5329d840db37671b1918f688ead26865473b87db73dbc75f7c8b2a8bbe8d6d43",
5
5
  "PLANNER_PORT.md": "fb5935bac8b7ac18994de21e43ace3a5ef8cd55f85b0e3349fca261280054f11",
@@ -385,12 +385,12 @@
385
385
  "detectors/bun_interlocking_binding/fixtures/dirty_transcribed_runtime/server.ts": "d5bc34c5b1b2b0a511e0c4604ce6f9c77491dca9aee994be720540f3052babc1",
386
386
  "detectors/bun_interlocking_binding/fixtures/dirty_transcribed_runtime/src/trains/interlocking.ts": "9bcfdb4ecff6efd1f658b9fd587342615179924e25468c75daa07c2626d4d8ab",
387
387
  "detectors/bun_interlocking_binding/fixtures/dirty_transcribed_runtime/src/trains/runner.ts": "0e4b885e50f12cc2a8c8f6740bf83e7b2e7137a28e1e143586e9a8550ea5bd45",
388
- "detectors/bun_interlocking_coverage/_shared/interlocking.mjs": "ea5f87285111a6cd67d7febf3731d5f37c8932a98749ee51cfd39e076994aed6",
388
+ "detectors/bun_interlocking_coverage/_shared/interlocking.mjs": "ee5e880f8e5800a767fb37a177bae29faf2b2207c56244ff8bd14bb51b1a64ab",
389
389
  "detectors/bun_interlocking_coverage/atdd.implementation.yaml": "f160517a8138ba3ca05e3d130e6fbfb796e643376f25743fdef4fc210196525b",
390
390
  "detectors/bun_interlocking_coverage/checks/_map.json": "0a218ea319cc6258af7bd721c2154cb9561fe67e80d80ef32d08fc77bc9403c3",
391
- "detectors/bun_interlocking_coverage/checks/interlocking_production_runner.mjs": "6d0645debe91d05987d858efbaf04f5b70f383d34aeb319fa96b3d3e7ee28330",
391
+ "detectors/bun_interlocking_coverage/checks/interlocking_production_runner.mjs": "04fbc7c2ea8f3b762b1fae227e020defdee539fd28d0dec5ab0a6c87f421a1af",
392
392
  "detectors/bun_interlocking_coverage/checks/interlocking_route_coverage.mjs": "2230b8477ece6a9f459fb0d3cd9e3f8d194d9800eb976ed7064f4dfb820202b0",
393
- "detectors/bun_interlocking_coverage/checks/interlocking_smoke_coverage.mjs": "ca1250e762a40f04520aa3ef525d61fe834a8f5df03d961e0d6c80e4f3044a2e",
393
+ "detectors/bun_interlocking_coverage/checks/interlocking_smoke_coverage.mjs": "e4a564689bc82041db791d88e6e029ce0fff2e4a2ebf071083b65fafbf7eaec8",
394
394
  "detectors/bun_interlocking_coverage/checks/interlocking_trace_binds.mjs": "1cc3a11d50a075f3211a6d9cbf27aafcbf7e77b6a855ab040f765ec89dfe0eef",
395
395
  "detectors/bun_interlocking_coverage/checks/interlocking_train_sequence.mjs": "8a8a8e88ce8a80024d0bdcc6866819fdfaa5a2ed26e8a9c5a459848d2de346d9",
396
396
  "detectors/bun_interlocking_coverage/detect.mjs": "a124418dc29aaf8fda9cd09427230d25768206b3f563d516774667e8cf75b66e",
@@ -435,6 +435,12 @@
435
435
  "detectors/bun_interlocking_coverage/fixtures/dirty/interlocking_smoke_coverage/e2e/interlockings/match-resolution/routes.test.ts": "ba6ea85b3fbe15e3c49332da3ad2921a0ff3436a6fe3b5a5035fb35fdc874720",
436
436
  "detectors/bun_interlocking_coverage/fixtures/dirty/interlocking_smoke_coverage/e2e/interlockings/match-resolution/trace.test.ts": "aee4ea93ceb8636846344e915cd7e66f7e8f99ba08596f83266590fa2b9ff58e",
437
437
  "detectors/bun_interlocking_coverage/fixtures/dirty/interlocking_smoke_coverage/plan/_trains/_interlockings/match-resolution.yaml": "79b5b7ccbd7d0e73df74c53ec071580a0442fe30ae6d0bf393e3102b2be2e5c0",
438
+ "detectors/bun_interlocking_coverage/fixtures/dirty/interlocking_smoke_coverage_inert_module/e2e/smoke/resolve-match.smoke.test.ts": "80fe9fac7af88bade87683c4dd8b78beff26f2c9ac0e4f550353817f7de96f5e",
439
+ "detectors/bun_interlocking_coverage/fixtures/dirty/interlocking_smoke_coverage_inert_module/plan/_trains/_interlockings/match-resolution.yaml": "3f475539871048e7a0c9b92cfc4b6fb0690f591c91e4da00bdca7cf397f39025",
440
+ "detectors/bun_interlocking_coverage/fixtures/dirty/interlocking_smoke_coverage_inert_module/src/server.ts": "82bc38c96843f5dfe811cbb49b5ece2523d178c0f48337177bc45ba15ea96a0f",
441
+ "detectors/bun_interlocking_coverage/fixtures/dirty/interlocking_smoke_coverage_mismatched_action/e2e/smoke/resolve-match.smoke.test.ts": "a71ae9e8e2eb4c143e7c8a333ec1a56b831dc60245fd9cab15422801c659a89d",
442
+ "detectors/bun_interlocking_coverage/fixtures/dirty/interlocking_smoke_coverage_mismatched_action/plan/_trains/_interlockings/match-resolution.yaml": "3f475539871048e7a0c9b92cfc4b6fb0690f591c91e4da00bdca7cf397f39025",
443
+ "detectors/bun_interlocking_coverage/fixtures/dirty/interlocking_smoke_coverage_mismatched_action/src/server.ts": "962f6f7e84c7a770b932b672271b7542e3bc7c850f8cfcb97bb75f8b1f32196a",
438
444
  "detectors/bun_interlocking_coverage/fixtures/dirty/interlocking_trace_binds/e2e/interlockings/match-resolution/routes.test.ts": "ba6ea85b3fbe15e3c49332da3ad2921a0ff3436a6fe3b5a5035fb35fdc874720",
439
445
  "detectors/bun_interlocking_coverage/fixtures/dirty/interlocking_trace_binds/e2e/interlockings/match-resolution/trace.test.ts": "ba682afab675c12a4404ebd58b3dc7ffc4147982a96f8727c9d80215682b2788",
440
446
  "detectors/bun_interlocking_coverage/fixtures/dirty/interlocking_trace_binds/e2e/smoke/resolve-match.smoke.test.ts": "07398b0d33ef6b7139c1a2c762bb8cc7afc307b7dfad248d048e7f4bad81320d",
@@ -1218,7 +1224,7 @@
1218
1224
  "relationships.yaml": "5860a308ebc3ee7fa8172fc2c6633984000ab3003f0ae4b6fa28db48879eda22",
1219
1225
  "src/agent.ts": "6d6c6e75908af8fbab13432f0001b0374bb25cf3dee46ba218dfd67e0090cb56",
1220
1226
  "src/ci.ts": "67e54d2cbfc44a9e42837d5e750af526d6255c539d3c452a4d5fca3226c0d9ac",
1221
- "src/cli.ts": "c4ede85983445a6388bf74bbccf1251dd45df2788a699f1bf2250fa0fda3c516",
1227
+ "src/cli.ts": "90e59edbb8a7ea116ad9e7b4ba56b5c612ac18ada05824e8e941048be4dc72c8",
1222
1228
  "src/docs-capability.ts": "bdeeb414649049b1cd4fa8008cada3a576318be51c43bec91bfa46a8e250c270",
1223
1229
  "src/enforce.ts": "7e6b3a1fc7c90f8e1836ab3e4655778fb569a63be5e7c199de44024fc003e6be",
1224
1230
  "src/hooks.ts": "233d26c0320adea3b315d808811da9b53b989db8effd4f6ed9306a80069101e8",
@@ -1229,7 +1235,7 @@
1229
1235
  "src/planner-schema-validator.ts": "ce529c0936171075e93c7539b3655fe895a98dd2973ebda5775958e6ebc316d9",
1230
1236
  "src/planner-validators.ts": "99adeb65f579af923c11f21a8f55cfb9993b007fd8df59c5f764696b55379cfc",
1231
1237
  "src/profile-registry.ts": "5a7761d324181ae27deb51f198d63648a4c99e858b4de98b54d35e6cf8a39fe7",
1232
- "src/ratchet.ts": "1e90fba040fd90437cd9d0300bcd2dc575a3952a7e5919cc2d5c8ff59814f1f1",
1238
+ "src/ratchet.ts": "16213f88fc09de7911defc185ac6ec45b9ecdfbebbe40a12fb6114fe75e7f896",
1233
1239
  "src/register.ts": "6a2f1c6b8df0dc9c8f45df8ec9fac2be29044e048b08c8ffe423410e94dadedd",
1234
1240
  "src/release.ts": "7b0fe444ee81f476f53b9c21b6c347ef588ee616c60211419cf984132be768ba",
1235
1241
  "src/setup.ts": "38233265afedc63499bbef3b5403889651add335a70efd3813254ba53f4fc4fe",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@afokapu/atdd-bun",
3
- "version": "0.10.55",
3
+ "version": "0.10.57",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/afokapu/atdd-bun.git"
package/src/cli.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env bun
2
2
  import { enforce, enabledProfiles, flowCompatibilityDiagnostic, normalizeProfileName, profileNames, type Profile } from "./enforce";
3
- import { activationContextDigest, compareFindings, contextDigest, isStrictProfileExpansion, parseRatchetPolicy } from "./ratchet";
3
+ import { activationContextDigest, canonicalFindingPath, compareFindings, contextDigest, isStrictProfileExpansion, parseRatchetPolicy } from "./ratchet";
4
4
  import { existsSync } from "node:fs";
5
5
  import { mkdtemp, readFile, rm } from "node:fs/promises";
6
6
  import { tmpdir } from "node:os";
7
- import { join, relative, resolve } from "node:path";
7
+ import { join, resolve } from "node:path";
8
8
  import { finishWorktree, hookEvents, hooksStatus, installHooks, runHook, startWorktree, uninstallHooks, worktreeStatus } from "./hooks";
9
9
  import { ciInit, ciStatus } from "./ci";
10
10
  import { agentInit, agentStatus } from "./agent";
@@ -173,7 +173,7 @@ if (!args.includes("--ratchet")) {
173
173
  if (!(profileActivation ? activationIsValid : ordinaryContextMatches)) { console.error(profileActivation ? "ratchet profile activation requires an explicit monotonic profile expansion and identical non-profile context" : "ratchet base and candidate profile/context differ; use a separately governed adoption path"); process.exitCode = 1; }
174
174
  else {
175
175
  const [baseFindings, candidateFindings] = await Promise.all([enforce({ root: scratch, profiles: candidateProfiles as Profile[] }), enforce({ root: candidateRoot, profiles: candidateProfiles as Profile[] })]);
176
- const normalize = (findings: Awaited<ReturnType<typeof enforce>>, directory: string) => findings.map(finding => ({ ...finding, file: relative(directory, finding.file).replaceAll("\\", "/") }));
176
+ const normalize = (findings: Awaited<ReturnType<typeof enforce>>, directory: string) => findings.map(finding => ({ ...finding, file: canonicalFindingPath(directory, finding.file) }));
177
177
  const delta = compareFindings(normalize(baseFindings, scratch), normalize(candidateFindings, candidateRoot));
178
178
  console.log(JSON.stringify({ schema: "atdd-bun.ratchet-report/v1", mode: policy.mode, base, candidate: (await git(candidateRoot, ["rev-parse", "HEAD"])).out, profiles: stable(candidateProfiles), context: contextDigest(candidateConfig, candidateProfiles), ...delta }, null, 2));
179
179
  if (policy.mode === "reject-new" && delta.new.length) process.exitCode = 1;
package/src/ratchet.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { relative, resolve } from "node:path";
2
+
1
3
  /** Pure v1 primitives for the opt-in exact-base finding ratchet. No baseline is written or accepted. */
2
4
  export type RatchetMode = "report" | "reject-new";
3
5
  export type RatchetPolicy = { mode: RatchetMode; profiles: string[] };
@@ -24,6 +26,14 @@ const normalizedPath = (path: string) => {
24
26
  return normalized;
25
27
  };
26
28
 
29
+ /** Canonicalize a detector location against the root that produced it, refusing traversal and external paths. */
30
+ export function canonicalFindingPath(root: string, path: string): string {
31
+ const supplied = path.replaceAll("\\", "/");
32
+ if (supplied.split("/").includes("..")) throw new Error(`ratchet finding path must be normalized and repository-relative: ${path}`);
33
+ const rootPath = resolve(root), target = resolve(rootPath, path), fromRoot = relative(rootPath, target).replaceAll("\\", "/");
34
+ return normalizedPath(fromRoot);
35
+ }
36
+
27
37
  /** Stable semantic identity: presentation position and rendered evidence deliberately do not participate. */
28
38
  export function findingFingerprint(finding: FindingIdentity): string {
29
39
  const path = normalizedPath(finding.file), subject = finding.subject ?? path;