@ecoma-io/archkeep 0.13.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 (131) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +262 -0
  3. package/cli.mjs +2792 -0
  4. package/index.mjs +85 -0
  5. package/lsp.mjs +81 -0
  6. package/nx.mjs +24 -0
  7. package/package.json +81 -0
  8. package/presets/clean-architecture.json +78 -0
  9. package/presets/ddd-bounded-contexts.json +88 -0
  10. package/presets/hexagonal.json +68 -0
  11. package/presets/layered.json +92 -0
  12. package/presets/modular-monolith.json +85 -0
  13. package/presets/vertical-slice.json +68 -0
  14. package/src/analysis/analyze.mjs +218 -0
  15. package/src/analysis/contract.md +259 -0
  16. package/src/analysis/go.mjs +414 -0
  17. package/src/analysis/manifest-util.mjs +68 -0
  18. package/src/analysis/python.mjs +1266 -0
  19. package/src/analysis/registry.mjs +74 -0
  20. package/src/analysis/rust.mjs +674 -0
  21. package/src/analysis/source-util.mjs +230 -0
  22. package/src/analysis/typescript.mjs +1034 -0
  23. package/src/analysis/vue.mjs +156 -0
  24. package/src/architecture-intent/intent-fingerprint.mjs +29 -0
  25. package/src/architecture-intent/judge.mjs +539 -0
  26. package/src/architecture-intent/model.mjs +703 -0
  27. package/src/architecture-intent/selectors.mjs +170 -0
  28. package/src/canonical.mjs +48 -0
  29. package/src/commands/README.md +266 -0
  30. package/src/commands/adr.mjs +248 -0
  31. package/src/commands/check.mjs +989 -0
  32. package/src/commands/context-command.mjs +212 -0
  33. package/src/commands/context.mjs +790 -0
  34. package/src/commands/custom-rules.mjs +428 -0
  35. package/src/commands/debt.mjs +218 -0
  36. package/src/commands/diff.mjs +523 -0
  37. package/src/commands/discover.mjs +159 -0
  38. package/src/commands/drift.mjs +473 -0
  39. package/src/commands/edge-constraints.mjs +355 -0
  40. package/src/commands/explain.mjs +359 -0
  41. package/src/commands/fitness.mjs +226 -0
  42. package/src/commands/graph.mjs +297 -0
  43. package/src/commands/health.mjs +213 -0
  44. package/src/commands/history.mjs +614 -0
  45. package/src/commands/impact.mjs +226 -0
  46. package/src/commands/plan-context-command.mjs +496 -0
  47. package/src/commands/policy.mjs +138 -0
  48. package/src/commands/provenance-command.mjs +352 -0
  49. package/src/commands/provenance.mjs +159 -0
  50. package/src/commands/reconcile.mjs +219 -0
  51. package/src/commands/report.mjs +553 -0
  52. package/src/commands/snapshot-meta.mjs +107 -0
  53. package/src/commands/waivers.mjs +240 -0
  54. package/src/config.mjs +1308 -0
  55. package/src/containment.mjs +234 -0
  56. package/src/custom-rules/evidence.mjs +340 -0
  57. package/src/custom-rules/host.mjs +1023 -0
  58. package/src/custom-rules/values.mjs +43 -0
  59. package/src/entry-point.mjs +55 -0
  60. package/src/errors.mjs +36 -0
  61. package/src/eslint-config.mjs +542 -0
  62. package/src/go-work.mjs +394 -0
  63. package/src/governance/adr-registry.mjs +539 -0
  64. package/src/governance/clock.mjs +69 -0
  65. package/src/governance/debt-ledger.mjs +274 -0
  66. package/src/governance/discovery-proposal.mjs +423 -0
  67. package/src/governance/fitness-registry.mjs +504 -0
  68. package/src/governance/fitness-rules.mjs +668 -0
  69. package/src/governance/metrics.mjs +392 -0
  70. package/src/governance/preset-fingerprints.json +16 -0
  71. package/src/governance/profile-registry.mjs +366 -0
  72. package/src/governance/provenance-record.mjs +177 -0
  73. package/src/governance/reconcile-candidates.mjs +301 -0
  74. package/src/governance/reconcile-score.mjs +503 -0
  75. package/src/governance/row-schema.mjs +208 -0
  76. package/src/governance/verdict.mjs +127 -0
  77. package/src/governance/waiver.mjs +105 -0
  78. package/src/graph/create-dependencies.mjs +96 -0
  79. package/src/intent/intent-manifest.json +347 -0
  80. package/src/intent/mask-non-code.mjs +640 -0
  81. package/src/lsp/boundary-config.mjs +225 -0
  82. package/src/lsp/diagnose.mjs +202 -0
  83. package/src/lsp/diagnostics.mjs +241 -0
  84. package/src/lsp/protocol.mjs +215 -0
  85. package/src/lsp/server.mjs +922 -0
  86. package/src/lsp/workspace-index.mjs +891 -0
  87. package/src/nx-json.mjs +95 -0
  88. package/src/options.mjs +611 -0
  89. package/src/process.mjs +91 -0
  90. package/src/providers/moon.mjs +733 -0
  91. package/src/providers/native/README.md +204 -0
  92. package/src/providers/native/coverage.mjs +74 -0
  93. package/src/providers/native/differential.fixtures.mjs +1277 -0
  94. package/src/providers/native/discover.mjs +431 -0
  95. package/src/providers/native/graph.mjs +234 -0
  96. package/src/providers/native/index.mjs +152 -0
  97. package/src/providers/native/model.mjs +755 -0
  98. package/src/providers/nx.mjs +178 -0
  99. package/src/report/README.md +89 -0
  100. package/src/report/adr-text.mjs +129 -0
  101. package/src/report/context-text.mjs +109 -0
  102. package/src/report/debt-text.mjs +105 -0
  103. package/src/report/diff-text.mjs +219 -0
  104. package/src/report/discover-text.mjs +186 -0
  105. package/src/report/drift-text.mjs +194 -0
  106. package/src/report/envelope-shape.mjs +161 -0
  107. package/src/report/evidence.mjs +157 -0
  108. package/src/report/explain-text.mjs +159 -0
  109. package/src/report/graph-text.mjs +116 -0
  110. package/src/report/health-text.mjs +123 -0
  111. package/src/report/history-text.mjs +204 -0
  112. package/src/report/impact-text.mjs +128 -0
  113. package/src/report/json.mjs +173 -0
  114. package/src/report/plan-context-text.mjs +159 -0
  115. package/src/report/provenance-text.mjs +78 -0
  116. package/src/report/reconcile-text.mjs +159 -0
  117. package/src/report/report-text.mjs +264 -0
  118. package/src/report/sarif.mjs +953 -0
  119. package/src/report/text.mjs +823 -0
  120. package/src/report/waivers-text.mjs +100 -0
  121. package/src/rules/README.md +123 -0
  122. package/src/rules/index.mjs +962 -0
  123. package/src/rules/match.mjs +1708 -0
  124. package/src/rules/messages.mjs +73 -0
  125. package/src/rules/reachability.mjs +224 -0
  126. package/src/rules/specifiers.mjs +300 -0
  127. package/src/rules/tags.mjs +238 -0
  128. package/src/rules/topology.mjs +333 -0
  129. package/src/tsconfig-paths.mjs +237 -0
  130. package/src/verdict.mjs +145 -0
  131. package/src/workspace.mjs +580 -0
@@ -0,0 +1,431 @@
1
+ /**
2
+ * Declared ∪ inferred project discovery over a `archkeep.json`-rooted
3
+ * workspace — no Nx, no `project.json` requirement, no assumption about
4
+ * directory layout.
5
+ *
6
+ * Two sources merge into one project list, declared winning field-by-field:
7
+ * every `projects.declared` row names a project outright, and every tracked
8
+ * manifest matching `projects.infer` (`project.json`, `package.json`, `go.mod`,
9
+ * `Cargo.toml`, `pyproject.toml` unless the model says otherwise) contributes
10
+ * one more UNLESS its directory is already a declared root. Name precedence
11
+ * reproduces Nx's own exactly — `discoverProjects` in `../../lsp/workspace-index.mjs`
12
+ * is the oracle: `config.name ?? packageName ?? directoryBasename` — so a tree
13
+ * that used to run Nx and now runs `archkeep.json` names its projects the same
14
+ * way it always did.
15
+ *
16
+ * Tags are a UNION, never a precedence: a project's own declared tags, every
17
+ * `projectRules` row whose `match` covers its root, and its `project.json`
18
+ * tags if it has one, all merge — sorted, deduplicated, with provenance kept
19
+ * on the side (`tagOrigins`) rather than inside the tag list itself, because a
20
+ * tag can arrive from more than one source and the rule engine
21
+ * (`../../rules/tags.mjs`) only ever needs to know a project HAS a tag, never
22
+ * where it came from.
23
+ *
24
+ * `nodeTypeOf` and `PROJECT_CONFIG_FILE` live here rather than in
25
+ * `../../lsp/workspace-index.mjs`, which used to define them: this module is
26
+ * the promotion target (`../../../AGENTS.md`, "`src/providers/` is the layer
27
+ * that supplies a graph to `evaluate()`"), and `../../lsp/workspace-index.mjs`
28
+ * now imports both from here. Defining them there instead would mean
29
+ * importing `../../lsp/workspace-index.mjs` from this module — which imports
30
+ * `../../workspace.mjs`, which loads the TypeScript compiler at module scope
31
+ * (`../../process.mjs`'s header) — for two functions that need nothing beyond
32
+ * a string.
33
+ */
34
+ import { fileFailure } from "../../analysis/source-util.mjs";
35
+ import { parseNxJson } from "../../nx-json.mjs";
36
+ import { projectPatternError } from "../../rules/match.mjs";
37
+ import { matchesGlob } from "./model.mjs";
38
+
39
+ /** The file Nx reads to learn a project exists — and so does this. */
40
+ export const PROJECT_CONFIG_FILE = "project.json";
41
+
42
+ /**
43
+ * Nx's own `getProjectType`, reproduced over the same inputs.
44
+ *
45
+ * Reproduced rather than reached, unlike `../../nx-json.mjs`'s parser: Nx
46
+ * keeps this one as a module-private function of `normalize-project-nodes`,
47
+ * which exports two other names and not this. What makes reproducing it safe
48
+ * is that the rule is Nx's own convention — the `-e2e` suffix is a naming Nx
49
+ * defines, never one this workspace supplies — so nothing here assumes
50
+ * anything about the tree it runs over.
51
+ *
52
+ * The filesystem fallbacks Nx applies when `projectType` is absent are NOT
53
+ * reproduced. Nx probes for `tsconfig.lib.json`, `tsconfig.app.json` and a
54
+ * `package.json` entry point; a project that states no `projectType` lands on
55
+ * `lib` here. That direction is the safe one: `lib` is the only type with no
56
+ * blanket import ban, so a mis-typed project is judged by its tags rather than
57
+ * refused outright by a rule that never should have fired.
58
+ *
59
+ * @param {string} name
60
+ * @param {string|undefined} projectType From `project.json`.
61
+ * @returns {"app"|"e2e"|"lib"}
62
+ */
63
+ export function nodeTypeOf(name, projectType) {
64
+ if (projectType === "application") {
65
+ return name.endsWith("-e2e") || name === "e2e" ? "e2e" : "app";
66
+ }
67
+ return "lib";
68
+ }
69
+
70
+ /** The directory part of a workspace-relative path; `""` at the tree root. */
71
+ const directoryOf = (file) => {
72
+ const slash = file.lastIndexOf("/");
73
+ return slash === -1 ? "" : file.slice(0, slash);
74
+ };
75
+
76
+ /** `root`'s own basename, Nx's own fallback when nothing else names a project. */
77
+ const basenameOf = (root) => (root === "" ? "" : root.slice(root.lastIndexOf("/") + 1));
78
+
79
+ /**
80
+ * `project.json` at `projectRoot`, parsed the same JSONC-tolerant way
81
+ * `../../nx-json.mjs` reads every other config this package trusts —
82
+ * `../../lsp/workspace-index.mjs` reads its own copy of `project.json` the
83
+ * same way, so a trailing comma or a comment that Nx itself accepts is not a
84
+ * reason for this provider to disagree with it.
85
+ *
86
+ * `.json` is not an analyzable extension (`../../analysis/analyze.mjs`'s
87
+ * `LANGUAGE_BY_EXTENSION`), so `../../workspace.mjs`'s own analysis pass never
88
+ * looks at this file and never reports it broken — a `project.json` this
89
+ * function cannot parse would otherwise cost its project every field the
90
+ * manifest carries (`tags`, `type`, `implicitDependencies`) with nothing
91
+ * anywhere naming why, which is exactly the silent hole
92
+ * `../../../../../AGENTS.md`'s invariant refuses. So a parse failure is returned
93
+ * here as a `failure` (a `fileFailure`, the same whole-file record shape a
94
+ * language analyzer produces for an unreadable file) rather than swallowed:
95
+ * the project still keeps its row — `discoverNativeProjects` below, same as
96
+ * before — but the caller surfaces the broken manifest as its own finding.
97
+ *
98
+ * The same refusal applies to a null read of a TRACKED manifest: the reader
99
+ * (`../../commands/context.mjs`'s `readWorkspaceRoot`, `../../lsp/workspace-index.mjs`'s
100
+ * `readWorkspaceFile`) already refuses a containment escape — a tracked symlink
101
+ * whose realpath leaves the workspace — by returning null. Treating that null
102
+ * as "no manifest" would silently re-read the project as named by its
103
+ * directory basename, outside bytes purged but the wrong verdict still clean.
104
+ * So a tracked manifest that reads null is surfaced as a `fileFailure`, the
105
+ * identical loud shape a parse failure gets (`../../containment.mjs`, the
106
+ * read-side G-10 closure).
107
+ *
108
+ * @param {string} projectRoot Workspace-relative project root (`""` for the
109
+ * workspace root itself).
110
+ * @param {(path: string) => string|null} readFile
111
+ * @param {(path: string) => boolean} isTracked Whether `path` (workspace-relative)
112
+ * is in the tracked file list.
113
+ * @returns {{manifest: {name?: string, tags?: string[], projectType?: string, implicitDependencies?: string[]}|undefined, failure: object|undefined}}
114
+ */
115
+ function readProjectManifest(projectRoot, readFile, isTracked) {
116
+ const path = projectRoot === "" ? PROJECT_CONFIG_FILE : `${projectRoot}/${PROJECT_CONFIG_FILE}`;
117
+ const text = readFile(path);
118
+ if (text === null) {
119
+ // A null read is "no such file" only when the tree does not track one; a
120
+ // tracked manifest that cannot be read was refused (containment) or is
121
+ // missing from the working tree — either way the project must not silently
122
+ // fall back to its basename.
123
+ if (isTracked(path)) {
124
+ return { manifest: undefined, failure: fileFailure(path, "could not be read") };
125
+ }
126
+ return { manifest: undefined, failure: undefined };
127
+ }
128
+ try {
129
+ return { manifest: parseNxJson(text), failure: undefined };
130
+ } catch (cause) {
131
+ return {
132
+ manifest: undefined,
133
+ failure: fileFailure(path, `could not be parsed as JSON: ${cause?.message ?? cause}`),
134
+ };
135
+ }
136
+ }
137
+
138
+ /**
139
+ * `package.json`'s `name` at `root` — the second rung of the name-precedence
140
+ * ladder — read the same JSONC-tolerant way `project.json` is
141
+ * (`readProjectManifest` above), not with a bare `JSON.parse`.
142
+ *
143
+ * This is not a formality: Nx itself resolves a `package.json` project node's
144
+ * name through `readJsonFile` → `parseJson` — measured against the installed
145
+ * `nx`, `dist/plugins/package-json.js`'s `createNodeFromPackageJson` reads
146
+ * every tracked `package.json` that way, the same `jsonc-parser` Nx reads
147
+ * `nx.json` and `project.json` with — even though `npm install` itself is
148
+ * strict JSON about the same file. `../../lsp/workspace-index.mjs`'s
149
+ * `discoverProjects`, the oracle this module's own header cites for name
150
+ * precedence, reads `package.json` through that same reader
151
+ * (`parseProjectJson`, its local name for `parseNxJson`) for exactly that
152
+ * reason. A bare `JSON.parse` here would read a `package.json` carrying a
153
+ * trailing comma or a `//` comment as unparseable while Nx reads it fine,
154
+ * naming the project differently than Nx does for a config Nx accepts.
155
+ *
156
+ * A `package.json` that will not parse even with that reader is surfaced as a
157
+ * `fileFailure`, never swallowed into "no name": falling back to the
158
+ * directory basename here would cost the project its real identity with
159
+ * nothing anywhere naming why — an import that targets it by name would
160
+ * resolve as external rather than cross-project, unnoticed. The same silent
161
+ * hole `readProjectManifest`'s own header describes for `project.json`.
162
+ *
163
+ * @param {string} projectRoot Workspace-relative project root (`""` for the
164
+ * workspace root itself).
165
+ * @param {(path: string) => string|null} readFile
166
+ * @param {(path: string) => boolean} isTracked Whether `path` (workspace-relative)
167
+ * is in the tracked file list.
168
+ * @returns {{name: string|undefined, failure: object|undefined}}
169
+ */
170
+ function readPackageName(projectRoot, readFile, isTracked) {
171
+ const path = projectRoot === "" ? "package.json" : `${projectRoot}/package.json`;
172
+ const text = readFile(path);
173
+ if (text === null) {
174
+ // The same tracked-but-unreadable rule `readProjectManifest` applies: a
175
+ // null read is "no package.json" only when the tree tracks none.
176
+ if (isTracked(path)) {
177
+ return { name: undefined, failure: fileFailure(path, "could not be read") };
178
+ }
179
+ return { name: undefined, failure: undefined };
180
+ }
181
+ try {
182
+ const parsed = parseNxJson(text);
183
+ return {
184
+ name: typeof parsed?.name === "string" ? parsed.name : undefined,
185
+ failure: undefined,
186
+ };
187
+ } catch (cause) {
188
+ return {
189
+ name: undefined,
190
+ failure: fileFailure(path, `could not be parsed as JSON: ${cause?.message ?? cause}`),
191
+ };
192
+ }
193
+ }
194
+
195
+ /**
196
+ * Every workspace-relative directory `projects.infer` covers: a tracked
197
+ * manifest's own directory, when its basename is in `infer.manifests` and its
198
+ * path matches `infer.include` and none of `infer.exclude`.
199
+ *
200
+ * @param {{files: string[], infer: {manifests: string[], include: string[], exclude: string[]}}} args
201
+ * @returns {Set<string>}
202
+ */
203
+ function inferProjectRoots({ files, infer }) {
204
+ const roots = new Set();
205
+ for (const file of files) {
206
+ const base = file.slice(file.lastIndexOf("/") + 1);
207
+ if (!infer.manifests.includes(base)) continue;
208
+ if (!infer.include.some((pattern) => matchesGlob(file, pattern))) continue;
209
+ if (infer.exclude.some((pattern) => matchesGlob(file, pattern))) continue;
210
+ roots.add(directoryOf(file));
211
+ }
212
+ return roots;
213
+ }
214
+
215
+ /**
216
+ * Declared ∪ inferred discovery, tag union, and every discovery-time defect
217
+ * `archkeep.json` can state that does not hold up against the tree: a declared
218
+ * root with no tracked file under it, a `projectRules` row matching no
219
+ * project, two projects resolving to the same name, and a workspace
220
+ * describing zero projects at all.
221
+ *
222
+ * Every one of those throws — a defect in the model IS a "could not reach a
223
+ * verdict" failure (`../../../../../AGENTS.md`, "An empty result is a claim, not a
224
+ * shrug"): a `projectRules` row that silently matched nothing would report a
225
+ * clean workspace exactly like one where every project really does carry the
226
+ * tag, and the two are not the same fact.
227
+ *
228
+ * @param {{root: string, files: string[], readFile: (path: string) => string|null, model: object}} args
229
+ * `model` is a `NativeModel` from `./model.mjs`.
230
+ * @returns {{projects: {name: string, root: string, type: "app"|"e2e"|"lib", tags: string[], tagOrigins: Record<string, string[]>, implicitDependencies: string[], targets: string[]}[], failures: object[]}}
231
+ * @throws {Error} named `archkeep: archkeep.json describes a workspace that does not match the tree`.
232
+ */
233
+ export function discoverNativeProjects({ root, files, readFile, model }) {
234
+ const violations = [];
235
+ const failures = [];
236
+ const declaredByRoot = new Map();
237
+ for (const row of model.projects.declared) {
238
+ if (declaredByRoot.has(row.root)) {
239
+ violations.push(`projects.declared: two rows declare the same root '${row.root}'`);
240
+ continue;
241
+ }
242
+ declaredByRoot.set(row.root, row);
243
+ }
244
+
245
+ for (const declaredRoot of declaredByRoot.keys()) {
246
+ // Strict containment only: `file === declaredRoot` used to also pass, so
247
+ // a tracked FILE sharing its exact path with a declared root (a
248
+ // `README.md` sitting where a project's directory ought to be, say)
249
+ // could back a root that names no real directory at all. Only a file
250
+ // genuinely inside the root's directory counts.
251
+ const hasFile =
252
+ declaredRoot === "" || files.some((file) => file.startsWith(`${declaredRoot}/`));
253
+ if (!hasFile) {
254
+ violations.push(
255
+ `projects.declared: root '${declaredRoot}' has no tracked file under it — nothing in ` +
256
+ `the tree backs this project`,
257
+ );
258
+ }
259
+ }
260
+
261
+ const inferredRoots = model.projects.infer
262
+ ? inferProjectRoots({ files, infer: model.projects.infer })
263
+ : new Set();
264
+
265
+ const allRoots = new Set([...declaredByRoot.keys(), ...inferredRoots]);
266
+
267
+ const resolved = [];
268
+ for (const projectRoot of allRoots) {
269
+ const declared = declaredByRoot.get(projectRoot);
270
+ const isTracked = (path) => files.includes(path);
271
+ const { manifest, failure } = readProjectManifest(projectRoot, readFile, isTracked);
272
+ if (failure) failures.push(failure);
273
+ // Read unconditionally, not only when `project.json` is absent: a
274
+ // `project.json` that EXISTS but omits `name` is still a truthy
275
+ // `manifest`, and `manifest?.name` alone is `undefined` in that case —
276
+ // gating this read on `manifest` being falsy skipped `package.json`
277
+ // entirely for that project, landing straight on the directory basename
278
+ // and skipping the middle rung of the precedence chain below.
279
+ // `../../lsp/workspace-index.mjs`'s `discoverProjects` — the oracle this
280
+ // module's own header names for this exact precedence — reads
281
+ // `package.json` the same unconditional way for the same reason.
282
+ const { name: packageName, failure: packageFailure } = readPackageName(
283
+ projectRoot,
284
+ readFile,
285
+ isTracked,
286
+ );
287
+ if (packageFailure) failures.push(packageFailure);
288
+ // Nx's own precedence, reproduced exactly (`../../lsp/workspace-index.mjs`,
289
+ // `discoverProjects`): a declared name, then `project.json`'s, then
290
+ // `package.json`'s, then the directory basename.
291
+ const name = declared?.name ?? manifest?.name ?? packageName ?? basenameOf(projectRoot);
292
+ if (typeof name !== "string" || name === "") {
293
+ violations.push(
294
+ `projects: no name resolves for the project at root '${projectRoot}' — declare a ` +
295
+ `'name' for it in projects.declared`,
296
+ );
297
+ continue;
298
+ }
299
+
300
+ // Null-prototype: every key here is a TAG, and tags are workspace text —
301
+ // `archkeep.json`'s `projects.declared[].tags`, a `projectRules` row, a
302
+ // tracked `project.json` — so a pull request can name one `toString`,
303
+ // `constructor`, `valueOf`, `hasOwnProperty` or `__proto__`. On a plain
304
+ // `{}` the `??=` below finds an inherited member instead of `undefined`,
305
+ // keeps it, and calls `.add` on it: measured, `TypeError:
306
+ // tagOrigins[tag].add is not a function`, printed verbatim with no
307
+ // `archkeep:` prefix, no file and no row, on the exit-3 path.
308
+ //
309
+ // The near miss is worse than the bug, which is why this is
310
+ // `Object.create(null)` and not a plain assignment: `Object.keys({__proto__:
311
+ // new Set()})` is `[]`, so "fixing" the crash by writing the key onto a
312
+ // plain object would repoint that object's prototype and the tag would
313
+ // VANISH from `Object.keys(tagOrigins).sort()` below — a project silently
314
+ // carrying one fewer tag than it declared, which is every tag-keyed rule
315
+ // (`../../rules/tags.mjs`) quietly not applying to it. With a null
316
+ // prototype the `??=` sees a real `undefined`, the `Set` is stored as an
317
+ // own enumerable entry, and the tag reaches both `tags` and `tagOrigins`
318
+ // exactly like any other spelling. (`Object.fromEntries` at the bottom of
319
+ // this loop defines own properties, so it carries such a key through
320
+ // unchanged.)
321
+ const tagOrigins = /** @type {Record<string, Set<string>>} */ (Object.create(null));
322
+ const addTags = (tags, origin) => {
323
+ for (const tag of tags ?? []) {
324
+ (tagOrigins[tag] ??= new Set()).add(origin);
325
+ }
326
+ };
327
+ addTags(declared?.tags, "declared");
328
+ addTags(manifest?.tags, "project.json");
329
+ for (const [index, rule] of model.projectRules.entries()) {
330
+ if (matchesGlob(projectRoot, rule.match)) addTags(rule.tags, `projectRules[${index}]`);
331
+ }
332
+
333
+ // Spec D6: every `projectRules` row whose `match` covers this root and
334
+ // states a `type` gets a vote, and the votes must agree. `.find()` used
335
+ // to take the first match and ignore the rest, so reordering two rows in
336
+ // `archkeep.json` — a change that should never affect a verdict — could
337
+ // silently flip a project's type and, with it, which import-ban rules
338
+ // apply to it. A tie (every voting row names the SAME type) is fine and
339
+ // resolves to that type; only disagreement is fatal.
340
+ const typeVotes = model.projectRules
341
+ .map((rule, index) => ({ rule, index }))
342
+ .filter(({ rule }) => rule.type && matchesGlob(projectRoot, rule.match));
343
+ const distinctTypes = new Set(typeVotes.map(({ rule }) => rule.type));
344
+ if (distinctTypes.size > 1) {
345
+ violations.push(
346
+ `projectRules: ${typeVotes.map(({ index }) => `[${index}]`).join(", ")} disagree on ` +
347
+ `'${projectRoot}'s type (${[...distinctTypes].join(" vs. ")}) — every projectRules ` +
348
+ `row matching one project must agree on its type`,
349
+ );
350
+ continue;
351
+ }
352
+ const type =
353
+ declared?.type ?? typeVotes[0]?.rule.type ?? nodeTypeOf(name, manifest?.projectType);
354
+
355
+ // A manifest-sourced implicitDependencies entry gets the same pattern
356
+ // check `./model.mjs`'s `declaredProjectViolations` already runs on a
357
+ // declared row's own list — the same matcher `./graph.mjs`'s
358
+ // `buildDependencies` will eventually call
359
+ // (`../../rules/match.mjs`'s `findMatchingProjects`) — so a pattern it
360
+ // cannot resolve is a discovery-time defect named against this project,
361
+ // never a silently dropped edge at graph-build time.
362
+ for (const pattern of manifest?.implicitDependencies ?? []) {
363
+ const error = projectPatternError(pattern);
364
+ if (error) {
365
+ violations.push(
366
+ `${projectRoot}/${PROJECT_CONFIG_FILE}: implicitDependencies entry '${pattern}': ${error}`,
367
+ );
368
+ }
369
+ }
370
+
371
+ const implicitDependencies = [
372
+ ...new Set([
373
+ ...(declared?.implicitDependencies ?? []),
374
+ ...(manifest?.implicitDependencies ?? []),
375
+ ]),
376
+ ];
377
+
378
+ resolved.push({
379
+ name,
380
+ root: projectRoot,
381
+ type: /** @type {"app"|"e2e"|"lib"} */ (type),
382
+ tags: Object.keys(tagOrigins).sort(),
383
+ tagOrigins: Object.fromEntries(
384
+ Object.entries(tagOrigins).map(([tag, origins]) => [tag, [...origins].sort()]),
385
+ ),
386
+ implicitDependencies,
387
+ // `targets` names target NAMES only — see `./model.mjs`'s
388
+ // `declaredProjectViolations` for the shape validated, and
389
+ // `./graph.mjs`'s `buildNativeGraph` for how each name becomes a
390
+ // synthesized `{executor: "archkeep:declared"}` entry. `project.json`
391
+ // is not a source: this provider never reads its real target
392
+ // definitions, only whether `archkeep.json` declared the project has
393
+ // one.
394
+ targets: declared?.targets ?? [],
395
+ });
396
+ }
397
+
398
+ const seenNames = new Map();
399
+ for (const project of resolved) {
400
+ if (seenNames.has(project.name)) {
401
+ violations.push(
402
+ `projects: '${project.name}' names both '${seenNames.get(project.name)}' and ` +
403
+ `'${project.root}' — every project must resolve to a unique name`,
404
+ );
405
+ continue;
406
+ }
407
+ seenNames.set(project.name, project.root);
408
+ }
409
+
410
+ model.projectRules.forEach((rule, index) => {
411
+ if (!resolved.some((project) => matchesGlob(project.root, rule.match))) {
412
+ violations.push(`projectRules[${index}]: '${rule.match}' matches no discovered project`);
413
+ }
414
+ });
415
+
416
+ if (violations.length === 0 && resolved.length === 0) {
417
+ violations.push(
418
+ "projects: this workspace describes zero projects — declare at least one in " +
419
+ "projects.declared, or let projects.infer find one",
420
+ );
421
+ }
422
+
423
+ if (violations.length > 0) {
424
+ throw new Error(
425
+ `archkeep: ${root}/archkeep.json describes a workspace that does not match the tree:\n ` +
426
+ violations.join("\n "),
427
+ );
428
+ }
429
+
430
+ return { projects: resolved, failures };
431
+ }
@@ -0,0 +1,234 @@
1
+ /**
2
+ * Import edges and implicit edges, reduced to Nx's own dependency-map shape —
3
+ * the second half of what `../../lsp/workspace-index.mjs` used to build by
4
+ * hand, promoted here so a native `archkeep.json` workspace and the language
5
+ * server share one implementation rather than growing two that drift
6
+ * (`../../../AGENTS.md`, "`src/providers/` is the layer that supplies a graph
7
+ * to `evaluate()`").
8
+ */
9
+ import { findMatchingProjects } from "../../rules/match.mjs";
10
+
11
+ /**
12
+ * The dependency map, keyed by source project as Nx keys it.
13
+ *
14
+ * Two kinds of edge, and both are Nx's:
15
+ *
16
+ * - **Import edges**, from the analysis records. A `dynamic` import site
17
+ * becomes a `dynamic` edge, because `noImportsOfLazyLoadedLibraries` is
18
+ * decided on exactly that distinction (`../../rules/topology.mjs`).
19
+ * - **Implicit edges**, from each project's `implicitDependencies`, expanded
20
+ * with the SAME matcher Nx uses — reached through `../../rules/match.mjs`
21
+ * rather than reimplemented, so a pattern that resolves one way for a
22
+ * constraint cannot resolve another way here.
23
+ *
24
+ * @param {{importSites: object[], nodes: Record<string, object>, projectOf: (file: string) => string|undefined}} input
25
+ * @returns {Record<string, {source: string, target: string, type: string}[]>}
26
+ */
27
+ export function buildDependencies({ importSites, nodes, projectOf }) {
28
+ // Null-prototype: every key here is a project NAME, and project names come
29
+ // from `archkeep.json`'s own `projects.declared[].name` or a tracked
30
+ // manifest's own `name` field — both attacker-supplied the moment a pull
31
+ // request adds a project called `__proto__`. A plain `{}` literal answers
32
+ // `dependencies["__proto__"] = […]` by reassigning the object's OWN
33
+ // prototype rather than adding an entry (`Object.prototype`'s inherited
34
+ // `__proto__` accessor), so the array this line means to store is silently
35
+ // discarded and any read of `dependencies["__proto__"]` afterward returns
36
+ // whatever the prototype now is — not an array, so the very next `.push`
37
+ // onto it throws `TypeError: … .push is not a function`, taking the whole
38
+ // graph build down over one project name. `Object.create(null)` has no
39
+ // inherited `__proto__` accessor to collide with, so the key behaves like
40
+ // every other project name: a real, own, enumerable entry.
41
+ /** @type {Record<string, {source: string, target: string, type: string}[]>} */
42
+ const dependencies = Object.create(null);
43
+ const seen = new Set();
44
+ // Nx's own rule for config files, reproduced over the same two dialect
45
+ // spellings of the workspace-root project's root. `explicit-project-dependencies.js`
46
+ // (the import-site half of nx's graph construction) drops every edge whose
47
+ // TARGET is the workspace root, keeping only edges that originate in it —
48
+ // `if (isRoot(source) || !isRoot(target))` — and names the gap it papers
49
+ // over in its own TODO: "These edges technically should be allowed but we
50
+ // need to figure out how to separate config files out from root". The root
51
+ // project carries the tree's own config files (`eslint.config.*`,
52
+ // `*.config.ts`, `global-setup.*`), and Nx drops exactly their imported
53
+ // edges: measured on the code-pushup real tree at the pinned sha, `nx graph`
54
+ // reports ZERO edges into the root `workspace` node while project-level
55
+ // config files visibly import the root's — the import site exists, the
56
+ // target-root edge is what Nx suppresses. So no edge here may point at the
57
+ // workspace root, or the graph invents dependencies Nx never drew and
58
+ // `noCircularDependencies` reports cycles that only close through that
59
+ // node. `scripts/differential-real-trees.mjs`'s ledger carries the
60
+ // full finding; Nx spells the root `"."` (`isRoot` reads `root === '.'`
61
+ // verbatim) while this package's native `archkeep.json` dialect spells it
62
+ // `""` (`./discover.mjs`'s `discoverNativeProjects`), so both are
63
+ // recognised here rather than one silently differing the other.
64
+ const isRoot = (name) => {
65
+ const root = nodes[name]?.data?.root;
66
+ return root === "" || root === ".";
67
+ };
68
+ const add = (source, target, type) => {
69
+ if (!source || !target || source === target) return;
70
+ if (!nodes[target]) return;
71
+ // `JSON.stringify` of the tuple, not a space-joined string: a project name
72
+ // may contain a space — neither `./model.mjs`'s `declaredProjectViolations`
73
+ // nor `./discover.mjs`'s manifest-sourced name resolution reject one (only
74
+ // non-empty is required; npm's own naming rules, which forbid a space, bind
75
+ // `package.json`'s name and nothing declared in `archkeep.json` directly) —
76
+ // so `${source} ${target} ${type}` collides whenever a space moves across
77
+ // the join: source `"a b"` target `"c"` and source `"a"` target `"b c"`
78
+ // both key to `"a b c static"`, and the second edge silently vanishes as a
79
+ // false duplicate of the first. A JSON array has no such ambiguity: each
80
+ // element is individually quoted and escaped.
81
+ const key = JSON.stringify([source, target, type]);
82
+ if (seen.has(key)) return;
83
+ seen.add(key);
84
+ (dependencies[source] ??= []).push({ source, target, type });
85
+ };
86
+
87
+ for (const site of importSites) {
88
+ const source = projectOf(site.sourceFile);
89
+ const target = site.resolved?.target;
90
+ // The target-root skip, exactly as Nx applies it — and only to IMPORT
91
+ // edges. Nx's implicit-dependency expansion (`applyImplicitDependencies`)
92
+ // has no `isRoot` check, so a project.json naming the root as an implicit
93
+ // dependency still draws the edge there; import sites are the one place
94
+ // Nx refuses, and they are the one place this loop reproduces it. Keeping
95
+ // `source->root` without also dropping `root->source` would misread the
96
+ // real tree the other way (it would be a loud native-extra rather than a
97
+ // cycle — config files visibly do import INTO the root, so the only
98
+ // question is which side Nx withholds, and it withholds the target side).
99
+ if (isRoot(target) && !isRoot(source)) continue;
100
+ add(source, target, site.kind === "dynamic" ? "dynamic" : "static");
101
+ }
102
+ for (const [name, node] of Object.entries(nodes)) {
103
+ const declared = node.data.implicitDependencies;
104
+ if (!Array.isArray(declared) || declared.length === 0) continue;
105
+ // A pattern the matcher rejects is a project-definition problem, and
106
+ // `findMatchingProjects` throws naming it (`../../rules/match.mjs`). That
107
+ // throw is left to propagate rather than caught here, for both callers of
108
+ // this function: `./discover.mjs` validates every native
109
+ // `implicitDependencies` entry — declared-row (`./model.mjs`'s
110
+ // `declaredProjectViolations`) and `project.json`-sourced alike — before a
111
+ // graph is ever built from them, so a native workspace never reaches this
112
+ // line with a bad pattern in the first place; the throw below is dead code
113
+ // on that path, not a silent one. `../../lsp/workspace-index.mjs`'s
114
+ // `project.json` has no such validator, so its build CAN reach here with
115
+ // one — and letting the exception propagate is the correct answer there
116
+ // too: `../../lsp/server.mjs`'s `initialize`/`didOpen` handling already
117
+ // wraps the whole index build in a `.catch()` that turns any thrown error
118
+ // into a loud "could not analyze" state published to every open document,
119
+ // exactly the failure mode `../../../AGENTS.md`'s invariant asks for. A
120
+ // caught-and-dropped edge here used to reach that same bad pattern
121
+ // silently: the project kept building, one implicit edge simply never
122
+ // existed, and any boundary violation that edge would have carried read as
123
+ // a clean workspace instead of one that could not be judged.
124
+ const expanded = findMatchingProjects(declared, nodes);
125
+ for (const target of expanded) add(name, target, "implicit");
126
+ }
127
+ return dependencies;
128
+ }
129
+
130
+ /**
131
+ * The graph `evaluate()` judges, built from discovered native projects and
132
+ * the import sites analysis found in them.
133
+ *
134
+ * Only `data.root` and `data.tags` are written here — the two facts this
135
+ * provider actually measured (`./discover.mjs`). `entryPoints`,
136
+ * `declaredPackages` and `mfeRemote` are NOT spread in from `archkeep.json`;
137
+ * they stay annotator-computed by the same functions the Nx path calls
138
+ * (`../../workspace.mjs`'s `annotateMFERemotes`/`annotatePackageFacts`,
139
+ * invoked by the caller afterward — see `../nx.mjs`'s
140
+ * `ProjectModelProvider` doc for why that split holds for every provider).
141
+ * `implicitDependencies` rides on `data` too, because `buildDependencies`
142
+ * above reads it from there — the one field this provider both writes AND
143
+ * reads back, by the same contract Nx's own `project.json` uses.
144
+ *
145
+ * `externalNodes` is never set: `../../rules/specifiers.mjs`'s
146
+ * `findTransitiveExternalDependencies` already treats an absent
147
+ * `graph.externalNodes` as "none", and `../../rules/index.mjs`'s
148
+ * `externalNodeFor` synthesises a node for an external target on demand — a
149
+ * native provider declaring npm-registry bookkeeping would be a second
150
+ * source of truth for a fact this package already derives from the analysis
151
+ * records.
152
+ *
153
+ * `data.targets` is synthesised, never measured: `archkeep.json`'s
154
+ * `projects.declared[].targets` names target NAMES only (spec §5), never an
155
+ * executor or a config — this provider has no build system to ask for one.
156
+ * Each declared name becomes `{executor: "archkeep:declared"}`, a non-empty
157
+ * executor string so `../../rules/topology.mjs`'s `hasBuildExecutor` (which
158
+ * treats `executor === ''` as "not really buildable") reads it as real,
159
+ * making `enforceBuildableLibDependency` live on a native tree the same way
160
+ * it is on an Nx one. An empty `targets` list stays an ABSENT `data.targets`
161
+ * rather than an empty object, so `hasBuildExecutor`'s own `Boolean(targets
162
+ * && …)` sees "no targets" for a project that declared none, not a targets
163
+ * table that happens to match nothing.
164
+ *
165
+ * `workspaceLayout` rides on the returned graph OBJECT, never inside a node's
166
+ * `data` — it is a workspace-wide fact, not a per-project one, and
167
+ * `../../rules/index.mjs`'s `createContext` reads it with exactly this
168
+ * fallback: `graph.workspaceLayout ?? DEFAULT_WORKSPACE_LAYOUT`. Passing
169
+ * `undefined` through (rather than defaulting it here) keeps that the only
170
+ * place the default is ever applied — see `./model.mjs`'s
171
+ * `normalizeNativeModel` for why a second default here could drift from it.
172
+ *
173
+ * `exemptedFiles` rides the same way — the CONCRETE list of files
174
+ * `coverage.exempt` removed from coverage (`./coverage.mjs`'s `judgeCoverage`,
175
+ * threaded through `../index.mjs`'s `buildGraph` from `discovered.exempted`),
176
+ * never the globs the rows are written with. The rules layer matches import
177
+ * resolutions against these exact paths (`../../rules/index.mjs`'s
178
+ * `exemptResolvedFile`, #218), and the guard that keeps a broad row from
179
+ * becoming a boundary-off switch lives in what `judgeCoverage` already does:
180
+ * it expands rows over tracked, analyzable, UNOWNED files only, so a
181
+ * project-owned file cannot enter this list no matter how wide its row is.
182
+ * Absent when no file was exempted, so a graph with no exemptions stays
183
+ * byte-identical to one this field never existed on.
184
+ *
185
+ * @param {{projects: {name: string, root: string, type: string, tags: string[], implicitDependencies: string[], targets: string[]}[], importSites: object[], projectOf: (file: string) => string|undefined, workspaceLayout?: {appsDir: string, libsDir: string}, exemptedFiles?: string[]}} args
186
+ * @returns {{nodes: Record<string, object>, dependencies: Record<string, object[]>, workspaceLayout?: {appsDir: string, libsDir: string}, exemptedFiles?: string[]}}
187
+ */
188
+ export function buildNativeGraph({
189
+ projects,
190
+ importSites,
191
+ projectOf,
192
+ workspaceLayout,
193
+ exemptedFiles,
194
+ }) {
195
+ // Null-prototype for the same reason `buildDependencies` above uses one: a
196
+ // project literally named `__proto__` is a name this provider does not
197
+ // control (it comes straight from `archkeep.json` or a tracked manifest), and
198
+ // a plain `{}` answers `nodes["__proto__"] = …` by repointing the object's
199
+ // own prototype instead of adding an entry. That project would then vanish
200
+ // from every `Object.keys(nodes)`/`Object.entries(nodes)` walk in this
201
+ // module and in `../../rules/`, while `nodes["__proto__"]` kept reading back
202
+ // truthy — an import INTO it would still resolve as a real target, but the
203
+ // project's own outgoing edges, its tags, its type would all be invisible to
204
+ // anything that iterates rather than looks up by name. Silent, and exactly
205
+ // the shape `../../../AGENTS.md`'s invariant refuses: a workspace with a
206
+ // `__proto__` project would read as one with fewer projects than it
207
+ // declared, with no diagnostic naming why.
208
+ /** @type {Record<string, object>} */
209
+ const nodes = Object.create(null);
210
+ for (const project of projects) {
211
+ nodes[project.name] = {
212
+ name: project.name,
213
+ type: project.type,
214
+ data: {
215
+ root: project.root,
216
+ tags: project.tags,
217
+ implicitDependencies: project.implicitDependencies,
218
+ ...(project.targets && project.targets.length > 0
219
+ ? {
220
+ targets: Object.fromEntries(
221
+ project.targets.map((name) => [name, { executor: "archkeep:declared" }]),
222
+ ),
223
+ }
224
+ : {}),
225
+ },
226
+ };
227
+ }
228
+ return {
229
+ nodes,
230
+ dependencies: buildDependencies({ importSites, nodes, projectOf }),
231
+ ...(workspaceLayout ? { workspaceLayout } : {}),
232
+ ...(exemptedFiles && exemptedFiles.length > 0 ? { exemptedFiles } : {}),
233
+ };
234
+ }