@afokapu/atdd-bun 0.10.108 → 0.10.110

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.
@@ -558,6 +558,7 @@ function resolutionTrainExecution(after, resolution, aliases = [], base = 0) {
558
558
  return {
559
559
  execution: assigned?.[1] ?? null,
560
560
  alias: alias?.alias ?? null,
561
+ aliasTraceFields: alias?.traceFields ?? null,
561
562
  found: Boolean(assigned || alias || new RegExp(legacy).test(after) || new RegExp(declaration).test(bound)),
562
563
  returned: new RegExp(`${returns}${legacy}`).test(after) || returnsMatch(scope, declaration, bound.length) ||
563
564
  Boolean(assigned && new RegExp(`${returns}${escaped(assigned[1])}\\s*;?`).test(after)),
@@ -579,7 +580,9 @@ export function productionExecutionProofs(text, file, croot) {
579
580
  const proof = resolutionTrainExecution(text.slice(base), resolution,
580
581
  file ? transparentCallbackAliases(text, file, resolution) : [], base);
581
582
  if (proof.found) proofs.push({ resolution, execution: proof.execution });
582
- if (proof.alias && proof.alias !== proof.execution) proofs.push({ resolution, execution: proof.alias });
583
+ if (proof.alias && proof.alias !== proof.execution) {
584
+ proofs.push({ resolution, execution: proof.alias, callbackTraceFields: proof.aliasTraceFields });
585
+ }
583
586
  }
584
587
  return proofs;
585
588
  }
@@ -634,16 +637,23 @@ export function routeHasProductionProof(route, text, interlockingId = null, file
634
637
  }
635
638
 
636
639
  export function traceHasProductionProvenance(text, file, croot) {
637
- return productionExecutionProofs(text, file, croot).some(({ execution, direct }) => {
640
+ return productionExecutionProofs(text, file, croot).some(({ execution, direct, callbackTraceFields }) => {
638
641
  if (direct) return direct.traceBound && REQUIRED_TRACE_FIELDS.every(([field]) => direct.traceFields.includes(field));
639
642
  if (!execution) return false;
640
643
  const trace = boundTraceName(text, execution);
641
- return Boolean(trace);
644
+ return Boolean(trace) || Boolean(callbackTraceFields?.size);
642
645
  });
643
646
  }
644
647
 
645
- export function assertedTraceFields(text, traceName = "trace") {
646
- return REQUIRED_TRACE_FIELDS.filter(([field]) => !assertsExpression(text, `${escaped(traceName)}${MEMBER}${field}`)).map(([field]) => field);
648
+ // Required trace fields not asserted. A field also counts as asserted when a transparent callback return
649
+ // alias of a production execution compares `<alias>.trace` to an object literal carrying it (the proofs
650
+ // come from productionExecutionProofs, so only an execution proven for this file qualifies).
651
+ export function assertedTraceFields(text, traceName = "trace", file, croot) {
652
+ const comparedWhole = new Set(file
653
+ ? productionExecutionProofs(text, file, croot).flatMap(({ callbackTraceFields }) => [...(callbackTraceFields ?? [])])
654
+ : []);
655
+ const asserted = (field) => comparedWhole.has(field) || assertsExpression(text, `${escaped(traceName)}${MEMBER}${field}`);
656
+ return REQUIRED_TRACE_FIELDS.filter(([field]) => !asserted(field)).map(([field]) => field);
647
657
  }
648
658
 
649
659
  // A smoke path has a real Station Master call and observes that call's returned value; separately
@@ -44,7 +44,7 @@ for (const scanRoot of roots) {
44
44
  if (!isInterlockingTest(text, tokens)) continue;
45
45
  const m = TRACE_OBJECT.exec(text);
46
46
  if (!m) continue; // not a trace-binding test.
47
- const missing = assertedTraceFields(text);
47
+ const missing = assertedTraceFields(text, "trace", file, croot);
48
48
  const provenance = traceHasProductionProvenance(text, file, croot);
49
49
  if (!missing.length && provenance) continue;
50
50
  const line = lineOfIndex(text, m.index);
package/integrity.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.10.108",
2
+ "version": "0.10.110",
3
3
  "files": {
4
4
  "HOOK_AUDIT.md": "5329d840db37671b1918f688ead26865473b87db73dbc75f7c8b2a8bbe8d6d43",
5
5
  "README.md": "8aefceaa24bee498f39081b2aaf4c0d5c54bc1ed9849c20e1d98d1427df56eab",
@@ -384,13 +384,13 @@
384
384
  "detectors/bun_interlocking_binding/fixtures/dirty_transcribed_runtime/server.ts": "d5bc34c5b1b2b0a511e0c4604ce6f9c77491dca9aee994be720540f3052babc1",
385
385
  "detectors/bun_interlocking_binding/fixtures/dirty_transcribed_runtime/src/trains/interlocking.ts": "9bcfdb4ecff6efd1f658b9fd587342615179924e25468c75daa07c2626d4d8ab",
386
386
  "detectors/bun_interlocking_binding/fixtures/dirty_transcribed_runtime/src/trains/runner.ts": "0e4b885e50f12cc2a8c8f6740bf83e7b2e7137a28e1e143586e9a8550ea5bd45",
387
- "detectors/bun_interlocking_coverage/_shared/interlocking.mjs": "f6a716c4ef612a0f1bf7b612587c4af94d8e6aaa89ecdb8acee7cc977609bf43",
387
+ "detectors/bun_interlocking_coverage/_shared/interlocking.mjs": "6660f3cf9f16116114c4985111fefdb3666cb11d4049afc2854e544374be8168",
388
388
  "detectors/bun_interlocking_coverage/atdd.implementation.yaml": "f160517a8138ba3ca05e3d130e6fbfb796e643376f25743fdef4fc210196525b",
389
389
  "detectors/bun_interlocking_coverage/checks/_map.json": "0a218ea319cc6258af7bd721c2154cb9561fe67e80d80ef32d08fc77bc9403c3",
390
390
  "detectors/bun_interlocking_coverage/checks/interlocking_production_runner.mjs": "97224d46f0c4c3259f15e41f90bb7f9f5a4e651d4facd41f1e037cac919be500",
391
391
  "detectors/bun_interlocking_coverage/checks/interlocking_route_coverage.mjs": "a2cff94a6bf9e11fe8e370332551742f9c7db317dcd57a88d08dc6c74f868f49",
392
392
  "detectors/bun_interlocking_coverage/checks/interlocking_smoke_coverage.mjs": "28e016e1a4cd394291640679876b16c7d6a725530c7fbfa4bd1da8a354ecff86",
393
- "detectors/bun_interlocking_coverage/checks/interlocking_trace_binds.mjs": "1cc3a11d50a075f3211a6d9cbf27aafcbf7e77b6a855ab040f765ec89dfe0eef",
393
+ "detectors/bun_interlocking_coverage/checks/interlocking_trace_binds.mjs": "929f7ff757be481d71d38141283a4ca8622ccc7b344b31da06161252e19e25a2",
394
394
  "detectors/bun_interlocking_coverage/checks/interlocking_train_sequence.mjs": "28d0dd186f91f7aeb2f6672180d289ba6a02552581abda9562e41cca04bc3485",
395
395
  "detectors/bun_interlocking_coverage/detect.mjs": "122b928bbb49affbf187c59cbf8e10d0190ca84cb5b98bc222bd2c1c1aac2f26",
396
396
  "detectors/bun_interlocking_coverage/fixtures/clean/interlocking_production_runner/convex/trains/interlocking.ts": "e97288c4ca6084391bfa72d1457de26e99e9fa0c9540be4361e27731c19aeb6f",
@@ -1033,7 +1033,7 @@
1033
1033
  "lib/frontend.mjs": "d6d9b29c3d7f2d162cda03bb34b20a3224a82887b6be3db7cab375510913c9a3",
1034
1034
  "lib/imports.mjs": "3db4979269488fa26f6e7aefded6ece9c646be6587a10e249821a5a208109962",
1035
1035
  "lib/interlocking.mjs": "d0803d080ac7d7477c51be70112df1e85213d339fd3c6b71f75f26145fa8accd",
1036
- "lib/journey-chain.mjs": "bede441e5d61ea950ed5b76bbb6357ef0d2057cb29053ed32df1faab33ad9ac1",
1036
+ "lib/journey-chain.mjs": "8ba32f5ab52bec636eb87b97a532427dcca81f3481a588a093ff4b5e643f7c8f",
1037
1037
  "lib/scan.mjs": "b6973e411ecd14c548a055c871e01e948f002b772042136be8401f38d998e687",
1038
1038
  "planner-nodes/ENFORCEMENT_SCOPE.yaml": "47aa9c78a4bb1754c6b435f5e056038baa04a24cd48df2c205db2bc08a3a3cac",
1039
1039
  "planner-nodes/nodes/planner.acceptance.abstract-fields-required.convention.yaml": "bd565693f0ae861f0561ad2f7a91a8c55f9a209c3acd59c5750cfcffd96a85d8",
@@ -1269,7 +1269,7 @@
1269
1269
  "src/cli.ts": "395638ab87565bb10c2beb35988ff1acf686924d13515795156a2e8893b3da88",
1270
1270
  "src/docs-capability.ts": "bdeeb414649049b1cd4fa8008cada3a576318be51c43bec91bfa46a8e250c270",
1271
1271
  "src/enforce.ts": "c4811abe63efe4568f3cf26e21a152d1537e120cb3ce471daca32367cf6e2cda",
1272
- "src/hooks.ts": "7e3dd5518701d2f3399bee919a68e801588822b173a648ed738c05c4a57231f1",
1272
+ "src/hooks.ts": "fce67a102fe61c15b4100daddf842ce7264d9e0d0e79ec89c4ed4a0b188967b7",
1273
1273
  "src/index.ts": "4081f8b08d1d3ee9e15a9964143e53d9cfb94a5616ab3e58a180d24cc16dc381",
1274
1274
  "src/integrity.ts": "f1ede7cb04924ae88e11917047277e264af6596b9759e381bd9c497cd34a797d",
1275
1275
  "src/journey-docs.ts": "994d229376244c84212cff951032823068a76565dd2fd427b9d44e341bd14853",
@@ -804,9 +804,12 @@ function localFunction(ctx, identifier) {
804
804
  // A wrapper whose every return, on every path, IS the unchanged result of its callback parameter:
805
805
  // `return work()` / `return await work()`. Anything that replaces, conditions, copies or discards the
806
806
  // callback's value (spread, ternary, comma, a second return value, a returning catch) is not transparent.
807
+ // The callback is the wrapper's LAST parameter, so a leading configuration parameter is allowed
808
+ // (`withDeployment(env, work)`); a callback in any other position, or a rest/destructured parameter, is not.
807
809
  function transparentWrapper(ctx, fn) {
808
- const param = fn.parameters[0];
809
- if (!param || !ts.isIdentifier(param.name) || param.dotDotDotToken || fn.asteriskToken) return false;
810
+ if (fn.asteriskToken || !fn.parameters.length) return false;
811
+ const param = fn.parameters[fn.parameters.length - 1];
812
+ if (!ts.isIdentifier(param.name) || param.dotDotDotToken) return false;
810
813
  const symbol = symbolOf(ctx, param.name);
811
814
  if (!symbol) return false;
812
815
  const isWork = (expression) => {
@@ -820,9 +823,42 @@ function transparentWrapper(ctx, fn) {
820
823
  return !rebound;
821
824
  }
822
825
 
823
- // `const <alias> = await <wrapper>(<callback>)` where the local wrapper is transparent and every return of
824
- // the zero-parameter callback IS the production execution of `resolution` (the call itself, or a `const`
825
- // bound to it), so `<alias>` IS that execution. Returns [{ alias, start }] in source order.
826
+ // The required trace fields asserted by a whole-object comparison of `<execution>.trace` (or `?.trace`):
827
+ // `expect(<execution>?.trace).toEqual|toStrictEqual|toMatchObject({ <field>: "<literal>", ... })`. The
828
+ // expected value must be an object literal without spread, computed or shorthand members, each field a
829
+ // string literal; a negated assertion, another object, or a non-literal expected value asserts nothing.
830
+ const TRACE_COMPARISONS = new Set(["toEqual", "toStrictEqual", "toMatchObject"]);
831
+
832
+ // The plain `name: <value>` members of an object literal (identifier or string-literal names only), or
833
+ // null when any member is a spread, shorthand, method, accessor or computed name.
834
+ function plainMembers(literal) {
835
+ if (!literal || !ts.isObjectLiteralExpression(literal)) return null;
836
+ const plain = literal.properties.every((property) => ts.isPropertyAssignment(property) &&
837
+ (ts.isIdentifier(property.name) || ts.isStringLiteral(property.name)));
838
+ return plain ? literal.properties : null;
839
+ }
840
+
841
+ function wholeTraceFields(ctx, executionSymbol) {
842
+ const fields = new Set();
843
+ if (!executionSymbol) return fields;
844
+ walk(ctx.source, (node) => {
845
+ const assertion = expectAssertion(ctx, node);
846
+ if (!assertion || assertion.negated || !TRACE_COMPARISONS.has(assertion.matcher) ||
847
+ dead(node, ctx.source) || insideCatch(node, ctx.source)) return;
848
+ const path = pathFromSymbol(ctx, assertion.actual, executionSymbol);
849
+ if (path?.length !== 1 || path[0] !== "trace") return;
850
+ for (const property of plainMembers(assertion.expected && unwrap(assertion.expected)) ?? []) {
851
+ if (ts.isStringLiteralLike(property.initializer)) fields.add(property.name.text);
852
+ }
853
+ });
854
+ return fields;
855
+ }
856
+
857
+ // `const <alias> = await <wrapper>(<callback>)` or `await <wrapper>(<configuration>..., <callback>)` where
858
+ // the local wrapper is transparent and every return of the zero-parameter callback IS the production
859
+ // execution of `resolution` (the call itself, or a `const` bound to it), so `<alias>` IS that execution.
860
+ // Returns [{ alias, start, traceFields }] in source order; `traceFields` are the trace fields the alias
861
+ // asserts as a whole object (see wholeTraceFields).
826
862
  // Production forms: `new TrainRunner(<resolution>.trainId|selectedTrainId).execute(...)` and
827
863
  // `new TrainRunner(<resolution>.trainPath, ...).execute(<resolution>, ...)`.
828
864
  export function transparentCallbackAliases(text, file, resolution) {
@@ -862,14 +898,19 @@ export function transparentCallbackAliases(text, file, resolution) {
862
898
  !(ts.isVariableDeclarationList(node.parent) && node.parent.flags & ts.NodeFlags.Const) ||
863
899
  dead(node, ctx.source) || insideCatch(node, ctx.source)) return;
864
900
  const awaited = awaitedCall(node.initializer);
865
- if (!awaited || !ts.isCallExpression(awaited) || !ts.isIdentifier(awaited.expression) || awaited.arguments.length !== 1) return;
866
- const callback = unwrap(awaited.arguments[0]);
901
+ if (!awaited || !ts.isCallExpression(awaited) || !ts.isIdentifier(awaited.expression) || !awaited.arguments.length ||
902
+ awaited.arguments.some(ts.isSpreadElement)) return;
903
+ const callback = unwrap(awaited.arguments[awaited.arguments.length - 1]);
867
904
  if (!callback || !(ts.isArrowFunction(callback) || ts.isFunctionExpression(callback)) ||
868
905
  callback.parameters.length > 0 || callback.asteriskToken) return;
869
906
  const wrapper = localFunction(ctx, awaited.expression);
870
- if (!wrapper || !transparentWrapper(ctx, wrapper)) return;
907
+ if (!wrapper || wrapper.parameters.length !== awaited.arguments.length || !transparentWrapper(ctx, wrapper)) return;
871
908
  if (!alwaysReturns(callback, (expression) => isIdentity(expression))) return;
872
- aliases.push({ alias: node.name.text, start: node.getStart(ctx.source) });
909
+ aliases.push({
910
+ alias: node.name.text,
911
+ start: node.getStart(ctx.source),
912
+ traceFields: wholeTraceFields(ctx, symbolOf(ctx, node.name)),
913
+ });
873
914
  });
874
915
  return aliases;
875
916
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@afokapu/atdd-bun",
3
- "version": "0.10.108",
3
+ "version": "0.10.110",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/afokapu/atdd-bun.git"
package/src/hooks.ts CHANGED
@@ -345,7 +345,7 @@ type PrimaryPolicy = { policy: HookPolicy; source: { path: string; commit: strin
345
345
  async function primaryPolicy(root: string): Promise<PrimaryPolicy> { const found = await primaryRoot(await realpath(resolve(root))); if (!found) return { policy: defaultHookPolicy, source: null }; const primary = await realpath(found), revision = await git(primary, ["rev-parse", "--verify", "HEAD^{commit}"]); if (revision.code || !revision.out) return { policy: defaultHookPolicy, source: null }; const source = { path: primary, commit: revision.out }, committed = await git(primary, ["show", `${source.commit}:atdd-bun.yaml`]); return { policy: committed.code ? defaultHookPolicy : parsePolicy(committed.out), source }; }
346
346
  const disabledWorktreePolicy = (source: PrimaryPolicy["source"]) => bad(source ? `worktree policy is disabled in primary checkout ${source.path} at ${source.commit}; update the primary checkout and try again` : "worktree policy is disabled");
347
347
  export type WorktreeLayout = { primary: string; worktreeRoot: string; current: string; branch: string };
348
- async function layout(root: string, cfg: WorktreePolicy): Promise<WorktreeLayout | { error: string }> { const current = await realpath(resolve(root)), commonRoot = await primaryRoot(current); if (!commonRoot) return { error: "not inside a Git worktree" }; const primary = await realpath(commonRoot), expectedPrimary = cfg.primary_directory === "." ? primary : join(dirname(primary), cfg.primary_directory); if (primary !== expectedPrimary) return { error: `primary checkout must be ${expectedPrimary}, not ${primary}` }; const branch = (await git(primary, ["symbolic-ref", "--quiet", "--short", "HEAD"])).out; if (branch !== cfg.primary_branch) return { error: `primary checkout ${primary} must be on ${cfg.primary_branch}, not ${branch || "a detached HEAD"}` }; return { primary, worktreeRoot: resolve(primary, cfg.root.replaceAll("{repo}", basename(primary))), current, branch: (await git(current, ["symbolic-ref", "--quiet", "--short", "HEAD"])).out }; }
348
+ async function layout(root: string, cfg: WorktreePolicy): Promise<WorktreeLayout | { error: string }> { const current = await realpath(resolve(root)), commonRoot = await primaryRoot(current); if (!commonRoot) return { error: "not inside a Git worktree" }; const primary = await realpath(commonRoot), expectedPrimary = cfg.primary_directory === "." ? primary : join(dirname(primary), cfg.primary_directory); if (primary !== expectedPrimary) return { error: `primary checkout must be ${expectedPrimary}, not ${primary}` }; const branch = (await git(primary, ["symbolic-ref", "--quiet", "--short", "HEAD"])).out; if (branch !== cfg.primary_branch && !(await jjPrimaryOnBranch(primary, cfg.primary_branch))) return { error: `primary checkout ${primary} must be on ${cfg.primary_branch}, not ${branch || "a detached HEAD"}` }; return { primary, worktreeRoot: resolve(primary, cfg.root.replaceAll("{repo}", basename(primary))), current, branch: (await git(current, ["symbolic-ref", "--quiet", "--short", "HEAD"])).out }; }
349
349
  async function worktreeCommitPolicy(root: string, cfg: WorktreePolicy): Promise<string | null> { if (!cfg.enabled || !cfg.require_linked_worktree) return null; const state = await layout(root, cfg); if ("error" in state) return state.error; if (state.current === state.primary) return `commits must use a linked worktree beneath ${state.worktreeRoot}; run: atdd-bun worktree start <branch>`; if (!inside(state.worktreeRoot, state.current)) return `linked worktree ${state.current} is outside required root ${state.worktreeRoot}`; if (!state.branch) return "commits require a named branch, not a detached HEAD"; return null; }
350
350
 
351
351
  export async function runHook(event: HookEvent, root = process.cwd(), args: string[] = [], stdin = "") {
@@ -421,6 +421,17 @@ async function branchForCommit(root: string, commit: string, protectedBranches:
421
421
  : { error: `current Jujutsu revision must have exactly one non-protected exported bookmark; found ${candidates.length}` };
422
422
  }
423
423
 
424
+ // A JJ-colocated primary keeps Git HEAD detached at its working-copy parent, so JJ state decides:
425
+ // the parent must be exactly one commit and it must be the primary branch or its origin bookmark.
426
+ async function jjPrimaryOnBranch(primary: string, branch: string): Promise<boolean> {
427
+ if (!existsSync(join(primary, ".jj"))) return false;
428
+ const name = JSON.stringify(branch), lines = async (revset: string) => {
429
+ const result = await jj(primary, ["log", "--ignore-working-copy", "--no-graph", "-r", revset, "-T", 'commit_id ++ "\\n"']).catch(() => null);
430
+ return result && !result.code ? result.out.split("\n").filter(Boolean) : [];
431
+ };
432
+ return (await lines("@-")).length === 1 && (await lines(`@- & (bookmarks(exact:${name}) | remote_bookmarks(exact:${name}, exact:"origin"))`)).length === 1;
433
+ }
434
+
424
435
  type Layout = { primary: string; worktreeRoot: string; current: string };
425
436
  async function jjLayout(root: string, cfg: HookPolicy): Promise<Layout | { error: string }> {
426
437
  const current = await realpath(resolve(root));