@dotdotgod/cli 0.6.1 → 0.6.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/README.md +5 -5
- package/package.json +4 -2
- package/src/cli/usage.mjs +2 -2
- package/src/commands/query.mjs +37 -5
- package/src/core.mjs +2 -2
- package/src/index/cache.mjs +17 -3
- package/src/query/hybrid.mjs +62 -0
- package/src/symbols/discovery.mjs +55 -0
- package/src/symbols/graph.mjs +28 -0
- package/src/symbols/languages.mjs +29 -0
- package/src/symbols/parser.mjs +127 -0
- package/src/symbols/runtime.mjs +23 -0
- package/src/symbols/store.mjs +92 -0
- package/src/validate/run.mjs +6 -0
package/README.md
CHANGED
|
@@ -4,13 +4,13 @@
|
|
|
4
4
|
|
|
5
5
|
Command-line tools for dotdotgod project memory.
|
|
6
6
|
|
|
7
|
-
Use this package when you want to initialize a docs-first project scaffold, validate project-memory docs, build a local graph/cache, search
|
|
7
|
+
Use this package when you want to initialize a docs-first project scaffold, validate project-memory docs, build a local graph/cache, search full documentation and code outlines locally, expand project references, or ask what should be checked after a file changes.
|
|
8
8
|
|
|
9
9
|
These commands support dotdotgod's project-memory loop with initialization, documentation query, validation, and impact analysis for shells, scripts, CI, and agents that use the CLI directly.
|
|
10
10
|
|
|
11
11
|
## What Changes
|
|
12
12
|
|
|
13
|
-
- **Repository knowledge becomes navigable memory.**
|
|
13
|
+
- **Repository knowledge becomes navigable memory.** Hybrid query finds full-document and code-outline/comment evidence and keeps the cache under `.dotdotgod/`.
|
|
14
14
|
- **Project references become actionable context.** `resolve` and `expand` connect explicit `[[...]]` references and high-signal natural-language requests to maintained files; `--with-impact` adds related evidence.
|
|
15
15
|
- **Changes carry their verification neighborhood.** Multi-file graph impact combines structural, traceability, package, and optional semantic evidence while preserving a per-file explanation.
|
|
16
16
|
- **Large repositories reveal natural domains.** `graph communities` groups the maintained project graph to help teams inspect documentation and source boundaries.
|
|
@@ -43,9 +43,9 @@ dotdotgod graph serve . --changed <path>
|
|
|
43
43
|
## What It Does
|
|
44
44
|
|
|
45
45
|
- `init` creates `AGENTS.md`, thin agent entrypoints, docs indexes, active-plan space, archive map, local cache ignores, and the complete editable default config.
|
|
46
|
-
- `validate` checks
|
|
47
|
-
- `index` builds `.dotdotgod/
|
|
48
|
-
- `query`
|
|
46
|
+
- `validate` checks docs/project-memory rules; `--check-index` also checks graph and outline freshness without parsing, embedding or repair.
|
|
47
|
+
- `index` builds graph shards and `.dotdotgod/outlines.json` with source hashes, declarations, comments and exact ranges.
|
|
48
|
+
- `query` defaults to hybrid vector/keyword search over full Markdown and code outlines/comments, not implementation bodies. Use `--scope docs|code|all`, `--search hybrid|keyword|vector` and `--limit 30`. Keyword mode works without embeddings; hybrid reports vector failures and retains keyword results. Remote code-metadata embedding requires `--allow-code-embedding`. Local E5 remains the default when its optional runtime is installed. Tree-sitter coverage and boundaries: [CODE_OUTLINES.md](../../docs/spec/cli/CODE_OUTLINES.md).
|
|
49
49
|
- `resolve` and `expand` map explicit or high-signal prompt references to project files and can include related impact evidence.
|
|
50
50
|
- `graph impact` ranks likely related specs, tests, docs, commands, and source files for one or more changed paths, with a combined ranking and per-file top five. Non-seed scores use fixed weighted PPR connection `80` plus memory policy `20`. A bounded request-local multilingual vector overlay participates in PPR when the query cache is available; vector preparation failures degrade to structural-only results.
|
|
51
51
|
- `graph serve` starts a local read-only explorer rooted at one or more changed files. It keeps only connected shared structural nodes, lays them out by minimum structural hop, and supports persisted instant, layer, or one-by-one reveal modes.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotdotgod/cli",
|
|
3
|
-
"version": "0.6.
|
|
4
|
-
"description": "CLI for dotdotgod validation,
|
|
3
|
+
"version": "0.6.3",
|
|
4
|
+
"description": "CLI for dotdotgod validation, hybrid document/code-outline search, and graph indexing.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Elastic-2.0",
|
|
7
7
|
"publishConfig": {
|
|
@@ -29,8 +29,10 @@
|
|
|
29
29
|
"LICENSE"
|
|
30
30
|
],
|
|
31
31
|
"dependencies": {
|
|
32
|
+
"@vscode/tree-sitter-wasm": "0.3.1",
|
|
32
33
|
"d3": "^7.9.0",
|
|
33
34
|
"graphology": "^0.26.0",
|
|
35
|
+
"ignore": "7.0.5",
|
|
34
36
|
"leiden-ts": "^0.1.0",
|
|
35
37
|
"sigma": "^3.0.3"
|
|
36
38
|
},
|
package/src/cli/usage.mjs
CHANGED
|
@@ -33,9 +33,9 @@ Create dotdotgod.config.json from a bundled or user template. Without --template
|
|
|
33
33
|
dotdotgod status <root> [--json]`;
|
|
34
34
|
case 'query':
|
|
35
35
|
return `Usage:
|
|
36
|
-
dotdotgod query <root> <query> [--limit n] [--json]
|
|
36
|
+
dotdotgod query <root> <query> [--limit n] [--scope docs|code|all] [--search hybrid|keyword|vector] [--allow-code-embedding] [--json]
|
|
37
37
|
|
|
38
|
-
Search
|
|
38
|
+
Search full documentation and code outlines/comments. Default: hybrid docs+code. Remote code metadata requires explicit consent.`;
|
|
39
39
|
case 'embedding':
|
|
40
40
|
return `Usage:
|
|
41
41
|
dotdotgod embedding status [<root>] [--json]
|
package/src/commands/query.mjs
CHANGED
|
@@ -5,17 +5,22 @@ import { collectDocumentationChunks } from '../query/chunks.mjs';
|
|
|
5
5
|
import { resolveEmbedder } from '../query/embedder.mjs';
|
|
6
6
|
import { isValidNormalizedVectorValues, profileFingerprint, readVectorCache, writeVectorCache } from '../query/store.mjs';
|
|
7
7
|
|
|
8
|
+
import { projectCorpus, keywordCandidates, fuseCandidates, attachRelationships, assertCodeEmbeddingConsent } from '../query/hybrid.mjs';
|
|
9
|
+
|
|
8
10
|
const DEFAULT_LIMIT = 30;
|
|
9
11
|
const MAX_LIMIT = 100;
|
|
10
12
|
const EMBED_BATCH_SIZE = 16;
|
|
11
13
|
|
|
12
14
|
export function parseQueryOptions(argv) {
|
|
13
|
-
const options = { root: '.', query: '', limit: DEFAULT_LIMIT, json: false };
|
|
15
|
+
const options = { root: '.', query: '', limit: DEFAULT_LIMIT, json: false, scope: 'all', search: 'hybrid', allowCodeEmbedding: false };
|
|
14
16
|
let rootSet = false;
|
|
15
17
|
const queryParts = [];
|
|
16
18
|
for (let i = 0; i < argv.length; i += 1) {
|
|
17
19
|
const arg = argv[i];
|
|
18
20
|
if (arg === '--json') options.json = true;
|
|
21
|
+
else if (arg === '--scope') { options.scope = argv[++i]; if (!['docs', 'code', 'all'].includes(options.scope)) usage('--scope must be docs, code or all.', 'query'); }
|
|
22
|
+
else if (arg === '--search') { options.search = argv[++i]; if (!['hybrid', 'keyword', 'vector'].includes(options.search)) usage('--search must be hybrid, keyword or vector.', 'query'); }
|
|
23
|
+
else if (arg === '--allow-code-embedding') options.allowCodeEmbedding = true;
|
|
19
24
|
else if (arg === '--limit') { const value = Number(argv[++i]); if (!Number.isInteger(value) || value < 1 || value > MAX_LIMIT) usage(`--limit must be an integer from 1 to ${MAX_LIMIT}.`, 'query'); options.limit = value; }
|
|
20
25
|
else if (arg.startsWith('-')) usage(`Unknown option: ${arg}`, 'query');
|
|
21
26
|
else if (!rootSet) { options.root = resolve(arg); rootSet = true; }
|
|
@@ -63,7 +68,7 @@ export async function buildVectorIndex(root, embedOrOptions = {}) {
|
|
|
63
68
|
const resolved = await resolveEmbedder(root, options);
|
|
64
69
|
const config = readMemoryConfig(root);
|
|
65
70
|
const exclude = config.load?.documentationSummary?.exclude ?? ['docs/plan', 'docs/archive'];
|
|
66
|
-
const chunks = collectDocumentationChunks(root, exclude, config.documentation?.root ?? 'docs');
|
|
71
|
+
const chunks = options.chunks ?? collectDocumentationChunks(root, exclude, config.documentation?.root ?? 'docs');
|
|
67
72
|
const cached = readVectorCache(root, resolved.identity);
|
|
68
73
|
const cachedOffsets = new Map((cached?.chunks ?? []).map((chunk, index) => [chunk.fingerprint, index]));
|
|
69
74
|
const rows = new Array(chunks.length);
|
|
@@ -95,14 +100,41 @@ export async function queryDocumentation(root, query, options = {}) {
|
|
|
95
100
|
return { ok: true, command: 'query', root, query, provider: index.embedding.profile.provider, model: index.embedding.profile.model, embeddingSource: index.embedding.source, dimensions, limit, index: index.manifest, results };
|
|
96
101
|
}
|
|
97
102
|
|
|
103
|
+
export async function queryProject(root, query, options = {}) {
|
|
104
|
+
const scope = options.scope ?? 'all';
|
|
105
|
+
const search = options.search ?? 'hybrid';
|
|
106
|
+
const limit = options.limit ?? DEFAULT_LIMIT;
|
|
107
|
+
const corpus = await projectCorpus(root, scope);
|
|
108
|
+
const keyword = search === 'vector' ? [] : keywordCandidates(query, corpus.chunks);
|
|
109
|
+
let vector = []; let index; let vectorError;
|
|
110
|
+
if (search !== 'keyword' && corpus.chunks.length) {
|
|
111
|
+
try {
|
|
112
|
+
assertCodeEmbeddingConsent(root, options, corpus.chunks);
|
|
113
|
+
index = await buildVectorIndex(root, { ...options, chunks: corpus.chunks });
|
|
114
|
+
const [queryVector] = await index.embedding.embed([`query: ${query}`]);
|
|
115
|
+
vector = index.chunks.map((chunk, position) => ({ ...chunk, vectorScore: cosineScore(queryVector, index.vectors, position * index.manifest.dimensions, index.manifest.dimensions) }))
|
|
116
|
+
.sort((a, b) => b.vectorScore - a.vectorScore || a.id.localeCompare(b.id));
|
|
117
|
+
} catch (error) {
|
|
118
|
+
if (search === 'vector') throw error;
|
|
119
|
+
vectorError = error instanceof Error ? error.message : String(error);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const results = attachRelationships(fuseCandidates(keyword, vector, limit), corpus.outlines).map(({ passage, ...result }) => result);
|
|
123
|
+
return { ok: true, command: 'query', root, query, scope, search, limit,
|
|
124
|
+
provider: index?.embedding.profile.provider ?? null, model: index?.embedding.profile.model ?? null,
|
|
125
|
+
embeddingSource: index?.embedding.source ?? 'not-used', dimensions: index?.manifest.dimensions ?? null,
|
|
126
|
+
index: index?.manifest ?? null, outlines: corpus.outlines ? { refresh: corpus.outlines.refresh, indexedFiles: corpus.outlines.files.length, diagnostics: corpus.outlines.files.filter((file) => file.status !== 'parsed').map(({ path, status }) => ({ path, status })) } : null,
|
|
127
|
+
warnings: [...(vectorError ? [`Vector retrieval unavailable; keyword results only: ${vectorError}`] : []), ...(corpus.outlines?.files.filter((file) => file.status !== 'parsed').map((file) => `Outline ${file.status}: ${file.path}`) ?? [])], results };
|
|
128
|
+
}
|
|
129
|
+
|
|
98
130
|
function formatQueryResult(payload) {
|
|
99
|
-
const lines = [`dotdotgod query: ${payload.query}`, `- embedding: ${payload.provider}/${payload.model} (${payload.embeddingSource})`, `- results: ${payload.results.length}
|
|
100
|
-
payload.results.forEach((result, index) => { lines.push(`${index + 1}. ${result.path}${result.heading ? ` — ${result.heading}` : ''} (${result.score.toFixed(3)})`); const excerpt = result.text.replace(/\s+/g, ' ').trim().slice(0, 240); if (excerpt) lines.push(` ${excerpt}${result.text.length > 240 ? '…' : ''}`); });
|
|
131
|
+
const lines = [`dotdotgod query: ${payload.query}`, `- embedding: ${payload.provider ? `${payload.provider}/${payload.model}` : 'not used'} (${payload.embeddingSource})`, `- results: ${payload.results.length}`, ...(payload.warnings ?? [])];
|
|
132
|
+
payload.results.forEach((result, index) => { lines.push(`${index + 1}. ${result.path}${result.startLine ? `:${result.startLine}–${result.endLine}` : ''}${result.heading ? ` — ${result.heading}` : ''} (${result.score.toFixed(3)})`); const excerpt = result.text.replace(/\s+/g, ' ').trim().slice(0, 240); if (excerpt) lines.push(` ${excerpt}${result.text.length > 240 ? '…' : ''}`); });
|
|
101
133
|
return lines.join('\n');
|
|
102
134
|
}
|
|
103
135
|
|
|
104
136
|
export async function runQuery(argv) {
|
|
105
137
|
const options = parseQueryOptions(argv);
|
|
106
|
-
try { const payload = await
|
|
138
|
+
try { const payload = await queryProject(options.root, options.query, options); console.log(options.json ? JSON.stringify(payload, null, 2) : formatQueryResult(payload)); }
|
|
107
139
|
catch (error) { const message = `dotdotgod query failed: ${error instanceof Error ? error.message : String(error)}`; if (options.json) console.log(JSON.stringify({ ok: false, command: 'query', root: options.root, query: options.query, error: message }, null, 2)); else console.error(message); process.exitCode = 1; }
|
|
108
140
|
}
|
package/src/core.mjs
CHANGED
|
@@ -35,7 +35,7 @@ export { buildVectorImpactOverlay, VECTOR_RELATION_WEIGHT } from './impact/vecto
|
|
|
35
35
|
export { formatCompactImpactOutput, formatYmlGraphImpactError, formatYmlImpactOutput } from './impact/format.mjs';
|
|
36
36
|
export { runConfig } from './commands/config.mjs';
|
|
37
37
|
export { runMap } from './commands/map.mjs';
|
|
38
|
-
export { buildVectorIndex, cosineScore, parseQueryOptions, queryDocumentation, rankVectorFiles, runQuery } from './commands/query.mjs';
|
|
38
|
+
export { buildVectorIndex, cosineScore, parseQueryOptions, queryDocumentation, queryProject, rankVectorFiles, runQuery } from './commands/query.mjs';
|
|
39
39
|
export { runEmbedding } from './commands/embedding.mjs';
|
|
40
40
|
export { EmbeddingRuntimeMissingError, embeddingRuntimeRoot, embeddingRuntimeStatus, installEmbeddingRuntime, resolvePersistentTransformers } from './query/embedding-runtime.mjs';
|
|
41
41
|
export { chunkMarkdown, collectDocumentationChunks, collectDocumentationMarkdown, textFingerprint } from './query/chunks.mjs';
|
|
@@ -55,7 +55,7 @@ export async function runCli(argv = process.argv.slice(2)) {
|
|
|
55
55
|
if (hasHelpToken(args)) usage('', helpCommandFromArgs([command, ...args]));
|
|
56
56
|
if (command === 'validate') runValidate(args);
|
|
57
57
|
else if (command === 'init') runInit(args, usage);
|
|
58
|
-
else if (command === 'index') runIndex(args);
|
|
58
|
+
else if (command === 'index') await runIndex(args);
|
|
59
59
|
else if (command === 'config') runConfig(args);
|
|
60
60
|
else if (command === 'status') runStatus(args);
|
|
61
61
|
else if (command === 'query') await runQuery(args);
|
package/src/index/cache.mjs
CHANGED
|
@@ -7,6 +7,9 @@ import { addEdge, addNode, compactGraph, expandGraph, graphStats, jsonSize, shar
|
|
|
7
7
|
import { cacheFile, collectIndexFiles, fingerprint } from './files.mjs';
|
|
8
8
|
import { CACHE_VERSION } from './constants.mjs';
|
|
9
9
|
import { buildGraph } from '../graph/extract.mjs';
|
|
10
|
+
import { buildOutlines, readOutlines } from '../symbols/store.mjs';
|
|
11
|
+
import { outlinePolicy } from '../symbols/discovery.mjs';
|
|
12
|
+
import { addOutlineGraph } from '../symbols/graph.mjs';
|
|
10
13
|
|
|
11
14
|
function collectFingerprints(root, config = readMemoryConfig(root)) {
|
|
12
15
|
return collectIndexFiles(root, config).map((file) => {
|
|
@@ -44,7 +47,17 @@ export function buildIndex(root, previous = readIndex(root)) {
|
|
|
44
47
|
const fullRebuild = !previous?.graph || previous.version !== CACHE_VERSION;
|
|
45
48
|
const refreshReason = !previous ? 'missing' : previous.version !== CACHE_VERSION ? 'schema-mismatch' : removedPaths.length > 0 ? 'content-removed' : changedPaths.length > 0 ? 'content-changed' : 'fresh';
|
|
46
49
|
const rawGraph = fullRebuild ? buildGraph(root, files.map((file) => join(root, file.path)), memoryConfig) : mergeIncrementalGraph(previous.graph, buildGraph(root, changedFiles, memoryConfig), changedPaths);
|
|
47
|
-
const graph = rawGraph;
|
|
50
|
+
const graph = { nodes: rawGraph.nodes.filter((node) => !['symbol', 'unresolved_import'].includes(node.type)), edges: rawGraph.edges.filter((edge) => !['declares_symbol', 'contains_symbol', 'imports'].includes(edge.relation)) };
|
|
51
|
+
const outlines = readOutlines(root);
|
|
52
|
+
let outlinePolicyMatches = false;
|
|
53
|
+
if (outlines) {
|
|
54
|
+
try { outlinePolicyMatches = outlines.policy === outlinePolicy(root); }
|
|
55
|
+
catch { /* Outline runtime unavailable: preserve the existing structural graph path. */ }
|
|
56
|
+
}
|
|
57
|
+
if (outlinePolicyMatches) {
|
|
58
|
+
const hashes = new Map(files.map((file) => [file.path, file.sha256]));
|
|
59
|
+
addOutlineGraph(graph, { files: outlines.files.filter((file) => hashes.get(file.path) === file.hash && file.status === 'parsed') });
|
|
60
|
+
}
|
|
48
61
|
const archiveBodiesIncluded = (memoryConfig.areas ?? []).some((area) => area.id === 'archive-body' && area.includeBodiesByDefault !== false);
|
|
49
62
|
return { version: CACHE_VERSION, schemaVersion: CACHE_VERSION, generatedAt: new Date().toISOString(), archiveBodiesIncluded, memoryConfig: memoryConfigSummary(memoryConfig), files, graph, stats: graphStats(graph), incremental: { enabled: true, fullRebuild, changedFiles: changedPaths.length, refreshReason, elapsedMs: Date.now() - startedAt } };
|
|
50
63
|
}
|
|
@@ -92,11 +105,12 @@ export function getStatus(root) {
|
|
|
92
105
|
return { ok, status: ok ? 'fresh' : 'stale', cachePath: rel(root, cacheFile(root)), indexedFiles: index.files?.length ?? 0, currentFiles: currentFiles.length, staleFiles: staleFiles.length, examples: staleFiles.slice(0, 10), archiveBodiesIncluded: index.archiveBodiesIncluded === true, schemaVersion, expectedSchemaVersion: CACHE_VERSION, schemaOk, reason, graph: graphStats(index.graph ?? { nodes: [], edges: [] }) };
|
|
93
106
|
}
|
|
94
107
|
|
|
95
|
-
export function runIndex(argv) {
|
|
108
|
+
export async function runIndex(argv) {
|
|
96
109
|
const options = parseCommon(argv);
|
|
110
|
+
const outlines = await buildOutlines(options.root);
|
|
97
111
|
const index = buildIndex(options.root);
|
|
98
112
|
const manifest = writeIndex(options.root, index);
|
|
99
|
-
const result = { ok: true, cachePath: rel(options.root, cacheFile(options.root)), schemaVersion: CACHE_VERSION, indexedFiles: index.files.length, nodes: index.graph.nodes.length, edges: index.graph.edges.length, indexSizeBytes: manifest.indexSizeBytes, shards: manifest.graph.shards, incremental: index.incremental, archiveBodiesIncluded: index.archiveBodiesIncluded === true, memoryConfig: index.memoryConfig };
|
|
113
|
+
const result = { ok: true, cachePath: rel(options.root, cacheFile(options.root)), schemaVersion: CACHE_VERSION, indexedFiles: index.files.length, outlines: { refresh: outlines.refresh, unsupported: outlines.files.filter((file) => file.status === 'unsupported').map((file) => file.path), partial: outlines.files.filter((file) => file.status === 'partial').map((file) => file.path) }, nodes: index.graph.nodes.length, edges: index.graph.edges.length, indexSizeBytes: manifest.indexSizeBytes, shards: manifest.graph.shards, incremental: index.incremental, archiveBodiesIncluded: index.archiveBodiesIncluded === true, memoryConfig: index.memoryConfig };
|
|
100
114
|
if (options.json) console.log(JSON.stringify(result, null, 2));
|
|
101
115
|
else console.log(`✅ index written (${result.indexedFiles} files, ${result.nodes} nodes, ${result.edges} edges, ${(result.indexSizeBytes / 1024).toFixed(1)} KiB, cache: ${result.cachePath})`);
|
|
102
116
|
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { collectDocumentationChunks } from './chunks.mjs';
|
|
2
|
+
import { readMemoryConfig } from '../memory/config.mjs';
|
|
3
|
+
import { resolveEmbeddingProfile } from './embedding-config.mjs';
|
|
4
|
+
import { buildOutlines, outlineChunks } from '../symbols/store.mjs';
|
|
5
|
+
import { addOutlineGraph } from '../symbols/graph.mjs';
|
|
6
|
+
|
|
7
|
+
export const resultKey = (chunk) => chunk.kind === 'symbol' ? chunk.id : chunk.path;
|
|
8
|
+
const terms = (text) => [...new Set(text.replace(/([a-z])([A-Z])/g, '$1 $2').toLowerCase().split(/[^\p{L}\p{N}]+/u).filter(Boolean))];
|
|
9
|
+
export function keywordCandidates(query, chunks) {
|
|
10
|
+
const words = terms(query);
|
|
11
|
+
if (!words.length) return [];
|
|
12
|
+
const literal = query.toLowerCase();
|
|
13
|
+
return chunks.map((chunk) => {
|
|
14
|
+
const names = `${chunk.qualifiedName ?? ''} ${chunk.heading ?? ''} ${chunk.path}`.toLowerCase();
|
|
15
|
+
const body = `${chunk.signature ?? ''} ${chunk.documentation ?? chunk.text}`.toLowerCase();
|
|
16
|
+
const haystack = new Set(terms(`${names} ${body}`));
|
|
17
|
+
const matched = words.filter((word) => haystack.has(word)).length;
|
|
18
|
+
const exact = chunk.qualifiedName?.toLowerCase() === literal || chunk.name?.toLowerCase() === literal;
|
|
19
|
+
return { ...chunk, exactNameMatch: Boolean(exact), keywordScore: exact ? 3 : matched / words.length + (names.includes(literal) ? 0.5 : body.includes(literal) ? 0.25 : 0) };
|
|
20
|
+
}).filter((chunk) => chunk.keywordScore > 0).sort((a, b) => b.keywordScore - a.keywordScore || a.id.localeCompare(b.id));
|
|
21
|
+
}
|
|
22
|
+
export function fuseCandidates(keyword, vector, limit) {
|
|
23
|
+
const candidates = new Map();
|
|
24
|
+
for (const [channel, rows] of [['keyword', keyword], ['vector', vector]]) {
|
|
25
|
+
const seenIds = new Set(); let rank = 0;
|
|
26
|
+
rows.forEach((chunk) => {
|
|
27
|
+
if (seenIds.has(chunk.id)) return;
|
|
28
|
+
seenIds.add(chunk.id); rank += 1;
|
|
29
|
+
const existing = candidates.get(chunk.id) ?? { ...chunk, score: 0, retrieval: [] };
|
|
30
|
+
existing.score += 1 / (60 + rank);
|
|
31
|
+
existing.retrieval.push(channel);
|
|
32
|
+
if (chunk.vectorScore !== undefined) existing.vectorScore = chunk.vectorScore;
|
|
33
|
+
if (chunk.keywordScore !== undefined) existing.keywordScore = chunk.keywordScore;
|
|
34
|
+
candidates.set(chunk.id, existing);
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
const seen = new Set();
|
|
38
|
+
return [...candidates.values()].sort((a, b) => Number(b.exactNameMatch ?? false) - Number(a.exactNameMatch ?? false) || b.score - a.score || a.id.localeCompare(b.id)).filter((chunk) => {
|
|
39
|
+
const key = resultKey(chunk);
|
|
40
|
+
if (seen.has(key)) return false;
|
|
41
|
+
seen.add(key); return true;
|
|
42
|
+
}).slice(0, limit);
|
|
43
|
+
}
|
|
44
|
+
export async function projectCorpus(root, scope) {
|
|
45
|
+
const config = readMemoryConfig(root);
|
|
46
|
+
const docs = scope === 'code' ? [] : collectDocumentationChunks(root, config.load?.documentationSummary?.exclude, config.documentation?.root ?? 'docs').map((chunk) => ({ ...chunk, kind: 'document' }));
|
|
47
|
+
const outlines = scope === 'docs' ? null : await buildOutlines(root);
|
|
48
|
+
return { chunks: [...docs, ...(outlines ? outlineChunks(outlines) : [])], outlines };
|
|
49
|
+
}
|
|
50
|
+
export function assertCodeEmbeddingConsent(root, options, chunks) {
|
|
51
|
+
if (!chunks.some((chunk) => chunk.kind === 'symbol')) return;
|
|
52
|
+
const profile = options.profile ?? resolveEmbeddingProfile(root).profile;
|
|
53
|
+
if (profile.provider !== 'local' && options.allowCodeEmbedding !== true) throw new Error('Remote code-metadata embedding requires --allow-code-embedding; use --search keyword or --scope docs instead.');
|
|
54
|
+
}
|
|
55
|
+
export function attachRelationships(results, outlines) {
|
|
56
|
+
if (!outlines) return results;
|
|
57
|
+
const graph = addOutlineGraph({ nodes: [], edges: [] }, outlines);
|
|
58
|
+
return results.map((result) => {
|
|
59
|
+
const relationships = graph.edges.filter((edge) => edge.source === result.id || edge.target === result.id || (edge.source === `file:${result.path}` && edge.relation === 'imports'));
|
|
60
|
+
return { ...result, relationshipCount: relationships.length, relationships: relationships.slice(0, 10) };
|
|
61
|
+
});
|
|
62
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { existsSync, lstatSync, readFileSync, realpathSync } from 'node:fs';
|
|
2
|
+
import { extname, join, relative, isAbsolute } from 'node:path';
|
|
3
|
+
import ignore from 'ignore';
|
|
4
|
+
import { collectIndexFiles, fingerprint } from '../index/files.mjs';
|
|
5
|
+
import { readMemoryConfig, isSecretLikePathPattern } from '../memory/config.mjs';
|
|
6
|
+
import { textFingerprint } from '../query/chunks.mjs';
|
|
7
|
+
import { EXTRACTOR_ID, DECLARATIONS, LANGUAGE_EXTENSIONS, languageForPath, SOURCE_EXTENSIONS } from './languages.mjs';
|
|
8
|
+
import { parserIdentity } from './runtime.mjs';
|
|
9
|
+
|
|
10
|
+
function inside(root, file) {
|
|
11
|
+
if (!lstatSync(file).isFile()) return false;
|
|
12
|
+
const path = relative(realpathSync(root), realpathSync(file));
|
|
13
|
+
return !path.startsWith('..') && !isAbsolute(path);
|
|
14
|
+
}
|
|
15
|
+
function ignoreRules(root, directory, cache) {
|
|
16
|
+
if (!cache.has(directory)) {
|
|
17
|
+
const file = join(root, directory, '.gitignore');
|
|
18
|
+
cache.set(directory, ignore().add(existsSync(file) && inside(root, file) ? readFileSync(file, 'utf8') : ''));
|
|
19
|
+
}
|
|
20
|
+
return cache.get(directory);
|
|
21
|
+
}
|
|
22
|
+
function ignored(root, path, cache) {
|
|
23
|
+
const parts = path.split('/');
|
|
24
|
+
let excluded = false;
|
|
25
|
+
for (let depth = 0; depth < parts.length; depth += 1) {
|
|
26
|
+
const directory = parts.slice(0, depth).join('/');
|
|
27
|
+
const remaining = parts.slice(depth).join('/');
|
|
28
|
+
const rules = ignoreRules(root, directory, cache);
|
|
29
|
+
// An ignored directory cannot be re-included by a rule inside it.
|
|
30
|
+
for (let end = 1; end < parts.length - depth; end += 1) {
|
|
31
|
+
if (rules.ignores(parts.slice(depth, depth + end).join('/') + '/')) return true;
|
|
32
|
+
}
|
|
33
|
+
const result = rules.test(remaining);
|
|
34
|
+
if (result.ignored) excluded = true;
|
|
35
|
+
else if (result.unignored) excluded = false;
|
|
36
|
+
}
|
|
37
|
+
return excluded;
|
|
38
|
+
}
|
|
39
|
+
export function sourceFiles(root) {
|
|
40
|
+
const cache = new Map();
|
|
41
|
+
return collectIndexFiles(root).filter((file) => {
|
|
42
|
+
const path = relative(root, file).replaceAll('\\', '/');
|
|
43
|
+
if (!SOURCE_EXTENSIONS.has(extname(path)) || path.split('/').some((part) => part.startsWith('.')) || isSecretLikePathPattern(path)) return false;
|
|
44
|
+
if (!inside(root, file) || ignored(root, path, cache)) return false;
|
|
45
|
+
return !/^#![^\n]*\n\/\/ Generated|^\/\/ Generated|@generated/m.test(readFileSync(file, 'utf8').slice(0, 256));
|
|
46
|
+
}).map((file) => {
|
|
47
|
+
const source = readFileSync(file, 'utf8');
|
|
48
|
+
return { path: relative(root, file).replaceAll('\\', '/'), hash: fingerprint(file), language: languageForPath(file), sourceLength: source.length, lineCount: source.split('\n').length };
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
export function outlinePolicy(root) {
|
|
52
|
+
const config = readMemoryConfig(root);
|
|
53
|
+
const ignores = collectIndexFiles(root).filter((file) => file.endsWith('/.gitignore') && inside(root, file)).map((file) => [relative(root, file), fingerprint(file)]);
|
|
54
|
+
return textFingerprint(JSON.stringify({ areas: config.areas, ignores, extractor: EXTRACTOR_ID, declarations: DECLARATIONS, extensions: LANGUAGE_EXTENSIONS, parser: parserIdentity() }));
|
|
55
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { dirname, posix } from 'node:path';
|
|
2
|
+
import { addEdge, addNode } from '../graph/store.mjs';
|
|
3
|
+
|
|
4
|
+
function importTarget(file, specifier, paths) {
|
|
5
|
+
if (!specifier.startsWith('.')) return null;
|
|
6
|
+
const base = posix.normalize(posix.join(dirname(file).replaceAll('\\', '/'), specifier));
|
|
7
|
+
if (base.startsWith('../')) return null;
|
|
8
|
+
const stem = base.replace(/\.(js|jsx|mjs|cjs)$/, '');
|
|
9
|
+
const candidates = [base, ...['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.py'].map((extension) => `${stem}${extension}`), ...['.ts', '.js', '.py'].map((extension) => `${base}/index${extension}`)];
|
|
10
|
+
return candidates.find((path) => paths.has(path)) ?? null;
|
|
11
|
+
}
|
|
12
|
+
export function addOutlineGraph(graph, index) {
|
|
13
|
+
const paths = new Set(index.files.map((file) => file.path));
|
|
14
|
+
for (const file of index.files) {
|
|
15
|
+
const source = `file:${file.path}`;
|
|
16
|
+
for (const symbol of file.symbols) {
|
|
17
|
+
addNode(graph, symbol.id, 'symbol', { ...symbol, sourceHash: file.hash });
|
|
18
|
+
addEdge(graph, symbol.ownerId ?? source, symbol.id, symbol.ownerId ? 'contains_symbol' : 'declares_symbol', { confidence: 'PARSED', path: file.path });
|
|
19
|
+
}
|
|
20
|
+
for (const item of file.imports) {
|
|
21
|
+
const resolved = importTarget(file.path, item.specifier, paths);
|
|
22
|
+
const target = resolved ? `file:${resolved}` : `import:${file.path}#${item.specifier}`;
|
|
23
|
+
if (!resolved) addNode(graph, target, 'unresolved_import', { path: file.path, specifier: item.specifier });
|
|
24
|
+
addEdge(graph, source, target, 'imports', { ...item, resolved: Boolean(resolved), confidence: resolved ? 'RESOLVED_RELATIVE_FILE' : 'SYNTAX_ONLY', path: file.path });
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return graph;
|
|
28
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { extname } from 'node:path';
|
|
2
|
+
|
|
3
|
+
// Explicit capability matrix, not a claim that every Tree-sitter grammar yields outlines.
|
|
4
|
+
export const LANGUAGE_EXTENSIONS = {
|
|
5
|
+
javascript: ['.js', '.jsx', '.mjs', '.cjs'], typescript: ['.ts'], tsx: ['.tsx'],
|
|
6
|
+
python: ['.py', '.pyw'], go: ['.go'], rust: ['.rs'], java: ['.java'],
|
|
7
|
+
ruby: ['.rb'], php: ['.php'], 'c-sharp': ['.cs'], cpp: ['.cpp', '.cc', '.cxx', '.hpp'],
|
|
8
|
+
bash: ['.sh', '.bash'], powershell: ['.ps1'],
|
|
9
|
+
};
|
|
10
|
+
export const SOURCE_EXTENSIONS = new Set([
|
|
11
|
+
...Object.values(LANGUAGE_EXTENSIONS).flat(), '.c', '.h', '.kt', '.kts', '.swift',
|
|
12
|
+
'.scala', '.clj', '.cljs', '.ex', '.exs', '.erl', '.hrl', '.lua', '.pl', '.pm', '.r', '.R', '.sql', '.m', '.mm', '.zsh', '.fish',
|
|
13
|
+
]);
|
|
14
|
+
export const EXTRACTOR_ID = 'outline-v3:vscode-tree-sitter-wasm-0.3.1';
|
|
15
|
+
export function languageForPath(path) {
|
|
16
|
+
return Object.entries(LANGUAGE_EXTENSIONS).find(([, extensions]) => extensions.includes(extname(path)))?.[0] ?? null;
|
|
17
|
+
}
|
|
18
|
+
export const DECLARATIONS = {
|
|
19
|
+
function_declaration: 'function', generator_function_declaration: 'function',
|
|
20
|
+
function_definition: 'function', function_item: 'function', function_signature_item: 'function', function_signature: 'function',
|
|
21
|
+
method_definition: 'method', method_declaration: 'method', method_signature: 'method', method: 'method', singleton_method: 'method',
|
|
22
|
+
constructor_declaration: 'constructor', destructor_declaration: 'destructor',
|
|
23
|
+
class_declaration: 'class', class_definition: 'class', class: 'class', class_specifier: 'class', struct_specifier: 'struct',
|
|
24
|
+
interface_declaration: 'interface', interface_definition: 'interface', trait_item: 'trait',
|
|
25
|
+
struct_item: 'struct', enum_item: 'enum', enum_declaration: 'enum', record_declaration: 'class',
|
|
26
|
+
namespace_definition: 'namespace', mod_item: 'module', module: 'module',
|
|
27
|
+
function_statement: 'function',
|
|
28
|
+
};
|
|
29
|
+
export const COMMENT_TYPES = new Set(['comment', 'line_comment', 'block_comment']);
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { dirname, join } from 'node:path';
|
|
2
|
+
import { parserEntry, loadParserApi } from './runtime.mjs';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { COMMENT_TYPES, DECLARATIONS } from './languages.mjs';
|
|
5
|
+
|
|
6
|
+
let runtime;
|
|
7
|
+
const languages = new Map();
|
|
8
|
+
async function parserFor(language) {
|
|
9
|
+
if (!runtime) runtime = (async () => {
|
|
10
|
+
const entry = parserEntry();
|
|
11
|
+
const api = loadParserApi();
|
|
12
|
+
await api.Parser.init();
|
|
13
|
+
return { api, directory: dirname(entry) };
|
|
14
|
+
})();
|
|
15
|
+
const { api, directory } = await runtime;
|
|
16
|
+
if (!languages.has(language)) languages.set(language, api.Language.load(join(directory, `tree-sitter-${language}.wasm`)));
|
|
17
|
+
const loaded = await languages.get(language);
|
|
18
|
+
const parser = new api.Parser();
|
|
19
|
+
parser.setLanguage(loaded);
|
|
20
|
+
return parser;
|
|
21
|
+
}
|
|
22
|
+
const field = (node, name) => node.childForFieldName(name);
|
|
23
|
+
const lastLine = (node) => Math.max(node.startPosition.row + 1, node.endPosition.row + (node.endPosition.column ? 1 : 0));
|
|
24
|
+
const hash = (text) => createHash('sha256').update(text).digest('hex').slice(0, 16);
|
|
25
|
+
|
|
26
|
+
function declaredName(node) {
|
|
27
|
+
const name = field(node, 'name') ?? node.namedChildren.find((child) => child.type === 'function_name');
|
|
28
|
+
if (name) return name.text;
|
|
29
|
+
const declarator = field(node, 'declarator');
|
|
30
|
+
if (declarator) return declaredName(declarator) || (['identifier', 'field_identifier'].includes(declarator.type) ? declarator.text : '');
|
|
31
|
+
return '';
|
|
32
|
+
}
|
|
33
|
+
function declarationHeader(node, source, end) {
|
|
34
|
+
const cuts = [];
|
|
35
|
+
function visit(child) {
|
|
36
|
+
const body = field(child, 'body');
|
|
37
|
+
if (body && body.startIndex < end && body.endIndex <= end) { cuts.push([body.startIndex, body.endIndex]); return; }
|
|
38
|
+
for (const nested of child.namedChildren) if (nested.startIndex < end) visit(nested);
|
|
39
|
+
}
|
|
40
|
+
visit(node);
|
|
41
|
+
let header = source.slice(node.startIndex, end);
|
|
42
|
+
for (const [start, finish] of cuts.sort((a, b) => b[0] - a[0])) header = header.slice(0, start - node.startIndex) + '…' + header.slice(finish - node.startIndex);
|
|
43
|
+
return header.trim();
|
|
44
|
+
}
|
|
45
|
+
function documentation(node, body, language, source) {
|
|
46
|
+
if (language === 'python') {
|
|
47
|
+
const first = body?.namedChildren[0];
|
|
48
|
+
const string = first?.type === 'expression_statement' ? first.namedChildren[0] : null;
|
|
49
|
+
const literals = string?.type === 'concatenated_string' ? string.namedChildren : string ? [string] : [];
|
|
50
|
+
const valid = literals.length && literals.every((literal) => literal.type === 'string' && /^[rRuU]*['"]/.test(literal.text));
|
|
51
|
+
return valid ? { text: string.text, startLine: string.startPosition.row + 1 } : { text: '' };
|
|
52
|
+
}
|
|
53
|
+
let anchor = node;
|
|
54
|
+
while (anchor.parent && (['export_statement', 'decorated_definition'].includes(anchor.parent.type) || (['lexical_declaration', 'variable_declaration'].includes(anchor.parent.type) && !anchor.previousNamedSibling) || (language === 'powershell' && anchor.parent.type === 'statement_list' && !anchor.previousNamedSibling))) anchor = anchor.parent;
|
|
55
|
+
const comments = [];
|
|
56
|
+
let previous = anchor.previousNamedSibling;
|
|
57
|
+
let start = anchor.startIndex;
|
|
58
|
+
while (previous && source.slice(previous.endIndex, start).trim() === '') {
|
|
59
|
+
if (COMMENT_TYPES.has(previous.type)) comments.unshift(previous);
|
|
60
|
+
else if (!(language === 'rust' && previous.type === 'attribute_item')) break;
|
|
61
|
+
start = previous.startIndex;
|
|
62
|
+
previous = previous.previousNamedSibling;
|
|
63
|
+
}
|
|
64
|
+
const markers = ['javascript', 'typescript', 'tsx', 'java', 'php'].includes(language) ? /^\/\*\*/
|
|
65
|
+
: language === 'rust' ? /^(\/\/\/(?!\/)|\/\*\*)/
|
|
66
|
+
: ['cpp', 'c-sharp'].includes(language) ? /^(\/\*\*|\/\/\/|\/\/!|\/\*!)/ : null;
|
|
67
|
+
const docs = markers ? comments.filter((comment) => markers.test(comment.text)) : comments;
|
|
68
|
+
return { text: docs.map((comment) => comment.text).join('\n'), startLine: docs[0] ? docs[0].startPosition.row + 1 : undefined };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function extractSymbols(path, source, language) {
|
|
72
|
+
const parser = await parserFor(language);
|
|
73
|
+
let tree;
|
|
74
|
+
try {
|
|
75
|
+
tree = parser.parse(source);
|
|
76
|
+
if (!tree) throw new Error('Parser returned no syntax tree');
|
|
77
|
+
const symbols = [];
|
|
78
|
+
const imports = [];
|
|
79
|
+
const occurrences = new Map();
|
|
80
|
+
function walk(node, owner = null) {
|
|
81
|
+
let kind = DECLARATIONS[node.type];
|
|
82
|
+
let declaration = node;
|
|
83
|
+
let body = field(node, 'body') ?? (language === 'powershell' ? node.namedChildren.find((child) => child.type === 'script_block') : null);
|
|
84
|
+
if (['variable_declarator', 'public_field_definition'].includes(node.type)) {
|
|
85
|
+
const value = field(node, 'value');
|
|
86
|
+
if (value && ['arrow_function', 'function_expression', 'generator_function'].includes(value.type)) {
|
|
87
|
+
kind = 'function'; body = field(value, 'body');
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (node.type === 'type_spec' && ['struct_type', 'interface_type'].includes(field(node, 'type')?.type)) {
|
|
91
|
+
kind = 'class'; body = field(node, 'type').namedChildren.find((child) => child.type.endsWith('_list'));
|
|
92
|
+
}
|
|
93
|
+
if (node.type === 'impl_item') {
|
|
94
|
+
const type = field(node, 'type');
|
|
95
|
+
if (type) owner = symbols.find((symbol) => symbol.qualifiedName === type.text && ['struct', 'class'].includes(symbol.kind)) ?? { qualifiedName: type.text, id: null, kind: 'struct' };
|
|
96
|
+
}
|
|
97
|
+
if (kind === 'function' && owner && ['class', 'struct', 'interface', 'trait'].includes(owner.kind)) kind = 'method';
|
|
98
|
+
const name = kind ? declaredName(declaration) : '';
|
|
99
|
+
if (kind && name) {
|
|
100
|
+
const qualifiedName = owner ? `${owner.qualifiedName}.${name}` : name;
|
|
101
|
+
const signature = declarationHeader(declaration, source, body?.startIndex ?? declaration.endIndex);
|
|
102
|
+
const key = `${path}#${qualifiedName}:${kind}:${hash(signature)}`;
|
|
103
|
+
const occurrence = occurrences.get(key) ?? 0;
|
|
104
|
+
occurrences.set(key, occurrence + 1);
|
|
105
|
+
const doc = documentation(declaration, body, language, source);
|
|
106
|
+
const symbol = {
|
|
107
|
+
id: `symbol:${key}:${occurrence}`, kind, name, qualifiedName, ownerId: owner?.id ?? null,
|
|
108
|
+
language, path, signature, documentation: doc.text,
|
|
109
|
+
startLine: Math.min(doc.startLine ?? Infinity, declaration.startPosition.row + 1),
|
|
110
|
+
declarationStartLine: declaration.startPosition.row + 1, endLine: lastLine(declaration),
|
|
111
|
+
bodyStartLine: body ? body.startPosition.row + 1 : null, bodyEndLine: body ? lastLine(body) : null,
|
|
112
|
+
declarationStartOffset: declaration.startIndex, declarationEndOffset: declaration.endIndex,
|
|
113
|
+
bodyStartOffset: body?.startIndex ?? null, bodyEndOffset: body?.endIndex ?? null,
|
|
114
|
+
};
|
|
115
|
+
symbols.push(symbol);
|
|
116
|
+
owner = symbol;
|
|
117
|
+
}
|
|
118
|
+
if (['import_statement', 'export_statement', 'import_from_statement', 'use_declaration', 'include_directive', 'preproc_include'].includes(node.type)) {
|
|
119
|
+
const target = field(node, 'source') ?? field(node, 'module_name') ?? field(node, 'path') ?? field(node, 'argument');
|
|
120
|
+
if (target) imports.push({ specifier: target.text.replace(/^['"]|['"]$/g, ''), line: node.startPosition.row + 1, syntax: node.type });
|
|
121
|
+
}
|
|
122
|
+
for (const child of node.namedChildren) walk(child, owner);
|
|
123
|
+
}
|
|
124
|
+
walk(tree.rootNode);
|
|
125
|
+
return { symbols, imports, status: tree.rootNode.hasError ? 'partial' : 'parsed' };
|
|
126
|
+
} finally { tree?.delete(); parser.delete(); }
|
|
127
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
6
|
+
import { LANGUAGE_EXTENSIONS } from './languages.mjs';
|
|
7
|
+
|
|
8
|
+
const require = createRequire(import.meta.url);
|
|
9
|
+
export function parserEntry() {
|
|
10
|
+
const bundled = fileURLToPath(new URL('./parsers/tree-sitter.cjs', import.meta.url));
|
|
11
|
+
return existsSync(bundled) ? bundled : require.resolve('@vscode/tree-sitter-wasm');
|
|
12
|
+
}
|
|
13
|
+
let identity;
|
|
14
|
+
export function parserIdentity() {
|
|
15
|
+
if (!identity) {
|
|
16
|
+
const entry = parserEntry();
|
|
17
|
+
const hash = createHash('sha256').update(readFileSync(entry));
|
|
18
|
+
for (const name of ['tree-sitter.wasm', ...Object.keys(LANGUAGE_EXTENSIONS).map((language) => `tree-sitter-${language}.wasm`)]) hash.update(name).update(readFileSync(join(dirname(entry), name)));
|
|
19
|
+
identity = hash.digest('hex');
|
|
20
|
+
}
|
|
21
|
+
return identity;
|
|
22
|
+
}
|
|
23
|
+
export function loadParserApi() { return require(parserEntry()); }
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { fingerprint } from '../index/files.mjs';
|
|
5
|
+
import { sourceFiles, outlinePolicy as policy } from './discovery.mjs';
|
|
6
|
+
export { sourceFiles } from './discovery.mjs';
|
|
7
|
+
import { textFingerprint } from '../query/chunks.mjs';
|
|
8
|
+
import { EXTRACTOR_ID, languageForPath } from './languages.mjs';
|
|
9
|
+
|
|
10
|
+
export const OUTLINE_SCHEMA = 1;
|
|
11
|
+
export const outlinePath = (root) => join(root, '.dotdotgod', 'outlines.json');
|
|
12
|
+
function assertCachePath(root) {
|
|
13
|
+
for (const path of [dirname(outlinePath(root)), outlinePath(root)]) {
|
|
14
|
+
let stat;
|
|
15
|
+
try { stat = lstatSync(path); } catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
16
|
+
if (stat?.isSymbolicLink()) throw new Error(`Outline cache must not use symlinks: ${path}`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export function readOutlines(root) {
|
|
20
|
+
try {
|
|
21
|
+
assertCachePath(root);
|
|
22
|
+
const index = JSON.parse(readFileSync(outlinePath(root), 'utf8'));
|
|
23
|
+
if (index.schema !== OUTLINE_SCHEMA || index.extractor !== EXTRACTOR_ID || !Array.isArray(index.files)) return null;
|
|
24
|
+
const paths = new Set(); const ids = new Set();
|
|
25
|
+
for (const file of index.files) {
|
|
26
|
+
if (typeof file.path !== 'string' || file.path.startsWith('/') || file.path.split('/').includes('..') || paths.has(file.path) || !/^[a-f0-9]{64}$/.test(file.hash)) return null;
|
|
27
|
+
if (!Number.isInteger(file.sourceLength) || file.sourceLength < 0 || !Number.isInteger(file.lineCount) || file.lineCount < 1) return null;
|
|
28
|
+
if (file.language !== languageForPath(file.path) || ((file.language === null) !== (file.status === 'unsupported'))) return null;
|
|
29
|
+
paths.add(file.path);
|
|
30
|
+
if (!['parsed', 'partial', 'unsupported', 'failed'].includes(file.status) || !Array.isArray(file.symbols) || !Array.isArray(file.imports)) return null;
|
|
31
|
+
if (file.imports.some((item) => typeof item.specifier !== 'string' || !Number.isInteger(item.line) || item.line < 1)) return null;
|
|
32
|
+
for (const symbol of file.symbols) {
|
|
33
|
+
if (symbol.path !== file.path || typeof symbol.id !== 'string' || !symbol.id.startsWith(`symbol:${file.path}#`) || ids.has(symbol.id) || typeof symbol.signature !== 'string' || typeof symbol.documentation !== 'string') return null;
|
|
34
|
+
if (!Number.isInteger(symbol.startLine) || !Number.isInteger(symbol.endLine) || symbol.startLine < 1 || symbol.endLine < symbol.startLine || symbol.endLine > file.lineCount) return null;
|
|
35
|
+
if (typeof symbol.name !== 'string' || typeof symbol.qualifiedName !== 'string' || !Number.isInteger(symbol.declarationStartLine) || symbol.declarationStartLine < symbol.startLine || symbol.declarationStartLine > symbol.endLine) return null;
|
|
36
|
+
if ((symbol.bodyStartLine === null) !== (symbol.bodyEndLine === null)) return null;
|
|
37
|
+
if (symbol.bodyStartLine !== null && (!Number.isInteger(symbol.bodyStartLine) || !Number.isInteger(symbol.bodyEndLine) || symbol.bodyStartLine < symbol.declarationStartLine || symbol.bodyEndLine > symbol.endLine || symbol.bodyEndLine < symbol.bodyStartLine)) return null;
|
|
38
|
+
if (!Number.isInteger(symbol.declarationStartOffset) || !Number.isInteger(symbol.declarationEndOffset) || symbol.declarationStartOffset < 0 || symbol.declarationEndOffset < symbol.declarationStartOffset || symbol.declarationEndOffset > file.sourceLength) return null;
|
|
39
|
+
if ((symbol.bodyStartOffset === null) !== (symbol.bodyEndOffset === null)) return null;
|
|
40
|
+
if (symbol.bodyStartOffset !== null && (!Number.isInteger(symbol.bodyStartOffset) || !Number.isInteger(symbol.bodyEndOffset) || symbol.bodyStartOffset < symbol.declarationStartOffset || symbol.bodyEndOffset > symbol.declarationEndOffset || symbol.bodyEndOffset < symbol.bodyStartOffset)) return null;
|
|
41
|
+
ids.add(symbol.id);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
for (const file of index.files) for (const symbol of file.symbols) if (symbol.ownerId !== null && !ids.has(symbol.ownerId)) return null;
|
|
45
|
+
return index;
|
|
46
|
+
} catch { return null; }
|
|
47
|
+
}
|
|
48
|
+
export function outlineStatus(root) {
|
|
49
|
+
assertCachePath(root);
|
|
50
|
+
const files = sourceFiles(root);
|
|
51
|
+
const index = readOutlines(root);
|
|
52
|
+
if (!index) return { ok: files.length === 0 && !existsSync(outlinePath(root)), reason: existsSync(outlinePath(root)) ? 'missing-or-invalid' : 'missing', paths: files.map((file) => file.path) };
|
|
53
|
+
const old = new Map(index.files.map((file) => [file.path, file]));
|
|
54
|
+
const current = new Set(files.map((file) => file.path));
|
|
55
|
+
const changed = files.filter((file) => old.get(file.path)?.hash !== file.hash || old.get(file.path)?.sourceLength !== file.sourceLength || old.get(file.path)?.lineCount !== file.lineCount).map((file) => file.path);
|
|
56
|
+
const removed = [...old.keys()].filter((path) => !current.has(path));
|
|
57
|
+
const failures = index.files.filter((file) => ['failed', 'partial'].includes(file.status)).map((file) => file.path);
|
|
58
|
+
const policyOk = index.policy === policy(root);
|
|
59
|
+
return { ok: policyOk && !changed.length && !removed.length && !failures.length, reason: !policyOk ? 'policy-changed' : failures.length ? 'extraction-incomplete' : changed.length || removed.length ? 'source-changed' : 'fresh', paths: [...new Set([...changed, ...removed, ...failures])], unsupported: index.files.filter((file) => file.status === 'unsupported').map((file) => file.path) };
|
|
60
|
+
}
|
|
61
|
+
export async function buildOutlines(root) {
|
|
62
|
+
assertCachePath(root);
|
|
63
|
+
const files = sourceFiles(root);
|
|
64
|
+
const old = readOutlines(root);
|
|
65
|
+
const previous = new Map(old?.policy === policy(root) ? old.files.map((file) => [file.path, file]) : []);
|
|
66
|
+
let parsed = 0; let reused = 0;
|
|
67
|
+
const records = [];
|
|
68
|
+
for (const file of files) {
|
|
69
|
+
const cached = previous.get(file.path);
|
|
70
|
+
if (cached?.hash === file.hash && ['parsed', 'unsupported'].includes(cached.status)) { records.push(cached); reused += 1; continue; }
|
|
71
|
+
if (!file.language) { records.push({ ...file, status: 'unsupported', symbols: [], imports: [] }); continue; }
|
|
72
|
+
const { extractSymbols } = await import('./parser.mjs');
|
|
73
|
+
const source = readFileSync(join(root, file.path), 'utf8');
|
|
74
|
+
const extracted = await extractSymbols(file.path, source, file.language);
|
|
75
|
+
if (fingerprint(join(root, file.path)) !== file.hash) throw new Error(`Source changed during outline extraction: ${file.path}`);
|
|
76
|
+
records.push({ ...file, ...extracted }); parsed += 1;
|
|
77
|
+
}
|
|
78
|
+
const index = { schema: OUTLINE_SCHEMA, extractor: EXTRACTOR_ID, policy: policy(root), files: records, refresh: { parsed, reused } };
|
|
79
|
+
const target = outlinePath(root);
|
|
80
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
81
|
+
const temporary = `${target}.tmp-${process.pid}-${randomUUID()}`;
|
|
82
|
+
writeFileSync(temporary, JSON.stringify(index), { flag: 'wx' }); renameSync(temporary, target);
|
|
83
|
+
return index;
|
|
84
|
+
}
|
|
85
|
+
export function outlineChunks(index) {
|
|
86
|
+
// ponytail: one passage per symbol; split long comments if provider input limits dominate.
|
|
87
|
+
return index.files.flatMap((file) => file.symbols.map((symbol) => {
|
|
88
|
+
const text = `${symbol.signature}\n${symbol.documentation}`.trim();
|
|
89
|
+
const passage = `Path: ${file.path}\nSymbol: ${symbol.qualifiedName}\nKind: ${symbol.kind}\n\n${text}`;
|
|
90
|
+
return { ...symbol, kind: 'symbol', symbolKind: symbol.kind, heading: symbol.qualifiedName, text, passage, sourceHash: file.hash, fingerprint: textFingerprint(passage) };
|
|
91
|
+
}));
|
|
92
|
+
}
|
package/src/validate/run.mjs
CHANGED
|
@@ -9,6 +9,7 @@ import { cacheFile, collectIndexFiles, fingerprint } from '../index/files.mjs';
|
|
|
9
9
|
import { CACHE_DIR, CACHE_VERSION } from '../index/constants.mjs';
|
|
10
10
|
import { readIndex } from '../index/cache.mjs';
|
|
11
11
|
import { sharedLocalReferences } from './references.mjs';
|
|
12
|
+
import { outlineStatus, outlinePath } from '../symbols/store.mjs';
|
|
12
13
|
|
|
13
14
|
export function runValidate(argv) {
|
|
14
15
|
const options = { root: '.', includeLocalMemory: false, checkIndex: false, maxLines: null, maxChars: null, linkCheck: true, json: false };
|
|
@@ -173,6 +174,11 @@ export function runValidate(argv) {
|
|
|
173
174
|
}
|
|
174
175
|
}
|
|
175
176
|
if (options.checkIndex) {
|
|
177
|
+
let outlines;
|
|
178
|
+
try { outlines = outlineStatus(root); }
|
|
179
|
+
catch (error) { outlines = { ok: false, reason: `inspection-unavailable: ${error instanceof Error ? error.message : String(error)}`, paths: [] }; }
|
|
180
|
+
if (!outlines.ok) addError(outlinePath(root), 'OUTLINE_INDEX_STALE', `Outline index: ${outlines.reason}${outlines.paths.length ? ` (${outlines.paths.join(', ')})` : ''}`, null, 'run `dotdotgod index <root>` to refresh outlines and graph metadata; validation does not parse, embed or repair.');
|
|
181
|
+
for (const path of outlines.unsupported ?? []) addWarning(join(root, path), 'OUTLINE_LANGUAGE_UNSUPPORTED', 'Source language is not supported by the current outline extraction matrix.');
|
|
176
182
|
const index = readIndex(root);
|
|
177
183
|
if (!index) addError(cacheFile(root), 'INDEX_MISSING', 'Expected .dotdotgod index cache.', null, 'run `dotdotgod index <root>` to build the graph index.');
|
|
178
184
|
else {
|