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 +12 -0
- package/README.md +3 -1
- package/ROADMAP.md +1 -1
- package/docs/api.md +2 -0
- package/docs/architecture.md +4 -0
- package/docs/compatibility.md +40 -0
- package/docs/quickstart.md +2 -0
- package/package.json +1 -1
- 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.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.
|
|
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
|
|
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.
|
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.
|
|
@@ -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.
|
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
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.3";
|
|
14
14
|
export const SPEC_VERSION = "1";
|
|
15
15
|
|
|
16
16
|
export const SCOPES = Object.freeze(["file", "module", "symbol"]);
|