@ecoma-io/archkeep 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +262 -0
  3. package/cli.mjs +2792 -0
  4. package/index.mjs +85 -0
  5. package/lsp.mjs +81 -0
  6. package/nx.mjs +24 -0
  7. package/package.json +81 -0
  8. package/presets/clean-architecture.json +78 -0
  9. package/presets/ddd-bounded-contexts.json +88 -0
  10. package/presets/hexagonal.json +68 -0
  11. package/presets/layered.json +92 -0
  12. package/presets/modular-monolith.json +85 -0
  13. package/presets/vertical-slice.json +68 -0
  14. package/src/analysis/analyze.mjs +218 -0
  15. package/src/analysis/contract.md +259 -0
  16. package/src/analysis/go.mjs +414 -0
  17. package/src/analysis/manifest-util.mjs +68 -0
  18. package/src/analysis/python.mjs +1266 -0
  19. package/src/analysis/registry.mjs +74 -0
  20. package/src/analysis/rust.mjs +674 -0
  21. package/src/analysis/source-util.mjs +230 -0
  22. package/src/analysis/typescript.mjs +1034 -0
  23. package/src/analysis/vue.mjs +156 -0
  24. package/src/architecture-intent/intent-fingerprint.mjs +29 -0
  25. package/src/architecture-intent/judge.mjs +539 -0
  26. package/src/architecture-intent/model.mjs +703 -0
  27. package/src/architecture-intent/selectors.mjs +170 -0
  28. package/src/canonical.mjs +48 -0
  29. package/src/commands/README.md +266 -0
  30. package/src/commands/adr.mjs +248 -0
  31. package/src/commands/check.mjs +989 -0
  32. package/src/commands/context-command.mjs +212 -0
  33. package/src/commands/context.mjs +790 -0
  34. package/src/commands/custom-rules.mjs +428 -0
  35. package/src/commands/debt.mjs +218 -0
  36. package/src/commands/diff.mjs +523 -0
  37. package/src/commands/discover.mjs +159 -0
  38. package/src/commands/drift.mjs +473 -0
  39. package/src/commands/edge-constraints.mjs +355 -0
  40. package/src/commands/explain.mjs +359 -0
  41. package/src/commands/fitness.mjs +226 -0
  42. package/src/commands/graph.mjs +297 -0
  43. package/src/commands/health.mjs +213 -0
  44. package/src/commands/history.mjs +614 -0
  45. package/src/commands/impact.mjs +226 -0
  46. package/src/commands/plan-context-command.mjs +496 -0
  47. package/src/commands/policy.mjs +138 -0
  48. package/src/commands/provenance-command.mjs +352 -0
  49. package/src/commands/provenance.mjs +159 -0
  50. package/src/commands/reconcile.mjs +219 -0
  51. package/src/commands/report.mjs +553 -0
  52. package/src/commands/snapshot-meta.mjs +107 -0
  53. package/src/commands/waivers.mjs +240 -0
  54. package/src/config.mjs +1308 -0
  55. package/src/containment.mjs +234 -0
  56. package/src/custom-rules/evidence.mjs +340 -0
  57. package/src/custom-rules/host.mjs +1023 -0
  58. package/src/custom-rules/values.mjs +43 -0
  59. package/src/entry-point.mjs +55 -0
  60. package/src/errors.mjs +36 -0
  61. package/src/eslint-config.mjs +542 -0
  62. package/src/go-work.mjs +394 -0
  63. package/src/governance/adr-registry.mjs +539 -0
  64. package/src/governance/clock.mjs +69 -0
  65. package/src/governance/debt-ledger.mjs +274 -0
  66. package/src/governance/discovery-proposal.mjs +423 -0
  67. package/src/governance/fitness-registry.mjs +504 -0
  68. package/src/governance/fitness-rules.mjs +668 -0
  69. package/src/governance/metrics.mjs +392 -0
  70. package/src/governance/preset-fingerprints.json +16 -0
  71. package/src/governance/profile-registry.mjs +366 -0
  72. package/src/governance/provenance-record.mjs +177 -0
  73. package/src/governance/reconcile-candidates.mjs +301 -0
  74. package/src/governance/reconcile-score.mjs +503 -0
  75. package/src/governance/row-schema.mjs +208 -0
  76. package/src/governance/verdict.mjs +127 -0
  77. package/src/governance/waiver.mjs +105 -0
  78. package/src/graph/create-dependencies.mjs +96 -0
  79. package/src/intent/intent-manifest.json +347 -0
  80. package/src/intent/mask-non-code.mjs +640 -0
  81. package/src/lsp/boundary-config.mjs +225 -0
  82. package/src/lsp/diagnose.mjs +202 -0
  83. package/src/lsp/diagnostics.mjs +241 -0
  84. package/src/lsp/protocol.mjs +215 -0
  85. package/src/lsp/server.mjs +922 -0
  86. package/src/lsp/workspace-index.mjs +891 -0
  87. package/src/nx-json.mjs +95 -0
  88. package/src/options.mjs +611 -0
  89. package/src/process.mjs +91 -0
  90. package/src/providers/moon.mjs +733 -0
  91. package/src/providers/native/README.md +204 -0
  92. package/src/providers/native/coverage.mjs +74 -0
  93. package/src/providers/native/differential.fixtures.mjs +1277 -0
  94. package/src/providers/native/discover.mjs +431 -0
  95. package/src/providers/native/graph.mjs +234 -0
  96. package/src/providers/native/index.mjs +152 -0
  97. package/src/providers/native/model.mjs +755 -0
  98. package/src/providers/nx.mjs +178 -0
  99. package/src/report/README.md +89 -0
  100. package/src/report/adr-text.mjs +129 -0
  101. package/src/report/context-text.mjs +109 -0
  102. package/src/report/debt-text.mjs +105 -0
  103. package/src/report/diff-text.mjs +219 -0
  104. package/src/report/discover-text.mjs +186 -0
  105. package/src/report/drift-text.mjs +194 -0
  106. package/src/report/envelope-shape.mjs +161 -0
  107. package/src/report/evidence.mjs +157 -0
  108. package/src/report/explain-text.mjs +159 -0
  109. package/src/report/graph-text.mjs +116 -0
  110. package/src/report/health-text.mjs +123 -0
  111. package/src/report/history-text.mjs +204 -0
  112. package/src/report/impact-text.mjs +128 -0
  113. package/src/report/json.mjs +173 -0
  114. package/src/report/plan-context-text.mjs +159 -0
  115. package/src/report/provenance-text.mjs +78 -0
  116. package/src/report/reconcile-text.mjs +159 -0
  117. package/src/report/report-text.mjs +264 -0
  118. package/src/report/sarif.mjs +953 -0
  119. package/src/report/text.mjs +823 -0
  120. package/src/report/waivers-text.mjs +100 -0
  121. package/src/rules/README.md +123 -0
  122. package/src/rules/index.mjs +962 -0
  123. package/src/rules/match.mjs +1708 -0
  124. package/src/rules/messages.mjs +73 -0
  125. package/src/rules/reachability.mjs +224 -0
  126. package/src/rules/specifiers.mjs +300 -0
  127. package/src/rules/tags.mjs +238 -0
  128. package/src/rules/topology.mjs +333 -0
  129. package/src/tsconfig-paths.mjs +237 -0
  130. package/src/verdict.mjs +145 -0
  131. package/src/workspace.mjs +580 -0
@@ -0,0 +1,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
+ }