@ecoma-io/archkeep 0.21.0 → 0.22.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.
Files changed (53) hide show
  1. package/cli.mjs +156 -66
  2. package/gate-attestation.mjs +23 -0
  3. package/package.json +3 -1
  4. package/src/analysis/analyze.mjs +6 -0
  5. package/src/analysis/contract.md +32 -5
  6. package/src/analysis/csharp.mjs +18 -0
  7. package/src/analysis/go.mjs +18 -0
  8. package/src/analysis/java.mjs +15 -0
  9. package/src/analysis/kotlin.mjs +15 -0
  10. package/src/analysis/python.mjs +25 -3
  11. package/src/analysis/rust.mjs +18 -0
  12. package/src/analysis/source-util.mjs +113 -0
  13. package/src/analysis/typescript.mjs +86 -5
  14. package/src/canonical.mjs +43 -25
  15. package/src/commands/README.md +63 -12
  16. package/src/commands/change-intent.mjs +25 -1
  17. package/src/commands/change.mjs +90 -40
  18. package/src/commands/check.mjs +65 -26
  19. package/src/commands/completeness.mjs +126 -19
  20. package/src/commands/context-command.mjs +13 -5
  21. package/src/commands/context.mjs +31 -4
  22. package/src/commands/coverage-verdict.mjs +191 -0
  23. package/src/commands/debt.mjs +18 -15
  24. package/src/commands/delta-classify.mjs +13 -18
  25. package/src/commands/delta-snapshot.mjs +13 -5
  26. package/src/commands/delta.mjs +95 -33
  27. package/src/commands/diff.mjs +31 -24
  28. package/src/commands/discover.mjs +70 -29
  29. package/src/commands/drift.mjs +21 -21
  30. package/src/commands/edge-constraints.mjs +47 -1
  31. package/src/commands/evaluation-primitives.mjs +194 -2
  32. package/src/commands/evolution.mjs +27 -10
  33. package/src/commands/explain.mjs +14 -13
  34. package/src/commands/fitness.mjs +20 -19
  35. package/src/commands/graph.mjs +29 -11
  36. package/src/commands/health.mjs +12 -5
  37. package/src/commands/history.mjs +41 -26
  38. package/src/commands/impact.mjs +17 -18
  39. package/src/commands/plan-context-command.mjs +10 -5
  40. package/src/commands/reconcile.mjs +14 -17
  41. package/src/commands/scenario-evaluation.mjs +93 -16
  42. package/src/commands/scenario.mjs +28 -18
  43. package/src/commands/waivers.mjs +36 -28
  44. package/src/governance/evolution-event.mjs +96 -9
  45. package/src/intent/intent-manifest.json +83 -39
  46. package/src/lsp/diagnose.mjs +12 -3
  47. package/src/report/discover-text.mjs +31 -9
  48. package/src/report/graph-text.mjs +25 -5
  49. package/src/report/json.mjs +32 -5
  50. package/src/report/text.mjs +82 -12
  51. package/src/verdict.mjs +78 -36
  52. package/src/verify-gate-attestation.mjs +323 -0
  53. package/src/workspace.mjs +126 -2
@@ -351,6 +351,119 @@ export const refuseUnreadTree = (reader, failures) => {
351
351
  */
352
352
  export const isWholeFileFailure = (failure) => failure.line === null;
353
353
 
354
+ /**
355
+ * Whether a positioned failure is the declared dynamic limit rather than an
356
+ * unresolvable specifier.
357
+ *
358
+ * Both are permanent blind spots — a site the run saw and could not judge —
359
+ * and both are disclosed identically (`coverage.blindSpots`, the report's
360
+ * blind-spot section). They part ways at the verdict: an unresolvable LITERAL
361
+ * specifier is a concrete question the resolver was asked and could not answer
362
+ * (#595 — a missing workspace edge at site granularity, an uninstalled
363
+ * dependency), so it withholds the run's verdict; a non-literal
364
+ * `import()`/`require()` argument is the language itself declaring the target
365
+ * computed at runtime, which static analysis cannot answer in principle —
366
+ * every config loader that opens a consumer-named file contains one — so it
367
+ * is a declared limit the run states and moves past. Measured on this
368
+ * repository's own tree: ten such sites in config loaders, unfixable without
369
+ * giving up the feature that makes them config loaders.
370
+ *
371
+ * The field is set only by the TypeScript/Vue analyzer, the one analyzer whose
372
+ * language has the construct; every other analyzer's positioned failures are
373
+ * literal specifiers by construction and never set it.
374
+ *
375
+ * @param {{ line: number|null, dynamic?: true }} failure
376
+ * @returns {boolean}
377
+ */
378
+ export const isDynamicSiteFailure = (failure) => failure.dynamic === true;
379
+
380
+ /**
381
+ * Whether a positioned failure is an unresolvable literal pointing at the
382
+ * EXTERNAL dependency universe — the bare-package class that neither withholds
383
+ * the verdict nor may.
384
+ *
385
+ * A bare specifier (`vitest`, `zod`, a scoped package) resolves against an
386
+ * installed dependency tree, and a workspace without that tree — a fresh
387
+ * clone, a trimmed install, the native self-check's `git archive` copy, which
388
+ * by design carries no `node_modules` — is a normal state. Withholding the
389
+ * verdict over it would make the tree permanently un-green over dependencies
390
+ * nobody crossed: measured, that is exactly what this repository's own
391
+ * required boundary gate did under the unqualified flip, 284 such rows on the
392
+ * native face alone. The marker rides the row (the TypeScript/Vue analyzer
393
+ * sets it; `isExternalUnresolvable` holds the class line there — path-like,
394
+ * `#` subpath and `paths`-alias specifiers never get it, so a workspace-edge
395
+ * question can never masquerade as external), and every verdict lane counts
396
+ * by its absence, the same mechanism the `dynamic` marker uses.
397
+ *
398
+ * Every analyzer sets the field, and each holds the same class line as the
399
+ * TypeScript/Vue one: only a bare coordinate that genuinely names the
400
+ * language's dependency universe gets it (a Go module path outside the
401
+ * workspace modules, a Rust crate outside the workspace crates, a Python
402
+ * third-party top-level import outside the unmodelled gate, a JVM/C# dotted
403
+ * name no tracked package or namespace claims), and a specifier naming a
404
+ * declared workspace project never does — a workspace-edge question must not
405
+ * masquerade as external. What still withholds is everything that is NOT this
406
+ * class: a workspace-surface specifier, a Rust brace group, a Python relative
407
+ * import past the top-level package, an unmodelled layout, a split package
408
+ * (#603's per-language pins hold both directions).
409
+ *
410
+ * @param {{ line: number|null, external?: true }} failure
411
+ * @returns {boolean}
412
+ */
413
+ export const isExternalSiteFailure = (failure) => failure.external === true;
414
+
415
+ /**
416
+ * The count of positioned failures that WITHHOLD the run's verdict —
417
+ * unresolvable literal specifiers that reference the workspace's own surface,
418
+ * dynamic declared limits and external bare-package sites excluded.
419
+ *
420
+ * The one number every verdict lane and refusal guard reads (#595, narrowed):
421
+ * `check`'s `coverage.complete` and no-verdict lane, the refusal every
422
+ * descriptive and refuse-class command raises over an unjudged site, and the
423
+ * envelope builder's completeness law all read this class and nothing else.
424
+ * Centralized here so the class line cannot drift between the fifteen call
425
+ * sites the way a repeated inline filter would.
426
+ *
427
+ * @param {{ line: number|null, dynamic?: true, external?: true }[]} failures
428
+ * @returns {number}
429
+ */
430
+ export const unresolvableLiteralCount = (failures) =>
431
+ failures.filter(
432
+ (failure) =>
433
+ !isWholeFileFailure(failure) &&
434
+ !isDynamicSiteFailure(failure) &&
435
+ !isExternalSiteFailure(failure),
436
+ ).length;
437
+
438
+ /**
439
+ * The `coverage.blindSpots` rows every command's coverage block carries: one
440
+ * row per positioned failure, ALL permanent classes, the run's disclosure of
441
+ * every site it saw and did not judge.
442
+ *
443
+ * Disclosure is deliberately wider than the verdict's withholding: a dynamic
444
+ * or external site never flips an exit, but it is still named here — the
445
+ * fields that separate the classes ride the row, which is what lets the
446
+ * envelope builder's completeness law (`report/json.mjs`) tell a declared
447
+ * limit and an external bare package from unjudged work without a second
448
+ * classification.
449
+ *
450
+ * @param {{ sourceFile: string, line: number|null, column: number|null,
451
+ * reason: string, dynamic?: true, external?: true }[]} failures
452
+ * @returns {{ file: string, line: number, column: number, reason: string,
453
+ * dynamic?: true, external?: true }[]}
454
+ */
455
+ export const blindSpotRows = (failures) =>
456
+ failures
457
+ .filter((failure) => !isWholeFileFailure(failure))
458
+ .map(({ sourceFile, line, column, reason, dynamic, external }) => ({
459
+ file: sourceFile,
460
+ line,
461
+ column,
462
+ reason,
463
+ ...(dynamic ? { dynamic: true } : {}),
464
+ ...(external ? { external: true } : {}),
465
+ }));
466
+
354
467
  /**
355
468
  * One whole-file failure per source file, first reason kept.
356
469
  *
@@ -373,6 +373,57 @@ function namesDeclaredProject(specifier, workspace) {
373
373
  return workspace.projects.some((project) => project.name === name || project.name === specifier);
374
374
  }
375
375
 
376
+ /**
377
+ * Whether an unresolvable LITERAL specifier points at the external dependency
378
+ * universe rather than at the workspace's own governed surface — the row class
379
+ * that the loud-coverage contract (#595, narrowed) discloses without
380
+ * withholding the verdict, marked `external: true` on the failure.
381
+ *
382
+ * Three shapes always reference the workspace surface and never get the
383
+ * marker, so a run cannot go quiet over an edge the graph should carry:
384
+ *
385
+ * - a path-like specifier (`./`, `../`, `/`): it names a file in the
386
+ * workspace's own namespace, and failing to resolve it means that path
387
+ * cannot be proven to exist — a broken import, which is loud on purpose;
388
+ * - a `#` subpath import: package-internal by definition, never an external
389
+ * dependency. This is #595's own reported shape
390
+ * (`#canary-review/index.mjs`): `packageNameOf` keeps the `#` prefix, so
391
+ * the whole-file lane's name test cannot catch it, and without this branch
392
+ * the subpath would silently read as external;
393
+ * - a `paths` alias: the mapping declares the prefix workspace-internal, so
394
+ * a specifier under a failed alias is an unproven workspace edge. A `paths`
395
+ * key with `*` matches by its prefix (`@app/*` catches `@app/x/y`); an
396
+ * exact key matches exactly. (`baseUrl` alone is not consulted — its bare
397
+ * results are npm-shaped and read as external.)
398
+ *
399
+ * Everything else — `vitest`, `zod`, a scoped package no `paths` row claims —
400
+ * resolves against an installed dependency tree, and a workspace without that
401
+ * tree installed (a fresh clone, a trimmed install, the native self-check's
402
+ * `git archive` copy, which by design carries no `node_modules`) is a normal
403
+ * state. Those are the rows the native self-check's 284 blind spots are made
404
+ * of, and treating them as verdict-withholding would make that required CI
405
+ * step permanently exit 3 over dependencies nobody crossed.
406
+ *
407
+ * @param {string} specifier The raw specifier as written.
408
+ * @param {ts.CompilerOptions} options The resolution options in force.
409
+ * @returns {boolean}
410
+ */
411
+ function isExternalUnresolvable(specifier, options) {
412
+ if (
413
+ specifier.startsWith("./") ||
414
+ specifier.startsWith("../") ||
415
+ specifier.startsWith("/") ||
416
+ specifier.startsWith("#")
417
+ ) {
418
+ return false;
419
+ }
420
+ for (const key of Object.keys(options.paths ?? {})) {
421
+ const star = key.indexOf("*");
422
+ if (star === -1 ? key === specifier : specifier.startsWith(key.slice(0, star))) return false;
423
+ }
424
+ return true;
425
+ }
426
+
376
427
  /** The dialect to parse `sourceFile` as; `lang` wins when the caller knows it. */
377
428
  function scriptKindFor(sourceFile, lang) {
378
429
  if (lang) return SCRIPT_KIND_BY_LANG[lang] ?? ts.ScriptKind.TS;
@@ -784,7 +835,7 @@ function parseFailures(sourceFile, workspaceRelativePath) {
784
835
  * nested `node_modules/`, which every package that declares its own
785
836
  * `dependencies` has; the branch below says why, with the measurement.
786
837
  *
787
- * @returns {{ resolved: object|null, reason: string|null }}
838
+ * @returns {{ resolved: object|null, reason: string|null, external?: boolean }}
788
839
  */
789
840
  function resolveSpecifier(specifier, sourceFile, workspace) {
790
841
  const context = contextFor(workspace);
@@ -905,6 +956,12 @@ function resolveSpecifier(specifier, sourceFile, workspace) {
905
956
  if (sibling === null) {
906
957
  return {
907
958
  resolved: null,
959
+ // A failed literal is classified here, where the resolution options
960
+ // are in scope: `external: true` marks the bare-package class the
961
+ // contract discloses without withholding (`isExternalUnresolvable`
962
+ // holds the class line). The failure-push site forwards the flag to
963
+ // the row, and every verdict lane counts withholds from its absence.
964
+ external: isExternalUnresolvable(specifier, context.options),
908
965
  reason: `TypeScript cannot resolve '${specifier}' from '${sourceFile}'`,
909
966
  };
910
967
  }
@@ -992,7 +1049,7 @@ export function analyzeTypeScript({ sourceFile, text, workspace, lang }) {
992
1049
  // a grammar error there — and `import x = require(y)` is the form that
993
1050
  // makes `require` the right word for it.
994
1051
  const callee = site.callee ?? (site.kind === "dynamic" ? "import" : "require");
995
- const { resolved, reason } = site.literal
1052
+ const { resolved, reason, external } = site.literal
996
1053
  ? resolveSpecifier(site.specifier, sourceFile, workspace)
997
1054
  : {
998
1055
  resolved: null,
@@ -1023,13 +1080,37 @@ export function analyzeTypeScript({ sourceFile, text, workspace, lang }) {
1023
1080
  // site failures — the "blind spot" the contract documents as legitimately
1024
1081
  // permanent (`report/text.mjs`'s `formatFailures` is where the report
1025
1082
  // explains the two, and `cli.mjs` counts `unchecked` by
1026
- // `failure.line === null`). A NON-LITERAL argument keeps its line/column
1027
- // for the same reason: it is genuinely not statically knowable.
1083
+ // `failure.line === null`).
1084
+ //
1085
+ // The positioned rows then part ways by class, and the markers on the
1086
+ // row are what every verdict lane counts by:
1087
+ //
1088
+ // - a positioned literal that still references the workspace's own
1089
+ // surface — path-like, `#` subpath, or a `paths` alias — carries NO
1090
+ // marker and withholds the run's verdict (#595, narrowed): a `#`
1091
+ // subpath naming a declared project is exactly the shape #595
1092
+ // reported, and `packageNameOf` keeps the `#`, so the whole-file
1093
+ // lane's name test above cannot catch it;
1094
+ // - a positioned literal pointing at the bare-package universe carries
1095
+ // `external: true` (`isExternalUnresolvable` holds the class line)
1096
+ // and discloses without withholding — the native self-check's 284
1097
+ // bare rows are this class, and withholding them would make that
1098
+ // required CI step permanently exit 3;
1099
+ // - a NON-LITERAL argument keeps its line/column for the same reason:
1100
+ // it is genuinely not statically knowable, and carries
1101
+ // `dynamic: true` (contract.md).
1028
1102
  if (reason) {
1029
1103
  result.failures.push(
1030
1104
  site.literal && namesDeclaredProject(site.specifier, workspace)
1031
1105
  ? fileFailure(sourceFile, reason)
1032
- : { sourceFile, line: line + 1, column: character + 1, reason },
1106
+ : {
1107
+ sourceFile,
1108
+ line: line + 1,
1109
+ column: character + 1,
1110
+ reason,
1111
+ ...(site.literal ? {} : { dynamic: true }),
1112
+ ...(site.literal && external ? { external: true } : {}),
1113
+ },
1033
1114
  );
1034
1115
  }
1035
1116
  }
package/src/canonical.mjs CHANGED
@@ -11,11 +11,50 @@
11
11
  * correct.
12
12
  *
13
13
  * Used by `computePolicyFingerprint` (`../commands/graph.mjs`), the intent
14
- * fingerprint (`./intent-fingerprint.mjs`), and anything else a fingerprint
15
- * is computed over one canonicalizer, in one place, so two serializations
16
- * cannot drift.
14
+ * fingerprint (`./intent-fingerprint.mjs`), the evidence-snapshot serializer
15
+ * (`./commands/delta-snapshot.mjs`, through the exported replacer below), and
16
+ * anything else a fingerprint or a byte-deterministic file is produced from —
17
+ * one canonicalizer, in one place, so two serializations cannot drift.
17
18
  */
18
19
 
20
+ /**
21
+ * The `JSON.stringify` replacer that sorts plain-object keys at every depth.
22
+ *
23
+ * Exported beside `canonicalizeJson` so a serializer that needs a different
24
+ * `JSON.stringify` spacing can run the SAME rule rather than grow a second
25
+ * copy of it: `canonicalizeJson` passes this replacer at compact spacing, and
26
+ * `./commands/delta-snapshot.mjs`'s `serializeEvidenceSnapshot` passes it at
27
+ * two-space spacing, so both spellings sort identically by construction.
28
+ *
29
+ * @param {string} _key The key being visited; `JSON.stringify` calls the
30
+ * replacer once per key and once for the root with `""`.
31
+ * @param {*} current The value at that key.
32
+ * @returns {*} The value to serialize in its place — a key-sorted copy when
33
+ * `current` is a plain object, `current` itself otherwise.
34
+ */
35
+ export function canonicalJsonReplacer(_key, current) {
36
+ if (current !== null && typeof current === "object" && !Array.isArray(current)) {
37
+ // A null-prototype accumulator, not `{}`: `JSON.parse('{"__proto__":…}')`
38
+ // produces an OWN key literally named "__proto__" (JSON has no notion of
39
+ // prototypes), and `sorted[keyName] = …` on an ordinary object treats
40
+ // that one key specially — it sets the object's prototype instead of
41
+ // creating an own property, so the key silently vanishes from
42
+ // `JSON.stringify`'s output. Two documents that disagree only in a
43
+ // `__proto__` field would then canonicalize identically, a silent
44
+ // fingerprint collision (`../../../AGENTS.md`, "An empty result is a claim,
45
+ // not a shrug" — this is the same failure shape: two different inputs
46
+ // must never produce one indistinguishable output). `Object.create(null)`
47
+ // has no `__proto__` accessor to intercept the assignment, so every key
48
+ // — "__proto__" included — always becomes a real own property.
49
+ const sorted = Object.create(null);
50
+ for (const keyName of Object.keys(current).sort()) {
51
+ sorted[keyName] = current[keyName];
52
+ }
53
+ return sorted;
54
+ }
55
+ return current;
56
+ }
57
+
19
58
  /**
20
59
  * Serialize `value` with keys sorted at every object level.
21
60
  *
@@ -23,26 +62,5 @@
23
62
  * @returns {string} The canonical serialization.
24
63
  */
25
64
  export function canonicalizeJson(value) {
26
- return JSON.stringify(value, (key, current) => {
27
- if (current !== null && typeof current === "object" && !Array.isArray(current)) {
28
- // A null-prototype accumulator, not `{}`: `JSON.parse('{"__proto__":…}')`
29
- // produces an OWN key literally named "__proto__" (JSON has no notion of
30
- // prototypes), and `sorted[keyName] = …` on an ordinary object treats
31
- // that one key specially — it sets the object's prototype instead of
32
- // creating an own property, so the key silently vanishes from
33
- // `JSON.stringify`'s output. Two documents that disagree only in a
34
- // `__proto__` field would then canonicalize identically, a silent
35
- // fingerprint collision (`../../../AGENTS.md`, "An empty result is a claim,
36
- // not a shrug" — this is the same failure shape: two different inputs
37
- // must never produce one indistinguishable output). `Object.create(null)`
38
- // has no `__proto__` accessor to intercept the assignment, so every key
39
- // — "__proto__" included — always becomes a real own property.
40
- const sorted = Object.create(null);
41
- for (const keyName of Object.keys(current).sort()) {
42
- sorted[keyName] = current[keyName];
43
- }
44
- return sorted;
45
- }
46
- return current;
47
- });
65
+ return JSON.stringify(value, canonicalJsonReplacer);
48
66
  }
@@ -22,15 +22,18 @@ the resolution order.
22
22
  drift, a failing fitness gate, and a failing custom rule
23
23
  (`./custom-rules.mjs`). Exits 1 on any of them, and it is the only
24
24
  command holding all four exit codes
25
- ([which verbs carry exit 1 is settled in `docs/concepts/architecture.md`](../../../../docs/concepts/architecture.md)
26
- — `fitness` and `delta` are the other two).
25
+ ([which verbs carry exit 1 is settled in `docs/reference/exit-codes.md`](../../../../docs/reference/exit-codes.md)
26
+ — `fitness`, `delta`, `change` and `rules verify` are the other four).
27
27
 
28
28
  - **`graph`** (`./graph.mjs`'s `graphCommand`) — the project graph as a
29
29
  deterministic, serialisable snapshot: projects (with `targets` and `tags`) and
30
30
  dependencies, each as a flat sorted array. Strips internal fields
31
31
  (`mfeRemote`, `entryPoints`, `declaredPackages`). Includes
32
- `workspaceLayout`/`workspaceLayoutSource`. Refuses an Nx workspace with
33
- polyglot manifests but no plugin registration. Descriptive: never exits 1.
32
+ `workspaceLayout`/`workspaceLayoutSource`. Completeness comes from
33
+ `./coverage-verdict.mjs`'s shared constructor a whole-file failure, an
34
+ unjudged site, or a run that analyzed no file at all (#612) is the
35
+ no-verdict lane. Refuses an Nx workspace with polyglot manifests but no
36
+ plugin registration. Descriptive: never exits 1.
34
37
 
35
38
  - **`diff`** (`./diff.mjs`'s `diffCommand`) — two graph snapshots compared edge
36
39
  by edge. Takes a baseline file (not a git ref). When a boundary config is
@@ -56,8 +59,9 @@ the resolution order.
56
59
  incomplete head coverage, and an Nx workspace with polyglot manifests but no
57
60
  plugin registration; a policy-fingerprint change is a loud coverage note, not
58
61
  a refusal. A verdict, not a description: a non-waived introduced violation is
59
- a finding (exit 1 — then the third verb beside `check` and `fitness`; `change`
60
- and its declared-intent question arrived later as the fourth), an
62
+ a finding (exit 1 — one of the five verdict verbs
63
+ [`docs/reference/exit-codes.md`](../../../../docs/reference/exit-codes.md)
64
+ pins), an
61
65
  unclassifiable item is a no-verdict (exit 3), and a waived-introduced entry
62
66
  is reported without gating. Capture stays descriptive: never exits 1.
63
67
 
@@ -75,8 +79,9 @@ the resolution order.
75
79
  verdict, and the workspace-law axis it reports is informational — computed,
76
80
  labeled as evidence, never folded into the exit code, because `check`
77
81
  remains the authority on the law. Undeclared, unfulfilled, or a failed
78
- declared constraint is a finding (exit 1 — the fourth verb beside `check`,
79
- `fitness`, `delta`); an unproven base identity or an undeterminable
82
+ declared constraint is a finding (exit 1 — one of the five verdict verbs
83
+ [`docs/reference/exit-codes.md`](../../../../docs/reference/exit-codes.md)
84
+ pins); an unproven base identity or an undeterminable
80
85
  constraint is exit 3, and constraints are left unevaluated over a base the
81
86
  run cannot vouch for. Refuses a manifest that fails shape or reference
82
87
  validation, an unreadable/malformed/incomplete baseline, a provider
@@ -198,10 +203,13 @@ the resolution order.
198
203
  `../../src/governance/discovery-proposal.mjs`'s pure evaluator over it.
199
204
  Proposal-only: every candidate carries `proposed: true` and
200
205
  `notAuthoritative: true`, and the command never writes
201
- `architecture-intent.json`. Returns `status: "no-verdict"` (exit 3) over
202
- incomplete coverage and refuses `--propose` over it; refuses an Nx workspace
203
- with polyglot manifests but no plugin registration; a zero-project workspace
204
- is the empty `unknown` proposal, not a refusal. Descriptive: never exits 1.
206
+ `architecture-intent.json`. Completeness comes from
207
+ `./coverage-verdict.mjs`'s shared constructor a whole-file failure, an
208
+ unjudged site, or a run that analyzed no file at all (#619) is the
209
+ no-verdict lane. Returns `status: "no-verdict"` (exit 3) over incomplete
210
+ coverage and refuses `--propose` over it; refuses an Nx workspace with
211
+ polyglot manifests but no plugin registration; a zero-project workspace is
212
+ the empty `unknown` proposal, not a refusal. Descriptive: never exits 1.
205
213
 
206
214
  - **`drift`** (`./drift.mjs`'s `driftCommand`) — the observed architecture
207
215
  compared against the declared intended one. The intended side is the one
@@ -307,6 +315,49 @@ the resolution order.
307
315
  declaration carries is listed `unknown` — named, never hidden. Descriptive:
308
316
  never exits 1.
309
317
 
318
+ - **`scenario`** (`./scenario.mjs`'s `scenarioCommand`, with the scenario
319
+ grammar in `./scenario-evaluation.mjs`'s `parseScenarioInput`/`evaluateScenario`)
320
+ — a hypothetical change evaluated against the current workspace and compared
321
+ with its present impact. Virtual and read-only by construction: it never
322
+ mutates the workspace, never writes canonical history, never emits an
323
+ `EvolutionEvent`, and every output field carries `virtual: true` /
324
+ `notAuthoritative` — a what-if projection, never an authoritative verdict.
325
+ Refuses an Nx workspace with polyglot manifests but no plugin registration;
326
+ incomplete coverage withholds the evaluation as the structured
327
+ incomplete-coverage refusal (exit 3, no `result`). Descriptive: never exits 1.
328
+
329
+ - **`decisions`** (`./decisions.mjs`'s `decisionsCommand`) — the deterministic
330
+ chain behind one recorded decision: the decision, the governed rows that
331
+ stand on it (intent, constraint and fitness), the projects they govern, the
332
+ current evidence and findings, and the decision's verification level. It
333
+ composes the governance modules without owning any of them —
334
+ `../governance/decision-graph.mjs`'s `forwardDecision` walk,
335
+ `../governance/decision-fitness.mjs`'s verification level, and the row walk
336
+ `./provenance-command.mjs` exports, so it never holds a second copy of which
337
+ rows exist. A binding naming a declared fitness gate is judged against the
338
+ same snapshot the `fitness` command builds; one naming no declared gate
339
+ renders `unverifiable` — the registry alone asserts nothing, never a clean
340
+ pass. Refuses an unreadable registry; an unresolvable reference — the
341
+ positional `<id>` or any hop of the walk — is rendered as an unresolved block
342
+ (exit 3), never as a clean chain. Descriptive: never exits 1.
343
+
344
+ - **`rules`** (`./rules.mjs`'s `rulesListCommand`, `rulesInfoCommand`,
345
+ `rulesVerifyCommand` and `rulesAddCommand`) — the CLI face of the official
346
+ rules catalog (`@ecoma-io/archkeep-rules`): `list`, `info`, `verify` and
347
+ `add`. The catalog is read from the filesystem at a user-resolvable path
348
+ (explicit `--catalog`, then
349
+ `node_modules/@ecoma-io/archkeep-rules/catalog.json`), never by import, and
350
+ artifact integrity is validated through the engine's real host
351
+ (`../custom-rules/host.mjs`). `verify` is the one verdict verb: exit 1 when
352
+ the catalog's recorded digests disagree with the shipped bytes (a digest
353
+ mismatch, an artifact the host refuses, or one that escaped its directory),
354
+ exit 3 when the catalog could not be read — the two lanes `check` uses, so
355
+ "the artifact was modified" never reads as "the catalog could not be looked
356
+ at". `list` and `info` are always exit 0; `add` exits 0 on success and 3 on
357
+ any failure. Catalog-derived paths are contained to the catalog's own
358
+ directory (`../containment.mjs`), so data a consumer vendored cannot name a
359
+ path outside its tree. `check` never reads the catalog.
360
+
310
361
  ## Shared modules
311
362
 
312
363
  - **`snapshot-meta.mjs`** — `compareSnapshotMetadata`, shared by `diff` and
@@ -84,6 +84,30 @@ export const CONSTRAINT_ROW_NAMES = Object.freeze({
84
84
  noNewCycles: "no-new-cycles",
85
85
  });
86
86
 
87
+ /**
88
+ * The one spelling of a declared edge row's identity: the NUL-separated
89
+ * `(from, to)` project pair. Two sites must read one edge declaration as one
90
+ * fact — this module's duplicate rejection (the dedup key
91
+ * `sectionListViolations` carries rows by) and `./change.mjs`'s
92
+ * reconciliation (which matches a declared row against the observed graph's
93
+ * `{source, target}`) — so the string is built here and imported, never
94
+ * spelled twice. Two private spellings were the live defect this helper
95
+ * closes (#613): they produced the same bytes until a shape moved, which is
96
+ * byte-for-byte the silent direction — the reconciliation would stop
97
+ * recognizing declarations it was handed while every command-local test
98
+ * stayed green.
99
+ *
100
+ * The observed edge's `type` is deliberately not part of the pair: whether
101
+ * the graph emits a dependency as `static` or `dynamic` is the model's
102
+ * spelling, not the author's promise (`./change.mjs`, `reconcileMaterialDelta`).
103
+ *
104
+ * @param {{from: string, to: string}} row A validated edge row.
105
+ * @returns {string}
106
+ */
107
+ export function edgePairKey({ from, to }) {
108
+ return `${from}\u0000${to}`;
109
+ }
110
+
87
111
  /** A value's type, for an error message that shows what was actually there. */
88
112
  function describe(value) {
89
113
  if (Array.isArray(value)) return `an array (${JSON.stringify(value)})`;
@@ -280,7 +304,7 @@ export function findChangeIntentViolations(raw) {
280
304
  },
281
305
  identity: (entry) =>
282
306
  isPlainObject(entry) && nonEmptyString(entry.from) && nonEmptyString(entry.to)
283
- ? `${entry.from}\u0000${entry.to}`
307
+ ? edgePairKey(/** @type {{from: string, to: string}} */ (entry))
284
308
  : "",
285
309
  }),
286
310
  );