@ecoma-io/archkeep 0.24.0 → 0.25.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 (79) hide show
  1. package/package.json +1 -1
  2. package/src/analysis/csharp.mjs +3 -1
  3. package/src/analysis/dotnet/csproj.mjs +5 -1
  4. package/src/analysis/dotnet/namespaces.mjs +1 -0
  5. package/src/analysis/go.mjs +6 -0
  6. package/src/analysis/java.mjs +2 -0
  7. package/src/analysis/jvm/gradle.mjs +3 -1
  8. package/src/analysis/jvm/maven.mjs +6 -1
  9. package/src/analysis/jvm/packages.mjs +1 -0
  10. package/src/analysis/jvm/resolve.mjs +4 -2
  11. package/src/analysis/kotlin.mjs +2 -0
  12. package/src/analysis/markdown.mjs +1 -0
  13. package/src/analysis/python.mjs +8 -0
  14. package/src/analysis/rust.mjs +5 -1
  15. package/src/analysis/source-util.mjs +1 -1
  16. package/src/analysis/typescript.mjs +2 -0
  17. package/src/architecture-intent/model.mjs +11 -7
  18. package/src/architecture-intent/selectors.mjs +2 -1
  19. package/src/commands/change-intent.mjs +10 -9
  20. package/src/commands/change.mjs +86 -23
  21. package/src/commands/check.mjs +11 -9
  22. package/src/commands/completeness.mjs +7 -6
  23. package/src/commands/coverage-acceptance.mjs +46 -0
  24. package/src/commands/custom-rules.mjs +1 -0
  25. package/src/commands/delta-classify.mjs +3 -0
  26. package/src/commands/delta-snapshot.mjs +27 -5
  27. package/src/commands/delta.mjs +73 -39
  28. package/src/commands/diff.mjs +3 -9
  29. package/src/commands/drift.mjs +1 -1
  30. package/src/commands/evaluation-primitives.mjs +4 -4
  31. package/src/commands/evolution.mjs +2 -0
  32. package/src/commands/explain.mjs +2 -0
  33. package/src/commands/graph.mjs +39 -17
  34. package/src/commands/history.mjs +36 -0
  35. package/src/commands/plan-context-command.mjs +4 -1
  36. package/src/commands/policy.mjs +5 -2
  37. package/src/commands/scenario-evaluation.mjs +1 -1
  38. package/src/commands/snapshot-meta.mjs +33 -0
  39. package/src/commands/trajectory.mjs +2 -1
  40. package/src/config.mjs +1 -1
  41. package/src/custom-rules/host.mjs +3 -3
  42. package/src/custom-rules/values.mjs +1 -4
  43. package/src/eslint-config.mjs +1 -0
  44. package/src/fixtures/evolution-lifecycle/workspace.mjs +15 -4
  45. package/src/go-work.mjs +1 -1
  46. package/src/governance/adr-registry.mjs +4 -1
  47. package/src/governance/debt-ledger.mjs +1 -1
  48. package/src/governance/decision-fitness.mjs +2 -0
  49. package/src/governance/decision-graph.mjs +1 -0
  50. package/src/governance/discovery-proposal.mjs +8 -2
  51. package/src/governance/evolution-event.mjs +42 -0
  52. package/src/governance/fitness-registry.mjs +16 -1
  53. package/src/governance/preset-fingerprints.json +14 -14
  54. package/src/governance/profile-registry.mjs +22 -3
  55. package/src/governance/provenance-record.mjs +4 -1
  56. package/src/governance/reconcile-score.mjs +4 -0
  57. package/src/governance/row-schema.mjs +1 -0
  58. package/src/governance/verdict.mjs +37 -4
  59. package/src/governance/waiver.mjs +1 -0
  60. package/src/intent/intent-manifest.json +18 -12
  61. package/src/intent/mask-non-code.mjs +1 -0
  62. package/src/lsp/diagnostics.mjs +3 -2
  63. package/src/lsp/protocol.mjs +2 -1
  64. package/src/lsp/server.mjs +3 -0
  65. package/src/lsp/workspace-index.mjs +3 -1
  66. package/src/providers/native/differential.fixtures.mjs +29 -11
  67. package/src/providers/native/index.mjs +2 -1
  68. package/src/providers/native/model.mjs +4 -0
  69. package/src/report/envelope-shape.mjs +2 -0
  70. package/src/report/json.mjs +4 -2
  71. package/src/report/sarif.mjs +21 -8
  72. package/src/report/snapshot-text.mjs +3 -3
  73. package/src/report/text.mjs +10 -2
  74. package/src/rules/match.mjs +7 -5
  75. package/src/rules/specifiers.mjs +2 -0
  76. package/src/rules/tags.mjs +3 -2
  77. package/src/rules/topology.mjs +6 -1
  78. package/src/values.mjs +11 -0
  79. package/src/workspace.mjs +1 -0
@@ -61,7 +61,7 @@
61
61
  * The uncertainty marker vocabulary — three values, the bound the "bounded
62
62
  * uncertainty markers" test asserts.
63
63
  */
64
- export const CONFIDENCE = Object.freeze(["high", "medium", "low"]);
64
+ export const CONFIDENCE = Object.freeze(["high", "medium", "low"]); // used by its own test
65
65
 
66
66
  /**
67
67
  * The component model: every project's root's first path segment (`""` at the
@@ -73,6 +73,7 @@ export const CONFIDENCE = Object.freeze(["high", "medium", "low"]);
73
73
  * @returns {Map<string, {name: string, root: string, tags: string[]}[]>}
74
74
  */
75
75
  export function componentsByDirectory(projects) {
76
+ // used by its own test
76
77
  const buckets = new Map();
77
78
  for (const project of projects) {
78
79
  const component = project.root === "" ? "" : project.root.split("/")[0];
@@ -101,6 +102,7 @@ export function componentsByDirectory(projects) {
101
102
  * @returns {{tag: string, component: string, members: string[]}[]} Sorted.
102
103
  */
103
104
  export function dominantTags(components) {
105
+ // used by its own test
104
106
  const tags = [];
105
107
  for (const [component, members] of components) {
106
108
  if (members.length < 2) continue;
@@ -136,6 +138,7 @@ export function dominantTags(components) {
136
138
  * @returns {{axis: string, values: string[]}[]} Sorted by axis.
137
139
  */
138
140
  export function tagAxes(projects) {
141
+ // used by its own test
139
142
  const byAxis = new Map();
140
143
  for (const project of projects) {
141
144
  for (const tag of project.tags) {
@@ -169,7 +172,7 @@ export function tagAxes(projects) {
169
172
  * @param {string} target
170
173
  * @returns {boolean}
171
174
  */
172
- export function sameComponent(components, source, target) {
175
+ function sameComponent(components, source, target) {
173
176
  for (const members of components.values()) {
174
177
  if (members.some((m) => m.name === source) && members.some((m) => m.name === target)) {
175
178
  return true;
@@ -198,6 +201,7 @@ export function sameComponent(components, source, target) {
198
201
  * component?: string, evidence: object[], confidence: string}[]} Sorted.
199
202
  */
200
203
  export function boundaryAssertions({ projects, edges }) {
204
+ // used by its own test
201
205
  const components = componentsByDirectory(projects);
202
206
  const projectNames = new Set(projects.map((p) => p.name));
203
207
  /** @type {{kind: "edge"|"component", source: string|undefined, target: string|undefined,
@@ -256,6 +260,7 @@ export function boundaryAssertions({ projects, edges }) {
256
260
  * evidence: object[], confidence: string}[]} Sorted.
257
261
  */
258
262
  export function tagVocabulary(projects) {
263
+ // used by its own test
259
264
  const components = componentsByDirectory(projects);
260
265
  /** @type {{kind: "observed"|"suggested", tag: string|undefined, axis: string|undefined,
261
266
  * component: string|undefined, members: string[]|undefined, values: string[]|undefined,
@@ -308,6 +313,7 @@ export function tagVocabulary(projects) {
308
313
  * component?: string, evidence: object[], confidence: string}[]} Sorted.
309
314
  */
310
315
  export function candidateRules(assertions) {
316
+ // used by its own test
311
317
  /** @type {{kind: "noDependency"|"boundary", source: string|undefined, target: string|undefined,
312
318
  * component: string|undefined, evidence: object[], confidence: "medium"}[]} */
313
319
  const rules = [];
@@ -80,6 +80,48 @@ export function eventId(event) {
80
80
  return createHash("sha256").update(eventDedupeKey(event)).digest("hex");
81
81
  }
82
82
 
83
+ /**
84
+ * The refusal law every command that writes an evolution event holds: the
85
+ * write happens only from a reproducible identity — a committed, clean head
86
+ * and a clean base. A commitless head has no revision to name (the event
87
+ * would serialize its `head` as `{}`, and every run over that workspace
88
+ * would collide on one event id); a dirty tree names a commit its evidence
89
+ * does not back, so two distinct uncommitted states collapse onto one event
90
+ * id — a later transition is silently lost or aliased. That is the silent
91
+ * direction this repository refuses, so the write is refused loudly instead.
92
+ * The same run without `--event-out` stays a byte-identical in-memory run —
93
+ * the gate is the event write, never the verdict.
94
+ *
95
+ * The messages are the `delta` command's original wording, parameterized by
96
+ * the writing command's label. Consumers match on these strings (a refusal
97
+ * is part of a run's observable contract), so the wording is frozen here —
98
+ * one home, one copy, no per-command drift.
99
+ *
100
+ * @param {{label: "delta"|"change", headCommit: string|undefined,
101
+ * baseDirty: boolean, headDirty: boolean}} input `label` names the writing
102
+ * command in the refusal message; `headCommit` is the head revision the
103
+ * event would carry (`undefined` when provenance could not resolve one);
104
+ * `baseDirty`/`headDirty` are the two sides' provenance dirty bits.
105
+ * @returns {void} Throws on every state that cannot produce a reproducible
106
+ * event identity.
107
+ */
108
+ export function assertReproducibleEventIdentity({ label, headCommit, baseDirty, headDirty }) {
109
+ if (typeof headCommit !== "string") {
110
+ throw new Error(
111
+ `archkeep: refusing to write a ${label} event without a committed head — a commitless ` +
112
+ "head has no reproducible event identity, and every distinct head state would " +
113
+ "collide on one event id. Commit the head, or capture without --event-out.",
114
+ );
115
+ }
116
+ if (baseDirty === true || headDirty === true) {
117
+ throw new Error(
118
+ `archkeep: refusing to write a ${label} event from a dirty working tree — the event ` +
119
+ "would name a commit whose evidence is uncommitted, and distinct uncommitted " +
120
+ "states would collide on one event id. Commit both sides first.",
121
+ );
122
+ }
123
+ }
124
+
83
125
  /**
84
126
  * The digest of a normalized change-intent's DECLARATIVE parts only:
85
127
  * `{version, base, projects, edges, constraints}`. The prose `summary` is
@@ -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,
@@ -65,6 +65,7 @@ import {
65
65
 
66
66
  /** The condition types the registry can evaluate. */
67
67
  export const CONDITION_TYPES = Object.freeze([
68
+ // used by its own test
68
69
  "cycle-free",
69
70
  "layer-dependency",
70
71
  "tag-conformance",
@@ -327,6 +328,7 @@ function tagAxisIsolationViolations(condition, at) {
327
328
  * @returns {object} A verdict record from `fitnessVerdict`.
328
329
  */
329
330
  export function judgeFitnessRow(row, graph, analysis, intent, suppressions) {
331
+ // used by its own test
330
332
  const names = resolveMembers(row.match, graph.nodes);
331
333
  if (names.length === 0) {
332
334
  return fitnessVerdict({
@@ -479,6 +481,19 @@ export function evaluateFitness(rows, snapshot) {
479
481
  * @returns {{verdict: "pass"|"fail"|"unknown"|"not_applicable", decisions: object[]}}
480
482
  */
481
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
+ }
482
497
  if (decisions.some((d) => d.verdict === "fail")) return { verdict: "fail", decisions };
483
498
  if (decisions.some((d) => d.verdict === "unknown")) return { verdict: "unknown", decisions };
484
499
  if (decisions.length === 0 || decisions.every((d) => d.verdict === "not_applicable")) {
@@ -1,16 +1,16 @@
1
1
  {
2
- "clean-architecture#clean-architecture": "28e4dc572cf278e69f05c01bed020cda425ebd7b11bfe04804f21d2f1bbdcb69",
3
- "clean-architecture#clean-architecture-pure-core": "85e7d5a36759169190dab1d871acc1d2c733b145306ef33f7b161e641c9f78e4",
4
- "ddd-bounded-contexts#ddd-bounded-contexts": "8ec7feb0e6dbf64373e1aaf5bfc9d53d6299de3c11fb38955a5a3857fac04f09",
5
- "ddd-bounded-contexts#ddd-bounded-contexts-isolated": "8ac4183559010abf01e20ae50bffd7611b78417b4af531af6c7a15fbcdc443da",
6
- "ddd-bounded-contexts#ddd-bounded-contexts-partitioned": "982f90d0718c2bdd6874411964ef9baa379ee5f9c849404cb9cde2ab7aaccc19",
7
- "hexagonal#hexagonal": "08e57312c449a79ce32c4248931e7d9488441fa47f8638fb780fd02602442003",
8
- "hexagonal#hexagonal-pure-domain": "3a43406883c6e182592ff1f47347c6f9a04f04690300ea5b9260877dea2f134f",
9
- "layered#layered-relaxed": "ad2912594d26a270b6139c6561454ef01e2bf6e2214d2d8ec57afcc8fee11467",
10
- "layered#layered-strict": "26af569cb6c352a223e09e18e3e7c9817d5c1cb53575be46ffad950bd2f84be2",
11
- "modular-monolith#modular-monolith": "4b3517e4d4ae1357e675b947d7e4845260fa6d75122f40d8e75ad202d910606a",
12
- "modular-monolith#modular-monolith-sealed-kernel": "124a7bafd43abbd87e5218146219d57af6010e9374d80f0c3c1d2ac13a8e2677",
13
- "modular-monolith#modular-monolith-sealed-modules": "772d74fd8bbdb85ea39c5e38bdb8193a4f069ace8b458bff96943d5a243f5d25",
14
- "vertical-slice#vertical-slice": "5a4a17b041bb57046ba0f42a28032afb933c1f28ee3ecaa1262c706786c14dc7",
15
- "vertical-slice#vertical-slice-sealed-kernel": "efbcd5617f3c96f43b02f622cd79c4b27e49af907329ec9dcd9ee77549eb49fa"
2
+ "clean-architecture#clean-architecture": "5f32ab7e98b5e49ed832bbb4dd97d6757abdf607ca85dbe70a3fc9515493fcb4",
3
+ "clean-architecture#clean-architecture-pure-core": "9728201a3f57fc7db38747e58cd277fdff54277a34d3997b6ae67827dc547404",
4
+ "ddd-bounded-contexts#ddd-bounded-contexts": "6d9f733c915e3ab33932d5150d0651a4603a7869625a7ec55afc7bb3a0328871",
5
+ "ddd-bounded-contexts#ddd-bounded-contexts-isolated": "6dd7e4360b8d8fb0359148af1360bc8986526b2be51c6b7a647f7fb414e5a0a3",
6
+ "ddd-bounded-contexts#ddd-bounded-contexts-partitioned": "93b15523dda77be5aca06806d0235eefa32a504e411d4fcf6962a7c1bec9e719",
7
+ "hexagonal#hexagonal": "86321ee12d3e304567c988a5fb04ef17d040763c45f021a9b1f64c3852ea7c12",
8
+ "hexagonal#hexagonal-pure-domain": "2aa8efe7d71a4c731aee61c46961d6bba8d239af68909a71595488374c5de80a",
9
+ "layered#layered-relaxed": "ffc0f268d37b88c97eb8c783283adbf9779850750b3e21c780c7851d936fb6b4",
10
+ "layered#layered-strict": "4c90afb7cfe1e273fedce2a85e2403a1f354a4a038f1c872ad103f1c2f1d7df1",
11
+ "modular-monolith#modular-monolith": "634ef3e706d06780bfd72a57c1a4a3c0be2d4163b0728fc734f4bd4e5da8cbe1",
12
+ "modular-monolith#modular-monolith-sealed-kernel": "372cabc2d618aea8a0aa8b284e8be93a062dd5ebe22f64af52bc1fa785826a97",
13
+ "modular-monolith#modular-monolith-sealed-modules": "0ff0b39328d50fb3622dc84fb4d7b26fd567a5110437536aaa586e29ad4b8c92",
14
+ "vertical-slice#vertical-slice": "4df057e4ec1f92dae8aada8203d18869c6e283764f66a9f96f1c425304436f34",
15
+ "vertical-slice#vertical-slice-sealed-kernel": "1d1fbaf268a7afc851e6e4a126bd586dd0ca9547cf9d654e44dcf0dd99537f78"
16
16
  }
@@ -74,7 +74,7 @@ import { describe, isPlainObject } from "../values.mjs";
74
74
  const REGISTRY_KEYS = ["profiles", "version", "$schema"];
75
75
 
76
76
  /** A version a reader that predates it must refuse, per `docs/reference/profiles.md`. */
77
- export const PROFILE_REGISTRY_SCHEMA_VERSION = 1;
77
+ const PROFILE_REGISTRY_SCHEMA_VERSION = 1;
78
78
 
79
79
  /** The registry's schema version: stated, or schema 1 when absent. */
80
80
  function registrySchemaVersion(raw) {
@@ -112,6 +112,7 @@ const NAME_PATTERN = /^[a-zA-Z0-9_-]+$/u;
112
112
 
113
113
  /** A profile's declared block, kept ONLY for this command's own data. */
114
114
  export function listNames(registry) {
115
+ // used by its own test
115
116
  return registry.profiles.map((profile) => profile.name);
116
117
  }
117
118
 
@@ -126,6 +127,7 @@ export function listNames(registry) {
126
127
  * @returns {string[]}
127
128
  */
128
129
  export function profileRegistryViolations(raw) {
130
+ // used by its own test
129
131
  if (!isPlainObject(raw)) {
130
132
  return [`profiles: expected a JSON object, got ${describe(raw)}`];
131
133
  }
@@ -201,6 +203,7 @@ export function profileRegistryViolations(raw) {
201
203
  * @returns {string[]}
202
204
  */
203
205
  export function profileReferenceViolations(profiles) {
206
+ // used by its own test
204
207
  const violations = [];
205
208
  const byName = new Map(profiles.map((profile) => [profile.name, profile]));
206
209
  for (const profile of profiles) {
@@ -250,6 +253,7 @@ export function profileReferenceViolations(profiles) {
250
253
  * silently resolved as "no profile".
251
254
  */
252
255
  export function resolveProfile(profiles, name, seen = new Set()) {
256
+ // used by its own test
253
257
  const profile = profiles.find((candidate) => candidate.name === name);
254
258
  if (profile === undefined) {
255
259
  throw new Error(
@@ -302,6 +306,7 @@ export function resolveProfile(profiles, name, seen = new Set()) {
302
306
  * profile-registry or reference-graph defect.
303
307
  */
304
308
  export function loadProfileRegistry(path, { readFile = defaultProfileIo.readFile } = {}) {
309
+ // used by its own test
305
310
  const text = readFile(path);
306
311
  if (text === null) {
307
312
  throw new Error(`archkeep: cannot read profiles file ${path}`);
@@ -336,13 +341,27 @@ export function loadProfileRegistry(path, { readFile = defaultProfileIo.readFile
336
341
  * @param {string} profileName The profile to resolve.
337
342
  * @param {string} sourceLabel What failed, named in the thrown message.
338
343
  * @param {{readFile?: (path: string) => string|null}} [io]
339
- * @returns {{depConstraints: object[], options: object, suppressions: object[], fitness?: object[]}}
344
+ * @returns {{depConstraints: object[], options: object, suppressions: object[], fitness?: object[],
345
+ * profile: string}} `profile` is the selection the policy was resolved by,
346
+ * carried on the policy itself so the law's identity travels with it —
347
+ * `../commands/graph.mjs`'s `computePolicyFingerprint` reads it, and the
348
+ * fingerprint is the one policy fact that travels between captures
349
+ * (`delta`, `diff`, `history`).
340
350
  * @throws {Error} when the registry or the named profile is defective.
341
351
  */
342
352
  export function profilePolicy(registryPath, profileName, sourceLabel, io = {}) {
343
353
  const registry = loadProfileRegistry(registryPath, io);
344
354
  const effective = resolveProfile(registry.profiles, profileName);
345
- return policyFrom(effective, `${sourceLabel} (profile "${profileName}")`);
355
+ return {
356
+ ...policyFrom(effective, `${sourceLabel} (profile "${profileName}")`),
357
+ // The selection rides on the policy it produced: two profiles whose
358
+ // blocks converge resolve to the same fields, so without this key a
359
+ // switch between them resolves to the same fingerprint and a `delta`
360
+ // across the switch classifies as no law change. The NAME, never
361
+ // `registryPath` — a path is machine-local, and a fingerprint that
362
+ // differs between a laptop and CI reports a change nobody made.
363
+ profile: profileName,
364
+ };
346
365
  }
347
366
 
348
367
  /**
@@ -52,7 +52,7 @@ import { clockViolations } from "./clock.mjs";
52
52
  import { describe, isPlainObject } from "../values.mjs";
53
53
 
54
54
  /** The only keys a validated `origin` may carry. */
55
- export const ORIGIN_KEYS = Object.freeze(["by", "tool", "on"]);
55
+ export const ORIGIN_KEYS = Object.freeze(["by", "tool", "on"]); // used by its own test
56
56
 
57
57
  /**
58
58
  * @typedef {object} OriginRecord
@@ -135,6 +135,7 @@ export function originViolations(raw, io = {}) {
135
135
  * @throws {Error} naming every violation at once, prefixed by `at`.
136
136
  */
137
137
  export function validateOrigin(raw, io = {}, at = "origin") {
138
+ // used by its own test
138
139
  const violations = originViolations(raw, io).map((message) =>
139
140
  message.startsWith("origin.")
140
141
  ? `${at}.${message.slice("origin.".length)}`
@@ -181,6 +182,7 @@ export function recordOrigin({ by, tool, clock }) {
181
182
  * change, supersession, and bindings change is recorded as one of these.
182
183
  */
183
184
  export const DECISION_LIFECYCLE_KINDS = Object.freeze([
185
+ // used by its own test
184
186
  "status-transition",
185
187
  "supersession",
186
188
  "bindings-change",
@@ -234,6 +236,7 @@ export const DECISION_LIFECYCLE_KINDS = Object.freeze([
234
236
  * the registry's `ADR_STATUSES`, a no-op event, or an invalid origin/clock.
235
237
  */
236
238
  export function recordDecisionLifecycle({
239
+ // used by its own test
237
240
  kind,
238
241
  decisionId,
239
242
  from = null,
@@ -70,6 +70,7 @@ function boundaryKey(from, to) {
70
70
 
71
71
  /** The severity a state earns — the sort key a ranked proposal list uses. */
72
72
  export const SEVERITY_ORDER = Object.freeze({
73
+ // used by its own test
73
74
  unexpected: 4,
74
75
  absent: 3,
75
76
  match: 0,
@@ -133,6 +134,7 @@ function intentKeys(intent) {
133
134
  * @returns {{project: ScoredElement, tags: ScoredElement[]}}
134
135
  */
135
136
  export function scoreProject(project, keys, requiredTagsByProject) {
137
+ // used by its own test
136
138
  const tags = project.data?.tags ?? project.tags ?? [];
137
139
  const requiredTags = requiredTagsByProject.get(project.name) ?? [];
138
140
  const element = { plane: "project", name: project.name };
@@ -209,6 +211,7 @@ export function scoreProject(project, keys, requiredTagsByProject) {
209
211
  * @returns {ScoredElement}
210
212
  */
211
213
  export function scoreEdge(edge, keys, intentForbiddenPairs, tagForbiddenPairs) {
214
+ // used by its own test
212
215
  const key = `${edge.source} → ${edge.target}`;
213
216
  const element = { plane: "edge", name: key, intentRow: null };
214
217
 
@@ -279,6 +282,7 @@ export function scoreEdge(edge, keys, intentForbiddenPairs, tagForbiddenPairs) {
279
282
  * @returns {ScoredElement[]}
280
283
  */
281
284
  export function scoreIntentRows(intent, judgeVerdict, observed, tagsByProject) {
285
+ // used by its own test
282
286
  const rows = [];
283
287
  const observedNames = new Set(observed.projects.map((p) => p.name));
284
288
  const observedEdgeKeys = new Set(observed.edges.map((e) => `${e.source} → ${e.target}`));
@@ -94,6 +94,7 @@ export const GOVERNANCE_ROW_KEYS = Object.freeze([
94
94
  * @returns {string[]}
95
95
  */
96
96
  export function governanceBlockViolations(raw, at) {
97
+ // used by its own test
97
98
  if (!isPlainObject(raw)) return [];
98
99
  const violations = [];
99
100
 
@@ -43,11 +43,14 @@
43
43
  * path — a path, never a second implementation.
44
44
  */
45
45
 
46
+ import { describe, isNonEmptyString } from "../values.mjs";
47
+
46
48
  /** The four canonical verdict values. */
47
49
  export const VERDICTS = Object.freeze(["pass", "fail", "unknown", "not_applicable"]);
48
50
 
49
51
  /** The single mapping from an envelope status to a verdict. */
50
52
  export const VERDICT_FOR_STATUS = Object.freeze({
53
+ // used by its own test
51
54
  ok: "pass",
52
55
  findings: "fail",
53
56
  "no-verdict": "unknown",
@@ -112,7 +115,7 @@ export function fitnessVerdict({ verdict, name, evidence, message, rows, notAppl
112
115
  `expected one of ${VERDICTS.join(", ")}.`,
113
116
  );
114
117
  }
115
- if (verdict === "not_applicable" && notApplicableReason === undefined) {
118
+ if (verdict === "not_applicable" && !isNonEmptyString(notApplicableReason)) {
116
119
  throw new Error(
117
120
  `archkeep: fitness function "${name}" returned "not_applicable" without ` +
118
121
  `notApplicableReason — invariant I4: the reader must be told why the ` +
@@ -205,6 +208,21 @@ export function buildDecision(run) {
205
208
  throw new Error("archkeep: buildDecision needs either a status or an explicit verdict");
206
209
  }
207
210
  const verdict = run.verdict ?? verdictForStatus(run.status);
211
+ // The vocabulary latch. `verdictForStatus` cannot produce a stranger, so
212
+ // this refuses exactly the explicitly-passed verdicts — and it must run
213
+ // BEFORE the status-conflict check below, so a malformed verdict reports
214
+ // the vocabulary it violated rather than a conflict it never had. Without
215
+ // this latch, "perhaps", "PASS", `42` and `{}` fell through the
216
+ // pass/fail/unknown arms into the not_applicable one and SHIPPED whenever
217
+ // `notApplicableReason` happened to be truthy — a byte-legal decision
218
+ // about a workspace nobody judged.
219
+ if (!isVerdict(verdict)) {
220
+ throw new Error(
221
+ `archkeep: refusing to build a decision with verdict ${describe(run.verdict)} — ` +
222
+ `expected one of ${VERDICTS.join(", ")}. This is a bug in the command that ` +
223
+ `built the decision.`,
224
+ );
225
+ }
208
226
  if (
209
227
  run.verdict !== undefined &&
210
228
  run.status !== undefined &&
@@ -259,17 +277,32 @@ export function buildDecision(run) {
259
277
  }
260
278
 
261
279
  if (verdict === "unknown") {
280
+ // An absent reason (undefined/null) still defaults to the generic one —
281
+ // absence is not emptiness. A SUPPLIED reason must actually say
282
+ // something: "" and " " are byte-present but semantically absent, and
283
+ // a non-string reason would ship a `typeof` artifact where the reader
284
+ // was promised a sentence (I3).
262
285
  const reason =
263
286
  run.reason ??
264
287
  (run.coverageComplete === true ? "no verdict was reached" : "coverage was incomplete");
288
+ if (!isNonEmptyString(reason)) {
289
+ throw new Error(
290
+ `archkeep: refusing to emit an "unknown" decision with a ${describe(run.reason)} reason — ` +
291
+ `I3 requires a non-empty reason naming why no verdict was reached. ` +
292
+ `This is a bug in the command that built the decision.`,
293
+ );
294
+ }
265
295
  return withSampleTime({ verdict, reason }, run.sampleTime);
266
296
  }
267
297
 
268
- // verdict === "not_applicable" (I4).
269
- if (!run.notApplicableReason) {
298
+ // verdict === "not_applicable" (I4). The reason must be a string a reader
299
+ // could act on — the falsy check this replaces refused "" but SHIPPED
300
+ // " " and non-strings, the same byte-present-semantically-absent hole.
301
+ if (!isNonEmptyString(run.notApplicableReason)) {
270
302
  throw new Error(
271
303
  `archkeep: refusing to emit a "not_applicable" decision without notApplicableReason — ` +
272
- `"did not apply" and "did not run" must never be indistinguishable. ` +
304
+ `"did not apply" and "did not run" must never be indistinguishable, and a reason of ` +
305
+ `${describe(run.notApplicableReason)} tells the reader nothing. ` +
273
306
  `This is a bug in the command that built the decision.`,
274
307
  );
275
308
  }
@@ -55,6 +55,7 @@ export function isWaiver(row) {
55
55
  * @returns {number}
56
56
  */
57
57
  export function expiresAtMs(row) {
58
+ // used by its own test
58
59
  return Date.parse(String(row.expiresAt));
59
60
  }
60
61
 
@@ -25,7 +25,7 @@
25
25
  {
26
26
  "type": "source-evidence",
27
27
  "path": "src/commands/context.mjs",
28
- "assertion": "Only commands/context.mjs imports providers — the designated orchestration layer",
28
+ "assertion": "commands/context.mjs imports providers — the designated orchestration path",
29
29
  "sha256": "b505e3a7476c64af4b2daeb106e2f9cb05be82e314604229d508dda04b629a98"
30
30
  }
31
31
  ],
@@ -40,7 +40,7 @@
40
40
  "type": "behavioral-test",
41
41
  "path": "src/report/json.test.mjs",
42
42
  "assertion": "JSON envelope throws on status=ok + incomplete coverage, status/exitCode disagreement, coverage.complete/notAnalyzed disagreement",
43
- "sha256": "a371d36b89b10c5a4bda4d9e9a2e3bde114256c1377eb95699c9f0f195aabc4a"
43
+ "sha256": "8f587d3bd3f06519b90f760e7eafc091ca4e4cf26b67929ab180abe35c36c8f0"
44
44
  },
45
45
  {
46
46
  "type": "source-evidence",
@@ -66,13 +66,13 @@
66
66
  "type": "behavioral-test",
67
67
  "path": "src/intent/intent.test.mjs",
68
68
  "assertion": "analysis output conforms to the frozen contract schema (no extra verdict/policy fields); analysis output is invariant under project tag changes (three workspaces differing only in project tag fields compared byte-for-byte)",
69
- "sha256": "adb25388f17c97577e97e6b65bd3dacc5811710fe3bb60a409a51ff4abcd945e"
69
+ "sha256": "85b98de1f9268c94020b8b728a3ebbf1e8b50f8c70731c5460c788ea00be3198"
70
70
  },
71
71
  {
72
72
  "type": "architecture-test",
73
73
  "path": "src/intent/intent.test.mjs",
74
74
  "assertion": "the gate walks every production analysis module and fails when judging vocabulary (judge/forbid/permit/allow/ban) appears in code",
75
- "sha256": "adb25388f17c97577e97e6b65bd3dacc5811710fe3bb60a409a51ff4abcd945e"
75
+ "sha256": "85b98de1f9268c94020b8b728a3ebbf1e8b50f8c70731c5460c788ea00be3198"
76
76
  },
77
77
  {
78
78
  "type": "source-evidence",
@@ -103,14 +103,20 @@
103
103
  {
104
104
  "type": "source-evidence",
105
105
  "path": "src/commands/graph.mjs",
106
- "assertion": "Plain string comparison, never localeCompare; INTERNAL_DATA_FIELDS stripped; SCHEMA_VERSION = 2",
107
- "sha256": "72a28c1edcbcb209f74bd10a1a3691e25aaa848bac4bd54aceeacf8d415c8f13"
106
+ "assertion": "Plain string comparison, never localeCompare; INTERNAL_DATA_FIELDS stripped",
107
+ "sha256": "436902ab3ca994c233318437a896630f969a2c8a22b0f93e598d309faed39d49"
108
+ },
109
+ {
110
+ "type": "source-evidence",
111
+ "path": "src/report/json.mjs",
112
+ "assertion": "SCHEMA_VERSION = 2 — the envelope's schemaVersion constant",
113
+ "sha256": "090418f6f60dec51638976dff658c55f8a932012bfc7c8bc59952b0f03543340"
108
114
  },
109
115
  {
110
116
  "type": "source-evidence",
111
117
  "path": "src/commands/graph.mjs",
112
118
  "assertion": "computePolicyFingerprint produces SHA-256 of canonicalized policy",
113
- "sha256": "72a28c1edcbcb209f74bd10a1a3691e25aaa848bac4bd54aceeacf8d415c8f13"
119
+ "sha256": "436902ab3ca994c233318437a896630f969a2c8a22b0f93e598d309faed39d49"
114
120
  }
115
121
  ],
116
122
  "status": "proven"
@@ -130,7 +136,7 @@
130
136
  "type": "source-evidence",
131
137
  "path": "src/commands/diff.mjs",
132
138
  "assertion": "parseBaseline validates schemaVersion; refuses unknown versions",
133
- "sha256": "85e6400006a7e93c66e64547162c854320fa69d833a11cec42773703f522a163"
139
+ "sha256": "e28620e288b58e35916e8cd41de446b999bdc2c4e5fcb5f3a617ab0ee449b0fd"
134
140
  }
135
141
  ],
136
142
  "status": "proven"
@@ -150,7 +156,7 @@
150
156
  "type": "source-evidence",
151
157
  "path": "src/commands/diff.mjs",
152
158
  "assertion": "computeDiff returns structural diff; policyMismatch detected via fingerprint; computeRuleImpact for depConstraints-only context",
153
- "sha256": "85e6400006a7e93c66e64547162c854320fa69d833a11cec42773703f522a163"
159
+ "sha256": "e28620e288b58e35916e8cd41de446b999bdc2c4e5fcb5f3a617ab0ee449b0fd"
154
160
  }
155
161
  ],
156
162
  "status": "proven"
@@ -230,7 +236,7 @@
230
236
  "type": "behavioral-test",
231
237
  "path": "src/intent/intent.test.mjs",
232
238
  "assertion": "depConstraints verdicts from judgeEdge agree with evaluate in both directions (violating edge found by both, legal edge reported by neither); explain includes the same violations as evaluate at a given site",
233
- "sha256": "adb25388f17c97577e97e6b65bd3dacc5811710fe3bb60a409a51ff4abcd945e"
239
+ "sha256": "85b98de1f9268c94020b8b728a3ebbf1e8b50f8c70731c5460c788ea00be3198"
234
240
  },
235
241
  {
236
242
  "type": "behavioral-test",
@@ -260,7 +266,7 @@
260
266
  "type": "source-evidence",
261
267
  "path": "src/commands/diff.mjs",
262
268
  "assertion": "coverage.notes warns when ruleImpact is computed (depConstraints only, 3 of 15)",
263
- "sha256": "85e6400006a7e93c66e64547162c854320fa69d833a11cec42773703f522a163"
269
+ "sha256": "e28620e288b58e35916e8cd41de446b999bdc2c4e5fcb5f3a617ab0ee449b0fd"
264
270
  },
265
271
  {
266
272
  "type": "documentation",
@@ -286,7 +292,7 @@
286
292
  "type": "source-evidence",
287
293
  "path": "src/commands/graph.mjs",
288
294
  "assertion": "Plain string comparison throughout; never localeCompare",
289
- "sha256": "72a28c1edcbcb209f74bd10a1a3691e25aaa848bac4bd54aceeacf8d415c8f13"
295
+ "sha256": "436902ab3ca994c233318437a896630f969a2c8a22b0f93e598d309faed39d49"
290
296
  }
291
297
  ],
292
298
  "status": "proven"
@@ -536,6 +536,7 @@ function scanTemplate(src, i) {
536
536
  * same line structure.
537
537
  */
538
538
  export function maskNonCode(src) {
539
+ // used by its own test
539
540
  /**
540
541
  * Chunks of the result, joined once at the end. Code is copied through in
541
542
  * RUNS rather than a token at a time — `plainFrom` is where the current
@@ -38,7 +38,7 @@ import { DIAGNOSTIC_SEVERITY, SERVER_INFO } from "./protocol.mjs";
38
38
  * prints beside the message to say which tool spoke. Taken from the server's
39
39
  * own identity so the two can never disagree.
40
40
  */
41
- export const DIAGNOSTIC_SOURCE = SERVER_INFO.name;
41
+ export const DIAGNOSTIC_SOURCE = SERVER_INFO.name; // used by its own test
42
42
 
43
43
  /**
44
44
  * The `code` on a diagnostic that reports the ABSENCE of a verdict rather than
@@ -46,7 +46,7 @@ export const DIAGNOSTIC_SOURCE = SERVER_INFO.name;
46
46
  * against `MESSAGE_IDS` at load so a future upstream id cannot silently collide
47
47
  * with it.
48
48
  */
49
- export const ANALYSIS_FAILURE_CODE = "analysisFailure";
49
+ export const ANALYSIS_FAILURE_CODE = "analysisFailure"; // used by its own test
50
50
 
51
51
  if (MESSAGE_IDS.includes(ANALYSIS_FAILURE_CODE)) {
52
52
  throw new Error(
@@ -100,6 +100,7 @@ const QUOTES = new Set(['"', "'", "`"]);
100
100
  * @returns {{start: {line: number, character: number}, end: {line: number, character: number}}}
101
101
  */
102
102
  export function rangeAt(at, lines) {
103
+ // used by its own test
103
104
  // A failure about the file as a whole carries no position (`contract.md`
104
105
  // fixes it as an explicit `null`). It gets the first line, whole: a
105
106
  // zero-width range at the origin renders as an invisible caret in most
@@ -95,7 +95,7 @@ export const DIAGNOSTIC_SEVERITY = Object.freeze({
95
95
  * more is not a supported conversation, and saying so beats waiting for bytes
96
96
  * that will never arrive.
97
97
  */
98
- export const MAX_CONTENT_LENGTH = 64 * 1024 * 1024;
98
+ export const MAX_CONTENT_LENGTH = 64 * 1024 * 1024; // used by its own test
99
99
 
100
100
  /**
101
101
  * LSP `MessageType`, as `window/showMessage` reports it.
@@ -211,5 +211,6 @@ export function uriToPath(uri) {
211
211
  * @returns {string}
212
212
  */
213
213
  export function pathToUri(path) {
214
+ // used by its own test
214
215
  return pathToFileURL(path).href;
215
216
  }
@@ -292,6 +292,7 @@ const POLYGLOT_GRAPH_MANIFESTS = Object.freeze([
292
292
  * @returns {readonly string[]}
293
293
  */
294
294
  export function watchedFilesFor(options, { unresolved = false } = {}) {
295
+ // used by its own test
295
296
  return Object.freeze([
296
297
  ...(typeof options.boundaryConfig === "string" ? [options.boundaryConfig] : []),
297
298
  ...(unresolved
@@ -376,6 +377,7 @@ function markersAt(root) {
376
377
  * convention chain can find while carrying files that need one.
377
378
  */
378
379
  export function readWorkspaceOptions(root) {
380
+ // used by its own test
379
381
  const { hasNx, hasNative } = markersAt(root);
380
382
  if (hasNx && hasNative) {
381
383
  throw new Error(
@@ -450,6 +452,7 @@ const WATCHER_REGISTRATION_ID = "archkeep/watched-files";
450
452
  * text than the one on screen.
451
453
  */
452
454
  export const SERVER_CAPABILITIES = Object.freeze({
455
+ // used by its own test
453
456
  textDocumentSync: Object.freeze({
454
457
  openClose: true,
455
458
  change: TEXT_DOCUMENT_SYNC_KIND.full,
@@ -156,7 +156,7 @@ export { PROJECT_CONFIG_FILE, nodeTypeOf, buildDependencies };
156
156
  * @returns {object} Whatever the JSON describes.
157
157
  * @throws {Error} when neither parser can read it.
158
158
  */
159
- export const parseProjectJson = parseNxJson;
159
+ const parseProjectJson = parseNxJson;
160
160
 
161
161
  /**
162
162
  * Every file git considers part of the working tree, workspace-relative and
@@ -205,6 +205,7 @@ const directoryOf = (file) => {
205
205
  * @returns {{projects: {name: string, root: string, config: object}[], skipped: {file: string, reason: string}[]}}
206
206
  */
207
207
  export function discoverProjects({ files, readFile }) {
208
+ // used by its own test
208
209
  const projects = [];
209
210
  const skipped = [];
210
211
  for (const file of files) {
@@ -263,6 +264,7 @@ export function discoverProjects({ files, readFile }) {
263
264
  * publishes the collision through `indexGaps`.
264
265
  */
265
266
  export function buildNodes(projects) {
267
+ // used by its own test
266
268
  // Null-prototype for the same reason `../providers/native/graph.mjs` and
267
269
  // `../providers/moon.mjs` use them: every key here is a project NAME, and
268
270
  // project names come from a `project.json`'s own `name` field —