@ecoma-io/archkeep 0.25.0 → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli.mjs +166 -506
- package/commands.mjs +7 -1
- package/package.json +1 -1
- package/src/architecture-intent/judge.mjs +1 -1
- package/src/architecture-intent/model.mjs +34 -0
- package/src/canonical.mjs +2 -1
- package/src/commands/adr-for-workspace.mjs +63 -0
- package/src/commands/analyze-capability.mjs +13 -0
- package/src/commands/change-intent.mjs +11 -0
- package/src/commands/change.mjs +247 -34
- package/src/commands/check-capability.mjs +13 -0
- package/src/commands/check.mjs +13 -3
- package/src/commands/compare-capability.mjs +20 -0
- package/src/commands/completeness.mjs +12 -0
- package/src/commands/context-command.mjs +31 -0
- package/src/commands/coverage-verdict.mjs +15 -2
- package/src/commands/debt.mjs +30 -0
- package/src/commands/decisions.mjs +59 -10
- package/src/commands/delta.mjs +263 -49
- package/src/commands/diff.mjs +45 -0
- package/src/commands/discover.mjs +37 -0
- package/src/commands/drift.mjs +45 -0
- package/src/commands/evolution.mjs +22 -2
- package/src/commands/explain-capability.mjs +11 -0
- package/src/commands/explain.mjs +24 -0
- package/src/commands/fitness.mjs +119 -10
- package/src/commands/govern-capability.mjs +16 -0
- package/src/commands/graph.mjs +39 -0
- package/src/commands/health.mjs +34 -0
- package/src/commands/history.mjs +85 -2
- package/src/commands/impact.mjs +23 -0
- package/src/commands/inspect-capability.mjs +16 -0
- package/src/commands/plan-context-command.mjs +35 -0
- package/src/commands/policy.mjs +42 -4
- package/src/commands/provenance-command.mjs +15 -24
- package/src/commands/report.mjs +38 -0
- package/src/commands/rules-capability.mjs +16 -0
- package/src/commands/rules.mjs +3 -1
- package/src/commands/scenario.mjs +23 -0
- package/src/commands/trajectory.mjs +35 -0
- package/src/commands/waivers.mjs +30 -0
- package/src/corpus/goldens/.gitkeep +0 -0
- package/src/corpus/goldens/adr.json +62 -0
- package/src/corpus/goldens/adr.text +6 -0
- package/src/corpus/goldens/change.json +98 -0
- package/src/corpus/goldens/change.text +6 -0
- package/src/corpus/goldens/check.json +90 -0
- package/src/corpus/goldens/check.sarif +479 -0
- package/src/corpus/goldens/check.text +9 -0
- package/src/corpus/goldens/context.json +47 -0
- package/src/corpus/goldens/context.text +6 -0
- package/src/corpus/goldens/debt.json +59 -0
- package/src/corpus/goldens/debt.text +7 -0
- package/src/corpus/goldens/decisions.json +74 -0
- package/src/corpus/goldens/decisions.text +7 -0
- package/src/corpus/goldens/delta.json +94 -0
- package/src/corpus/goldens/delta.sarif +472 -0
- package/src/corpus/goldens/delta.text +5 -0
- package/src/corpus/goldens/diff.json +58 -0
- package/src/corpus/goldens/diff.text +4 -0
- package/src/corpus/goldens/discover.json +77 -0
- package/src/corpus/goldens/discover.text +10 -0
- package/src/corpus/goldens/drift.json +43 -0
- package/src/corpus/goldens/drift.text +3 -0
- package/src/corpus/goldens/evolution.json +328 -0
- package/src/corpus/goldens/evolution.text +32 -0
- package/src/corpus/goldens/explain.json +60 -0
- package/src/corpus/goldens/explain.text +7 -0
- package/src/corpus/goldens/fitness.json +45 -0
- package/src/corpus/goldens/fitness.text +3 -0
- package/src/corpus/goldens/graph.json +78 -0
- package/src/corpus/goldens/graph.text +10 -0
- package/src/corpus/goldens/health.json +72 -0
- package/src/corpus/goldens/health.text +10 -0
- package/src/corpus/goldens/help.text +104 -0
- package/src/corpus/goldens/history.json +43 -0
- package/src/corpus/goldens/history.text +4 -0
- package/src/corpus/goldens/impact.json +327 -0
- package/src/corpus/goldens/impact.text +10 -0
- package/src/corpus/goldens/provenance.json +453 -0
- package/src/corpus/goldens/provenance.text +14 -0
- package/src/corpus/goldens/reconcile.json +168 -0
- package/src/corpus/goldens/reconcile.text +3 -0
- package/src/corpus/goldens/report.json +168 -0
- package/src/corpus/goldens/report.text +42 -0
- package/src/corpus/goldens/rules verify.json +0 -0
- package/src/corpus/goldens/rules verify.text +0 -0
- package/src/corpus/goldens/scenario.json +441 -0
- package/src/corpus/goldens/scenario.text +28 -0
- package/src/corpus/goldens/trajectory.json +83 -0
- package/src/corpus/goldens/trajectory.text +10 -0
- package/src/corpus/goldens/usage-error.text +105 -0
- package/src/corpus/goldens/waivers.json +40 -0
- package/src/corpus/goldens/waivers.text +1 -0
- package/src/corpus/goldens-lsp/initialize.result.json +15 -0
- package/src/corpus/goldens-lsp/publish-cleared-empty.json +4 -0
- package/src/corpus/goldens-lsp/publish-fixed-empty.json +5 -0
- package/src/corpus/goldens-lsp/publish-unparseable.json +54 -0
- package/src/corpus/goldens-lsp/publish-violation.json +22 -0
- package/src/corpus/goldens-lsp/register-watched-files.json +72 -0
- package/src/go-work.mjs +8 -23
- package/src/governance/evolution-event.mjs +15 -0
- package/src/governance/evolution-store.mjs +59 -31
- package/src/governance/provenance-record.mjs +0 -152
- package/src/governance/verdict.mjs +26 -3
- package/src/intent/intent-manifest.json +14 -14
- package/src/lsp/server.mjs +5 -8
- package/src/lsp/workspace-index.mjs +55 -220
- package/src/options.mjs +10 -0
- package/src/providers/native/discover.mjs +13 -12
- package/src/providers/native/model.mjs +8 -3
- package/src/providers/nx-static.mjs +231 -0
- package/src/report/json.mjs +3 -2
- package/src/report/sarif.mjs +8 -3
- package/src/rules/edge-constraints.mjs +9 -0
- package/src/rules/index.mjs +26 -10
- package/src/rules/messages.mjs +64 -14
- package/src/rules/specifiers.mjs +21 -20
- package/src/tsconfig-paths.mjs +8 -14
- package/src/verdict.mjs +127 -16
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Nx provider's static acquisition — the graph built from the tree's own
|
|
3
|
+
* tracked `project.json` files, with no Nx process asked.
|
|
4
|
+
*
|
|
5
|
+
* This is the acquisition `../lsp/workspace-index.mjs` composed inline until
|
|
6
|
+
* the Phase 7 collapse: a language server is spawned by an editor, in a
|
|
7
|
+
* directory, with nothing else — no `nx` binary to resolve, and a spawn per
|
|
8
|
+
* index build would put `nx graph` on every file save. So beside
|
|
9
|
+
* `./nx.mjs`'s `readProjectGraph` (which asks Nx itself and is what
|
|
10
|
+
* `../../cli.mjs`'s `check` runs) this module builds the same `{nodes,
|
|
11
|
+
* dependencies}`-shaped starting point — here just the nodes; the caller
|
|
12
|
+
* folds the edges in, because edges need the import sites and the
|
|
13
|
+
* file→project map only the caller's analysis produces (`buildDependencies`,
|
|
14
|
+
* `./native/graph.mjs`).
|
|
15
|
+
*
|
|
16
|
+
* ## The blind spot this acquisition accepts, and says
|
|
17
|
+
*
|
|
18
|
+
* `project.json` is the only thing discovery reads, so a PACKAGE-BASED Nx
|
|
19
|
+
* workspace — projects declared in `package.json`, no `project.json` anywhere
|
|
20
|
+
* — yields zero nodes. `./nx.mjs`'s `readProjectGraph` asks Nx and does see
|
|
21
|
+
* them. The caller turns zero nodes under an `nx.json` marker into a recorded
|
|
22
|
+
* gap (`../lsp/workspace-index.mjs`'s `nxModelFailure`) that refuses
|
|
23
|
+
* `analyzed` on every open document, rather than a clean verdict over a graph
|
|
24
|
+
* that was never built — the loud direction, deliberately, because reading
|
|
25
|
+
* package-based projects here would be the second project-model reader this
|
|
26
|
+
* package must not grow (`../../../../AGENTS.md`).
|
|
27
|
+
*
|
|
28
|
+
* ## Failure policy: records for per-project defects, a throw for nothing
|
|
29
|
+
*
|
|
30
|
+
* A `project.json` that will not parse is skipped and reported, not thrown on —
|
|
31
|
+
* one project being edited must not blank the graph for the other nineteen.
|
|
32
|
+
* That is the same policy `./native/`'s `discover()` applies to its manifests
|
|
33
|
+
* (records riding the returned object) and the OPPOSITE of `./nx.mjs`'s
|
|
34
|
+
* `readProjectGraph` (which throws on anything, because a CLI that cannot
|
|
35
|
+
* answer leaves nothing to index). The `workspaceLayout` read is caught into
|
|
36
|
+
* `workspaceLayoutFailure` for the same reason — one malformed `nx.json` must
|
|
37
|
+
* not blank the index — where `readProjectGraph` throws the identical refusal;
|
|
38
|
+
* the two policies are the recorded difference between an acquisition that
|
|
39
|
+
* still has a tree to index and one that does not.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import { readWorkspaceLayout, requireCompleteWorkspaceLayout } from "../options.mjs";
|
|
43
|
+
import { parseNxJson } from "../nx-json.mjs";
|
|
44
|
+
import { nodeTypeOf, PROJECT_CONFIG_FILE } from "./native/discover.mjs";
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The directory part of a workspace-relative path; `""` at the tree root.
|
|
48
|
+
*
|
|
49
|
+
* @param {string} file
|
|
50
|
+
* @returns {string}
|
|
51
|
+
*/
|
|
52
|
+
const directoryOf = (file) => {
|
|
53
|
+
const slash = file.lastIndexOf("/");
|
|
54
|
+
return slash === -1 ? "" : file.slice(0, slash);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* One `project.json` — or the `package.json` beside it — read the way Nx reads
|
|
59
|
+
* it, which is NOT `JSON.parse`.
|
|
60
|
+
*
|
|
61
|
+
* The reader is `../nx-json.mjs`, shared with `../options.mjs` because
|
|
62
|
+
* `nx.json` has to be read the same way for the same reason. The local name
|
|
63
|
+
* stays because the stakes are specific to a project config: losing a
|
|
64
|
+
* `project.json` here is the worst failure this acquisition can have. The
|
|
65
|
+
* project leaves the graph; an import into it then resolves as external rather
|
|
66
|
+
* than cross-project; the rule engine's npm branch returns before the tag
|
|
67
|
+
* checks run; and the editor paints a real violation clean.
|
|
68
|
+
*
|
|
69
|
+
* @param {string} text
|
|
70
|
+
* @returns {object} Whatever the JSON describes.
|
|
71
|
+
* @throws {Error} when neither parser can read it.
|
|
72
|
+
*/
|
|
73
|
+
const parseProjectJson = parseNxJson;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The projects declared in a tree, from its `project.json` files.
|
|
77
|
+
*
|
|
78
|
+
* A `project.json` that will not parse is SKIPPED and reported, not thrown on:
|
|
79
|
+
* one project being edited must not blank the graph for the other nineteen. The
|
|
80
|
+
* caller decides how loud to be about the ones that were skipped.
|
|
81
|
+
*
|
|
82
|
+
* @param {{files: string[], readFile: (path: string) => string|null}} tree
|
|
83
|
+
* @returns {{projects: {name: string, root: string, config: object}[], skipped: {file: string, reason: string}[]}}
|
|
84
|
+
*/
|
|
85
|
+
export function discoverProjects({ files, readFile }) {
|
|
86
|
+
const projects = [];
|
|
87
|
+
const skipped = [];
|
|
88
|
+
for (const file of files) {
|
|
89
|
+
if (file !== PROJECT_CONFIG_FILE && !file.endsWith(`/${PROJECT_CONFIG_FILE}`)) continue;
|
|
90
|
+
const text = readFile(file);
|
|
91
|
+
if (text === null) {
|
|
92
|
+
skipped.push({ file, reason: "could not be read" });
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
let config;
|
|
96
|
+
try {
|
|
97
|
+
config = parseProjectJson(text);
|
|
98
|
+
} catch (cause) {
|
|
99
|
+
skipped.push({ file, reason: `is not valid JSON: ${cause?.message ?? cause}` });
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
const root = directoryOf(file);
|
|
103
|
+
// Nx's own precedence: the name a project states, then the one its
|
|
104
|
+
// `package.json` states, then the directory it lives in.
|
|
105
|
+
const packageName = (() => {
|
|
106
|
+
const manifest = readFile(root === "" ? "package.json" : `${root}/package.json`);
|
|
107
|
+
if (manifest === null) return undefined;
|
|
108
|
+
try {
|
|
109
|
+
// The same parser, because Nx reads this file with the same
|
|
110
|
+
// `readJsonFile` — a `package.json` Nx can name a project from must
|
|
111
|
+
// not become a project named after its directory here.
|
|
112
|
+
return parseProjectJson(manifest).name;
|
|
113
|
+
} catch {
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
})();
|
|
117
|
+
const name =
|
|
118
|
+
config.name ?? packageName ?? (root === "" ? "" : root.slice(root.lastIndexOf("/") + 1));
|
|
119
|
+
if (typeof name !== "string" || name === "") {
|
|
120
|
+
skipped.push({ file, reason: "declares no usable project name" });
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
projects.push({ name, root, config });
|
|
124
|
+
}
|
|
125
|
+
return { projects, skipped };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The graph nodes for a project list, in Nx's shape: `data` is the project's
|
|
130
|
+
* own configuration with `tags` guaranteed present, because `../rules/tags.mjs`
|
|
131
|
+
* reads it unguarded and an absent list is not the same fact as an empty one.
|
|
132
|
+
*
|
|
133
|
+
* @param {{name: string, root: string, config: object}[]} projects
|
|
134
|
+
* @returns {{nodes: Record<string, object>, duplicateProjects: {name: string, roots: string[]}[]}}
|
|
135
|
+
* `duplicateProjects` names every name two or more projects resolved to and
|
|
136
|
+
* every root that claimed it (#375): a silent `nodes[name] = …` overwrite
|
|
137
|
+
* drops the shadowed project from the graph, its files match no root, and
|
|
138
|
+
* the editor publishes no diagnostics for real boundary crossings — the
|
|
139
|
+
* exact silent direction `../../../../AGENTS.md`'s invariant refuses. The
|
|
140
|
+
* first project still wins in `nodes` (the index stays usable); the caller
|
|
141
|
+
* publishes the collision through `indexGaps`.
|
|
142
|
+
*/
|
|
143
|
+
export function buildNodes(projects) {
|
|
144
|
+
// Null-prototype for the same reason `./native/graph.mjs` and `./moon.mjs`
|
|
145
|
+
// use them: every key here is a project NAME, and project names come from a
|
|
146
|
+
// `project.json`'s own `name` field — attacker-supplied the moment a pull
|
|
147
|
+
// request adds a project called `__proto__`. A plain `{}` answers
|
|
148
|
+
// `nodes["__proto__"] = …` by repointing the object's OWN prototype rather
|
|
149
|
+
// than adding an entry, so the project vanishes from `graph.nodes` while
|
|
150
|
+
// `filesOf` still attributes it files — a real cross-project import into it
|
|
151
|
+
// then read a poisoned Node as a graph node and flips/throws on every rule
|
|
152
|
+
// that touches it. `Object.create(null)` has no inherited `__proto__`
|
|
153
|
+
// accessor to collide with, so the name behaves like every other project
|
|
154
|
+
// name: a real, own, enumerable entry.
|
|
155
|
+
const nodes = Object.create(null);
|
|
156
|
+
/** @type {Map<string, string>} name → root of the first project that claimed it. */
|
|
157
|
+
const seenNames = new Map();
|
|
158
|
+
/** @type {Map<string, string[]>} name → every root that resolved to it, for names claimed twice or more. */
|
|
159
|
+
const duplicateMap = new Map();
|
|
160
|
+
|
|
161
|
+
for (const { name, root, config } of projects) {
|
|
162
|
+
if (seenNames.has(name)) {
|
|
163
|
+
// Duplicate name detected — record it for loud reporting
|
|
164
|
+
if (!duplicateMap.has(name)) {
|
|
165
|
+
duplicateMap.set(name, [seenNames.get(name)]);
|
|
166
|
+
}
|
|
167
|
+
duplicateMap.get(name).push(root);
|
|
168
|
+
// Skip adding the duplicate to nodes — first project wins
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
seenNames.set(name, root);
|
|
172
|
+
nodes[name] = {
|
|
173
|
+
name,
|
|
174
|
+
type: nodeTypeOf(name, config.projectType),
|
|
175
|
+
data: { ...config, root, tags: config.tags ?? [] },
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Convert the duplicate map to the expected output format
|
|
180
|
+
const duplicateProjects = [];
|
|
181
|
+
for (const [name, roots] of duplicateMap.entries()) {
|
|
182
|
+
duplicateProjects.push({ name, roots });
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return { nodes, duplicateProjects };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The static Nx-shaped acquisition, composed: discovery, node building, and
|
|
190
|
+
* `nx.json`'s `workspaceLayout` merged onto the result — the merge
|
|
191
|
+
* `./nx.mjs`'s `readProjectGraph` also performs, for the same reason (see that
|
|
192
|
+
* function's doc: `nx graph --file=` emits no such key, and a non-default
|
|
193
|
+
* `appsDir`/`libsDir` must not silently become the default layout). Here a
|
|
194
|
+
* read/validation failure is caught into `workspaceLayoutFailure` rather than
|
|
195
|
+
* thrown — see this module's failure-policy note.
|
|
196
|
+
*
|
|
197
|
+
* @param {{root: string, files: string[], readFile: (path: string) => string|null,
|
|
198
|
+
* readLayout?: typeof readWorkspaceLayout}} args
|
|
199
|
+
* @returns {{nodes: Record<string, object>, skippedProjects: {file: string, reason: string}[],
|
|
200
|
+
* duplicateProjects: {name: string, roots: string[]}[], workspaceLayout: object|undefined,
|
|
201
|
+
* workspaceLayoutFailure: string|null}}
|
|
202
|
+
* `workspaceLayout` is `undefined` when `nx.json` declares nothing, so the
|
|
203
|
+
* caller can keep the key absent — the graph shape `evaluate()` reads is
|
|
204
|
+
* "declared or absent", never defaulted.
|
|
205
|
+
*/
|
|
206
|
+
export function readStaticProjectGraph({
|
|
207
|
+
root,
|
|
208
|
+
files,
|
|
209
|
+
readFile,
|
|
210
|
+
readLayout = readWorkspaceLayout,
|
|
211
|
+
}) {
|
|
212
|
+
const { projects, skipped } = discoverProjects({ files, readFile });
|
|
213
|
+
const { nodes, duplicateProjects } = buildNodes(projects);
|
|
214
|
+
|
|
215
|
+
let workspaceLayout;
|
|
216
|
+
let workspaceLayoutFailure = null;
|
|
217
|
+
try {
|
|
218
|
+
const declared = requireCompleteWorkspaceLayout(readLayout(root));
|
|
219
|
+
if (declared !== null) workspaceLayout = declared;
|
|
220
|
+
} catch (cause) {
|
|
221
|
+
workspaceLayoutFailure = cause?.message ?? String(cause);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return {
|
|
225
|
+
nodes,
|
|
226
|
+
skippedProjects: skipped,
|
|
227
|
+
duplicateProjects,
|
|
228
|
+
workspaceLayout,
|
|
229
|
+
workspaceLayoutFailure,
|
|
230
|
+
};
|
|
231
|
+
}
|
package/src/report/json.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The versioned JSON envelope every command's `--format json` wraps its
|
|
3
|
-
* result in — one wrapper
|
|
4
|
-
*
|
|
3
|
+
* result in — one wrapper over every command in the roster (`../../cli.mjs`'s
|
|
4
|
+
* `COMMAND_NAMES`), so a consumer writes one parser rather than one per
|
|
5
|
+
* command. `../../../../docs/reference/json-output.md` is the
|
|
5
6
|
* published contract this module builds; this file is where the contract's
|
|
6
7
|
* three consistency rules are enforced in code rather than left to a
|
|
7
8
|
* docs page a later command author might not read.
|
package/src/report/sarif.mjs
CHANGED
|
@@ -55,9 +55,14 @@
|
|
|
55
55
|
* as a side effect of a comment being corrected.
|
|
56
56
|
*/
|
|
57
57
|
import { INTENT_MESSAGE_IDS, INTENT_MESSAGES } from "../architecture-intent/judge.mjs";
|
|
58
|
-
import {
|
|
59
|
-
|
|
60
|
-
|
|
58
|
+
import {
|
|
59
|
+
GO_WORK_MESSAGE_IDS,
|
|
60
|
+
GO_WORK_MESSAGES,
|
|
61
|
+
MESSAGE_IDS,
|
|
62
|
+
MESSAGES,
|
|
63
|
+
TSCONFIG_PATHS_MESSAGE_IDS,
|
|
64
|
+
TSCONFIG_PATHS_MESSAGES,
|
|
65
|
+
} from "../rules/messages.mjs";
|
|
61
66
|
|
|
62
67
|
import { formatConstraint } from "./text.mjs";
|
|
63
68
|
|
|
@@ -66,6 +66,15 @@ import {
|
|
|
66
66
|
* table — it does NOT mean the edge is free of all boundary violations (see
|
|
67
67
|
* this module's header for what is not checked).
|
|
68
68
|
*
|
|
69
|
+
* One of the FOUR finding families — PD-13 (2026-09-06) outcome (c): no
|
|
70
|
+
* Finding supertype exists, and the relationship pin lives on `./index.mjs`'s
|
|
71
|
+
* `violationOf` header. These verdicts are NOT `Violation` records: the
|
|
72
|
+
* markdown-pairing fold in `../commands/check.mjs` reshapes each into the
|
|
73
|
+
* exact record `violationOf` builds, and the families fold into the one
|
|
74
|
+
* verdict lane as count keys into `verdictFor` (`../verdict.mjs`). The
|
|
75
|
+
* canonical statement is the "Finding — the unowned concept" section of
|
|
76
|
+
* `../../../../docs/architecture/refactor/SEMANTIC-MODEL.md`.
|
|
77
|
+
*
|
|
69
78
|
* @param {{source: string, target: string}} edge The graph edge to judge.
|
|
70
79
|
* @param {object} nodes The project graph's `nodes` map (carries `data.tags`).
|
|
71
80
|
* @param {object} dependencies The project graph's `dependencies` map (for `notDependOnLibsWithTags` reachability).
|
package/src/rules/index.mjs
CHANGED
|
@@ -424,7 +424,22 @@ export function exemptResolvedFile(site, exemptedFiles) {
|
|
|
424
424
|
return typeof file === "string" && exemptedFiles.has(file) ? file : null;
|
|
425
425
|
}
|
|
426
426
|
|
|
427
|
-
/**
|
|
427
|
+
/**
|
|
428
|
+
* Builds one `Violation` — the rules lane's canonical violation record (PD-13
|
|
429
|
+
* (2026-09-06) in `../../../../docs/architecture/refactor/DECISIONS.md`: the
|
|
430
|
+
* Violation concept ruled outcome (a), this constructor its one home).
|
|
431
|
+
*
|
|
432
|
+
* This is also one of the FOUR finding families — `violationOf` here,
|
|
433
|
+
* `judgeEdge` (`./edge-constraints.mjs`), `compareGoWork` (`../go-work.mjs`)
|
|
434
|
+
* and `judgeTsconfigPaths` (`../tsconfig-paths.mjs`) — and no Finding
|
|
435
|
+
* supertype exists (PD-13 outcome (c)): what binds the four is the
|
|
436
|
+
* relationship pin. Each family folds into the one verdict lane as count
|
|
437
|
+
* keys into `verdictFor` (`../verdict.mjs`), and `../commands/check.mjs`'s
|
|
438
|
+
* markdown fold is the documented seam where `judgeEdge`'s verdicts are
|
|
439
|
+
* reshaped into the exact record this function builds. The canonical
|
|
440
|
+
* statement is the "Finding — the unowned concept" section of
|
|
441
|
+
* `../../../../docs/architecture/refactor/SEMANTIC-MODEL.md`.
|
|
442
|
+
*/
|
|
428
443
|
function violationOf(site, sourceProject, targetProject, messageId, data = {}, constraint = null) {
|
|
429
444
|
return {
|
|
430
445
|
sourceFile: site.sourceFile,
|
|
@@ -507,19 +522,19 @@ function* constraintGroupsFor(site, sourceProject, targetProject, ctx) {
|
|
|
507
522
|
|
|
508
523
|
if (
|
|
509
524
|
options.checkNestedExternalImports &&
|
|
510
|
-
constraint.bannedExternalImports
|
|
511
|
-
constraint.bannedExternalImports.length
|
|
525
|
+
(constraint.bannedExternalImports?.length || constraint.allowedExternalImports)
|
|
512
526
|
) {
|
|
513
|
-
const matches = hasBannedDependencies(
|
|
514
|
-
transitiveExternalDeps,
|
|
515
|
-
graph,
|
|
516
|
-
constraint,
|
|
517
|
-
site.specifier,
|
|
518
|
-
);
|
|
527
|
+
const matches = hasBannedDependencies(transitiveExternalDeps, graph, constraint);
|
|
519
528
|
// One violation per offending package — the only check in the engine that
|
|
520
529
|
// reports more than once for a single import site.
|
|
521
530
|
if (matches.length > 0) {
|
|
522
|
-
|
|
531
|
+
// `packageName` is the field upstream's own report reads off the
|
|
532
|
+
// external node (`target.data.packageName`, measured against
|
|
533
|
+
// @nx/eslint-plugin 23.2.0) — the template's placeholder names it. The
|
|
534
|
+
// optional chain is the rule layer's never-throw contract: an external
|
|
535
|
+
// node without `data` renders the literal placeholder instead of
|
|
536
|
+
// aborting the run.
|
|
537
|
+
yield matches.map(([externalNode, violatingSource, matchedConstraint]) =>
|
|
523
538
|
violationOf(
|
|
524
539
|
site,
|
|
525
540
|
sourceProject,
|
|
@@ -529,6 +544,7 @@ function* constraintGroupsFor(site, sourceProject, targetProject, ctx) {
|
|
|
529
544
|
sourceTag: constraintSourceTagLabel(matchedConstraint),
|
|
530
545
|
childProjectName: violatingSource.name,
|
|
531
546
|
imp: site.specifier,
|
|
547
|
+
packageName: externalNode.data?.packageName,
|
|
532
548
|
},
|
|
533
549
|
matchedConstraint,
|
|
534
550
|
),
|
package/src/rules/messages.mjs
CHANGED
|
@@ -1,20 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
2
|
+
* The violation message tables — one per finding domain — and the renderer
|
|
3
|
+
* that fills the boundary table's templates.
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
* `@nx/eslint-plugin`'s `enforce-module-boundaries` rule, and every key is
|
|
6
|
-
* rule's `messageId` spelled exactly. The ids are the contract: a
|
|
7
|
-
* test can put this engine's verdict beside ESLint's for the
|
|
8
|
-
* compare ids, which is the only way to know the two agree
|
|
9
|
-
* both being red. `
|
|
10
|
-
* plugin's source and fails when a copy here drifts from it.
|
|
5
|
+
* `MESSAGES` below is a verbatim copy of `meta.messages` in
|
|
6
|
+
* `@nx/eslint-plugin`'s `enforce-module-boundaries` rule, and every key is
|
|
7
|
+
* that rule's `messageId` spelled exactly. The ids are the contract: a
|
|
8
|
+
* differential test can put this engine's verdict beside ESLint's for the
|
|
9
|
+
* same import and compare ids, which is the only way to know the two agree
|
|
10
|
+
* rather than merely both being red. `upstream.integration.test.mjs` reads
|
|
11
|
+
* the installed plugin's source and fails when a copy here drifts from it.
|
|
11
12
|
*
|
|
12
|
-
*
|
|
13
|
+
* `GO_WORK_MESSAGES` and `TSCONFIG_PATHS_MESSAGES` are this package's own
|
|
14
|
+
* domains — one entry per `messageId` a finding of that family can carry,
|
|
15
|
+
* stating what it means. Their checks live beside the code that produces the
|
|
16
|
+
* findings (`../../go-work.mjs`, `../../tsconfig-paths.mjs`); the message
|
|
17
|
+
* text lives here, because this file is the one home every violation message
|
|
18
|
+
* answers to and `../report/sarif.mjs` derives its rule descriptors from all
|
|
19
|
+
* three tables — a kind added to any of them cannot be nameless in a
|
|
20
|
+
* code-scanning upload.
|
|
21
|
+
*
|
|
22
|
+
* `MESSAGES` is copied rather than imported, and rather than derived: this
|
|
13
23
|
* project may import Node built-ins and `typescript` only (`../../AGENTS.md`),
|
|
14
|
-
* and importing the plugin would pull `@nx/devkit` — a project graph read —
|
|
15
|
-
* a layer whose whole point is being pure. The value is intrinsic to a
|
|
16
|
-
* external contract, it lives in exactly this one place, and the
|
|
17
|
-
* test is what keeps the copy honest.
|
|
24
|
+
* and importing the plugin would pull `@nx/devkit` — a project graph read —
|
|
25
|
+
* into a layer whose whole point is being pure. The value is intrinsic to a
|
|
26
|
+
* fixed external contract, it lives in exactly this one place, and the
|
|
27
|
+
* integration test is what keeps the copy honest.
|
|
18
28
|
*/
|
|
19
29
|
|
|
20
30
|
/**
|
|
@@ -34,7 +44,7 @@ export const MESSAGES = Object.freeze({
|
|
|
34
44
|
noImportsOfLazyLoadedLibraries: `Static imports of lazy-loaded libraries are forbidden.\n\nLibrary "{{targetProjectName}}" is lazy-loaded in these files:\n{{filePaths}}`,
|
|
35
45
|
projectWithoutTagsCannotHaveDependencies: `A project without tags matching at least one constraint cannot depend on any libraries`,
|
|
36
46
|
bannedExternalImportsViolation: `A project tagged with "{{sourceTag}}" is not allowed to import "{{imp}}"`,
|
|
37
|
-
nestedBannedExternalImportsViolation: `A project tagged with "{{sourceTag}}" is not allowed to import "{{imp}}". Nested import found at {{childProjectName}}`,
|
|
47
|
+
nestedBannedExternalImportsViolation: `A project tagged with "{{sourceTag}}" is not allowed to import "{{imp}}". Nested import of "{{packageName}}" found at {{childProjectName}}`,
|
|
38
48
|
noTransitiveDependencies: `Only packages defined in the "package.json" can be imported. Transitive or unresolvable dependencies are not allowed.`,
|
|
39
49
|
onlyTagsConstraintViolation: `A project tagged with "{{sourceTag}}" can only depend on libs tagged with {{tags}}`,
|
|
40
50
|
emptyOnlyTagsConstraintViolation: `A project tagged with "{{sourceTag}}" cannot depend on any libs with tags`,
|
|
@@ -44,6 +54,46 @@ export const MESSAGES = Object.freeze({
|
|
|
44
54
|
/** Every `messageId` this engine can produce — the checklist, as data. */
|
|
45
55
|
export const MESSAGE_IDS = Object.freeze(Object.keys(MESSAGES));
|
|
46
56
|
|
|
57
|
+
/**
|
|
58
|
+
* What each go.work drift finding means — one entry per `messageId` a finding
|
|
59
|
+
* can carry. `../report/sarif.mjs` derives its rule descriptors from this
|
|
60
|
+
* table, so a kind added here cannot be nameless in a code-scanning upload.
|
|
61
|
+
* The findings themselves — including their rendered sentences — are built by
|
|
62
|
+
* `compareGoWork` in `../../go-work.mjs`.
|
|
63
|
+
*/
|
|
64
|
+
export const GO_WORK_MESSAGES = Object.freeze({
|
|
65
|
+
goWorkMissingUse:
|
|
66
|
+
"A project's go.mod is not in go.work's use list: a developer's go build and gopls skip a " +
|
|
67
|
+
"module the Nx graph covers, so dev machines and CI select different module sets.",
|
|
68
|
+
goWorkStaleUse:
|
|
69
|
+
"A go.work use entry names a directory with no tracked go.mod: go commands fail on developer " +
|
|
70
|
+
"machines while CI, which never reads go.work, stays green.",
|
|
71
|
+
goWorkUnmodeledUse:
|
|
72
|
+
"A go.work use entry names a module the Nx graph does not model: it builds on developer " +
|
|
73
|
+
"machines while nx affected and the boundary check never see it.",
|
|
74
|
+
goWorkOutsideUse:
|
|
75
|
+
"A go.work use entry points outside the workspace: developer builds include a module no run " +
|
|
76
|
+
"over this workspace can cover.",
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
export const GO_WORK_MESSAGE_IDS = Object.freeze(Object.keys(GO_WORK_MESSAGES));
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* What a tsconfig paths hygiene finding means — one entry per `messageId`, the
|
|
83
|
+
* arrangement `../report/sarif.mjs` derives its rule descriptors from, so the
|
|
84
|
+
* id cannot be nameless in a code-scanning upload. The finding — including its
|
|
85
|
+
* rendered sentence — is built by `judgeTsconfigPaths` in
|
|
86
|
+
* `../../tsconfig-paths.mjs`.
|
|
87
|
+
*/
|
|
88
|
+
export const TSCONFIG_PATHS_MESSAGES = Object.freeze({
|
|
89
|
+
tsconfigDeadPathAlias:
|
|
90
|
+
"A tsconfig paths alias maps only to targets whose directories do not exist: no import of it " +
|
|
91
|
+
"can resolve through the alias table, so the build breaks — or silently resolves to an " +
|
|
92
|
+
"installed package of the same name instead of the workspace source the alias promised.",
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
export const TSCONFIG_PATHS_MESSAGE_IDS = Object.freeze(Object.keys(TSCONFIG_PATHS_MESSAGES));
|
|
96
|
+
|
|
47
97
|
/**
|
|
48
98
|
* Renders a message the way ESLint's own reporter does: `{{key}}` (whitespace
|
|
49
99
|
* around the key tolerated) is replaced by `data[key]`, and a placeholder with
|
package/src/rules/specifiers.mjs
CHANGED
|
@@ -165,14 +165,10 @@ export function getTargetProjectBasedOnRelativeImport(imp, sourceFile, projectRo
|
|
|
165
165
|
* Does this constraint ban this external import? Port of
|
|
166
166
|
* `isConstraintBanningProject`, whose three steps each hide something:
|
|
167
167
|
*
|
|
168
|
-
* 1.
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
* 2. `bannedExternalImports` is matched with `mapGlobToRegExp` against the FULL
|
|
173
|
-
* specifier, so `@scope/pkg/*` bans the deep paths while leaving the entry
|
|
174
|
-
* point importable, and `@scope/pkg*` bans both.
|
|
175
|
-
* 3. `allowedExternalImports` is an allowlist evaluated with `.every()`: an
|
|
168
|
+
* 1. `bannedExternalImports` is matched with `mapGlobToRegExp` against the
|
|
169
|
+
* given specifier, so `@scope/pkg/*` bans the deep paths while leaving the
|
|
170
|
+
* entry point importable, and `@scope/pkg*` bans both.
|
|
171
|
+
* 2. `allowedExternalImports` is an allowlist evaluated with `.every()`: an
|
|
176
172
|
* import is banned when it matches NONE of the entries. Two consequences —
|
|
177
173
|
* an absent list bans nothing (`undefined?.every` short-circuits), and an
|
|
178
174
|
* EMPTY list `[]` bans every import of the package, because `[].every()` is
|
|
@@ -196,6 +192,10 @@ export function isConstraintBanningProject(externalProject, constraint, imp) {
|
|
|
196
192
|
assertMatchableSpecifier(imp, "import specifier judged against the constraint table");
|
|
197
193
|
const { allowedExternalImports, bannedExternalImports } = constraint;
|
|
198
194
|
const { packageName } = externalProject.data;
|
|
195
|
+
// The guard is unchanged from upstream (nx 23.x): a constraint only speaks
|
|
196
|
+
// about imports OF this package. If `imp` — the specifier being judged,
|
|
197
|
+
// which for a nested-ban check callers set to the node's own `packageName`
|
|
198
|
+
// — is neither that package nor a path under it, the row is silent.
|
|
199
199
|
if (imp !== packageName && !imp.startsWith(`${packageName}/`)) return false;
|
|
200
200
|
if (bannedExternalImports?.some((definition) => mapGlobToRegExp(definition).test(imp))) {
|
|
201
201
|
return true;
|
|
@@ -262,18 +262,15 @@ export function findTransitiveExternalDependencies(graph, reach, source) {
|
|
|
262
262
|
* The nested external dependencies this constraint bans, as
|
|
263
263
|
* `[externalNode, violatingSourceNode, constraint]` triples.
|
|
264
264
|
*
|
|
265
|
-
*
|
|
266
|
-
*
|
|
267
|
-
*
|
|
268
|
-
*
|
|
269
|
-
*
|
|
270
|
-
*
|
|
271
|
-
*
|
|
272
|
-
* contract is to agree with ESLint's verdict, and a "fixed" version here would
|
|
273
|
-
* report violations ESLint does not, breaking the parity that makes the two
|
|
274
|
-
* comparable. It is recorded as a finding instead.
|
|
265
|
+
* No `imp` argument: the predicate judges the external NODE itself —
|
|
266
|
+
* `isConstraintBanningProject` is called with the node's own `packageName` as
|
|
267
|
+
* the specifier, exactly as upstream does in `runtime-lint-utils.js` since nx
|
|
268
|
+
* 23.2.0 (a change that made this check a pure ban-list predicate rather than
|
|
269
|
+
* one gated on the site's alias coinciding with the package name). This
|
|
270
|
+
* engine's contract is to agree with ESLint's verdict, so the port reproduces
|
|
271
|
+
* that behavior rather than keeping the pre-23.2.0 gate.
|
|
275
272
|
*/
|
|
276
|
-
export function hasBannedDependencies(externalDependencies, graph, constraint
|
|
273
|
+
export function hasBannedDependencies(externalDependencies, graph, constraint) {
|
|
277
274
|
// Exported, so it is reachable with a list this module did not build — the
|
|
278
275
|
// membership guard belongs here too, not only in
|
|
279
276
|
// `findTransitiveExternalDependencies` above. Same failure either way: an
|
|
@@ -296,7 +293,11 @@ export function hasBannedDependencies(externalDependencies, graph, constraint, i
|
|
|
296
293
|
(dependency) =>
|
|
297
294
|
Object.hasOwn(externalNodes, dependency.target) &&
|
|
298
295
|
Object.hasOwn(nodes, dependency.source) &&
|
|
299
|
-
isConstraintBanningProject(
|
|
296
|
+
isConstraintBanningProject(
|
|
297
|
+
externalNodes[dependency.target],
|
|
298
|
+
constraint,
|
|
299
|
+
externalNodes[dependency.target].data.packageName,
|
|
300
|
+
),
|
|
300
301
|
)
|
|
301
302
|
.map((dep) => [externalNodes[dep.target], nodes[dep.source], constraint]);
|
|
302
303
|
}
|
package/src/tsconfig-paths.mjs
CHANGED
|
@@ -79,20 +79,6 @@
|
|
|
79
79
|
import { posix } from "node:path";
|
|
80
80
|
import { stripTrailingSlashes } from "./path-util.mjs";
|
|
81
81
|
|
|
82
|
-
/**
|
|
83
|
-
* What a hygiene finding means — one entry per `messageId`, the arrangement
|
|
84
|
-
* `../report/sarif.mjs` derives its rule descriptors from, the same as
|
|
85
|
-
* `./go-work.mjs`, so the id cannot be nameless in a code-scanning upload.
|
|
86
|
-
*/
|
|
87
|
-
export const TSCONFIG_PATHS_MESSAGES = Object.freeze({
|
|
88
|
-
tsconfigDeadPathAlias:
|
|
89
|
-
"A tsconfig paths alias maps only to targets whose directories do not exist: no import of it " +
|
|
90
|
-
"can resolve through the alias table, so the build breaks — or silently resolves to an " +
|
|
91
|
-
"installed package of the same name instead of the workspace source the alias promised.",
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
export const TSCONFIG_PATHS_MESSAGE_IDS = Object.freeze(Object.keys(TSCONFIG_PATHS_MESSAGES));
|
|
95
|
-
|
|
96
82
|
/** A workspace-relative directory for display, `""` being the root. */
|
|
97
83
|
const displayDir = (dir) => (dir === "" ? "the workspace root" : `${dir}/`);
|
|
98
84
|
|
|
@@ -128,6 +114,14 @@ function probeDirectory(target, base, root) {
|
|
|
128
114
|
* need no filesystem: the table and its base come from the resolver's own
|
|
129
115
|
* parsed context, and existence arrives as a predicate.
|
|
130
116
|
*
|
|
117
|
+
* One of the FOUR finding families — PD-13 (2026-09-06) outcome (c): no
|
|
118
|
+
* Finding supertype exists, and the relationship pin lives on
|
|
119
|
+
* `./rules/index.mjs`'s `violationOf` header. These findings stay this
|
|
120
|
+
* family's own shape and fold into the one verdict lane as count keys into
|
|
121
|
+
* `verdictFor` (`./verdict.mjs`). The canonical statement is the
|
|
122
|
+
* "Finding — the unowned concept" section of
|
|
123
|
+
* `../../../docs/architecture/refactor/SEMANTIC-MODEL.md`.
|
|
124
|
+
*
|
|
131
125
|
* @param {{ paths: Record<string, unknown>,
|
|
132
126
|
* base: string,
|
|
133
127
|
* workspaceRoot: string,
|