llmnav 0.7.2 → 0.7.4

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/CHANGELOG.md CHANGED
@@ -6,6 +6,18 @@ The npm package follows Semantic Versioning. The `llmnav/N` source protocol is v
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.7.4] — 2026-08-14
10
+
11
+ ### Fixed
12
+
13
+ * Required npm provenance in package metadata, release validation, and the tag publication workflow so public Trusted Publisher releases fail closed if provenance is disabled.
14
+
15
+ ## [0.7.3] — 2026-08-14
16
+
17
+ ### Fixed
18
+
19
+ * Resolved repository-local Go module imports for annotation audit fan-in and repository graph edges, grouped Go audit candidates by package, recognized `cmd/*/main.go` entrypoints, suppressed `_test.go` support files, and invalidated graph partitions when module resolution changes.
20
+
9
21
  ## [0.7.2] — 2026-08-13
10
22
 
11
23
  ### Added
@@ -72,6 +72,8 @@ Generation compares the previous and current primary indexes to emit `affectedBo
72
72
 
73
73
  The read-only audit combines package entrypoints, public re-export reachability, generated structural boundaries, import fan-in, exported declaration counts, and source size. It lowers the priority of named utilities and low-fan-in export hubs, declaration files, non-production support paths, and pure re-export barrels. High-fan-in domain contract hubs do not receive the broad-utility penalty merely because they export many declarations. Every candidate retains its score inputs and deterministic repository-relative path.
74
74
 
75
+ Go source is audited by package rather than by individual file. LLMNav reads repository-contained `go.mod` files without executing the Go toolchain, resolves matching full module imports, counts importing packages, treats `cmd/*/main.go` and other `package main` entrypoints as commands, and excludes `_test.go` files from production signals. One reviewed `module` card covers its Go package for audit purposes; a `file` card remains file-specific. An uncovered package produces one deterministic representative path instead of one candidate per file. The schema-version 1 summary counters continue to report exact annotated file placement, while the candidate list reports these package-level omissions.
76
+
75
77
  This analyzer deliberately stops before semantic generation. Structure can identify a file worth inspecting, but it cannot safely invent a durable ID, role, ownership statement, invariant, risk, or relation. Accepted boundaries become explicit source cards, path-specific coverage rules, and retrieval regression queries through normal review.
76
78
 
77
79
  ## Deterministic inverted index
@@ -107,6 +109,8 @@ Search treats lexical results as seeds and applies only a confidence-scaled one-
107
109
 
108
110
  The graph is also the explicit workspace resolution surface. Qualified IDs resolve by exact node key. Unqualified IDs prefer the local repository and resolve externally only when the semantic ID is unique across imported repositories. No directory discovery or network lookup occurs during resolution.
109
111
 
112
+ For Go cards, repository-local full module imports resolve to cards in the imported package. Module cards are preferred when present so a package import does not create arbitrary symbol-level fan-out. The module resolver version and discovered module map participate in the graph resolution hash, so an upgraded resolver or changed `go.mod` rebuilds affected card partitions instead of reusing stale zero-edge state.
113
+
110
114
  `.llmnav/cache/graph-state.json` is a disposable schemaVersion 1 acceleration artifact. Content-addressed partitions isolate local cards and imported indexes. Partition keys and hashes include every graph-relevant dimension, and local path resolution changes invalidate all affected local-import decisions. Incompatible or malformed state is never partially trusted: generation rebuilds it from the current primary index and validated graph inputs.
111
115
 
112
116
  Graph state contains no timestamps, absolute paths, or filesystem identity. Incremental and forced-full builds must produce byte-identical graph and state bytes, and the transaction publishes both with the manifest.
@@ -31,7 +31,7 @@ npm run release:check
31
31
 
32
32
  ## 3. Configure npm authentication
33
33
 
34
- The supplied release workflow uses GitHub's OIDC token through npm Trusted Publishers. The GitHub source repository is private, so the workflow explicitly disables provenance; npm accepts provenance only from public source repositories. If the repository becomes public, enable provenance in both `package.json` and the workflow after a successful release check. The same tag workflow creates an idempotent GitHub Release only after the npm registry check or publication succeeds.
34
+ The supplied release workflow uses GitHub's OIDC token through npm Trusted Publishers. The public GitHub source repository enables provenance in both `package.json` and the workflow, and the release check fails if either surface disables it. The same tag workflow creates an idempotent GitHub Release only after the npm registry check or publication succeeds.
35
35
 
36
36
  The workflow references a GitHub environment named `npm`. Create that environment for release protection, or remove the `environment` line when no environment gate is desired.
37
37
 
@@ -55,14 +55,14 @@ Manual publication:
55
55
 
56
56
  ```sh
57
57
  npm login
58
- npm publish --provenance=false --access public
58
+ npm publish --provenance --access public
59
59
  ```
60
60
 
61
61
  Automated publication:
62
62
 
63
63
  ```sh
64
- git tag v0.6.0
65
- git push origin v0.6.0
64
+ git tag vX.Y.Z
65
+ git push origin vX.Y.Z
66
66
  ```
67
67
 
68
68
  The release workflow rejects a tag that does not match `package.json`. If the exact version is already present in npm, the workflow succeeds only when the registry tarball integrity matches the tagged package; a mismatched package fails closed. It then reuses an existing GitHub Release for the tag or creates one with generated release notes. Registry, GitHub API, and Release creation errors other than an expected missing Release fail the workflow.
@@ -21,6 +21,8 @@ npx llmnav audit
21
21
 
22
22
  Start with high and medium candidates. The audit explains whether a file is a package entrypoint, public API, generated structural boundary, or high fan-in module. Narrow source signals also identify versioned schema literals, persistent dotted JSON filename protocols, and Tauri invoke adapters. It does not write source or invent card contents. Declaration files and common non-production or low-fan-in utility shapes are suppressed so the result is a review queue, not a demand to annotate every file.
23
23
 
24
+ In Go repositories, candidates represent packages. Full imports below a repository-contained `go.mod` contribute package fan-in, `cmd/*/main.go` is treated as a command entrypoint, and `_test.go` files do not create separate production candidates. Put one `module` card on a durable package representative; do not repeat the same package role on every Go file.
25
+
24
26
  After accepting a candidate, write its durable meaning by inspecting the source, add a narrow `coverageRules` entry for that exact boundary, and add a real task-language query to `.llmnav/eval/queries.jsonl`. Use `npx llmnav audit --fail-on high` in CI only after the initial review.
25
27
 
26
28
  ## Annotate a module boundary
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "llmnav",
3
- "version": "0.7.2",
3
+ "version": "0.7.4",
4
4
  "description": "A deterministic semantic navigation layer for LLM coding agents.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -74,6 +74,6 @@
74
74
  "types": "./src/index.d.ts",
75
75
  "publishConfig": {
76
76
  "access": "public",
77
- "provenance": false
77
+ "provenance": true
78
78
  }
79
79
  }
package/src/audit.js CHANGED
@@ -11,6 +11,7 @@ stability=contract
11
11
 
12
12
  import path from "node:path";
13
13
  import { detectBoundaries } from "./boundaries.js";
14
+ import { moduleKeyForFile, resolveGoImportModule } from "./module-resolution.js";
14
15
  import { scanProject } from "./project.js";
15
16
  import { compareText, readJsonSafe, toPosix } from "./util.js";
16
17
 
@@ -23,7 +24,7 @@ const SOURCE_EXTENSIONS = Object.freeze([
23
24
  ".ts", ".tsx", ".vue",
24
25
  ]);
25
26
  const UTILITY_NAME_PATTERN = /^(?:common|helpers?|shared|utils?)$/u;
26
- const NON_PRODUCTION_PATH_PATTERN = /(?:^|\/)(?:__tests__|benchmarks?|fixtures?|tests?)(?:\/|$)|\.(?:spec|test)\.[^/]+$/u;
27
+ const NON_PRODUCTION_PATH_PATTERN = /(?:^|\/)(?:__tests__|benchmarks?|fixtures?|testdata|tests?)(?:\/|$)|\.(?:spec|test)\.[^/]+$|_test\.go$/u;
27
28
  const LARGE_SOURCE_BYTES = 12_000;
28
29
 
29
30
  export async function auditProject(root) {
@@ -31,44 +32,68 @@ export async function auditProject(root) {
31
32
  const fileByPath = new Map(
32
33
  project.fileRecords.map((record) => [toPosix(record.relativePath), record]),
33
34
  );
34
- const moduleCardPaths = new Set(
35
+ const cardedFilePaths = new Set(
35
36
  project.records
36
37
  .filter((record) => record.card.scope === "file" || record.card.scope === "module")
37
38
  .map((record) => toPosix(record.relativePath)),
38
39
  );
39
- const importsByPath = new Map();
40
- const importedBy = new Map([...fileByPath.keys()].map((file) => [file, new Set()]));
41
- for (const [file, record] of fileByPath) {
42
- const resolved = record.imports
43
- .map((specifier) => resolveLocalSpecifier(file, specifier, fileByPath))
44
- .filter(Boolean);
45
- const unique = [...new Set(resolved)].sort(compareText);
46
- importsByPath.set(file, unique);
47
- for (const target of unique) importedBy.get(target)?.add(file);
40
+ const exactCardPaths = new Set(
41
+ project.records
42
+ .filter((record) => record.card.scope === "file")
43
+ .map((record) => toPosix(record.relativePath)),
44
+ );
45
+ const coveredModules = new Set(
46
+ project.records
47
+ .filter((record) => record.card.scope === "module")
48
+ .map((record) => moduleKeyForFile(record.relativePath)),
49
+ );
50
+ const filesByModule = groupFilesByModule(fileByPath);
51
+ const moduleKeys = new Set(filesByModule.keys());
52
+ const importedBy = new Map([...moduleKeys].map((key) => [key, new Set()]));
53
+ for (const [sourceKey, files] of filesByModule) {
54
+ const targets = new Set();
55
+ for (const [file, record] of files) {
56
+ for (const specifier of record.imports) {
57
+ const target = resolveImportedModule(file, specifier, fileByPath, project.moduleResolution, moduleKeys);
58
+ if (target) targets.add(target);
59
+ }
60
+ }
61
+ for (const target of targets) importedBy.get(target)?.add(sourceKey);
48
62
  }
49
63
 
50
64
  const entrypoints = await collectWorkspacePackageEntrypoints(root, fileByPath);
65
+ collectGoEntrypoints(fileByPath, entrypoints);
51
66
  const publicApiPaths = collectPublicApiPaths(entrypoints, fileByPath);
52
67
  const candidates = [];
53
68
 
54
- for (const [file, record] of [...fileByPath.entries()].sort(([left], [right]) => compareText(left, right))) {
55
- if (moduleCardPaths.has(file)) continue;
56
- if (/\.d\.[cm]?ts$/u.test(file)) continue;
57
- const boundaries = detectBoundaries({
58
- relativePath: file,
69
+ for (const [moduleKey, files] of [...filesByModule.entries()].sort(([left], [right]) => compareText(left, right))) {
70
+ if (coveredModules.has(moduleKey)) continue;
71
+ const selectableFiles = files.filter(([file]) => !exactCardPaths.has(file) && !/\.d\.[cm]?ts$/u.test(file));
72
+ if (selectableFiles.length === 0) continue;
73
+ const productionFiles = selectableFiles.filter(([file]) => !NON_PRODUCTION_PATH_PATTERN.test(file));
74
+ const analyzedFiles = productionFiles.length > 0 ? productionFiles : selectableFiles;
75
+ const file = selectRepresentativeFile(analyzedFiles, entrypoints);
76
+ const record = fileByPath.get(file);
77
+ const boundaries = [...new Set(analyzedFiles.flatMap(([candidate, item]) => detectBoundaries({
78
+ relativePath: candidate,
59
79
  card: { effect: [], risk: [] },
60
- source: record.source ?? "",
61
- }).map((boundary) => boundary.kind);
62
- const exportedDeclarations = countExportedDeclarations(record.source ?? "", file);
63
- const entrypoint = entrypoints.has(file);
64
- const publicApi = publicApiPaths.has(file) && !entrypoint;
65
- const importers = importedBy.get(file)?.size ?? 0;
66
- const reexportBarrel = isReexportBarrel(record.source ?? "", file);
67
- const basename = path.posix.basename(file, path.posix.extname(file)).toLowerCase();
80
+ source: item.source ?? "",
81
+ }).map((boundary) => boundary.kind)))].sort(compareText);
82
+ const exportedDeclarations = analyzedFiles.reduce(
83
+ (sum, [candidate, item]) => sum + countExportedDeclarations(item.source ?? "", candidate),
84
+ 0,
85
+ );
86
+ const entrypoint = analyzedFiles.some(([candidate]) => entrypoints.has(candidate));
87
+ const publicApi = analyzedFiles.some(([candidate]) => publicApiPaths.has(candidate) && !entrypoints.has(candidate));
88
+ const importers = importedBy.get(moduleKey)?.size ?? 0;
89
+ const reexportBarrel = analyzedFiles.length === 1 && isReexportBarrel(record.source ?? "", file);
90
+ const isGoModule = moduleKey.startsWith("go:");
91
+ const moduleName = isGoModule ? moduleKey.slice(3) : file;
92
+ const basename = path.posix.basename(moduleName, path.posix.extname(moduleName)).toLowerCase();
68
93
  const broadUtility = UTILITY_NAME_PATTERN.test(basename) ||
69
- (exportedDeclarations >= 10 && importers <= 1 && !entrypoint && !publicApi && boundaries.length === 0);
70
- const nonProduction = NON_PRODUCTION_PATH_PATTERN.test(file);
71
- const largeSource = record.sourceBytes >= LARGE_SOURCE_BYTES;
94
+ (!isGoModule && exportedDeclarations >= 10 && importers <= 1 && !entrypoint && !publicApi && boundaries.length === 0);
95
+ const nonProduction = productionFiles.length === 0;
96
+ const largeSource = analyzedFiles.reduce((sum, [, item]) => sum + item.sourceBytes, 0) >= LARGE_SOURCE_BYTES;
72
97
  const hasSignal = entrypoint || publicApi || boundaries.length > 0 || importers > 0 || exportedDeclarations > 0 || largeSource;
73
98
  if (!hasSignal) continue;
74
99
 
@@ -139,8 +164,8 @@ export async function auditProject(root) {
139
164
  compareText(left.path, right.path));
140
165
  const summary = {
141
166
  analyzedFiles: fileByPath.size,
142
- cardedFiles: moduleCardPaths.size,
143
- filesWithoutModuleCards: fileByPath.size - moduleCardPaths.size,
167
+ cardedFiles: cardedFilePaths.size,
168
+ filesWithoutModuleCards: fileByPath.size - cardedFilePaths.size,
144
169
  candidates: candidates.length,
145
170
  high: candidates.filter((candidate) => candidate.priority === "high").length,
146
171
  medium: candidates.filter((candidate) => candidate.priority === "medium").length,
@@ -155,6 +180,46 @@ export async function auditProject(root) {
155
180
  };
156
181
  }
157
182
 
183
+ function groupFilesByModule(fileByPath) {
184
+ const output = new Map();
185
+ for (const [file, record] of fileByPath) {
186
+ const key = moduleKeyForFile(file);
187
+ const files = output.get(key) ?? [];
188
+ files.push([file, record]);
189
+ output.set(key, files);
190
+ }
191
+ for (const files of output.values()) files.sort(([left], [right]) => compareText(left, right));
192
+ return output;
193
+ }
194
+
195
+ function resolveImportedModule(file, specifier, fileByPath, moduleResolution, moduleKeys) {
196
+ const goTarget = resolveGoImportModule(file, specifier, moduleResolution, moduleKeys);
197
+ if (goTarget) return goTarget;
198
+ const target = resolveLocalSpecifier(file, specifier, fileByPath);
199
+ return target ? moduleKeyForFile(target) : null;
200
+ }
201
+
202
+ function collectGoEntrypoints(fileByPath, entrypoints) {
203
+ for (const [file, record] of fileByPath) {
204
+ if (!file.endsWith(".go") || path.posix.basename(file) !== "main.go") continue;
205
+ if (/^\s*package\s+main\b/mu.test(record.source ?? "")) entrypoints.add(file);
206
+ }
207
+ }
208
+
209
+ function selectRepresentativeFile(files, entrypoints) {
210
+ return [...files].sort(([leftPath, left], [rightPath, right]) =>
211
+ representativeScore(rightPath, right, entrypoints) - representativeScore(leftPath, left, entrypoints) ||
212
+ compareText(leftPath, rightPath))[0][0];
213
+ }
214
+
215
+ function representativeScore(file, record, entrypoints) {
216
+ let score = entrypoints.has(file) ? 100_000 : 0;
217
+ if (path.posix.basename(file) === "doc.go") score += 50_000;
218
+ score += countExportedDeclarations(record.source ?? "", file) * 1_000;
219
+ score += Math.min(999, Math.floor(record.sourceBytes / 100));
220
+ return score;
221
+ }
222
+
158
223
  export function auditHasFindings(result, minimumPriority = "none") {
159
224
  if (minimumPriority === "none") return false;
160
225
  if (!AUDIT_PRIORITIES.includes(minimumPriority)) {
package/src/boundaries.js CHANGED
@@ -50,7 +50,8 @@ export function detectBoundaries(record) {
50
50
  if (risks.includes("migration") || /(?:^|\/)(?:migrations?|migrate)(?:\/|$)/u.test(relativePath)) {
51
51
  add("migration", "high", risks.includes("migration") ? "risk" : "path");
52
52
  }
53
- if (/(?:^|\/)(?:commands?|cli|bin)(?:\/|$)/u.test(relativePath) || /(?:command|cmd)\.[^.]+$/u.test(basename)) {
53
+ if (/(?:^|\/)(?:cmd|commands?|cli|bin)(?:\/|$)/u.test(relativePath) || /(?:command|cmd)\.[^.]+$/u.test(basename) ||
54
+ (basename === "main.go" && /^\s*package\s+main\b/mu.test(source))) {
54
55
  add("command", "high", "path");
55
56
  }
56
57
  if (/\.rs$/u.test(relativePath) && /#\[tauri::command\]|tauri::generate_handler!/u.test(source)) {
package/src/graph.js CHANGED
@@ -10,6 +10,12 @@ stability=architecture
10
10
  */
11
11
 
12
12
  import path from "node:path";
13
+ import {
14
+ compatibleModuleResolution,
15
+ MODULE_RESOLVER_VERSION,
16
+ moduleKeyForFile,
17
+ resolveGoImportModule,
18
+ } from "./module-resolution.js";
13
19
  import { compareText, sha256, stableJson, stableStringify, toPosix } from "./util.js";
14
20
 
15
21
  export const GRAPH_SCHEMA_VERSION = 1;
@@ -23,11 +29,15 @@ export function buildRepositoryGraph(project, index) {
23
29
  export function buildRepositoryGraphIncremental(project, index, previousState = null) {
24
30
  const repositoryId = index.repositoryId;
25
31
  const cardsByPath = groupCardsByPath(index.cards);
26
- const resolutionHash = sha256(stableJson(
27
- [...cardsByPath.entries()]
32
+ const cardsByModule = groupCardsByModule(index.cards);
33
+ const moduleResolution = compatibleModuleResolution(project.moduleResolution);
34
+ const resolutionHash = sha256(stableJson({
35
+ resolverVersion: MODULE_RESOLVER_VERSION,
36
+ moduleResolution,
37
+ cardsByPath: [...cardsByPath.entries()]
28
38
  .sort(([left], [right]) => compareText(left, right))
29
39
  .map(([file, cards]) => [file, cards.map((card) => card.id).sort(compareText)]),
30
- ));
40
+ }));
31
41
  const previousPartitions = compatibleGraphState(previousState, repositoryId)
32
42
  ? new Map(previousState.partitions.map((partition) => [partition.key, partition]))
33
43
  : new Map();
@@ -50,7 +60,7 @@ export function buildRepositoryGraphIncremental(project, index, previousState =
50
60
  partitions.push(previous);
51
61
  reusedPartitions += 1;
52
62
  } else {
53
- partitions.push(buildCardPartition(key, inputHash, card, repositoryId, cardsByPath));
63
+ partitions.push(buildCardPartition(key, inputHash, card, repositoryId, cardsByPath, cardsByModule, moduleResolution));
54
64
  rebuiltPartitions += 1;
55
65
  }
56
66
  }
@@ -196,7 +206,7 @@ export function renderGraphNode(node) {
196
206
  return lines.join("\n");
197
207
  }
198
208
 
199
- function buildCardPartition(partitionKey, inputHash, card, repositoryId, cardsByPath) {
209
+ function buildCardPartition(partitionKey, inputHash, card, repositoryId, cardsByPath, cardsByModule, moduleResolution) {
200
210
  const nodes = new Map();
201
211
  const edges = new Map();
202
212
  const key = qualifyId(card.id, repositoryId);
@@ -231,7 +241,7 @@ function buildCardPartition(partitionKey, inputHash, card, repositoryId, cardsBy
231
241
  }
232
242
 
233
243
  for (const specifier of card.imports ?? []) {
234
- for (const target of resolveLocalImport(card.location.path, specifier, cardsByPath)) {
244
+ for (const target of resolveLocalImport(card.location.path, specifier, cardsByPath, cardsByModule, moduleResolution)) {
235
245
  addEdge(edges, nodes, {
236
246
  from: key,
237
247
  to: qualifyId(target.id, repositoryId),
@@ -378,7 +388,25 @@ function groupCardsByPath(cards) {
378
388
  return output;
379
389
  }
380
390
 
381
- function resolveLocalImport(sourcePath, specifier, cardsByPath) {
391
+ function groupCardsByModule(cards) {
392
+ const output = new Map();
393
+ for (const card of cards) {
394
+ const key = moduleKeyForFile(card.location.path);
395
+ const records = output.get(key) ?? [];
396
+ records.push(card);
397
+ output.set(key, records);
398
+ }
399
+ for (const records of output.values()) records.sort((left, right) => compareText(left.id, right.id));
400
+ return output;
401
+ }
402
+
403
+ function resolveLocalImport(sourcePath, specifier, cardsByPath, cardsByModule, moduleResolution) {
404
+ const goTarget = resolveGoImportModule(sourcePath, specifier, moduleResolution, new Set(cardsByModule.keys()));
405
+ if (goTarget) {
406
+ const cards = cardsByModule.get(goTarget) ?? [];
407
+ const moduleCards = cards.filter((card) => card.scope === "module");
408
+ return moduleCards.length > 0 ? moduleCards : cards;
409
+ }
382
410
  if (!specifier.startsWith(".")) return [];
383
411
  const base = path.posix.normalize(path.posix.join(path.posix.dirname(toPosix(sourcePath)), specifier));
384
412
  if (base.startsWith("../")) return [];
@@ -15,6 +15,7 @@ import { createDeclarationScanContext, findAttachedDeclaration, extractImports }
15
15
  import { collectSourceFiles } from "./files.js";
16
16
  import { parseLlmnavBlocks } from "./parser.js";
17
17
  import { loadRegistry } from "./registry.js";
18
+ import { loadModuleResolution } from "./module-resolution.js";
18
19
  import {
19
20
  assertNoSymlinkTraversal,
20
21
  atomicWrite,
@@ -119,6 +120,7 @@ export async function scanProjectIncremental(root, options = {}) {
119
120
  files: Object.fromEntries(Object.entries(nextHintFiles).sort(([left], [right]) => compareText(left, right))),
120
121
  };
121
122
  const registry = await loadRegistry(root);
123
+ const moduleResolution = await loadModuleResolution(root, fileRecords);
122
124
  const project = {
123
125
  root,
124
126
  config,
@@ -127,6 +129,7 @@ export async function scanProjectIncremental(root, options = {}) {
127
129
  fileRecords,
128
130
  records,
129
131
  registry,
132
+ moduleResolution,
130
133
  sourceBytes,
131
134
  semanticBytes,
132
135
  incremental: stats,
package/src/index.d.ts CHANGED
@@ -371,6 +371,11 @@ export interface ProjectRecord {
371
371
  declaration: Declaration | null;
372
372
  }
373
373
 
374
+ export interface ModuleResolution {
375
+ schemaVersion: 1;
376
+ goModules: Array<{ directory: string; modulePath: string }>;
377
+ }
378
+
374
379
  export interface ScannedProject {
375
380
  root: string;
376
381
  config: LlmnavConfig;
@@ -381,6 +386,7 @@ export interface ScannedProject {
381
386
  registry: Registry;
382
387
  sourceBytes: number;
383
388
  semanticBytes: number;
389
+ moduleResolution?: ModuleResolution;
384
390
  graphInputs?: GraphInputIndex[];
385
391
  }
386
392
 
@@ -0,0 +1,106 @@
1
+ /* llmnav/1 module
2
+ id=llmnav.structure.modules
3
+ role=Resolve repository-contained language modules without executing project toolchains or following metadata symlinks.
4
+ owns=language module identity|Go module discovery|Go package import resolution
5
+ excludes=dependency installation|external module lookup|semantic card generation
6
+ search=Go module import|package boundary|local module resolution
7
+ invariant=Module resolution reads only regular repository-contained metadata files and never executes a language toolchain.
8
+ rel=workflow>llmnav.project.scan
9
+ rel=workflow>llmnav.graph.generate
10
+ stability=architecture
11
+ */
12
+
13
+ import { lstat, readFile } from "node:fs/promises";
14
+ import path from "node:path";
15
+ import { compareText, toPosix } from "./util.js";
16
+
17
+ export const MODULE_RESOLUTION_SCHEMA_VERSION = 1;
18
+ export const MODULE_RESOLVER_VERSION = 1;
19
+
20
+ export async function loadModuleResolution(root, fileRecords = []) {
21
+ const directories = new Set([""]);
22
+ for (const record of fileRecords) {
23
+ const file = toPosix(record.relativePath ?? "");
24
+ if (!file.endsWith(".go")) continue;
25
+ let directory = path.posix.dirname(file);
26
+ while (directory !== "." && directory !== "") {
27
+ directories.add(directory);
28
+ const parent = path.posix.dirname(directory);
29
+ if (parent === directory || parent === ".") break;
30
+ directory = parent;
31
+ }
32
+ }
33
+
34
+ const goModules = [];
35
+ for (const directory of [...directories].sort(compareDirectories)) {
36
+ const relativeFile = directory ? `${directory}/go.mod` : "go.mod";
37
+ const absoluteFile = path.join(root, ...relativeFile.split("/"));
38
+ let details;
39
+ try {
40
+ details = await lstat(absoluteFile);
41
+ } catch (error) {
42
+ if (error && typeof error === "object" && error.code === "ENOENT") continue;
43
+ throw error;
44
+ }
45
+ if (details.isSymbolicLink()) throw new Error(`Go module file ${JSON.stringify(relativeFile)} is a symbolic link.`);
46
+ if (!details.isFile()) continue;
47
+ const modulePath = parseGoModulePath(await readFile(absoluteFile, "utf8"));
48
+ if (modulePath) goModules.push({ directory, modulePath });
49
+ }
50
+ goModules.sort((left, right) => compareText(left.directory, right.directory) || compareText(left.modulePath, right.modulePath));
51
+ return {
52
+ schemaVersion: MODULE_RESOLUTION_SCHEMA_VERSION,
53
+ goModules,
54
+ };
55
+ }
56
+
57
+ export function parseGoModulePath(source) {
58
+ for (const line of String(source).split(/\r?\n/u)) {
59
+ const match = /^\s*module\s+([^\s]+)\s*(?:\/\/.*)?$/u.exec(line);
60
+ if (!match) continue;
61
+ const value = match[1];
62
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("`") && value.endsWith("`"))) {
63
+ return value.slice(1, -1).trim() || null;
64
+ }
65
+ return value.trim() || null;
66
+ }
67
+ return null;
68
+ }
69
+
70
+ export function moduleKeyForFile(file) {
71
+ const normalized = toPosix(file);
72
+ if (path.posix.extname(normalized).toLowerCase() !== ".go") return `file:${normalized}`;
73
+ const directory = path.posix.dirname(normalized);
74
+ return `go:${directory === "." ? "." : directory}`;
75
+ }
76
+
77
+ export function resolveGoImportModule(sourcePath, specifier, resolution, availableModuleKeys = null) {
78
+ if (path.posix.extname(toPosix(sourcePath)).toLowerCase() !== ".go") return null;
79
+ const modules = compatibleModuleResolution(resolution).goModules
80
+ .filter((item) => specifier === item.modulePath || specifier.startsWith(`${item.modulePath}/`))
81
+ .sort((left, right) => right.modulePath.length - left.modulePath.length || compareText(left.modulePath, right.modulePath));
82
+ const selected = modules[0];
83
+ if (!selected) return null;
84
+ const suffix = specifier === selected.modulePath ? "" : specifier.slice(selected.modulePath.length + 1);
85
+ const directory = path.posix.normalize(path.posix.join(selected.directory || ".", suffix || "."));
86
+ if (directory === ".." || directory.startsWith("../")) return null;
87
+ const key = `go:${directory}`;
88
+ if (availableModuleKeys && !availableModuleKeys.has(key)) return null;
89
+ return key;
90
+ }
91
+
92
+ export function compatibleModuleResolution(value) {
93
+ if (!value || value.schemaVersion !== MODULE_RESOLUTION_SCHEMA_VERSION || !Array.isArray(value.goModules)) {
94
+ return { schemaVersion: MODULE_RESOLUTION_SCHEMA_VERSION, goModules: [] };
95
+ }
96
+ const goModules = value.goModules
97
+ .filter((item) => item && typeof item.directory === "string" && typeof item.modulePath === "string" && item.modulePath)
98
+ .map((item) => ({ directory: toPosix(item.directory), modulePath: item.modulePath }))
99
+ .sort((left, right) => compareText(left.directory, right.directory) || compareText(left.modulePath, right.modulePath));
100
+ return { schemaVersion: MODULE_RESOLUTION_SCHEMA_VERSION, goModules };
101
+ }
102
+
103
+ function compareDirectories(left, right) {
104
+ const depth = left.split("/").filter(Boolean).length - right.split("/").filter(Boolean).length;
105
+ return depth || compareText(left, right);
106
+ }
package/src/project.js CHANGED
@@ -16,6 +16,7 @@ import { createDeclarationScanContext, findAttachedDeclaration, extractImports }
16
16
  import { collectSourceFiles } from "./files.js";
17
17
  import { parseLlmnavBlocks } from "./parser.js";
18
18
  import { loadRegistry } from "./registry.js";
19
+ import { loadModuleResolution } from "./module-resolution.js";
19
20
  import { relativePosix, sha256 } from "./util.js";
20
21
 
21
22
  export async function scanProject(root, options = {}) {
@@ -64,6 +65,7 @@ export async function scanProject(root, options = {}) {
64
65
  }
65
66
 
66
67
  const registry = await loadRegistry(root);
68
+ const moduleResolution = await loadModuleResolution(root, fileRecords);
67
69
  return {
68
70
  root,
69
71
  config,
@@ -72,6 +74,7 @@ export async function scanProject(root, options = {}) {
72
74
  fileRecords,
73
75
  records,
74
76
  registry,
77
+ moduleResolution,
75
78
  sourceBytes,
76
79
  semanticBytes,
77
80
  };
package/src/spec.js CHANGED
@@ -10,7 +10,7 @@ rel=workflow>llmnav.rules.validate
10
10
  stability=contract
11
11
  */
12
12
 
13
- export const PACKAGE_VERSION = "0.7.2";
13
+ export const PACKAGE_VERSION = "0.7.4";
14
14
  export const SPEC_VERSION = "1";
15
15
 
16
16
  export const SCOPES = Object.freeze(["file", "module", "symbol"]);