@ecoma-io/archkeep 0.23.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 (83) 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 +341 -0
  13. package/src/analysis/python.mjs +8 -0
  14. package/src/analysis/rust.mjs +5 -1
  15. package/src/analysis/source-util.mjs +6 -5
  16. package/src/analysis/typescript.mjs +148 -0
  17. package/src/architecture-intent/model.mjs +13 -8
  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 +177 -17
  22. package/src/commands/completeness.mjs +7 -6
  23. package/src/commands/context-command.mjs +12 -20
  24. package/src/commands/coverage-acceptance.mjs +46 -0
  25. package/src/commands/coverage-verdict.mjs +12 -2
  26. package/src/commands/custom-rules.mjs +1 -0
  27. package/src/commands/delta-classify.mjs +3 -0
  28. package/src/commands/delta-snapshot.mjs +3 -6
  29. package/src/commands/delta.mjs +35 -21
  30. package/src/commands/drift.mjs +1 -1
  31. package/src/commands/evaluation-primitives.mjs +4 -4
  32. package/src/commands/evolution.mjs +2 -0
  33. package/src/commands/explain.mjs +19 -20
  34. package/src/commands/graph.mjs +44 -15
  35. package/src/commands/health.mjs +4 -0
  36. package/src/commands/history.mjs +2 -0
  37. package/src/commands/plan-context-command.mjs +9 -2
  38. package/src/commands/policy.mjs +8 -5
  39. package/src/commands/provenance.mjs +8 -2
  40. package/src/commands/scenario-evaluation.mjs +1 -1
  41. package/src/commands/trajectory.mjs +2 -1
  42. package/src/config.mjs +170 -2
  43. package/src/custom-rules/host.mjs +3 -3
  44. package/src/errors.mjs +23 -1
  45. package/src/eslint-config.mjs +3 -5
  46. package/src/fixtures/evolution-lifecycle/workspace.mjs +15 -4
  47. package/src/go-work.mjs +1 -1
  48. package/src/governance/adr-registry.mjs +6 -2
  49. package/src/governance/debt-ledger.mjs +1 -1
  50. package/src/governance/decision-fitness.mjs +2 -0
  51. package/src/governance/decision-graph.mjs +1 -0
  52. package/src/governance/discovery-proposal.mjs +8 -2
  53. package/src/governance/evolution-event.mjs +42 -0
  54. package/src/governance/evolution-store.mjs +3 -2
  55. package/src/governance/fitness-registry.mjs +2 -3
  56. package/src/governance/preset-fingerprints.json +14 -14
  57. package/src/governance/profile-registry.mjs +22 -3
  58. package/src/governance/provenance-record.mjs +4 -1
  59. package/src/governance/reconcile-score.mjs +4 -0
  60. package/src/governance/row-schema.mjs +1 -3
  61. package/src/governance/verdict.mjs +1 -0
  62. package/src/governance/waiver.mjs +1 -0
  63. package/src/intent/intent-manifest.json +6 -6
  64. package/src/intent/mask-non-code.mjs +1 -0
  65. package/src/lsp/diagnostics.mjs +3 -2
  66. package/src/lsp/protocol.mjs +2 -1
  67. package/src/lsp/server.mjs +3 -0
  68. package/src/lsp/workspace-index.mjs +3 -1
  69. package/src/providers/moon.mjs +5 -5
  70. package/src/providers/native/differential.fixtures.mjs +29 -11
  71. package/src/providers/native/index.mjs +2 -1
  72. package/src/providers/native/model.mjs +4 -0
  73. package/src/report/envelope-shape.mjs +2 -0
  74. package/src/report/sarif.mjs +21 -8
  75. package/src/report/snapshot-text.mjs +3 -3
  76. package/src/report/text.mjs +10 -2
  77. package/src/rules/index.mjs +30 -0
  78. package/src/rules/match.mjs +7 -5
  79. package/src/rules/specifiers.mjs +2 -0
  80. package/src/rules/tags.mjs +3 -2
  81. package/src/rules/topology.mjs +6 -1
  82. package/src/verdict.mjs +33 -2
  83. package/src/workspace.mjs +1 -0
@@ -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
 
@@ -79,16 +79,13 @@
79
79
  * was: the field, and its rendered lines, exist only when the comparison was
80
80
  * requested.
81
81
  */
82
- import {
83
- blindSpotRows,
84
- isWholeFileFailure,
85
- unresolvableLiteralCount,
86
- } from "../analysis/source-util.mjs";
82
+ import { isWholeFileFailure } from "../analysis/source-util.mjs";
87
83
  import { UsageError } from "../errors.mjs";
88
84
  import { evaluate } from "../rules/index.mjs";
89
85
  import { findConstraintsFor } from "../rules/tags.mjs";
90
86
  import { findProjectForPath, createProjectRootMappings } from "../rules/specifiers.mjs";
91
87
  import { jsonEnvelope, renderJson } from "../report/json.mjs";
88
+ import { coverageVerdict } from "./coverage-verdict.mjs";
92
89
  import { formatExplainReport } from "../report/explain-text.mjs";
93
90
  import { resolveProvenance } from "./provenance.mjs";
94
91
  import { readAdrContext } from "./adr.mjs";
@@ -114,6 +111,7 @@ import { isAbsolute, relative, resolve, sep } from "node:path";
114
111
  * @throws {UsageError} when the site string is malformed.
115
112
  */
116
113
  export function parseSite(site) {
114
+ // used by its own test
117
115
  const lastColon = site.lastIndexOf(":");
118
116
  if (lastColon === -1 || lastColon === 0) {
119
117
  throw new UsageError(
@@ -160,6 +158,7 @@ export function parseSite(site) {
160
158
  * @returns {object|null} The matching record, or `null`.
161
159
  */
162
160
  export function findSite(parsed, imports) {
161
+ // used by its own test
163
162
  return (
164
163
  imports.find(
165
164
  (site) =>
@@ -369,18 +368,18 @@ export function explainCommand(site, commandContext, config, options = {}) {
369
368
  // Normalize backslash separators (Windows paths) to forward slashes.
370
369
  parsed.sourceFile = sep === "\\" ? normalizedFile.replaceAll("\\", "/") : normalizedFile;
371
370
 
372
- const notAnalyzed = commandContext.analysis.failures
373
- .filter(isWholeFileFailure)
374
- .map(({ sourceFile, reason }) => ({ file: sourceFile, reason }));
375
-
376
- // An unresolvable site was seen but never judged (#595): the graph is
377
- // missing whatever edge that site would have drawn, and rules that judge
378
- // the whole graph (circularity, lazy loading) would answer over a gap. The
379
- // explanation still reports status no-verdict naming the site in
380
- // `coverage.blindSpots`, the same contract `graph`/`discover` run.
381
- const blindSpotCount = unresolvableLiteralCount(commandContext.analysis.failures);
382
- const complete = notAnalyzed.length === 0 && blindSpotCount === 0;
383
- const status = complete ? "ok" : "no-verdict";
371
+ // The completeness verdict is the shared constructor's, not this file's —
372
+ // the same contract `graph`/`discover` run: an unresolvable site was seen
373
+ // but never judged (#595), the graph is missing whatever edge it would have
374
+ // drawn, and rules that judge the whole graph (circularity, lazy loading)
375
+ // would answer over a gap. The explanation still reports status
376
+ // no-verdict naming the site in `coverage.blindSpots`. The restatement
377
+ // this replaces carried two of the constructor's three axes; the third
378
+ // (`analyzed > 0`, #599) moves no byte here, because every lane below that
379
+ // builds an envelope already implies a read file — an import record or a
380
+ // positioned failure exists only for a file the run analyzed — so the axis
381
+ // is carried by composition, not changed by it.
382
+ const { notAnalyzed, blindSpots, complete, status, exitCode } = coverageVerdict(commandContext);
384
383
 
385
384
  // Find the import record at this site.
386
385
  const record = findSite(parsed, commandContext.analysis.imports);
@@ -432,7 +431,7 @@ export function explainCommand(site, commandContext, config, options = {}) {
432
431
  analyzedFiles: commandContext.analysis.analyzed,
433
432
  imports: commandContext.analysis.imports.length,
434
433
  notAnalyzed,
435
- blindSpots: blindSpotRows(commandContext.analysis.failures),
434
+ blindSpots,
436
435
  notes: [],
437
436
  };
438
437
 
@@ -447,7 +446,7 @@ export function explainCommand(site, commandContext, config, options = {}) {
447
446
  command: "explain",
448
447
  context,
449
448
  status,
450
- exitCode: complete ? 0 : 3,
449
+ exitCode,
451
450
  coverage,
452
451
  result,
453
452
  });
@@ -579,7 +578,7 @@ export function explainCommand(site, commandContext, config, options = {}) {
579
578
  analyzedFiles: commandContext.analysis.analyzed,
580
579
  imports: commandContext.analysis.imports.length,
581
580
  notAnalyzed,
582
- blindSpots: blindSpotRows(commandContext.analysis.failures),
581
+ blindSpots,
583
582
  notes: [],
584
583
  };
585
584
 
@@ -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,6 +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 }),
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
196
+ // decides what this run judges, so a policy that adds or edits a
197
+ // `markdown` block must not share a fingerprint with one that does not —
198
+ // `diff`'s policy-changed warning reads this hash. Conditional, like the
199
+ // three above, so a policy declaring no block hashes exactly as it did
200
+ // before this key existed.
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 }),
183
212
  };
184
213
  // Canonicalise: sort object keys at every depth so insertion order does not
185
214
  // affect the hash. Semantic equality, not construction order, is the claim —
@@ -112,10 +112,14 @@ export function healthCommand(commandContext, io = {}) {
112
112
  const edges = buildDependencies(graph.dependencies);
113
113
 
114
114
  // The run's coverage facts, the same shape every command's envelope carries.
115
+ // A run that analyzed nothing judged nothing (#599, #694): judging nothing
116
+ // is not finding nothing, so it defeats completeness the way a whole-file
117
+ // failure does.
115
118
  // An unresolvable site is a fact the run saw but never judged (#595) —
116
119
  // metrics measured over it would read precision the run does not have,
117
120
  // so it defeats file completeness the way a whole-file failure does.
118
121
  const fileComplete =
122
+ analysis.analyzed > 0 &&
119
123
  analysis.failures.filter(isWholeFileFailure).length === 0 &&
120
124
  unresolvableLiteralCount(analysis.failures) === 0;
121
125
  // The graph is complete only when the files are AND the graph actually sees
@@ -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
 
@@ -501,10 +504,14 @@ export async function planContextCommand(
501
504
  (a.messageId < b.messageId ? -1 : a.messageId > b.messageId ? 1 : 0),
502
505
  );
503
506
 
507
+ // A run that analyzed nothing judged nothing (#599, #694): judging nothing
508
+ // is not finding nothing, so it defeats completeness the way a whole-file
509
+ // failure does.
504
510
  // An unresolvable literal site is work the run saw but never judged
505
511
  // (#595, narrowed): a plan over it would present edges the run does not
506
512
  // hold, so it defeats completeness the way a whole-file failure does.
507
- const complete = notAnalyzed.length === 0 && unresolvableLiteralCount(failures) === 0;
513
+ const complete =
514
+ wholeTree.analyzed > 0 && notAnalyzed.length === 0 && unresolvableLiteralCount(failures) === 0;
508
515
  const status = complete ? "ok" : "no-verdict";
509
516
  const exitCode = complete ? 0 : 3;
510
517
 
@@ -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, notes?: string[]}|null, profile: string|null, source: string|null}>}
90
- * `fitness` and `customRules` are present only when the resolved policy
91
- * declares them — an absent key is the workspace's decision not to declare
92
- * that law, never an empty one (`../config.mjs`'s `policyFrom`).
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
+ * `fitness`, `customRules` and `markdown` are present only when the resolved
91
+ * policy declares them — an absent key is the workspace's decision not to
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.
@@ -114,8 +114,14 @@ export function resolveProvenance(root) {
114
114
 
115
115
  // Dirty: any uncommitted change to tracked files means the working tree
116
116
  // does not match the commit. A baseline from a dirty tree is not a
117
- // reproducible claim about that commit.
118
- const status = runProcess("git", ["status", "--porcelain"], root).trim();
117
+ // reproducible claim about that commit. `--untracked-files=no` is what
118
+ // makes the code agree with that sentence: bare `--porcelain` includes
119
+ // untracked paths, and an untracked file is not an uncommitted change to a
120
+ // tracked file — the analysis reads `git ls-files`-tracked files only, so a
121
+ // tree whose only dirt is an editor swap, a scratch file, or an unignored
122
+ // build output has an unchanged analyzed input and must produce an
123
+ // unchanged envelope (#683).
124
+ const status = runProcess("git", ["status", "--porcelain", "--untracked-files=no"], root).trim();
119
125
  const dirty = status.length > 0;
120
126
 
121
127
  return { commit, remote, dirty };
@@ -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
@@ -639,6 +639,155 @@ function findCoverageViolations(value) {
639
639
  return violations;
640
640
  }
641
641
 
642
+ /**
643
+ * The one edge kind a markdown marker row can declare. The name is the claim
644
+ * the row makes about the graph: the captured symbol is resolved to the project
645
+ * that exports it, and the edge runs from the document's own project to that
646
+ * project. Kept as data rather than inlined at the check site so a second kind
647
+ * (`docs/reference/policy-schema.md`, "markdown") extends this list and nothing
648
+ * else — and so a row naming a kind this reader cannot draw is refused at load,
649
+ * where a law that would silently never run belongs.
650
+ *
651
+ * @type {Readonly<string[]>}
652
+ */
653
+ const MARKDOWN_EDGE_KINDS = Object.freeze(["resolvedExportOwner"]);
654
+
655
+ /**
656
+ * One markdown marker row's problems, prefixed with its index.
657
+ *
658
+ * `pattern` is a regular-expression SOURCE matched against one line of a
659
+ * matched document at a time — never a glob, and never a whole-document
660
+ * match — because the position a violation reports must be a real
661
+ * `file:line:column` a developer can open. Its FIRST capture group must name
662
+ * the exported symbol the marker claims; a pattern with no capture group can
663
+ * match every line it is aimed at and still resolve nothing, which is a law
664
+ * that reads as enforced while testing nothing — refused here for the reason
665
+ * every dead shape in this file is refused.
666
+ *
667
+ * @param {object} row
668
+ * @param {number} index
669
+ * @returns {string[]}
670
+ */
671
+ function markdownMarkerRowViolations(row, index) {
672
+ const at = `markdown.markers[${index}]`;
673
+ if (!isPlainObject(row)) return [`${at}: must be an object, got ${describe(row)}`];
674
+
675
+ const violations = [];
676
+ if (typeof row.pattern !== "string" || row.pattern === "") {
677
+ violations.push(
678
+ `${at}.pattern: must be a non-empty regular-expression source matched against one line of ` +
679
+ `a matched document, got ${describe(row.pattern)}`,
680
+ );
681
+ } else {
682
+ let compiled = null;
683
+ try {
684
+ compiled = new RegExp(row.pattern, "u");
685
+ } catch (cause) {
686
+ violations.push(
687
+ `${at}.pattern: '${row.pattern}' is not a valid regular expression under the 'u' flag ` +
688
+ `(${cause?.message ?? cause})`,
689
+ );
690
+ }
691
+ if (compiled !== null) {
692
+ // The capture count, read off the standard `pattern|` trick: matching the
693
+ // empty string yields one element per capture group plus the whole match,
694
+ // so a pattern that cannot capture anything reports `length === 1` here.
695
+ // Compiled in its own try, because the appended `|` can fail on a source
696
+ // the pattern alone accepted; the compile violation above is already
697
+ // reported in that case, and a second one would name the same row twice.
698
+ let captures;
699
+ try {
700
+ captures = (new RegExp(`${row.pattern}|`, "u").exec("")?.length ?? 1) - 1;
701
+ } catch {
702
+ captures = 1; // already refused above by the pattern's own compile check
703
+ }
704
+ if (captures < 1) {
705
+ violations.push(
706
+ `${at}.pattern: '${row.pattern}' has no capture group — the first capture group must ` +
707
+ `name the exported symbol the marker claims, and a pattern that captures nothing ` +
708
+ `resolves nothing while reading as enforced`,
709
+ );
710
+ }
711
+ }
712
+ }
713
+ if (!MARKDOWN_EDGE_KINDS.includes(row.edge)) {
714
+ violations.push(
715
+ `${at}.edge: ${describe(row.edge)} is not an edge kind this reader can draw — expected ` +
716
+ `${MARKDOWN_EDGE_KINDS.map((kind) => `"${kind}"`).join(", ")} (the captured symbol is ` +
717
+ `resolved to the project exporting it, and the edge runs there from the document's own ` +
718
+ `project)`,
719
+ );
720
+ }
721
+ for (const key of Object.keys(row)) {
722
+ if (key !== "pattern" && key !== "edge") {
723
+ violations.push(`${at}.${key}: not a markdown marker field — expected 'pattern' and 'edge'`);
724
+ }
725
+ }
726
+ return violations;
727
+ }
728
+
729
+ /**
730
+ * The markdown document track's problems, as messages; empty when it is
731
+ * well-formed. Pure, so a test drives it without a file on disk.
732
+ *
733
+ * `include` names the documents the track reads, by workspace-relative glob —
734
+ * the same glob machinery `boundarySuppressions` rows and `coverage.unowned`
735
+ * rows match with (`./rules/match.mjs`'s `safeMatchesGlob`), complexity-checked
736
+ * here at load for the reason a suppression's `path` is. An ABSOLUTE pattern is
737
+ * refused rather than left to match nothing forever: it can never match a
738
+ * workspace-relative document path, so declaring one is a law aimed outside
739
+ * the tree it governs (the family test is `ABSOLUTE_ARTIFACT_PATH`'s — the same
740
+ * "absolute in either path family" question, asked of a glob instead of a file).
741
+ *
742
+ * @param {unknown} value The parsed `markdown` value.
743
+ * @returns {string[]}
744
+ */
745
+ function findMarkdownViolations(value) {
746
+ if (!isPlainObject(value)) {
747
+ return [`markdown: must be an object carrying 'include' and 'markers', got ${describe(value)}`];
748
+ }
749
+ const violations = [];
750
+ if (!Array.isArray(value.include) || value.include.length === 0) {
751
+ violations.push(
752
+ `markdown.include: must be a non-empty array of workspace-relative glob patterns naming ` +
753
+ `the documents the track reads, got ${describe(value.include)}`,
754
+ );
755
+ } else {
756
+ value.include.forEach((pattern, index) => {
757
+ const at = `markdown.include[${index}]`;
758
+ if (typeof pattern !== "string" || pattern === "") {
759
+ violations.push(
760
+ `${at}: must be a non-empty workspace-relative glob, got ${describe(pattern)}`,
761
+ );
762
+ } else if (ABSOLUTE_ARTIFACT_PATH.test(pattern)) {
763
+ violations.push(
764
+ `${at}: '${pattern}' is an absolute path — an include glob is matched against ` +
765
+ `workspace-relative document paths, so an absolute one can never match`,
766
+ );
767
+ } else {
768
+ const problem = globComplexityError(pattern);
769
+ if (problem) violations.push(`${at}: '${pattern}' ${problem}`);
770
+ }
771
+ });
772
+ }
773
+ if (!Array.isArray(value.markers) || value.markers.length === 0) {
774
+ violations.push(
775
+ `markdown.markers: must be a non-empty array of {pattern, edge} rows, got ` +
776
+ `${describe(value.markers)}`,
777
+ );
778
+ } else {
779
+ value.markers.forEach((row, index) =>
780
+ violations.push(...markdownMarkerRowViolations(row, index)),
781
+ );
782
+ }
783
+ for (const key of Object.keys(value)) {
784
+ if (key !== "include" && key !== "markers") {
785
+ violations.push(`markdown.${key}: not a markdown field — expected 'include' and 'markers'`);
786
+ }
787
+ }
788
+ return violations;
789
+ }
790
+
642
791
  /**
643
792
  * The grammar a custom rule's `name` is written in: lowercase letters and
644
793
  * digits, single `-` separators, nothing else.
@@ -945,6 +1094,7 @@ export function findBoundaryConfigViolations(module, io = {}) {
945
1094
  fitness,
946
1095
  customRules,
947
1096
  coverage,
1097
+ markdown,
948
1098
  } = module;
949
1099
  // F05: the resolution half of the governance block (`row-schema.mjs`'s
950
1100
  // `io.resolve`) was validator-only until now — no production caller passed
@@ -1000,6 +1150,19 @@ export function findBoundaryConfigViolations(module, io = {}) {
1000
1150
  violations.push(...findCoverageViolations(coverage));
1001
1151
  }
1002
1152
 
1153
+ // The markdown document track — the seventh top-level law, declared here and
1154
+ // executed at graph level (`../analysis/markdown.mjs`): machine-readable
1155
+ // markers inside tracked markdown documents, each resolved to an edge from
1156
+ // the document's own project to the project that exports the named symbol.
1157
+ // Absent means "no document track" — the workspace decision this key exists
1158
+ // to state, and the state every config-absent run must stay byte-identical
1159
+ // to. Present and malformed is refused here, loudly, for the reason the two
1160
+ // blocks above state: a row this reader cannot understand is a document law
1161
+ // that would not run while the policy still says it does.
1162
+ if (markdown !== undefined) {
1163
+ violations.push(...findMarkdownViolations(markdown));
1164
+ }
1165
+
1003
1166
  // Absent means "nothing is suppressed", which is the only default that fails
1004
1167
  // toward reporting — unlike the eight options above, where a missing value
1005
1168
  // would be a second copy of something ESLint also reads and this module has
@@ -1095,7 +1258,10 @@ export function findBoundaryConfigViolations(module, io = {}) {
1095
1258
  * `coverage.exempt` is that provider's one channel for the same decision —
1096
1259
  * and the refusal lives in `./commands/policy.mjs`'s `resolvePolicy` and
1097
1260
  * `./providers/native/model.mjs`'s inline-policy check, because only they
1098
- * know which provider is reading.
1261
+ * know which provider is reading. `markdown` is the seventh: the document
1262
+ * track (`findMarkdownViolations` above owns the shape), read into the graph
1263
+ * by `../analysis/markdown.mjs` on every provider, because documents belong
1264
+ * to projects under all three.
1099
1265
  *
1100
1266
  * The name says `.json` and the list binds both file dialects: `loadModulePolicy`
1101
1267
  * runs the same check over an ES module's exports, which is what makes a
@@ -1109,6 +1275,7 @@ const JSON_POLICY_KEYS = [
1109
1275
  "fitness",
1110
1276
  "customRules",
1111
1277
  "coverage",
1278
+ "markdown",
1112
1279
  ];
1113
1280
 
1114
1281
  /**
@@ -1185,7 +1352,7 @@ export function policyKeyViolations(parsed, { allowSchema }) {
1185
1352
  * inline one.
1186
1353
  * @param {string[]} [extraViolations] Violations the caller already found that
1187
1354
  * `findBoundaryConfigViolations` does not check on its own.
1188
- * @returns {{ depConstraints: object[], options: object, suppressions: object[], fitness?: object[], customRules?: object[], coverage?: object }}
1355
+ * @returns {{ depConstraints: object[], options: object, suppressions: object[], fitness?: object[], customRules?: object[], coverage?: object, markdown?: object }}
1189
1356
  * `fitness` and `customRules` are present only when the config declares
1190
1357
  * them — a workspace without one carries no key, the same "absent is a
1191
1358
  * decision" posture `cli.mjs`'s `check` uses for a missing
@@ -1209,6 +1376,7 @@ export function policyFrom(parsed, sourceLabel, extraViolations = []) {
1209
1376
  ...(parsed.fitness === undefined ? {} : { fitness: parsed.fitness }),
1210
1377
  ...(parsed.customRules === undefined ? {} : { customRules: parsed.customRules }),
1211
1378
  ...(parsed.coverage === undefined ? {} : { coverage: parsed.coverage }),
1379
+ ...(parsed.markdown === undefined ? {} : { markdown: parsed.markdown }),
1212
1380
  };
1213
1381
  }
1214
1382
 
@@ -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",