llmnav 0.7.1 → 0.7.3

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.3] — 2026-08-14
10
+
11
+ ### Fixed
12
+
13
+ * 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.
14
+
15
+ ## [0.7.2] — 2026-08-13
16
+
17
+ ### Added
18
+
19
+ * Documented pre-1.0 stability classes, a one-minor and 90-day public API deprecation window, generated-format migration guarantees, and the limited security exception.
20
+
9
21
  ## [0.7.1] — 2026-08-13
10
22
 
11
23
  ### Fixed
package/README.md CHANGED
@@ -6,7 +6,7 @@ It adds compact, stable metadata to a small number of architectural and behavior
6
6
 
7
7
  LLMNav is not a documentation generator, an embedding database, or a reason to annotate every function. It is a zero-runtime-dependency Node.js CLI and ESM library for reducing broad repository scans, irrelevant context, stale hand-written links, repeated card tokenization, and avoidable cache invalidation.
8
8
 
9
- ## What v0.6 provides
9
+ ## What v0.7 provides
10
10
 
11
11
  * The backward-compatible `llmnav/1` source comment specification
12
12
  * A parser and data-loss-resistant canonical formatter
@@ -36,6 +36,8 @@ LLMNav is not a documentation generator, an embedding database, or a reason to a
36
36
 
37
37
  The package supports Node.js 22 or newer, uses ESM, performs no network requests, and has no runtime dependencies.
38
38
 
39
+ Public compatibility rules are documented in [Compatibility and deprecation policy](docs/compatibility.md). In short, `llmnav/1` source cards and the schemaVersion 1 primary index are stable contracts; disposable generated accelerators may be rebuilt, and documented CLI or library removals receive a replacement and a minimum one-minor/90-day deprecation window.
40
+
39
41
  ## Install
40
42
 
41
43
  ```sh
package/ROADMAP.md CHANGED
@@ -89,7 +89,7 @@ The source grammar and generated formats will be declared stable only after use
89
89
 
90
90
  In progress:
91
91
 
92
- * Generated-format migration tooling is implemented for the current public formats; compatibility guarantees still need a documented support and deprecation policy.
92
+ * Generated-format migration tooling and the public support and deprecation policy are implemented for the current formats.
93
93
  * A read-only cross-repository conformance matrix measures validation, retrieval, audit, repeatability, and cache freshness without averaging weak repositories away.
94
94
  * LLMNav, Workduck, Sairon, and AI BOM Generator currently pass repository-isolated conformance checks across JavaScript, TypeScript, Rust, Go, and Python.
95
95
  * The current evidence covers 4 repositories and 5 required languages with no held or failed repository, while 1.0 still requires sustained Windows and Linux verification and published benchmark methodology.
package/docs/api.md CHANGED
@@ -306,3 +306,5 @@ The typed `llmnav/examples/provider-neutral-host.mjs` export composes these APIs
306
306
  ## Compatibility boundary
307
307
 
308
308
  The public API follows package semantic versioning. `index.json` schemaVersion 1 and `llmnav/1` source syntax remain compatible. Contract fingerprints are optional additive index fields. `search-index.json`, `file-state.json`, `graph-state.json`, transaction journals, and performance metrics retain their own schema or implementation versions.
309
+
310
+ See [Compatibility and deprecation policy](compatibility.md) for the pre-1.0 stability classes, removal window, migration guarantees, and security exception.
@@ -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.
@@ -0,0 +1,40 @@
1
+ # Compatibility and deprecation policy
2
+
3
+ This policy applies to LLMNav releases before 1.0 and defines which surfaces are durable enough for repositories and integrations to depend on.
4
+
5
+ ## Stability classes
6
+
7
+ | Surface | Stability before 1.0 | Compatibility rule |
8
+ | --- | --- | --- |
9
+ | `llmnav/1` source cards | Stable protocol | Existing valid cards keep their meaning. A breaking grammar or semantic change requires a new protocol header such as `llmnav/2`; LLMNav will not reinterpret `llmnav/1` incompatibly. |
10
+ | `.llmnav/cache/index.json` schemaVersion 1 | Stable primary generated contract | Additive optional fields are allowed. Removing or changing an existing field requires a new schema version and a migration path. |
11
+ | CLI commands, flags, exit codes, JSON fields, ESM exports, and TypeScript declarations documented in this repository | SemVer public API | Compatible additions may ship in a minor release. Removal or incompatible behavior requires a minor release before 1.0, a documented replacement, and the deprecation window below. |
12
+ | Diagnostic codes | Stable identifiers | A code may gain clearer wording, but its documented category and remediation meaning remain compatible. Retiring a code follows the deprecation window. |
13
+ | Search ranking and performance | Behavioral contract | Determinism and published regression thresholds are protected. Exact scores or ordering may change in a minor release when benchmarks justify the change and the changelog explains it. |
14
+ | `search-index.json`, `file-state.json`, `graph-state.json`, prompt bundles, shards, transaction journals, locks, and other generated accelerators | Disposable versioned formats | Consumers must not treat these as source truth. LLMNav may replace an incompatible format after `migrate --check`; it must rebuild or fail closed rather than silently misread it. |
15
+ | Undocumented internals and test helpers | Unstable | They may change in any release. Importing source files outside the documented package exports is unsupported. |
16
+
17
+ Package versions follow Semantic Versioning. Before 1.0, an incompatible change to a documented package API may use a minor version, while patches remain backward compatible. Protocol and generated-schema versions are independent from the npm package version.
18
+
19
+ ## Deprecation window
20
+
21
+ For a documented CLI, JSON, ESM, or TypeScript surface:
22
+
23
+ 1. The introducing release documents the deprecated surface, its replacement, and any automated migration.
24
+ 2. The old surface remains functional for at least one subsequent minor release and for at least 90 days. Removal waits until both conditions are satisfied.
25
+ 3. `llmnav doctor`, `check`, or `migrate --check` reports a stable diagnostic when repository action is required. A warning must not silently become destructive behavior.
26
+ 4. The removal release records the change in `CHANGELOG.md` and `docs/migration.md`.
27
+
28
+ Immediate removal is reserved for an actively exploitable security issue or behavior that can corrupt source or repository state. The security release must explain the exception and provide the safest available migration.
29
+
30
+ ## Upgrade guarantees
31
+
32
+ * Canonical source cards and `.llmnav/ids.jsonl` remain the recovery authority; disposable caches do not.
33
+ * `migrate --check` is read-only. `migrate --write` validates canonical source before mutation and publishes a complete recoverable cache transaction.
34
+ * A supported upgrade path covers the latest release of the previous minor line to the current release. Skipping several minor lines may require running the newest migration tool directly, but must not require installing every intermediate package version.
35
+ * Downgrade compatibility is not guaranteed for generated caches. Restore caches by regenerating them with the target version; never hand-edit generated schema versions.
36
+ * Node.js runtime support follows the declared `engines` range. Dropping a supported Node.js major is an incompatible documented API change and follows the deprecation window unless that runtime is no longer receiving security updates.
37
+
38
+ ## 1.0 commitment
39
+
40
+ At 1.0, incompatible changes to documented package APIs require a new major package version. The `llmnav/1` and primary-index rules above already apply and do not wait for 1.0. The remaining 1.0 gates are sustained cross-platform evidence, published benchmark methodology, and closure of high-severity parser or transaction ambiguity.
@@ -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.1",
3
+ "version": "0.7.3",
4
4
  "description": "A deterministic semantic navigation layer for LLM coding agents.",
5
5
  "type": "module",
6
6
  "bin": {
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.1";
13
+ export const PACKAGE_VERSION = "0.7.3";
14
14
  export const SPEC_VERSION = "1";
15
15
 
16
16
  export const SCOPES = Object.freeze(["file", "module", "symbol"]);