@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.
- package/gate-attestation.mjs +23 -0
- package/package.json +3 -1
- package/src/analysis/analyze.mjs +6 -0
- package/src/analysis/csharp.mjs +18 -0
- package/src/analysis/go.mjs +18 -0
- package/src/analysis/java.mjs +15 -0
- package/src/analysis/kotlin.mjs +15 -0
- package/src/analysis/python.mjs +25 -3
- package/src/analysis/rust.mjs +18 -0
- package/src/analysis/source-util.mjs +11 -5
- package/src/canonical.mjs +43 -25
- package/src/commands/README.md +63 -12
- package/src/commands/change-intent.mjs +25 -1
- package/src/commands/change.mjs +31 -12
- package/src/commands/coverage-verdict.mjs +11 -4
- package/src/commands/delta-snapshot.mjs +13 -5
- package/src/commands/discover.mjs +59 -38
- package/src/commands/graph.mjs +29 -20
- package/src/commands/history.mjs +12 -11
- package/src/governance/evolution-event.mjs +36 -2
- package/src/intent/intent-manifest.json +5 -5
- package/src/lsp/diagnose.mjs +12 -3
- package/src/report/discover-text.mjs +31 -9
- package/src/report/graph-text.mjs +25 -5
- package/src/verify-gate-attestation.mjs +323 -0
|
@@ -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.
|
|
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/",
|
package/src/analysis/analyze.mjs
CHANGED
|
@@ -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
|
/**
|
package/src/analysis/csharp.mjs
CHANGED
|
@@ -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({
|
package/src/analysis/go.mjs
CHANGED
|
@@ -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}`));
|
package/src/analysis/java.mjs
CHANGED
|
@@ -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
|
package/src/analysis/kotlin.mjs
CHANGED
|
@@ -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({
|
package/src/analysis/python.mjs
CHANGED
|
@@ -1243,10 +1243,15 @@ export function analyzePython({ sourceFile, text, workspace }) {
|
|
|
1243
1243
|
continue;
|
|
1244
1244
|
}
|
|
1245
1245
|
if (!site.literal) {
|
|
1246
|
-
|
|
1247
|
-
|
|
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 ` +
|
package/src/analysis/rust.mjs
CHANGED
|
@@ -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
|
-
*
|
|
399
|
-
*
|
|
400
|
-
*
|
|
401
|
-
*
|
|
402
|
-
*
|
|
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`),
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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,
|
|
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
|
}
|
package/src/commands/README.md
CHANGED
|
@@ -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/
|
|
26
|
-
— `fitness` and `
|
|
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`.
|
|
33
|
-
|
|
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 —
|
|
60
|
-
|
|
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
|
|
79
|
-
`
|
|
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`.
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
?
|
|
307
|
+
? edgePairKey(/** @type {{from: string, to: string}} */ (entry))
|
|
284
308
|
: "",
|
|
285
309
|
}),
|
|
286
310
|
);
|
package/src/commands/change.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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(
|
|
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(
|
|
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) =>
|
|
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) =>
|
|
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(
|
|
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(
|
|
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
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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
|