@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.
- package/cli.mjs +156 -66
- package/gate-attestation.mjs +23 -0
- package/package.json +3 -1
- package/src/analysis/analyze.mjs +6 -0
- package/src/analysis/contract.md +32 -5
- 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 +113 -0
- package/src/analysis/typescript.mjs +86 -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 +90 -40
- package/src/commands/check.mjs +65 -26
- package/src/commands/completeness.mjs +126 -19
- package/src/commands/context-command.mjs +13 -5
- package/src/commands/context.mjs +31 -4
- package/src/commands/coverage-verdict.mjs +191 -0
- package/src/commands/debt.mjs +18 -15
- package/src/commands/delta-classify.mjs +13 -18
- package/src/commands/delta-snapshot.mjs +13 -5
- package/src/commands/delta.mjs +95 -33
- package/src/commands/diff.mjs +31 -24
- package/src/commands/discover.mjs +70 -29
- package/src/commands/drift.mjs +21 -21
- package/src/commands/edge-constraints.mjs +47 -1
- package/src/commands/evaluation-primitives.mjs +194 -2
- package/src/commands/evolution.mjs +27 -10
- package/src/commands/explain.mjs +14 -13
- package/src/commands/fitness.mjs +20 -19
- package/src/commands/graph.mjs +29 -11
- package/src/commands/health.mjs +12 -5
- package/src/commands/history.mjs +41 -26
- package/src/commands/impact.mjs +17 -18
- package/src/commands/plan-context-command.mjs +10 -5
- package/src/commands/reconcile.mjs +14 -17
- package/src/commands/scenario-evaluation.mjs +93 -16
- package/src/commands/scenario.mjs +28 -18
- package/src/commands/waivers.mjs +36 -28
- package/src/governance/evolution-event.mjs +96 -9
- package/src/intent/intent-manifest.json +83 -39
- 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/report/json.mjs +32 -5
- package/src/report/text.mjs +82 -12
- package/src/verdict.mjs +78 -36
- package/src/verify-gate-attestation.mjs +323 -0
- 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`).
|
|
1027
|
-
//
|
|
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
|
-
: {
|
|
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`),
|
|
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
|
);
|