@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,414 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Go resolver — static analysis only, no `go` binary required (the same
|
|
3
|
+
* property gonx gets from tree-sitter, achieved here with a comment mask and
|
|
4
|
+
* two regexes over a format that `gofmt` keeps canonical for the whole
|
|
5
|
+
* ecosystem).
|
|
6
|
+
*
|
|
7
|
+
* Model: one Go module per Nx project (`<projectRoot>/go.mod`); the module
|
|
8
|
+
* path is the project's identity. An import of another project's module path
|
|
9
|
+
* (exact or `<modulePath>/...`) is a static edge.
|
|
10
|
+
*
|
|
11
|
+
* **Comments are blanked before either regex runs** (`maskGoComments`). A
|
|
12
|
+
* regex that cannot see a comment misreads ordinary, `gofmt`-clean Go in both
|
|
13
|
+
* directions, and only one of those directions is survivable. A `)` written
|
|
14
|
+
* inside a comment — `// TODO(alice): drop this once the port lands`, or the
|
|
15
|
+
* note a blank import is expected to carry, `_ "github.com/lib/pq" // register
|
|
16
|
+
* the driver (postgres)` — closes the `import (…)` block early, and every
|
|
17
|
+
* import below it disappears: no graph edge, so `nx affected` stops rebuilding
|
|
18
|
+
* dependents, and no import record, so the boundary check calls the file clean
|
|
19
|
+
* while a violation sits in it. A checker that reports "clean" about a file it
|
|
20
|
+
* failed to read is worse than no checker. The opposite direction — an import
|
|
21
|
+
* written inside a block comment counted as one that is written — costs a
|
|
22
|
+
* spurious edge, and the same mask removes it.
|
|
23
|
+
*
|
|
24
|
+
* Known parse limit, deliberate and pinned by tests, and it errs toward a
|
|
25
|
+
* record naming text the file really contains, never toward a missed import:
|
|
26
|
+
*
|
|
27
|
+
* - A **raw string literal** holding what looks like an import declaration at
|
|
28
|
+
* the start of one of its lines is read as that declaration. `gofmt` leaves
|
|
29
|
+
* such a file alone, so this is the one limit a formatted tree still meets.
|
|
30
|
+
* The mask keeps raw strings intact on purpose: an import path is itself a
|
|
31
|
+
* string literal, and a mask that ate string literals would have nothing
|
|
32
|
+
* left to read.
|
|
33
|
+
*
|
|
34
|
+
* `import` opens its line, after indentation only, OR follows a `;` on the
|
|
35
|
+
* same line — the same statement separator `gofmt` inserts automatically at
|
|
36
|
+
* a newline, so an explicit one reopens an import exactly the way a fresh
|
|
37
|
+
* line does: `import "a"; import "b"`, inside a block, `import ("a"; "b")`,
|
|
38
|
+
* and a block whose opener follows one, `package main; import ("a")`, all
|
|
39
|
+
* read every path they hold, not one followed by inert text. The path
|
|
40
|
+
* itself may be a quoted string or, since Go treats a raw string as an
|
|
41
|
+
* equally legal string literal, backtick-delimited — both spellings are read
|
|
42
|
+
* the same way, though only the quoted form is what `gofmt` ever writes.
|
|
43
|
+
*
|
|
44
|
+
* Two layers read those regexes. `resolveGoDependencies` reduces them to Nx
|
|
45
|
+
* graph edges; `analyzeGo` returns the fuller import-site record
|
|
46
|
+
* `analysis/contract.md` fixes — same parse, same limits, more of the answer
|
|
47
|
+
* kept. `parseGoImportSites` is the single parse both go through, so the two
|
|
48
|
+
* layers can never disagree about what a file imports.
|
|
49
|
+
*
|
|
50
|
+
* What the richer record adds, and what it deliberately leaves null:
|
|
51
|
+
*
|
|
52
|
+
* - `file` is always `null`. A Go import names a **package directory**, not a
|
|
53
|
+
* file, and which files that directory contributes is a build-constraint
|
|
54
|
+
* question needing the toolchain this resolver exists to avoid. That is the
|
|
55
|
+
* "resolution stops at a package rather than a file" case the contract
|
|
56
|
+
* allows, not a gap.
|
|
57
|
+
* - `packageName` for an external import is the whole import path. Where a
|
|
58
|
+
* module path ends and a package path begins inside `example.com/a/b/c` is
|
|
59
|
+
* not statically knowable — only the module proxy knows — so the full path
|
|
60
|
+
* stands in, and a `bannedExternalImports` glob matches it the same way it
|
|
61
|
+
* would match a module prefix.
|
|
62
|
+
* - `kind` is always `static`. Go has no dynamic import, no type-only import,
|
|
63
|
+
* and no re-export form; a blank (`_`) or dot (`.`) import is still an
|
|
64
|
+
* ordinary compile-time dependency.
|
|
65
|
+
* - `spelling.path` is always `false`; `spelling.relative` is true exactly when
|
|
66
|
+
* the import resolved to the source file's own project. Go has no relative
|
|
67
|
+
* import form, so the second bit reads what an import REACHED rather than
|
|
68
|
+
* how it was written — `isOwnProjectImport` states why that is the honest
|
|
69
|
+
* answer for a language whose package graph cannot cycle.
|
|
70
|
+
*/
|
|
71
|
+
import { normalizePath } from "./manifest-util.mjs";
|
|
72
|
+
import {
|
|
73
|
+
emptyResult,
|
|
74
|
+
fileFailure,
|
|
75
|
+
perWorkspace,
|
|
76
|
+
positionAt,
|
|
77
|
+
projectOwning,
|
|
78
|
+
trackedManifests,
|
|
79
|
+
} from "./source-util.mjs";
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Module path declared in a go.mod, or null.
|
|
83
|
+
*
|
|
84
|
+
* The token may be `"`-quoted — `module "example.com/beta"` is legal,
|
|
85
|
+
* gofmt-clean go.mod syntax, the same string-literal spelling an import path
|
|
86
|
+
* uses. The quotes are not part of the path: kept, they would silently break
|
|
87
|
+
* every comparison against an (unquoted) import path, dropping the edge in
|
|
88
|
+
* both directions for a module declared this way. A backquoted module path is
|
|
89
|
+
* NOT stripped here because it is not legal go.mod syntax to begin with — the
|
|
90
|
+
* real toolchain rejects it, so there is nothing gofmt-clean to preserve.
|
|
91
|
+
*
|
|
92
|
+
* A UTF-8 BOM before the directive is tolerated (`contract.md`, byte
|
|
93
|
+
* tolerance): `/^module/m` never matches the file's first line through a
|
|
94
|
+
* leading `\uFEFF`, so an editor-written BOM used to return null and drop the
|
|
95
|
+
* whole project from the module map — no nodes, no edges, violations reported
|
|
96
|
+
* as none. This parse yields a path, never a position, so removing the one
|
|
97
|
+
* character shifts nothing any record points at.
|
|
98
|
+
*/
|
|
99
|
+
export function parseGoModulePath(goModText) {
|
|
100
|
+
const match = goModText.replace(/^\uFEFF/, "").match(/^module\s+(\S+)/m);
|
|
101
|
+
if (!match) return null;
|
|
102
|
+
const token = match[1];
|
|
103
|
+
return token.length >= 2 && token.startsWith('"') && token.endsWith('"')
|
|
104
|
+
? token.slice(1, -1)
|
|
105
|
+
: token;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Every character of `text` except its line breaks, replaced by a space. */
|
|
109
|
+
const blankOut = (text) => text.replace(/[^\n]/g, " ");
|
|
110
|
+
|
|
111
|
+
/** Where the scan has to stop and decide: a comment opener or a literal. */
|
|
112
|
+
const LEXICAL_START = /\/\/|\/\*|["'`]/g;
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* `goText` with every comment blanked out, byte-for-byte the same length and
|
|
116
|
+
* with its line breaks in place, so an offset into the result is the same
|
|
117
|
+
* offset into the original.
|
|
118
|
+
*
|
|
119
|
+
* A regex that cannot see a comment reads Go wrong in both directions, and
|
|
120
|
+
* this is the single pass that makes both go away — see the header for what
|
|
121
|
+
* each direction costs. Blanking rather than deleting is what keeps
|
|
122
|
+
* `positionAt` honest: the import-site record is read as `file:line:column`,
|
|
123
|
+
* and a mask that shortened the text would report every position after the
|
|
124
|
+
* first comment somewhere it is not.
|
|
125
|
+
*
|
|
126
|
+
* The scan has to know Go's literals to know where a comment is NOT: `//`
|
|
127
|
+
* inside a string is text, and so is the `*` + `/` that would otherwise close
|
|
128
|
+
* a block comment. Three literal forms carry that risk — an interpreted string
|
|
129
|
+
* and a rune literal, both escaped with `\` and neither able to span a line,
|
|
130
|
+
* and a raw string, which takes no escapes and may span as many lines as it
|
|
131
|
+
* likes. Go's block comments do not nest, so the first terminator closes one
|
|
132
|
+
* however many openers precede it.
|
|
133
|
+
*
|
|
134
|
+
* @param {string} goText
|
|
135
|
+
* @returns {string} Same length as `goText`.
|
|
136
|
+
*/
|
|
137
|
+
export function maskGoComments(goText) {
|
|
138
|
+
const scan = new RegExp(LEXICAL_START.source, "g");
|
|
139
|
+
let masked = "";
|
|
140
|
+
let copied = 0;
|
|
141
|
+
let match;
|
|
142
|
+
while ((match = scan.exec(goText)) !== null) {
|
|
143
|
+
const start = match.index;
|
|
144
|
+
let end;
|
|
145
|
+
let isComment = false;
|
|
146
|
+
if (match[0] === "//") {
|
|
147
|
+
// A line comment runs to the newline, which is not part of it.
|
|
148
|
+
const newline = goText.indexOf("\n", start);
|
|
149
|
+
end = newline === -1 ? goText.length : newline;
|
|
150
|
+
isComment = true;
|
|
151
|
+
} else if (match[0] === "/*") {
|
|
152
|
+
const terminator = goText.indexOf("*/", start + 2);
|
|
153
|
+
end = terminator === -1 ? goText.length : terminator + 2;
|
|
154
|
+
isComment = true;
|
|
155
|
+
} else if (match[0] === "`") {
|
|
156
|
+
const close = goText.indexOf("`", start + 1);
|
|
157
|
+
end = close === -1 ? goText.length : close + 1;
|
|
158
|
+
} else {
|
|
159
|
+
// Interpreted string or rune literal: `\` escapes the next character,
|
|
160
|
+
// except at a line break, where the literal is unterminated instead.
|
|
161
|
+
let at = start + 1;
|
|
162
|
+
while (at < goText.length && goText[at] !== match[0] && goText[at] !== "\n") {
|
|
163
|
+
at += goText[at] === "\\" && goText[at + 1] !== "\n" ? 2 : 1;
|
|
164
|
+
}
|
|
165
|
+
end = Math.min(goText[at] === match[0] ? at + 1 : at, goText.length);
|
|
166
|
+
}
|
|
167
|
+
const span = goText.slice(start, end);
|
|
168
|
+
masked += goText.slice(copied, start) + (isComment ? blankOut(span) : span);
|
|
169
|
+
copied = end;
|
|
170
|
+
scan.lastIndex = end;
|
|
171
|
+
}
|
|
172
|
+
return masked + goText.slice(copied);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// An import alias is a Go identifier: `\p{L}` is what `unicode.IsLetter`
|
|
176
|
+
// accepts as a starting rune (gofmt permits `import π "…"`), and the ASCII
|
|
177
|
+
// `[A-Za-z_.]` this used to be silently dropped every non-ASCII one — a
|
|
178
|
+
// silent missed edge/import, not a rejection. The `u` flag both regexes below
|
|
179
|
+
// are built with is what makes `\p{...}` a Unicode class rather than a
|
|
180
|
+
// literal `p`.
|
|
181
|
+
const GO_IMPORT_ALIAS = "[\\p{L}_.][\\p{L}\\p{Nd}_.]*";
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Every import in a .go file with the offset of its quoted path, in source
|
|
185
|
+
* order and WITHOUT deduplication — one entry per written import, which is
|
|
186
|
+
* what an import-site record is (`analysis/contract.md`).
|
|
187
|
+
*
|
|
188
|
+
* Offsets index the ORIGINAL text, not the mask: `maskGoComments` preserves
|
|
189
|
+
* length, so the two are the same coordinate system.
|
|
190
|
+
*
|
|
191
|
+
* @param {string} goText
|
|
192
|
+
* @returns {{ specifier: string, offset: number }[]}
|
|
193
|
+
*/
|
|
194
|
+
export function parseGoImportSites(goText) {
|
|
195
|
+
const source = maskGoComments(goText);
|
|
196
|
+
const sites = [];
|
|
197
|
+
// import "p" | import alias "p" | import _ "p" | import . "p" — the path
|
|
198
|
+
// quoted or backtick-delimited, and the whole spec opening either a line or
|
|
199
|
+
// (see the header) a `;`-separated continuation of one.
|
|
200
|
+
const singleForm = new RegExp(
|
|
201
|
+
`(?:^|;)\\s*import\\s+(?:${GO_IMPORT_ALIAS}\\s+)?(?:"([^"]+)"|\`([^\`]+)\`)`,
|
|
202
|
+
"gmu",
|
|
203
|
+
);
|
|
204
|
+
for (const m of source.matchAll(singleForm)) {
|
|
205
|
+
const quote = m[1] !== undefined ? '"' : "`";
|
|
206
|
+
sites.push({ specifier: m[1] ?? m[2], offset: m.index + m[0].indexOf(quote) });
|
|
207
|
+
}
|
|
208
|
+
const blockForm = new RegExp(
|
|
209
|
+
`(?:^|;)\\s*(?:${GO_IMPORT_ALIAS}\\s+)?(?:"([^"]+)"|\`([^\`]+)\`)`,
|
|
210
|
+
"gmu",
|
|
211
|
+
);
|
|
212
|
+
// The block opener takes the SAME `(?:^|;)` prefix the single form does, and
|
|
213
|
+
// for the same reason the header gives: `;` is the statement separator gofmt
|
|
214
|
+
// inserts at a newline, so `package main; import (…)` opens a block exactly
|
|
215
|
+
// the way a fresh line does. Anchoring this one on `^` alone read that file
|
|
216
|
+
// as having no block at all — every path inside it dropped with no record and
|
|
217
|
+
// no failure, which is the silent direction the single form was already fixed
|
|
218
|
+
// for. `import (` can never also match `singleForm` above, which requires a
|
|
219
|
+
// quote or an alias after `import`, so no site is counted twice.
|
|
220
|
+
for (const block of source.matchAll(/(?:^|;)\s*import\s*\(([\s\S]*?)\)/gm)) {
|
|
221
|
+
const contentOffset = block.index + block[0].indexOf("(") + 1;
|
|
222
|
+
for (const m of block[1].matchAll(blockForm)) {
|
|
223
|
+
const quote = m[1] !== undefined ? '"' : "`";
|
|
224
|
+
sites.push({
|
|
225
|
+
specifier: m[1] ?? m[2],
|
|
226
|
+
offset: contentOffset + m.index + m[0].indexOf(quote),
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return sites.sort((a, b) => a.offset - b.offset);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Every import path in a .go file (single-form and block-form), deduped. */
|
|
234
|
+
export function parseGoImports(goText) {
|
|
235
|
+
return [...new Set(parseGoImportSites(goText).map((site) => site.specifier))];
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Static edges between Go projects.
|
|
240
|
+
*
|
|
241
|
+
* `projects`: [{ name, root }]; `filesOf(name)`: workspace-relative paths of
|
|
242
|
+
* a project's tracked files; `readFile(path)`: contents or null. Returns raw
|
|
243
|
+
* Nx dependencies ({ source, target, sourceFile, type: "static" }).
|
|
244
|
+
*/
|
|
245
|
+
export function resolveGoDependencies(projects, filesOf, readFile) {
|
|
246
|
+
const moduleOf = new Map(); // module path -> project name
|
|
247
|
+
const goProjects = [];
|
|
248
|
+
for (const project of projects) {
|
|
249
|
+
const goModPath = normalizePath(project.root, "go.mod");
|
|
250
|
+
if (!filesOf(project.name).includes(goModPath)) continue;
|
|
251
|
+
const modulePath = parseGoModulePath(readFile(goModPath) ?? "");
|
|
252
|
+
if (!modulePath) continue;
|
|
253
|
+
moduleOf.set(modulePath, project.name);
|
|
254
|
+
goProjects.push(project);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const dependencies = [];
|
|
258
|
+
for (const project of goProjects) {
|
|
259
|
+
for (const file of filesOf(project.name)) {
|
|
260
|
+
if (!file.endsWith(".go")) continue;
|
|
261
|
+
const text = readFile(file);
|
|
262
|
+
if (text === null) continue;
|
|
263
|
+
for (const importPath of parseGoImports(text)) {
|
|
264
|
+
// The same longest-module-path resolution `analyzeGo` applies, so the
|
|
265
|
+
// graph edge names the same project the import-site record resolves to
|
|
266
|
+
// (WSX-D02): a nested module is its own project, not its parent.
|
|
267
|
+
const resolved = resolveGoModule(
|
|
268
|
+
importPath,
|
|
269
|
+
[...moduleOf].map(([modulePath, target]) => ({ modulePath, project: target })),
|
|
270
|
+
);
|
|
271
|
+
if (resolved !== null && resolved.project !== project.name && resolved.project !== null) {
|
|
272
|
+
dependencies.push({
|
|
273
|
+
source: project.name,
|
|
274
|
+
target: resolved.project,
|
|
275
|
+
sourceFile: file,
|
|
276
|
+
type: "static",
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return dependencies;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Every project's Go module paths, read once per workspace rather than once
|
|
287
|
+
* per `.go` file. Every tracked `go.mod` in a project counts, not only the one
|
|
288
|
+
* at its root — see `trackedManifests` for why analysis is broader here than
|
|
289
|
+
* the edge resolver above.
|
|
290
|
+
*/
|
|
291
|
+
const goModulesOf = perWorkspace((workspace) => {
|
|
292
|
+
const byModulePath = new Map(); // module path -> project name
|
|
293
|
+
const byProject = new Map(); // project name -> [module path]
|
|
294
|
+
for (const project of workspace.projects) {
|
|
295
|
+
for (const goModPath of trackedManifests(workspace, project.name, "go.mod")) {
|
|
296
|
+
const modulePath = parseGoModulePath(workspace.readFile(goModPath) ?? "");
|
|
297
|
+
if (!modulePath) continue;
|
|
298
|
+
byModulePath.set(modulePath, project.name);
|
|
299
|
+
byProject.set(project.name, [...(byProject.get(project.name) ?? []), modulePath]);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return { byModulePath, byProject };
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
/** True when `importPath` is inside the module rooted at `modulePath`. */
|
|
306
|
+
const isUnderModule = (importPath, modulePath) =>
|
|
307
|
+
importPath === modulePath || importPath.startsWith(`${modulePath}/`);
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* The single longest-module-path resolution both layers read an import with,
|
|
311
|
+
* so the graph and the verdict can never disagree about which project an
|
|
312
|
+
* import belongs to (WSX-D02): a module nested under another module's path is
|
|
313
|
+
* a different project, and a first-match answer names its parent.
|
|
314
|
+
*
|
|
315
|
+
* @param {string} importPath
|
|
316
|
+
* @param {({ modulePath: string, project: string | null })[]} modules
|
|
317
|
+
* Longest-match candidates; `project` null marks an own-module candidate.
|
|
318
|
+
* @returns {{ matched: string, project: string | null } | null} `null` when
|
|
319
|
+
* no module claims `importPath`.
|
|
320
|
+
*/
|
|
321
|
+
export function resolveGoModule(importPath, modules) {
|
|
322
|
+
let matched = "";
|
|
323
|
+
let project = null;
|
|
324
|
+
for (const { modulePath, project: target } of modules) {
|
|
325
|
+
if (!isUnderModule(importPath, modulePath)) continue;
|
|
326
|
+
if (modulePath.length <= matched.length) continue;
|
|
327
|
+
matched = modulePath;
|
|
328
|
+
project = target;
|
|
329
|
+
}
|
|
330
|
+
return matched === "" ? null : { matched, project };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Did this import land inside the file's own project — the `spelling.relative`
|
|
335
|
+
* bit of the analysis record (`contract.md`)?
|
|
336
|
+
*
|
|
337
|
+
* Go has no relative import form: every import is the full module-qualified
|
|
338
|
+
* package path, so a package reaching a sibling package of its own module must
|
|
339
|
+
* spell the module path out. That is the same position Rust's
|
|
340
|
+
* `isOwnProjectPath` answers for a binary naming its own package's library
|
|
341
|
+
* crate — the language offers no other spelling, so the spelling is not
|
|
342
|
+
* evidence of anything.
|
|
343
|
+
*
|
|
344
|
+
* The bit exists as counter-evidence for `noSelfCircularDependencies`, which
|
|
345
|
+
* names a file leaving its project through the project's public alias and
|
|
346
|
+
* coming back in. A Go import cannot be that: the compiler rejects an import
|
|
347
|
+
* cycle outright, so an import resolving to the source's own project is
|
|
348
|
+
* internal by construction, and reporting it would demand a form the language
|
|
349
|
+
* does not have. Nx models one module as one project, so source and target
|
|
350
|
+
* land on the same node whenever a real Go project has two packages.
|
|
351
|
+
*
|
|
352
|
+
* @param {string|null} target The project the import resolved to.
|
|
353
|
+
* @param {{name: string}|null} owner The project owning the source file.
|
|
354
|
+
* @returns {boolean}
|
|
355
|
+
*/
|
|
356
|
+
const isOwnProjectImport = (target, owner) => target !== null && target === owner?.name;
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Analyzes one `.go` file.
|
|
360
|
+
*
|
|
361
|
+
* An import of the file's own module resolves to its own project rather than
|
|
362
|
+
* being dropped: `contract.md` keeps intra-project imports because a rule
|
|
363
|
+
* about a project reaching itself through its public path cannot be written
|
|
364
|
+
* without them.
|
|
365
|
+
*
|
|
366
|
+
* @param {{ sourceFile: string, text: string, workspace: object }} request
|
|
367
|
+
* @returns {{ imports: object[], failures: object[] }}
|
|
368
|
+
*/
|
|
369
|
+
export function analyzeGo({ sourceFile, text, workspace }) {
|
|
370
|
+
const result = emptyResult();
|
|
371
|
+
try {
|
|
372
|
+
const { byModulePath, byProject } = goModulesOf(workspace);
|
|
373
|
+
const owner = projectOwning(workspace.projects, sourceFile);
|
|
374
|
+
const ownModules = owner ? (byProject.get(owner.name) ?? []) : [];
|
|
375
|
+
|
|
376
|
+
for (const site of parseGoImportSites(text)) {
|
|
377
|
+
const { line, column } = positionAt(text, site.offset);
|
|
378
|
+
// The one resolution both layers share (WSX-D02): longest module path
|
|
379
|
+
// wins, for the reason `projectOwning` matches the longest project root —
|
|
380
|
+
// a module nested under another module's path is a different project,
|
|
381
|
+
// and a first-match answer would name its parent.
|
|
382
|
+
const resolved = resolveGoModule(site.specifier, [
|
|
383
|
+
...ownModules.map((modulePath) => ({ modulePath, project: owner?.name ?? null })),
|
|
384
|
+
...[...byModulePath].map(([modulePath, project]) => ({ modulePath, project })),
|
|
385
|
+
]);
|
|
386
|
+
const target = resolved?.project ?? null;
|
|
387
|
+
result.imports.push({
|
|
388
|
+
sourceFile,
|
|
389
|
+
line,
|
|
390
|
+
column,
|
|
391
|
+
specifier: site.specifier,
|
|
392
|
+
kind: "static",
|
|
393
|
+
// Both answers are the language's rather than a default. `path` is
|
|
394
|
+
// never true: a Go import path is a PACKAGE path resolved through the
|
|
395
|
+
// module graph, never a filesystem path resolved against the importing
|
|
396
|
+
// file — modules mode rejects `import "./sub"` outright.
|
|
397
|
+
//
|
|
398
|
+
// `relative` is true exactly when the import landed inside the file's
|
|
399
|
+
// own project, which is `isOwnProjectImport` above and the same answer
|
|
400
|
+
// Rust gives a binary that names its own package's library crate.
|
|
401
|
+
spelling: { path: false, relative: isOwnProjectImport(target, owner) },
|
|
402
|
+
resolved: {
|
|
403
|
+
target,
|
|
404
|
+
file: null,
|
|
405
|
+
external: target === null,
|
|
406
|
+
packageName: target === null ? site.specifier : null,
|
|
407
|
+
},
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
} catch (cause) {
|
|
411
|
+
result.failures.push(fileFailure(sourceFile, `Go analysis failed: ${cause?.message ?? cause}`));
|
|
412
|
+
}
|
|
413
|
+
return result;
|
|
414
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/** Shared manifest helpers for the per-language resolvers. */
|
|
2
|
+
import { parse as parseToml } from "smol-toml";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Parses TOML, returning null instead of throwing on malformed input.
|
|
6
|
+
*
|
|
7
|
+
* A leading UTF-8 BOM is tolerated (`contract.md`, byte tolerance): smol-toml
|
|
8
|
+
* rejects it outright (measured), so a manifest an editor wrote a BOM into
|
|
9
|
+
* came back null and everything read from it vanished — a `Cargo.toml` crate
|
|
10
|
+
* dropped out of the crate map and every import of it resolved as if it were
|
|
11
|
+
* external, a `pyproject.toml`'s declared layout went unmodelled. Manifests
|
|
12
|
+
* contribute no position to any record (`contract.md`), so removing the one
|
|
13
|
+
* character here shifts nothing.
|
|
14
|
+
*
|
|
15
|
+
* The return type is deliberately loose: a manifest's shape is whatever its
|
|
16
|
+
* author wrote, and every reader here guards each access with optional
|
|
17
|
+
* chaining and typeof checks rather than trusting a declared shape.
|
|
18
|
+
*
|
|
19
|
+
* @param {string} text
|
|
20
|
+
* @returns {Record<string, any> | null}
|
|
21
|
+
*/
|
|
22
|
+
export function parseManifest(text) {
|
|
23
|
+
try {
|
|
24
|
+
return parseToml(text.replace(/^\uFEFF/, ""));
|
|
25
|
+
} catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** POSIX-normalizes `relative` against `baseDir` without touching the fs. */
|
|
31
|
+
export function normalizePath(baseDir, relative) {
|
|
32
|
+
const segments = [];
|
|
33
|
+
for (const part of `${baseDir}/${relative}`.split("/")) {
|
|
34
|
+
if (part === "" || part === ".") continue;
|
|
35
|
+
if (part === "..") segments.pop();
|
|
36
|
+
else segments.push(part);
|
|
37
|
+
}
|
|
38
|
+
return segments.join("/");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Like `normalizePath`, but answers `null` when the path climbs above the
|
|
43
|
+
* workspace root instead of quietly staying there.
|
|
44
|
+
*
|
|
45
|
+
* `normalizePath` pops a `..` off an empty stack as a no-op, so
|
|
46
|
+
* `libs/alpha` + `../../../elsewhere` comes back as `elsewhere` — an in-tree
|
|
47
|
+
* spelling of a directory that is NOT in the tree. A caller comparing that
|
|
48
|
+
* against project roots would then reason about the wrong directory in both
|
|
49
|
+
* directions: a spurious match, or a "no project here" verdict about a path
|
|
50
|
+
* the workspace never contained. `null` keeps the two cases apart: the target
|
|
51
|
+
* lies outside the tree every project root is relative to, so no comparison
|
|
52
|
+
* against those roots can mean anything.
|
|
53
|
+
*
|
|
54
|
+
* @param {string} baseDir Workspace-relative directory the path is written in.
|
|
55
|
+
* @param {string} relative As written in the manifest.
|
|
56
|
+
* @returns {string|null} Workspace-relative path, or `null` when it escapes.
|
|
57
|
+
*/
|
|
58
|
+
export function resolveWithinWorkspace(baseDir, relative) {
|
|
59
|
+
const segments = [];
|
|
60
|
+
for (const part of `${baseDir}/${relative}`.split("/")) {
|
|
61
|
+
if (part === "" || part === ".") continue;
|
|
62
|
+
if (part === "..") {
|
|
63
|
+
if (segments.length === 0) return null;
|
|
64
|
+
segments.pop();
|
|
65
|
+
} else segments.push(part);
|
|
66
|
+
}
|
|
67
|
+
return segments.join("/");
|
|
68
|
+
}
|