@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.
- package/LICENSE +202 -0
- package/README.md +262 -0
- package/cli.mjs +2792 -0
- package/index.mjs +85 -0
- package/lsp.mjs +81 -0
- package/nx.mjs +24 -0
- package/package.json +81 -0
- package/presets/clean-architecture.json +78 -0
- package/presets/ddd-bounded-contexts.json +88 -0
- package/presets/hexagonal.json +68 -0
- package/presets/layered.json +92 -0
- package/presets/modular-monolith.json +85 -0
- package/presets/vertical-slice.json +68 -0
- package/src/analysis/analyze.mjs +218 -0
- package/src/analysis/contract.md +259 -0
- package/src/analysis/go.mjs +414 -0
- package/src/analysis/manifest-util.mjs +68 -0
- package/src/analysis/python.mjs +1266 -0
- package/src/analysis/registry.mjs +74 -0
- package/src/analysis/rust.mjs +674 -0
- package/src/analysis/source-util.mjs +230 -0
- package/src/analysis/typescript.mjs +1034 -0
- package/src/analysis/vue.mjs +156 -0
- package/src/architecture-intent/intent-fingerprint.mjs +29 -0
- package/src/architecture-intent/judge.mjs +539 -0
- package/src/architecture-intent/model.mjs +703 -0
- package/src/architecture-intent/selectors.mjs +170 -0
- package/src/canonical.mjs +48 -0
- package/src/commands/README.md +266 -0
- package/src/commands/adr.mjs +248 -0
- package/src/commands/check.mjs +989 -0
- package/src/commands/context-command.mjs +212 -0
- package/src/commands/context.mjs +790 -0
- package/src/commands/custom-rules.mjs +428 -0
- package/src/commands/debt.mjs +218 -0
- package/src/commands/diff.mjs +523 -0
- package/src/commands/discover.mjs +159 -0
- package/src/commands/drift.mjs +473 -0
- package/src/commands/edge-constraints.mjs +355 -0
- package/src/commands/explain.mjs +359 -0
- package/src/commands/fitness.mjs +226 -0
- package/src/commands/graph.mjs +297 -0
- package/src/commands/health.mjs +213 -0
- package/src/commands/history.mjs +614 -0
- package/src/commands/impact.mjs +226 -0
- package/src/commands/plan-context-command.mjs +496 -0
- package/src/commands/policy.mjs +138 -0
- package/src/commands/provenance-command.mjs +352 -0
- package/src/commands/provenance.mjs +159 -0
- package/src/commands/reconcile.mjs +219 -0
- package/src/commands/report.mjs +553 -0
- package/src/commands/snapshot-meta.mjs +107 -0
- package/src/commands/waivers.mjs +240 -0
- package/src/config.mjs +1308 -0
- package/src/containment.mjs +234 -0
- package/src/custom-rules/evidence.mjs +340 -0
- package/src/custom-rules/host.mjs +1023 -0
- package/src/custom-rules/values.mjs +43 -0
- package/src/entry-point.mjs +55 -0
- package/src/errors.mjs +36 -0
- package/src/eslint-config.mjs +542 -0
- package/src/go-work.mjs +394 -0
- package/src/governance/adr-registry.mjs +539 -0
- package/src/governance/clock.mjs +69 -0
- package/src/governance/debt-ledger.mjs +274 -0
- package/src/governance/discovery-proposal.mjs +423 -0
- package/src/governance/fitness-registry.mjs +504 -0
- package/src/governance/fitness-rules.mjs +668 -0
- package/src/governance/metrics.mjs +392 -0
- package/src/governance/preset-fingerprints.json +16 -0
- package/src/governance/profile-registry.mjs +366 -0
- package/src/governance/provenance-record.mjs +177 -0
- package/src/governance/reconcile-candidates.mjs +301 -0
- package/src/governance/reconcile-score.mjs +503 -0
- package/src/governance/row-schema.mjs +208 -0
- package/src/governance/verdict.mjs +127 -0
- package/src/governance/waiver.mjs +105 -0
- package/src/graph/create-dependencies.mjs +96 -0
- package/src/intent/intent-manifest.json +347 -0
- package/src/intent/mask-non-code.mjs +640 -0
- package/src/lsp/boundary-config.mjs +225 -0
- package/src/lsp/diagnose.mjs +202 -0
- package/src/lsp/diagnostics.mjs +241 -0
- package/src/lsp/protocol.mjs +215 -0
- package/src/lsp/server.mjs +922 -0
- package/src/lsp/workspace-index.mjs +891 -0
- package/src/nx-json.mjs +95 -0
- package/src/options.mjs +611 -0
- package/src/process.mjs +91 -0
- package/src/providers/moon.mjs +733 -0
- package/src/providers/native/README.md +204 -0
- package/src/providers/native/coverage.mjs +74 -0
- package/src/providers/native/differential.fixtures.mjs +1277 -0
- package/src/providers/native/discover.mjs +431 -0
- package/src/providers/native/graph.mjs +234 -0
- package/src/providers/native/index.mjs +152 -0
- package/src/providers/native/model.mjs +755 -0
- package/src/providers/nx.mjs +178 -0
- package/src/report/README.md +89 -0
- package/src/report/adr-text.mjs +129 -0
- package/src/report/context-text.mjs +109 -0
- package/src/report/debt-text.mjs +105 -0
- package/src/report/diff-text.mjs +219 -0
- package/src/report/discover-text.mjs +186 -0
- package/src/report/drift-text.mjs +194 -0
- package/src/report/envelope-shape.mjs +161 -0
- package/src/report/evidence.mjs +157 -0
- package/src/report/explain-text.mjs +159 -0
- package/src/report/graph-text.mjs +116 -0
- package/src/report/health-text.mjs +123 -0
- package/src/report/history-text.mjs +204 -0
- package/src/report/impact-text.mjs +128 -0
- package/src/report/json.mjs +173 -0
- package/src/report/plan-context-text.mjs +159 -0
- package/src/report/provenance-text.mjs +78 -0
- package/src/report/reconcile-text.mjs +159 -0
- package/src/report/report-text.mjs +264 -0
- package/src/report/sarif.mjs +953 -0
- package/src/report/text.mjs +823 -0
- package/src/report/waivers-text.mjs +100 -0
- package/src/rules/README.md +123 -0
- package/src/rules/index.mjs +962 -0
- package/src/rules/match.mjs +1708 -0
- package/src/rules/messages.mjs +73 -0
- package/src/rules/reachability.mjs +224 -0
- package/src/rules/specifiers.mjs +300 -0
- package/src/rules/tags.mjs +238 -0
- package/src/rules/topology.mjs +333 -0
- package/src/tsconfig-paths.mjs +237 -0
- package/src/verdict.mjs +145 -0
- 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
|
+
}
|