@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ecoma-io/archkeep",
3
- "version": "0.23.0",
3
+ "version": "0.24.1",
4
4
  "description": "Architecture authority for human and agentic software development — deterministic, evidence-backed enforcement of declared architecture.",
5
5
  "keywords": [
6
6
  "architecture",
@@ -188,7 +188,7 @@ function withoutGenericArguments(rhs) {
188
188
  * @returns {{ specifier: string, importableName: string|null, specifierStartInBody: number }|null} `null`
189
189
  * when the body is a using STATEMENT's shape, not a directive's.
190
190
  */
191
- export function classifyUsingBody(body) {
191
+ function classifyUsingBody(body) {
192
192
  const trimmed = body.trim();
193
193
  if (trimmed === "") return null;
194
194
  const staticForm = STATIC_FORM.exec(trimmed);
@@ -236,6 +236,7 @@ export function classifyUsingBody(body) {
236
236
  * @returns {{ specifier: string, importableName: string|null, offset: number }[]}
237
237
  */
238
238
  export function parseCSharpDirectiveSites(csharpText) {
239
+ // used by its own test
239
240
  const source = maskCSharpComments(csharpText);
240
241
  const sites = [];
241
242
  for (const match of source.matchAll(CS_USING_BODY)) {
@@ -305,6 +306,7 @@ export function parseCSharpDirectiveSites(csharpText) {
305
306
  * each naming its line. Empty when the directives read fully.
306
307
  */
307
308
  export function csharpDirectiveMalformations(csharpText) {
309
+ // used by its own test
308
310
  const source = maskCSharpComments(csharpText);
309
311
  /** @type {string[]} */
310
312
  const reasons = [];
@@ -122,6 +122,7 @@ function collectElements(node, name) {
122
122
  * { project?: undefined, reason: string }}
123
123
  */
124
124
  export function parseCsproj(text) {
125
+ // used by its own test
125
126
  const { parser, validate, error } = xmlParser();
126
127
  if (parser === null) {
127
128
  return { reason: `${XML_PARSER} is unavailable (${error})` };
@@ -153,6 +154,7 @@ export function parseCsproj(text) {
153
154
  * @returns {{ paths: string[], problems: string[] }}
154
155
  */
155
156
  export function projectReferenceFacts(project, csprojDir) {
157
+ // used by its own test
156
158
  const paths = [];
157
159
  const problems = [];
158
160
  for (const ref of collectElements(project, "ProjectReference")) {
@@ -184,6 +186,7 @@ export function projectReferenceFacts(project, csprojDir) {
184
186
  * @returns {string[]}
185
187
  */
186
188
  export function usingNamespacesOf(project) {
189
+ // used by its own test
187
190
  const namespaces = [];
188
191
  for (const item of collectElements(project, "Using")) {
189
192
  const include = textOf(item["@_Include"] ?? item.Include);
@@ -215,6 +218,7 @@ export function usingNamespacesOf(project) {
215
218
  * { entry?: undefined, problems?: undefined, reason: string }}
216
219
  */
217
220
  export function csprojEntryOf(projectName, csprojPath, text) {
221
+ // used by its own test
218
222
  const parsed = parseCsproj(text);
219
223
  if (parsed.reason !== undefined) return { reason: parsed.reason };
220
224
  // A manifest at the workspace root has no separator: `lastIndexOf` answers
@@ -251,7 +255,7 @@ export function csprojEntryOf(projectName, csprojPath, text) {
251
255
  * usingEdges: { source: string, target: string, sourceFile: string, type: string }[],
252
256
  * failures: { sourceFile: string, line: null, column: null, reason: string }[] }}
253
257
  */
254
- export const csprojModelOf = perWorkspace(({ projects, filesOf, readFile }) => {
258
+ const csprojModelOf = perWorkspace(({ projects, filesOf, readFile }) => {
255
259
  const entries = [];
256
260
  const failures = [];
257
261
  const identity = new Map();
@@ -62,6 +62,7 @@ import { maskCSharpComments } from "./mask.mjs";
62
62
  * which declares no name the index can carry.
63
63
  */
64
64
  export function parseCSharpNamespaceDeclarations(maskedText) {
65
+ // used by its own test
65
66
  const CS_NAMESPACE_DECLARATION =
66
67
  /(?:^\uFEFF?|\n)[ \t]*namespace[ \t]+([\p{L}_][\p{L}\p{Nd}_]*(?:[ \t]*\.[ \t]*[\p{L}_][\p{L}\p{Nd}_]*)*)[ \t]*(?:;|(?=[{;\r\n]|$))/gu;
67
68
  const declarations = [];
@@ -132,6 +132,7 @@ import {
132
132
  * character shifts nothing any record points at.
133
133
  */
134
134
  export function parseGoModulePath(goModText) {
135
+ // used by its own test
135
136
  const match = goModText.replace(/^\uFEFF/, "").match(/^module\s+(\S+)/m);
136
137
  if (!match) return null;
137
138
  const token = match[1];
@@ -170,6 +171,7 @@ const LEXICAL_START = /\/\/|\/\*|["'`]/g;
170
171
  * @returns {string} Same length as `goText`.
171
172
  */
172
173
  export function maskGoComments(goText) {
174
+ // used by its own test
173
175
  const scan = new RegExp(LEXICAL_START.source, "g");
174
176
  let masked = "";
175
177
  let copied = 0;
@@ -298,6 +300,7 @@ const GO_ALIAS_AT = /[\p{L}_.][\p{L}\p{Nd}_.]*\s+/uy;
298
300
  * seen on. Empty when the imports read fully.
299
301
  */
300
302
  export function goImportMalformations(goText) {
303
+ // used by its own test
301
304
  const source = blankGoStringContents(maskGoComments(goText));
302
305
  /** @type {string[]} */
303
306
  const reasons = [];
@@ -417,6 +420,7 @@ const GO_IMPORT_ALIAS = "[\\p{L}_.][\\p{L}\\p{Nd}_.]*";
417
420
  * @returns {{ specifier: string, offset: number }[]}
418
421
  */
419
422
  export function parseGoImportSites(goText) {
423
+ // used by its own test
420
424
  const source = maskGoComments(goText);
421
425
  const sites = [];
422
426
  // import "p" | import alias "p" | import _ "p" | import . "p" — the path
@@ -457,6 +461,7 @@ export function parseGoImportSites(goText) {
457
461
 
458
462
  /** Every import path in a .go file (single-form and block-form), deduped. */
459
463
  export function parseGoImports(goText) {
464
+ // used by its own test
460
465
  return [...new Set(parseGoImportSites(goText).map((site) => site.specifier))];
461
466
  }
462
467
 
@@ -604,6 +609,7 @@ const isUnderModule = (importPath, modulePath) =>
604
609
  * no module claims `importPath`.
605
610
  */
606
611
  export function resolveGoModule(importPath, modules) {
612
+ // used by its own test
607
613
  let matched = "";
608
614
  let project = null;
609
615
  for (const { modulePath, project: target } of modules) {
@@ -69,6 +69,7 @@ import {
69
69
  * @returns {{ specifier: string, importableName: string, offset: number }[]}
70
70
  */
71
71
  export function parseJavaImportSites(javaText) {
72
+ // used by its own test
72
73
  const source = maskJavaComments(javaText);
73
74
  // Anchored to a line head — through a leading UTF-8 BOM, matched rather than
74
75
  // stripped so offsets keep indexing the bytes on disk, the same anchor
@@ -132,6 +133,7 @@ export function parseJavaImportSites(javaText) {
132
133
  * read fully.
133
134
  */
134
135
  export function javaImportMalformations(javaText) {
136
+ // used by its own test
135
137
  const source = maskJavaComments(javaText);
136
138
  const JAVA_IMPORT_HEAD = /(?:^\uFEFF?|[\n;])[ \t]*(?:import[ \t]+)/gu;
137
139
  /** @type {string[]} */
@@ -90,6 +90,7 @@ import { fileFailure, perWorkspace, refuseUnreadTree } from "../source-util.mjs"
90
90
  * { rootProjectName?: undefined, includedProjects?: undefined, reason: string }}
91
91
  */
92
92
  export function parseGradleSettings(text) {
93
+ // used by its own test
93
94
  // First, remove block comments to avoid matching include statements inside them
94
95
  let processedText = text.replace(/\/\*[\s\S]*?\*\//g, "");
95
96
 
@@ -187,6 +188,7 @@ export function parseGradleSettings(text) {
187
188
  * { projectDependencies?: undefined, reason: string }}
188
189
  */
189
190
  export function parseGradleBuild(text) {
191
+ // used by its own test
190
192
  const lines = text.split(/\r?\n/);
191
193
  const projectDependencies = [];
192
194
  let inString = false;
@@ -480,7 +482,7 @@ function buildGradleModel(workspace) {
480
482
  return { entries, pathToDirectory, failures };
481
483
  }
482
484
 
483
- export const gradleModelOf = perWorkspace(buildGradleModel);
485
+ const gradleModelOf = perWorkspace(buildGradleModel);
484
486
 
485
487
  /**
486
488
  * Manifest-edge resolver: one edge per declared project dependency whose
@@ -128,6 +128,7 @@ const textOf = (value) => {
128
128
  * { project?: undefined, reason: string }}
129
129
  */
130
130
  export function parsePomProject(text) {
131
+ // used by its own test
131
132
  const { parser, validate, error } = xmlParser();
132
133
  if (parser === null) {
133
134
  return { reason: `${XML_PARSER} is unavailable (${error})` };
@@ -179,6 +180,7 @@ export function parsePomProject(text) {
179
180
  * @returns {{ entry: PomEntry, reason?: undefined } | { entry?: undefined, reason: string }}
180
181
  */
181
182
  export function pomEntryOf(projectName, pomPath, text) {
183
+ // used by its own test
182
184
  const parsed = parsePomProject(text);
183
185
  if (parsed.reason !== undefined) return { reason: parsed.reason };
184
186
  const project = parsed.project;
@@ -252,6 +254,7 @@ export function pomEntryOf(projectName, pomPath, text) {
252
254
  * @returns {string|null}
253
255
  */
254
256
  export function parentPomPath(pomPath, relativePath) {
257
+ // used by its own test
255
258
  const dir = pomPath.includes("/") ? pomPath.slice(0, pomPath.lastIndexOf("/")) : "";
256
259
  const direct = resolveWithinWorkspace(dir, relativePath);
257
260
  if (direct === null) return null;
@@ -270,6 +273,7 @@ export function parentPomPath(pomPath, relativePath) {
270
273
  * @returns {Record<string, string>}
271
274
  */
272
275
  export function mavenConfigProperties(readFile, candidatePaths) {
276
+ // used by its own test
273
277
  const props = /** @type {Record<string, string>} */ ({});
274
278
  for (const path of candidatePaths) {
275
279
  const text = readFile(path);
@@ -300,6 +304,7 @@ export function mavenConfigProperties(readFile, candidatePaths) {
300
304
  * @returns {{ value: string, resolved: boolean }}
301
305
  */
302
306
  export function interpolateCoordinate(value, props, builtins) {
307
+ // used by its own test
303
308
  let resolved = true;
304
309
  const out = value.replace(/\$\{([^}]+)\}/g, (_, key) => {
305
310
  if (Object.hasOwn(builtins, key)) return builtins[key];
@@ -557,7 +562,7 @@ function buildMavenModel(workspace) {
557
562
  return { entries, identityHolders, failures };
558
563
  }
559
564
 
560
- export const mavenModelOf = perWorkspace(buildMavenModel);
565
+ export const mavenModelOf = perWorkspace(buildMavenModel); // used by its own test
561
566
 
562
567
  /**
563
568
  * Manifest-edge resolver: one edge per declared dependency whose coordinates
@@ -81,6 +81,7 @@ import { maskJavaComments, maskKotlinComments } from "./mask.mjs";
81
81
  * package file, which declares no name the index can carry.
82
82
  */
83
83
  export function parseJvmPackageDeclaration(maskedText) {
84
+ // used by its own test
84
85
  // An identifier segment starts with a letter (Unicode, via \p{L}), `_`, or
85
86
  // `$`, continues with those plus digits; segments join on optional spaces
86
87
  // around the dot, because `com . example` is legal if absurd. The match is
@@ -40,7 +40,7 @@ import { resolveJvmPackagePrefix } from "./packages.mjs";
40
40
  * their own explicit imports, which is why an explicit `import java.util.List`
41
41
  * classifies as ordinary external rather than by this table.
42
42
  */
43
- export const JAVA_DEFAULT_IMPORT_ROOTS = Object.freeze(["java.lang"]);
43
+ export const JAVA_DEFAULT_IMPORT_ROOTS = Object.freeze(["java.lang"]); // used by its own test
44
44
 
45
45
  /**
46
46
  * Kotlin's default imports (kotlinlang.org, "Default imports", page dated
@@ -50,6 +50,7 @@ export const JAVA_DEFAULT_IMPORT_ROOTS = Object.freeze(["java.lang"]);
50
50
  * testable classification of names the compiler brings in unasked.
51
51
  */
52
52
  export const KOTLIN_DEFAULT_IMPORT_ROOTS = Object.freeze([
53
+ // used by its own test
53
54
  "kotlin",
54
55
  "kotlin.annotation",
55
56
  "kotlin.collections",
@@ -70,7 +71,7 @@ const DEFAULT_IMPORT_ROOTS_BY_LANGUAGE = {
70
71
  };
71
72
 
72
73
  /** The default-import roots a language's files carry, or `[]` when unknown. */
73
- export const defaultImportRootsFor = (language) => DEFAULT_IMPORT_ROOTS_BY_LANGUAGE[language] ?? [];
74
+ export const defaultImportRootsFor = (language) => DEFAULT_IMPORT_ROOTS_BY_LANGUAGE[language] ?? []; // used by its own test
74
75
 
75
76
  /**
76
77
  * True when `specifier` falls under one of `roots`: equal to a root or a dot-
@@ -83,6 +84,7 @@ export const defaultImportRootsFor = (language) => DEFAULT_IMPORT_ROOTS_BY_LANGU
83
84
  * @returns {boolean}
84
85
  */
85
86
  export const underAnyRoot = (specifier, roots) =>
87
+ // used by its own test
86
88
  roots.some((root) => specifier === root || specifier.startsWith(`${root}.`));
87
89
 
88
90
  /**
@@ -69,6 +69,7 @@ const KOTLIN_IMPORT = new RegExp(
69
69
  * @returns {{ specifier: string, importableName: string, offset: number }[]}
70
70
  */
71
71
  export function parseKotlinImportSites(kotlinText) {
72
+ // used by its own test
72
73
  // A shebang needs no handling of its own: `#!…` cannot anchor an import
73
74
  // match (`import` must follow a line head, `;`, or newline), and masking
74
75
  // runs before anything reads the text anyway.
@@ -112,6 +113,7 @@ const importableNameOf = (name) => (name.endsWith(".*") ? name.slice(0, -2) : na
112
113
  * @returns {string[]} Reasons, at most one per malformation kind.
113
114
  */
114
115
  export function kotlinImportMalformations(kotlinText) {
116
+ // used by its own test
115
117
  const source = maskKotlinComments(kotlinText);
116
118
  const KOTLIN_IMPORT_HEAD = /(?:^\uFEFF?|[\n;])[ \t]*(?:import[ \t]+)/gu;
117
119
  /** @type {string[]} */
@@ -0,0 +1,341 @@
1
+ /**
2
+ * The markdown document track: machine-readable markers inside tracked
3
+ * documents, resolved to graph edges the existing tag rows judge.
4
+ *
5
+ * A boundary law can declare a `markdown` block (`../config.mjs`'s
6
+ * `findMarkdownViolations` owns the shape): a set of document globs and a set
7
+ * of marker rows, each a regular expression whose first capture group names an
8
+ * exported symbol — the `<!-- @api Button -->` an architecture-intent
9
+ * document pairs with the component it documents. This module turns those
10
+ * markers into `{source, target, type}` edges: source is the project that owns
11
+ * the document, target is the project that exports the named symbol, and the
12
+ * type is the row's declared edge kind (`resolvedExportOwner`, the one kind
13
+ * today). Everything downstream of the fold is machinery that already existed:
14
+ * `../providers/native/graph.mjs`'s `mergeDeclaredEdges` folds the edges into
15
+ * the graph the way it folds the declared manifest track, and
16
+ * `../rules/edge-constraints.mjs`'s `judgeEdge` — the same function
17
+ * `declaredEdgeViolationsForCheck` runs `implicit` edges through — decides
18
+ * each one against `depConstraints`. No rule knows this track exists, which is
19
+ * the point: a document pairing is a project-to-project claim, and the claims
20
+ * a workspace already wrote are the ones that should judge it.
21
+ *
22
+ * ## What this module deliberately is not
23
+ *
24
+ * It does not render markdown, lint prose, or index free text. The only bytes
25
+ * read past the extension are lines matched by a configured marker row —
26
+ * declared, machine-readable claims, the same contract the declared manifest
27
+ * track reads a pom or a csproj under. A document whose every line matches no
28
+ * row contributes nothing, and a workspace that declares no `markdown` block
29
+ * pays nothing at all: the fold is unreachable without the block, so a
30
+ * config-absent run is byte-identical to one this module never existed for.
31
+ *
32
+ * ## The export index, and why re-exports cannot own a symbol
33
+ *
34
+ * Resolution asks "which project publishes `Button`", and the engine has no
35
+ * export table to ask — so the fold builds one, scanning every project-owned
36
+ * TypeScript-language file's top-level exports once (`./typescript.mjs`'s
37
+ * `exportedNamesOf`; `.vue` single-file components are not scanned, because
38
+ * their public surface is the TypeScript barrel that re-exports it, and the
39
+ * barrel is scanned). Names are kept in two tiers: a name a file DECLARES is
40
+ * its project's, and wins over any number of projects that merely RE-EXPORT
41
+ * it — an umbrella barrel re-exporting a library must not turn that library's
42
+ * every symbol into an ambiguous claim. A name in neither tier is a document
43
+ * claim the graph cannot honor, and the marker's file fails whole: a pairing
44
+ * the tree cannot establish must never read as a clean one (`../../../AGENTS.md`,
45
+ * "an empty result is a claim, not a shrug") — the same refusal posture the
46
+ * declared manifest track holds for a pom it cannot read, and for the same
47
+ * reason: the run would otherwise report a verdict computed over a track that
48
+ * silently dropped a declared edge.
49
+ */
50
+
51
+ import { safeMatchesGlob } from "../rules/match.mjs";
52
+ import { exportedNamesOf } from "./typescript.mjs";
53
+ import { languageOf } from "./registry.mjs";
54
+ import { fileFailure } from "./source-util.mjs";
55
+
56
+ /** The extension a file must carry to be a candidate document. */
57
+ const MARKDOWN_EXTENSION = ".md";
58
+
59
+ /**
60
+ * The document track's fold, over an already-built context: every marker the
61
+ * law's rows match in every document the law's globs include, resolved against
62
+ * the workspace's exports.
63
+ *
64
+ * Edges are returned deduped by `(source, target, type)` — two markers in two
65
+ * documents naming the same symbol are one dependency at project grain, the
66
+ * same grain every other track reports at — and the caller folds them into the
67
+ * graph with `../providers/native/graph.mjs`'s `mergeDeclaredEdges`, which
68
+ * enforces the same key against the edges already there.
69
+ *
70
+ * Failures are WHOLE-FILE failures on the document that earned them, never
71
+ * positioned rows: a marker the tree cannot resolve means the document's
72
+ * pairing claim went unjudged, so the file has no verdict to claim and the
73
+ * run's coverage says so (`unchecked`), with the reason naming the line and
74
+ * the name. A positioned row would read as a resolved no — a verdict the run
75
+ * does not hold.
76
+ *
77
+ * @param {{ tracked: string[], owned: {file: string, project: string}[],
78
+ * readFile: (path: string) => string|null, workspace: object,
79
+ * markdown: {include: string[], markers: {pattern: string, edge: string}[]} }} input
80
+ * `tracked` in the caller's file order (the run's determinism basis),
81
+ * `owned` the context's file→project map, `readFile` the workspace's own
82
+ * reader, `markdown` the loaded policy's markdown block.
83
+ * @returns {{ edges: {source: string, target: string, type: string}[],
84
+ * claims: {source: string, target: string, type: string, file: string,
85
+ * line: number, column: number, name: string}[],
86
+ * failures: {sourceFile: string, reason: string}[], documents: number,
87
+ * judged: number, resolved: number, selfPaired: number,
88
+ * includeCounts: number[], rowMatches: number[] }}
89
+ * `edges` is the deduped graph fold; `claims` is the same resolution at
90
+ * marker grain — one record per resolved marker, carrying the document
91
+ * position the caller's verdicts must point at, which the deduped list
92
+ * deliberately does not. `documents` counts the files the include globs
93
+ * selected and read. `judged` counts markers extracted, `resolved` the
94
+ * ones that became an edge, `selfPaired` the ones whose document and symbol
95
+ * live in the same project (a legal claim that draws no edge — a project
96
+ * cannot depend on itself, the rule every track holds), `includeCounts` the
97
+ * per-pattern document counts and `rowMatches` the per-row match counts the
98
+ * caller's dead-law gate reads.
99
+ */
100
+ export function foldMarkdownTrack({ tracked, owned, readFile, workspace, markdown }) {
101
+ const documents = markdownIncludedFiles({ include: markdown.include, tracked });
102
+ const includeCounts = markdown.include.map(
103
+ (pattern) => documents.filter((file) => matchesInclude(file, pattern)).length,
104
+ );
105
+ /** @type {{ row: number, file: string, line: number, column: number, name: string }[]} */
106
+ const markers = [];
107
+ /** @type {{sourceFile: string, reason: string}[]} */
108
+ const failures = [];
109
+ const rowMatches = markdown.markers.map(() => 0);
110
+ /** @type {(RegExp|null)[]} */
111
+ const compiled = markdown.markers.map((row) => {
112
+ try {
113
+ return new RegExp(row.pattern, "u");
114
+ } catch {
115
+ // Load-time validation refuses an uncompilable pattern; this arm exists
116
+ // so a hand-built config in a test degrades to "this row matches
117
+ // nothing" instead of throwing past every caller that guards.
118
+ return null;
119
+ }
120
+ });
121
+
122
+ for (const file of documents) {
123
+ const text = readFile(file);
124
+ if (text === null) {
125
+ failures.push(
126
+ fileFailure(
127
+ file,
128
+ "cannot be read — the markdown track matched it, so its markers cannot be extracted " +
129
+ "and its document claims cannot be judged",
130
+ ),
131
+ );
132
+ continue;
133
+ }
134
+ const lines = text.split("\n");
135
+ for (const [rowIndex, regex] of compiled.entries()) {
136
+ if (regex === null) continue;
137
+ for (const [at, line] of lines.entries()) {
138
+ const match = regex.exec(line);
139
+ if (match === null) continue;
140
+ rowMatches[rowIndex] += 1;
141
+ const name = match[1] ?? "";
142
+ if (name.trim() === "") {
143
+ failures.push(
144
+ fileFailure(
145
+ file,
146
+ `line ${at + 1}: the marker matches markdown.markers[${rowIndex}] but captures an ` +
147
+ `empty name — the row's first capture group must carry the exported symbol the ` +
148
+ `document claims`,
149
+ ),
150
+ );
151
+ continue;
152
+ }
153
+ markers.push({ row: rowIndex, file, line: at + 1, column: (match.index ?? 0) + 1, name });
154
+ }
155
+ }
156
+ }
157
+
158
+ const edges = [];
159
+ /** @type {{source: string, target: string, type: string, file: string,
160
+ * line: number, column: number, name: string}[]} */
161
+ const claims = [];
162
+ let resolved = 0;
163
+ let selfPaired = 0;
164
+ if (markers.length > 0) {
165
+ const projectOfFile = new Map(owned.map(({ file, project }) => [file, project]));
166
+ const index = exportIndexOf({ owned, readFile, workspace });
167
+ for (const marker of markers) {
168
+ // Declared beats re-exported: see this file's header. Candidates are
169
+ // sorted because a Set's insertion order is file order, and a message
170
+ // that names two projects must not name them in a different order on a
171
+ // different checkout.
172
+ const declaredOwners = index.declared.get(marker.name);
173
+ const candidates = declaredOwners ?? index.reexported.get(marker.name) ?? new Set();
174
+ if (candidates.size === 0) {
175
+ failures.push(
176
+ fileFailure(
177
+ marker.file,
178
+ `line ${marker.line}: the marker names '${marker.name}', which no tracked project ` +
179
+ `exports — the pairing this document claims cannot be resolved to a project, so ` +
180
+ `its edge was not drawn. Exports are scanned from TypeScript-language project ` +
181
+ `files; either the symbol does not exist, is not exported from a project file, or ` +
182
+ `its name is misspelt here`,
183
+ ),
184
+ );
185
+ continue;
186
+ }
187
+ if (candidates.size > 1) {
188
+ failures.push(
189
+ fileFailure(
190
+ marker.file,
191
+ `line ${marker.line}: the marker names '${marker.name}', which more than one project ` +
192
+ `exports — ${[...candidates]
193
+ .sort()
194
+ .map((name) => `'${name}'`)
195
+ .join(", ")} — and a ` +
196
+ `claim this tree cannot read one way must not be read as kept. Qualify the marker ` +
197
+ `or narrow the export surface so the name resolves to one project`,
198
+ ),
199
+ );
200
+ continue;
201
+ }
202
+ const source = projectOfFile.get(marker.file);
203
+ const [target] = candidates;
204
+ if (source === undefined) {
205
+ failures.push(
206
+ fileFailure(
207
+ marker.file,
208
+ `line ${marker.line}: the document is owned by no project, so the edge its marker ` +
209
+ `claims has no source — include the document's directory in a project, or narrow ` +
210
+ `markdown.include to documents that live inside one`,
211
+ ),
212
+ );
213
+ continue;
214
+ }
215
+ if (source === target) {
216
+ // A document pairing its own project's symbol: a legal claim that
217
+ // carries no boundary weight — no project depends on itself, the rule
218
+ // `buildDependencies` holds for every track — but a claim the row DID
219
+ // match and resolve, so it counts as judged rather than vanishing.
220
+ selfPaired += 1;
221
+ continue;
222
+ }
223
+ resolved += 1;
224
+ const edge = { source, target, type: markdown.markers[marker.row].edge };
225
+ edges.push(edge);
226
+ claims.push({
227
+ ...edge,
228
+ file: marker.file,
229
+ line: marker.line,
230
+ column: marker.column,
231
+ name: marker.name,
232
+ });
233
+ }
234
+ }
235
+
236
+ return {
237
+ edges: dedupeEdges(edges),
238
+ claims,
239
+ failures,
240
+ documents: documents.length,
241
+ judged: markers.length,
242
+ resolved,
243
+ selfPaired,
244
+ includeCounts,
245
+ rowMatches,
246
+ };
247
+ }
248
+
249
+ /**
250
+ * The tracked documents the law's globs select — tracked order preserved, and
251
+ * restricted to markdown files: the track reads documents, and a glob whose
252
+ * every match is some other kind of file selects nothing (loudly — the
253
+ * caller's dead-law gate counts what each pattern actually matched).
254
+ *
255
+ * Exported for the dead-law gate and its tests, which need the same selection
256
+ * the fold makes without re-deriving it a second way.
257
+ *
258
+ * @param {{include: string[], tracked: string[]}} input
259
+ * @returns {string[]}
260
+ */
261
+ export function markdownIncludedFiles({ include, tracked }) {
262
+ // used by its own test
263
+ return tracked.filter(
264
+ (file) =>
265
+ file.endsWith(MARKDOWN_EXTENSION) && include.some((pattern) => matchesInclude(file, pattern)),
266
+ );
267
+ }
268
+
269
+ /**
270
+ * Whether a tracked file matches one include pattern — `./rules/match.mjs`'s
271
+ * `safeMatchesGlob`, the one matcher `boundarySuppressions` and
272
+ * `coverage.exempt` rows use, so a glob spells the same language here it does
273
+ * everywhere else in the policy.
274
+ *
275
+ * @param {string} file Workspace-relative path.
276
+ * @param {string} pattern Workspace-relative glob.
277
+ * @returns {boolean}
278
+ */
279
+ function matchesInclude(file, pattern) {
280
+ return safeMatchesGlob(file, pattern);
281
+ }
282
+
283
+ /**
284
+ * The workspace's export index, built once per fold: every project-owned
285
+ * TypeScript-language file's exported names, keyed by name to the set of
286
+ * projects that declare or re-export them.
287
+ *
288
+ * A file whose read fails contributes nothing — and no failure of its own:
289
+ * that file's analysis already reports the read to the caller's own funnel,
290
+ * and a second row naming the same bytes would count one hole twice. A file
291
+ * whose parse fails contributes what TypeScript could read, the posture
292
+ * `exportedNamesOf` itself holds.
293
+ *
294
+ * @param {{ owned: {file: string, project: string}[], readFile: (path: string) => string|null,
295
+ * workspace: object }} input
296
+ * @returns {{ declared: Map<string, Set<string>>, reexported: Map<string, Set<string>> }}
297
+ */
298
+ function exportIndexOf({ owned, readFile, workspace }) {
299
+ /** @type {Map<string, Set<string>>} */
300
+ const declared = new Map();
301
+ /** @type {Map<string, Set<string>>} */
302
+ const reexported = new Map();
303
+ const add = (map, names, project) => {
304
+ for (const name of names) {
305
+ if (name === "") continue;
306
+ const holders = map.get(name) ?? new Set();
307
+ holders.add(project);
308
+ map.set(name, holders);
309
+ }
310
+ };
311
+ for (const { file, project } of owned) {
312
+ // `.vue` single-file components are TypeScript too, but their script
313
+ // blocks live behind the SFC parser (`./vue.mjs`), and this index needs
314
+ // only what a barrel already re-exports — see this file's header.
315
+ if (languageOf(file) !== "typescript") continue;
316
+ const text = readFile(file);
317
+ if (text === null) continue;
318
+ const names = exportedNamesOf({ sourceFile: file, text, workspace });
319
+ add(declared, names.declared, project);
320
+ add(reexported, names.reexported, project);
321
+ }
322
+ return { declared, reexported };
323
+ }
324
+
325
+ /**
326
+ * One edge per `(source, target, type)` — the same canonical key
327
+ * `buildDependencies` reduces import sites by, applied here so the fold's own
328
+ * answer is canonical before the merge adds its dedup on top.
329
+ *
330
+ * @param {{source: string, target: string, type: string}[]} edges
331
+ * @returns {{source: string, target: string, type: string}[]}
332
+ */
333
+ function dedupeEdges(edges) {
334
+ const seen = new Set();
335
+ return edges.filter((edge) => {
336
+ const key = JSON.stringify([edge.source, edge.target, edge.type]);
337
+ if (seen.has(key)) return false;
338
+ seen.add(key);
339
+ return true;
340
+ });
341
+ }