@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.
Files changed (120) hide show
  1. package/cli.mjs +166 -506
  2. package/commands.mjs +7 -1
  3. package/package.json +1 -1
  4. package/src/architecture-intent/judge.mjs +1 -1
  5. package/src/architecture-intent/model.mjs +34 -0
  6. package/src/canonical.mjs +2 -1
  7. package/src/commands/adr-for-workspace.mjs +63 -0
  8. package/src/commands/analyze-capability.mjs +13 -0
  9. package/src/commands/change-intent.mjs +11 -0
  10. package/src/commands/change.mjs +247 -34
  11. package/src/commands/check-capability.mjs +13 -0
  12. package/src/commands/check.mjs +13 -3
  13. package/src/commands/compare-capability.mjs +20 -0
  14. package/src/commands/completeness.mjs +12 -0
  15. package/src/commands/context-command.mjs +31 -0
  16. package/src/commands/coverage-verdict.mjs +15 -2
  17. package/src/commands/debt.mjs +30 -0
  18. package/src/commands/decisions.mjs +59 -10
  19. package/src/commands/delta.mjs +263 -49
  20. package/src/commands/diff.mjs +45 -0
  21. package/src/commands/discover.mjs +37 -0
  22. package/src/commands/drift.mjs +45 -0
  23. package/src/commands/evolution.mjs +22 -2
  24. package/src/commands/explain-capability.mjs +11 -0
  25. package/src/commands/explain.mjs +24 -0
  26. package/src/commands/fitness.mjs +119 -10
  27. package/src/commands/govern-capability.mjs +16 -0
  28. package/src/commands/graph.mjs +39 -0
  29. package/src/commands/health.mjs +34 -0
  30. package/src/commands/history.mjs +85 -2
  31. package/src/commands/impact.mjs +23 -0
  32. package/src/commands/inspect-capability.mjs +16 -0
  33. package/src/commands/plan-context-command.mjs +35 -0
  34. package/src/commands/policy.mjs +42 -4
  35. package/src/commands/provenance-command.mjs +15 -24
  36. package/src/commands/report.mjs +38 -0
  37. package/src/commands/rules-capability.mjs +16 -0
  38. package/src/commands/rules.mjs +3 -1
  39. package/src/commands/scenario.mjs +23 -0
  40. package/src/commands/trajectory.mjs +35 -0
  41. package/src/commands/waivers.mjs +30 -0
  42. package/src/corpus/goldens/.gitkeep +0 -0
  43. package/src/corpus/goldens/adr.json +62 -0
  44. package/src/corpus/goldens/adr.text +6 -0
  45. package/src/corpus/goldens/change.json +98 -0
  46. package/src/corpus/goldens/change.text +6 -0
  47. package/src/corpus/goldens/check.json +90 -0
  48. package/src/corpus/goldens/check.sarif +479 -0
  49. package/src/corpus/goldens/check.text +9 -0
  50. package/src/corpus/goldens/context.json +47 -0
  51. package/src/corpus/goldens/context.text +6 -0
  52. package/src/corpus/goldens/debt.json +59 -0
  53. package/src/corpus/goldens/debt.text +7 -0
  54. package/src/corpus/goldens/decisions.json +74 -0
  55. package/src/corpus/goldens/decisions.text +7 -0
  56. package/src/corpus/goldens/delta.json +94 -0
  57. package/src/corpus/goldens/delta.sarif +472 -0
  58. package/src/corpus/goldens/delta.text +5 -0
  59. package/src/corpus/goldens/diff.json +58 -0
  60. package/src/corpus/goldens/diff.text +4 -0
  61. package/src/corpus/goldens/discover.json +77 -0
  62. package/src/corpus/goldens/discover.text +10 -0
  63. package/src/corpus/goldens/drift.json +43 -0
  64. package/src/corpus/goldens/drift.text +3 -0
  65. package/src/corpus/goldens/evolution.json +328 -0
  66. package/src/corpus/goldens/evolution.text +32 -0
  67. package/src/corpus/goldens/explain.json +60 -0
  68. package/src/corpus/goldens/explain.text +7 -0
  69. package/src/corpus/goldens/fitness.json +45 -0
  70. package/src/corpus/goldens/fitness.text +3 -0
  71. package/src/corpus/goldens/graph.json +78 -0
  72. package/src/corpus/goldens/graph.text +10 -0
  73. package/src/corpus/goldens/health.json +72 -0
  74. package/src/corpus/goldens/health.text +10 -0
  75. package/src/corpus/goldens/help.text +104 -0
  76. package/src/corpus/goldens/history.json +43 -0
  77. package/src/corpus/goldens/history.text +4 -0
  78. package/src/corpus/goldens/impact.json +327 -0
  79. package/src/corpus/goldens/impact.text +10 -0
  80. package/src/corpus/goldens/provenance.json +453 -0
  81. package/src/corpus/goldens/provenance.text +14 -0
  82. package/src/corpus/goldens/reconcile.json +168 -0
  83. package/src/corpus/goldens/reconcile.text +3 -0
  84. package/src/corpus/goldens/report.json +168 -0
  85. package/src/corpus/goldens/report.text +42 -0
  86. package/src/corpus/goldens/rules verify.json +0 -0
  87. package/src/corpus/goldens/rules verify.text +0 -0
  88. package/src/corpus/goldens/scenario.json +441 -0
  89. package/src/corpus/goldens/scenario.text +28 -0
  90. package/src/corpus/goldens/trajectory.json +83 -0
  91. package/src/corpus/goldens/trajectory.text +10 -0
  92. package/src/corpus/goldens/usage-error.text +105 -0
  93. package/src/corpus/goldens/waivers.json +40 -0
  94. package/src/corpus/goldens/waivers.text +1 -0
  95. package/src/corpus/goldens-lsp/initialize.result.json +15 -0
  96. package/src/corpus/goldens-lsp/publish-cleared-empty.json +4 -0
  97. package/src/corpus/goldens-lsp/publish-fixed-empty.json +5 -0
  98. package/src/corpus/goldens-lsp/publish-unparseable.json +54 -0
  99. package/src/corpus/goldens-lsp/publish-violation.json +22 -0
  100. package/src/corpus/goldens-lsp/register-watched-files.json +72 -0
  101. package/src/go-work.mjs +8 -23
  102. package/src/governance/evolution-event.mjs +15 -0
  103. package/src/governance/evolution-store.mjs +59 -31
  104. package/src/governance/provenance-record.mjs +0 -152
  105. package/src/governance/verdict.mjs +26 -3
  106. package/src/intent/intent-manifest.json +14 -14
  107. package/src/lsp/server.mjs +5 -8
  108. package/src/lsp/workspace-index.mjs +55 -220
  109. package/src/options.mjs +10 -0
  110. package/src/providers/native/discover.mjs +13 -12
  111. package/src/providers/native/model.mjs +8 -3
  112. package/src/providers/nx-static.mjs +231 -0
  113. package/src/report/json.mjs +3 -2
  114. package/src/report/sarif.mjs +8 -3
  115. package/src/rules/edge-constraints.mjs +9 -0
  116. package/src/rules/index.mjs +26 -10
  117. package/src/rules/messages.mjs +64 -14
  118. package/src/rules/specifiers.mjs +21 -20
  119. package/src/tsconfig-paths.mjs +8 -14
  120. 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
+ }
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * The versioned JSON envelope every command's `--format json` wraps its
3
- * result in — one wrapper, six commands, so a consumer writes one parser
4
- * rather than one per command. `../../../../docs/reference/json-output.md` is the
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.
@@ -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 { GO_WORK_MESSAGE_IDS, GO_WORK_MESSAGES } from "../go-work.mjs";
59
- import { MESSAGE_IDS, MESSAGES } from "../rules/messages.mjs";
60
- import { TSCONFIG_PATHS_MESSAGE_IDS, TSCONFIG_PATHS_MESSAGES } from "../tsconfig-paths.mjs";
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).
@@ -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
- /** Builds one `Violation`. */
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
- yield matches.map(([, violatingSource, matchedConstraint]) =>
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
  ),
@@ -1,20 +1,30 @@
1
1
  /**
2
- * The fifteen violation messages, and the renderer that fills them.
2
+ * The violation message tables — one per finding domain — and the renderer
3
+ * that fills the boundary table's templates.
3
4
  *
4
- * Every string below is a verbatim copy of `meta.messages` in
5
- * `@nx/eslint-plugin`'s `enforce-module-boundaries` rule, and every key is that
6
- * rule's `messageId` spelled exactly. The ids are the contract: a differential
7
- * test can put this engine's verdict beside ESLint's for the same import and
8
- * compare ids, which is the only way to know the two agree rather than merely
9
- * both being red. `src/rules/upstream.integration.test.mjs` reads the installed
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
- * Copied rather than imported, and rather than derived: this
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 — into
15
- * a layer whose whole point is being pure. The value is intrinsic to a fixed
16
- * external contract, it lives in exactly this one place, and the integration
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
@@ -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. The constraint only speaks about imports OF THIS PACKAGE. `imp` must be
169
- * the package name itself or a path under it, otherwise the row is silent —
170
- * which is what makes `nestedBannedExternalImportsViolation` so hard to
171
- * trigger (see `hasBannedDependencies`).
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
- * **Read the `imp` argument carefully.** It is the specifier of the import
266
- * being judged which, at this point in the pipeline, resolves to a PROJECT,
267
- * not to any of the external packages being scanned. `isConstraintBanningProject`
268
- * returns false immediately unless that specifier is the nested package's name
269
- * or a path under it, so this fires only where a project's import alias and a
270
- * transitively-reachable package name coincide. That is upstream's behaviour in
271
- * `@nx/eslint-plugin` 23.1.1, reproduced rather than corrected: this engine's
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, imp) {
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(externalNodes[dependency.target], constraint, imp),
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
  }
@@ -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,