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 +12 -0
- package/docs/architecture.md +4 -0
- package/docs/publishing.md +4 -4
- package/docs/quickstart.md +2 -0
- package/package.json +2 -2
- package/src/audit.js +94 -29
- package/src/boundaries.js +2 -1
- package/src/graph.js +35 -7
- package/src/incremental.js +3 -0
- package/src/index.d.ts +6 -0
- package/src/module-resolution.js +106 -0
- package/src/project.js +3 -0
- package/src/spec.js +1 -1
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
|
package/docs/architecture.md
CHANGED
|
@@ -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.
|
package/docs/publishing.md
CHANGED
|
@@ -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
|
|
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
|
|
58
|
+
npm publish --provenance --access public
|
|
59
59
|
```
|
|
60
60
|
|
|
61
61
|
Automated publication:
|
|
62
62
|
|
|
63
63
|
```sh
|
|
64
|
-
git tag
|
|
65
|
-
git push origin
|
|
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.
|
package/docs/quickstart.md
CHANGED
|
@@ -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.
|
|
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":
|
|
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)\.[^/]
|
|
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
|
|
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
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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 [
|
|
55
|
-
if (
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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:
|
|
61
|
-
}).map((boundary) => boundary.kind);
|
|
62
|
-
const exportedDeclarations =
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
const
|
|
67
|
-
const
|
|
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 =
|
|
71
|
-
const largeSource =
|
|
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:
|
|
143
|
-
filesWithoutModuleCards: fileByPath.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
|
|
27
|
-
|
|
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
|
|
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 [];
|
package/src/incremental.js
CHANGED
|
@@ -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.
|
|
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"]);
|