@ecoma-io/archkeep 0.24.0 → 0.24.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.
Files changed (74) 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 +17 -1
  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 +2 -1
  27. package/src/commands/delta.mjs +35 -21
  28. package/src/commands/drift.mjs +1 -1
  29. package/src/commands/evaluation-primitives.mjs +4 -4
  30. package/src/commands/evolution.mjs +2 -0
  31. package/src/commands/explain.mjs +2 -0
  32. package/src/commands/graph.mjs +39 -17
  33. package/src/commands/history.mjs +2 -0
  34. package/src/commands/plan-context-command.mjs +4 -1
  35. package/src/commands/policy.mjs +5 -2
  36. package/src/commands/scenario-evaluation.mjs +1 -1
  37. package/src/commands/trajectory.mjs +2 -1
  38. package/src/config.mjs +1 -1
  39. package/src/custom-rules/host.mjs +3 -3
  40. package/src/eslint-config.mjs +1 -0
  41. package/src/fixtures/evolution-lifecycle/workspace.mjs +15 -4
  42. package/src/go-work.mjs +1 -1
  43. package/src/governance/adr-registry.mjs +4 -1
  44. package/src/governance/debt-ledger.mjs +1 -1
  45. package/src/governance/decision-fitness.mjs +2 -0
  46. package/src/governance/decision-graph.mjs +1 -0
  47. package/src/governance/discovery-proposal.mjs +8 -2
  48. package/src/governance/evolution-event.mjs +42 -0
  49. package/src/governance/fitness-registry.mjs +2 -0
  50. package/src/governance/preset-fingerprints.json +14 -14
  51. package/src/governance/profile-registry.mjs +22 -3
  52. package/src/governance/provenance-record.mjs +4 -1
  53. package/src/governance/reconcile-score.mjs +4 -0
  54. package/src/governance/row-schema.mjs +1 -0
  55. package/src/governance/verdict.mjs +1 -0
  56. package/src/governance/waiver.mjs +1 -0
  57. package/src/intent/intent-manifest.json +3 -3
  58. package/src/intent/mask-non-code.mjs +1 -0
  59. package/src/lsp/diagnostics.mjs +3 -2
  60. package/src/lsp/protocol.mjs +2 -1
  61. package/src/lsp/server.mjs +3 -0
  62. package/src/lsp/workspace-index.mjs +3 -1
  63. package/src/providers/native/differential.fixtures.mjs +29 -11
  64. package/src/providers/native/index.mjs +2 -1
  65. package/src/providers/native/model.mjs +4 -0
  66. package/src/report/envelope-shape.mjs +2 -0
  67. package/src/report/sarif.mjs +21 -8
  68. package/src/report/snapshot-text.mjs +3 -3
  69. package/src/report/text.mjs +10 -2
  70. package/src/rules/match.mjs +7 -5
  71. package/src/rules/specifiers.mjs +2 -0
  72. package/src/rules/tags.mjs +3 -2
  73. package/src/rules/topology.mjs +6 -1
  74. package/src/workspace.mjs +1 -0
@@ -29,7 +29,10 @@ import {
29
29
  unownedGapWithoutRunConfiguration,
30
30
  untrackedOwnedFiles,
31
31
  } from "./context.mjs";
32
- import { partitionUnownedCoverage } from "./coverage-acceptance.mjs";
32
+ import {
33
+ partitionUnownedCoverage,
34
+ withdrawAcceptedUnclaimedFailures,
35
+ } from "./coverage-acceptance.mjs";
33
36
  import { readAdrContext } from "./adr.mjs";
34
37
  import { declaredFitnessNames, unresolvedDecisionRefRows } from "../governance/adr-registry.mjs";
35
38
  import { declaredEdgeViolationsForCheck, judgeEdge } from "../rules/edge-constraints.mjs";
@@ -223,7 +226,7 @@ export async function check(
223
226
  );
224
227
  const { root, graph, workspace, tracked } = commandContext;
225
228
  const { imports, exemptedFiles, unsupportedLanguageFiles } = commandContext.analysis;
226
- const failures = [...commandContext.analysis.failures];
229
+ let failures = [...commandContext.analysis.failures];
227
230
  const analyzed = commandContext.analysis.analyzed;
228
231
 
229
232
  // The config's location is a separate fact from the workspace root, which is
@@ -308,17 +311,16 @@ export async function check(
308
311
  // acceptance now — stated below as the `"accepted-unowned-files"` coverage
309
312
  // gap, never silently — rather than the exit-3 refusal an unanswered
310
313
  // orphan earns. Uncovered unclaimed files keep their failures, and with
311
- // them the exit code, byte-identical to before the channel existed. An
312
- // unclaimed file carries exactly one failure (it is unowned, so no
313
- // analyzer ever read it), so filtering by file cannot drop an unrelated
314
- // read failure.
314
+ // them the exit code, byte-identical to before the channel existed. The
315
+ // one-failure-per-file assumption the withdrawal rests on is enforced where
316
+ // it is spent (`./coverage-acceptance.mjs`), not stated here: an accepted
317
+ // file carrying a second failure refuses the run instead of losing both
318
+ // rows to a by-file splice.
315
319
  const acceptedUnclaimed = new Set(
316
320
  commandContext.unclaimedGap.files.filter((file) => unownedCoverage.acceptedFiles.has(file)),
317
321
  );
318
322
  if (acceptedUnclaimed.size > 0) {
319
- for (let at = failures.length - 1; at >= 0; at -= 1) {
320
- if (acceptedUnclaimed.has(failures[at].sourceFile)) failures.splice(at, 1);
321
- }
323
+ failures = withdrawAcceptedUnclaimedFailures(failures, acceptedUnclaimed);
322
324
  }
323
325
 
324
326
  // The go.work drift check, keyed off the manifest's presence the way every
@@ -40,10 +40,10 @@ export const EVALUATION_STATUS = Object.freeze({
40
40
  REFUSED: "refused",
41
41
  });
42
42
  export const EVALUATED = EVALUATION_STATUS.EVALUATED;
43
- export const PARTIAL = EVALUATION_STATUS.PARTIAL;
43
+ export const PARTIAL = EVALUATION_STATUS.PARTIAL; // used by its own test
44
44
  export const NOT_EVALUATED = EVALUATION_STATUS.NOT_EVALUATED;
45
- export const UNSUPPORTED = EVALUATION_STATUS.UNSUPPORTED;
46
- export const REFUSED = EVALUATION_STATUS.REFUSED;
45
+ export const UNSUPPORTED = EVALUATION_STATUS.UNSUPPORTED; // used by its own test
46
+ export const REFUSED = EVALUATION_STATUS.REFUSED; // used by its own test
47
47
 
48
48
  // ---------------------------------------------------------------------------
49
49
  // Evaluation contract types — which gates are required per evaluation type
@@ -71,7 +71,7 @@ export const EVALUATION_CONTRACT_TYPES = Object.freeze({
71
71
  *
72
72
  * @type {Readonly<Object<string, ReadonlySet<string>>>}
73
73
  */
74
- export const REQUIRED_GATES_FOR_CONTRACT = Object.freeze({
74
+ const REQUIRED_GATES_FOR_CONTRACT = Object.freeze({
75
75
  [EVALUATION_CONTRACT_TYPES.CANONICAL]: Object.freeze(
76
76
  new Set([
77
77
  "domainCoverage",
@@ -107,7 +107,7 @@ export const REQUIRED_GATES_FOR_CONTRACT = Object.freeze({
107
107
  * Defaults to SCENARIO (most restrictive).
108
108
  * @returns {boolean}
109
109
  */
110
- export function isGateRequired(gateKey, contractType = EVALUATION_CONTRACT_TYPES.SCENARIO) {
110
+ function isGateRequired(gateKey, contractType = EVALUATION_CONTRACT_TYPES.SCENARIO) {
111
111
  const required = REQUIRED_GATES_FOR_CONTRACT[contractType];
112
112
  return required ? required.has(gateKey) : true;
113
113
  }
@@ -121,6 +121,7 @@ export function isGateRequired(gateKey, contractType = EVALUATION_CONTRACT_TYPES
121
121
  * @type {Readonly<{key: string, label: string, type: string}[]>}
122
122
  */
123
123
  export const EVIDENCE_COMPLETE_GATES = Object.freeze([
124
+ // used by its own test
124
125
  { key: "domainCoverage", label: "Domain coverage", type: "ratio" },
125
126
  { key: "claimEvidenceCoverage", label: "Claim evidence coverage", type: "ratio" },
126
127
  { key: "causalCoverage", label: "Causal coverage", type: "ratio" },
@@ -265,7 +266,7 @@ export const REQUIRED_DOMAINS = Object.freeze([
265
266
  * @param {string} status EVALUATION_STATUS value
266
267
  * @returns {boolean}
267
268
  */
268
- export function isDomainEvaluated(status) {
269
+ function isDomainEvaluated(status) {
269
270
  return status === EVALUATION_STATUS.EVALUATED;
270
271
  }
271
272
 
@@ -111,3 +111,49 @@ export function partitionUnownedCoverage({ rows, unownedGap, unclaimedFiles, tra
111
111
  },
112
112
  };
113
113
  }
114
+
115
+ /**
116
+ * Withdraws each accepted unclaimed file's failure from the run's failure
117
+ * list — the acceptance channel's effect on it, kept beside the matching so
118
+ * the channel's contract (accept the file ⇒ withdraw its refusal) is one
119
+ * module's. `./check.mjs` holds the list and narrows `acceptedFiles` to the
120
+ * unclaimed half; `./waivers.mjs` has no failure list and only reports rows.
121
+ *
122
+ * An accepted unclaimed file carries exactly one failure — its unowned-file
123
+ * refusal, the only row any producer appends for a file no analyzer ever
124
+ * read. The withdrawal does not TRUST that assumption, it ENFORCES it: an
125
+ * accepted file carrying more than one failure means some producer appended a
126
+ * second row for it, and a splice by `sourceFile` would take the second one
127
+ * down with the first — the failure count falls, `coverage.notAnalyzed` loses
128
+ * a row, and a could-not-look run tips toward a clean one, the silent
129
+ * direction (`../../../../AGENTS.md`). The run refuses instead, naming the
130
+ * file and the count it found.
131
+ *
132
+ * @param {{sourceFile: string}[]} failures The run's failure list, in order.
133
+ * @param {Set<string>} acceptedFiles The accepted subset of the unclaimed
134
+ * list — `partitionUnownedCoverage`'s `acceptedFiles` narrowed to that list
135
+ * by the caller.
136
+ * @returns {{sourceFile: string}[]} A new array without the accepted files'
137
+ * single failures; the input is not mutated.
138
+ * @throws {Error} when an accepted file carries more than one failure.
139
+ */
140
+ export function withdrawAcceptedUnclaimedFailures(failures, acceptedFiles) {
141
+ const carried = new Map();
142
+ for (const failure of failures) {
143
+ if (!acceptedFiles.has(failure.sourceFile)) continue;
144
+ carried.set(failure.sourceFile, (carried.get(failure.sourceFile) ?? 0) + 1);
145
+ }
146
+ const overloaded = [...carried.entries()].filter(([, count]) => count > 1);
147
+ if (overloaded.length > 0) {
148
+ const named = overloaded
149
+ .map(([file, count]) => `'${file}' (carries ${count} failures)`)
150
+ .join(", ");
151
+ throw new Error(
152
+ `archkeep: the coverage.unowned acceptance channel cannot withdraw ${named} — ` +
153
+ `acceptance withdraws exactly one failure per file (the unowned-file refusal), and ` +
154
+ `withdrawing by file would drop the second one with it, understating what this run ` +
155
+ `could not read`,
156
+ );
157
+ }
158
+ return failures.filter((failure) => !acceptedFiles.has(failure.sourceFile));
159
+ }
@@ -140,6 +140,7 @@ export function declaresCustomRules(config) {
140
140
  * @returns {(artifact: string) => Uint8Array|null}
141
141
  */
142
142
  export function readArtifactBytes(root) {
143
+ // used by its own test
143
144
  return (artifact) => {
144
145
  const abs = join(root, artifact);
145
146
  if (containmentViolation(root, abs) !== null) return null;
@@ -79,6 +79,7 @@ import { namespacedId } from "./custom-rules.mjs";
79
79
  * constraint: object|null}}|{ok: false, reason: string}}
80
80
  */
81
81
  export function violationIdentity(violation) {
82
+ // used by its own test
82
83
  if (violation === null || typeof violation !== "object" || Array.isArray(violation)) {
83
84
  return { ok: false, reason: `violation is ${describe(violation)}, not an object` };
84
85
  }
@@ -148,6 +149,7 @@ export function violationIdentity(violation) {
148
149
  * original violation plus the `reason` its identity could not be stated.
149
150
  */
150
151
  export function classifyViolations({ base, head, suppressions = [], now = referenceTime() }) {
152
+ // used by its own test
151
153
  const baseIdentified = base.map(identityOf);
152
154
  const headIdentified = head.map(identityOf);
153
155
 
@@ -237,6 +239,7 @@ export function classifyViolations({ base, head, suppressions = [], now = refere
237
239
  * unknown: object[]}}
238
240
  */
239
241
  export function classifyUnresolvableRecords({ base, head, sourceProjectOf }) {
242
+ // used by its own test
240
243
  const attribute = sourceProjectOf ?? (() => null);
241
244
  const baseIdentified = base
242
245
  .filter(isUnresolvable)
@@ -52,7 +52,7 @@ import { isPlainObject } from "../values.mjs";
52
52
  import { buildDependencies, buildProjects } from "./graph.mjs";
53
53
 
54
54
  /** The only snapshot schemaVersion this module writes and reads. */
55
- export const EVIDENCE_SNAPSHOT_SCHEMA_VERSION = 1;
55
+ export const EVIDENCE_SNAPSHOT_SCHEMA_VERSION = 1; // used by its own test
56
56
 
57
57
  /**
58
58
  * Builds the snapshot object from already-captured evidence.
@@ -338,6 +338,7 @@ export function readEvidenceSnapshot(path, io = {}) {
338
338
  * @throws {Error} on every condition above.
339
339
  */
340
340
  export function parseEvidenceSnapshot(text, path) {
341
+ // used by its own test
341
342
  let parsed;
342
343
  try {
343
344
  parsed = JSON.parse(text);
@@ -74,6 +74,7 @@ import {
74
74
  import { stripTrailingSlashes } from "../path-util.mjs";
75
75
  import { referenceTime } from "../governance/clock.mjs";
76
76
  import {
77
+ assertReproducibleEventIdentity,
77
78
  eventDedupeKey,
78
79
  eventId,
79
80
  EVOLUTION_EVENT_SCHEMA_VERSION,
@@ -161,6 +162,7 @@ export function refusePluginGapHead(commandContext, activity) {
161
162
  * @throws {Error} on either condition.
162
163
  */
163
164
  export function refuseUnjudgeableHead(commandContext, activity) {
165
+ // used by its own test
164
166
  refusePluginGapHead(commandContext, activity);
165
167
  const notAnalyzed = commandContext.analysis.failures.filter(isWholeFileFailure);
166
168
 
@@ -271,6 +273,8 @@ export function captureDelta(commandContext, { config }) {
271
273
  * type: string}[], workspaceLayout?: object, exemptedFiles?: string[]}} storedGraph
272
274
  * A validated snapshot's `graph` section (`parseEvidenceSnapshot`).
273
275
  * @returns {object} A graph `evaluate()` consumes.
276
+ * @throws {Error} when an `exemptedFiles` entry is not a string — the engine's
277
+ * own filter would drop it in silence (the refusal beside the copy below).
274
278
  */
275
279
  export function evidenceGraphToProjectGraph(storedGraph) {
276
280
  /** @type {Record<string, object>} */
@@ -297,7 +301,26 @@ export function evidenceGraphToProjectGraph(storedGraph) {
297
301
  const graph = { nodes, dependencies };
298
302
  if (storedGraph.workspaceLayout !== undefined)
299
303
  graph.workspaceLayout = storedGraph.workspaceLayout;
300
- if (Array.isArray(storedGraph.exemptedFiles)) graph.exemptedFiles = storedGraph.exemptedFiles;
304
+ if (Array.isArray(storedGraph.exemptedFiles)) {
305
+ // An entry that is not a string is refused here rather than carried:
306
+ // `../rules/index.mjs`'s `createContext` filters `graph.exemptedFiles`
307
+ // with `typeof file === "string"`, so a corrupted snapshot's entry would
308
+ // ride through this conversion and vanish there — the exemption set the
309
+ // snapshot recorded silently shrinking by one file, the under-count
310
+ // disclosed nowhere.
311
+ const malformed = storedGraph.exemptedFiles
312
+ .map((entry, at) => ({ entry, at }))
313
+ .filter(({ entry }) => typeof entry !== "string");
314
+ if (malformed.length > 0) {
315
+ const { entry, at } = malformed[0];
316
+ throw new Error(
317
+ `archkeep: the snapshot's graph.exemptedFiles[${at}] is ${JSON.stringify(entry)}, ` +
318
+ `not a string — the rule engine drops such entries in silence, which would shrink ` +
319
+ `the exemption set the snapshot recorded; re-capture the baseline or correct the file`,
320
+ );
321
+ }
322
+ graph.exemptedFiles = storedGraph.exemptedFiles;
323
+ }
301
324
  return graph;
302
325
  }
303
326
 
@@ -373,6 +396,7 @@ export function sourceProjectAttributor(headGraph, baselineProjects) {
373
396
  * @returns {"accepted"|"rejected"|"no-verdict"}
374
397
  */
375
398
  export function deltaDisposition({ status }) {
399
+ // used by its own test
376
400
  if (status === "no-verdict") return "no-verdict";
377
401
  if (status === "findings") return "rejected";
378
402
  return "accepted";
@@ -810,26 +834,16 @@ export async function deltaCommand(
810
834
  let eventWrite = null;
811
835
  if (eventOut !== null && eventOut !== undefined) {
812
836
  // F-delta-event-id: an evolution event is only written from a reproducible
813
- // identity — a committed, clean head and a clean base. A commitless head
814
- // has no revision to name, and a dirty tree names a commit its evidence
815
- // does not back; either way TWO distinct evidence states collapse onto ONE
816
- // event id, so a later transition is silently lost or aliased (the silent
817
- // direction). Refuse loudly instead. The same run without `--event-out`
818
- // stays a byte-identical in-memory delta.
819
- if (typeof headCommit !== "string") {
820
- throw new Error(
821
- "archkeep: refusing to write a delta event without a committed head — a commitless " +
822
- "head has no reproducible event identity, and every distinct head state would " +
823
- "collide on one event id. Commit the head, or capture without --event-out.",
824
- );
825
- }
826
- if (baseline.provenance?.dirty === true || headProvenance?.dirty === true) {
827
- throw new Error(
828
- "archkeep: refusing to write a delta event from a dirty working tree — the event " +
829
- "would name a commit whose evidence is uncommitted, and distinct uncommitted " +
830
- "states would collide on one event id. Commit both sides first.",
831
- );
832
- }
837
+ // identity — a committed, clean head and a clean base, by the shared law
838
+ // `assertReproducibleEventIdentity` owns. The wording is frozen there; the
839
+ // messages consumers match on are byte-identical to the inline refusals
840
+ // this call replaces.
841
+ assertReproducibleEventIdentity({
842
+ label: "delta",
843
+ headCommit,
844
+ baseDirty: baseline.provenance?.dirty === true,
845
+ headDirty: headProvenance?.dirty === true,
846
+ });
833
847
  // The architecture-debt sub-ledger (design §8): judged by re-running the
834
848
  // current intent over this run's base and head graphs — a drift finding
835
849
  // present at head but not base is introduced; one gone is resolved. Both
@@ -250,7 +250,7 @@ export async function driftForCheck(commandContext, io = {}) {
250
250
  * @param {object} intent The normalized intent model.
251
251
  * @returns {{kind: string, row: object}[]}
252
252
  */
253
- export function intentDecisionRefRows(intent) {
253
+ function intentDecisionRefRows(intent) {
254
254
  return governanceIntentRows(intent).filter(
255
255
  ({ row }) => typeof row?.decisionRef === "string" && row.decisionRef.trim() !== "",
256
256
  );
@@ -195,7 +195,7 @@ export function buildDecisionImpact(root, constraintImpact, config) {
195
195
  * not available.
196
196
  * @returns {{evaluated: boolean, findings: object[], count: number}}
197
197
  */
198
- export function evaluateFindingsImpact(affectedProjects, availableFindings) {
198
+ function evaluateFindingsImpact(affectedProjects, availableFindings) {
199
199
  if (!availableFindings || availableFindings.length === 0) {
200
200
  return { evaluated: false, findings: [], count: 0 };
201
201
  }
@@ -221,7 +221,7 @@ export function evaluateFindingsImpact(affectedProjects, availableFindings) {
221
221
  * entry's associated project.
222
222
  * @returns {{evaluated: boolean, debt: object[], count: number}}
223
223
  */
224
- export function evaluateDebtImpact(affectedProjects, availableDebt, resolveProject = null) {
224
+ function evaluateDebtImpact(affectedProjects, availableDebt, resolveProject = null) {
225
225
  if (!availableDebt || availableDebt.length === 0) {
226
226
  return { evaluated: false, debt: [], count: 0 };
227
227
  }
@@ -247,7 +247,7 @@ export function evaluateDebtImpact(affectedProjects, availableDebt, resolveProje
247
247
  * @param {string} targetProject The target of the impact analysis.
248
248
  * @returns {{boundaries: object[], evaluated: boolean}}
249
249
  */
250
- export function evaluateBoundaryImpact(graph, constraintImpact, targetProject) {
250
+ function evaluateBoundaryImpact(graph, constraintImpact, targetProject) {
251
251
  if (!constraintImpact || constraintImpact.length === 0) {
252
252
  return { boundaries: [], evaluated: false };
253
253
  }
@@ -339,7 +339,7 @@ export function decisionProvenanceCoverage(decisions) {
339
339
  * @param {object} evaluation.evolutionAlignment Evolution alignment.
340
340
  * @returns {object} Evidence gate values for buildEvidenceComplete.
341
341
  */
342
- export function deriveEvidenceGates(evaluation) {
342
+ function deriveEvidenceGates(evaluation) {
343
343
  const { completeness, constraintImpact, decisionImpact } = evaluation;
344
344
 
345
345
  // domainCoverage: ratio of evaluated required domains
@@ -138,6 +138,7 @@ function shortSha(sha) {
138
138
  * and a shallow clone whose cut-off sits below the requested revision.
139
139
  */
140
140
  export function resolveRevision(root, rev, flag, { run = runProcess } = {}) {
141
+ // used by its own test
141
142
  if (typeof rev !== "string" || rev.length === 0) {
142
143
  throw new Error(`archkeep: ${flag} needs a revision — a commit, branch, tag, or HEAD~n.`);
143
144
  }
@@ -190,6 +191,7 @@ export function resolveRevision(root, rev, flag, { run = runProcess } = {}) {
190
191
  * head's ancestry, or a merge commit inside the range.
191
192
  */
192
193
  export function selectLinearRange(root, { base, head }, { run = runProcess } = {}) {
194
+ // used by its own test
193
195
  const baseSha = resolveRevision(root, base, "--base", { run });
194
196
  const headSha = resolveRevision(root, head ?? "HEAD", "--head", { run });
195
197
 
@@ -111,6 +111,7 @@ import { isAbsolute, relative, resolve, sep } from "node:path";
111
111
  * @throws {UsageError} when the site string is malformed.
112
112
  */
113
113
  export function parseSite(site) {
114
+ // used by its own test
114
115
  const lastColon = site.lastIndexOf(":");
115
116
  if (lastColon === -1 || lastColon === 0) {
116
117
  throw new UsageError(
@@ -157,6 +158,7 @@ export function parseSite(site) {
157
158
  * @returns {object|null} The matching record, or `null`.
158
159
  */
159
160
  export function findSite(parsed, imports) {
161
+ // used by its own test
160
162
  return (
161
163
  imports.find(
162
164
  (site) =>
@@ -151,24 +151,26 @@ export function buildDependencies(dependencies) {
151
151
  * config comparison logic.
152
152
  *
153
153
  * The fingerprint is SHA-256 of the canonicalized JSON for `depConstraints`,
154
- * `options`, `suppressions` and — when the policy declares them — `fitness`
155
- * and `customRules`. Those are every field of a loaded policy that states
156
- * law: the first three decide which violations `evaluate` produces, the
157
- * fourth decides which fitness functions `check` folds into the same exit
158
- * code (`../governance/fitness-registry.mjs`), and the fifth names the rule
154
+ * `options`, `suppressions` and — when the policy declares them — the
155
+ * `fitness`, `customRules`, `coverage` and `markdown` blocks, plus the
156
+ * selected profile's NAME when the law was selected by name. Those are every
157
+ * field of a loaded policy that states law: the first three decide which
158
+ * violations `evaluate` produces, the fourth decides which fitness functions
159
+ * `check` folds into the same exit code
160
+ * (`../governance/fitness-registry.mjs`), and the fifth names the rule
159
161
  * artifacts a workspace declared, each pinned to the bytes its `sha256` claims
160
162
  * (`../config.mjs`'s `customRuleRowViolations`) — swap one row's hash or its
161
- * `params` and the policy says something different. A field that can fail a
162
- * build and is not in the hash is a law that can be rewritten while `diff`
163
- * reports the policy unchanged the silent direction, and the reason the list
164
- * here and `policyFrom`'s return shape (`../config.mjs`) are revisited
165
- * together.
163
+ * `params` and the policy says something different. The three after those are
164
+ * argued at their keys below. A field that can fail a build and is not in the
165
+ * hash is a law that can be rewritten while `diff` reports the policy
166
+ * unchanged — the silent direction, and the reason the list here and
167
+ * `policyFrom`'s return shape (`../config.mjs`) are revisited together.
166
168
  *
167
- * `fitness` and `customRules` are included only when they are DECLARED, and
168
- * the absent case contributes no key rather than an empty array. A policy that
169
- * declares neither therefore fingerprints exactly as it did before those
170
- * fields were covered, so extending the hash did not move every existing
171
- * snapshot's value — only those whose law it was failing to describe.
169
+ * The conditional fields are included only when they are DECLARED, and the
170
+ * absent case contributes no key rather than an empty array. A policy that
171
+ * declares none therefore fingerprints exactly as it did before those fields
172
+ * were covered, so extending the hash did not move every existing snapshot's
173
+ * value — only those whose law it was failing to describe.
172
174
  *
173
175
  * @param {object} config The loaded boundary config.
174
176
  * @returns {string} A hex-encoded SHA-256 fingerprint.
@@ -180,13 +182,33 @@ export function computePolicyFingerprint(config) {
180
182
  suppressions: config.suppressions ?? [],
181
183
  ...(config.fitness === undefined ? {} : { fitness: config.fitness }),
182
184
  ...(config.customRules === undefined ? {} : { customRules: config.customRules }),
183
- // The document track is law the same way the two blocks above are: it
185
+ // The acceptance channel is law the same way the blocks above are: a
186
+ // `coverage.unowned` row withdraws an unclaimed file's whole-file failure
187
+ // as a recorded acceptance (`../config.mjs`'s `findCoverageViolations`
188
+ // owns the shape, `../commands/coverage-acceptance.mjs` matches it), so a
189
+ // policy that records or removes an acceptance must not share a
190
+ // fingerprint with one that does not — `diff`'s policy-changed warning
191
+ // reads this hash (#709: the one block `policyFrom` sets that the hash
192
+ // skipped). Conditional, like the three above, so a policy recording no
193
+ // acceptance hashes exactly as it did before this key existed.
194
+ ...(config.coverage === undefined ? {} : { coverage: config.coverage }),
195
+ // The document track is law the same way the blocks above are: it
184
196
  // decides what this run judges, so a policy that adds or edits a
185
197
  // `markdown` block must not share a fingerprint with one that does not —
186
198
  // `diff`'s policy-changed warning reads this hash. Conditional, like the
187
- // two above, so a policy declaring no block hashes exactly as it did
199
+ // three above, so a policy declaring no block hashes exactly as it did
188
200
  // before this key existed.
189
201
  ...(config.markdown === undefined ? {} : { markdown: config.markdown }),
202
+ // The named selection is law identity the same way the blocks above are:
203
+ // `profilePolicy` (`../governance/profile-registry.mjs`) sets this key
204
+ // when the workspace selects a profile by name, and two profiles whose
205
+ // resolved blocks converge are still two different named laws. Only
206
+ // `check`'s report names the selection, so a hash blind to it would let a
207
+ // `delta` across a switch report the law unchanged with nothing anywhere
208
+ // saying it moved. Conditional, like the four above, so a policy
209
+ // selected by file or inline — no `profile` key at all — hashes exactly
210
+ // as it did before the selection was covered.
211
+ ...(config.profile === undefined ? {} : { profile: config.profile }),
190
212
  };
191
213
  // Canonicalise: sort object keys at every depth so insertion order does not
192
214
  // affect the hash. Semantic equality, not construction order, is the claim —
@@ -229,6 +229,7 @@ export function readSnapshots(dir, root) {
229
229
  * @returns {string} First 8 hex characters.
230
230
  */
231
231
  export function shortId(id) {
232
+ // used by its own test
232
233
  return id.slice(0, 8);
233
234
  }
234
235
 
@@ -248,6 +249,7 @@ export function shortId(id) {
248
249
  * @returns {string} Zero-padded sequence, at least four digits.
249
250
  */
250
251
  export function nextSequence(read) {
252
+ // used by its own test
251
253
  let max = 0;
252
254
  for (const file of read.files) {
253
255
  const match = /^(\d+)-/.exec(file.name);
@@ -87,7 +87,7 @@ import { declaredFitnessNames, unresolvedDecisionRefRows } from "../governance/a
87
87
  import { formatPlanContextReport } from "../report/plan-context-text.mjs";
88
88
 
89
89
  /** How many dependents are listed before an explicit overflow note. */
90
- export const DEPENDENT_CAP = 10;
90
+ export const DEPENDENT_CAP = 10; // used by its own test
91
91
 
92
92
  /**
93
93
  * The projects a change touches: the target project, plus every project whose
@@ -100,6 +100,7 @@ export const DEPENDENT_CAP = 10;
100
100
  * @returns {string[]} Distinct affected project names, sorted.
101
101
  */
102
102
  export function collectAffectedProjects(commandContext, paths) {
103
+ // used by its own test
103
104
  const affected = new Set();
104
105
  for (const { file, project } of commandContext.owned ?? []) {
105
106
  for (const p of paths) {
@@ -124,6 +125,7 @@ export function collectAffectedProjects(commandContext, paths) {
124
125
  * @returns {object[]}
125
126
  */
126
127
  export function collectImpact(projectName, affected, graph) {
128
+ // used by its own test
127
129
  const targets = [...new Set([projectName, ...affected])].sort((a, b) =>
128
130
  a < b ? -1 : a > b ? 1 : 0,
129
131
  );
@@ -172,6 +174,7 @@ function scopedFiles(commandContext, affected) {
172
174
  * @returns {{goWork: object|null, tsconfigPaths: object|null, failures: object[]}}
173
175
  */
174
176
  export function collectDrift(commandContext) {
177
+ // used by its own test
175
178
  const { root, tracked, workspace } = commandContext;
176
179
  const failures = [];
177
180
 
@@ -26,7 +26,7 @@ import { ARCHKEEP_MODEL_FILE } from "../providers/native/model.mjs";
26
26
  * @param {object} options The resolved options from `resolveCommandContext`.
27
27
  * @returns {boolean}
28
28
  */
29
- export function hasProfiles(options) {
29
+ function hasProfiles(options) {
30
30
  return typeof options?.profiles === "string" && options.profiles !== "";
31
31
  }
32
32
 
@@ -86,10 +86,13 @@ export function hasProfiles(options) {
86
86
  * @param {string} cwd The process's working directory a relative `--config`
87
87
  * resolves against — kept separate from the workspace root for the reason
88
88
  * above.
89
- * @returns {Promise<{config: {depConstraints: object[], options: object, suppressions: object[], fitness?: object[], customRules?: object[], coverage?: object, markdown?: {include: string[], markers: {pattern: string, edge: string}[]}, notes?: string[]}|null, profile: string|null, source: string|null}>}
89
+ * @returns {Promise<{config: {depConstraints: object[], options: object, suppressions: object[], fitness?: object[], customRules?: object[], coverage?: object, markdown?: {include: string[], markers: {pattern: string, edge: string}[]}, notes?: string[], profile?: string}|null, profile: string|null, source: string|null}>}
90
90
  * `fitness`, `customRules` and `markdown` are present only when the resolved
91
91
  * policy declares them — an absent key is the workspace's decision not to
92
92
  * declare that law, never an empty one (`../config.mjs`'s `policyFrom`).
93
+ * `profile` is present only when the law was selected by name, set by the
94
+ * profiles arm (`../governance/profile-registry.mjs`'s `profilePolicy`) so
95
+ * the selection the policy was resolved by travels with it.
93
96
  * @throws {Error} when a named profile, a `--config` file, or an inline
94
97
  * policy cannot be resolved or is malformed — every arm's existing failure
95
98
  * mode, unchanged by the extraction.
@@ -55,7 +55,7 @@ import {
55
55
  // ---------------------------------------------------------------------------
56
56
 
57
57
  /** The supported scenario change types. */
58
- export const SCENARIO_CHANGE_TYPES = Object.freeze(["dependency_added", "dependency_removed"]);
58
+ export const SCENARIO_CHANGE_TYPES = Object.freeze(["dependency_added", "dependency_removed"]); // used by its own test
59
59
 
60
60
  // ---------------------------------------------------------------------------
61
61
  // Input schema types
@@ -107,7 +107,7 @@ const OBSERVATION_BASIS = "graph_snapshots";
107
107
  * constant in the envelope, so a consumer branches on a documented value
108
108
  * rather than on prose.
109
109
  */
110
- export const INSUFFICIENT_HISTORY = "insufficient_history";
110
+ export const INSUFFICIENT_HISTORY = "insufficient_history"; // used by its own test
111
111
 
112
112
  /**
113
113
  * The derived-number block for one structural axis (projects keyed by name,
@@ -187,6 +187,7 @@ export const INSUFFICIENT_HISTORY = "insufficient_history";
187
187
  * trends: TrajectoryTrends|null}}
188
188
  */
189
189
  export function computeTrajectory(files) {
190
+ // used by its own test
190
191
  const n = files.length;
191
192
  const available = n >= 2;
192
193
 
package/src/config.mjs CHANGED
@@ -650,7 +650,7 @@ function findCoverageViolations(value) {
650
650
  *
651
651
  * @type {Readonly<string[]>}
652
652
  */
653
- export const MARKDOWN_EDGE_KINDS = Object.freeze(["resolvedExportOwner"]);
653
+ const MARKDOWN_EDGE_KINDS = Object.freeze(["resolvedExportOwner"]);
654
654
 
655
655
  /**
656
656
  * One markdown marker row's problems, prefixed with its index.
@@ -124,17 +124,17 @@ export const CUSTOM_RULE_TIMEOUT_MS = 10_000;
124
124
  * bundle for a large workspace is smaller — and it is what stops a claimed
125
125
  * length from being an allocation instruction.
126
126
  */
127
- export const CUSTOM_RULE_MAX_VERDICT_BYTES = 8 * 1024 * 1024;
127
+ export const CUSTOM_RULE_MAX_VERDICT_BYTES = 8 * 1024 * 1024; // used by its own test
128
128
 
129
129
  /**
130
130
  * The most linear memory a rule may hold at the two boundaries the worker
131
131
  * measures. 256 MiB is far past what reading an evidence bundle needs and far
132
132
  * short of what a workspace's CI runner can absorb without noticing.
133
133
  */
134
- export const CUSTOM_RULE_MEMORY_LIMIT_BYTES = 256 * 1024 * 1024;
134
+ export const CUSTOM_RULE_MEMORY_LIMIT_BYTES = 256 * 1024 * 1024; // used by its own test
135
135
 
136
136
  /** The four ABI symbols a rule module must export, and the kind each must be. */
137
- export const REQUIRED_EXPORTS = Object.freeze({
137
+ const REQUIRED_EXPORTS = Object.freeze({
138
138
  memory: "memory",
139
139
  archkeep_alloc: "function",
140
140
  archkeep_describe: "function",
@@ -235,6 +235,7 @@ function parseRuleValue(value, index) {
235
235
  * severity-only fallback has been applied.
236
236
  */
237
237
  export function extractBoundaryRule(flatConfig, readerOptions = {}) {
238
+ // used by its own test
238
239
  const pathScopedMode = readerOptions.pathScoped ?? "refuse";
239
240
  if (pathScopedMode !== "refuse" && pathScopedMode !== "bind-tree-wide") {
240
241
  throw new Error(