@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,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
|
+
}
|