@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.
- package/package.json +1 -1
- package/src/analysis/csharp.mjs +3 -1
- package/src/analysis/dotnet/csproj.mjs +5 -1
- package/src/analysis/dotnet/namespaces.mjs +1 -0
- package/src/analysis/go.mjs +6 -0
- package/src/analysis/java.mjs +2 -0
- package/src/analysis/jvm/gradle.mjs +3 -1
- package/src/analysis/jvm/maven.mjs +6 -1
- package/src/analysis/jvm/packages.mjs +1 -0
- package/src/analysis/jvm/resolve.mjs +4 -2
- package/src/analysis/kotlin.mjs +2 -0
- package/src/analysis/markdown.mjs +341 -0
- package/src/analysis/python.mjs +8 -0
- package/src/analysis/rust.mjs +5 -1
- package/src/analysis/source-util.mjs +6 -5
- package/src/analysis/typescript.mjs +148 -0
- package/src/architecture-intent/model.mjs +13 -8
- package/src/architecture-intent/selectors.mjs +2 -1
- package/src/commands/change-intent.mjs +10 -9
- package/src/commands/change.mjs +17 -1
- package/src/commands/check.mjs +177 -17
- package/src/commands/completeness.mjs +7 -6
- package/src/commands/context-command.mjs +12 -20
- package/src/commands/coverage-acceptance.mjs +46 -0
- package/src/commands/coverage-verdict.mjs +12 -2
- package/src/commands/custom-rules.mjs +1 -0
- package/src/commands/delta-classify.mjs +3 -0
- package/src/commands/delta-snapshot.mjs +3 -6
- package/src/commands/delta.mjs +35 -21
- package/src/commands/drift.mjs +1 -1
- package/src/commands/evaluation-primitives.mjs +4 -4
- package/src/commands/evolution.mjs +2 -0
- package/src/commands/explain.mjs +19 -20
- package/src/commands/graph.mjs +44 -15
- package/src/commands/health.mjs +4 -0
- package/src/commands/history.mjs +2 -0
- package/src/commands/plan-context-command.mjs +9 -2
- package/src/commands/policy.mjs +8 -5
- package/src/commands/provenance.mjs +8 -2
- package/src/commands/scenario-evaluation.mjs +1 -1
- package/src/commands/trajectory.mjs +2 -1
- package/src/config.mjs +170 -2
- package/src/custom-rules/host.mjs +3 -3
- package/src/errors.mjs +23 -1
- package/src/eslint-config.mjs +3 -5
- package/src/fixtures/evolution-lifecycle/workspace.mjs +15 -4
- package/src/go-work.mjs +1 -1
- package/src/governance/adr-registry.mjs +6 -2
- package/src/governance/debt-ledger.mjs +1 -1
- package/src/governance/decision-fitness.mjs +2 -0
- package/src/governance/decision-graph.mjs +1 -0
- package/src/governance/discovery-proposal.mjs +8 -2
- package/src/governance/evolution-event.mjs +42 -0
- package/src/governance/evolution-store.mjs +3 -2
- package/src/governance/fitness-registry.mjs +2 -3
- package/src/governance/preset-fingerprints.json +14 -14
- package/src/governance/profile-registry.mjs +22 -3
- package/src/governance/provenance-record.mjs +4 -1
- package/src/governance/reconcile-score.mjs +4 -0
- package/src/governance/row-schema.mjs +1 -3
- package/src/governance/verdict.mjs +1 -0
- package/src/governance/waiver.mjs +1 -0
- package/src/intent/intent-manifest.json +6 -6
- package/src/intent/mask-non-code.mjs +1 -0
- package/src/lsp/diagnostics.mjs +3 -2
- package/src/lsp/protocol.mjs +2 -1
- package/src/lsp/server.mjs +3 -0
- package/src/lsp/workspace-index.mjs +3 -1
- package/src/providers/moon.mjs +5 -5
- package/src/providers/native/differential.fixtures.mjs +29 -11
- package/src/providers/native/index.mjs +2 -1
- package/src/providers/native/model.mjs +4 -0
- package/src/report/envelope-shape.mjs +2 -0
- package/src/report/sarif.mjs +21 -8
- package/src/report/snapshot-text.mjs +3 -3
- package/src/report/text.mjs +10 -2
- package/src/rules/index.mjs +30 -0
- package/src/rules/match.mjs +7 -5
- package/src/rules/specifiers.mjs +2 -0
- package/src/rules/tags.mjs +3 -2
- package/src/rules/topology.mjs +6 -1
- package/src/verdict.mjs +33 -2
- 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.
|
|
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",
|
package/src/analysis/csharp.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 = [];
|
package/src/analysis/go.mjs
CHANGED
|
@@ -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) {
|
package/src/analysis/java.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
/**
|
package/src/analysis/kotlin.mjs
CHANGED
|
@@ -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
|
+
}
|