@ecoma-io/archkeep 0.24.1 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/cli.mjs +166 -506
  2. package/commands.mjs +7 -1
  3. package/package.json +1 -1
  4. package/src/architecture-intent/judge.mjs +1 -1
  5. package/src/architecture-intent/model.mjs +34 -0
  6. package/src/canonical.mjs +2 -1
  7. package/src/commands/adr-for-workspace.mjs +63 -0
  8. package/src/commands/analyze-capability.mjs +13 -0
  9. package/src/commands/change-intent.mjs +11 -0
  10. package/src/commands/change.mjs +315 -55
  11. package/src/commands/check-capability.mjs +13 -0
  12. package/src/commands/check.mjs +13 -3
  13. package/src/commands/compare-capability.mjs +20 -0
  14. package/src/commands/completeness.mjs +12 -0
  15. package/src/commands/context-command.mjs +31 -0
  16. package/src/commands/coverage-verdict.mjs +15 -2
  17. package/src/commands/debt.mjs +30 -0
  18. package/src/commands/decisions.mjs +59 -10
  19. package/src/commands/delta-snapshot.mjs +25 -4
  20. package/src/commands/delta.mjs +301 -67
  21. package/src/commands/diff.mjs +48 -9
  22. package/src/commands/discover.mjs +37 -0
  23. package/src/commands/drift.mjs +45 -0
  24. package/src/commands/evolution.mjs +22 -2
  25. package/src/commands/explain-capability.mjs +11 -0
  26. package/src/commands/explain.mjs +24 -0
  27. package/src/commands/fitness.mjs +119 -10
  28. package/src/commands/govern-capability.mjs +16 -0
  29. package/src/commands/graph.mjs +39 -0
  30. package/src/commands/health.mjs +34 -0
  31. package/src/commands/history.mjs +119 -2
  32. package/src/commands/impact.mjs +23 -0
  33. package/src/commands/inspect-capability.mjs +16 -0
  34. package/src/commands/plan-context-command.mjs +35 -0
  35. package/src/commands/policy.mjs +42 -4
  36. package/src/commands/provenance-command.mjs +15 -24
  37. package/src/commands/report.mjs +38 -0
  38. package/src/commands/rules-capability.mjs +16 -0
  39. package/src/commands/rules.mjs +3 -1
  40. package/src/commands/scenario.mjs +23 -0
  41. package/src/commands/snapshot-meta.mjs +33 -0
  42. package/src/commands/trajectory.mjs +35 -0
  43. package/src/commands/waivers.mjs +30 -0
  44. package/src/corpus/goldens/.gitkeep +0 -0
  45. package/src/corpus/goldens/adr.json +62 -0
  46. package/src/corpus/goldens/adr.text +6 -0
  47. package/src/corpus/goldens/change.json +98 -0
  48. package/src/corpus/goldens/change.text +6 -0
  49. package/src/corpus/goldens/check.json +90 -0
  50. package/src/corpus/goldens/check.sarif +479 -0
  51. package/src/corpus/goldens/check.text +9 -0
  52. package/src/corpus/goldens/context.json +47 -0
  53. package/src/corpus/goldens/context.text +6 -0
  54. package/src/corpus/goldens/debt.json +59 -0
  55. package/src/corpus/goldens/debt.text +7 -0
  56. package/src/corpus/goldens/decisions.json +74 -0
  57. package/src/corpus/goldens/decisions.text +7 -0
  58. package/src/corpus/goldens/delta.json +94 -0
  59. package/src/corpus/goldens/delta.sarif +472 -0
  60. package/src/corpus/goldens/delta.text +5 -0
  61. package/src/corpus/goldens/diff.json +58 -0
  62. package/src/corpus/goldens/diff.text +4 -0
  63. package/src/corpus/goldens/discover.json +77 -0
  64. package/src/corpus/goldens/discover.text +10 -0
  65. package/src/corpus/goldens/drift.json +43 -0
  66. package/src/corpus/goldens/drift.text +3 -0
  67. package/src/corpus/goldens/evolution.json +328 -0
  68. package/src/corpus/goldens/evolution.text +32 -0
  69. package/src/corpus/goldens/explain.json +60 -0
  70. package/src/corpus/goldens/explain.text +7 -0
  71. package/src/corpus/goldens/fitness.json +45 -0
  72. package/src/corpus/goldens/fitness.text +3 -0
  73. package/src/corpus/goldens/graph.json +78 -0
  74. package/src/corpus/goldens/graph.text +10 -0
  75. package/src/corpus/goldens/health.json +72 -0
  76. package/src/corpus/goldens/health.text +10 -0
  77. package/src/corpus/goldens/help.text +104 -0
  78. package/src/corpus/goldens/history.json +43 -0
  79. package/src/corpus/goldens/history.text +4 -0
  80. package/src/corpus/goldens/impact.json +327 -0
  81. package/src/corpus/goldens/impact.text +10 -0
  82. package/src/corpus/goldens/provenance.json +453 -0
  83. package/src/corpus/goldens/provenance.text +14 -0
  84. package/src/corpus/goldens/reconcile.json +168 -0
  85. package/src/corpus/goldens/reconcile.text +3 -0
  86. package/src/corpus/goldens/report.json +168 -0
  87. package/src/corpus/goldens/report.text +42 -0
  88. package/src/corpus/goldens/rules verify.json +0 -0
  89. package/src/corpus/goldens/rules verify.text +0 -0
  90. package/src/corpus/goldens/scenario.json +441 -0
  91. package/src/corpus/goldens/scenario.text +28 -0
  92. package/src/corpus/goldens/trajectory.json +83 -0
  93. package/src/corpus/goldens/trajectory.text +10 -0
  94. package/src/corpus/goldens/usage-error.text +105 -0
  95. package/src/corpus/goldens/waivers.json +40 -0
  96. package/src/corpus/goldens/waivers.text +1 -0
  97. package/src/corpus/goldens-lsp/initialize.result.json +15 -0
  98. package/src/corpus/goldens-lsp/publish-cleared-empty.json +4 -0
  99. package/src/corpus/goldens-lsp/publish-fixed-empty.json +5 -0
  100. package/src/corpus/goldens-lsp/publish-unparseable.json +54 -0
  101. package/src/corpus/goldens-lsp/publish-violation.json +22 -0
  102. package/src/corpus/goldens-lsp/register-watched-files.json +72 -0
  103. package/src/custom-rules/values.mjs +1 -4
  104. package/src/go-work.mjs +8 -23
  105. package/src/governance/evolution-event.mjs +15 -0
  106. package/src/governance/evolution-store.mjs +59 -31
  107. package/src/governance/fitness-registry.mjs +14 -1
  108. package/src/governance/provenance-record.mjs +0 -152
  109. package/src/governance/verdict.mjs +62 -7
  110. package/src/intent/intent-manifest.json +22 -16
  111. package/src/lsp/server.mjs +5 -8
  112. package/src/lsp/workspace-index.mjs +55 -220
  113. package/src/options.mjs +10 -0
  114. package/src/providers/native/discover.mjs +13 -12
  115. package/src/providers/native/model.mjs +8 -3
  116. package/src/providers/nx-static.mjs +231 -0
  117. package/src/report/json.mjs +7 -4
  118. package/src/report/sarif.mjs +8 -3
  119. package/src/rules/edge-constraints.mjs +9 -0
  120. package/src/rules/index.mjs +26 -10
  121. package/src/rules/messages.mjs +64 -14
  122. package/src/rules/specifiers.mjs +21 -20
  123. package/src/tsconfig-paths.mjs +8 -14
  124. package/src/values.mjs +11 -0
  125. package/src/verdict.mjs +127 -16
@@ -0,0 +1,83 @@
1
+ {
2
+ "schemaVersion": 2,
3
+ "tool": {
4
+ "name": "@ecoma-io/archkeep",
5
+ "version": "0.26.0"
6
+ },
7
+ "command": "trajectory",
8
+ "workspace": {
9
+ "root": "/tmp/archkeep-golden-fixture",
10
+ "provider": "native",
11
+ "marker": "archkeep.json",
12
+ "provenance": {
13
+ "commit": "1fd51709c377d99a6139891e1200291cf08aede9",
14
+ "remote": null,
15
+ "dirty": false
16
+ }
17
+ },
18
+ "status": "ok",
19
+ "exitCode": 0,
20
+ "coverage": {
21
+ "complete": true,
22
+ "projects": 3,
23
+ "analyzedFiles": 3,
24
+ "imports": 2,
25
+ "notAnalyzed": [],
26
+ "blindSpots": [],
27
+ "notes": [
28
+ "counts are snapshot-relative: 1 observation are stored graph snapshots — capture points, not commits, days, or captures attempted",
29
+ "rule-impact cannot be recomputed from stored snapshots — snapshots carry the graph and the policy fingerprint, not the constraint table or import sites, so no violation-level trajectory is reported. Run `delta` between two live points, or `check` at any commit."
30
+ ]
31
+ },
32
+ "result": {
33
+ "dir": "/tmp/archkeep-golden-fixture/.archkeep-history",
34
+ "observations": {
35
+ "count": 1,
36
+ "basis": "graph_snapshots",
37
+ "first": "0001-71341a04.json",
38
+ "last": "0001-71341a04.json",
39
+ "withProvenance": 1,
40
+ "dirtyProvenance": 0
41
+ },
42
+ "available": false,
43
+ "unavailableReason": "insufficient_history",
44
+ "transitions": {
45
+ "count": 0,
46
+ "architecture": 0,
47
+ "policy": 0,
48
+ "provider": 0,
49
+ "codeDrift": 0,
50
+ "incomparable": 0,
51
+ "unchanged": 0
52
+ },
53
+ "disclosures": {
54
+ "policyOneSided": 0,
55
+ "provenanceOneSided": 0,
56
+ "crossRepo": 0
57
+ },
58
+ "projects": {
59
+ "first": 3,
60
+ "current": 3,
61
+ "delta": null,
62
+ "addedEvents": null,
63
+ "removedEvents": null,
64
+ "changedEvents": null,
65
+ "introduced": null,
66
+ "resolved": null,
67
+ "persistent": null
68
+ },
69
+ "edges": {
70
+ "first": 2,
71
+ "current": 2,
72
+ "delta": null,
73
+ "addedEvents": null,
74
+ "removedEvents": null,
75
+ "changedEvents": null,
76
+ "introduced": null,
77
+ "resolved": null,
78
+ "persistent": null
79
+ },
80
+ "trends": null
81
+ }
82
+ }
83
+
@@ -0,0 +1,10 @@
1
+ trajectory /tmp/archkeep-golden-fixture/.archkeep-history
2
+ 1 observation (graph_snapshots), 0 transitions
3
+ ✖ insufficient_history: a trajectory needs at least two observations — derived values are unavailable, not zero
4
+ signals architecture 0 · policy 0 · provider 0 · code drift 0 · incomparable 0 · unchanged 0
5
+ projects first 3 · current 3 · delta n/a · added n/a · removed n/a · changed n/a · introduced n/a · resolved n/a · persistent n/a
6
+ edges first 2 · current 2 · delta n/a · added n/a · removed n/a · introduced n/a · resolved n/a · persistent n/a
7
+ disclosures policy incomparable 0 · provenance incomparable 0 · cross-repo 0 · dirty captures 0 · with provenance 1
8
+ trends n/a
9
+ counts are snapshot-relative: 1 observation are stored graph snapshots — capture points, not commits, days, or captures attempted
10
+ rule-impact cannot be recomputed from stored snapshots — snapshots carry the graph and the policy fingerprint, not the constraint table or import sites, so no violation-level trajectory is reported. Run `delta` between two live points, or `check` at any commit.
@@ -0,0 +1,105 @@
1
+ archkeep: no command given.
2
+ archkeep — module-boundary enforcement across every language in the workspace
3
+
4
+ Usage:
5
+ archkeep check [<path>...] Check imports against the boundary rules
6
+ archkeep graph Print the project graph as a deterministic snapshot
7
+ archkeep diff <baseline> Compare two graph snapshots edge by edge
8
+ archkeep delta <baseline> | --capture Classify how boundary violations moved between a captured baseline and head
9
+ archkeep change <baseline> --intent <file> Reconcile a declared change intent against the architectural delta
10
+ archkeep discover [--propose] Report observed facts, and optionally propose candidate architecture
11
+ archkeep drift Compare the observed architecture against the declared intent
12
+ archkeep reconcile Compare the declared intent against the observed architecture
13
+ archkeep waivers List the boundary waivers and permanent suppressions on the table
14
+ archkeep fitness Judge every declared fitness function against the workspace
15
+ archkeep history <dir> Describe how the architecture evolved across snapshots
16
+ archkeep trajectory <dir> Aggregate the deterministic drift trajectory across snapshots
17
+ archkeep evolution Describe how the architecture evolved across a Git revision range
18
+ archkeep health [<snapshot-dir>] Describe architecture health metrics and trends
19
+ archkeep report [<snapshot-dir>] One governance document: how healthy the architecture is, and why
20
+ archkeep debt <dir> Print the architecture-debt ledger across snapshots
21
+ archkeep impact <project> List projects that depend on the named project
22
+ archkeep scenario <project> Evaluate a hypothetical change against the current workspace
23
+ archkeep explain <file:line:column> Explain the judgment for one import site
24
+ archkeep context <project> [--plan [<path>...]] Show the architecture constraints that apply to a project
25
+ archkeep provenance Describe where this run's facts came from and which rows carry an origin
26
+ archkeep decisions <id> Walk the full chain behind one recorded decision — decision to bound rows, projects, findings, and its verification level
27
+ archkeep adr [<id>] List recorded architecture decisions and what each binds
28
+ archkeep rules <list|info|verify|add> [<rule-name>] List official rules, show details, verify catalog integrity, or add a rule
29
+ archkeep --help Show this message
30
+
31
+ Options:
32
+ --format text|sarif|json Terminal report (default), SARIF 2.1.0 for GitHub
33
+ code scanning, or the versioned JSON envelope
34
+ docs/reference/json-output.md documents
35
+ --output <file> Write the report to a file instead of stdout
36
+ --config <file> Read the boundary law from here instead of
37
+ <workspace root>/module-boundaries.config.mjs
38
+ --evidence-out <dir> Also write each custom rule's evidence bundle
39
+ into this existing directory, as <rule>.json —
40
+ the exact document the rule was judged over
41
+ --capture Print an evidence snapshot of the current tree
42
+ (raw import records, graph, coverage, policy
43
+ fingerprint) for a later delta run to compare against.
44
+ Without --output, the snapshot goes to stdout.
45
+ --event-out <dir> Append the delta's evolution event to this directory
46
+ (one canonical record per transition; idempotent — a
47
+ rerun over the same transition writes nothing new).
48
+ Absent: no event file is written
49
+ --intent <file> The change-intent manifest declaring the material
50
+ architectural consequences this change expects
51
+ (required; see docs/usage/change.md)
52
+ --propose Also derive candidate components, boundaries, tags and rules from
53
+ the observed facts — every candidate marked proposed and not
54
+ authoritative; nothing is ever written
55
+ --write-intent <file> Write the proposed components and rules to a valid
56
+ architecture-intent.json file. Only valid with --propose;
57
+ refuses to overwrite a file that already exists — the file
58
+ is a candidate for drift/reconcile and must be reviewed
59
+ before use
60
+ --base <rev> The baseline revision — a commit, branch, tag,
61
+ or HEAD~n; the first revision analyzed
62
+ --head <rev> The tip revision (default HEAD); must be a
63
+ linear descendant of --base with no merges between
64
+ --events <dir> Link the evolution event store in <dir> so debt entries carry
65
+ introducedBy/resolvedBy refs and a resolved list
66
+ --scenario-file <file> Path to the scenario JSON file describing the
67
+ hypothetical changes to evaluate
68
+ --plan Request the agent planning context: current
69
+ architecture, applicable policy (with Intent),
70
+ impact, current violations, drift, coverage, and
71
+ the commands that verify the change. Trailing
72
+ paths scope the change; deterministic and
73
+ never an LLM plan — Archkeep produces facts,
74
+ agents produce plans.
75
+ --history-dir <dir> Path to the workspace's history directory. When given and
76
+ the directory holds archived snapshots, the planning context
77
+ includes the architecture-debt snapshot: current violations,
78
+ exemptions, and gaps aged across the history. Used only with
79
+ `--plan`; ignored otherwise.
80
+ --catalog <path> Path to the catalog file
81
+ (default: node_modules/@ecoma-io/archkeep-rules/catalog.json)
82
+ --to <dir> Target directory for the .wasm file
83
+ (default: tools/rules/)
84
+
85
+ Projects and tags come from the project graph (archkeep.json or Nx); the rules come from
86
+ module-boundaries.config.mjs at the workspace root — the same table ESLint
87
+ reads, so both enforcers answer from one source. That filename is a
88
+ convention and can be changed per workspace: through archkeep.json's
89
+ `boundaryConfig` field, or the integration's `boundaryConfig` option in nx.json.
90
+
91
+ Naming paths scopes the run to those files. That is a fast local pre-check and
92
+ not the gate: the cycle and lazy-load rules judge the file graph as a whole, so
93
+ a scoped run can miss what a whole-workspace run would find.
94
+
95
+ A workspace with a go.work at its root also has its use list compared against
96
+ every project's go.mod, whatever paths scope the run — a module in one list
97
+ and not the other means a developer's go build and CI build different trees.
98
+
99
+ A workspace whose tsconfig declares a paths table also has each alias judged
100
+ for life: an alias whose every target points into directories that do not
101
+ exist resolves no import, so it fails the run the way a violation does. The
102
+ table itself is never re-resolved — the check reads the same parsed tsconfig
103
+ the import resolver uses.
104
+
105
+ Exit codes: 0 clean · 1 findings (violations, go.work drift, dead path aliases) · 2 usage error · 3 no verdict (a file could not be analyzed, or the run could not start)
@@ -0,0 +1,40 @@
1
+ {
2
+ "schemaVersion": 2,
3
+ "tool": {
4
+ "name": "@ecoma-io/archkeep",
5
+ "version": "0.26.0"
6
+ },
7
+ "command": "waivers",
8
+ "workspace": {
9
+ "root": "/tmp/archkeep-golden-fixture",
10
+ "provider": "native",
11
+ "marker": "archkeep.json",
12
+ "provenance": {
13
+ "commit": "1fd51709c377d99a6139891e1200291cf08aede9",
14
+ "remote": null,
15
+ "dirty": false
16
+ }
17
+ },
18
+ "status": "ok",
19
+ "exitCode": 0,
20
+ "coverage": {
21
+ "complete": true,
22
+ "projects": 3,
23
+ "analyzedFiles": 3,
24
+ "imports": 2,
25
+ "notAnalyzed": [],
26
+ "blindSpots": [],
27
+ "notes": [
28
+ "remainingMs is the wall clock at the moment of this run, not a fact about the workspace — it is expected to differ between two runs of an unchanged tree and should be excluded from any diff or hash meant to detect real change. Every other field here is deterministic given the same law and the same tree."
29
+ ]
30
+ },
31
+ "result": {
32
+ "waivers": [],
33
+ "covered": 0,
34
+ "expired": 0,
35
+ "stale": 0,
36
+ "suppressions": [],
37
+ "suppressed": 0
38
+ }
39
+ }
40
+
@@ -0,0 +1 @@
1
+ no waivers — every boundary is enforced, nothing is being accepted temporarily or permanently
@@ -0,0 +1,15 @@
1
+ {
2
+ "capabilities": {
3
+ "textDocumentSync": {
4
+ "change": 1,
5
+ "openClose": true,
6
+ "save": {
7
+ "includeText": false
8
+ }
9
+ }
10
+ },
11
+ "serverInfo": {
12
+ "name": "archkeep",
13
+ "version": "<version>"
14
+ }
15
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "diagnostics": [],
3
+ "uri": "file:///tmp/archkeep-lsp-golden-fixture/libs/inner/main.go"
4
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "diagnostics": [],
3
+ "uri": "file:///tmp/archkeep-lsp-golden-fixture/libs/inner/main.go",
4
+ "version": 2
5
+ }
@@ -0,0 +1,54 @@
1
+ {
2
+ "diagnostics": [
3
+ {
4
+ "code": "analysisFailure",
5
+ "message": "Module boundaries were not fully checked here: parse error: ',' expected.. Imports this server could not read are not covered by the verdict below.",
6
+ "range": {
7
+ "end": {
8
+ "character": 15,
9
+ "line": 0
10
+ },
11
+ "start": {
12
+ "character": 15,
13
+ "line": 0
14
+ }
15
+ },
16
+ "severity": 2,
17
+ "source": "archkeep"
18
+ },
19
+ {
20
+ "code": "analysisFailure",
21
+ "message": "Module boundaries were not fully checked here: parse error: Expression expected.. Imports this server could not read are not covered by the verdict below.",
22
+ "range": {
23
+ "end": {
24
+ "character": 21,
25
+ "line": 1
26
+ },
27
+ "start": {
28
+ "character": 21,
29
+ "line": 1
30
+ }
31
+ },
32
+ "severity": 2,
33
+ "source": "archkeep"
34
+ },
35
+ {
36
+ "code": "analysisFailure",
37
+ "message": "Module boundaries were not fully checked here: TypeScript cannot resolve './thing' from 'libs/inner/broken.ts'. Imports this server could not read are not covered by the verdict below.",
38
+ "range": {
39
+ "end": {
40
+ "character": 22,
41
+ "line": 0
42
+ },
43
+ "start": {
44
+ "character": 20,
45
+ "line": 0
46
+ }
47
+ },
48
+ "severity": 2,
49
+ "source": "archkeep"
50
+ }
51
+ ],
52
+ "uri": "file:///tmp/archkeep-lsp-golden-fixture/libs/inner/broken.ts",
53
+ "version": 1
54
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "diagnostics": [
3
+ {
4
+ "code": "onlyTagsConstraintViolation",
5
+ "message": "A project tagged with \"zone:inner\" can only depend on libs tagged with \"zone:inner\"",
6
+ "range": {
7
+ "end": {
8
+ "character": 27,
9
+ "line": 3
10
+ },
11
+ "start": {
12
+ "character": 1,
13
+ "line": 3
14
+ }
15
+ },
16
+ "severity": 1,
17
+ "source": "archkeep"
18
+ }
19
+ ],
20
+ "uri": "file:///tmp/archkeep-lsp-golden-fixture/libs/inner/main.go",
21
+ "version": 1
22
+ }
@@ -0,0 +1,72 @@
1
+ {
2
+ "registrations": [
3
+ {
4
+ "id": "archkeep/watched-files",
5
+ "method": "workspace/didChangeWatchedFiles",
6
+ "registerOptions": {
7
+ "watchers": [
8
+ {
9
+ "globPattern": "**/module-boundaries.config.mjs"
10
+ },
11
+ {
12
+ "globPattern": "**/tsconfig.base.json"
13
+ },
14
+ {
15
+ "globPattern": "**/project.json"
16
+ },
17
+ {
18
+ "globPattern": "**/nx.json"
19
+ },
20
+ {
21
+ "globPattern": "**/archkeep.json"
22
+ },
23
+ {
24
+ "globPattern": "**/package.json"
25
+ },
26
+ {
27
+ "globPattern": "**/module-federation.config.js"
28
+ },
29
+ {
30
+ "globPattern": "**/module-federation.config.ts"
31
+ },
32
+ {
33
+ "globPattern": "**/go.mod"
34
+ },
35
+ {
36
+ "globPattern": "**/go.work"
37
+ },
38
+ {
39
+ "globPattern": "**/Cargo.toml"
40
+ },
41
+ {
42
+ "globPattern": "**/pyproject.toml"
43
+ },
44
+ {
45
+ "globPattern": "**/pom.xml"
46
+ },
47
+ {
48
+ "globPattern": "**/settings.gradle"
49
+ },
50
+ {
51
+ "globPattern": "**/settings.gradle.kts"
52
+ },
53
+ {
54
+ "globPattern": "**/build.gradle"
55
+ },
56
+ {
57
+ "globPattern": "**/build.gradle.kts"
58
+ },
59
+ {
60
+ "globPattern": "**/moon.yml"
61
+ },
62
+ {
63
+ "globPattern": "**/.moon/workspace.yml"
64
+ },
65
+ {
66
+ "globPattern": "**/.config/moon/workspace.yml"
67
+ }
68
+ ]
69
+ }
70
+ }
71
+ ]
72
+ }
@@ -25,10 +25,7 @@
25
25
  * to reach it.
26
26
  */
27
27
 
28
- export { isPlainObject } from "../values.mjs";
29
-
30
- /** @type {(value: unknown) => boolean} */
31
- export const isNonEmptyString = (value) => typeof value === "string" && value.trim() !== "";
28
+ export { isPlainObject, isNonEmptyString } from "../values.mjs";
32
29
 
33
30
  /**
34
31
  * A value's type, for a refusal that shows what was actually there.
package/src/go-work.mjs CHANGED
@@ -71,29 +71,6 @@ import { isAbsolute, posix, relative, sep } from "node:path";
71
71
 
72
72
  import { projectOwning } from "./analysis/source-util.mjs";
73
73
 
74
- /**
75
- * What each drift finding means — one entry per `messageId` a finding can
76
- * carry. `../report/sarif.mjs` derives its rule descriptors from this table,
77
- * the same arrangement it has with `../rules/messages.mjs`, so a kind added
78
- * here cannot be nameless in a code-scanning upload.
79
- */
80
- export const GO_WORK_MESSAGES = Object.freeze({
81
- goWorkMissingUse:
82
- "A project's go.mod is not in go.work's use list: a developer's go build and gopls skip a " +
83
- "module the Nx graph covers, so dev machines and CI select different module sets.",
84
- goWorkStaleUse:
85
- "A go.work use entry names a directory with no tracked go.mod: go commands fail on developer " +
86
- "machines while CI, which never reads go.work, stays green.",
87
- goWorkUnmodeledUse:
88
- "A go.work use entry names a module the Nx graph does not model: it builds on developer " +
89
- "machines while nx affected and the boundary check never see it.",
90
- goWorkOutsideUse:
91
- "A go.work use entry points outside the workspace: developer builds include a module no run " +
92
- "over this workspace can cover.",
93
- });
94
-
95
- export const GO_WORK_MESSAGE_IDS = Object.freeze(Object.keys(GO_WORK_MESSAGES));
96
-
97
74
  /** A parse failure that names its line, so the failure record is actionable. */
98
75
  const parseError = (line, reason) => new Error(`go.work:${line}: ${reason}`);
99
76
 
@@ -299,6 +276,14 @@ function useDirectory(usePath, workspaceRoot) {
299
276
  * - **`goWorkOutsideUse`** — a `use` entry above the workspace root. Whatever
300
277
  * it builds, no run over this workspace can cover it.
301
278
  *
279
+ * One of the FOUR finding families — PD-13 (2026-09-06) outcome (c): no
280
+ * Finding supertype exists, and the relationship pin lives on
281
+ * `./rules/index.mjs`'s `violationOf` header. These findings stay this
282
+ * family's own shape and fold into the one verdict lane as count keys into
283
+ * `verdictFor` (`./verdict.mjs`). The canonical statement is the
284
+ * "Finding — the unowned concept" section of
285
+ * `../../../docs/architecture/refactor/SEMANTIC-MODEL.md`.
286
+ *
302
287
  * @param {{ uses: { path: string, line: number, column: number }[],
303
288
  * workspaceRoot: string,
304
289
  * projects: { name: string, root: string }[],
@@ -191,6 +191,21 @@ export function escapeIdentityField(value) {
191
191
  * string (#627) — while a triple with no delimiter in any field spells
192
192
  * exactly what earlier versions spelled, byte for byte.
193
193
  *
194
+ * One other spelling of edge identity exists in this package, for a
195
+ * different medium, and the two are deliberately not unified:
196
+ * `edgeIdentityKey` (`../commands/diff.mjs`) joins the same triple with
197
+ * `\0` for in-memory set arithmetic in the diff family — `computeDiff`'s
198
+ * index maps, `trajectory`'s persistence sets — and is never stored. Different
199
+ * media, one law: each family keeps exactly one identity constructor, and
200
+ * consumers consume a constructor's output rather than re-deriving a second
201
+ * spelling of it (`../../../../docs/adr/0008-snapshot-identity-per-family.md`,
202
+ * INV-6 in `../../../../docs/architecture/refactor/INVARIANTS.md`). An edge
203
+ * reaches this module only as its raw triple, never as a pre-built string
204
+ * from either spelling (`evolutionBoundary` below refuses one), and a
205
+ * `\0`-joined diff key written into an event would give one edge two
206
+ * spellings inside the store — every identity string it already holds reads
207
+ * `source>target:type`.
208
+ *
194
209
  * @param {{source: string, target: string, type: string}} edge
195
210
  * @returns {string}
196
211
  */
@@ -101,8 +101,14 @@ function shortId(id) {
101
101
  * silently manufacture duplicates — the failure shape this store exists to
102
102
  * rule out.
103
103
  *
104
+ * It then runs the same vocabulary rules the read path enforces (see
105
+ * `eventVocabularyViolation`): a record outside the event vocabulary — a
106
+ * wrong `schemaVersion`, a classification or disposition outside its
107
+ * vocabulary — is refused BEFORE it can be persisted, so `writeEvent` never
108
+ * writes a record `readEvents` would refuse and brick the store on (#738).
109
+ *
104
110
  * @param {object} event The event to write.
105
- * @throws {Error} naming the mismatch.
111
+ * @throws {Error} naming the mismatch or the violated vocabulary rule.
106
112
  */
107
113
  function validateEventForWrite(event) {
108
114
  if (typeof event !== "object" || event === null || Array.isArray(event)) {
@@ -130,12 +136,50 @@ function validateEventForWrite(event) {
130
136
  "tuple {base, head, declarationDigest}",
131
137
  );
132
138
  }
139
+ const violation = eventVocabularyViolation(event);
140
+ if (violation !== null) {
141
+ throw new Error(`archkeep: refusing to write the evolution event: ${violation}`);
142
+ }
143
+ }
144
+
145
+ /**
146
+ * The three vocabulary rules every stored event must pass — the schema
147
+ * version, the classification subset, and the disposition — as ONE copy both
148
+ * sides of the store run: `validateEventForWrite` refuses a write outside
149
+ * them, `validateEventRecord` refuses a read of one, so a record `writeEvent`
150
+ * persists is exactly a record `readEvents` accepts and a refused write can
151
+ * never brick a later read (#738).
152
+ *
153
+ * @param {object} event The record to check.
154
+ * @returns {string|null} The violated rule, or `null` when the record is
155
+ * within the vocabulary.
156
+ */
157
+ function eventVocabularyViolation(event) {
158
+ if (event.schemaVersion !== EVOLUTION_EVENT_SCHEMA_VERSION) {
159
+ return `schemaVersion ${JSON.stringify(
160
+ event.schemaVersion,
161
+ )} is not ${EVOLUTION_EVENT_SCHEMA_VERSION}`;
162
+ }
163
+ if (
164
+ !Array.isArray(event.classifications) ||
165
+ event.classifications.some((entry) => !EVENT_CLASSIFICATIONS.includes(entry))
166
+ ) {
167
+ return `classifications must be a subset of [${EVENT_CLASSIFICATIONS.join(", ")}]`;
168
+ }
169
+ if (!EVENT_DISPOSITIONS.includes(event.disposition)) {
170
+ return `disposition ${JSON.stringify(
171
+ event.disposition,
172
+ )} is not one of [${EVENT_DISPOSITIONS.join(", ")}]`;
173
+ }
174
+ return null;
133
175
  }
134
176
 
135
177
  /**
136
- * The three validations every stored event must pass: the schema version, the
137
- * classification subset, and the disposition. Any other shape is a malformed
138
- * store — thrown, never read as an event.
178
+ * The three vocabulary rules every stored event must pass the schema
179
+ * version, the classification subset, and the disposition the ONE copy
180
+ * `eventVocabularyViolation` owns, shared with the write path so a record
181
+ * `writeEvent` accepts is exactly a record `readEvents` accepts. Any other
182
+ * shape is a malformed store — thrown, never read as an event.
139
183
  *
140
184
  * @param {object} parsed The parsed record.
141
185
  * @param {string} path The file it came from, for the error message.
@@ -147,28 +191,9 @@ function validateEventRecord(parsed, path) {
147
191
  `archkeep: malformed evolution event '${path}': the record is not a JSON object`,
148
192
  );
149
193
  }
150
- if (parsed.schemaVersion !== EVOLUTION_EVENT_SCHEMA_VERSION) {
151
- throw new Error(
152
- `archkeep: malformed evolution event '${path}': schemaVersion ${JSON.stringify(
153
- parsed.schemaVersion,
154
- )} is not ${EVOLUTION_EVENT_SCHEMA_VERSION}`,
155
- );
156
- }
157
- if (
158
- !Array.isArray(parsed.classifications) ||
159
- parsed.classifications.some((entry) => !EVENT_CLASSIFICATIONS.includes(entry))
160
- ) {
161
- throw new Error(
162
- `archkeep: malformed evolution event '${path}': classifications must be a subset of ` +
163
- `[${EVENT_CLASSIFICATIONS.join(", ")}]`,
164
- );
165
- }
166
- if (!EVENT_DISPOSITIONS.includes(parsed.disposition)) {
167
- throw new Error(
168
- `archkeep: malformed evolution event '${path}': disposition ${JSON.stringify(
169
- parsed.disposition,
170
- )} is not one of [${EVENT_DISPOSITIONS.join(", ")}]`,
171
- );
194
+ const violation = eventVocabularyViolation(parsed);
195
+ if (violation !== null) {
196
+ throw new Error(`archkeep: malformed evolution event '${path}': ${violation}`);
172
197
  }
173
198
  }
174
199
 
@@ -182,9 +207,11 @@ function validateEventRecord(parsed, path) {
182
207
  * against `io.root`. A missing directory is an empty store: it is created
183
208
  * before the scan, so the first event in a fresh store lands at `0000`.
184
209
  *
185
- * The event's `id`/`dedupeKey` must match the canonical tuple (see
186
- * `validateEventForWrite`) a caller cannot persist a record whose identity
187
- * lies about its content.
210
+ * The event's `id`/`dedupeKey` must match the canonical tuple and the record
211
+ * must sit within the event vocabulary (see `validateEventForWrite`) a
212
+ * caller cannot persist a record whose identity lies about its content, or
213
+ * whose vocabulary `readEvents` would refuse: the store never gains a file
214
+ * that would brick its own reads.
188
215
  *
189
216
  * @param {string} dir Absolute or relative path to the event store directory.
190
217
  * @param {object} event The EvolutionEvent to append.
@@ -200,8 +227,9 @@ function validateEventRecord(parsed, path) {
200
227
  * against and is REQUIRED for a write.
201
228
  * @returns {{id: string, duplicate: boolean}} The event id, and whether this
202
229
  * call wrote nothing because the event already existed.
203
- * @throws {Error} on a mismatched event identity, an unreadable or malformed
204
- * store, a containment violation, or a `wx` refusal.
230
+ * @throws {Error} on an event that fails validation (identity or vocabulary),
231
+ * an unreadable or malformed store, a containment violation, or a `wx`
232
+ * refusal.
205
233
  */
206
234
  export function writeEvent(dir, event, io = {}) {
207
235
  validateEventForWrite(event);
@@ -52,7 +52,7 @@ import { languageOf } from "../analysis/registry.mjs";
52
52
  import { canonicalizeJson } from "../canonical.mjs";
53
53
  import { describe, isPlainObject } from "../values.mjs";
54
54
  import { GOVERNANCE_ROW_KEYS, rowSchemaViolations } from "./row-schema.mjs";
55
- import { fitnessVerdict, isVerdict } from "./verdict.mjs";
55
+ import { VERDICTS, fitnessVerdict, isVerdict } from "./verdict.mjs";
56
56
  import {
57
57
  coverageMinimum,
58
58
  cycleFree,
@@ -481,6 +481,19 @@ export function evaluateFitness(rows, snapshot) {
481
481
  * @returns {{verdict: "pass"|"fail"|"unknown"|"not_applicable", decisions: object[]}}
482
482
  */
483
483
  export function fitnessVerdictFor(decisions) {
484
+ // The fold is an overall-verdict constructor, so it owns the same vocabulary
485
+ // latch `buildDecision` does rather than trusting every caller to have
486
+ // latched its rows first: a stranger verdict matches none of the checks
487
+ // below and falls through to `pass` — the loudest clean state a governance
488
+ // run can emit, fabricated from a row nobody judged.
489
+ for (const decision of decisions) {
490
+ if (!isVerdict(decision.verdict)) {
491
+ throw new Error(
492
+ `archkeep: refusing to fold a fitness row whose verdict is ${describe(decision.verdict)} — ` +
493
+ `expected one of ${VERDICTS.join(", ")}. This is a bug in the rule that judged the row.`,
494
+ );
495
+ }
496
+ }
484
497
  if (decisions.some((d) => d.verdict === "fail")) return { verdict: "fail", decisions };
485
498
  if (decisions.some((d) => d.verdict === "unknown")) return { verdict: "unknown", decisions };
486
499
  if (decisions.length === 0 || decisions.every((d) => d.verdict === "not_applicable")) {