@ecoma-io/archkeep 0.27.0 → 0.27.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ecoma-io/archkeep",
3
- "version": "0.27.0",
3
+ "version": "0.27.1",
4
4
  "description": "Architecture authority for human and agentic software development — deterministic, evidence-backed enforcement of declared architecture.",
5
5
  "keywords": [
6
6
  "architecture",
@@ -73,6 +73,7 @@
73
73
  "!src/custom-rules/wasm-fixture.mjs",
74
74
  "!src/report/envelope-shape.json",
75
75
  "!src/custom-rules/evidence-golden.json",
76
+ "!src/fixtures/",
76
77
  "presets/",
77
78
  "LICENSE",
78
79
  "README.md"
@@ -54,8 +54,10 @@ the resolution order.
54
54
  introduced/resolved/unchanged/unknown (`./delta-classify.mjs`), with
55
55
  unresolvable import sites carried as their own category, never counted as
56
56
  violations. Refuses an unreadable, malformed, foreign-schema, or
57
- incomplete-coverage baseline, a provider mismatch (stricter than `diff`'s
58
- note violation identity across two project models is not evidence),
57
+ incomplete-coverage baseline, a `command`-carrying report envelope (a graph
58
+ snapshot is `diff`'s input, not delta evidence), a provider mismatch
59
+ (stricter than `diff`'s note — violation identity across two project models
60
+ is not evidence),
59
61
  incomplete head coverage, and an Nx workspace with polyglot manifests but no
60
62
  plugin registration; a policy-fingerprint change is a loud coverage note, not
61
63
  a refusal. A verdict, not a description: a non-waived introduced violation is
@@ -84,8 +86,9 @@ the resolution order.
84
86
  pins); an unproven base identity or an undeterminable
85
87
  constraint is exit 3, and constraints are left unevaluated over a base the
86
88
  run cannot vouch for. Refuses a manifest that fails shape or reference
87
- validation, an unreadable/malformed/incomplete baseline, a provider
88
- mismatch, incomplete head coverage, and the unregistered-plugin graph.
89
+ validation, an unreadable/malformed/incomplete baseline, a `command`-carrying
90
+ report envelope in its place, a provider mismatch, incomplete head coverage,
91
+ and the unregistered-plugin graph.
89
92
 
90
93
  - **`impact`** (`./impact.mjs`'s `impactCommand`) — reverse reachability from
91
94
  the project graph: given a project name, lists every project that transitively
@@ -76,8 +76,9 @@
76
76
  * exit 3, a `coverage` block naming every file and site the run could not
77
77
  * judge — where a parser and `--output` can read it; the rest are throws →
78
78
  * exit 3 upstream: a manifest that fails shape or reference validation, an
79
- * unreadable/malformed/foreign-schema baseline, incomplete baseline coverage,
80
- * a provider mismatch, an unregistered-plugin graph over polyglot manifests,
79
+ * unreadable/malformed/foreign-schema baseline, a `command`-carrying report
80
+ * envelope in its place, incomplete baseline coverage, a provider mismatch,
81
+ * an unregistered-plugin graph over polyglot manifests,
81
82
  * and a run with no boundary law (constraints and the law fingerprint need
82
83
  * one).
83
84
  *
@@ -320,6 +320,10 @@ export function readEvidenceSnapshot(path, io = {}) {
320
320
  *
321
321
  * Refusals, each loud:
322
322
  * - unreadable/malformed JSON — named with the path and the parse error;
323
+ * - a `command` field — that marker belongs to a report envelope (the graph
324
+ * family), so the document is not delta evidence at all; the family is
325
+ * decided before the schemaVersion refusals, whose "newer version" advice
326
+ * would be false for a file this binary itself wrote;
323
327
  * - a `schemaVersion` that is not the integer this format uses — a FUTURE
324
328
  * version refuses too: a reader that half-understood a newer format would
325
329
  * classify over evidence it misread;
@@ -360,6 +364,19 @@ export function parseEvidenceSnapshot(text, path) {
360
364
  );
361
365
  }
362
366
 
367
+ // A `command` field marks a report envelope — the graph family's document,
368
+ // not delta evidence. The family decides before the schemaVersion refusals
369
+ // below: that number is the OTHER format's version, so its "newer version;
370
+ // upgrade" advice would be false for a file this binary itself wrote (#810).
371
+ if (typeof parsed.command === "string") {
372
+ const pointer = parsed.command === "graph" ? " For graph snapshots use 'diff <baseline>'." : "";
373
+ throw new Error(
374
+ `archkeep: the evidence snapshot '${path}' has a 'command' field — it is not a delta ` +
375
+ `evidence snapshot, it is a '${parsed.command}' envelope. delta requires an evidence ` +
376
+ `snapshot (from 'delta --capture').${pointer}`,
377
+ );
378
+ }
379
+
363
380
  const problems = [];
364
381
  if (!Number.isInteger(parsed.schemaVersion)) {
365
382
  problems.push(
@@ -29,8 +29,10 @@
29
29
  * status "no-verdict", exit 3, a `coverage` block naming every file and site
30
30
  * the run could not judge — where a parser and `--output` can read it; the
31
31
  * rest are throws, exit 3 upstream:
32
- * - a baseline that cannot be read, parsed, or holds a foreign schemaVersion
33
- * (`./delta-snapshot.mjs`'s loader owns those);
32
+ * - a baseline that cannot be read, parsed, or holds a foreign schemaVersion,
33
+ * or a report envelope — a document carrying a `command` field (a graph
34
+ * snapshot is `diff`'s input), refused as the wrong family before any
35
+ * schemaVersion reading (`./delta-snapshot.mjs`'s loader owns those);
34
36
  * - a provider mismatch between baseline and this run (`providerMismatch`) —
35
37
  * a THROW here where `diff` settles for a note, because violation IDENTITY
36
38
  * across two different project models is not trustworthy: the same tree
@@ -152,11 +152,13 @@ function cloneGraph(graph) {
152
152
  *
153
153
  * @param {object} graph The base graph to apply changes to.
154
154
  * @param {DependencyChange[]} changes The hypothetical changes.
155
- * @returns {{graph: object, applied: string[], refused: string[]}}
155
+ * @returns {{graph: object, applied: string[],
156
+ * mutations: {type: string, source: string, target: string}[], refused: string[]}}
156
157
  */
157
158
  function applyChanges(graph, changes) {
158
159
  const cloned = cloneGraph(graph);
159
160
  const applied = [];
161
+ const mutations = [];
160
162
  const refused = [];
161
163
 
162
164
  for (const change of changes) {
@@ -203,6 +205,7 @@ function applyChanges(graph, changes) {
203
205
  source: change.source,
204
206
  });
205
207
  applied.push(`added dependency: ${change.source} → ${change.target} (${change.edgeType})`);
208
+ mutations.push({ type: "dependency_added", source: change.source, target: change.target });
206
209
  }
207
210
 
208
211
  if (change.type === "dependency_removed") {
@@ -238,6 +241,7 @@ function applyChanges(graph, changes) {
238
241
  existing.splice(idx, 1);
239
242
  const typeLabel = change.edgeType ? ` (${change.edgeType})` : "";
240
243
  applied.push(`removed dependency: ${change.source} → ${change.target}${typeLabel}`);
244
+ mutations.push({ type: "dependency_removed", source: change.source, target: change.target });
241
245
  }
242
246
  }
243
247
  // Clean up empty dependency arrays
@@ -247,7 +251,7 @@ function applyChanges(graph, changes) {
247
251
  }
248
252
  }
249
253
 
250
- return { graph: cloned, applied, refused };
254
+ return { graph: cloned, applied, mutations, refused };
251
255
  }
252
256
 
253
257
  /**
@@ -410,6 +414,50 @@ function resolveBaseRevision(root, userBase) {
410
414
  };
411
415
  }
412
416
 
417
+ /**
418
+ * Merges constraint-impact rows for the named project's own changed edges
419
+ * into one side's rows (#809), preserving the row shape the primitive
420
+ * returns — no new fields, no second judgment.
421
+ *
422
+ * One row per project: when the side already carries a row for the project,
423
+ * the edge, constraint-row and violation lists union without duplicates
424
+ * (constraint rows match by identity — they are the config's own row
425
+ * objects; violations by value, since `judgeEdge` builds fresh objects);
426
+ * otherwise the row is appended. Appending keeps every pre-existing row
427
+ * byte-identical and the order deterministic, because it follows the
428
+ * input changes' order.
429
+ *
430
+ * @param {{project: string, edges: object[], constraintRows: object[],
431
+ * violations: object[]}[]} rows The side's constraint impact, mutated in place.
432
+ * @param {{project: string, edges: object[], constraintRows: object[],
433
+ * violations: object[]}[]} merged Rows computed for the changed edge.
434
+ */
435
+ function mergeConstraintImpact(rows, merged) {
436
+ for (const row of merged) {
437
+ const existing = rows.find((r) => r.project === row.project);
438
+ if (!existing) {
439
+ rows.push(row);
440
+ continue;
441
+ }
442
+ for (const edge of row.edges) {
443
+ if (!existing.edges.some((e) => e.target === edge.target && e.type === edge.type)) {
444
+ existing.edges.push(edge);
445
+ }
446
+ }
447
+ for (const constraintRow of row.constraintRows) {
448
+ if (!existing.constraintRows.includes(constraintRow)) {
449
+ existing.constraintRows.push(constraintRow);
450
+ }
451
+ }
452
+ for (const violation of row.violations) {
453
+ const identity = JSON.stringify(violation);
454
+ if (!existing.violations.some((v) => JSON.stringify(v) === identity)) {
455
+ existing.violations.push(violation);
456
+ }
457
+ }
458
+ }
459
+ }
460
+
413
461
  /**
414
462
  * Evaluates a scenario against the current workspace.
415
463
  *
@@ -452,7 +500,12 @@ export function evaluateScenario(
452
500
  }
453
501
 
454
502
  // Step 3: Apply scenario changes to the graph
455
- const { graph: scenarioGraph, applied, refused } = applyChanges(graph, scenarioInput.changes);
503
+ const {
504
+ graph: scenarioGraph,
505
+ applied,
506
+ mutations,
507
+ refused,
508
+ } = applyChanges(graph, scenarioInput.changes);
456
509
 
457
510
  // Step 4: Compute scenario impact
458
511
  const scenarioImpact = computeImpact(projectName, scenarioGraph);
@@ -468,6 +521,47 @@ export function evaluateScenario(
468
521
  );
469
522
  }
470
523
 
524
+ // Step 4b: the named project's own changed edges (#809).
525
+ //
526
+ // `computeImpactConstraints` judges edges INTO the target from its
527
+ // dependents, so a change whose SOURCE is the named project never enters
528
+ // that frame — an edge pointing out of the target is invisible to it, and
529
+ // the source-named run reported `unchanged` where the target-named run of
530
+ // the same change reported the violation. The rows for the changed edge
531
+ // are computed here by the same primitive — never a second judgment — and
532
+ // merged into the frame the edge actually belongs to: additions into the
533
+ // scenario side (the edge exists only there), removals into the current
534
+ // side (it exists only there).
535
+ if (config && config.depConstraints) {
536
+ for (const mutation of mutations) {
537
+ if (mutation.source !== projectName) continue;
538
+ if (mutation.type === "dependency_added") {
539
+ mergeConstraintImpact(
540
+ scenarioConstraintImpact,
541
+ computeImpactConstraints(
542
+ mutation.target,
543
+ [mutation.source],
544
+ scenarioGraph.nodes,
545
+ scenarioGraph.dependencies,
546
+ config.depConstraints,
547
+ ),
548
+ );
549
+ }
550
+ if (mutation.type === "dependency_removed") {
551
+ mergeConstraintImpact(
552
+ currentConstraintImpact,
553
+ computeImpactConstraints(
554
+ mutation.target,
555
+ [mutation.source],
556
+ graph.nodes,
557
+ graph.dependencies,
558
+ config.depConstraints,
559
+ ),
560
+ );
561
+ }
562
+ }
563
+ }
564
+
471
565
  // Step 5: Build decision impact for both sides
472
566
  const currentDecisionImpact = buildDecisionImpact(root, currentConstraintImpact, config);
473
567
  const scenarioDecisionImpact = buildDecisionImpact(root, scenarioConstraintImpact, config);
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 2,
3
3
  "tool": {
4
4
  "name": "@ecoma-io/archkeep",
5
- "version": "0.27.0"
5
+ "version": "0.27.1"
6
6
  },
7
7
  "command": "adr",
8
8
  "workspace": {
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 2,
3
3
  "tool": {
4
4
  "name": "@ecoma-io/archkeep",
5
- "version": "0.27.0"
5
+ "version": "0.27.1"
6
6
  },
7
7
  "command": "change",
8
8
  "workspace": {
@@ -45,7 +45,7 @@
45
45
  "path": "<fixture-root>/.archkeep-delta.json",
46
46
  "tool": {
47
47
  "name": "@ecoma-io/archkeep",
48
- "version": "0.27.0"
48
+ "version": "0.27.1"
49
49
  },
50
50
  "provider": "native",
51
51
  "provenance": {
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 2,
3
3
  "tool": {
4
4
  "name": "@ecoma-io/archkeep",
5
- "version": "0.27.0"
5
+ "version": "0.27.1"
6
6
  },
7
7
  "command": "check",
8
8
  "workspace": {
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 2,
3
3
  "tool": {
4
4
  "name": "@ecoma-io/archkeep",
5
- "version": "0.27.0"
5
+ "version": "0.27.1"
6
6
  },
7
7
  "command": "context",
8
8
  "workspace": {
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 2,
3
3
  "tool": {
4
4
  "name": "@ecoma-io/archkeep",
5
- "version": "0.27.0"
5
+ "version": "0.27.1"
6
6
  },
7
7
  "command": "debt",
8
8
  "workspace": {
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 2,
3
3
  "tool": {
4
4
  "name": "@ecoma-io/archkeep",
5
- "version": "0.27.0"
5
+ "version": "0.27.1"
6
6
  },
7
7
  "command": "decisions",
8
8
  "workspace": {
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 2,
3
3
  "tool": {
4
4
  "name": "@ecoma-io/archkeep",
5
- "version": "0.27.0"
5
+ "version": "0.27.1"
6
6
  },
7
7
  "command": "delta",
8
8
  "workspace": {
@@ -31,7 +31,7 @@
31
31
  "path": "<fixture-root>/.archkeep-delta.json",
32
32
  "tool": {
33
33
  "name": "@ecoma-io/archkeep",
34
- "version": "0.27.0"
34
+ "version": "0.27.1"
35
35
  },
36
36
  "provider": "native",
37
37
  "provenance": {
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 2,
3
3
  "tool": {
4
4
  "name": "@ecoma-io/archkeep",
5
- "version": "0.27.0"
5
+ "version": "0.27.1"
6
6
  },
7
7
  "command": "diff",
8
8
  "workspace": {
@@ -33,7 +33,7 @@
33
33
  "path": "<fixture-root>/.archkeep-graph.json",
34
34
  "projects": 3,
35
35
  "edges": 2,
36
- "toolVersion": "0.27.0",
36
+ "toolVersion": "0.27.1",
37
37
  "provenance": {
38
38
  "commit": "1fd51709c377d99a6139891e1200291cf08aede9",
39
39
  "remote": null,
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 2,
3
3
  "tool": {
4
4
  "name": "@ecoma-io/archkeep",
5
- "version": "0.27.0"
5
+ "version": "0.27.1"
6
6
  },
7
7
  "command": "discover",
8
8
  "workspace": {
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 2,
3
3
  "tool": {
4
4
  "name": "@ecoma-io/archkeep",
5
- "version": "0.27.0"
5
+ "version": "0.27.1"
6
6
  },
7
7
  "command": "drift",
8
8
  "workspace": {
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 2,
3
3
  "tool": {
4
4
  "name": "@ecoma-io/archkeep",
5
- "version": "0.27.0"
5
+ "version": "0.27.1"
6
6
  },
7
7
  "command": "evolution",
8
8
  "workspace": {
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 2,
3
3
  "tool": {
4
4
  "name": "@ecoma-io/archkeep",
5
- "version": "0.27.0"
5
+ "version": "0.27.1"
6
6
  },
7
7
  "command": "explain",
8
8
  "workspace": {
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 2,
3
3
  "tool": {
4
4
  "name": "@ecoma-io/archkeep",
5
- "version": "0.27.0"
5
+ "version": "0.27.1"
6
6
  },
7
7
  "command": "fitness",
8
8
  "workspace": {
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 2,
3
3
  "tool": {
4
4
  "name": "@ecoma-io/archkeep",
5
- "version": "0.27.0"
5
+ "version": "0.27.1"
6
6
  },
7
7
  "command": "graph",
8
8
  "workspace": {
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 2,
3
3
  "tool": {
4
4
  "name": "@ecoma-io/archkeep",
5
- "version": "0.27.0"
5
+ "version": "0.27.1"
6
6
  },
7
7
  "command": "health",
8
8
  "workspace": {
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 2,
3
3
  "tool": {
4
4
  "name": "@ecoma-io/archkeep",
5
- "version": "0.27.0"
5
+ "version": "0.27.1"
6
6
  },
7
7
  "command": "history",
8
8
  "workspace": {
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 2,
3
3
  "tool": {
4
4
  "name": "@ecoma-io/archkeep",
5
- "version": "0.27.0"
5
+ "version": "0.27.1"
6
6
  },
7
7
  "command": "impact",
8
8
  "workspace": {
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 2,
3
3
  "tool": {
4
4
  "name": "@ecoma-io/archkeep",
5
- "version": "0.27.0"
5
+ "version": "0.27.1"
6
6
  },
7
7
  "command": "provenance",
8
8
  "workspace": {
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 2,
3
3
  "tool": {
4
4
  "name": "@ecoma-io/archkeep",
5
- "version": "0.27.0"
5
+ "version": "0.27.1"
6
6
  },
7
7
  "command": "reconcile",
8
8
  "workspace": {
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 2,
3
3
  "tool": {
4
4
  "name": "@ecoma-io/archkeep",
5
- "version": "0.27.0"
5
+ "version": "0.27.1"
6
6
  },
7
7
  "command": "report",
8
8
  "workspace": {
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 2,
3
3
  "tool": {
4
4
  "name": "@ecoma-io/archkeep",
5
- "version": "0.27.0"
5
+ "version": "0.27.1"
6
6
  },
7
7
  "command": "scenario",
8
8
  "workspace": {
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 2,
3
3
  "tool": {
4
4
  "name": "@ecoma-io/archkeep",
5
- "version": "0.27.0"
5
+ "version": "0.27.1"
6
6
  },
7
7
  "command": "trajectory",
8
8
  "workspace": {
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 2,
3
3
  "tool": {
4
4
  "name": "@ecoma-io/archkeep",
5
- "version": "0.27.0"
5
+ "version": "0.27.1"
6
6
  },
7
7
  "command": "waivers",
8
8
  "workspace": {
@@ -268,8 +268,13 @@ export function scoreEdge(edge, keys, intentForbiddenPairs, tagForbiddenPairs) {
268
268
  * Boundary `allowed`/`forbidden` rows are scored from the canonical judge's
269
269
  * findings (matched exactly by `from`/`to`): a `forbidden` row with an
270
270
  * `intentForbiddenEdge` finding is `unexpected`, an `allowed` row with an
271
- * `intentAllowedMissing` finding is `absent`. Project and dependency rows are
272
- * scored directly against the observed names and edges. A
271
+ * `intentAllowedMissing` finding is `absent`. Project rows are scored against
272
+ * the observed names, and the two forbidden drift planes are scored from the
273
+ * judge's findings as well — `dependencies.forbidden` through its
274
+ * `dependencyForbidden` findings, `forbiddenTags` through its
275
+ * `tagDependencyForbidden` witnesses attributed via `tagsByProject` — so a
276
+ * row's verdict IS the verdict `check` and `drift` render, including the
277
+ * transitive closure a direct-edge re-derivation would read as "match". A
273
278
  * `dependencies.allowed` row is an allowlist entry, not an existence claim —
274
279
  * its absence in the graph is not divergence, so it scores `match` either
275
280
  * way (the divergent direction is the observed edge outside the list, scored
@@ -285,7 +290,29 @@ export function scoreIntentRows(intent, judgeVerdict, observed, tagsByProject) {
285
290
  // used by its own test
286
291
  const rows = [];
287
292
  const observedNames = new Set(observed.projects.map((p) => p.name));
288
- const observedEdgeKeys = new Set(observed.edges.map((e) => `${e.source} ${e.target}`));
293
+ // The judge has already judged every forbidden plane on the any-path
294
+ // closure (`../architecture-intent/judge.mjs` emits `dependencyForbidden`
295
+ // and `tagDependencyForbidden` for direct AND transitive paths, as concrete
296
+ // `source`/`target` project names with `boundaryFrom: null`). These two
297
+ // projections score the drift rows from those findings — projecting the
298
+ // canonical verdict, never re-deriving reachability here: a re-walk of the
299
+ // direct-edge list would score a transitive violation the judge reported
300
+ // as a row "match", the divergence `check` and `drift` do report. The
301
+ // judge's pairless findings (`intentUnknownProject`, `intentUnknownTag`)
302
+ // carry no witness pair; the rows they belong to are scored below from the
303
+ // observed names and tags themselves — the same existence predicate the
304
+ // judge used to emit them: a row whose endpoint can never resolve is
305
+ // unknown/unverifiable, never a silent "match".
306
+ const dependencyForbiddenPairs = new Set();
307
+ const tagForbiddenWitnesses = [];
308
+ for (const finding of judgeVerdict.findings) {
309
+ if (finding.source === null || finding.target === null) continue;
310
+ if (finding.rule === "dependencyForbidden") {
311
+ dependencyForbiddenPairs.add(`${finding.source} → ${finding.target}`);
312
+ } else if (finding.rule === "tagDependencyForbidden") {
313
+ tagForbiddenWitnesses.push([finding.source, finding.target]);
314
+ }
315
+ }
289
316
 
290
317
  const boundaryFinding = new Map();
291
318
  for (const finding of judgeVerdict.findings) {
@@ -341,22 +368,45 @@ export function scoreIntentRows(intent, judgeVerdict, observed, tagsByProject) {
341
368
  }
342
369
  for (const forbidden of dependencies.forbidden ?? []) {
343
370
  const key = `${forbidden.source} → ${forbidden.target}`;
371
+ // A row naming a project the observed architecture does not have can
372
+ // never fire — the judge reports it as `intentUnknownProject` with no
373
+ // witness pair, so the projection above stays empty for it. Reading
374
+ // that as "match — the ban holds" would be the silent direction; score
375
+ // it unknown, mirroring the boundary plane (`reconcileScores`).
376
+ if (!observedNames.has(forbidden.source) || !observedNames.has(forbidden.target)) {
377
+ row("edge", key, "unknown", "intentUnknownProject", "forbidden", key);
378
+ continue;
379
+ }
380
+ const violated = dependencyForbiddenPairs.has(key);
344
381
  row(
345
382
  "edge",
346
383
  key,
347
- observedEdgeKeys.has(key) ? "unexpected" : "match",
348
- observedEdgeKeys.has(key) ? "dependencyForbidden" : "match",
384
+ violated ? "unexpected" : "match",
385
+ violated ? "dependencyForbidden" : "match",
349
386
  "forbidden",
350
387
  key,
351
388
  );
352
389
  }
353
390
 
391
+ // Every tag any observed project carries — the existence side of a
392
+ // `forbiddenTags` row, the same vocabulary the judge checks when it emits
393
+ // `intentUnknownTag`.
394
+ const allTags = new Set();
395
+ for (const tags of tagsByProject.values()) {
396
+ for (const tag of tags) allTags.add(tag);
397
+ }
354
398
  for (const tagRow of intent.forbiddenTags ?? []) {
355
399
  const key = `${tagRow.from} → ${tagRow.to}`;
356
- const violated = observed.edges.some((e) => {
357
- if (e.source === e.target) return false;
358
- const sourceTags = tagsByProject.get(e.source) ?? [];
359
- const targetTags = tagsByProject.get(e.target) ?? [];
400
+ // A row naming a tag no observed project carries can never fire — the
401
+ // judge reports it as `intentUnknownTag` with no witness pair. Score it
402
+ // unknown, never a silent "match".
403
+ if (!allTags.has(tagRow.from) || !allTags.has(tagRow.to)) {
404
+ row("tag", key, "unknown", "intentUnknownTag", "tag-forbidden", key);
405
+ continue;
406
+ }
407
+ const violated = tagForbiddenWitnesses.some(([source, target]) => {
408
+ const sourceTags = tagsByProject.get(source) ?? [];
409
+ const targetTags = tagsByProject.get(target) ?? [];
360
410
  return sourceTags.includes(tagRow.from) && targetTags.includes(tagRow.to);
361
411
  });
362
412
  row(
@@ -774,7 +774,7 @@ function formatUntrackedFilesGap(gap) {
774
774
  `⚠ ${count} project-owned file${count === 1 ? "" : "s"} ${count === 1 ? "is" : "are"} ` +
775
775
  `not tracked by git — never read by this run, so no boundary verdict here covers ${them}\n` +
776
776
  `${lines.join("\n")}\n` +
777
- `${DETAIL}git add ${them} so the next run reads ${them}, or let git ignore ${them}`
777
+ `${DETAIL}git add ${them} so the next run reads ${them}`
778
778
  );
779
779
  }
780
780
 
@@ -1,248 +0,0 @@
1
- /**
2
- * Fixture scaffolding for the Wave 3 W8 evolution-lifecycle conformance suite
3
- * (`../evolution-lifecycle.integration.test.mjs`). This module is the ONE home
4
- * for the real-git workspace builders that suite uses — a throwaway native Go
5
- * workspace per case, materialized through real `git`, driven through the real
6
- * `archkeep evolution` entry point.
7
- *
8
- * It deliberately reuses the native-workspace recipe already proven by
9
- * `../commands/evolution.cli.integration.test.mjs` (an `archkeep.json` model,
10
- * a `module-boundaries.config.mjs` law, and Go sources) rather than inventing
11
- * a second convention, and threads the same environment guard (`../process.mjs`).
12
- *
13
- * Nothing here decides a verdict. It builds trees and drives the CLI; the
14
- * assertions live in the suite. Keeping the builders here (and only here) is
15
- * what the W8 task boundary requires: fixture scaffolding lives in
16
- * `./fixtures/evolution-lifecycle/`, nowhere else.
17
- */
18
-
19
- import { execFileSync } from "node:child_process";
20
- import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
21
- import { tmpdir } from "node:os";
22
- import { join } from "node:path";
23
-
24
- import { EXIT, runCli } from "../../../cli.mjs";
25
- import { SPAWN_BUDGET_MS, SPAWN_TEST_BUDGET_MS } from "../../../spawn-budget.mjs";
26
- import { environmentForTree } from "../../workspace.mjs";
27
-
28
- export { SPAWN_TEST_BUDGET_MS, EXIT };
29
-
30
- /** Identity flags keeping every fixture commit independent of the machine. */
31
- const IDENTITY = ["-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false"];
32
-
33
- /**
34
- * Runs git in `cwd` through the same environment guard production uses, with
35
- * the single-spawn budget on every child so a wedged git fails the test rather
36
- * than blocking the worker thread forever.
37
- */
38
- export function git(cwd, ...args) {
39
- // used by its own test
40
- return execFileSync("git", args, {
41
- cwd,
42
- env: environmentForTree(),
43
- encoding: "utf8",
44
- timeout: SPAWN_BUDGET_MS,
45
- killSignal: "SIGKILL",
46
- });
47
- }
48
-
49
- /** Writes `text` to `root/relativePath`, creating parent directories. */
50
- export function writeIn(root, relativePath, text) {
51
- // used by its own test
52
- mkdirSync(join(root, relativePath, ".."), { recursive: true });
53
- writeFileSync(join(root, relativePath), text);
54
- }
55
-
56
- /** Stages every change and commits with the fixture identity; returns the SHA. */
57
- export function commit(root, message) {
58
- // used by its own test
59
- git(root, ...IDENTITY, "add", "-A");
60
- git(root, ...IDENTITY, "commit", "-q", "-m", message);
61
- return git(root, "rev-parse", "HEAD").trim();
62
- }
63
-
64
- /**
65
- * Opens a brand-new throwaway native git workspace (never the repository's own
66
- * tree). `archkeep.json` declares two Go projects on two layers, exactly the
67
- * MODEL `../commands/evolution.cli.integration.test.mjs` uses, so a case can
68
- * lay an edge between them and the native provider draws it.
69
- *
70
- * @returns {{root: string}}
71
- */
72
- export function createWorkspace() {
73
- const root = mkdtempSync(join(tmpdir(), "archkeep-lifecycle-"));
74
- git(root, "init", "-q", "-b", "main");
75
- writeIn(root, "archkeep.json", `${MODEL()}\n`);
76
- writeIn(root, "libs/alpha/go.mod", "module example.com/alpha\n\ngo 1.22\n");
77
- writeIn(root, "libs/beta/go.mod", "module example.com/beta\n\ngo 1.22\n");
78
- return { root };
79
- }
80
-
81
- /**
82
- * The native workspace model: two Go projects on two layers (alpha is
83
- * `layer:a`, beta is `layer:b`), the law file exempted from coverage.
84
- */
85
- const MODEL = () =>
86
- JSON.stringify(
87
- {
88
- projects: {
89
- declared: [
90
- { root: "libs/alpha", name: "alpha", tags: ["layer:a"] },
91
- { root: "libs/beta", name: "beta", tags: ["layer:b"] },
92
- ],
93
- },
94
- coverage: {
95
- exempt: [{ path: "module-boundaries.config.mjs", reason: "the workspace's own law" }],
96
- },
97
- },
98
- null,
99
- 2,
100
- );
101
-
102
- /** The eight options a valid boundary law must carry, per `policyFrom`. */
103
- const OPTIONS = `export const moduleBoundaryOptions = {
104
- allow: [],
105
- buildTargets: ["build"],
106
- enforceBuildableLibDependency: false,
107
- allowCircularSelfDependency: false,
108
- checkDynamicDependenciesExceptions: [],
109
- ignoredCircularDependencies: [],
110
- banTransitiveDependencies: false,
111
- checkNestedExternalImports: false,
112
- };
113
- `;
114
-
115
- export const ALPHA_CLEAN = `package alpha // used by its own test
116
-
117
- func Name() string { return "alpha" }
118
- `;
119
-
120
- export const ALPHA_REACHING = `package alpha // used by its own test
121
-
122
- import (
123
- "example.com/beta"
124
- )
125
-
126
- func Name() string { return "alpha" + beta.Suffix() }
127
- `;
128
-
129
- export const BETA = `package beta // used by its own test
130
-
131
- func Suffix() string { return "-beta" }
132
- `;
133
-
134
- /**
135
- * Writes a `module-boundaries.config.mjs` law at `root` with the given
136
- * `depConstraints` rows and optional `fitness` array.
137
- *
138
- * @param {string} root
139
- * @param {{rows?: string, fitness?: string}} [law]
140
- */
141
- export function writeLaw(root, { rows = "", fitness } = {}) {
142
- // used by its own test
143
- writeIn(
144
- root,
145
- "module-boundaries.config.mjs",
146
- `export const depConstraints = [\n${rows}\n];\n${OPTIONS}` +
147
- (fitness === undefined ? "" : `\nexport const fitness = ${fitness};\n`),
148
- );
149
- }
150
-
151
- /**
152
- * A single permitted layer rule (a may reach b). The same ONE_ROW the
153
- * evolution CLI integration fixtures use, so an allowed alpha→beta edge never
154
- * trips a boundary rule.
155
- */
156
- export const ALLOW_A_TO_B = ` { sourceTag: "layer:a", onlyDependOnLibsWithTags: ["layer:b"] },`; // used by its own test
157
-
158
- /**
159
- * Writes `architecture-intent.json` at `root`. `sections` carries the top-level
160
- * keys directly (`version`, `boundaries`, `allowed`, `forbidden`,
161
- * `dependencies`, …); `version` defaults to "1".
162
- */
163
- export function writeIntent(root, sections) {
164
- // used by its own test
165
- writeIn(root, "architecture-intent.json", `${JSON.stringify(sections, null, 2)}\n`);
166
- }
167
-
168
- /**
169
- * Writes one ADR record under `docs/adr/`, the shape `adr-registry.mjs` reads.
170
- * `record` is the frontmatter map (`{id, status, supersedes?, bindings?}`).
171
- */
172
- export function writeAdr(root, filename, record) {
173
- // used by its own test
174
- const lines = ["---", `id: ${record.id}`, `status: ${record.status}`];
175
- if (record.supersedes?.length) {
176
- lines.push("supersedes:");
177
- for (const target of record.supersedes) lines.push(` - ${target}`);
178
- }
179
- if (record.bindings?.length) {
180
- lines.push("bindings:");
181
- for (const binding of record.bindings) lines.push(` - ${binding}`);
182
- }
183
- lines.push("---", "", `# ${record.id}`, "");
184
- writeIn(root, join("docs/adr", filename), `${lines.join("\n")}\n`);
185
- }
186
-
187
- /**
188
- * Drives the CLI in-process over `cwd`, capturing streams. Returns the exit
189
- * code and joined `out`/`err`. `runCli` is the real entry point
190
- * (`../cli.mjs`), never a shell-out to a binary named `archkeep`.
191
- */
192
- export async function runEvolution(cwd, argv) {
193
- // used by its own test
194
- const out = [];
195
- const err = [];
196
- const exitCode = await runCli(argv, {
197
- out: (text) => out.push(text),
198
- err: (text) => err.push(text),
199
- cwd,
200
- });
201
- return { exitCode, out: out.join("\n"), err: err.join("\n") };
202
- }
203
-
204
- /**
205
- * Invokes `evolution --base <base> [--head <head>] [--event-out <dir>] [--format json]`.
206
- *
207
- * @param {string} base The base revision (full SHA).
208
- * @param {{head?: string, eventOut?: string, format?: string}} [options]
209
- */
210
- export function evolutionArgs(base, { head, eventOut, format = "json" } = {}) {
211
- // used by its own test
212
- const args = ["evolution", "--base", base];
213
- if (head) args.push("--head", head);
214
- if (eventOut) args.push("--event-out", eventOut);
215
- if (format) args.push("--format", format);
216
- return args;
217
- }
218
-
219
- /**
220
- * Parses the `--format json` envelope out of a successful evolution run.
221
- */
222
- export function parseEnvelope(run) {
223
- // used by its own test
224
- if (run.exitCode !== EXIT.ok) throw new Error(`evolution exited ${run.exitCode}: ${run.err}`);
225
- return JSON.parse(run.out);
226
- }
227
-
228
- /** The parsed event files in `dir`, in filename order. */
229
- export function readEvents(dir) {
230
- return readdirSync(dir)
231
- .filter((name) => name.endsWith(".json") && !name.endsWith(".json.tmp"))
232
- .sort()
233
- .map((name) => JSON.parse(readFileSync(join(dir, name), "utf8")));
234
- }
235
-
236
- /** The event store's file names in `dir`, in filename order. */
237
- export function eventFiles(dir) {
238
- // used by its own test
239
- return readdirSync(dir)
240
- .filter((name) => name.endsWith(".json") && !name.endsWith(".json.tmp"))
241
- .sort();
242
- }
243
-
244
- /** Removes a throwaway workspace. */
245
- export function dispose(root) {
246
- // used by its own test
247
- rmSync(root, { recursive: true, force: true });
248
- }