@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,1266 @@
1
+ /**
2
+ * Python resolver — reads pyproject.toml with a real TOML parser (smol-toml),
3
+ * no `uv` binary required.
4
+ *
5
+ * Model: one package per Nx project (`<projectRoot>/pyproject.toml`,
6
+ * `[project].name`), and an edge exists only where the manifest EXPLICITLY
7
+ * wires a dependency to a workspace path. A name that merely coincides with a
8
+ * sibling package or a PyPI package never creates an edge — each tool's
9
+ * documented semantics, not string matching. Three tools' declarations are
10
+ * read, each against its current documentation (fetched 2026-08-11):
11
+ *
12
+ * - **uv** — a dependency string in `[project].dependencies`,
13
+ * `[project.optional-dependencies].*`, or `[dependency-groups].*` creates an
14
+ * edge only when `[tool.uv.sources]` routes that name to the workspace
15
+ * (`{ workspace = true }`) or to a path that is another project's directory.
16
+ * A source may also be uv's documented multiple-sources-by-marker form — an
17
+ * ARRAY of source tables, one selected per environment marker at install
18
+ * time — and each element is read the same way a lone table is; a static
19
+ * reader cannot evaluate the markers, so any entry that names a workspace
20
+ * project draws the edge. Quiet handling of a path source that resolves to
21
+ * no project is unchanged.
22
+ * - **Poetry** — `name = { path = "…" }` in `[tool.poetry.dependencies]` or
23
+ * `[tool.poetry.group.<group>.dependencies]`
24
+ * (https://python-poetry.org/docs/dependency-specification/ §"Path
25
+ * dependencies", https://python-poetry.org/docs/managing-dependencies/
26
+ * §"Dependency groups"). An entry that is an array of tables — the
27
+ * documented multiple-constraints form — has each element read the same
28
+ * way. `develop` changes install mode, never whether the dependency exists,
29
+ * so it is ignored. The entry itself is the declaration in both of Poetry's
30
+ * modes: in the legacy layout `[tool.poetry.dependencies]` IS the dependency
31
+ * list, and in PEP 621 layout the docs state it "is only used to enrich
32
+ * `project.dependencies` for locking" — either way a `path` there is an
33
+ * explicit local wiring. The `[tool.poetry.dev-dependencies]` table of old
34
+ * Poetry versions is deliberately NOT read: it appears nowhere in the
35
+ * current documentation, and reading undocumented shapes is how a resolver
36
+ * drifts from the tool it mirrors.
37
+ * - **PDM** — a requirement string in the same three dependency arrays, in the
38
+ * two root-anchored local-URL forms its docs write
39
+ * (https://pdm-project.org/latest/usage/dependency/ §"Local dependencies",
40
+ * https://pdm-project.org/latest/reference/pep621/ §"Relative paths"):
41
+ * `name @ file:///${PROJECT_ROOT}/<path>` (pdm-backend) and
42
+ * `name @ {root:uri}/<path>` (hatchling), plus the editable
43
+ * `-e file:///${PROJECT_ROOT}/<path>` entry form the monorepo guide puts in
44
+ * `[dependency-groups]` (https://pdm-project.org/latest/usage/advanced/).
45
+ * `${PROJECT_ROOT}` "will be expanded based on the project root" per those
46
+ * docs, i.e. against the manifest's own directory — the same base every
47
+ * relative `path` above resolves against. A `[tool.pdm.dev-dependencies]`
48
+ * table is deliberately NOT read for the same reason as Poetry's legacy
49
+ * table: current PDM docs route development groups through
50
+ * `[dependency-groups]`, which is already scanned.
51
+ *
52
+ * ## Where a declared path may land, and what each landing produces
53
+ *
54
+ * Relative paths resolve against the declaring manifest's directory. The
55
+ * verdicts, in the order they are tested:
56
+ *
57
+ * - **Another Nx project's root** — an edge. Exactly the root: the
58
+ * one-manifest-per-project-root model (`packages/archkeep/AGENTS.md`)
59
+ * is what makes a directory-to-project attribution well defined at all.
60
+ * - **The declaring project itself** (its root, or anything under it, such as
61
+ * a vendored wheel `file:///${PROJECT_ROOT}/vendor/x.whl`) — no edge. No
62
+ * cross-project wiring exists to lose.
63
+ * - **Outside the workspace** (the path climbs above the root) — no edge, and
64
+ * that is a verdict rather than a shrug: whatever sits there is not a
65
+ * workspace project, so no project↔project edge can exist. Same answer an
66
+ * external PyPI package gets.
67
+ * - **Anywhere else in the tree** — a directory that is no project's root, a
68
+ * file inside another project, a path that resolves to nothing — **throws**,
69
+ * failing graph computation with an error naming the manifest, the entry,
70
+ * and where the path landed. The graph hook has exactly two outputs, edges
71
+ * and a throw; a skipped entry here is an edge `nx affected` silently never
72
+ * sees on a wiring the manifest plainly states, and a stderr line during
73
+ * graph computation is scrollback, not a report. Throwing is the same door
74
+ * `../options.mjs` uses for an unknown key, and it is self-correcting in
75
+ * the way silence is not: the error says to point the path at the owning
76
+ * project's root, split the nested package into its own project, or fix the
77
+ * typo. A PDM local URL that is NOT root-anchored (an absolute `file:///…`,
78
+ * which the PDM docs note other build backends write) throws for the same
79
+ * reason from one step earlier: without the anchor this resolver cannot
80
+ * even say whether the target is in the tree.
81
+ * - **A `pyproject.toml` that is not valid TOML** draws no edges and does NOT
82
+ * throw — deliberately asymmetric with the dangling path. Nx recomputes the
83
+ * graph on every invocation, so a manifest is malformed mid-keystroke in
84
+ * every editing session (`manifest-util.test.mjs` pins that survival), while
85
+ * a dangling path is a stable, readable claim. The loud report for a
86
+ * malformed manifest is the analysis layer's: `pythonPackageLayout` returns
87
+ * it as unmodelled, and imports then fail rather than resolve.
88
+ *
89
+ * ## Two resolutions, kept side by side, because they answer different things
90
+ *
91
+ * `resolvePythonDependencies` above is **manifest-level**: it reports what a
92
+ * project DECLARED, which is what an Nx edge should carry. `analyzePython`
93
+ * below is **source-level**: it reports what a file actually IMPORTS.
94
+ *
95
+ * They are not the same question, and the gap between them is a real false
96
+ * negative rather than a theoretical one. A `.py` file that writes
97
+ * `import other_project.thing` without any `[tool.uv.sources]` entry imports
98
+ * perfectly at runtime — in a uv workspace both packages are installed and
99
+ * both are on `sys.path` — while the manifest says nothing at all. The
100
+ * manifest-level view sees no dependency; the boundary was still crossed.
101
+ *
102
+ * Neither replaces the other. A declared-but-unused dependency and an
103
+ * undeclared-but-imported one are both findings, and the two views disagreeing
104
+ * is itself the information.
105
+ *
106
+ * ## Import roots come from the filesystem, because that is what Python reads
107
+ *
108
+ * Python has no `ts.resolveModuleName` to delegate to, and it does not need
109
+ * one: an import name is a directory or file name on `sys.path`. So the layout
110
+ * itself is read:
111
+ *
112
+ * ```
113
+ * src/<pkg>/__init__.py -> import name <pkg> (src layout)
114
+ * <pkg>/__init__.py -> import name <pkg> (flat layout)
115
+ * <mod>.py -> import name <mod> (single module)
116
+ * ```
117
+ *
118
+ * Both bases — `<projectRoot>/src` and `<projectRoot>` — are read for every
119
+ * project, in that order, because a src-layout project routinely still has a
120
+ * root-level `conftest.py` that pytest makes importable as `conftest`. A file
121
+ * is attributed to the first base that contains it, so a src-layout package is
122
+ * never also indexed as `src.<pkg>`.
123
+ *
124
+ * ## Those two bases are not the whole layout, and assuming they were asserted a lie
125
+ *
126
+ * A package may sit anywhere its build backend says it does —
127
+ * `[tool.setuptools] package-dir = {"" = "lib"}` and
128
+ * `[tool.hatch.build.targets.wheel] packages = ["python/pkg"]` are both real,
129
+ * both import fine at runtime, and neither puts a directory where the scan
130
+ * above looks. Reading only the two default bases did not make that a blind
131
+ * spot the tool reported; it made the import resolve to nothing, which the
132
+ * branch below turned into `external: true` — a positive assertion that a
133
+ * first-party project is a PyPI distribution. Every tag constraint then
134
+ * evaporates, because `../rules/` returns from its `type === "npm"` branch
135
+ * before the constraint block. The contract's "never guesses a target from a
136
+ * name that looks similar" was being honoured in one direction and inverted in
137
+ * the other: this guessed that NO project owns the name.
138
+ *
139
+ * So the declarations are read, with `smol-toml` — already a dependency, and
140
+ * already parsing these same manifests one function up. Four shapes, because
141
+ * each is a table lookup rather than a build backend reimplemented:
142
+ *
143
+ * ```
144
+ * [tool.setuptools] package-dir = { "" = "lib" } -> lib
145
+ * [tool.setuptools.packages.find] where = ["lib"] -> lib
146
+ * [tool.hatch.build.targets.wheel] packages = ["py/x"] -> py (wheel path is `x`)
147
+ * [tool.poetry] packages = [{ include = "x", from = "lib" }] -> lib
148
+ * ```
149
+ *
150
+ * They are read whichever backend `[build-system]` names, because the table's
151
+ * presence is the declaration; a manifest carries at most one of them.
152
+ *
153
+ * **Everything else is a FAILURE, never an `external: true`.** A `package-dir`
154
+ * key other than `""` (which renames a package rather than naming a root), a
155
+ * hatch `sources` rewrite, a poetry `to`, a `pyproject.toml` that is not valid
156
+ * TOML, and a `[build-system] build-backend` this file does not read all mean
157
+ * the same thing: some directory of this workspace may be importable under a
158
+ * name nothing here knows. An import that then resolves to no project is
159
+ * recorded with `resolved: null` and a failure saying which project's manifest
160
+ * put the answer out of reach. That is louder than the silence it replaces and
161
+ * strictly weaker than the falsehood it replaces.
162
+ *
163
+ * **PEP 420 namespace packages are the one case the filesystem cannot settle,
164
+ * and here is what this does about it.** A directory with no `__init__.py` is
165
+ * still importable, and worse, two projects may each contribute a different
166
+ * subpackage to the SAME top-level namespace — which is the point of the
167
+ * feature. A top-level name alone therefore cannot say which project an import
168
+ * reached. So the index is not a list of top-level roots but a map of every
169
+ * importable dotted path, and resolution matches the LONGEST dotted prefix:
170
+ * `import ns.alpha.thing` resolves through `ns.alpha` to the project that owns
171
+ * it, never through the shared `ns`. When the longest matching prefix is
172
+ * genuinely owned by more than one project, that is reported as an ambiguity
173
+ * with `resolved: null` — Python itself resolves it by `sys.path` order, which
174
+ * no static reader can know, and guessing is what the contract forbids.
175
+ *
176
+ * Known parse limits, deliberate and pinned by tests. As with the Go and Rust
177
+ * headers, the worst case of each is a spurious record naming text the file
178
+ * really contains — never a missed project:
179
+ *
180
+ * - **Imports are matched per line**, with any indentation allowed. That is
181
+ * deliberate, not sloppy: it is what catches a function-local import and an
182
+ * import under `if TYPE_CHECKING:`, both of which cross a boundary. Python's
183
+ * own statement separators are followed rather than dropped at: a `;` opens
184
+ * a fresh statement to check on the same line, and a line whose last
185
+ * non-blank byte is `\` (`CONTINUATION_TAIL`) is joined with the next one
186
+ * first, the same explicit line-joining Python itself does — but only
187
+ * starting from a line that already opens with `from`/`import`, so an
188
+ * unrelated line elsewhere that happens to end in `\` (a comment noting a
189
+ * Windows path, say) never pulls a statement it has nothing to do with into
190
+ * this one. A statement that pulled continuation lines in this way and
191
+ * still could not be read as `from`/`import` is a failure naming the file
192
+ * rather than a silently dropped record — the same choice a brace-group
193
+ * `use` gets in the Rust analyzer below.
194
+ * - **A UTF-8 BOM and CRLF line endings are tolerated** (`contract.md`, byte
195
+ * tolerance): the BOM failed every `^[ \t]*` statement anchor, dropping a
196
+ * first-line import outright, and the `\r` a `\n` split leaves on each line
197
+ * hid every joining backslash from an end-of-line check, so a continued
198
+ * statement parsed as if its continuation did not exist and every name
199
+ * after the break was missed with nothing reported. Both bytes are blanked
200
+ * to a space, never stripped — see `parsePythonImportSites`.
201
+ * - **A parenthesised name list** — `import (a, b)` or `from x import (a, b)`,
202
+ * Black and isort's normalised multi-import spelling — is read the same way
203
+ * as the single-line comma list: the surrounding parens and any interior
204
+ * line breaks are stripped, and each name is a record. A paren group whose
205
+ * contents cannot be read as names is a failure, never a silently empty
206
+ * group (`parsePythonImportSites` pins both).
207
+ * - **A triple-quoted string containing a line that looks like an import** is
208
+ * read as one. `#` comments are not, since the `#` precedes the keyword.
209
+ * - **`if TYPE_CHECKING:` imports stay `kind: "static"`.** They are erased at
210
+ * runtime, so `type-only` is tempting, but a TYPE_CHECKING guard is a
211
+ * runtime conditional rather than a declaration that the dependency is
212
+ * absent — the module is still named, and the boundary is still crossed.
213
+ * Marking them `type-only` would let a rule that exempts erased imports
214
+ * exempt them, which is the bypass this tool exists to close.
215
+ * - **`packageName` for an external import is the top-level import name**, not
216
+ * the PyPI distribution name. The two differ (`import PIL` ships as
217
+ * `pillow`) and only the import name is knowable from a source file.
218
+ */
219
+ import { normalizePath, parseManifest, resolveWithinWorkspace } from "./manifest-util.mjs";
220
+ import {
221
+ emptyResult,
222
+ fileFailure,
223
+ perWorkspace,
224
+ positionAt,
225
+ projectOwning,
226
+ } from "./source-util.mjs";
227
+
228
+ /** PEP 503 name normalization: case-insensitive, runs of `-_.` collapse to `-`. */
229
+ export function normalizePackageName(name) {
230
+ return name.toLowerCase().replace(/[-_.]+/g, "-");
231
+ }
232
+
233
+ /** The package name a PEP 508 requirement string refers to, or null. */
234
+ export function parseRequirementName(requirement) {
235
+ const match = requirement.trim().match(/^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?/);
236
+ return match ? normalizePackageName(match[0]) : null;
237
+ }
238
+
239
+ /** Every dependency name a pyproject manifest declares, deduped. */
240
+ export function collectDeclaredDependencies(manifest) {
241
+ const names = new Set();
242
+ const groups = [
243
+ manifest.project?.dependencies ?? [],
244
+ ...Object.values(manifest.project?.["optional-dependencies"] ?? {}),
245
+ ...Object.values(manifest["dependency-groups"] ?? {}),
246
+ ];
247
+ for (const group of groups) {
248
+ for (const entry of group) {
249
+ if (typeof entry !== "string") continue; // {include-group = …} tables
250
+ const name = parseRequirementName(entry);
251
+ if (name) names.add(name);
252
+ }
253
+ }
254
+ return [...names];
255
+ }
256
+
257
+ /**
258
+ * Poetry's documented path dependencies: `name = { path = "…" }` entries in
259
+ * `[tool.poetry.dependencies]` and `[tool.poetry.group.<group>.dependencies]`,
260
+ * including each element of a multiple-constraints array. See the header for
261
+ * the doc pages and for the two tables deliberately not read.
262
+ *
263
+ * @param {object} manifest A parsed pyproject.toml.
264
+ * @returns {{ name: string, path: string, unanchored?: undefined, declaredIn: string }[]}
265
+ */
266
+ function poetryPathDependencies(manifest) {
267
+ const tables = [];
268
+ const main = manifest.tool?.poetry?.dependencies;
269
+ if (main !== undefined) tables.push(["[tool.poetry.dependencies]", main]);
270
+ const groups = manifest.tool?.poetry?.group;
271
+ if (typeof groups === "object" && groups !== null && !Array.isArray(groups)) {
272
+ for (const [groupName, group] of Object.entries(groups)) {
273
+ const deps = group?.dependencies;
274
+ if (deps !== undefined) {
275
+ tables.push([`[tool.poetry.group.${groupName}.dependencies]`, deps]);
276
+ }
277
+ }
278
+ }
279
+
280
+ const entries = [];
281
+ for (const [declaredIn, table] of tables) {
282
+ if (typeof table !== "object" || table === null || Array.isArray(table)) continue;
283
+ for (const [name, spec] of Object.entries(table)) {
284
+ for (const constraint of Array.isArray(spec) ? spec : [spec]) {
285
+ if (typeof constraint !== "object" || constraint === null) continue;
286
+ if (typeof constraint.path !== "string") continue;
287
+ entries.push({ name, path: constraint.path, declaredIn });
288
+ }
289
+ }
290
+ }
291
+ return entries;
292
+ }
293
+
294
+ /**
295
+ * The two root-anchored spellings PDM's docs write a local dependency in.
296
+ * `${PROJECT_ROOT}` is not expanded by TOML — it reaches this reader verbatim.
297
+ */
298
+ const PDM_LOCAL_ANCHORS = ["file:///${PROJECT_ROOT}/", "{root:uri}/"];
299
+
300
+ /**
301
+ * PDM's documented local-path requirements across the three dependency
302
+ * arrays: `name @ <anchored-url>` and the editable `-e <anchored-url>` form.
303
+ *
304
+ * A `file:` URL WITHOUT one of the two anchors is returned with
305
+ * `unanchored` set instead of a path — the docs note that backends other
306
+ * than pdm-backend/hatchling "will write the absolute path instead", and an
307
+ * absolute path gives this reader no way to place the target relative to the
308
+ * tree, so the caller reports it rather than guessing. Any other URL (https,
309
+ * git+…) and any plain name requirement is not a local path and yields
310
+ * nothing here.
311
+ *
312
+ * @param {object} manifest A parsed pyproject.toml.
313
+ * @returns {{ name: string|null, path?: string, unanchored?: string, declaredIn: string }[]}
314
+ */
315
+ function pdmLocalDependencies(manifest) {
316
+ const arrays = [
317
+ ["[project] dependencies", manifest.project?.dependencies],
318
+ ...Object.entries(manifest.project?.["optional-dependencies"] ?? {}).map(([extra, list]) => [
319
+ `[project.optional-dependencies] ${extra}`,
320
+ list,
321
+ ]),
322
+ ...Object.entries(manifest["dependency-groups"] ?? {}).map(([group, list]) => [
323
+ `[dependency-groups] ${group}`,
324
+ list,
325
+ ]),
326
+ ];
327
+
328
+ const entries = [];
329
+ for (const [declaredIn, list] of arrays) {
330
+ if (!Array.isArray(list)) continue;
331
+ for (const requirement of list) {
332
+ if (typeof requirement !== "string") continue; // {include-group = …} tables
333
+ const trimmed = requirement.trim();
334
+ let name = null;
335
+ let url;
336
+ if (/^-e\s/.test(trimmed)) {
337
+ url = trimmed.slice(2).trim();
338
+ } else {
339
+ // PEP 508 direct reference: `name[extras] @ URI ; markers`. The name
340
+ // cannot contain `@`, so the first one is the separator; the URI runs
341
+ // to the first whitespace, which is also where a marker would start.
342
+ const at = trimmed.indexOf("@");
343
+ if (at === -1) continue;
344
+ name = parseRequirementName(trimmed.slice(0, at));
345
+ url = trimmed.slice(at + 1).trim();
346
+ }
347
+ url = url.split(/\s/, 1)[0];
348
+ const anchor = PDM_LOCAL_ANCHORS.find((prefix) => url.startsWith(prefix));
349
+ if (anchor) {
350
+ entries.push({ name, path: url.slice(anchor.length), declaredIn });
351
+ } else if (url.startsWith("file:")) {
352
+ entries.push({ name, unanchored: url, declaredIn });
353
+ }
354
+ }
355
+ }
356
+ return entries;
357
+ }
358
+
359
+ /**
360
+ * Static edges between Python projects. Same contract as the other
361
+ * resolvers: `projects` [{ name, root }], `filesOf(name)`, `readFile(path)`.
362
+ *
363
+ * @throws {Error} when a well-formed manifest declares a Poetry/PDM path
364
+ * dependency this resolver cannot attribute to a project — the header's
365
+ * "Where a declared path may land" section is the argument.
366
+ */
367
+ export function resolvePythonDependencies(projects, filesOf, readFile) {
368
+ const projectByPackage = new Map(); // normalized package name -> project name
369
+ const packageProjectByRoot = new Map(); // uv path sources resolve against Python packages only
370
+ const projectByRoot = new Map(); // every Nx project root, for declared path dependencies
371
+ const packages = []; // manifests with a [project].name — the uv flow's scan set
372
+ const manifests = []; // every parseable manifest — the Poetry/PDM flow's scan set
373
+ const normalizedProjects = projects.map((project) => ({
374
+ name: project.name,
375
+ root: normalizePath(project.root, ""),
376
+ }));
377
+ for (const project of normalizedProjects) {
378
+ projectByRoot.set(project.root, project.name);
379
+ }
380
+ for (const project of projects) {
381
+ const manifestPath = normalizePath(project.root, "pyproject.toml");
382
+ if (!filesOf(project.name).includes(manifestPath)) continue;
383
+ const manifest = parseManifest(readFile(manifestPath) ?? "");
384
+ if (manifest === null) continue; // malformed mid-keystroke must not fail the graph — see header
385
+ manifests.push({ project, manifest, manifestPath });
386
+ const packageName = manifest.project?.name;
387
+ if (!packageName) continue; // a uv workspace root without [project] is not a package
388
+ projectByPackage.set(normalizePackageName(packageName), project.name);
389
+ // Keyed normalized: the only lookup (below) normalizes its side too, and
390
+ // `normalizePath` collapses the Nx root spelling `"."` to `""` — keying
391
+ // this map by the raw `project.root` left the root project's key (`"."`)
392
+ // and a `path` source's lookup (`""`) unable to ever meet, so a uv `path`
393
+ // source that named the workspace root drew no edge.
394
+ packageProjectByRoot.set(normalizePath(project.root, ""), project.name);
395
+ packages.push({ project, manifest, manifestPath });
396
+ }
397
+
398
+ const dependencies = [];
399
+ for (const { project, manifest, manifestPath } of packages) {
400
+ const sources = manifest.tool?.uv?.sources ?? {};
401
+ const sourceOf = new Map(
402
+ Object.entries(sources).map(([name, spec]) => [normalizePackageName(name), spec]),
403
+ );
404
+ for (const depName of collectDeclaredDependencies(manifest)) {
405
+ const spec = sourceOf.get(depName);
406
+ if (typeof spec !== "object" || spec === null) continue;
407
+ // uv's documented multiple-sources-by-marker form is an ARRAY of source
408
+ // tables rather than one: `beta = [{ workspace = true, marker = "…" },
409
+ // { path = "…", marker = "…" }]`. Each entry is read the same way a lone
410
+ // table is; the first entry that names a workspace project wins.
411
+ let target = null;
412
+ for (const entry of Array.isArray(spec) ? spec : [spec]) {
413
+ if (typeof entry !== "object" || entry === null) continue;
414
+ if (entry.workspace === true) {
415
+ target = projectByPackage.get(depName) ?? null;
416
+ } else if (typeof entry.path === "string") {
417
+ target = packageProjectByRoot.get(normalizePath(project.root, entry.path)) ?? null;
418
+ }
419
+ if (target) break;
420
+ }
421
+ if (target && target !== project.name) {
422
+ dependencies.push({
423
+ source: project.name,
424
+ target,
425
+ sourceFile: manifestPath,
426
+ type: "static",
427
+ });
428
+ }
429
+ }
430
+ }
431
+
432
+ for (const { project, manifest, manifestPath } of manifests) {
433
+ const declared = [...poetryPathDependencies(manifest), ...pdmLocalDependencies(manifest)];
434
+ for (const entry of declared) {
435
+ const label = entry.name === null ? "an editable entry" : `'${entry.name}'`;
436
+ if (entry.unanchored !== undefined) {
437
+ throw new Error(
438
+ `archkeep: ${manifestPath} declares ${label} in ${entry.declaredIn} with the ` +
439
+ `local URL '${entry.unanchored}', which is not anchored to the manifest's directory. ` +
440
+ `Only the documented root-anchored forms — 'file:///\${PROJECT_ROOT}/…' (pdm-backend) ` +
441
+ `and '{root:uri}/…' (hatchling) — can be placed relative to the workspace, so this ` +
442
+ `resolver cannot tell whether the target is a workspace project, and a dropped entry ` +
443
+ `would be an edge \`nx affected\` silently never sees.`,
444
+ );
445
+ }
446
+ const resolved = resolveWithinWorkspace(project.root, entry.path);
447
+ if (resolved === null) continue; // left the workspace: not a workspace project, so no edge exists
448
+ const target = projectByRoot.get(resolved);
449
+ if (target === project.name) continue; // self-reference wires nothing new
450
+ if (target !== undefined) {
451
+ dependencies.push({
452
+ source: project.name,
453
+ target,
454
+ sourceFile: manifestPath,
455
+ type: "static",
456
+ });
457
+ continue;
458
+ }
459
+ const owner = projectOwning(normalizedProjects, resolved);
460
+ if (owner?.name === project.name) continue; // inside its own tree: a vendored artifact, not a wiring
461
+ const landed =
462
+ owner === null
463
+ ? `'${resolved}' — not in any Nx project's tree`
464
+ : `'${resolved}', inside project '${owner.name}' but not at its root`;
465
+ throw new Error(
466
+ `archkeep: ${manifestPath} declares ${label} in ${entry.declaredIn} as a path ` +
467
+ `dependency on '${entry.path}', which resolves to ${landed}. An explicit workspace ` +
468
+ `path this resolver cannot attribute to a project's root means the graph would be ` +
469
+ `missing an edge \`nx affected\` needs, so none is handed over: point the path at the ` +
470
+ `root of the project that owns that location, split a nested package into its own ` +
471
+ `project, or fix the path.`,
472
+ );
473
+ }
474
+ }
475
+
476
+ // One edge per (source, target): the same sibling declared in several
477
+ // groups, or by uv and Poetry at once, is still one dependency.
478
+ const seen = new Set();
479
+ return dependencies.filter((dependency) => {
480
+ const key = `${dependency.source} ${dependency.target}`;
481
+ if (seen.has(key)) return false;
482
+ seen.add(key);
483
+ return true;
484
+ });
485
+ }
486
+
487
+ /**
488
+ * Is this specifier one of Python's relative forms — the `spelling.relative`
489
+ * bit of the analysis record (`contract.md`)?
490
+ *
491
+ * `from . import x`, `from .mod import y`, `from ..pkg.sub import z`: a leading
492
+ * dot count, resolved against the importing file's own package and unable to
493
+ * name anything outside it (`resolveRelativeModule` reports the climb that
494
+ * tries). These are Python's `./x` and `../x`, and the rule engine's old
495
+ * JavaScript-shaped predicate saw only the two spellings that happen to
496
+ * coincide — a bare `.` and `..` — while `.mod` and `..pkg` read as package
497
+ * names to it.
498
+ *
499
+ * They are not filesystem paths, which is the other half: a dotted module name
500
+ * is resolved on `sys.path`, never by path arithmetic against the source file,
501
+ * so `spelling.path` is always false for Python. That distinction is what stops
502
+ * an unresolvable `from . import x` from being reported as
503
+ * `noRelativeOrAbsoluteExternals` — a message about a path, aimed at a name.
504
+ *
505
+ * @param {string} specifier As written.
506
+ * @returns {boolean}
507
+ */
508
+ const isRelativeImport = (specifier) => specifier.startsWith(".");
509
+
510
+ /** A name Python can spell in an import statement. */
511
+ const isImportableName = (name) => /^[A-Za-z_]\w*$/.test(name);
512
+
513
+ /** The directory `path` sits in, or `""` when it sits at the top. */
514
+ const parentDirectory = (path) => (path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : "");
515
+
516
+ /**
517
+ * `[tool.setuptools]`: `package-dir` names an import root only under the `""`
518
+ * key. Any other key maps ONE package name to a directory, which is a rename —
519
+ * `{"pkg" = "lib/other"}` makes `lib/other` importable as `pkg`, and no base
520
+ * this scan could add would produce that name.
521
+ */
522
+ function setuptoolsDirectories(manifest) {
523
+ const directories = [];
524
+ const unmodelled = [];
525
+ const setuptools = manifest.tool?.setuptools ?? {};
526
+
527
+ const packageDir = setuptools["package-dir"];
528
+ if (packageDir !== undefined) {
529
+ if (typeof packageDir !== "object" || packageDir === null || Array.isArray(packageDir)) {
530
+ unmodelled.push("its `[tool.setuptools] package-dir` is not a table");
531
+ } else {
532
+ for (const [name, directory] of Object.entries(packageDir)) {
533
+ if (name === "" && typeof directory === "string") directories.push(directory);
534
+ else unmodelled.push(`its \`[tool.setuptools] package-dir\` renames the package '${name}'`);
535
+ }
536
+ }
537
+ }
538
+
539
+ // `[tool.setuptools.packages.find] where` — a list of directories to search.
540
+ // `packages` may instead be a plain array of import names, in which case
541
+ // there is no `.find` table and the names live at the default bases.
542
+ const where = setuptools.packages?.find?.where;
543
+ if (where !== undefined) {
544
+ if (Array.isArray(where) && where.every((entry) => typeof entry === "string")) {
545
+ directories.push(...where);
546
+ } else {
547
+ unmodelled.push("its `[tool.setuptools.packages.find] where` is not a list of directories");
548
+ }
549
+ }
550
+ return { directories, unmodelled };
551
+ }
552
+
553
+ /**
554
+ * `[tool.hatch.build]` and its wheel target: `packages = ["python/pkg"]` ships
555
+ * `python/pkg` into the wheel as `pkg`, so the import base is the entry's
556
+ * PARENT. `sources` rewrites those paths arbitrarily and is not followed.
557
+ */
558
+ function hatchDirectories(manifest) {
559
+ const directories = [];
560
+ const unmodelled = [];
561
+ const build = manifest.tool?.hatch?.build ?? {};
562
+ const tables = [
563
+ ["[tool.hatch.build]", build],
564
+ ["[tool.hatch.build.targets.wheel]", build.targets?.wheel ?? {}],
565
+ ];
566
+ for (const [label, table] of tables) {
567
+ if (table.sources !== undefined) {
568
+ unmodelled.push(`its \`${label} sources\` rewrites the path each package is imported under`);
569
+ }
570
+ if (table.packages === undefined) continue;
571
+ if (!Array.isArray(table.packages) || table.packages.some((e) => typeof e !== "string")) {
572
+ unmodelled.push(`its \`${label} packages\` is not a list of paths`);
573
+ continue;
574
+ }
575
+ directories.push(...table.packages.map(parentDirectory));
576
+ }
577
+ return { directories, unmodelled };
578
+ }
579
+
580
+ /**
581
+ * `[tool.poetry] packages`: each entry's `from` is the import base, defaulting
582
+ * to the project root. `to` renames the package inside the wheel and is not
583
+ * followed.
584
+ */
585
+ function poetryDirectories(manifest) {
586
+ const directories = [];
587
+ const unmodelled = [];
588
+ const packages = manifest.tool?.poetry?.packages;
589
+ if (packages === undefined) return { directories, unmodelled };
590
+ if (!Array.isArray(packages)) {
591
+ unmodelled.push("its `[tool.poetry] packages` is not a list");
592
+ return { directories, unmodelled };
593
+ }
594
+ for (const entry of packages) {
595
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
596
+ unmodelled.push("its `[tool.poetry] packages` holds an entry that is not a table");
597
+ } else if (entry.to !== undefined) {
598
+ unmodelled.push("its `[tool.poetry] packages` renames a package with `to`");
599
+ } else {
600
+ directories.push(typeof entry.from === "string" ? entry.from : "");
601
+ }
602
+ }
603
+ return { directories, unmodelled };
604
+ }
605
+
606
+ /**
607
+ * The `[build-system] build-backend` values whose package-location keys the
608
+ * three readers above cover. A backend outside this list declares its packages
609
+ * in keys nothing here reads — maturin's `[tool.maturin] python-source` and
610
+ * scikit-build's `[tool.scikit-build] wheel.packages` are two — so its layout
611
+ * is unknown rather than default. An ABSENT `build-backend` is setuptools by
612
+ * PEP 517's fallback, which is read.
613
+ */
614
+ const READ_BUILD_BACKENDS = new Set([
615
+ "setuptools.build_meta",
616
+ "setuptools.build_meta:__legacy__",
617
+ "hatchling.build",
618
+ "poetry.core.masonry.api",
619
+ "poetry.masonry.api",
620
+ ]);
621
+
622
+ /**
623
+ * Where a project's `pyproject.toml` says its packages live, relative to the
624
+ * project root — and everything it declares that this reader cannot follow.
625
+ *
626
+ * @param {string|null} manifestText Contents, or null when the project has no
627
+ * `pyproject.toml` at all — then it declares nothing and the filesystem scan
628
+ * is the whole answer, which is not a gap.
629
+ * @returns {{ directories: string[], unmodelled: string[] }}
630
+ */
631
+ export function pythonPackageLayout(manifestText) {
632
+ if (manifestText === null) return { directories: [], unmodelled: [] };
633
+ const manifest = parseManifest(manifestText);
634
+ if (manifest === null) {
635
+ return {
636
+ directories: [],
637
+ unmodelled: ["its `pyproject.toml` is not valid TOML, so nothing it declares can be read"],
638
+ };
639
+ }
640
+
641
+ const directories = [];
642
+ const unmodelled = [];
643
+ // Read by table presence rather than by the declared backend: a manifest
644
+ // carries at most one of these, and a `[tool.hatch…]` table means hatch
645
+ // whether or not `[build-system]` bothered to say so.
646
+ for (const read of [setuptoolsDirectories, hatchDirectories, poetryDirectories]) {
647
+ const found = read(manifest);
648
+ directories.push(...found.directories);
649
+ unmodelled.push(...found.unmodelled);
650
+ }
651
+
652
+ const backend = manifest["build-system"]?.["build-backend"];
653
+ if (backend !== undefined && !READ_BUILD_BACKENDS.has(backend)) {
654
+ unmodelled.push(
655
+ `it builds with '${backend}', whose package-location keys this reader does not read`,
656
+ );
657
+ }
658
+ return { directories, unmodelled };
659
+ }
660
+
661
+ /**
662
+ * The directories a project puts on `sys.path`: whatever its manifest declared,
663
+ * then the two the filesystem answers with.
664
+ *
665
+ * The project root is always LAST. A declared subdirectory that also matched
666
+ * the root first would index `lib/pkg/mod.py` as `lib.pkg.mod` — a dotted name
667
+ * nothing imports — and the package would stay invisible for a second reason.
668
+ */
669
+ const importBasesOf = (projectRoot, directories = []) => {
670
+ const root = normalizePath(projectRoot, "");
671
+ const declared = directories
672
+ .map((directory) => normalizePath(projectRoot, directory))
673
+ .filter((base) => base !== root);
674
+ return [...new Set([...declared, normalizePath(projectRoot, "src"), root])];
675
+ };
676
+
677
+ /** `file` relative to `base`, or null when it is not under it. */
678
+ function relativeTo(base, file) {
679
+ if (base === "") return file;
680
+ return file.startsWith(`${base}/`) ? file.slice(base.length + 1) : null;
681
+ }
682
+
683
+ /** A `.py` file's path components below its import base, or null. */
684
+ function componentsOf(file, projectRoot, directories) {
685
+ for (const base of importBasesOf(projectRoot, directories)) {
686
+ const relative = relativeTo(base, file);
687
+ if (relative === null) continue;
688
+ return relative.slice(0, -".py".length).split("/");
689
+ }
690
+ return null;
691
+ }
692
+
693
+ /** The dotted path a `.py` file is importable as, relative to its base. */
694
+ function dottedNameOf(file, projectRoot, directories) {
695
+ const parts = componentsOf(file, projectRoot, directories);
696
+ if (parts === null) return null;
697
+ if (parts[parts.length - 1] === "__init__") parts.pop();
698
+ return parts.every(isImportableName) ? parts : null;
699
+ }
700
+
701
+ /**
702
+ * The package a `.py` file lives IN, which is what a relative import is
703
+ * resolved against. This is not the file's own dotted name: `pkg/__init__.py`
704
+ * IS the package `pkg`, so `from . import x` inside it means `pkg`, while
705
+ * `pkg/mod.py` sits in `pkg` and means the same thing from a different name.
706
+ * Dropping the last path component answers both, which dropping `__init__`
707
+ * first would not.
708
+ */
709
+ function ownPackageOf(file, projectRoot, directories) {
710
+ const parts = componentsOf(file, projectRoot, directories);
711
+ if (parts === null) return null;
712
+ parts.pop();
713
+ return parts.every(isImportableName) ? parts : null;
714
+ }
715
+
716
+ /**
717
+ * Every dotted name a project's tracked `.py` files make importable.
718
+ *
719
+ * Regular packages and modules map to the file that defines them. Every
720
+ * directory prefix along the way is also recorded, with a null file, because
721
+ * PEP 420 makes a directory importable whether or not it carries an
722
+ * `__init__.py` — a prefix that DOES have one overwrites the null entry, so
723
+ * the result does not depend on the order files arrive in.
724
+ *
725
+ * @param {string} projectRoot Workspace-relative.
726
+ * @param {string[]} files The project's tracked files, workspace-relative.
727
+ * @param {string[]} [directories] Extra import bases the manifest declared,
728
+ * relative to the project root; `pythonPackageLayout` reads them.
729
+ * @returns {Map<string, { file: string|null, namespace: boolean }>}
730
+ */
731
+ export function pythonModuleIndex(projectRoot, files, directories = []) {
732
+ const index = new Map();
733
+ for (const file of files) {
734
+ if (!file.endsWith(".py")) continue;
735
+ const parts = dottedNameOf(file, projectRoot, directories);
736
+ if (parts === null || parts.length === 0) continue;
737
+ index.set(parts.join("."), { file, namespace: false });
738
+ for (let depth = 1; depth < parts.length; depth++) {
739
+ const prefix = parts.slice(0, depth).join(".");
740
+ if (!index.has(prefix)) index.set(prefix, { file: null, namespace: true });
741
+ }
742
+ }
743
+ return index;
744
+ }
745
+
746
+ /**
747
+ * A project's top-level import names — the `src/<pkg>` · `<pkg>` · `<mod>.py`
748
+ * model stated in the header, derived from the same scan rather than from a
749
+ * second one, so the two can never disagree.
750
+ *
751
+ * @param {string} projectRoot
752
+ * @param {string[]} files
753
+ * @param {string[]} [directories] As `pythonModuleIndex`.
754
+ * @returns {string[]}
755
+ */
756
+ export function pythonImportRoots(projectRoot, files, directories = []) {
757
+ return [...pythonModuleIndex(projectRoot, files, directories).keys()]
758
+ .filter((name) => !name.includes("."))
759
+ .sort();
760
+ }
761
+
762
+ /**
763
+ * Every Python project's module index, the global dotted-name map, and the
764
+ * projects whose declared layout this reader could not follow.
765
+ *
766
+ * `unmodelled` is workspace-scoped on purpose. A package this reader cannot
767
+ * locate could carry ANY top-level import name, so it is not the importing
768
+ * project that is compromised but every name that resolves to nothing —
769
+ * whichever file wrote it.
770
+ *
771
+ * @returns {{ byModule: Map<string, { project: string, file: string|null }[]>,
772
+ * directoriesOf: Map<string, string[]>,
773
+ * unmodelled: { project: string, root: string, reason: string }[] }}
774
+ */
775
+ const pythonModulesOf = perWorkspace((workspace) => {
776
+ const byModule = new Map(); // dotted name -> [{ project, file }]
777
+ const directoriesOf = new Map(); // project name -> declared import bases
778
+ const unmodelled = [];
779
+ for (const project of workspace.projects) {
780
+ const files = workspace.filesOf(project.name);
781
+ if (!files.some((file) => file.endsWith(".py"))) continue;
782
+ const manifestPath = normalizePath(project.root, "pyproject.toml");
783
+ const layout = pythonPackageLayout(
784
+ files.includes(manifestPath) ? (workspace.readFile(manifestPath) ?? null) : null,
785
+ );
786
+ directoriesOf.set(project.name, layout.directories);
787
+ for (const reason of layout.unmodelled) {
788
+ unmodelled.push({ project: project.name, root: project.root, reason });
789
+ }
790
+ for (const [dotted, entry] of pythonModuleIndex(project.root, files, layout.directories)) {
791
+ const owners = byModule.get(dotted) ?? [];
792
+ owners.push({ project: project.name, file: entry.file });
793
+ byModule.set(dotted, owners);
794
+ }
795
+ }
796
+ return { byModule, directoriesOf, unmodelled };
797
+ });
798
+
799
+ /**
800
+ * Every Python project whose declared package layout this reader could not
801
+ * follow, as whole-file failures attributed to its `pyproject.toml` —
802
+ * workspace-scoped on purpose, and surfaced separately from per-import
803
+ * resolution: a malformed manifest is a hole in every run, not only in the
804
+ * files that happen to hit a name that reaches no project. The CLI funnels
805
+ * these alongside the analyzers' own failures, so `check` reports the run
806
+ * incomplete (exit 3) rather than clean while a project's manifest says
807
+ * nothing this tool can read.
808
+ *
809
+ * @param {object} workspace
810
+ * @returns {object[]} `fileFailure` shapes (`../analysis/source-util.mjs`).
811
+ */
812
+ export const pythonUnmodelledFailures = (workspace) =>
813
+ pythonModulesOf(workspace).unmodelled.map(({ root, reason }) =>
814
+ fileFailure(
815
+ normalizePath(root, "pyproject.toml"),
816
+ `its pyproject.toml cannot be fully read: ${reason}`,
817
+ ),
818
+ );
819
+
820
+ /**
821
+ * The project a dotted module name reaches, by longest matching prefix.
822
+ *
823
+ * @returns {{ owner: { project: string, file: string|null }, ambiguous?: undefined,
824
+ * prefix?: undefined }|{ ambiguous: string[], prefix: string, owner?: undefined }|null}
825
+ * `null` when no project claims any prefix — the module is external.
826
+ */
827
+ function resolveModuleName(dotted, byModule) {
828
+ const parts = dotted.split(".");
829
+ for (let depth = parts.length; depth >= 1; depth--) {
830
+ const prefix = parts.slice(0, depth).join(".");
831
+ const owners = byModule.get(prefix);
832
+ if (!owners) continue;
833
+ const projects = [...new Set(owners.map((owner) => owner.project))];
834
+ if (projects.length > 1) return { ambiguous: projects, prefix };
835
+ return { owner: owners[0] };
836
+ }
837
+ return null;
838
+ }
839
+
840
+ /**
841
+ * The absolute module a relative specifier names, or `null` when it climbs
842
+ * past the top-level package — which Python rejects too, and which is how an
843
+ * import escapes the project it was written in.
844
+ *
845
+ * @param {string} specifier As written: `.`, `..`, `.mod`, `..pkg.sub`.
846
+ * @param {string[]} ownPackage The importing file's own package, dotted-split.
847
+ */
848
+ function resolveRelativeModule(specifier, ownPackage) {
849
+ const dots = /^\.+/.exec(specifier)[0].length;
850
+ const climb = dots - 1;
851
+ if (ownPackage.length - climb <= 0) return null;
852
+ const rest = specifier.slice(dots);
853
+ const parts = [
854
+ ...ownPackage.slice(0, ownPackage.length - climb),
855
+ ...(rest === "" ? [] : rest.split(".")),
856
+ ];
857
+ return parts.join(".");
858
+ }
859
+
860
+ const IMPORT_STATEMENT = /^[ \t]*import[ \t]+/;
861
+ // The space before `import` is mandatory after a module NAME — Python's
862
+ // tokenizer reads `.modimport` as one identifier and then has no `import`
863
+ // keyword left, a syntax error (verified: `python3 -c 'import ast;
864
+ // ast.parse("from .modimport x")'`) — but a bare run of dots needs none:
865
+ // `from .import x` and `from ..import y` are both valid (level=1/level=2,
866
+ // module=None), because a `.` is never part of an identifier and so already
867
+ // ends the token the same way whitespace would. The `(?<=\.)` alternative
868
+ // captures exactly that one case; a module-name arm can still satisfy it only
869
+ // by ending right after a dot, which is the same rule stated the other way.
870
+ const FROM_STATEMENT =
871
+ /^([ \t]*from[ \t]+)(\.+[A-Za-z_][\w.]*|\.+|[A-Za-z_][\w.]*)(?:[ \t]+|(?<=\.))import\b/;
872
+ const DOTTED_NAME = /^[ \t]*([A-Za-z_][\w.]*)/;
873
+ /**
874
+ * A line whose last non-blank byte is the joining `\`.
875
+ *
876
+ * Python itself rejects bytes between the backslash and the newline, so for
877
+ * an LF file this matches exactly what a bare `endsWith("\\")` does. It is a
878
+ * character class rather than that equality because of the CRLF case: a
879
+ * split line carries its `\r`, and read verbatim it hid every joining
880
+ * backslash from this test, so a continued statement parsed as if it were
881
+ * one line long and every name after the break disappeared — reported by
882
+ * nobody, visible to no rule. Reading `\` plus trailing blanks as a
883
+ * continuation is the same spurious-direction trade the parser makes
884
+ * elsewhere (the file would not run as written), never a missed import.
885
+ */
886
+ const CONTINUATION_TAIL = /\\[ \t]*$/;
887
+
888
+ /**
889
+ * `text` with a parenthesised name group's parentheses blanked to spaces —
890
+ * `import (a, b)` → `import a, b `, and `from x import (a, b)` →
891
+ * `from x import a, b ` — Black and isort's normalised multi-import
892
+ * spelling. The parens are blanked rather than removed so the result is the
893
+ * same length as the source and a position in it is the same position in the
894
+ * file (the record is read as `file:line:column`). The interior is left where
895
+ * it is: the existing comma-name loop reads each name exactly as it does in
896
+ * the single-line form, and a non-name interior (a call, a slice) reads its
897
+ * first identifier or fails the caller's prefilter — never a silent empty
898
+ * from a statement that plainly says `import`.
899
+ *
900
+ * @param {string} text One statement (a `;`-split piece, possibly the joined
901
+ * continuation of several physical lines).
902
+ * @returns {string}
903
+ */
904
+ function blankParenGroup(text) {
905
+ const open = text.indexOf("(");
906
+ if (open === -1) return text;
907
+ const close = text.lastIndexOf(")");
908
+ if (close <= open) return text;
909
+ // One space in, one space out for each paren, so both lengths and offsets
910
+ // survive the call.
911
+ return `${text.slice(0, open)} ${text.slice(open + 1, close)} ${text.slice(close + 1)}`;
912
+ }
913
+
914
+ /** A `(` in `text` that no `)` closes — the marker of a paren group in
915
+ * progress. String contents are skipped, so a `(` inside a string never opens
916
+ * one, and a `#` comment is inert, so a paren written in prose after `#` —
917
+ * `from x import (a, b) # (see` — never looks like a group still open. A
918
+ * trailing `(` in a comment joining the next physical line is how a real
919
+ * `import` on that line silently vanished.
920
+ *
921
+ * `text` may be several physical lines already concatenated with no
922
+ * separator (`joinContinuedStatement` appends each one directly, so no `\n`
923
+ * marks where one ends and the next begins) — `lineStarts` names those
924
+ * boundaries, the offset each later physical line begins at. Without them a
925
+ * `#` on an EARLIER line reads as running to the end of the whole joined
926
+ * blob rather than just that line, which is what let a comment before an
927
+ * interior line's own content swallow every `)` still to come and every
928
+ * import after it. A comment always ends at the next such boundary — or, on
929
+ * the last physical line seen so far, at the end of `text` itself, the same
930
+ * as before.
931
+ *
932
+ * @param {string} text
933
+ * @param {number[]} [lineStarts] Ascending offsets into `text` where a new
934
+ * physical line begins; empty when `text` is a single line.
935
+ */
936
+ function hasUnclosedParen(text, lineStarts = []) {
937
+ let depth = 0;
938
+ let quote = null;
939
+ for (let i = 0; i < text.length; i++) {
940
+ const ch = text[i];
941
+ if (quote !== null) {
942
+ if (ch === quote && text[i - 1] !== "\\") quote = null;
943
+ continue;
944
+ }
945
+ if (ch === '"' || ch === "'") {
946
+ quote = ch;
947
+ continue;
948
+ }
949
+ if (ch === "#") {
950
+ const next = lineStarts.find((start) => start > i);
951
+ if (next === undefined) break; // last physical line: comment runs to the end
952
+ i = next - 1; // the loop's own `i++` lands exactly on the next line's start
953
+ continue;
954
+ }
955
+ if (ch === "(") depth++;
956
+ else if (ch === ")") depth = Math.max(0, depth - 1);
957
+ }
958
+ return depth > 0;
959
+ }
960
+ /** A line, or a `;`-separated piece of one, worth trying the statement forms on. */
961
+ const FROM_OR_IMPORT_HEAD = /^[ \t]*(?:from|import)\b/;
962
+ /** `importlib.import_module(`, a bare `import_module(`, and `__import__(`. */
963
+ const DYNAMIC_CALL = /\b(?:importlib\s*\.\s*)?import_module\s*\(\s*|\b__import__\s*\(\s*/g;
964
+ const STRING_LITERAL = /^(['"])([^'"\\]*)\1/;
965
+
966
+ /**
967
+ * Python's own statement separator: `line` split at each `;`, with each
968
+ * piece's start offset within `line`.
969
+ *
970
+ * The split is not string-aware — a `;` inside a string literal splits too —
971
+ * but the only cost is a spurious piece that fails the `from`/`import`
972
+ * prefilter right after, the same "worst case is a spurious record, never a
973
+ * missed one" trade the header already makes for the triple-quoted-string
974
+ * limit below.
975
+ *
976
+ * @param {string} line
977
+ * @returns {{ text: string, start: number }[]}
978
+ */
979
+ function splitStatements(line) {
980
+ const pieces = [];
981
+ let start = 0;
982
+ for (const text of line.split(";")) {
983
+ pieces.push({ text, start });
984
+ start += text.length + 1;
985
+ }
986
+ return pieces;
987
+ }
988
+
989
+ /**
990
+ * Python's explicit line-joining, starting at physical line `index`: a line
991
+ * whose last non-blank byte is `\` (`CONTINUATION_TAIL`) continues onto the
992
+ * next one with the backslash and the newline both gone, exactly as Python's
993
+ * own tokenizer joins them, and a `from`/`import` line whose paren group
994
+ * stays open continues too — the `import (a,` + `b,)` spelling Black and
995
+ * isort normalise to. Called only for a line that already opens with
996
+ * `from`/`import` — see the caller — so this never reaches into a line that
997
+ * has nothing to do with the statement, a comment ending in `\` while noting
998
+ * a Windows path, say.
999
+ *
1000
+ * Each continuation swaps the matched tail (the joining `\`, plus whatever
1001
+ * blanked bytes follow it) for blanks of the same length and then appends
1002
+ * the next physical line whole, so the joined
1003
+ * text's length up to any given physical line's contribution always equals
1004
+ * the sum of the real lines before it. That is what lets `toOffset` translate
1005
+ * a position in the joined text back into the ORIGINAL source by arithmetic
1006
+ * alone, the same length-preserving trick Go's comment mask uses for the same
1007
+ * reason: the record is read as `file:line:column`, and a position computed
1008
+ * from a shortened copy would name a byte the file does not have.
1009
+ *
1010
+ * @param {string[]} physicalLines
1011
+ * @param {number[]} lineOffsets Absolute start offset of each physical line.
1012
+ * @param {number} index
1013
+ * @returns {{ text: string, end: number, toOffset: (i: number) => number }}
1014
+ */
1015
+ function joinContinuedStatement(physicalLines, lineOffsets, index) {
1016
+ const segments = [{ start: 0, offset: lineOffsets[index] }];
1017
+ let text = physicalLines[index];
1018
+ let end = index;
1019
+ while (
1020
+ end + 1 < physicalLines.length &&
1021
+ // `segments[0].start` is always 0 (the start of `text` itself, not a
1022
+ // later line's boundary) — only the ones appended since are boundaries a
1023
+ // `#` comment must stop at.
1024
+ (CONTINUATION_TAIL.test(text) ||
1025
+ hasUnclosedParen(
1026
+ text,
1027
+ segments.slice(1).map((s) => s.start),
1028
+ ))
1029
+ ) {
1030
+ end++;
1031
+ if (CONTINUATION_TAIL.test(text)) {
1032
+ // One space in for every byte out: the backslash and the blanked bytes
1033
+ // after it become blanks, so the joined text's length up to this point
1034
+ // still equals the sum of the real lines before it and `toOffset`
1035
+ // stays pure arithmetic.
1036
+ text = text.replace(CONTINUATION_TAIL, (tail) => " ".repeat(tail.length));
1037
+ }
1038
+ segments.push({ start: text.length, offset: lineOffsets[end] });
1039
+ text += physicalLines[end];
1040
+ }
1041
+ const toOffset = (i) => {
1042
+ let segment = segments[0];
1043
+ for (const candidate of segments) {
1044
+ if (candidate.start > i) break;
1045
+ segment = candidate;
1046
+ }
1047
+ return segment.offset + (i - segment.start);
1048
+ };
1049
+ return { text, end, toOffset };
1050
+ }
1051
+
1052
+ /**
1053
+ * Every import site in a `.py` source, in source order and without
1054
+ * deduplication — one entry per written import.
1055
+ *
1056
+ * @param {string} pythonText
1057
+ * @returns {{ specifier: string, kind: string, offset: number, literal: boolean,
1058
+ * continuation?: boolean }[]}
1059
+ */
1060
+ export function parsePythonImportSites(pythonText) {
1061
+ const sites = [];
1062
+ // Byte tolerance (`contract.md`): the lines a CRLF file splits into still
1063
+ // carry their `\r`, and a BOM-prefixed file's first line starts with
1064
+ // `\uFEFF`. The `\r` hid every joining backslash from an end-of-line check;
1065
+ // the BOM failed every `^[ \t]*` statement anchor. Both are blanked to a
1066
+ // space, one character for one, never stripped: this parser's offsets stay
1067
+ // offsets into the file as it sits on disk, which is what lets
1068
+ // `positionAt` report columns a reader would count.
1069
+ const physicalLines = pythonText.split("\n").map((line, index) => {
1070
+ const withoutCarriageReturn = line.endsWith("\r") ? `${line.slice(0, -1)} ` : line;
1071
+ return index === 0 ? withoutCarriageReturn.replace(/^\uFEFF/, " ") : withoutCarriageReturn;
1072
+ });
1073
+ const lineOffsets = [];
1074
+ for (let offset = 0, i = 0; i < physicalLines.length; i++) {
1075
+ lineOffsets.push(offset);
1076
+ offset += physicalLines[i].length + 1;
1077
+ }
1078
+
1079
+ let index = 0;
1080
+ while (index < physicalLines.length) {
1081
+ const line = physicalLines[index];
1082
+ const continues =
1083
+ FROM_OR_IMPORT_HEAD.test(line) && (CONTINUATION_TAIL.test(line) || hasUnclosedParen(line));
1084
+ const { text, end, toOffset } = continues
1085
+ ? joinContinuedStatement(physicalLines, lineOffsets, index)
1086
+ : { text: line, end: index, toOffset: (i) => lineOffsets[index] + i };
1087
+
1088
+ let matchedAny = false;
1089
+ for (const piece of splitStatements(text)) {
1090
+ // `import (a, b)` / `from x import (a, b)` — blank the parens before
1091
+ // the statement forms are tried, so the group reads exactly like the
1092
+ // single-line comma list. Blanking rather than deleting keeps every
1093
+ // position inside the original text (`piece.start` maps back through
1094
+ // `toOffset`, and a shorter copy would name a byte the file has not).
1095
+ const pieceText = blankParenGroup(piece.text);
1096
+ if (!FROM_OR_IMPORT_HEAD.test(pieceText)) continue;
1097
+ const from = FROM_STATEMENT.exec(pieceText);
1098
+ if (from) {
1099
+ matchedAny = true;
1100
+ sites.push({
1101
+ specifier: from[2],
1102
+ kind: "static",
1103
+ offset: toOffset(piece.start + from[1].length),
1104
+ literal: true,
1105
+ });
1106
+ continue;
1107
+ }
1108
+ const statement = IMPORT_STATEMENT.exec(pieceText);
1109
+ if (!statement) continue;
1110
+ matchedAny = true;
1111
+ // `import a.b as c, x.y` is several written imports on one line, and
1112
+ // each gets its own record with its own column.
1113
+ let cursor = statement[0].length;
1114
+ for (const namePiece of pieceText.slice(cursor).split(",")) {
1115
+ const name = DOTTED_NAME.exec(namePiece);
1116
+ if (name) {
1117
+ sites.push({
1118
+ specifier: name[1],
1119
+ kind: "static",
1120
+ offset: toOffset(piece.start + cursor + name[0].length - name[1].length),
1121
+ literal: true,
1122
+ });
1123
+ }
1124
+ cursor += namePiece.length + 1;
1125
+ }
1126
+ }
1127
+ if (end > index && !matchedAny) {
1128
+ // Pulled one or more continuation lines to complete what looked like a
1129
+ // `from`/`import` statement, and the joined result still does not parse
1130
+ // as one — reported rather than silently dropped, the same choice a
1131
+ // brace-group `use` gets in the Rust analyzer below.
1132
+ sites.push({
1133
+ specifier: text.trim(),
1134
+ kind: "static",
1135
+ offset: toOffset(0),
1136
+ literal: false,
1137
+ continuation: true,
1138
+ });
1139
+ }
1140
+ index = end + 1;
1141
+ }
1142
+
1143
+ for (const call of pythonText.matchAll(DYNAMIC_CALL)) {
1144
+ const offset = call.index + call[0].length;
1145
+ const rest = pythonText.slice(offset);
1146
+ const literal = STRING_LITERAL.exec(rest);
1147
+ sites.push(
1148
+ literal
1149
+ ? { specifier: literal[2], kind: "dynamic", offset, literal: true }
1150
+ : { specifier: /^[^),\n]*/.exec(rest)[0].trim(), kind: "dynamic", offset, literal: false },
1151
+ );
1152
+ }
1153
+
1154
+ return sites.sort((a, b) => a.offset - b.offset);
1155
+ }
1156
+
1157
+ /**
1158
+ * Analyzes one `.py` file.
1159
+ *
1160
+ * @param {{ sourceFile: string, text: string, workspace: object }} request
1161
+ * @returns {{ imports: object[], failures: object[] }}
1162
+ */
1163
+ export function analyzePython({ sourceFile, text, workspace }) {
1164
+ const result = emptyResult();
1165
+ try {
1166
+ const { byModule, directoriesOf, unmodelled } = pythonModulesOf(workspace);
1167
+ const owner = projectOwning(workspace.projects, sourceFile);
1168
+ const ownPackage = owner
1169
+ ? ownPackageOf(sourceFile, owner.root, directoriesOf.get(owner.name) ?? [])
1170
+ : null;
1171
+
1172
+ for (const site of parsePythonImportSites(text)) {
1173
+ const { line, column } = positionAt(text, site.offset);
1174
+ const record = {
1175
+ sourceFile,
1176
+ line,
1177
+ column,
1178
+ specifier: site.specifier,
1179
+ kind: site.kind,
1180
+ spelling: { path: false, relative: isRelativeImport(site.specifier) },
1181
+ resolved: null,
1182
+ };
1183
+ result.imports.push(record);
1184
+ const fail = (reason) => result.failures.push({ sourceFile, line, column, reason });
1185
+
1186
+ if (site.continuation) {
1187
+ fail(
1188
+ `'${site.specifier}' looks like a \`from\`/\`import\` statement continued across a ` +
1189
+ `backslash-joined line, but does not parse as one once its continuation lines are ` +
1190
+ `joined — this reader cannot say what it imports`,
1191
+ );
1192
+ continue;
1193
+ }
1194
+ if (!site.literal) {
1195
+ fail(
1196
+ `dynamic import of '${site.specifier}' has a non-literal argument, ` +
1197
+ `so its target is not knowable statically`,
1198
+ );
1199
+ continue;
1200
+ }
1201
+
1202
+ let absolute = site.specifier;
1203
+ if (site.specifier.startsWith(".")) {
1204
+ if (ownPackage === null) {
1205
+ fail(
1206
+ `relative import '${site.specifier}' cannot be resolved: '${sourceFile}' is not on any import root`,
1207
+ );
1208
+ continue;
1209
+ }
1210
+ absolute = resolveRelativeModule(site.specifier, ownPackage);
1211
+ if (absolute === null) {
1212
+ fail(
1213
+ `relative import '${site.specifier}' climbs past the top-level package of '${sourceFile}', ` +
1214
+ `which leaves the project's import root — Python rejects it the same way`,
1215
+ );
1216
+ continue;
1217
+ }
1218
+ }
1219
+
1220
+ const resolution = resolveModuleName(absolute, byModule);
1221
+ if (resolution === null) {
1222
+ // Reaching no project is only evidence of a PyPI package when every
1223
+ // project's packages are where this reader can see them. Otherwise the
1224
+ // honest answer is that the name is unplaceable — asserting `external`
1225
+ // here is what let a first-party import cross a tag boundary silently.
1226
+ if (unmodelled.length > 0) {
1227
+ fail(
1228
+ `'${site.specifier}' reaches no project, and this reader cannot conclude it is ` +
1229
+ `external — ${unmodelled
1230
+ .map(
1231
+ (entry) =>
1232
+ `project '${entry.project}' may place its packages where this reader ` +
1233
+ `cannot look: ${entry.reason}`,
1234
+ )
1235
+ .join("; ")}. A first-party package put there would look exactly like this.`,
1236
+ );
1237
+ continue;
1238
+ }
1239
+ record.resolved = {
1240
+ target: null,
1241
+ file: null,
1242
+ external: true,
1243
+ packageName: absolute.split(".")[0],
1244
+ };
1245
+ } else if (resolution.ambiguous) {
1246
+ fail(
1247
+ `'${site.specifier}' resolves through the namespace package '${resolution.prefix}', which ` +
1248
+ `${resolution.ambiguous.join(" and ")} both contribute to — Python picks by sys.path order, ` +
1249
+ `which no static reader can know`,
1250
+ );
1251
+ } else {
1252
+ record.resolved = {
1253
+ target: resolution.owner.project,
1254
+ file: resolution.owner.file,
1255
+ external: false,
1256
+ packageName: null,
1257
+ };
1258
+ }
1259
+ }
1260
+ } catch (cause) {
1261
+ result.failures.push(
1262
+ fileFailure(sourceFile, `Python analysis failed: ${cause?.message ?? cause}`),
1263
+ );
1264
+ }
1265
+ return result;
1266
+ }