@ecoma-io/archkeep 0.22.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.
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The gate-attestation verifier's public face — what a consumer imports from
3
+ * this package's `./gate-attestation` subpath to validate an attestation with
4
+ * the version it actually installed.
5
+ *
6
+ * It holds no logic on purpose, the same bargain `./commands` (`commands.mjs`)
7
+ * and `./nx` (`nx.mjs`) make: a named entry that is a re-export, so the
8
+ * verifier can grow under `src/` without a second copy of any decision
9
+ * appearing beside it.
10
+ *
11
+ * ```js
12
+ * import { validateGateAttestation, readGateAttestation }
13
+ * from "@ecoma-io/archkeep/gate-attestation";
14
+ * ```
15
+ */
16
+ export {
17
+ ATTESTED_PACKAGE,
18
+ GATE_ATTESTATION_SCHEMA_VERSION,
19
+ readGateAttestation,
20
+ reachesCheckVerdict,
21
+ validateGateAttestation,
22
+ verifiedAdopters,
23
+ } from "./src/verify-gate-attestation.mjs";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ecoma-io/archkeep",
3
- "version": "0.22.0",
3
+ "version": "0.22.1",
4
4
  "description": "Architecture enforcement for polyglot repositories — dependency graphs and module boundaries for Go, Rust, Python, TypeScript, JavaScript, Vue, Java and Kotlin",
5
5
  "keywords": [
6
6
  "architecture",
@@ -29,6 +29,7 @@
29
29
  ".": "./index.mjs",
30
30
  "./nx": "./nx.mjs",
31
31
  "./commands": "./commands.mjs",
32
+ "./gate-attestation": "./gate-attestation.mjs",
32
33
  "./presets/*.json": "./presets/*.json",
33
34
  "./package.json": "./package.json"
34
35
  },
@@ -36,6 +37,7 @@
36
37
  "index.mjs",
37
38
  "nx.mjs",
38
39
  "commands.mjs",
40
+ "gate-attestation.mjs",
39
41
  "cli.mjs",
40
42
  "lsp.mjs",
41
43
  "src/",
@@ -121,6 +121,12 @@ export { LANGUAGE_BY_EXTENSION, languageOf };
121
121
  * the file as a whole rather than one position.
122
122
  * @property {number|null} column 1-based, or `null`.
123
123
  * @property {string} reason Human-readable; written to be read in a report.
124
+ * @property {true} [dynamic] The site's target is computed at runtime — the
125
+ * language itself declines a static answer. Disclosed without withholding
126
+ * (`isDynamicSiteFailure`).
127
+ * @property {true} [external] The site names the external dependency universe
128
+ * rather than the governed graph. Disclosed without withholding
129
+ * (`isExternalSiteFailure`); every analyzer sets it (#603).
124
130
  */
125
131
 
126
132
  /**
@@ -429,6 +429,24 @@ export function analyzeCSharp({ sourceFile, text, workspace }) {
429
429
  external: true,
430
430
  packageName: site.importableName,
431
431
  };
432
+ // The bare-coordinate class the contract discloses without
433
+ // withholding (#603): the namespace names the external dependency
434
+ // universe, not the governed graph, so the site is DISCLOSED — a
435
+ // positioned row carrying `external: true`
436
+ // (`isExternalSiteFailure`), the run's verdict untouched — rather
437
+ // than swallowed, the same classification the TypeScript analyzer
438
+ // already emits. A name a tracked namespace claims resolved through
439
+ // the index above and never reaches this branch; the split-package
440
+ // branch below keeps withholding. The `importableName === null`
441
+ // arm above is the extern-alias construct, not an unresolvable
442
+ // coordinate, and stays unfailed.
443
+ result.failures.push({
444
+ sourceFile,
445
+ line,
446
+ column,
447
+ reason: `C# cannot resolve '${site.importableName}' from '${sourceFile}'`,
448
+ external: true,
449
+ });
432
450
  } else if (resolved.ambiguous) {
433
451
  resolution = null;
434
452
  result.failures.push({
@@ -704,6 +704,24 @@ export function analyzeGo({ sourceFile, text, workspace }) {
704
704
  packageName: target === null ? site.specifier : null,
705
705
  },
706
706
  });
707
+ // The bare-module class the contract discloses without withholding
708
+ // (#603): the import names the external dependency universe, not the
709
+ // governed graph, so the site is DISCLOSED — a positioned row carrying
710
+ // `external: true` (`isExternalSiteFailure`), the run's verdict
711
+ // untouched — rather than swallowed. An empty blind-spot list must mean
712
+ // "nothing to disclose", and a workspace whose imports reach outside it
713
+ // has things to disclose. An import naming a declared module resolved
714
+ // above and never reaches this branch; the same classification the
715
+ // TypeScript analyzer already emits.
716
+ if (target === null) {
717
+ result.failures.push({
718
+ sourceFile,
719
+ line,
720
+ column,
721
+ reason: `Go cannot resolve '${site.specifier}' from '${sourceFile}'`,
722
+ external: true,
723
+ });
724
+ }
707
725
  }
708
726
  } catch (cause) {
709
727
  result.failures.push(fileFailure(sourceFile, `Go analysis failed: ${cause?.message ?? cause}`));
@@ -235,6 +235,21 @@ export function analyzeJava({ sourceFile, text, workspace }) {
235
235
  external: true,
236
236
  packageName: site.importableName,
237
237
  };
238
+ // The bare-coordinate class the contract discloses without withholding
239
+ // (#603): the dotted name names the external dependency universe, not
240
+ // the governed graph, so the site is DISCLOSED — a positioned row
241
+ // carrying `external: true` (`isExternalSiteFailure`), the run's
242
+ // verdict untouched — rather than swallowed, the same classification
243
+ // the TypeScript analyzer already emits. A name a tracked package
244
+ // prefix claims resolved through the index above and never reaches
245
+ // this branch; the split-package branch below keeps withholding.
246
+ result.failures.push({
247
+ sourceFile,
248
+ line,
249
+ column,
250
+ reason: `Java cannot resolve '${site.importableName}' from '${sourceFile}'`,
251
+ external: true,
252
+ });
238
253
  } else if (resolved.ambiguous) {
239
254
  // Split package: unresolvable by static reading, so `resolved` is
240
255
  // null WITH a positioned failure naming every claimant — the Python
@@ -184,6 +184,21 @@ export function analyzeKotlin({ sourceFile, text, workspace }) {
184
184
  external: true,
185
185
  packageName: site.importableName,
186
186
  };
187
+ // The bare-coordinate class the contract discloses without withholding
188
+ // (#603): the dotted name names the external dependency universe, not
189
+ // the governed graph, so the site is DISCLOSED — a positioned row
190
+ // carrying `external: true` (`isExternalSiteFailure`), the run's
191
+ // verdict untouched — rather than swallowed, the same classification
192
+ // the TypeScript analyzer already emits. A name a tracked package
193
+ // prefix claims resolved through the index above and never reaches
194
+ // this branch; the split-package branch below keeps withholding.
195
+ result.failures.push({
196
+ sourceFile,
197
+ line,
198
+ column,
199
+ reason: `Kotlin cannot resolve '${site.importableName}' from '${sourceFile}'`,
200
+ external: true,
201
+ });
187
202
  } else if (resolved.ambiguous) {
188
203
  resolution = null;
189
204
  result.failures.push({
@@ -1243,10 +1243,15 @@ export function analyzePython({ sourceFile, text, workspace }) {
1243
1243
  continue;
1244
1244
  }
1245
1245
  if (!site.literal) {
1246
- fail(
1247
- `dynamic import of '${site.specifier}' has a non-literal argument, ` +
1246
+ result.failures.push({
1247
+ sourceFile,
1248
+ line,
1249
+ column,
1250
+ reason:
1251
+ `dynamic import of '${site.specifier}' has a non-literal argument, ` +
1248
1252
  `so its target is not knowable statically`,
1249
- );
1253
+ dynamic: true,
1254
+ });
1250
1255
  continue;
1251
1256
  }
1252
1257
 
@@ -1293,6 +1298,23 @@ export function analyzePython({ sourceFile, text, workspace }) {
1293
1298
  external: true,
1294
1299
  packageName: absolute.split(".")[0],
1295
1300
  };
1301
+ // The bare-package class the contract discloses without withholding
1302
+ // (#603): every layout is modelled and the name still reaches no
1303
+ // tracked package, so the external answer is the language's, and the
1304
+ // site is DISCLOSED — a positioned row carrying `external: true`
1305
+ // (`isExternalSiteFailure`), the run's verdict untouched — rather
1306
+ // than swallowed, the same classification the TypeScript analyzer
1307
+ // already emits. The unmodelled branch above keeps withholding,
1308
+ // because there the external answer is NOT the language's; the
1309
+ // relative-import and ambiguous branches keep withholding because
1310
+ // those names the workspace's own surface.
1311
+ result.failures.push({
1312
+ sourceFile,
1313
+ line,
1314
+ column,
1315
+ reason: `Python cannot resolve '${site.specifier}' from '${sourceFile}'`,
1316
+ external: true,
1317
+ });
1296
1318
  } else if (resolution.ambiguous) {
1297
1319
  fail(
1298
1320
  `'${site.specifier}' resolves through the namespace package '${resolution.prefix}', which ` +
@@ -804,6 +804,24 @@ export function analyzeRust({ sourceFile, text, workspace }) {
804
804
  // so a `bannedExternalImports` glob is written against this form.
805
805
  packageName: target === null ? site.root : null,
806
806
  };
807
+ // The bare-crate class the contract discloses without withholding
808
+ // (#603): the `use` names the external dependency universe, not the
809
+ // governed graph, so the site is DISCLOSED — a positioned row carrying
810
+ // `external: true` (`isExternalSiteFailure`), the run's verdict
811
+ // untouched — rather than swallowed, the same classification the
812
+ // TypeScript analyzer already emits. A name a workspace crate declares
813
+ // resolved above and never reaches this branch; the brace-group
814
+ // malformation above keeps withholding, because there the name itself
815
+ // was never read.
816
+ if (target === null) {
817
+ result.failures.push({
818
+ sourceFile,
819
+ line,
820
+ column,
821
+ reason: `Rust cannot resolve '${site.specifier}' from '${sourceFile}'`,
822
+ external: true,
823
+ });
824
+ }
807
825
  }
808
826
  result.imports.push({
809
827
  sourceFile,
@@ -395,11 +395,17 @@ export const isDynamicSiteFailure = (failure) => failure.dynamic === true;
395
395
  * question can never masquerade as external), and every verdict lane counts
396
396
  * by its absence, the same mechanism the `dynamic` marker uses.
397
397
  *
398
- * Only the TypeScript/Vue analyzer sets the field today. The other analyzers'
399
- * unresolvable literals keep withholding including legitimately external
400
- * coordinates (a JVM package coordinate naming an uninstalled library)
401
- * which is the over-loud direction and is tracked as its own issue rather
402
- * than silently tolerated.
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).
403
409
  *
404
410
  * @param {{ line: number|null, external?: true }} failure
405
411
  * @returns {boolean}
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
  );
@@ -90,6 +90,7 @@ import { buildDependencies, buildProjects, computePolicyFingerprint } from "./gr
90
90
  import {
91
91
  CONSTRAINT_ORDER,
92
92
  CONSTRAINT_ROW_NAMES,
93
+ edgePairKey,
93
94
  findChangeIntentReferenceViolations,
94
95
  readChangeIntent,
95
96
  } from "./change-intent.mjs";
@@ -113,6 +114,7 @@ import { referenceTime } from "../governance/clock.mjs";
113
114
  import {
114
115
  classifyEvolution,
115
116
  declarationDigest,
117
+ escapeIdentityField,
116
118
  eventDedupeKey,
117
119
  eventId,
118
120
  EVOLUTION_EVENT_SCHEMA_VERSION,
@@ -151,13 +153,26 @@ function cmpFacts(a, b) {
151
153
  * The identity string a delta-classified violation entry carries into the
152
154
  * event's `findings` — the same identity fields the delta classification
153
155
  * emits (`messageId`, `sourceProject`, `target`), serialized deterministically
154
- * so the ref is stable across runs over the same transition.
156
+ * so the ref is stable across runs over the same transition. Fields are
157
+ * escaped through `escapeIdentityField`, so a `:` inside a project name no
158
+ * longer reads as the field separator and two distinct entries never share
159
+ * one id (#628). The sentinel is the ONE literal written unescaped: it is
160
+ * this function's own spelling for "absent", so a source project literally
161
+ * named `-` escapes to `\-` instead of merging with it — while an absent
162
+ * source project still spells exactly what earlier versions spelled, byte
163
+ * for byte.
164
+ *
165
+ * Exported as the ONE spelling of this id — the same arrangement
166
+ * `edgeEvolutionIdentity` holds for edge identity — so a consumer composing
167
+ * the command layer cannot grow a second one that drifts.
155
168
  *
156
169
  * @param {{messageId: string, sourceProject: string|null, target: string}} entry
157
170
  * @returns {string}
158
171
  */
159
- function violationFindingId(entry) {
160
- return `${entry.messageId}:${entry.sourceProject ?? "-"}:${entry.target}`;
172
+ export function violationFindingId(entry) {
173
+ const sourceProject =
174
+ entry.sourceProject == null ? "-" : escapeIdentityField(entry.sourceProject);
175
+ return `${escapeIdentityField(entry.messageId)}:${sourceProject}:${escapeIdentityField(entry.target)}`;
161
176
  }
162
177
 
163
178
  /**
@@ -237,9 +252,13 @@ function observedFrom(structural, meta) {
237
252
  export function reconcileMaterialDelta(intent, delta) {
238
253
  const addedProjectNames = new Set(intent.projects.add);
239
254
  const removedProjectNames = new Set(intent.projects.remove);
240
- const edgeKey = ({ from, to }) => `${from}\u0000${to}`;
241
- const addEdgeKeys = new Set(intent.edges.add.map(edgeKey));
242
- const removeEdgeKeys = new Set(intent.edges.remove.map(edgeKey));
255
+ // The declared row's identity is `./change-intent.mjs`'s `edgePairKey` — the
256
+ // ONE spelling, shared with the grammar's own duplicate detection. A local
257
+ // spelling here was the live defect (#613): it agreed byte-for-byte until a
258
+ // shape moved, and the reconciliation would then stop recognizing the
259
+ // declarations it was handed while every command-local test stayed green.
260
+ const addEdgeKeys = new Set(intent.edges.add.map(edgePairKey));
261
+ const removeEdgeKeys = new Set(intent.edges.remove.map(edgePairKey));
243
262
 
244
263
  /** @type {object[]} */
245
264
  const matched = [];
@@ -263,7 +282,7 @@ export function reconcileMaterialDelta(intent, delta) {
263
282
  to: edge.target,
264
283
  type: edge.type,
265
284
  });
266
- if (addEdgeKeys.has(edgeKey({ from: edge.source, to: edge.target }))) matched.push(row);
285
+ if (addEdgeKeys.has(edgePairKey({ from: edge.source, to: edge.target }))) matched.push(row);
267
286
  else unexpected.push(row);
268
287
  }
269
288
  for (const edge of delta.removedEdges) {
@@ -273,7 +292,7 @@ export function reconcileMaterialDelta(intent, delta) {
273
292
  to: edge.target,
274
293
  type: edge.type,
275
294
  });
276
- if (removeEdgeKeys.has(edgeKey({ from: edge.source, to: edge.target }))) matched.push(row);
295
+ if (removeEdgeKeys.has(edgePairKey({ from: edge.source, to: edge.target }))) matched.push(row);
277
296
  else unexpected.push(row);
278
297
  }
279
298
  // No declaration surface in this version: a metadata change to a project
@@ -292,10 +311,10 @@ export function reconcileMaterialDelta(intent, delta) {
292
311
  const observedAdded = new Set(delta.addedProjects.map((p) => p.name));
293
312
  const observedRemoved = new Set(delta.removedProjects.map((p) => p.name));
294
313
  const observedAddEdges = new Set(
295
- delta.addedEdges.map((edge) => edgeKey({ from: edge.source, to: edge.target })),
314
+ delta.addedEdges.map((edge) => edgePairKey({ from: edge.source, to: edge.target })),
296
315
  );
297
316
  const observedRemoveEdges = new Set(
298
- delta.removedEdges.map((edge) => edgeKey({ from: edge.source, to: edge.target })),
317
+ delta.removedEdges.map((edge) => edgePairKey({ from: edge.source, to: edge.target })),
299
318
  );
300
319
 
301
320
  // Declared but never observed — the unfulfilled half, kept apart from
@@ -313,12 +332,12 @@ export function reconcileMaterialDelta(intent, delta) {
313
332
  }
314
333
  }
315
334
  for (const edge of intent.edges.add) {
316
- if (!observedAddEdges.has(edgeKey(edge))) {
335
+ if (!observedAddEdges.has(edgePairKey(edge))) {
317
336
  missingExpected.push(factRow({ kind: "edge-added", from: edge.from, to: edge.to }));
318
337
  }
319
338
  }
320
339
  for (const edge of intent.edges.remove) {
321
- if (!observedRemoveEdges.has(edgeKey(edge))) {
340
+ if (!observedRemoveEdges.has(edgePairKey(edge))) {
322
341
  missingExpected.push(factRow({ kind: "edge-removed", from: edge.from, to: edge.to }));
323
342
  }
324
343
  }
@@ -13,10 +13,17 @@
13
13
  * the shared construction the throw family now goes through, so the two
14
14
  * families cannot drift again:
15
15
  *
16
- * - `coverageVerdict` computes the one completeness the envelope law
17
- * (`../report/json.mjs`) already enforces no whole-file failure, no
18
- * unjudged blind spot, at least one file analyzed and the status/exit
19
- * pair that completeness implies. The failure classes are read through
16
+ * - `coverageVerdict` computes the one completeness over three axes — no
17
+ * whole-file failure, no unjudged blind spot, at least one file analyzed
18
+ * and the status/exit pair that completeness implies. The first two axes
19
+ * ride the envelope law (`../report/json.mjs`), and there only in ONE
20
+ * direction: the envelope refuses `complete: true` over a non-empty
21
+ * `notAnalyzed` list or an unjudged blind-spot row, and deliberately
22
+ * allows `complete: false` with both lists empty, so a run that judged
23
+ * nothing is able to say so. The third axis — `analyzed > 0` — is the one
24
+ * this constructor adds on top (#599, #612, #619): the envelope has no
25
+ * zero-analysis refusal by design, which is WHY this constructor exists.
26
+ * The failure classes are read through
20
27
  * `../analysis/source-util.mjs`'s classifiers, never an inline filter: the
21
28
  * class line is theirs alone to own.
22
29
  * - `coverageRefusal` builds the refusal result itself: the envelope through