diff --git a/.github/workflows/_static-checks.yml b/.github/workflows/_static-checks.yml index 63b38cf..d37ae9f 100644 --- a/.github/workflows/_static-checks.yml +++ b/.github/workflows/_static-checks.yml @@ -35,6 +35,8 @@ jobs: run: pnpm format:check - name: ESLint run: pnpm lint + - name: No spec-tracking tags in source + run: pnpm lint:spec-tags - name: TypeScript run: pnpm typecheck - name: pnpm dedupe check diff --git a/eslint.config.js b/eslint.config.js index 3066b36..2cede7d 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -81,5 +81,33 @@ export default [ }, }, }, + { + // core must run in Node, browser (WASM), and VS Code contexts — it can never + // depend on the `vscode` host API. The DuckDB driver is confined to the + // storage adapter (`storage/adapters/**`); every other core module depends on + // the connection type re-exported from `storage/db.ts`, not the driver. + // Enforces the no-vscode-in-core + driver-behind-adapter boundaries. + files: ["packages/core/src/**/*.ts"], + ignores: ["**/*.test.ts", "**/__fixtures__/**", "packages/core/src/storage/adapters/**"], + rules: { + "no-restricted-imports": [ + "error", + { + paths: [ + { + name: "vscode", + message: + "core must not import vscode — it runs in Node/WASM/browser. Inject host behaviour via an interface.", + }, + { + name: "@duckdb/node-api", + message: + "Import the DuckDB driver only in storage/adapters/. Other modules use GraphDbConnection from storage/db.ts.", + }, + ], + }, + ], + }, + }, prettier, ]; diff --git a/package.json b/package.json index f187879..521ca90 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "format:check": "prettier --check .", "lint": "turbo run lint", "lint:fix": "eslint --fix \"packages/**/*.ts\"", + "lint:spec-tags": "node scripts/check-no-spec-tags.mjs", "package": "pnpm --filter dextree run package", "prepare": "husky", "test": "turbo run test", diff --git a/packages/core/scripts/gen-tags-providers.mjs b/packages/core/scripts/gen-tags-providers.mjs index f202e13..813fbb1 100644 --- a/packages/core/scripts/gen-tags-providers.mjs +++ b/packages/core/scripts/gen-tags-providers.mjs @@ -13,18 +13,24 @@ const outDir = resolve(coreRoot, "src/extractors/languages/generated"); // One entry per language. The ONLY per-language data: package name + which // detectLanguage value(s) it serves + the default capture→kind config. +// `imports` = top-level AST node types that represent an import in that grammar, +// used to emit IMPORTS edges. Empty/omitted → the language emits no IMPORTS. const LANGUAGES = [ - { pkg: "tree-sitter-python", languages: ["python"] }, - { pkg: "tree-sitter-go", languages: ["go"] }, - { pkg: "tree-sitter-java", languages: ["java"] }, - { pkg: "tree-sitter-ruby", languages: ["ruby"] }, - { pkg: "tree-sitter-rust", languages: ["rust"] }, - { pkg: "tree-sitter-c", languages: ["c"] }, - { pkg: "tree-sitter-cpp", languages: ["cpp"] }, - { pkg: "tree-sitter-c-sharp", languages: ["csharp"] }, - { pkg: "tree-sitter-php", languages: ["php"] }, - { pkg: "tree-sitter-elixir", languages: ["elixir"] }, - { pkg: "tree-sitter-scala", languages: ["scala"] }, + { + pkg: "tree-sitter-python", + languages: ["python"], + imports: ["import_statement", "import_from_statement"], + }, + { pkg: "tree-sitter-go", languages: ["go"], imports: ["import_declaration"] }, + { pkg: "tree-sitter-java", languages: ["java"], imports: ["import_declaration"] }, + { pkg: "tree-sitter-ruby", languages: ["ruby"], imports: [] }, + { pkg: "tree-sitter-rust", languages: ["rust"], imports: ["use_declaration"] }, + { pkg: "tree-sitter-c", languages: ["c"], imports: ["preproc_include"] }, + { pkg: "tree-sitter-cpp", languages: ["cpp"], imports: ["preproc_include"] }, + { pkg: "tree-sitter-c-sharp", languages: ["csharp"], imports: ["using_directive"] }, + { pkg: "tree-sitter-php", languages: ["php"], imports: ["namespace_use_declaration"] }, + { pkg: "tree-sitter-elixir", languages: ["elixir"], imports: [] }, + { pkg: "tree-sitter-scala", languages: ["scala"], imports: ["import_declaration"] }, ]; // Standard tree-sitter tags `definition.` → Dextree SymbolKind. Suffixes @@ -48,10 +54,11 @@ function tagsFor(pkg) { return readFileSync(p, "utf8"); } -function emit({ pkg, languages }) { +function emit({ pkg, languages, imports = [] }) { const tags = tagsFor(pkg); const constName = pkg.replace(/[^a-z0-9]/gi, "_").toUpperCase(); const escaped = tags.replace(/\\/g, "\\\\").replace(/`/g, "\\`"); + const importsLine = imports.length > 0 ? ` importNodeTypes: ${JSON.stringify(imports)},\n` : ""; const lines = [ `// AUTO-GENERATED from ${pkg}/queries/tags.scm — do not edit by hand.`, `// Regenerate: pnpm --filter @dextree/core gen:tags`, @@ -62,7 +69,10 @@ function emit({ pkg, languages }) { `const CONFIG: ProviderConfig = {`, ` symbolKinds: ${JSON.stringify(DEFAULT_SYMBOL_KINDS, null, 2).replace(/\n/g, "\n ")},`, ` callCaptures: ["reference.call"],`, + importsLine ? importsLine.replace(/\n$/, "") : null, `};`, + ].filter((l) => l !== null); + lines.push( ``, `export const ${constName}_PROVIDERS: readonly LanguageProvider[] = [`, ...languages.map( @@ -70,7 +80,7 @@ function emit({ pkg, languages }) { ), `];`, ``, - ]; + ); const out = resolve(outDir, `${pkg}.ts`); writeFileSync(out, lines.join("\n"), "utf8"); return { pkg, constName, file: `${pkg}.ts`, languages }; diff --git a/packages/core/src/extractors/GenericTagsExtractor.ts b/packages/core/src/extractors/GenericTagsExtractor.ts index 638cf10..3c2cef7 100644 --- a/packages/core/src/extractors/GenericTagsExtractor.ts +++ b/packages/core/src/extractors/GenericTagsExtractor.ts @@ -6,6 +6,7 @@ import type { Language, Node, Tree } from "web-tree-sitter"; import { detectLanguage, extractImportRefs } from "../parser/extractor.js"; import { loadGrammar } from "../parser/grammars.js"; +import { EDGE_META_KEYS } from "../storage/edgeMetadata.js"; import type { StoredSymbol, SymbolKind, SymbolRange } from "../types.js"; import { getLanguageProvider } from "./languages/registry.js"; import type { LanguageProvider } from "./languages/types.js"; @@ -60,6 +61,8 @@ export class GenericTagsExtractor implements Extractor { input.absolutePath, input.workspaceRoot, input.fileId, + input.language, + provider.config.importNodeTypes, ); const edges = provider.config.structuralOnly @@ -194,9 +197,9 @@ function buildCallEdges(calls: CallSite[], symbols: StoredSymbol[], fileId: stri targetId: null, kind: "CALLS", metadata: { - callee_name: call.callee, - source_fqn: source ? source.fqn : null, - call_site_range: rangeOf(call.node), + [EDGE_META_KEYS.calleeName]: call.callee, + [EDGE_META_KEYS.sourceFqn]: source ? source.fqn : null, + [EDGE_META_KEYS.callSiteRange]: rangeOf(call.node), }, }); } @@ -207,11 +210,11 @@ function buildCallEdges(calls: CallSite[], symbols: StoredSymbol[], fileId: stri // REFERENCES resolves to a symbol by name; RE_EXPORTS names a module path // (resolved at query time like IMPORTS), so its key is distinct. const TARGET_NAME_KEY: Record = { - INHERITS: "parent_name", - INSTANTIATES: "class_name", - IMPLEMENTS: "interface_name", - REFERENCES: "referenced_name", - RE_EXPORTS: "reexport_path", + INHERITS: EDGE_META_KEYS.parentName, + INSTANTIATES: EDGE_META_KEYS.className, + IMPLEMENTS: EDGE_META_KEYS.interfaceName, + REFERENCES: EDGE_META_KEYS.referencedName, + RE_EXPORTS: EDGE_META_KEYS.reexportPath, }; /** @@ -235,8 +238,8 @@ function buildRelationEdges( kind: rel.kind, metadata: { [targetKey]: rel.targetName, - source_fqn: source ? source.fqn : null, - reference_range: rangeOf(rel.node), + [EDGE_META_KEYS.sourceFqn]: source ? source.fqn : null, + [EDGE_META_KEYS.referenceRange]: rangeOf(rel.node), }, }); } @@ -350,8 +353,6 @@ function emptyResult(): ExtractionResult { imports: [], edges: [], annotations: [], - modules: [], - tests: [], }; } diff --git a/packages/core/src/extractors/classification/classifySymbol.ts b/packages/core/src/extractors/classification/classifySymbol.ts index dc9f980..5d16272 100644 --- a/packages/core/src/extractors/classification/classifySymbol.ts +++ b/packages/core/src/extractors/classification/classifySymbol.ts @@ -24,8 +24,7 @@ const UNCLASSIFIED_ENTRY: EntryKind = "unclassified"; const UNKNOWN_LAYER: ArchitecturalLayer = "unknown"; // Each branch is wrapped so the trailing `$` on the file-extension branch -// cannot be misread as also anchoring the directory-segment branch -// (CodeQL js/regex/missing-regexp-anchor). +// cannot be misread as also anchoring the directory-segment branch. const TEST_PATH_RX = /(?:(?:^|\/)(?:__tests__|__test__|tests?)\/)|(?:\.(?:test|spec)\.[mc]?[jt]sx?$)/; diff --git a/packages/core/src/extractors/frameworks/detector.ts b/packages/core/src/extractors/frameworks/detector.ts index a7b4c35..6c3955d 100644 --- a/packages/core/src/extractors/frameworks/detector.ts +++ b/packages/core/src/extractors/frameworks/detector.ts @@ -10,8 +10,9 @@ import type { /** * Pure entrypoint for workspace-level framework detection. Walks the - * registry; per framework wraps both matchers in try/catch (FR-007). Returns - * results in registry order. Empty array — never null — when nothing matched. + * registry; per framework wraps both matchers in try/catch for isolation. + * Returns results in registry order. Empty array — never null — when nothing + * matched. */ export const detectFrameworks: DetectFrameworksFn = async ( params: DetectFrameworksParams, @@ -45,7 +46,7 @@ async function runMatchersFor( tryMatchStructural(def.structural, structuralIO), ]); - // FR-003: BOTH signals required. + // BOTH signals required. if (!manifestHit || !structuralHit) { return null; } diff --git a/packages/core/src/extractors/frameworks/matchers/manifest.ts b/packages/core/src/extractors/frameworks/matchers/manifest.ts index 1d11b59..67e3210 100644 --- a/packages/core/src/extractors/frameworks/matchers/manifest.ts +++ b/packages/core/src/extractors/frameworks/matchers/manifest.ts @@ -53,8 +53,8 @@ function extractValue(content: string, keyPath: ManifestKeyPath): string | undef } if (lower.endsWith(".toml") || lower === "go.mod") { // go.mod is technically not TOML, but its `require ( ... )` blocks are - // matched by a different path — for slice 018 we treat the whole file as - // text and rely on the regex policy. The TOML reader will return + // matched by a different path — we treat the whole file as text and rely on + // the regex policy. The TOML reader will return // undefined for go.mod, so we fall through to whole-file regex below. const tomlValue = readTomlKeypath(content, keyPath.keypath); if (tomlValue !== undefined) return tomlValue; diff --git a/packages/core/src/extractors/frameworks/matchers/toml.ts b/packages/core/src/extractors/frameworks/matchers/toml.ts index a4eb89c..e45a29c 100644 --- a/packages/core/src/extractors/frameworks/matchers/toml.ts +++ b/packages/core/src/extractors/frameworks/matchers/toml.ts @@ -2,8 +2,8 @@ * Minimal TOML keypath reader. Returns the string value at a dot-separated * keypath, or undefined if missing. Handles `[section]` and `[section.sub]` * headers, plus `key = "value"` and `key = value` lines. Comments (`#`) are - * ignored. Arrays-of-tables (`[[name]]`) are out of scope for slice 018; - * if a framework needs them, upgrade to a library and keep the contract. + * ignored. Arrays-of-tables (`[[name]]`) are out of scope for this minimal + * parser; if a framework needs them, upgrade to a library and keep the contract. * * Why inline: we only need keypath lookup. A full TOML parser is ~5 KB * of dependency surface we don't otherwise use. @@ -26,7 +26,7 @@ export function readTomlKeypath(content: string, keypath: string): string | unde } if (line.startsWith("[[")) { - // Array-of-tables not supported in this slice; skip the section. + // Array-of-tables not supported; skip the section. currentSection = "__unsupported__"; continue; } diff --git a/packages/core/src/extractors/frameworks/registry.ts b/packages/core/src/extractors/frameworks/registry.ts index b5e2a66..16623cd 100644 --- a/packages/core/src/extractors/frameworks/registry.ts +++ b/packages/core/src/extractors/frameworks/registry.ts @@ -15,7 +15,7 @@ * - manifest: one or more `ManifestKeyPath` rules. Detection requires at * least one manifest match. * - structural: one `StructuralSignal`. Detection requires this AND - * manifest to fire — both signals are needed (FR-003). + * manifest to fire — both signals are needed. * - fileRole: pure (filePath, fileContent) → role | null. Returns the * per-file role attribution for files in workspaces where * this framework was detected. diff --git a/packages/core/src/extractors/languages/generated/tree-sitter-c-sharp.ts b/packages/core/src/extractors/languages/generated/tree-sitter-c-sharp.ts index 09412cb..6c893d8 100644 --- a/packages/core/src/extractors/languages/generated/tree-sitter-c-sharp.ts +++ b/packages/core/src/extractors/languages/generated/tree-sitter-c-sharp.ts @@ -41,6 +41,7 @@ const CONFIG: ProviderConfig = { variable: "variable", }, callCaptures: ["reference.call"], + importNodeTypes: ["using_directive"], }; export const TREE_SITTER_C_SHARP_PROVIDERS: readonly LanguageProvider[] = [ diff --git a/packages/core/src/extractors/languages/generated/tree-sitter-c.ts b/packages/core/src/extractors/languages/generated/tree-sitter-c.ts index add8865..ad1fe17 100644 --- a/packages/core/src/extractors/languages/generated/tree-sitter-c.ts +++ b/packages/core/src/extractors/languages/generated/tree-sitter-c.ts @@ -27,6 +27,7 @@ const CONFIG: ProviderConfig = { variable: "variable", }, callCaptures: ["reference.call"], + importNodeTypes: ["preproc_include"], }; export const TREE_SITTER_C_PROVIDERS: readonly LanguageProvider[] = [ diff --git a/packages/core/src/extractors/languages/generated/tree-sitter-cpp.ts b/packages/core/src/extractors/languages/generated/tree-sitter-cpp.ts index be49dba..dadd004 100644 --- a/packages/core/src/extractors/languages/generated/tree-sitter-cpp.ts +++ b/packages/core/src/extractors/languages/generated/tree-sitter-cpp.ts @@ -33,6 +33,7 @@ const CONFIG: ProviderConfig = { variable: "variable", }, callCaptures: ["reference.call"], + importNodeTypes: ["preproc_include"], }; export const TREE_SITTER_CPP_PROVIDERS: readonly LanguageProvider[] = [ diff --git a/packages/core/src/extractors/languages/generated/tree-sitter-go.ts b/packages/core/src/extractors/languages/generated/tree-sitter-go.ts index f5cfe8a..a46b962 100644 --- a/packages/core/src/extractors/languages/generated/tree-sitter-go.ts +++ b/packages/core/src/extractors/languages/generated/tree-sitter-go.ts @@ -60,6 +60,7 @@ const CONFIG: ProviderConfig = { variable: "variable", }, callCaptures: ["reference.call"], + importNodeTypes: ["import_declaration"], }; export const TREE_SITTER_GO_PROVIDERS: readonly LanguageProvider[] = [ diff --git a/packages/core/src/extractors/languages/generated/tree-sitter-java.ts b/packages/core/src/extractors/languages/generated/tree-sitter-java.ts index 1d458e3..7d50b72 100644 --- a/packages/core/src/extractors/languages/generated/tree-sitter-java.ts +++ b/packages/core/src/extractors/languages/generated/tree-sitter-java.ts @@ -38,6 +38,7 @@ const CONFIG: ProviderConfig = { variable: "variable", }, callCaptures: ["reference.call"], + importNodeTypes: ["import_declaration"], }; export const TREE_SITTER_JAVA_PROVIDERS: readonly LanguageProvider[] = [ diff --git a/packages/core/src/extractors/languages/generated/tree-sitter-php.ts b/packages/core/src/extractors/languages/generated/tree-sitter-php.ts index 12e6e90..1816def 100644 --- a/packages/core/src/extractors/languages/generated/tree-sitter-php.ts +++ b/packages/core/src/extractors/languages/generated/tree-sitter-php.ts @@ -58,6 +58,7 @@ const CONFIG: ProviderConfig = { variable: "variable", }, callCaptures: ["reference.call"], + importNodeTypes: ["namespace_use_declaration"], }; export const TREE_SITTER_PHP_PROVIDERS: readonly LanguageProvider[] = [ diff --git a/packages/core/src/extractors/languages/generated/tree-sitter-python.ts b/packages/core/src/extractors/languages/generated/tree-sitter-python.ts index 28a9ec1..71da9e9 100644 --- a/packages/core/src/extractors/languages/generated/tree-sitter-python.ts +++ b/packages/core/src/extractors/languages/generated/tree-sitter-python.ts @@ -32,6 +32,7 @@ const CONFIG: ProviderConfig = { variable: "variable", }, callCaptures: ["reference.call"], + importNodeTypes: ["import_statement", "import_from_statement"], }; export const TREE_SITTER_PYTHON_PROVIDERS: readonly LanguageProvider[] = [ diff --git a/packages/core/src/extractors/languages/generated/tree-sitter-rust.ts b/packages/core/src/extractors/languages/generated/tree-sitter-rust.ts index 3e6fff1..a5e20c5 100644 --- a/packages/core/src/extractors/languages/generated/tree-sitter-rust.ts +++ b/packages/core/src/extractors/languages/generated/tree-sitter-rust.ts @@ -78,6 +78,7 @@ const CONFIG: ProviderConfig = { variable: "variable", }, callCaptures: ["reference.call"], + importNodeTypes: ["use_declaration"], }; export const TREE_SITTER_RUST_PROVIDERS: readonly LanguageProvider[] = [ diff --git a/packages/core/src/extractors/languages/generated/tree-sitter-scala.ts b/packages/core/src/extractors/languages/generated/tree-sitter-scala.ts index 2c47fac..a2887a8 100644 --- a/packages/core/src/extractors/languages/generated/tree-sitter-scala.ts +++ b/packages/core/src/extractors/languages/generated/tree-sitter-scala.ts @@ -84,6 +84,7 @@ const CONFIG: ProviderConfig = { variable: "variable", }, callCaptures: ["reference.call"], + importNodeTypes: ["import_declaration"], }; export const TREE_SITTER_SCALA_PROVIDERS: readonly LanguageProvider[] = [ diff --git a/packages/core/src/extractors/languages/multiLanguage.test.ts b/packages/core/src/extractors/languages/multiLanguage.test.ts index a7a9eb3..769c7a6 100644 --- a/packages/core/src/extractors/languages/multiLanguage.test.ts +++ b/packages/core/src/extractors/languages/multiLanguage.test.ts @@ -124,4 +124,33 @@ describe("multi-language extraction (data-only providers)", () => { expect(result.symbols.every((s) => s.language === c.language)).toBe(true); }); } + + // IMPORTS edges must exist for non-TS/JS languages whose provider declares + // import node types (previously the import walk was TS/JS-only). + const IMPORT_CASES = [ + { + language: "python", + path: "a.py", + source: "import os\nfrom a.b import c\n", + expect: ["os", "a.b"], + }, + { language: "go", path: "a.go", source: 'package m\nimport "fmt"\n', expect: ["fmt"] }, + { language: "rust", path: "a.rs", source: "use std::io;\n", expect: ["std::io"] }, + ]; + + for (const c of IMPORT_CASES) { + it(`produces IMPORTS edges for ${c.language}`, async () => { + const result = await extract(c.source, c.path, c.language); + expect(result.imports.length).toBeGreaterThan(0); + // Every import edge is tagged with the file's language, not hardcoded TS. + expect(result.imports.every((i) => i.language === c.language)).toBe(true); + const paths = result.imports.map((i) => i.importPath); + for (const want of c.expect) { + expect( + paths.some((p) => p.includes(want)), + `${c.language} import path should include ${want}; got ${paths.join(", ")}`, + ).toBe(true); + } + }); + } }); diff --git a/packages/core/src/extractors/languages/types.ts b/packages/core/src/extractors/languages/types.ts index fb63a95..6338faf 100644 --- a/packages/core/src/extractors/languages/types.ts +++ b/packages/core/src/extractors/languages/types.ts @@ -28,6 +28,14 @@ export interface ProviderConfig { * no such edges for the language. */ readonly relationCaptures?: Readonly>; + /** + * Top-level AST node types that represent an import in this grammar (e.g. TS + * `["import_statement"]`, Python `["import_statement","import_from_statement"]`, + * Go `["import_declaration"]`, Rust `["use_declaration"]`). The engine reads the + * module specifier from these and emits an `IMPORTS` edge per import. Omitted → + * no IMPORTS edges for the language (e.g. structural formats). + */ + readonly importNodeTypes?: readonly string[]; /** * Structural-only formats (markdown/yaml/json) that have no call graph. When * true the engine still records file + definition nodes but fabricates no diff --git a/packages/core/src/extractors/languages/typescript.ts b/packages/core/src/extractors/languages/typescript.ts index ad681df..2e3c598 100644 --- a/packages/core/src/extractors/languages/typescript.ts +++ b/packages/core/src/extractors/languages/typescript.ts @@ -171,6 +171,7 @@ const TS_CONFIG: ProviderConfig = { module: "type", }, callCaptures: ["reference.call"], + importNodeTypes: ["import_statement"], relationCaptures: { "reference.extends": "INHERITS", "reference.implements": "IMPLEMENTS", diff --git a/packages/core/src/extractors/registry.test.ts b/packages/core/src/extractors/registry.test.ts index 2eb2ca6..8de5aca 100644 --- a/packages/core/src/extractors/registry.test.ts +++ b/packages/core/src/extractors/registry.test.ts @@ -24,8 +24,6 @@ function emptyResult(): ExtractionResult { imports: [], edges: [], annotations: [], - modules: [], - tests: [], }; } diff --git a/packages/core/src/extractors/registry.ts b/packages/core/src/extractors/registry.ts index 5bd2d5a..994c3c2 100644 --- a/packages/core/src/extractors/registry.ts +++ b/packages/core/src/extractors/registry.ts @@ -15,7 +15,7 @@ function toKnownSymbol(s: StoredSymbol): KnownSymbol { name: s.name, kind: s.kind, // StoredSymbol.range uses 0-based rows (tree-sitter convention from toRange()). - // NaiveCallExtractor also uses 0-based node.startPosition.row — no adjustment needed. + // Extractors also use 0-based node.startPosition.row — no adjustment needed. startLine: s.range.startLine, startCol: s.range.startCol, endLine: s.range.endLine, @@ -47,12 +47,10 @@ class InMemoryExtractorRegistry implements ExtractorRegistry { const imports: ExtractedImportRef[] = []; const edges: EdgeRow[] = []; const annotations: unknown[] = []; - const modules: unknown[] = []; - const tests: unknown[] = []; // Accumulate known symbols so each extractor sees the IDs the earlier - // extractors already minted. This lets NaiveCallExtractor look up the - // exact symbol IDs that BaselineTsJsExtractor wrote instead of minting - // its own (which would produce dangling foreign keys in the edge table). + // extractors already minted. This lets relational extractors look up the + // exact symbol IDs that definition extractors wrote instead of minting + // their own (which would produce dangling foreign keys in the edge table). const knownSymbols: KnownSymbol[] = [...(input.knownSymbols ?? [])]; for (const extractor of matching) { @@ -61,7 +59,8 @@ class InMemoryExtractorRegistry implements ExtractorRegistry { try { result = await extractor.extract(enrichedInput); } catch (err) { - // Per FR-007 / contract: failure isolation. Log and continue. + // Failure isolation: one extractor's error must not abort the rest. + // Log and continue. this.logger?.warn("Extractor failed", { extractor: extractor.name, file: input.absolutePath, @@ -86,17 +85,11 @@ class InMemoryExtractorRegistry implements ExtractorRegistry { if (result.annotations !== undefined) { annotations.push(...result.annotations); } - if (result.modules !== undefined) { - modules.push(...result.modules); - } - if (result.tests !== undefined) { - tests.push(...result.tests); - } // Forward this extractor's symbols to all subsequent extractors. knownSymbols.push(...result.symbols.map(toKnownSymbol)); } - return { file, symbols, imports, edges, annotations, modules, tests }; + return { file, symbols, imports, edges, annotations }; } } diff --git a/packages/core/src/extractors/types.ts b/packages/core/src/extractors/types.ts index 954578a..d0e1d77 100644 --- a/packages/core/src/extractors/types.ts +++ b/packages/core/src/extractors/types.ts @@ -4,8 +4,8 @@ import type { ExtractedFileRecord, ExtractedImportRef, StoredSymbol } from "../t /** * Minimal symbol descriptor forwarded from earlier extractors so that later - * extractors (e.g. NaiveCallExtractor) can resolve symbol IDs without minting - * their own. Populated by the registry after each extractor runs. + * relational extractors can resolve symbol IDs without minting their own. + * Populated by the registry after each extractor runs. */ export interface KnownSymbol { readonly id: string; @@ -60,8 +60,7 @@ export interface EdgeRow { } /** - * What each extractor returns. Matches `.dextree/design.md` §8.6 except for the - * additive `imports` field — slice 009 persists imports as `edge` rows, but the + * What each extractor returns. Imports are persisted as `edge` rows, but the * in-memory representation still travels through this typed array on the way to * `replaceFileGraph`. * @@ -74,16 +73,14 @@ export interface ExtractionResult { readonly imports: readonly ExtractedImportRef[]; readonly edges: readonly EdgeRow[]; readonly annotations?: readonly unknown[]; - readonly modules?: readonly unknown[]; - readonly tests?: readonly unknown[]; } /** * Plugin-style extractor contract. `name` is unique across registrations. * `version` is informational and surfaces in `console.warn` failure logs. * `supports(language)` is the language filter; `extract(input)` produces rows. - * Future plugin-loaded extractors will implement the same interface — slice 010 - * keeps the surface internal, so no packaging / sandboxing yet. + * Future plugin-loaded extractors will implement the same interface; the surface + * is internal for now, so no packaging / sandboxing yet. */ export interface Extractor { readonly name: string; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 26aeb5d..de1891c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,5 +1,5 @@ import { mkdir, readFile } from "node:fs/promises"; -import { dirname } from "node:path"; +import { dirname, isAbsolute } from "node:path"; import { v4 as uuidv4 } from "uuid"; @@ -113,7 +113,14 @@ export type { export { getPresentEdgeKinds } from "./query/presentEdgeKinds.js"; export { getCoverageReport } from "./query/coverage.js"; export { neighborhood } from "./query/neighborhood.js"; -export type { CallResolver, ResolvedEdge, ResolutionTier } from "./resolution/types.js"; +export type { + CallResolver, + ResolvedEdge, + ResolutionTier, + NodeLocation, + PreciseCallEdge, + PreciseLocationResolver, +} from "./resolution/types.js"; export type { ForeignWorkspaceGraph, WorkspaceIndexSummary } from "./storage/workspaceRegistry.js"; export { readWorkspaceGraph, readWorkspaceIndexSummary } from "./storage/workspaceRegistry.js"; @@ -183,6 +190,15 @@ class DuckTreeIndexer implements Indexer { workspaceRoot: string, cacheIdentity?: WorkspaceCacheIdentity, ): Promise { + // Fail fast at the boundary (RULE-ARCH-006) — an empty/relative path would + // otherwise surface as an opaque fs error deep in extraction. + if (!isAbsolute(absolutePath)) { + throw new Error(`indexFile: absolutePath must be an absolute path, got "${absolutePath}"`); + } + if (!isAbsolute(workspaceRoot)) { + throw new Error(`indexFile: workspaceRoot must be an absolute path, got "${workspaceRoot}"`); + } + const existing = this.indexFileInFlight.get(absolutePath); if (existing !== undefined) { return existing; @@ -250,7 +266,7 @@ class DuckTreeIndexer implements Indexer { // (which `replaceFileGraph` writes itself) flow through `extraEdges`. const extraEdges = [...result.edges]; - // Per-file framework attribution (slice 018) is needed before classification + // Per-file framework attribution is needed before classification // so the classifier can use it as one of its local structural inputs. const detected = this.frameworkCache.get(workspaceRoot) ?? []; const attribution = @@ -367,6 +383,11 @@ class DuckTreeIndexer implements Indexer { } async neighborhood(nodeId: string, options: NeighborhoodOptions): Promise { + // Fail fast at the boundary (RULE-ARCH-006) — an empty node id would scan + // from a non-existent seed and silently return nothing. + if (nodeId.trim() === "") { + throw new Error("neighborhood: nodeId must be a non-empty string"); + } await this.initialize(); const database = this.requireDatabaseHandle(); return neighborhood(database.connection, nodeId, options); diff --git a/packages/core/src/parser/extractor.ts b/packages/core/src/parser/extractor.ts index 482e1bd..2b1abac 100644 --- a/packages/core/src/parser/extractor.ts +++ b/packages/core/src/parser/extractor.ts @@ -138,7 +138,15 @@ function expandedCandidates(basePath: string): string[] { ]; } -/** Per-workspace-root cache of tsconfig `compilerOptions.paths` alias mappings. */ +/** + * Per-workspace-root cache of tsconfig `compilerOptions.paths` alias mappings. + * + * Module-global and never evicted: it grows by one entry per distinct + * workspace root seen for the process lifetime. Bounded in practice (a session + * indexes a handful of roots), but unlike `frameworkCache` it has no + * clear-on-workspace hook — if a long-lived host indexes many roots this leaks. + * Acceptable for now; revisit with a bounded/LRU cache if it ever matters. + */ const pathAliasCache = new Map>(); /** Candidate tsconfig filenames to probe, in preference order. */ @@ -202,45 +210,64 @@ async function loadPathAliases(workspaceRoot: string): Promise { const imports: ExtractedImportRef[] = []; + const nodeTypes = new Set(importNodeTypes); for (const child of rootChildren) { - if (child.type !== "import_statement") { + if (!nodeTypes.has(child.type)) { continue; } - const match = child.text.match(/["']([^"']+)["']/); - const specifier = match?.[1]; + // First quoted/bracketed module specifier in the import text. Works across + // grammars: TS/Python `"x"`/`'x'`, Go `"fmt"`, Rust/Java fall back to the + // dotted/scoped path below when there is no quoted string. + const quoted = child.text.match(/["']([^"']+)["']/)?.[1]; + const specifier = quoted ?? readUnquotedSpecifier(child); - if (specifier === undefined) { + if (specifier === undefined || specifier === "") { continue; } - const importPath = await resolveImportPath(absolutePath, workspaceRoot, specifier); - - if (importPath === null) { - continue; - } + // Path resolution is TS-style (relative + tsconfig aliases). When it resolves + // to a workspace file, store that; otherwise keep the raw specifier so the + // IMPORTS edge still exists for every language, not only TS/JS. + const resolved = await resolveImportPath(absolutePath, workspaceRoot, specifier); imports.push({ id: uuidv4(), fileId, - importPath, + importPath: resolved ?? specifier, importedSymbol: null, range: toRange(child), - language: "typescript", + language, }); } return imports; } +/** + * Best-effort module specifier for grammars whose import has no quoted string + * (Rust `use a::b::c;`, Java `import a.b.C;`) — take the dotted/scoped path token. + */ +function readUnquotedSpecifier(node: Node): string | undefined { + const text = node.text + .replace(/^\s*(use|import)\s+/, "") + .replace(/;\s*$/, "") + .trim(); + return text.length > 0 ? text : undefined; +} + export async function extractPlainFile( absolutePath: string, workspaceRoot: string, diff --git a/packages/core/src/quality/recomputeGraphHealth.ts b/packages/core/src/quality/recomputeGraphHealth.ts index 97a9425..5e76cc3 100644 --- a/packages/core/src/quality/recomputeGraphHealth.ts +++ b/packages/core/src/quality/recomputeGraphHealth.ts @@ -1,4 +1,4 @@ -import type { DuckDBConnection } from "@duckdb/node-api"; +import type { GraphDbConnection } from "../storage/db.js"; /** * Recompute graph-health attributes (`Symbol.fan_in`, `Symbol.is_core`, @@ -12,9 +12,8 @@ import type { DuckDBConnection } from "@duckdb/node-api"; * Owned by `packages/core/src/quality/` per Constitution III: "quality logic * MUST remain under `packages/core/src/quality`". * - * STUB — implementation scheduled for slice S11.7 (PageRank symbol ranking + - * community overlay). See `.dextree/design.md` §7.5 (Blast Radius) and ROADMAP - * slice S11.7 for the planned algorithm: + * STUB — implementation pending (PageRank symbol ranking + community overlay). + * See `.dextree/design.md` §7.5 (Blast Radius) for the planned algorithm: * 1. Build a graphology MultiDirectedGraph from the persisted `edge` table. * 2. Compute per-symbol fan-in (incoming CALLS + REFERENCES). * 3. Run PageRank; mark top-5% as `is_core`. @@ -26,10 +25,10 @@ import type { DuckDBConnection } from "@duckdb/node-api"; * the real implementation lands — without re-touching `repository.ts` or the * indexer orchestration. */ -export async function recomputeGraphHealth(_connection: DuckDBConnection): Promise { +export async function recomputeGraphHealth(_connection: GraphDbConnection): Promise { // Intentionally a no-op stub for the MVP foundation. - // Throwing here would break the workspace-indexing loop in slice S6 once any - // caller wires this in; instead the function is a no-op until S11.7 lands real - // logic. The `_connection` parameter is reserved for that implementation. + // Throwing here would break the workspace-indexing loop once any caller wires + // this in; instead the function is a no-op until real logic lands. The + // `_connection` parameter is reserved for that implementation. return; } diff --git a/packages/core/src/query/coverage.ts b/packages/core/src/query/coverage.ts index a6330eb..9946336 100644 --- a/packages/core/src/query/coverage.ts +++ b/packages/core/src/query/coverage.ts @@ -1,4 +1,4 @@ -import type { DuckDBConnection } from "@duckdb/node-api"; +import type { GraphDbConnection } from "../storage/db.js"; import type { CoverageReport, CoverageRow } from "../types.js"; @@ -10,7 +10,7 @@ export type { CoverageReport, CoverageRow }; * tier comes from `metadata.resolution` (stamped during resolution); * `precise`/`heuristic` count as resolved, `unresolved` does not. */ -export async function getCoverageReport(connection: DuckDBConnection): Promise { +export async function getCoverageReport(connection: GraphDbConnection): Promise { const reader = await connection.run( ` SELECT diff --git a/packages/core/src/query/files.ts b/packages/core/src/query/files.ts index 7e5df75..1162bf0 100644 --- a/packages/core/src/query/files.ts +++ b/packages/core/src/query/files.ts @@ -1,8 +1,8 @@ -import type { DuckDBConnection } from "@duckdb/node-api"; +import type { GraphDbConnection } from "../storage/db.js"; import type { StoredFile } from "../types.js"; -export async function getAllFilesQuery(connection: DuckDBConnection): Promise { +export async function getAllFilesQuery(connection: GraphDbConnection): Promise { const rows = await ( await connection.run(` SELECT diff --git a/packages/core/src/query/neighborhood.ts b/packages/core/src/query/neighborhood.ts index e96bf87..ae75643 100644 --- a/packages/core/src/query/neighborhood.ts +++ b/packages/core/src/query/neighborhood.ts @@ -1,4 +1,4 @@ -import type { DuckDBConnection, DuckDBValue } from "@duckdb/node-api"; +import type { GraphDbConnection, GraphDbValue } from "../storage/db.js"; import type { GraphEdge, @@ -21,7 +21,7 @@ const DEFAULT_MAX_NODES = 5000; * terminate; `truncated` signals the depth/size cap was hit. */ export async function neighborhood( - connection: DuckDBConnection, + connection: GraphDbConnection, nodeId: string, options: NeighborhoodOptions, ): Promise { @@ -34,7 +34,7 @@ export async function neighborhood( const kindFilter = kinds.length > 0 ? `AND e.kind IN (${kinds.map((_, i) => `$k${i}`).join(", ")})` : ""; - const params: Record = { seed: nodeId, max_depth: depth }; + const params: Record = { seed: nodeId, max_depth: depth }; kinds.forEach((k, i) => { params[`k${i}`] = k; }); @@ -92,12 +92,12 @@ export async function neighborhood( /** All edges whose endpoints are both within the reached set (and pass the kind filter). */ async function edgesAmong( - connection: DuckDBConnection, + connection: GraphDbConnection, nodeIds: string[], kinds: readonly GraphEdgeKind[], ): Promise { const idList = nodeIds.map((_, i) => `$n${i}`).join(", "); - const params: Record = {}; + const params: Record = {}; nodeIds.forEach((id, i) => { params[`n${i}`] = id; }); @@ -127,9 +127,12 @@ async function edgesAmong( } /** Hydrate node ids into GraphNodes from the folder/file/symbol tables. */ -async function hydrateNodes(connection: DuckDBConnection, nodeIds: string[]): Promise { +async function hydrateNodes( + connection: GraphDbConnection, + nodeIds: string[], +): Promise { const idList = nodeIds.map((_, i) => `$h${i}`).join(", "); - const params: Record = {}; + const params: Record = {}; nodeIds.forEach((id, i) => { params[`h${i}`] = id; }); diff --git a/packages/core/src/query/presentEdgeKinds.ts b/packages/core/src/query/presentEdgeKinds.ts index faf6871..21e70e2 100644 --- a/packages/core/src/query/presentEdgeKinds.ts +++ b/packages/core/src/query/presentEdgeKinds.ts @@ -1,4 +1,4 @@ -import type { DuckDBConnection } from "@duckdb/node-api"; +import type { GraphDbConnection } from "../storage/db.js"; /** * Returns the sorted, deduplicated set of `edge.kind` values currently present @@ -12,7 +12,7 @@ import type { DuckDBConnection } from "@duckdb/node-api"; * Empty result on a fresh / empty workspace is a normal outcome, not an error. */ export async function getPresentEdgeKinds( - connection: DuckDBConnection, + connection: GraphDbConnection, _workspaceRoot: string, ): Promise { const rows = await ( diff --git a/packages/core/src/query/sessionSummary.ts b/packages/core/src/query/sessionSummary.ts index 5694b47..f7e476f 100644 --- a/packages/core/src/query/sessionSummary.ts +++ b/packages/core/src/query/sessionSummary.ts @@ -1,4 +1,4 @@ -import type { DuckDBConnection } from "@duckdb/node-api"; +import type { GraphDbConnection } from "../storage/db.js"; import { basename } from "node:path"; import { EmptyGraphError, type SessionSummary } from "../types.js"; @@ -10,10 +10,10 @@ import { EmptyGraphError, type SessionSummary } from "../types.js"; * or data changes (CC-001). Returns only pass-1 structural data; pass-2 * enrichment fields are not required (CC-003). * - * @throws {EmptyGraphError} when no files have been indexed (FR-005). + * @throws {EmptyGraphError} when no files have been indexed. */ export async function querySessionSummary( - connection: DuckDBConnection, + connection: GraphDbConnection, workspaceRoot: string, ): Promise { // 1. File count (also serves as empty-graph guard) diff --git a/packages/core/src/query/subgraph.ts b/packages/core/src/query/subgraph.ts index 2bb4597..bd14fd7 100644 --- a/packages/core/src/query/subgraph.ts +++ b/packages/core/src/query/subgraph.ts @@ -1,4 +1,4 @@ -import type { DuckDBConnection } from "@duckdb/node-api"; +import type { GraphDbConnection } from "../storage/db.js"; import { MultiDirectedGraph } from "graphology"; import type { @@ -20,6 +20,41 @@ function workspaceParams(workspaceRoot: string) { }; } +/** + * Fetch resolved symbol→symbol edges of one kind, both endpoints inside the + * workspace. CALLS/INHERITS/INSTANTIATES/IMPLEMENTS share this exact shape; only + * the edge kind differs. The kind is a fixed literal (never user input), so it is + * interpolated directly rather than bound. + */ +async function queryResolvedEdges( + connection: GraphDbConnection, + kind: "CALLS" | "INHERITS" | "INSTANTIATES" | "IMPLEMENTS", + params: ReturnType, +): Promise[]> { + return ( + await connection.run( + ` + SELECT + MIN(e.id) AS id, + e.source_id AS source, + e.target_id AS target + FROM edge e + INNER JOIN symbol src_symbol ON src_symbol.id = e.source_id + INNER JOIN symbol dst_symbol ON dst_symbol.id = e.target_id + INNER JOIN file src_file ON src_file.id = src_symbol.file_id + INNER JOIN file dst_file ON dst_file.id = dst_symbol.file_id + WHERE e.kind = '${kind}' + AND e.target_id IS NOT NULL + AND (src_file.path = $workspace_root OR src_file.path LIKE $workspace_prefix) + AND (dst_file.path = $workspace_root OR dst_file.path LIKE $workspace_prefix) + GROUP BY e.source_id, e.target_id + ORDER BY source ASC, target ASC + `, + params, + ) + ).getRowObjectsJS(); +} + function normalizeRange(value: unknown): SymbolRange { const range = value as Record; @@ -34,7 +69,7 @@ function normalizeRange(value: unknown): SymbolRange { /** * DuckDB returns `VARCHAR[]` columns as JS arrays. NULL maps to undefined so * downstream "is this defined?" checks behave correctly. An empty array stays - * as `[]` — meaningful distinct from undefined (slice 020 FR-010). + * as `[]` — meaningful distinct from undefined. */ function normalizeStringArray(value: unknown): readonly string[] | undefined { if (value === null || value === undefined) return undefined; @@ -80,7 +115,7 @@ function normalizeArchLayer(value: unknown): ArchitecturalLayer | undefined { } export async function getWorkspaceSubgraph( - connection: DuckDBConnection, + connection: GraphDbConnection, workspaceRoot: string, ): Promise { const params = workspaceParams(workspaceRoot); @@ -145,9 +180,9 @@ export async function getWorkspaceSubgraph( ) ).getRowObjectsJS(); - // Slice 031 US3 — count annotations per parent symbol so the graph view's - // Decorator node-filter chip can become truthful (and not a placeholder). - // An empty result is the honest signal for an unsupported workspace. + // Count annotations per parent symbol so the graph view's Decorator + // node-filter chip can become truthful (and not a placeholder). An empty + // result is the honest signal for an unsupported workspace. const annotationCountRows = await ( await connection.run( ` @@ -209,106 +244,14 @@ export async function getWorkspaceSubgraph( ) ).getRowObjectsJS(); - // Post-v3: CALLS edges live in the unified `edge` table too. Pass-1 may leave - // target_id NULL (unresolved); pass-2 LSP (S8) will fill it in. This query - // shows only resolved calls. - const callRows = await ( - await connection.run( - ` - SELECT - MIN(e.id) AS id, - e.source_id AS source, - e.target_id AS target - FROM edge e - INNER JOIN symbol src_symbol ON src_symbol.id = e.source_id - INNER JOIN symbol dst_symbol ON dst_symbol.id = e.target_id - INNER JOIN file src_file ON src_file.id = src_symbol.file_id - INNER JOIN file dst_file ON dst_file.id = dst_symbol.file_id - WHERE e.kind = 'CALLS' - AND e.target_id IS NOT NULL - AND (src_file.path = $workspace_root OR src_file.path LIKE $workspace_prefix) - AND (dst_file.path = $workspace_root OR dst_file.path LIKE $workspace_prefix) - GROUP BY e.source_id, e.target_id - ORDER BY source ASC, target ASC - `, - params, - ) - ).getRowObjectsJS(); - - // INHERITS: class → base class (resolved source + target symbols, cross-file included) - const inheritsRows = await ( - await connection.run( - ` - SELECT - MIN(e.id) AS id, - e.source_id AS source, - e.target_id AS target - FROM edge e - INNER JOIN symbol src_symbol ON src_symbol.id = e.source_id - INNER JOIN symbol dst_symbol ON dst_symbol.id = e.target_id - INNER JOIN file src_file ON src_file.id = src_symbol.file_id - INNER JOIN file dst_file ON dst_file.id = dst_symbol.file_id - WHERE e.kind = 'INHERITS' - AND e.target_id IS NOT NULL - AND (src_file.path = $workspace_root OR src_file.path LIKE $workspace_prefix) - AND (dst_file.path = $workspace_root OR dst_file.path LIKE $workspace_prefix) - GROUP BY e.source_id, e.target_id - ORDER BY source ASC, target ASC - `, - params, - ) - ).getRowObjectsJS(); - - // INSTANTIATES: symbol/file → class (resolved source + target) - const instantiatesRows = await ( - await connection.run( - ` - SELECT - MIN(e.id) AS id, - e.source_id AS source, - e.target_id AS target - FROM edge e - INNER JOIN symbol src_symbol ON src_symbol.id = e.source_id - INNER JOIN symbol dst_symbol ON dst_symbol.id = e.target_id - INNER JOIN file src_file ON src_file.id = src_symbol.file_id - INNER JOIN file dst_file ON dst_file.id = dst_symbol.file_id - WHERE e.kind = 'INSTANTIATES' - AND e.target_id IS NOT NULL - AND (src_file.path = $workspace_root OR src_file.path LIKE $workspace_prefix) - AND (dst_file.path = $workspace_root OR dst_file.path LIKE $workspace_prefix) - GROUP BY e.source_id, e.target_id - ORDER BY source ASC, target ASC - `, - params, - ) - ).getRowObjectsJS(); - - // IMPLEMENTS: class → interface (resolved source + target, same shape as INHERITS). - // Slice 031 US2 — kept distinct from INHERITS by edge kind so the classDiagram - // renderer can use a different relationship arrow (`<|..`) and the graph edge - // filter exposes them separately. - const implementsRows = await ( - await connection.run( - ` - SELECT - MIN(e.id) AS id, - e.source_id AS source, - e.target_id AS target - FROM edge e - INNER JOIN symbol src_symbol ON src_symbol.id = e.source_id - INNER JOIN symbol dst_symbol ON dst_symbol.id = e.target_id - INNER JOIN file src_file ON src_file.id = src_symbol.file_id - INNER JOIN file dst_file ON dst_file.id = dst_symbol.file_id - WHERE e.kind = 'IMPLEMENTS' - AND e.target_id IS NOT NULL - AND (src_file.path = $workspace_root OR src_file.path LIKE $workspace_prefix) - AND (dst_file.path = $workspace_root OR dst_file.path LIKE $workspace_prefix) - GROUP BY e.source_id, e.target_id - ORDER BY source ASC, target ASC - `, - params, - ) - ).getRowObjectsJS(); + // Resolved symbol→symbol edges (both endpoints in the workspace). Pass-1 may + // leave target_id NULL (unresolved); these queries show only resolved edges. + // IMPLEMENTS is kept a distinct kind from INHERITS so the classDiagram renderer + // can use a different arrow (`<|..`) and the edge filter exposes them separately. + const callRows = await queryResolvedEdges(connection, "CALLS", params); + const inheritsRows = await queryResolvedEdges(connection, "INHERITS", params); + const instantiatesRows = await queryResolvedEdges(connection, "INSTANTIATES", params); + const implementsRows = await queryResolvedEdges(connection, "IMPLEMENTS", params); const nodes: GraphNode[] = [ ...fileRows.map((row) => { @@ -348,9 +291,9 @@ export async function getWorkspaceSubgraph( typeof rawEnclosing === "string" && rawEnclosing.length > 0 ? rawEnclosing : undefined; const symbolId = String(row.id); const annotationCount = annotationCountsBySymbolId.get(symbolId) ?? 0; - // Slice 031 US3 — project decorator-backed presence onto the node's - // flags so the Decorator node-filter chip in the webview can become a - // truthful filter instead of a disabled stub. + // Project decorator-backed presence onto the node's flags so the + // Decorator node-filter chip in the webview can become a truthful filter + // instead of a disabled stub. const flags = annotationCount > 0 ? (Object.freeze([...(baseFlags ?? []), "decorator-backed"]) as readonly string[]) diff --git a/packages/core/src/query/symbols.ts b/packages/core/src/query/symbols.ts index ccdfe42..fcc4983 100644 --- a/packages/core/src/query/symbols.ts +++ b/packages/core/src/query/symbols.ts @@ -1,4 +1,4 @@ -import type { DuckDBConnection } from "@duckdb/node-api"; +import type { GraphDbConnection } from "../storage/db.js"; import type { StoredSymbol, SymbolRange } from "../types.js"; @@ -14,7 +14,7 @@ function normalizeRange(value: unknown): SymbolRange { } export async function getSymbolsForFile( - connection: DuckDBConnection, + connection: GraphDbConnection, relativePath: string, ): Promise { const rows = await ( diff --git a/packages/core/src/resolution/types.ts b/packages/core/src/resolution/types.ts index 651e372..980f4b6 100644 --- a/packages/core/src/resolution/types.ts +++ b/packages/core/src/resolution/types.ts @@ -31,3 +31,36 @@ export interface CallResolver { */ resolve(nodeId: string, direction: "in" | "out"): Promise; } + +/** A node's source position, for resolvers that query by location (e.g. an LSP). */ +export interface NodeLocation { + filePath: string; + /** 0-based line. */ + line: number; + /** 0-based column. */ + column: number; +} + +/** A precise caller/callee resolved by a location-based source (e.g. a language server). */ +export interface PreciseCallEdge { + /** Display name of the related symbol (the caller or callee). */ + name: string; + filePath: string; + /** 0-based start line. */ + line: number; + readonly tier: "precise"; + confidence: number; +} + +/** + * Host-side precise resolver contract (RULE-ARCH-010). Unlike {@link CallResolver} + * (graph-node-id based, in `core`), this resolves from a source *position* because + * the precise source — the user's language server — answers by file+position, not + * by our graph ids. The host (VS Code / IntelliJ) implements it; `core` owns the + * contract so the seam is real and portable. Degrades to an empty array when no + * precise source answers, leaving the heuristic tier in place. + */ +export interface PreciseLocationResolver { + readonly tier: "precise"; + resolve(node: NodeLocation, direction: "in" | "out"): Promise; +} diff --git a/packages/core/src/storage/adapters/duckdb.ts b/packages/core/src/storage/adapters/duckdb.ts new file mode 100644 index 0000000..1d03e06 --- /dev/null +++ b/packages/core/src/storage/adapters/duckdb.ts @@ -0,0 +1,135 @@ +import type { + DuckDBConnection, + DuckDBInstance, + DuckDBMaterializedResult, + DuckDBValue, +} from "@duckdb/node-api"; + +import type { Logger } from "../../types.js"; + +/** + * DuckDB adapter — the ONLY module in core permitted to import the `@duckdb/node-api` + * driver (RULE-ARCH-003, enforced by ESLint). Storage and query modules depend on + * the connection type re-exported here, not on the driver directly, so the concrete + * database is swappable from one place. + */ + +/** The database connection surface the storage + query layers depend on. */ +export type GraphDbConnection = DuckDBConnection; +/** A materialized query result (row readers). */ +export type GraphDbResult = DuckDBMaterializedResult; +/** A bound statement parameter value. */ +export type GraphDbValue = DuckDBValue; + +export interface DatabaseHandle { + instance: DuckDBInstance; + connection: GraphDbConnection; + close(): void; +} + +// Per-connection transaction state. Keyed by connection (not a module-global +// boolean) so the nesting guard reflects *this* connection's state — a process +// that holds several handles (e.g. the writable workspace DB plus read-only +// foreign DBs for the switcher) must not let one connection's transaction flip +// another's guard. WeakMap so a closed connection's entry is collected with it. +const activeTransactions = new WeakMap(); + +export async function openDatabase(dbPath: string): Promise { + const { DuckDBInstance } = await import("@duckdb/node-api"); + const instance = await DuckDBInstance.create(dbPath); + const connection = await instance.connect(); + + return { + instance, + connection, + close() { + connection.closeSync(); + instance.closeSync(); + }, + }; +} + +/** + * Open a foreign DuckDB file read-only (e.g. another workspace's index for the + * switcher). Read-only so we never mutate a DB owned by a different session. + */ +export async function openReadOnlyDatabase(dbPath: string): Promise { + const { DuckDBInstance } = await import("@duckdb/node-api"); + const instance = await DuckDBInstance.create(dbPath, { access_mode: "READ_ONLY" }); + const connection = await instance.connect(); + + return { + instance, + connection, + close() { + connection.closeSync(); + instance.closeSync(); + }, + }; +} + +/** + * Runs `operation` inside a BEGIN TRANSACTION / COMMIT block. + * + * Resilience contract: + * - A defensive ROLLBACK is issued before BEGIN TRANSACTION to clear any + * aborted state left by a previous failed transaction (e.g. if the prior + * ROLLBACK threw due to a DuckDB Node API quirk). Errors from this + * pre-flight ROLLBACK are silently ignored — the only two outcomes are + * "no active transaction" (normal) or "aborted state cleared" (desired). + * - On operation failure, ROLLBACK is attempted and any error it throws is + * swallowed so the original error always propagates to the caller. + * - A runtime guard throws `Error("Nested runInTransaction detected")` if + * called while another `runInTransaction` is already in-flight. This + * prevents silent rollback of the outer transaction. + * + * NOTE: `runInTransaction` must never be called from inside another + * `runInTransaction` block. The defensive ROLLBACK would silently undo the + * outer transaction. All call sites in this codebase use it at the top level. + */ +export async function runInTransaction( + connection: GraphDbConnection, + operation: () => Promise, + logger?: Logger, +): Promise { + // Nesting guard — throws synchronously before any SQL is issued. + if (activeTransactions.has(connection)) { + throw new Error("Nested runInTransaction detected"); + } + + // Pre-flight: clear any lingering aborted transaction from a previous call. + // "No active transaction" is expected on a clean connection and not an error. + try { + await connection.run("ROLLBACK"); + } catch { + // Normal path — no active transaction to roll back. + } + + try { + activeTransactions.set(connection, true); + logger?.debug("BEGIN TRANSACTION"); + await connection.run("BEGIN TRANSACTION"); + + const result = await operation(); + logger?.debug("COMMIT"); + await connection.run("COMMIT"); + return result; + } catch (error) { + logger?.error("ROLLBACK", error, {}); + // Wrap ROLLBACK so a failing rollback doesn't replace the original error. + try { + await connection.run("ROLLBACK"); + } catch { + // Best-effort only — the pre-flight on the next call will clean this up. + } + throw error; + } finally { + activeTransactions.delete(connection); + } +} + +export async function readRows( + result: Promise | GraphDbResult, +): Promise[]> { + return (await result).getRowObjectsJS(); +} diff --git a/packages/core/src/storage/clear.ts b/packages/core/src/storage/clear.ts index bedaa67..92542d3 100644 --- a/packages/core/src/storage/clear.ts +++ b/packages/core/src/storage/clear.ts @@ -1,4 +1,4 @@ -import type { DuckDBConnection } from "@duckdb/node-api"; +import { runInTransaction, type GraphDbConnection } from "./db.js"; import { initializeSchema, REQUIRED_TABLES } from "./schema.js"; @@ -26,7 +26,7 @@ function workspaceParams(workspaceRoot: string) { } async function countMatchingFiles( - connection: DuckDBConnection, + connection: GraphDbConnection, workspaceRoot: string, ): Promise { const rows = await ( @@ -41,7 +41,7 @@ async function countMatchingFiles( } async function countMatchingSymbols( - connection: DuckDBConnection, + connection: GraphDbConnection, workspaceRoot: string, ): Promise { const rows = await ( @@ -57,7 +57,7 @@ async function countMatchingSymbols( } async function countMatchingEdges( - connection: DuckDBConnection, + connection: GraphDbConnection, workspaceRoot: string, ): Promise { // Two separate COUNT queries because DuckDB's named-parameter binding @@ -88,7 +88,7 @@ async function countMatchingEdges( } export async function clearWorkspace( - connection: DuckDBConnection, + connection: GraphDbConnection, workspaceRoot: string, ): Promise { const params = workspaceParams(workspaceRoot); @@ -97,9 +97,7 @@ export async function clearWorkspace( const deletedSymbols = await countMatchingSymbols(connection, workspaceRoot); const deletedEdges = await countMatchingEdges(connection, workspaceRoot); - await connection.run("BEGIN TRANSACTION"); - - try { + await runInTransaction(connection, async () => { // NOTE: @duckdb/node-api throws "Failed to retrieve bind parameter index" // when the params dict contains a key the SQL statement does not reference. // Each connection.run below therefore receives ONLY the keys its SQL uses. @@ -125,18 +123,13 @@ export async function clearWorkspace( await connection.run(`DELETE FROM workspace_cache WHERE workspace_root = $workspace_root`, { workspace_root: params.workspace_root, }); - - await connection.run("COMMIT"); - } catch (error) { - await connection.run("ROLLBACK"); - throw error; - } + }); return { deletedFiles, deletedSymbols, deletedEdges }; } export async function clearFile( - connection: DuckDBConnection, + connection: GraphDbConnection, filePath: string, ): Promise { const fileRows = await ( @@ -170,9 +163,7 @@ export async function clearFile( const deletedEdges = Number(edgeFromFileRows[0]?.count ?? 0) + Number(edgeFromSymbolRows[0]?.count ?? 0); - await connection.run("BEGIN TRANSACTION"); - - try { + await runInTransaction(connection, async () => { const symbolSubquery = `(SELECT id FROM symbol WHERE file_id = $file_id)`; await connection.run(`DELETE FROM edge WHERE source_id = $file_id`, params); await connection.run(`DELETE FROM edge WHERE target_id = $file_id`, params); @@ -181,29 +172,19 @@ export async function clearFile( await connection.run(`DELETE FROM diagnostic WHERE file_id = $file_id`, params); await connection.run(`DELETE FROM symbol WHERE file_id = $file_id`, params); await connection.run(`DELETE FROM file WHERE id = $file_id`, params); - await connection.run("COMMIT"); - } catch (error) { - await connection.run("ROLLBACK"); - throw error; - } + }); return { deletedFiles: 1, deletedSymbols, deletedEdges }; } -export async function clearAll(connection: DuckDBConnection): Promise { - await connection.run("BEGIN TRANSACTION"); - - try { +export async function clearAll(connection: GraphDbConnection): Promise { + await runInTransaction(connection, async () => { for (const table of REQUIRED_TABLES) { await connection.run(`DROP TABLE IF EXISTS ${table}`); } + }); - await connection.run("COMMIT"); - } catch (error) { - await connection.run("ROLLBACK"); - throw error; - } - + // Recreate the schema outside the drop transaction — a fresh, independent step. await initializeSchema(connection); return { clearedTables: REQUIRED_TABLES.length }; diff --git a/packages/core/src/storage/db.ts b/packages/core/src/storage/db.ts index 93ca9fe..c868458 100644 --- a/packages/core/src/storage/db.ts +++ b/packages/core/src/storage/db.ts @@ -1,92 +1,16 @@ -import type { DuckDBConnection, DuckDBInstance, DuckDBMaterializedResult } from "@duckdb/node-api"; - -import type { Logger } from "../types.js"; - -export interface DatabaseHandle { - instance: DuckDBInstance; - connection: DuckDBConnection; - close(): void; -} - -let inTransaction = false; - -export async function openDatabase(dbPath: string): Promise { - const { DuckDBInstance } = await import("@duckdb/node-api"); - const instance = await DuckDBInstance.create(dbPath); - const connection = await instance.connect(); - - return { - instance, - connection, - close() { - connection.closeSync(); - instance.closeSync(); - }, - }; -} - /** - * Runs `operation` inside a BEGIN TRANSACTION / COMMIT block. - * - * Resilience contract: - * - A defensive ROLLBACK is issued before BEGIN TRANSACTION to clear any - * aborted state left by a previous failed transaction (e.g. if the prior - * ROLLBACK threw due to a DuckDB Node API quirk). Errors from this - * pre-flight ROLLBACK are silently ignored — the only two outcomes are - * "no active transaction" (normal) or "aborted state cleared" (desired). - * - On operation failure, ROLLBACK is attempted and any error it throws is - * swallowed so the original error always propagates to the caller. - * - A runtime guard throws `Error("Nested runInTransaction detected")` if - * called while another `runInTransaction` is already in-flight. This - * prevents silent rollback of the outer transaction. - * - * NOTE: `runInTransaction` must never be called from inside another - * `runInTransaction` block. The defensive ROLLBACK would silently undo the - * outer transaction. All call sites in this codebase use it at the top level. + * Storage database facade. Re-exports the DuckDB adapter (the only module allowed + * to touch the driver — RULE-ARCH-003). Storage and query modules import the + * connection type (`GraphDbConnection`) and helpers from here, never from + * `@duckdb/node-api` directly. */ -export async function runInTransaction( - connection: DuckDBConnection, - operation: () => Promise, - logger?: Logger, -): Promise { - // Nesting guard — throws synchronously before any SQL is issued. - if (inTransaction) { - throw new Error("Nested runInTransaction detected"); - } - - // Pre-flight: clear any lingering aborted transaction from a previous call. - // "No active transaction" is expected on a clean connection and not an error. - try { - await connection.run("ROLLBACK"); - } catch { - // Normal path — no active transaction to roll back. - } - - try { - inTransaction = true; - logger?.debug("BEGIN TRANSACTION"); - await connection.run("BEGIN TRANSACTION"); - - const result = await operation(); - logger?.debug("COMMIT"); - await connection.run("COMMIT"); - return result; - } catch (error) { - logger?.error("ROLLBACK", error, {}); - // Wrap ROLLBACK so a failing rollback doesn't replace the original error. - try { - await connection.run("ROLLBACK"); - } catch { - // Best-effort only — the pre-flight on the next call will clean this up. - } - throw error; - } finally { - inTransaction = false; - } -} - -export async function readRows( - result: Promise | DuckDBMaterializedResult, -): Promise[]> { - return (await result).getRowObjectsJS(); -} +export { + openDatabase, + openReadOnlyDatabase, + runInTransaction, + readRows, + type DatabaseHandle, + type GraphDbConnection, + type GraphDbResult, + type GraphDbValue, +} from "./adapters/duckdb.js"; diff --git a/packages/core/src/storage/edgeMetadata.ts b/packages/core/src/storage/edgeMetadata.ts new file mode 100644 index 0000000..97b733a --- /dev/null +++ b/packages/core/src/storage/edgeMetadata.ts @@ -0,0 +1,64 @@ +import type { SymbolRange } from "../types.js"; + +/** + * Canonical edge-metadata key names. These keys are written by the extraction + * engine, read by the resolution SQL, and projected by the subgraph query — three + * places that previously hardcoded the same string literals (RULE-ARCH-007). A + * drifted key here is a single edit, and `EdgeMetadata` makes a typo on the + * TypeScript side a compile error. + */ +export const EDGE_META_KEYS = { + /** CALLS: the called symbol's name. */ + calleeName: "callee_name", + /** INHERITS: the parent class name. */ + parentName: "parent_name", + /** INSTANTIATES: the constructed class name. */ + className: "class_name", + /** IMPLEMENTS: the implemented interface name. */ + interfaceName: "interface_name", + /** REFERENCES: the referenced symbol's name. */ + referencedName: "referenced_name", + /** RE_EXPORTS: the re-exported module path. */ + reexportPath: "reexport_path", + /** Source symbol fqn, used to resolve the edge's source_id. */ + sourceFqn: "source_fqn", + /** IMPORTS: the imported module's resolved path (read at query time). */ + importPath: "import_path", + /** Call-site range in the source file. */ + callSiteRange: "call_site_range", + /** Reference-site range in the source file. */ + referenceRange: "reference_range", + /** Resolution tier stamped by the resolver: precise | heuristic | unresolved | structural. */ + resolution: "resolution", + /** Numeric confidence for the resolution tier. */ + confidence: "confidence", +} as const; + +/** + * A single-quoted `'$.key'` JSON path literal for embedding directly in a DuckDB + * `json_extract_string(metadata, ...)` call. Quotes are included so it drops into + * the SQL string as a valid string literal. + */ +export function metaPath(key: keyof typeof EDGE_META_KEYS): string { + return `'$.${EDGE_META_KEYS[key]}'`; +} + +/** + * Typed view of `edge.metadata`. All fields optional — a given edge kind only + * populates the subset relevant to it. Stored as JSON; this is the in-memory + * contract producers and consumers agree on. + */ +export interface EdgeMetadata { + callee_name?: string; + parent_name?: string; + class_name?: string; + interface_name?: string; + referenced_name?: string; + reexport_path?: string; + source_fqn?: string | null; + import_path?: string; + call_site_range?: SymbolRange; + reference_range?: SymbolRange; + resolution?: "precise" | "heuristic" | "unresolved" | "structural"; + confidence?: number; +} diff --git a/packages/core/src/storage/folderTree.ts b/packages/core/src/storage/folderTree.ts new file mode 100644 index 0000000..888827a --- /dev/null +++ b/packages/core/src/storage/folderTree.ts @@ -0,0 +1,80 @@ +import { createHash } from "node:crypto"; + +import type { GraphDbConnection } from "./db.js"; +import { runInTransaction } from "./db.js"; + +/** Deterministic folder id: stable across re-index so the tree doesn't reshuffle. */ +function folderId(path: string): string { + return `folder:${createHash("sha256").update(path, "utf8").digest("hex").slice(0, 32)}`; +} + +/** + * Synthesize `folder` nodes + `CONTAINS` edges from the indexed files' relative + * paths, producing a connected root→folder→file tree. Deterministic ids (path + * hash) keep re-indexing stable. Rebuilt wholesale each call (idempotent): clear + * folders + CONTAINS, then re-derive from current files. Runs in finalize. + */ +export async function synthesizeFolderTree(connection: GraphDbConnection): Promise { + await runInTransaction(connection, async () => { + await connection.run("DELETE FROM folder"); + await connection.run("DELETE FROM edge WHERE kind = 'CONTAINS'"); + + const reader = await connection.run("SELECT id, relative_path FROM file"); + const files = await reader.getRowObjects(); + if (files.length === 0) return; + + const folders = new Map(); + const containsFileEdges: { folder: string; file: string }[] = []; + + // The parent path is always registered before its children (root is seeded + // first, then each path segment is added before we descend into it), so a + // miss here means the invariant broke — fail loudly rather than via `!`. + const folderIdFor = (path: string): string => { + const entry = folders.get(path); + if (entry === undefined) { + throw new Error( + `synthesizeFolderTree: parent folder '${path}' not registered before child`, + ); + } + return entry.id; + }; + + for (const f of files) { + const rel = String(f.relative_path); + const parts = rel.split("/"); + parts.pop(); // drop the filename + // Register every ancestor folder ("" = root), chaining parent links. + let accum = ""; + // Root sentinel so top-level files attach to a single root node. + if (!folders.has("")) folders.set("", { id: folderId(""), parent: null }); + let parentPath = ""; + for (const part of parts) { + accum = accum === "" ? part : `${accum}/${part}`; + if (!folders.has(accum)) { + folders.set(accum, { id: folderId(accum), parent: folderIdFor(parentPath) }); + } + parentPath = accum; + } + containsFileEdges.push({ folder: folderIdFor(parentPath), file: String(f.id) }); + } + + for (const [path, info] of folders) { + await connection.run( + "INSERT INTO folder (id, path, parent_id) VALUES ($id, $path, $parent)", + { id: info.id, path, parent: info.parent }, + ); + if (info.parent !== null) { + await connection.run( + "INSERT INTO edge (id, source_id, target_id, kind, metadata) VALUES ($id, $s, $t, 'CONTAINS', '{}')", + { id: `contains:${info.parent}->${info.id}`, s: info.parent, t: info.id }, + ); + } + } + for (const e of containsFileEdges) { + await connection.run( + "INSERT INTO edge (id, source_id, target_id, kind, metadata) VALUES ($id, $s, $t, 'CONTAINS', '{}')", + { id: `contains:${e.folder}->${e.file}`, s: e.folder, t: e.file }, + ); + } + }); +} diff --git a/packages/core/src/storage/migrations/runner.ts b/packages/core/src/storage/migrations/runner.ts index 4de53cd..ae4d243 100644 --- a/packages/core/src/storage/migrations/runner.ts +++ b/packages/core/src/storage/migrations/runner.ts @@ -1,4 +1,4 @@ -import type { DuckDBConnection } from "@duckdb/node-api"; +import type { GraphDbConnection } from "../db.js"; import { SCHEMA_VERSION } from "../../types.js"; import type { Logger } from "../../types.js"; @@ -31,7 +31,7 @@ interface Migration { * has to skip sidecar copy on fresh DBs that never had call_site/import_ref). * If present, the runner calls `apply` instead of `connection.run(sql)`. */ - apply?: (connection: DuckDBConnection) => Promise; + apply?: (connection: GraphDbConnection) => Promise; } const MIGRATION_001: Migration = { @@ -108,7 +108,7 @@ const MIGRATION_002: Migration = { // these tables, so the runner skips the copy step when they're absent — but // still registers v3 and ensures the sidecars are dropped if a stale install // somehow created them. -async function runMigration003(connection: DuckDBConnection): Promise { +async function runMigration003(connection: GraphDbConnection): Promise { // Relax edge.target_id from NOT NULL to nullable. Pass-1 IMPORTS edges and // naive CALLS edges may not have a resolved target yet. Check the column's // current nullability via information_schema BEFORE running ALTER — running it @@ -185,7 +185,7 @@ async function runMigration003(connection: DuckDBConnection): Promise { `); } -async function tableExists(connection: DuckDBConnection, tableName: string): Promise { +async function tableExists(connection: GraphDbConnection, tableName: string): Promise { const reader = await connection.run( `SELECT 1 AS present FROM information_schema.tables WHERE table_schema = 'main' AND table_name = '${tableName}'`, @@ -195,7 +195,7 @@ async function tableExists(connection: DuckDBConnection, tableName: string): Pro } async function columnExists( - connection: DuckDBConnection, + connection: GraphDbConnection, tableName: string, columnName: string, ): Promise { @@ -208,7 +208,7 @@ async function columnExists( } async function columnIsNotNull( - connection: DuckDBConnection, + connection: GraphDbConnection, tableName: string, columnName: string, ): Promise { @@ -295,7 +295,7 @@ const MIGRATION_005: Migration = { // classification values from classifySymbol(), so the NULLs are short-lived. // The subgraph projection coalesces NULL to the conservative fallback so // any read between migration and reindex still renders sensibly. -async function runMigration006(connection: DuckDBConnection): Promise { +async function runMigration006(connection: GraphDbConnection): Promise { const hasEntryKind = await columnExists(connection, "symbol", "entry_kind"); const hasArchLayer = await columnExists(connection, "symbol", "arch_layer"); @@ -335,11 +335,10 @@ const MIGRATION_006: Migration = { // enclosing_symbol_id briefly. The schema-version bump invalidates the workspace // cache (validateWorkspaceCache compares metadata.schemaVersion to // SCHEMA_VERSION), which forces a reindex on the next session. That reindex -// rewrites every symbol row with the parent class id populated by -// ClassRelationExtractor's tree-sitter parent walk (slice 028 T022). NULL is -// also the correct steady-state value for any top-level symbol that is not a -// member of a class-like parent. -async function runMigration007(connection: DuckDBConnection): Promise { +// rewrites every symbol row with the parent class id populated by the +// extractor's tree-sitter parent walk. NULL is also the correct steady-state +// value for any top-level symbol that is not a member of a class-like parent. +async function runMigration007(connection: GraphDbConnection): Promise { const hasEnclosingId = await columnExists(connection, "symbol", "enclosing_symbol_id"); if (!hasEnclosingId) { @@ -415,7 +414,7 @@ export interface MigrationResultFailed { export type MigrationResult = MigrationResultOk | MigrationResultFailed; -async function readCurrentVersion(connection: DuckDBConnection): Promise { +async function readCurrentVersion(connection: GraphDbConnection): Promise { const reader = await connection.run( "SELECT COALESCE(MAX(version), 0) AS version FROM _schema_version", ); @@ -444,7 +443,7 @@ async function readCurrentVersion(connection: DuckDBConnection): Promise * `DuckTreeIndexer.initialize`) decide whether failure is fatal. */ export async function applyMigrations( - connection: DuckDBConnection, + connection: GraphDbConnection, logger?: Logger, ): Promise { const applied: string[] = []; diff --git a/packages/core/src/storage/repository.ts b/packages/core/src/storage/repository.ts index 8bb3dbf..82e6162 100644 --- a/packages/core/src/storage/repository.ts +++ b/packages/core/src/storage/repository.ts @@ -1,6 +1,4 @@ -import { createHash } from "node:crypto"; - -import type { DuckDBConnection } from "@duckdb/node-api"; +import type { GraphDbConnection } from "./db.js"; import { v4 as uuidv4 } from "uuid"; import type { EdgeRow } from "../extractors/types.js"; @@ -12,6 +10,10 @@ import type { SymbolClassificationRecord, } from "../types.js"; import { runInTransaction } from "./db.js"; +import { resolveCallEdgeSymbols } from "./resolution.js"; + +export { stampResolutionTier, resolveWorkspaceCrossFileEdges } from "./resolution.js"; +export { synthesizeFolderTree } from "./folderTree.js"; export interface DetectedFrameworkRow { frameworkName: string; @@ -38,7 +40,7 @@ function importRangeParams(importRef: ExtractedImportRef): Record { const rows = await ( @@ -52,7 +54,7 @@ async function findExistingFileId( } async function deleteExistingRows( - connection: DuckDBConnection, + connection: GraphDbConnection, existingFileId: string, ): Promise { // Two separate statements because DuckDB's named-parameter binding fails @@ -66,8 +68,8 @@ async function deleteExistingRows( "DELETE FROM edge WHERE target_id IN (SELECT id FROM symbol WHERE file_id = $file_id)", { file_id: existingFileId }, ); - // Slice 031 US3 — clear annotation rows whose parent symbol is about to be - // dropped so the table never carries dangling rows after a reindex. + // Clear annotation rows whose parent symbol is about to be dropped so the + // table never carries dangling rows after a reindex. await connection.run( "DELETE FROM annotation WHERE parent_symbol_id IN (SELECT id FROM symbol WHERE file_id = $file_id)", { file_id: existingFileId }, @@ -77,12 +79,12 @@ async function deleteExistingRows( }); } -async function insertFile(connection: DuckDBConnection, input: ExtractedIndexData): Promise { +async function insertFile(connection: GraphDbConnection, input: ExtractedIndexData): Promise { // _schema_version, is_core, fan_in, tags, labels, metadata, last_modified, // last_author, change_count_30d are omitted from the column list — they take // their schema-defined default values. fan_in/is_core are populated later by - // `recomputeGraphHealth` (S11.7); the rest stay at their defaults until git - // (S10) or diagnostics (S9) fill them in. + // `recomputeGraphHealth`; the rest stay at their defaults until git or + // diagnostics fill them in. await connection.run( ` INSERT INTO file ( @@ -114,12 +116,12 @@ async function insertFile(connection: DuckDBConnection, input: ExtractedIndexDat ); } -async function updateFile(connection: DuckDBConnection, input: ExtractedIndexData): Promise { +async function updateFile(connection: GraphDbConnection, input: ExtractedIndexData): Promise { // Update only the columns that change when a file is re-indexed (path metadata, // size, hash, last_indexed). is_core, fan_in, tags, labels, metadata, and the // git-derived columns are deliberately NOT reset: they're owned by other - // subsystems (S9 diagnostics, S10 git, S11.7 graph health) and a reindex - // should preserve their state. Closes audit finding M7. + // subsystems (diagnostics, git, graph health) and a reindex should preserve + // their state. await connection.run( ` UPDATE file @@ -147,13 +149,13 @@ const DEFAULT_CLASSIFICATION: SymbolClassificationRecord = { }; async function insertSymbol( - connection: DuckDBConnection, + connection: GraphDbConnection, symbol: StoredSymbol, classification: SymbolClassificationRecord = DEFAULT_CLASSIFICATION, ): Promise { // _schema_version, fan_in, is_core are omitted — column defaults handle them. // The pass-2 enrichment columns (visibility, signature, return_type, etc.) are - // also omitted; they're nullable and stay NULL until LSP enrichment (S8) runs. + // also omitted; they're nullable and stay NULL until LSP enrichment runs. // // entry_kind and arch_layer are always written explicitly so migrated v5→v6 // databases (where the columns are bare-added without DEFAULT) get the same @@ -205,7 +207,7 @@ async function insertSymbol( } async function insertDefinesEdges( - connection: DuckDBConnection, + connection: GraphDbConnection, input: ExtractedIndexData, ): Promise { for (const symbol of input.symbols) { @@ -224,7 +226,7 @@ async function insertDefinesEdges( } async function insertImportRefs( - connection: DuckDBConnection, + connection: GraphDbConnection, input: ExtractedIndexData, ): Promise { // Post-v3: imports are stored as `edge` rows with kind='IMPORTS'. The sidecar @@ -274,11 +276,11 @@ async function insertImportRefs( } async function insertExtraEdges( - connection: DuckDBConnection, + connection: GraphDbConnection, edges: readonly EdgeRow[], ): Promise { - // Generic insert path for extractor-emitted edges (e.g. naive `CALLS` rows - // from `NaiveCallExtractor`). Metadata is serialized as JSON via `json` cast. + // Generic insert path for extractor-emitted edges (e.g. `CALLS` rows). + // Metadata is serialized as JSON via `json` cast. for (const edge of edges) { await connection.run( ` @@ -314,213 +316,8 @@ function remapExtraEdges( })); } -/** - * SQL post-pass that resolves source_id and target_id for extractor-emitted - * relational edges (CALLS, INHERITS, INSTANTIATES) after the symbols and edges - * for `fileId` have been inserted. - * - * Why this is needed: NaiveCallExtractor and ClassRelationExtractor cannot know - * the symbol UUIDs minted by BaselineTsJsExtractor (both use random uuidv4). - * Instead they write a stable `source_fqn` (e.g. `"src/foo.ts:MyClass"`) and a - * target-name key (`callee_name` / `parent_name` / `class_name`) into edge - * metadata. This step resolves them against the just-written `symbol` rows. - * - * Step 1 — source_id: all three kinds have `source_fqn` in metadata. Edges - * whose source_id still equals the file UUID placeholder are updated to the - * matching symbol's id. Falls back to file-level id (via COALESCE) when the - * call/instantiation is at module scope. - * - * Step 2 — target_id: the per-kind metadata key names the target symbol. Same- - * file targets are resolved immediately; cross-file targets remain null (pass-2). - */ -async function resolveCallEdgeSymbols(connection: DuckDBConnection, fileId: string): Promise { - const RELATIONAL_KINDS = `('CALLS', 'INHERITS', 'INSTANTIATES', 'IMPLEMENTS', 'REFERENCES', 'RE_EXPORTS')`; - - // Step 1: source_id → actual symbol id, keyed by source_fqn (all kinds share this) - await connection.run( - ` - UPDATE edge - SET source_id = COALESCE( - ( - SELECT s.id FROM symbol s - WHERE s.file_id = $file_id - AND s.fqn = json_extract_string(edge.metadata, '$.source_fqn') - LIMIT 1 - ), - source_id - ) - WHERE kind IN ${RELATIONAL_KINDS} - AND source_id = $file_id - `, - { file_id: fileId }, - ); - - // Step 2a (CALLS): target_id → same-file symbol by callee_name - await connection.run( - ` - UPDATE edge - SET target_id = COALESCE( - ( - SELECT s.id FROM symbol s - WHERE s.file_id = $file_id - AND s.name = json_extract_string(edge.metadata, '$.callee_name') - AND s.kind IN ('function', 'method', 'class') - LIMIT 1 - ), - target_id - ) - WHERE kind = 'CALLS' - AND target_id IS NULL - AND source_id IN (SELECT id FROM symbol WHERE file_id = $file_id) - `, - { file_id: fileId }, - ); - - // Step 2b (INHERITS): target_id → same-file class by parent_name - await connection.run( - ` - UPDATE edge - SET target_id = COALESCE( - ( - SELECT s.id FROM symbol s - WHERE s.file_id = $file_id - AND s.name = json_extract_string(edge.metadata, '$.parent_name') - AND s.kind = 'class' - LIMIT 1 - ), - target_id - ) - WHERE kind = 'INHERITS' - AND target_id IS NULL - AND source_id IN (SELECT id FROM symbol WHERE file_id = $file_id) - `, - { file_id: fileId }, - ); - - // Step 2c (INSTANTIATES): target_id → same-file class by class_name - await connection.run( - ` - UPDATE edge - SET target_id = COALESCE( - ( - SELECT s.id FROM symbol s - WHERE s.file_id = $file_id - AND s.name = json_extract_string(edge.metadata, '$.class_name') - AND s.kind = 'class' - LIMIT 1 - ), - target_id - ) - WHERE kind = 'INSTANTIATES' - AND target_id IS NULL - AND source_id IN (SELECT id FROM symbol WHERE file_id = $file_id) - `, - { file_id: fileId }, - ); - - // Step 2d (IMPLEMENTS): target_id → same-file interface (or class for the - // JS pattern where an interface is implemented via a class shape) by - // interface_name. Slice 031 US2. - await connection.run( - ` - UPDATE edge - SET target_id = COALESCE( - ( - SELECT s.id FROM symbol s - WHERE s.file_id = $file_id - AND s.name = json_extract_string(edge.metadata, '$.interface_name') - AND s.kind IN ('interface', 'class') - LIMIT 1 - ), - target_id - ) - WHERE kind = 'IMPLEMENTS' - AND target_id IS NULL - AND source_id IN (SELECT id FROM symbol WHERE file_id = $file_id) - `, - { file_id: fileId }, - ); - - // Step 2e (REFERENCES): target_id → same-file symbol by referenced_name - // (type usage etc.). Cross-file references resolve in the workspace pass. - await connection.run( - ` - UPDATE edge - SET target_id = COALESCE( - ( - SELECT s.id FROM symbol s - WHERE s.file_id = $file_id - AND s.name = json_extract_string(edge.metadata, '$.referenced_name') - LIMIT 1 - ), - target_id - ) - WHERE kind = 'REFERENCES' - AND target_id IS NULL - AND source_id IN (SELECT id FROM symbol WHERE file_id = $file_id) - `, - { file_id: fileId }, - ); - - await stampResolutionTier(connection); -} - -/** - * Stamp every relational edge with an explicit resolution tier + confidence so a - * consumer can distinguish a real target from a guess (RULE-ARCH-010). Same-file - * + cross-file name resolution is the `heuristic` tier; the precise tier (the - * user's LSP) is applied later, by the host, and overrides this. Edges still - * without a target are `unresolved`. Idempotent — re-running only upgrades the - * tier field, never the target. - */ -export async function stampResolutionTier(connection: DuckDBConnection): Promise { - // RE_EXPORTS is path-based (resolved at query time like IMPORTS), so it is not - // tier-stamped here — it gets the 'structural' tier below. - const RELATIONAL_KINDS = `('CALLS', 'INHERITS', 'INSTANTIATES', 'IMPLEMENTS', 'REFERENCES')`; - // Resolved → heuristic (unless already marked precise by a higher tier). - await connection.run( - ` - UPDATE edge - SET metadata = json_merge_patch( - metadata, - '{"resolution":"heuristic","confidence":0.6}' - ) - WHERE kind IN ${RELATIONAL_KINDS} - AND target_id IS NOT NULL - AND COALESCE(json_extract_string(metadata, '$.resolution'), '') <> 'precise' - `, - ); - // Unresolved → explicit unresolved tier (never silently target-less). - await connection.run( - ` - UPDATE edge - SET metadata = json_merge_patch( - metadata, - '{"resolution":"unresolved","confidence":0.0}' - ) - WHERE kind IN ${RELATIONAL_KINDS} - AND target_id IS NULL - AND COALESCE(json_extract_string(metadata, '$.resolution'), '') NOT IN ('precise', 'heuristic') - `, - ); - // Structural / path-resolved edges (DEFINES, CONTAINS, RE_EXPORTS) are facts - // resolved structurally or at query time, not heuristic guesses — tag them - // 'structural' so coverage reporting has no 'unspecified' rows. - await connection.run( - ` - UPDATE edge - SET metadata = json_merge_patch( - metadata, - '{"resolution":"structural","confidence":1.0}' - ) - WHERE kind IN ('DEFINES', 'CONTAINS', 'RE_EXPORTS') - AND COALESCE(json_extract_string(metadata, '$.resolution'), '') = '' - `, - ); -} - export async function replaceFileGraph( - connection: DuckDBConnection, + connection: GraphDbConnection, input: ExtractedIndexData, extraEdges: readonly EdgeRow[] = [], classifications: ReadonlyMap = new Map(), @@ -598,14 +395,13 @@ function isAnnotationLikeRow(row: unknown): row is AnnotationLikeRow { } /** - * Persist annotation rows from {@link DecoratorExtractor} into the existing - * `annotation` table. Rows missing a valid `parentSymbolId` (e.g. when the - * extractor could not resolve an enclosing symbol) are silently skipped — - * the table has a NOT NULL FK to `symbol`, and per slice 031 contract the - * extractor must not invent synthetic targets. Slice 031 US3. + * Persist extractor-emitted annotation rows into the existing `annotation` + * table. Rows missing a valid `parentSymbolId` (e.g. when the extractor could + * not resolve an enclosing symbol) are silently skipped — the table has a NOT + * NULL FK to `symbol`, and extractors must not invent synthetic targets. */ async function insertAnnotations( - connection: DuckDBConnection, + connection: GraphDbConnection, annotations: readonly unknown[], validSymbolIds: ReadonlySet, ): Promise { @@ -654,203 +450,13 @@ async function insertAnnotations( } } -/** - * Workspace-wide cross-file edge resolution pass. - * - * Called once after all files in a workspace have been indexed. The per-file - * `resolveCallEdgeSymbols` pass already resolved same-file targets; this pass - * resolves edges whose target still lives in a *different* file that was - * indexed later in the batch. - * - * For each relational kind (CALLS, INHERITS, INSTANTIATES) that still has - * `target_id = NULL`, we look up the target symbol by name across all symbols - * in the workspace. On name collision we prefer symbols in the same file as - * the source (already done in per-file pass) and fall back to workspace-wide - * first-match. This is a pass-1 heuristic; pass-2 (LSP) will refine it. - * - * The `workspaceRoot` parameter is used only to scope the UPDATE to the - * workspace's own symbols (not symbols from other indexed workspaces). - */ -export async function resolveWorkspaceCrossFileEdges( - connection: DuckDBConnection, - workspaceRoot: string, -): Promise { - const prefix = workspaceRoot.endsWith("/") ? workspaceRoot : `${workspaceRoot}/`; - const params = { workspace_root: workspaceRoot, workspace_prefix: `${prefix}%` }; - - // Resolve CALLS: callee_name → any matching function/method/class in workspace - await connection.run( - ` - UPDATE edge - SET target_id = ( - SELECT s.id FROM symbol s - INNER JOIN file f ON f.id = s.file_id - WHERE s.name = json_extract_string(edge.metadata, '$.callee_name') - AND s.kind IN ('function', 'method', 'class') - AND (f.path = $workspace_root OR f.path LIKE $workspace_prefix) - ORDER BY s.id - LIMIT 1 - ) - WHERE kind = 'CALLS' - AND target_id IS NULL - AND source_id IN ( - SELECT s2.id FROM symbol s2 - INNER JOIN file f2 ON f2.id = s2.file_id - WHERE f2.path = $workspace_root OR f2.path LIKE $workspace_prefix - ) - `, - params, - ); - - // Resolve INHERITS: parent_name → any matching class in workspace - await connection.run( - ` - UPDATE edge - SET target_id = ( - SELECT s.id FROM symbol s - INNER JOIN file f ON f.id = s.file_id - WHERE s.name = json_extract_string(edge.metadata, '$.parent_name') - AND s.kind = 'class' - AND (f.path = $workspace_root OR f.path LIKE $workspace_prefix) - ORDER BY s.id - LIMIT 1 - ) - WHERE kind = 'INHERITS' - AND target_id IS NULL - AND source_id IN ( - SELECT s2.id FROM symbol s2 - INNER JOIN file f2 ON f2.id = s2.file_id - WHERE f2.path = $workspace_root OR f2.path LIKE $workspace_prefix - ) - `, - params, - ); - - // Resolve INSTANTIATES: class_name → any matching class in workspace - await connection.run( - ` - UPDATE edge - SET target_id = ( - SELECT s.id FROM symbol s - INNER JOIN file f ON f.id = s.file_id - WHERE s.name = json_extract_string(edge.metadata, '$.class_name') - AND s.kind = 'class' - AND (f.path = $workspace_root OR f.path LIKE $workspace_prefix) - ORDER BY s.id - LIMIT 1 - ) - WHERE kind = 'INSTANTIATES' - AND target_id IS NULL - AND source_id IN ( - SELECT s2.id FROM symbol s2 - INNER JOIN file f2 ON f2.id = s2.file_id - WHERE f2.path = $workspace_root OR f2.path LIKE $workspace_prefix - ) - `, - params, - ); - - // Resolve IMPLEMENTS: interface_name → any matching interface (or class - // used as an interface) in the workspace. Slice 031 US2. - await connection.run( - ` - UPDATE edge - SET target_id = ( - SELECT s.id FROM symbol s - INNER JOIN file f ON f.id = s.file_id - WHERE s.name = json_extract_string(edge.metadata, '$.interface_name') - AND s.kind IN ('interface', 'class') - AND (f.path = $workspace_root OR f.path LIKE $workspace_prefix) - ORDER BY s.id - LIMIT 1 - ) - WHERE kind = 'IMPLEMENTS' - AND target_id IS NULL - AND source_id IN ( - SELECT s2.id FROM symbol s2 - INNER JOIN file f2 ON f2.id = s2.file_id - WHERE f2.path = $workspace_root OR f2.path LIKE $workspace_prefix - ) - `, - params, - ); - - // Re-stamp tiers now that cross-file targets are filled in. - await stampResolutionTier(connection); -} - -/** Deterministic folder id: stable across re-index so the tree doesn't reshuffle. */ -function folderId(path: string): string { - return `folder:${createHash("sha256").update(path, "utf8").digest("hex").slice(0, 32)}`; -} - -/** - * Synthesize `folder` nodes + `CONTAINS` edges from the indexed files' relative - * paths, producing a connected root→folder→file tree. Deterministic ids (path - * hash) keep re-indexing stable. Rebuilt wholesale each call (idempotent): clear - * folders + CONTAINS, then re-derive from current files. Runs in finalize. - */ -export async function synthesizeFolderTree(connection: DuckDBConnection): Promise { - await runInTransaction(connection, async () => { - await connection.run("DELETE FROM folder"); - await connection.run("DELETE FROM edge WHERE kind = 'CONTAINS'"); - - const reader = await connection.run("SELECT id, relative_path FROM file"); - const files = await reader.getRowObjects(); - if (files.length === 0) return; - - const folders = new Map(); - const containsFileEdges: { folder: string; file: string }[] = []; - - for (const f of files) { - const rel = String(f.relative_path); - const parts = rel.split("/"); - parts.pop(); // drop the filename - // Register every ancestor folder ("" = root), chaining parent links. - let parentPath: string | null = null; - let accum = ""; - // Root sentinel so top-level files attach to a single root node. - const rootId = folderId(""); - if (!folders.has("")) folders.set("", { id: rootId, parent: null }); - parentPath = ""; - for (const part of parts) { - accum = accum === "" ? part : `${accum}/${part}`; - if (!folders.has(accum)) { - folders.set(accum, { id: folderId(accum), parent: folders.get(parentPath!)!.id }); - } - parentPath = accum; - } - containsFileEdges.push({ folder: folders.get(parentPath)!.id, file: String(f.id) }); - } - - for (const [path, info] of folders) { - await connection.run( - "INSERT INTO folder (id, path, parent_id) VALUES ($id, $path, $parent)", - { id: info.id, path, parent: info.parent }, - ); - if (info.parent !== null) { - await connection.run( - "INSERT INTO edge (id, source_id, target_id, kind, metadata) VALUES ($id, $s, $t, 'CONTAINS', '{}')", - { id: `contains:${info.parent}->${info.id}`, s: info.parent, t: info.id }, - ); - } - } - for (const e of containsFileEdges) { - await connection.run( - "INSERT INTO edge (id, source_id, target_id, kind, metadata) VALUES ($id, $s, $t, 'CONTAINS', '{}')", - { id: `contains:${e.folder}->${e.file}`, s: e.folder, t: e.file }, - ); - } - }); -} - /** * Replace all workspace_framework rows with the supplied list, atomically. * Mirrors the replaceFileGraph pattern: DELETE all, INSERT new, in one transaction. * Empty input clears the table (workspace has no detected frameworks). */ export async function replaceWorkspaceFrameworks( - connection: DuckDBConnection, + connection: GraphDbConnection, rows: readonly DetectedFrameworkRow[], ): Promise { await runInTransaction(connection, async () => { @@ -881,7 +487,7 @@ export async function replaceWorkspaceFrameworks( * values is a no-op at the DB level. */ export async function setFileFramework( - connection: DuckDBConnection, + connection: GraphDbConnection, fileId: string, framework: string | null, role: string | null, diff --git a/packages/core/src/storage/resolution.ts b/packages/core/src/storage/resolution.ts new file mode 100644 index 0000000..1e9d9fc --- /dev/null +++ b/packages/core/src/storage/resolution.ts @@ -0,0 +1,335 @@ +import type { GraphDbConnection } from "./db.js"; +import { metaPath } from "./edgeMetadata.js"; + +/** + * SQL post-pass that resolves source_id and target_id for extractor-emitted + * relational edges (CALLS, INHERITS, INSTANTIATES) after the symbols and edges + * for `fileId` have been inserted. + * + * Why this is needed: relational extractors cannot know the symbol UUIDs minted + * for definitions (every symbol gets a random uuidv4). Instead they write a + * stable `source_fqn` (e.g. `"src/foo.ts:MyClass"`) and a target-name key + * (`callee_name` / `parent_name` / `class_name`) into edge metadata. This step + * resolves them against the just-written `symbol` rows. + * + * Step 1 — source_id: all three kinds have `source_fqn` in metadata. Edges + * whose source_id still equals the file UUID placeholder are updated to the + * matching symbol's id. Falls back to file-level id (via COALESCE) when the + * call/instantiation is at module scope. + * + * Step 2 — target_id: the per-kind metadata key names the target symbol. Same- + * file targets are resolved immediately; cross-file targets remain null (pass-2). + */ +export async function resolveCallEdgeSymbols( + connection: GraphDbConnection, + fileId: string, +): Promise { + const RELATIONAL_KINDS = `('CALLS', 'INHERITS', 'INSTANTIATES', 'IMPLEMENTS', 'REFERENCES', 'RE_EXPORTS')`; + + // Step 1: source_id → actual symbol id, keyed by source_fqn (all kinds share this) + await connection.run( + ` + UPDATE edge + SET source_id = COALESCE( + ( + SELECT s.id FROM symbol s + WHERE s.file_id = $file_id + AND s.fqn = json_extract_string(edge.metadata, ${metaPath("sourceFqn")}) + LIMIT 1 + ), + source_id + ) + WHERE kind IN ${RELATIONAL_KINDS} + AND source_id = $file_id + `, + { file_id: fileId }, + ); + + // Step 2a (CALLS): target_id → same-file symbol by callee_name + await connection.run( + ` + UPDATE edge + SET target_id = COALESCE( + ( + SELECT s.id FROM symbol s + WHERE s.file_id = $file_id + AND s.name = json_extract_string(edge.metadata, ${metaPath("calleeName")}) + AND s.kind IN ('function', 'method', 'class') + LIMIT 1 + ), + target_id + ) + WHERE kind = 'CALLS' + AND target_id IS NULL + AND source_id IN (SELECT id FROM symbol WHERE file_id = $file_id) + `, + { file_id: fileId }, + ); + + // Step 2b (INHERITS): target_id → same-file class by parent_name + await connection.run( + ` + UPDATE edge + SET target_id = COALESCE( + ( + SELECT s.id FROM symbol s + WHERE s.file_id = $file_id + AND s.name = json_extract_string(edge.metadata, ${metaPath("parentName")}) + AND s.kind = 'class' + LIMIT 1 + ), + target_id + ) + WHERE kind = 'INHERITS' + AND target_id IS NULL + AND source_id IN (SELECT id FROM symbol WHERE file_id = $file_id) + `, + { file_id: fileId }, + ); + + // Step 2c (INSTANTIATES): target_id → same-file class by class_name + await connection.run( + ` + UPDATE edge + SET target_id = COALESCE( + ( + SELECT s.id FROM symbol s + WHERE s.file_id = $file_id + AND s.name = json_extract_string(edge.metadata, ${metaPath("className")}) + AND s.kind = 'class' + LIMIT 1 + ), + target_id + ) + WHERE kind = 'INSTANTIATES' + AND target_id IS NULL + AND source_id IN (SELECT id FROM symbol WHERE file_id = $file_id) + `, + { file_id: fileId }, + ); + + // Step 2d (IMPLEMENTS): target_id → same-file interface (or class for the + // JS pattern where an interface is implemented via a class shape) by + // interface_name. + await connection.run( + ` + UPDATE edge + SET target_id = COALESCE( + ( + SELECT s.id FROM symbol s + WHERE s.file_id = $file_id + AND s.name = json_extract_string(edge.metadata, ${metaPath("interfaceName")}) + AND s.kind IN ('interface', 'class') + LIMIT 1 + ), + target_id + ) + WHERE kind = 'IMPLEMENTS' + AND target_id IS NULL + AND source_id IN (SELECT id FROM symbol WHERE file_id = $file_id) + `, + { file_id: fileId }, + ); + + // Step 2e (REFERENCES): target_id → same-file symbol by referenced_name + // (type usage etc.). Cross-file references resolve in the workspace pass. + await connection.run( + ` + UPDATE edge + SET target_id = COALESCE( + ( + SELECT s.id FROM symbol s + WHERE s.file_id = $file_id + AND s.name = json_extract_string(edge.metadata, ${metaPath("referencedName")}) + LIMIT 1 + ), + target_id + ) + WHERE kind = 'REFERENCES' + AND target_id IS NULL + AND source_id IN (SELECT id FROM symbol WHERE file_id = $file_id) + `, + { file_id: fileId }, + ); + + await stampResolutionTier(connection); +} + +/** + * Stamp every relational edge with an explicit resolution tier + confidence so a + * consumer can distinguish a real target from a guess (RULE-ARCH-010). Same-file + * + cross-file name resolution is the `heuristic` tier; the precise tier (the + * user's LSP) is applied later, by the host, and overrides this. Edges still + * without a target are `unresolved`. Idempotent — re-running only upgrades the + * tier field, never the target. + */ +export async function stampResolutionTier(connection: GraphDbConnection): Promise { + // RE_EXPORTS is path-based (resolved at query time like IMPORTS), so it is not + // tier-stamped here — it gets the 'structural' tier below. + const RELATIONAL_KINDS = `('CALLS', 'INHERITS', 'INSTANTIATES', 'IMPLEMENTS', 'REFERENCES')`; + // Resolved → heuristic (unless already marked precise by a higher tier). + await connection.run( + ` + UPDATE edge + SET metadata = json_merge_patch( + metadata, + '{"resolution":"heuristic","confidence":0.6}' + ) + WHERE kind IN ${RELATIONAL_KINDS} + AND target_id IS NOT NULL + AND COALESCE(json_extract_string(metadata, '$.resolution'), '') <> 'precise' + `, + ); + // Unresolved → explicit unresolved tier (never silently target-less). + await connection.run( + ` + UPDATE edge + SET metadata = json_merge_patch( + metadata, + '{"resolution":"unresolved","confidence":0.0}' + ) + WHERE kind IN ${RELATIONAL_KINDS} + AND target_id IS NULL + AND COALESCE(json_extract_string(metadata, '$.resolution'), '') NOT IN ('precise', 'heuristic') + `, + ); + // Structural / path-resolved edges (DEFINES, CONTAINS, RE_EXPORTS) are facts + // resolved structurally or at query time, not heuristic guesses — tag them + // 'structural' so coverage reporting has no 'unspecified' rows. + await connection.run( + ` + UPDATE edge + SET metadata = json_merge_patch( + metadata, + '{"resolution":"structural","confidence":1.0}' + ) + WHERE kind IN ('DEFINES', 'CONTAINS', 'RE_EXPORTS') + AND COALESCE(json_extract_string(metadata, '$.resolution'), '') = '' + `, + ); +} + +/** + * Workspace-wide cross-file edge resolution pass. + * + * Called once after all files in a workspace have been indexed. The per-file + * `resolveCallEdgeSymbols` pass already resolved same-file targets; this pass + * resolves edges whose target still lives in a *different* file that was + * indexed later in the batch. + * + * For each relational kind (CALLS, INHERITS, INSTANTIATES) that still has + * `target_id = NULL`, we look up the target symbol by name across all symbols + * in the workspace. On name collision we prefer symbols in the same file as + * the source (already done in per-file pass) and fall back to workspace-wide + * first-match. This is a pass-1 heuristic; pass-2 (LSP) will refine it. + * + * The `workspaceRoot` parameter is used only to scope the UPDATE to the + * workspace's own symbols (not symbols from other indexed workspaces). + */ +export async function resolveWorkspaceCrossFileEdges( + connection: GraphDbConnection, + workspaceRoot: string, +): Promise { + const prefix = workspaceRoot.endsWith("/") ? workspaceRoot : `${workspaceRoot}/`; + const params = { workspace_root: workspaceRoot, workspace_prefix: `${prefix}%` }; + + // Resolve CALLS: callee_name → any matching function/method/class in workspace + await connection.run( + ` + UPDATE edge + SET target_id = ( + SELECT s.id FROM symbol s + INNER JOIN file f ON f.id = s.file_id + WHERE s.name = json_extract_string(edge.metadata, ${metaPath("calleeName")}) + AND s.kind IN ('function', 'method', 'class') + AND (f.path = $workspace_root OR f.path LIKE $workspace_prefix) + ORDER BY s.id + LIMIT 1 + ) + WHERE kind = 'CALLS' + AND target_id IS NULL + AND source_id IN ( + SELECT s2.id FROM symbol s2 + INNER JOIN file f2 ON f2.id = s2.file_id + WHERE f2.path = $workspace_root OR f2.path LIKE $workspace_prefix + ) + `, + params, + ); + + // Resolve INHERITS: parent_name → any matching class in workspace + await connection.run( + ` + UPDATE edge + SET target_id = ( + SELECT s.id FROM symbol s + INNER JOIN file f ON f.id = s.file_id + WHERE s.name = json_extract_string(edge.metadata, ${metaPath("parentName")}) + AND s.kind = 'class' + AND (f.path = $workspace_root OR f.path LIKE $workspace_prefix) + ORDER BY s.id + LIMIT 1 + ) + WHERE kind = 'INHERITS' + AND target_id IS NULL + AND source_id IN ( + SELECT s2.id FROM symbol s2 + INNER JOIN file f2 ON f2.id = s2.file_id + WHERE f2.path = $workspace_root OR f2.path LIKE $workspace_prefix + ) + `, + params, + ); + + // Resolve INSTANTIATES: class_name → any matching class in workspace + await connection.run( + ` + UPDATE edge + SET target_id = ( + SELECT s.id FROM symbol s + INNER JOIN file f ON f.id = s.file_id + WHERE s.name = json_extract_string(edge.metadata, ${metaPath("className")}) + AND s.kind = 'class' + AND (f.path = $workspace_root OR f.path LIKE $workspace_prefix) + ORDER BY s.id + LIMIT 1 + ) + WHERE kind = 'INSTANTIATES' + AND target_id IS NULL + AND source_id IN ( + SELECT s2.id FROM symbol s2 + INNER JOIN file f2 ON f2.id = s2.file_id + WHERE f2.path = $workspace_root OR f2.path LIKE $workspace_prefix + ) + `, + params, + ); + + // Resolve IMPLEMENTS: interface_name → any matching interface (or class + // used as an interface) in the workspace. + await connection.run( + ` + UPDATE edge + SET target_id = ( + SELECT s.id FROM symbol s + INNER JOIN file f ON f.id = s.file_id + WHERE s.name = json_extract_string(edge.metadata, ${metaPath("interfaceName")}) + AND s.kind IN ('interface', 'class') + AND (f.path = $workspace_root OR f.path LIKE $workspace_prefix) + ORDER BY s.id + LIMIT 1 + ) + WHERE kind = 'IMPLEMENTS' + AND target_id IS NULL + AND source_id IN ( + SELECT s2.id FROM symbol s2 + INNER JOIN file f2 ON f2.id = s2.file_id + WHERE f2.path = $workspace_root OR f2.path LIKE $workspace_prefix + ) + `, + params, + ); + + // Re-stamp tiers now that cross-file targets are filled in. + await stampResolutionTier(connection); +} diff --git a/packages/core/src/storage/schema.ts b/packages/core/src/storage/schema.ts index 7b49a82..493d838 100644 --- a/packages/core/src/storage/schema.ts +++ b/packages/core/src/storage/schema.ts @@ -1,4 +1,4 @@ -import type { DuckDBConnection } from "@duckdb/node-api"; +import type { GraphDbConnection } from "./db.js"; import { SCHEMA_VERSION } from "../types.js"; @@ -77,7 +77,7 @@ export const SCHEMA_STATEMENTS = [ id VARCHAR PRIMARY KEY, source_id VARCHAR NOT NULL, -- target_id is nullable post-v3: pass-1 IMPORTS edges and pass-1 naive CALLS - -- edges may not have a resolved target yet; pass-2 (S8 LSP) fills them in. + -- edges may not have a resolved target yet; pass-2 LSP fills them in. target_id VARCHAR, kind VARCHAR NOT NULL, weight FLOAT, @@ -209,7 +209,7 @@ export const SCHEMA_STATEMENTS = [ "CREATE INDEX IF NOT EXISTS idx_file_framework ON file(framework)", ] as const; -export async function initializeSchema(connection: DuckDBConnection): Promise { +export async function initializeSchema(connection: GraphDbConnection): Promise { for (const statement of SCHEMA_STATEMENTS) { await connection.run(statement); } diff --git a/packages/core/src/storage/workspaceCache.ts b/packages/core/src/storage/workspaceCache.ts index bd05f5f..ea2bb47 100644 --- a/packages/core/src/storage/workspaceCache.ts +++ b/packages/core/src/storage/workspaceCache.ts @@ -1,4 +1,4 @@ -import type { DuckDBConnection } from "@duckdb/node-api"; +import type { GraphDbConnection } from "./db.js"; import { SCHEMA_VERSION, @@ -82,7 +82,7 @@ function identityMatches(row: WorkspaceCacheRow, identity: WorkspaceCacheIdentit } export async function writeWorkspaceCacheSnapshot( - connection: DuckDBConnection, + connection: GraphDbConnection, input: WriteWorkspaceCacheSnapshotInput, ): Promise { const timestamp = new Date().toISOString(); @@ -143,7 +143,7 @@ export async function writeWorkspaceCacheSnapshot( } export async function validateWorkspaceCache( - connection: DuckDBConnection, + connection: GraphDbConnection, identity: WorkspaceCacheIdentity, ): Promise { try { diff --git a/packages/core/src/storage/workspaceRegistry.ts b/packages/core/src/storage/workspaceRegistry.ts index 5d0a844..73b18ad 100644 --- a/packages/core/src/storage/workspaceRegistry.ts +++ b/packages/core/src/storage/workspaceRegistry.ts @@ -1,5 +1,5 @@ /** - * Per-workspace DuckDB read summary for the workspace switcher (slice 024). + * Per-workspace DuckDB read summary for the workspace switcher. * * Opens a foreign DuckDB file in read-only mode to extract the single * `workspace_cache` row and the framework list — without running migrations @@ -9,6 +9,7 @@ import { basename } from "node:path"; +import { openReadOnlyDatabase } from "./adapters/duckdb.js"; import { getPresentEdgeKinds } from "../query/presentEdgeKinds.js"; import { getWorkspaceSubgraph } from "../query/subgraph.js"; import type { WorkspaceSubgraph } from "../types.js"; @@ -40,7 +41,7 @@ function toIsoOrNull(value: unknown): string | null { * Opens the DuckDB file at `dbPath` in read-only mode and returns a summary of * the indexed workspace. Returns null if the file is missing, the workspace_cache * row is absent, or any I/O error occurs. The framework list degrades to `[]` - * when the `workspace_framework` table is missing (slice 018 not applied). + * when the `workspace_framework` table is missing (an older index). * * Does NOT run migrations on the opened file — read-only access mode prevents * accidental schema mutation of foreign workspace DBs. @@ -49,9 +50,8 @@ export async function readWorkspaceIndexSummary( dbPath: string, ): Promise { try { - const { DuckDBInstance } = await import("@duckdb/node-api"); - const instance = await DuckDBInstance.create(dbPath, { access_mode: "READ_ONLY" }); - const connection = await instance.connect(); + const handle = await openReadOnlyDatabase(dbPath); + const connection = handle.connection; try { const cacheRows = await ( @@ -96,8 +96,7 @@ export async function readWorkspaceIndexSummary( frameworks, }; } finally { - connection.closeSync(); - instance.closeSync(); + handle.close(); } } catch { return null; @@ -120,23 +119,20 @@ export interface ForeignWorkspaceGraph { * payload `Indexer.getWorkspaceSubgraph` produces for the active workspace. * * Returns null on any I/O or schema error so the caller can show a graceful - * "could not open workspace" notification (slice 024 FR-008). + * "could not open workspace" notification. */ export async function readWorkspaceGraph( dbPath: string, workspaceRoot: string, ): Promise { try { - const { DuckDBInstance } = await import("@duckdb/node-api"); - const instance = await DuckDBInstance.create(dbPath, { access_mode: "READ_ONLY" }); - const connection = await instance.connect(); + const handle = await openReadOnlyDatabase(dbPath); try { - const subgraph = await getWorkspaceSubgraph(connection, workspaceRoot); - const presentEdgeKinds = await getPresentEdgeKinds(connection, workspaceRoot); + const subgraph = await getWorkspaceSubgraph(handle.connection, workspaceRoot); + const presentEdgeKinds = await getPresentEdgeKinds(handle.connection, workspaceRoot); return { subgraph, presentEdgeKinds }; } finally { - connection.closeSync(); - instance.closeSync(); + handle.close(); } } catch { return null; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 6bd7a3b..8f0aabc 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1,5 +1,5 @@ // Schema version history: -// 1 — initial baseline (slice 008 era) +// 1 — initial baseline // 2 — adds annotation, module, test core entity tables (migration 002) // 3 — unifies call_site + import_ref into edge with kind/metadata (migration 003) // 4 — adds workspace_cache table (migration 004) @@ -277,7 +277,7 @@ export interface SessionSummary { /** * Thrown by `querySessionSummary` when the graph contains no indexed files. * The export command catches this and shows "Index your workspace first" - * without writing any file (FR-005). + * without writing any file. */ export class EmptyGraphError extends Error { constructor() { diff --git a/packages/exporters/src/__fuzz__/serializer.fuzz.ts b/packages/exporters/src/__fuzz__/serializer.fuzz.ts index 9c6e208..e3ee3a1 100644 --- a/packages/exporters/src/__fuzz__/serializer.fuzz.ts +++ b/packages/exporters/src/__fuzz__/serializer.fuzz.ts @@ -14,17 +14,18 @@ import { validateClassDiagramExport } from "../mermaid/classDiagram.js"; import { DEFAULT_MERMAID_THEME } from "../mermaid/theme.js"; import { clampGranularityToScope, + MERMAID_DIRECTIONS, + MERMAID_GRANULARITIES, serializeToScopedMermaid, validateScopedMermaidExport, type MermaidDiagram, - type MermaidDirection, - type MermaidGranularity, type MermaidScope, } from "../mermaid/scopedSerializer.js"; import { serializeToMermaid } from "../mermaid/serializer.js"; -const GRANULARITIES: MermaidGranularity[] = ["package", "file", "symbol"]; -const DIRECTIONS: MermaidDirection[] = ["auto", "TB", "LR", "BT", "RL"]; +const GRANULARITIES = MERMAID_GRANULARITIES; +const DIRECTIONS = MERMAID_DIRECTIONS; +// Deliberate subset: sequenceDiagram needs a trace snapshot, not fuzzable here. const DIAGRAMS: MermaidDiagram[] = ["flowchart", "classDiagram"]; export function fuzz(data: Buffer): void { diff --git a/packages/exporters/src/index.ts b/packages/exporters/src/index.ts index 418cde8..9081569 100644 --- a/packages/exporters/src/index.ts +++ b/packages/exporters/src/index.ts @@ -9,6 +9,9 @@ export { applyMermaidGranularity, extractMermaidScope, inferMermaidDirection, + MERMAID_DIAGRAMS, + MERMAID_DIRECTIONS, + MERMAID_GRANULARITIES, MERMAID_GRANULARITY_CAPS, serializeToScopedMermaid, validateScopedMermaidExport, diff --git a/packages/exporters/src/mermaid/classDiagram.ts b/packages/exporters/src/mermaid/classDiagram.ts index 6ef734c..2432bf7 100644 --- a/packages/exporters/src/mermaid/classDiagram.ts +++ b/packages/exporters/src/mermaid/classDiagram.ts @@ -2,6 +2,7 @@ import type { GraphNode, WorkspaceSubgraph } from "@dextree/core"; import type { ScopedMermaidOptions } from "./scopedSerializer.js"; import { MERMAID_INIT_DIRECTIVE } from "./theme.js"; +import { firstCapBreach } from "./validator.js"; export interface ClassDiagramEntry { classId: string; @@ -136,23 +137,27 @@ export function validateClassDiagramExport(subgraph: WorkspaceSubgraph): ClassDi const classCount = entries.length; const methodCount = entries.reduce((sum, e) => sum + e.methods.length, 0); - if (classCount > MERMAID_CLASS_DIAGRAM_CAPS.classes) { + const breach = firstCapBreach([ + { + count: classCount, + cap: MERMAID_CLASS_DIAGRAM_CAPS.classes, + reason: (count, cap) => + `${count} classes exceeds the class-diagram cap of ${cap}; narrow the scope.`, + }, + { + count: methodCount, + cap: MERMAID_CLASS_DIAGRAM_CAPS.methods, + reason: (count, cap) => + `${count} method stubs exceeds the class-diagram cap of ${cap}; narrow the scope.`, + }, + ]); + if (breach !== null) { return { status: "oversized", classCount, methodCount, cap: MERMAID_CLASS_DIAGRAM_CAPS, - reason: `${classCount} classes exceeds the class-diagram cap of ${MERMAID_CLASS_DIAGRAM_CAPS.classes}; narrow the scope.`, - }; - } - - if (methodCount > MERMAID_CLASS_DIAGRAM_CAPS.methods) { - return { - status: "oversized", - classCount, - methodCount, - cap: MERMAID_CLASS_DIAGRAM_CAPS, - reason: `${methodCount} method stubs exceeds the class-diagram cap of ${MERMAID_CLASS_DIAGRAM_CAPS.methods}; narrow the scope.`, + reason: breach, }; } @@ -169,25 +174,12 @@ function toMermaidClassName(label: string): string { return /^[A-Za-z_]/.test(safe) ? safe : "_" + safe; } -export function serializeToClassDiagram( - subgraph: WorkspaceSubgraph, - options: ScopedMermaidOptions, -): string { - const validation = validateClassDiagramExport(subgraph); - if (validation.status !== "ok") { - throw new Error(validation.reason); - } - - const entries = groupClassDiagramEntries(subgraph); - const classNameById = new Map(); - for (const entry of entries) { - classNameById.set(entry.classId, toMermaidClassName(entry.className)); - } - +/** Emit the `class Foo { +bar() }` blocks (one per entry, methods inline). */ +function emitClassBodies( + entries: ReadonlyArray, + classNameById: ReadonlyMap, +): string[] { const lines: string[] = []; - lines.push(MERMAID_INIT_DIRECTIVE[options.theme]); - lines.push("classDiagram"); - for (const entry of entries) { const safeName = classNameById.get(entry.classId)!; if (entry.methods.length === 0) { @@ -200,47 +192,72 @@ export function serializeToClassDiagram( lines.push(` }`); } } + return lines; +} +/** + * Collect inheritance / instantiation / implementation relationship lines, + * sorted deterministically by (source, target) so the diagram is stable across + * runs. Edges to targets outside the current scope are skipped. + */ +function collectRelationshipLines( + entries: ReadonlyArray, + classNameById: ReadonlyMap, +): string[] { type RelationshipLine = { source: string; target: string; line: string }; const relationships: RelationshipLine[] = []; - for (const entry of entries) { + const push = ( + entry: ClassDiagramEntry, + targetId: string, + render: (s: string, t: string) => string, + ) => { const sourceName = classNameById.get(entry.classId)!; + const targetName = classNameById.get(targetId); + if (targetName === undefined) return; + relationships.push({ + source: entry.classId, + target: targetId, + line: render(sourceName, targetName), + }); + }; + + for (const entry of entries) { for (const targetId of entry.inheritsFrom) { - const targetName = classNameById.get(targetId); - if (targetName === undefined) continue; - relationships.push({ - source: entry.classId, - target: targetId, - line: ` ${sourceName} <|-- ${targetName}`, - }); + push(entry, targetId, (s, t) => ` ${s} <|-- ${t}`); } for (const targetId of entry.instantiates) { - const targetName = classNameById.get(targetId); - if (targetName === undefined) continue; - relationships.push({ - source: entry.classId, - target: targetId, - line: ` ${sourceName} <.. ${targetName}`, - }); + push(entry, targetId, (s, t) => ` ${s} <.. ${t}`); } for (const targetId of entry.implementsInterfaces) { - const targetName = classNameById.get(targetId); - if (targetName === undefined) continue; - relationships.push({ - source: entry.classId, - target: targetId, - line: ` ${targetName} <|.. ${sourceName}`, - }); + push(entry, targetId, (s, t) => ` ${t} <|.. ${s}`); } } - relationships.sort((a, b) => { - const keyA = `${a.source}\0${a.target}`; - const keyB = `${b.source}\0${b.target}`; - return keyA.localeCompare(keyB); - }); - for (const rel of relationships) { - lines.push(rel.line); + + relationships.sort((a, b) => + `${a.source}\0${a.target}`.localeCompare(`${b.source}\0${b.target}`), + ); + return relationships.map((rel) => rel.line); +} + +export function serializeToClassDiagram( + subgraph: WorkspaceSubgraph, + options: ScopedMermaidOptions, +): string { + const validation = validateClassDiagramExport(subgraph); + if (validation.status !== "ok") { + throw new Error(validation.reason); + } + + const entries = groupClassDiagramEntries(subgraph); + const classNameById = new Map(); + for (const entry of entries) { + classNameById.set(entry.classId, toMermaidClassName(entry.className)); } - return lines.join("\n"); + return [ + MERMAID_INIT_DIRECTIVE[options.theme], + "classDiagram", + ...emitClassBodies(entries, classNameById), + ...collectRelationshipLines(entries, classNameById), + ].join("\n"); } diff --git a/packages/exporters/src/mermaid/preview.ts b/packages/exporters/src/mermaid/preview.ts index 7dd4977..fe77f4d 100644 --- a/packages/exporters/src/mermaid/preview.ts +++ b/packages/exporters/src/mermaid/preview.ts @@ -1,19 +1,13 @@ import type { Logger, WorkspaceSubgraph } from "@dextree/core"; -import { - applyMermaidGranularity, - clampGranularityToScope, - extractMermaidScope, - serializeToScopedMermaid, - validateScopedMermaidExport, -} from "./scopedSerializer.js"; +import { serializeScopedMermaidResult } from "./scopedSerializer.js"; import type { MermaidDirection, MermaidGranularity, MermaidScope } from "./scopedSerializer.js"; import { MERMAID_INIT_DIRECTIVE } from "./theme.js"; /** * Bounded diagram kind for the preview tab. `flowchart` and `classDiagram` - * are routable since slice 029 PR-B; `sequenceDiagram` stays explicit - * `unsupported` until slice 031 ships the sequence serializer. + * are routable; `sequenceDiagram` stays explicit `unsupported` until the + * sequence serializer ships. */ export type MermaidDiagramKind = "flowchart" | "classDiagram" | "sequenceDiagram"; @@ -26,7 +20,7 @@ export type MermaidPreviewTheme = "light" | "dark"; /** * Full option payload for {@link generateMermaidPreview}. Mirrors the - * slice-027 scoped serializer surface and adds the diagram discriminator + * scoped serializer surface and adds the diagram discriminator * and resolved theme used by the preview tab. */ export interface MermaidPreviewOptions { @@ -93,8 +87,8 @@ function titleForOptions(options: MermaidPreviewOptions): string { /** * Pure preview router. Builds the Mermaid source text for the requested - * diagram + scope combination by delegating to the slice-027 scoped flowchart - * serializer or the slice-028 class-diagram serializer. Never performs + * diagram + scope combination by delegating to the scoped flowchart + * serializer or the class-diagram serializer. Never performs * filesystem, DOM, or network work. * * Errors raised by the underlying serializer (empty / oversized / unsupported @@ -102,7 +96,7 @@ function titleForOptions(options: MermaidPreviewOptions): string { * variants so callers can render the failure reason without try/catch noise. * * `sequenceDiagram` is recognised but returns explicit `unsupported` until - * slice 031 ships the sequence serializer. + * the sequence serializer ships. */ export function generateMermaidPreview( subgraph: WorkspaceSubgraph, @@ -113,65 +107,43 @@ export function generateMermaidPreview( return { status: "unsupported", options, - reason: "Sequence preview is unavailable until slice 031.", + reason: "Sequence preview is unavailable until trace export is supported.", }; } - try { - const source = serializeToScopedMermaid(subgraph, { - diagram: options.diagram, - scope: options.scope, - granularity: options.granularity, - direction: options.direction, - theme: SCOPED_THEME_BY_PREVIEW_THEME[options.theme], - }); - return { - status: "ok", - options, - source, - title: titleForOptions(options), - ...(softCapWarning(subgraph, options) ?? {}), - }; - } catch (err) { - logger?.error("generateMermaidPreview failed", err, { + // The serializer returns its validation as data, so status comes from the + // typed outcome (not a regex over a thrown Error message) and the soft-cap + // `warning` is read directly (no re-run of the extract→collapse→validate pipeline). + const { source, validation } = serializeScopedMermaidResult(subgraph, { + diagram: options.diagram, + scope: options.scope, + granularity: options.granularity, + direction: options.direction, + theme: SCOPED_THEME_BY_PREVIEW_THEME[options.theme], + }); + + if (source === null) { + const reason = "reason" in validation ? validation.reason : "Preview unavailable."; + // source === null only for blocking statuses; ok/warning always carry source. + const status: "empty" | "oversized" | "unsupported" = + validation.status === "empty" || validation.status === "oversized" + ? validation.status + : "unsupported"; + logger?.error("generateMermaidPreview failed", new Error(reason), { options: options as unknown as Record, nodeCount: subgraph.nodes.length, edgeCount: subgraph.edges.length, }); - const reason = err instanceof Error ? err.message : String(err); - const status = classifyFailure(reason); return { status, options, reason }; } -} - -/** - * Detect the soft-cap `warning` for a successful flowchart preview by re-running - * the (pure) extract → collapse → validate pipeline. Returns `{ warning }` when - * the export is above the soft cap (but within the hard cap, else it would have - * thrown), or null otherwise. ClassDiagram has no node-cap path, so it never - * warns here. - */ -function softCapWarning( - subgraph: WorkspaceSubgraph, - options: MermaidPreviewOptions, -): { warning: string } | null { - if (options.diagram !== "flowchart") { - return null; - } - const extracted = extractMermaidScope(subgraph, options.scope); - if (extracted.status !== "ok") { - return null; - } - const granularity = clampGranularityToScope(options.scope, options.granularity); - const collapsed = applyMermaidGranularity(extracted.subgraph, granularity); - const validation = validateScopedMermaidExport(collapsed, granularity); - return validation.status === "warning" ? { warning: validation.reason } : null; -} -function classifyFailure(reason: string): "empty" | "oversized" | "unsupported" { - if (/zero nodes|empty/i.test(reason)) return "empty"; - if (/exceeds|oversized|cap/i.test(reason)) return "oversized"; - return "unsupported"; + return { + status: "ok", + options, + source, + title: titleForOptions(options), + ...(validation.status === "warning" ? { warning: validation.reason } : {}), + }; } // Re-exported so consumers (the extension command + webview render path) can diff --git a/packages/exporters/src/mermaid/scopedSerializer.ts b/packages/exporters/src/mermaid/scopedSerializer.ts index 4659202..b1c89a4 100644 --- a/packages/exporters/src/mermaid/scopedSerializer.ts +++ b/packages/exporters/src/mermaid/scopedSerializer.ts @@ -23,18 +23,23 @@ export { /** * Bounded discriminator for the diagram shape produced by - * {@link serializeToScopedMermaid}. `flowchart` is the slice-016/027 default; - * `classDiagram` lands in slice 028; `sequenceDiagram` is reserved for slice - * 031. + * {@link serializeToScopedMermaid}. `flowchart` is the default. */ -export type MermaidDiagram = "flowchart" | "classDiagram" | "sequenceDiagram"; +/** + * Canonical option vocabularies. The arrays are the single source of truth + * (RULE-ARCH-007); each type is derived from its array so values and type can + * never drift, and runtime validators / UI dropdowns import the array instead + * of re-listing the strings. + */ +export const MERMAID_DIAGRAMS = ["flowchart", "classDiagram", "sequenceDiagram"] as const; +export type MermaidDiagram = (typeof MERMAID_DIAGRAMS)[number]; /** * Discriminated union describing which portion of the indexed workspace graph * to export. `workspace` and `file` are the original scopes; `visible` exports * an explicit node/edge id set (the rendered `VisibleView`, so an export matches * exactly what the user sees after lenses/filters/depth). `symbol-callers` / - * `symbol-callees` are pre-declared so later slices add behavior without + * `symbol-callees` are pre-declared so behavior can be added without * changing the option type. */ export type MermaidScope = @@ -45,19 +50,20 @@ export type MermaidScope = | { kind: "symbol-callees"; symbolId: string; maxDepth?: number }; /** Bounded level of detail. `symbol` is the pass-through baseline. */ -export type MermaidGranularity = "package" | "file" | "symbol"; +export const MERMAID_GRANULARITIES = ["package", "file", "symbol"] as const; +export type MermaidGranularity = (typeof MERMAID_GRANULARITIES)[number]; /** * Flowchart orientation. `auto` resolves to a per-scope-shape default; the * other values map directly to Mermaid's `graph ` tokens. */ -export type MermaidDirection = "auto" | "TB" | "LR" | "BT" | "RL"; +export const MERMAID_DIRECTIONS = ["auto", "TB", "LR", "BT", "RL"] as const; +export type MermaidDirection = (typeof MERMAID_DIRECTIONS)[number]; /** Full option payload accepted by the scoped serializer. */ export interface ScopedMermaidOptions { /** - * Required since slice 028. The `flowchart` branch is byte-identical to the - * slice-027 path; the `classDiagram` branch delegates to + * The `classDiagram` branch delegates to * `serializeToClassDiagram` and ignores `granularity` (forced to `"symbol"`) * and `direction` (Mermaid classDiagram has no direction token). */ @@ -69,7 +75,7 @@ export interface ScopedMermaidOptions { /** * Required when `diagram === "sequenceDiagram"`. Holds the active webview * trace route the serializer turns into ordered participants and steps. - * Slice-031 wire-through; ignored by the flowchart and classDiagram branches. + * Ignored by the flowchart and classDiagram branches. * Callers that already validate the snapshot themselves may still pass it * here so `serializeToScopedMermaid` can re-validate and fail closed. */ @@ -77,7 +83,7 @@ export interface ScopedMermaidOptions { /** * Opt out of the workspace granularity floor. User-facing export paths leave * this unset so a `workspace` scope never descends to `symbol` (see - * {@link clampGranularityToScope}). The slice-016 legacy `serializeToMermaid` + * {@link clampGranularityToScope}). The legacy `serializeToMermaid` * shim sets it `true` to preserve its byte-identical symbol-level output for * the snapshot/fuzz callers that predate the floor. */ @@ -132,7 +138,7 @@ export type ScopeExtractionResult = // Per-axis behavior lives in scope.ts / granularity.ts / direction.ts / // validator.ts — all re-exported above for consumers of @dextree/exporters. // --------------------------------------------------------------------------- -// Output formatting helpers. Mirror the slice-016 serializer.ts contract so +// Output formatting helpers. Mirror the serializer.ts contract so // the legacy shim and the new scoped path emit byte-identical node + edge // lines (only the `graph ` header differs). // --------------------------------------------------------------------------- @@ -200,20 +206,69 @@ function emitMermaidLines( * non-ok status; callers that want graceful handling should call the * helpers themselves first. */ +/** + * Strategy contract for a Mermaid diagram type (RULE-ARCH-004). Each diagram is + * one serializer; the registry below maps the diagram discriminator to its + * strategy, so adding a diagram type is a registration, not a new switch arm. + */ +export interface SubgraphSerializer { + serialize(subgraph: WorkspaceSubgraph, options: ScopedMermaidOptions): string; +} + +const SERIALIZERS: Readonly> = { + flowchart: { serialize: serializeFlowchart }, + classDiagram: { serialize: serializeToClassDiagram }, + sequenceDiagram: { serialize: serializeSequence }, +}; + export function serializeToScopedMermaid( subgraph: WorkspaceSubgraph, options: ScopedMermaidOptions, ): string { - switch (options.diagram) { - case "flowchart": - return serializeFlowchart(subgraph, options); - case "classDiagram": - return serializeToClassDiagram(subgraph, options); - case "sequenceDiagram": - return serializeSequence(subgraph, options); + const serializer = SERIALIZERS[options.diagram]; + if (serializer === undefined) { + throw new Error(`No Mermaid serializer registered for diagram type '${options.diagram}'`); } + return serializer.serialize(subgraph, options); } +/** + * Typed, non-throwing variant of {@link serializeToScopedMermaid}. Returns the + * validation outcome as data alongside the source, so callers (the preview + * router) can branch on `validation.status` instead of catching an Error and + * regex-matching its message, and read the soft-cap `warning` without re-running + * the pipeline. `source` is non-null exactly when `validation.status` is `ok` or + * `warning`; it is null for `empty` / `oversized` / `unsupported`. + */ +export interface ScopedMermaidResult { + source: string | null; + validation: ScopedExportValidation; +} + +export function serializeScopedMermaidResult( + subgraph: WorkspaceSubgraph, + options: ScopedMermaidOptions, +): ScopedMermaidResult { + if (options.diagram === "flowchart") { + return serializeFlowchartResult(subgraph, options); + } + // classDiagram / sequenceDiagram have no soft-cap node path; surface their + // blocking failures as a typed validation rather than a thrown Error. Their + // failures are empty-scope or unsupported (both `{status, reason}` shapes) — + // never the cap-bearing `oversized`. + try { + return { source: SERIALIZERS[options.diagram].serialize(subgraph, options), validation: OK }; + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + const status: "empty" | "unsupported" = /zero nodes|empty/i.test(reason) + ? "empty" + : "unsupported"; + return { source: null, validation: { status, reason } }; + } +} + +const OK: ScopedExportValidation = { status: "ok", nodeCount: 0, edgeCount: 0 }; + function serializeSequence(subgraph: WorkspaceSubgraph, options: ScopedMermaidOptions): string { if (!options.trace) { throw new Error( @@ -228,9 +283,26 @@ function serializeSequence(subgraph: WorkspaceSubgraph, options: ScopedMermaidOp } function serializeFlowchart(subgraph: WorkspaceSubgraph, options: ScopedMermaidOptions): string { + const result = serializeFlowchartResult(subgraph, options); + if (result.source === null) { + throw new Error( + "reason" in result.validation ? result.validation.reason : "flowchart export failed", + ); + } + return result.source; +} + +/** + * Flowchart serialization computing its validation once and returning it with the + * source. The throwing {@link serializeFlowchart} is a thin wrapper over this. + */ +function serializeFlowchartResult( + subgraph: WorkspaceSubgraph, + options: ScopedMermaidOptions, +): ScopedMermaidResult { const extracted = extractMermaidScope(subgraph, options.scope); if (extracted.status === "unsupported") { - throw new Error(extracted.reason); + return { source: null, validation: { status: "unsupported", reason: extracted.reason } }; } const granularity = options.allowUnscopedSymbols @@ -243,11 +315,11 @@ function serializeFlowchart(subgraph: WorkspaceSubgraph, options: ScopedMermaidO // (the UI surfaces the non-blocking notice). Only empty / oversized / // unsupported block the export. if (validation.status !== "ok" && validation.status !== "warning") { - throw new Error(validation.reason); + return { source: null, validation }; } const direction = options.direction === "auto" ? inferMermaidDirection(options.scope) : options.direction; - return emitMermaidLines(collapsed, direction, options.theme); + return { source: emitMermaidLines(collapsed, direction, options.theme), validation }; } diff --git a/packages/exporters/src/mermaid/sequenceDiagram.ts b/packages/exporters/src/mermaid/sequenceDiagram.ts index 7bd1369..3fb591a 100644 --- a/packages/exporters/src/mermaid/sequenceDiagram.ts +++ b/packages/exporters/src/mermaid/sequenceDiagram.ts @@ -56,12 +56,22 @@ export type SequenceDiagramValidation = const SEQUENCE_DIAGRAM_CAPS = { participants: 40, steps: 100 } as const; -function lookupNode(subgraph: WorkspaceSubgraph, id: string): GraphNode | undefined { - return subgraph.nodes.find((n) => n.id === id); +/** + * Per-serialization id→node / id→edge indexes. Built once per build pass so the + * id lookups inside the trace loops are O(1) instead of a `.find` over the whole + * subgraph each iteration (the loops run per trace node/edge, so the naive form + * was O(trace · subgraph)). + */ +interface SubgraphIndex { + nodes: ReadonlyMap; + edges: ReadonlyMap; } -function lookupEdge(subgraph: WorkspaceSubgraph, id: string) { - return subgraph.edges.find((e) => e.id === id); +function indexSubgraph(subgraph: WorkspaceSubgraph): SubgraphIndex { + return { + nodes: new Map(subgraph.nodes.map((n) => [n.id, n])), + edges: new Map(subgraph.edges.map((e) => [e.id, e])), + }; } function classParticipantLabel(node: GraphNode): string { @@ -74,8 +84,7 @@ function classParticipantLabel(node: GraphNode): string { * quote-escape pass produces `\\"` and the subsequent backslash pass * double-escapes the backslash we just wrote, breaking round-trip parsing. * Also strips newlines and carriage returns since Mermaid line-terminates - * on them. Flagged by CodeQL "Incomplete string escaping" on the prior - * single-replace pattern. + * on them. A single-replace pattern here leaves an incomplete string escape. */ function escapeMermaidLabel(label: string): string { return label @@ -97,24 +106,25 @@ function buildParticipants( // Track non-class participant ids so a cyclic trace that revisits the same // file or top-level symbol produces one participant, not N duplicates. Class // participants are already deduped via `classMap`. Method participants are - // folded into their enclosing class. CodeRabbit flagged that a cap check on + // folded into their enclosing class. Without this dedup, a cap check on // `participants.length` would falsely return `oversized` for valid cyclic - // traces without this dedup — but the same duplication would also have - // emitted duplicate `participant Foo as bar` lines in the serialized output. + // traces — and the same duplication would also have emitted duplicate + // `participant Foo as bar` lines in the serialized output. const seenNonClassIds = new Set(); // Methods can be re-visited too; dedupe the sourceNodeIds we attach to the // enclosing class so the participant's source list doesn't grow unbounded // on cyclic traces. const seenMethodIdsByClass = new Map>(); + const index = indexSubgraph(subgraph); for (const nodeId of trace.nodeIds) { - const node = lookupNode(subgraph, nodeId); + const node = index.nodes.get(nodeId); if (!node) continue; if (node.symbolKind === "method" && node.enclosingSymbolId) { const enclosureId = node.enclosingSymbolId; if (!classMap.has(enclosureId)) { - const enc = lookupNode(subgraph, enclosureId); + const enc = index.nodes.get(enclosureId); classMap.set(enclosureId, { label: enc ? classParticipantLabel(enc) : enclosureId, sourceNodeIds: [], @@ -183,15 +193,16 @@ function buildSteps( participants: SequenceDiagramParticipant[], ): SequenceDiagramStep[] { const steps: SequenceDiagramStep[] = []; + const index = indexSubgraph(subgraph); let hopIndex = 0; for (const edgeId of trace.edgeIds) { - const edge = lookupEdge(subgraph, edgeId); + const edge = index.edges.get(edgeId); hopIndex += 1; if (!edge) continue; const from = resolveParticipantId(edge.source, participants); const to = resolveParticipantId(edge.target, participants); if (!from || !to) continue; - const sourceNode = lookupNode(subgraph, edge.source); + const sourceNode = index.nodes.get(edge.source); const label = sourceNode?.label ?? edge.kind; steps.push({ edgeId: edge.id, diff --git a/packages/exporters/src/mermaid/serializer.ts b/packages/exporters/src/mermaid/serializer.ts index 1f18517..72c65e1 100644 --- a/packages/exporters/src/mermaid/serializer.ts +++ b/packages/exporters/src/mermaid/serializer.ts @@ -9,10 +9,10 @@ export interface MermaidSerializeOptions { } /** - * Slice-016 compatibility shim. Delegates to {@link serializeToScopedMermaid} + * Compatibility shim. Delegates to {@link serializeToScopedMermaid} * with the workspace / symbol / auto defaults. * - * Preserves two slice-016 invariants that the new scoped path does not + * Preserves two invariants that the new scoped path does not * guarantee on its own: * * 1. Throws `Error("Graph has no nodes to export")` on an empty workspace @@ -20,8 +20,8 @@ export interface MermaidSerializeOptions { * 2. Emits `graph TD` as the second line (Mermaid alias of `graph TB`) so * existing snapshot tests stay byte-identical. * - * Slated for removal in slice 029 once the preview panel becomes the only - * consumer of the legacy entry point. + * Slated for removal once the preview panel becomes the only consumer of + * the legacy entry point. */ export function serializeToMermaid( subgraph: WorkspaceSubgraph, diff --git a/packages/exporters/src/mermaid/validator.ts b/packages/exporters/src/mermaid/validator.ts index 200676d..832bead 100644 --- a/packages/exporters/src/mermaid/validator.ts +++ b/packages/exporters/src/mermaid/validator.ts @@ -7,7 +7,7 @@ import type { MermaidGranularity, ScopedExportValidation } from "./scopedSeriali * * - `soft` — beyond this the diagram is still exported but the result is * flagged `warning` so the UI can surface a non-blocking "large diagram" - * notice (the slice-033 status-bar "soft cap"). + * notice (the status-bar "soft cap"). * - `hard` — beyond this the export is refused (`oversized`); the diagram is * too dense to be legible. * @@ -87,3 +87,24 @@ export function validateScopedMermaidExport( return { status: "ok", nodeCount, edgeCount }; } + +/** + * Single-tier hard-cap check shared by the class- and sequence-diagram + * validators (the scoped validator above is two-tier and not a consumer). + * Returns the breach reason for the first dimension over its cap, or null when + * every dimension is within budget. Each dimension supplies its own reason + * builder so the messages stay diagram-specific (a class diagram says + * "classes", a sequence diagram says "participants"). + */ +export interface CapDimension { + count: number; + cap: number; + reason: (count: number, cap: number) => string; +} + +export function firstCapBreach(dimensions: readonly CapDimension[]): string | null { + for (const { count, cap, reason } of dimensions) { + if (count > cap) return reason(count, cap); + } + return null; +} diff --git a/packages/extension/src/cache/workspaceRegistry.ts b/packages/extension/src/cache/workspaceRegistry.ts index 4792000..5c3a627 100644 --- a/packages/extension/src/cache/workspaceRegistry.ts +++ b/packages/extension/src/cache/workspaceRegistry.ts @@ -1,5 +1,5 @@ /** - * Global workspace registry for the workspace switcher (slice 024). + * Global workspace registry for the workspace switcher. * * Maintains a JSON file under `globalStorageUri` that maps each indexed * workspaceRoot to its DuckDB path. This is the only durable way for the diff --git a/packages/extension/src/commands/exportMermaid.ts b/packages/extension/src/commands/exportMermaid.ts index dc21178..ceb00d3 100644 --- a/packages/extension/src/commands/exportMermaid.ts +++ b/packages/extension/src/commands/exportMermaid.ts @@ -22,10 +22,10 @@ export interface ExportMermaidCommandDependencies { /** * Default preview options used when the command opens the preview tab from - * the command palette or the existing graph-toolbar export button. Slice 029 - * deliberately removes the slice 027/028 QuickPick chain (Diagram / Scope / - * Granularity / Direction) in favour of inline controls on the preview tab - * itself (US2 in PR-B). The `theme` here is a host-side default that the + * the command palette or the existing graph-toolbar export button. The + * QuickPick chain (Diagram / Scope / Granularity / Direction) was removed in + * favour of inline controls on the preview tab itself. The `theme` here is a + * host-side default that the * webview re-resolves against the live VS Code theme on inline rerender; * for the initial open it is good enough to thread `light` through the * router so the source generation is deterministic. @@ -43,13 +43,13 @@ const DEFAULT_PREVIEW_OPTIONS: MermaidPreviewOptions = { }; /** - * `dextree.exportMermaid` command body (slice 029). + * `dextree.exportMermaid` command body. * - * Before slice 029 this command walked the user through a 4-step QuickPick - * (Diagram → Scope → Granularity → Direction) and a save dialog. Slice 029 - * replaces both surfaces with the Mermaid preview tab, which hosts the same - * controls inline (US2) and offers `.mmd` / `.svg` / `.png` / clipboard / - * Markdown-snippet output actions (US3) without ever closing. + * This command previously walked the user through a 4-step QuickPick + * (Diagram → Scope → Granularity → Direction) and a save dialog. Both + * surfaces are now replaced with the Mermaid preview tab, which hosts the + * same controls inline and offers `.mmd` / `.svg` / `.png` / clipboard / + * Markdown-snippet output actions without ever closing. * * In PR-A the command only opens the preview at workspace + symbol + * flowchart + auto defaults; inline controls and output actions ship in @@ -82,7 +82,7 @@ export function createExportMermaidCommand( /** * Execute an inferred Mermaid export. Used by selection-aware entry points - * (slice 030, US1) and focused export commands (slice 030, US3). + * and focused export commands. */ export async function executeInferredMermaidExport( dependencies: ExportMermaidCommandDependencies, @@ -165,7 +165,7 @@ export async function startInferredMermaidExport( } /** - * Slice 031 (US1) — `dextree.exportTraceSequence` command body. Receives the + * `dextree.exportTraceSequence` command body. Receives the * webview's `TraceSequenceSnapshot` as the first arg, re-validates it against * the current workspace subgraph (fail-closed for empty / unsupported / * oversized routes), serializes to Mermaid sequence syntax, and writes the diff --git a/packages/extension/src/commands/exportShortcuts.ts b/packages/extension/src/commands/exportShortcuts.ts new file mode 100644 index 0000000..4385bb6 --- /dev/null +++ b/packages/extension/src/commands/exportShortcuts.ts @@ -0,0 +1,55 @@ +import * as vscode from "vscode"; + +import { + startInferredMermaidExport, + type ExportMermaidCommandDependencies, +} from "./exportMermaid.js"; + +/** A symbol picked by the user (id + file) for a symbol-scoped export shortcut. */ +export interface PickedSymbol { + id: string; + filePath: string; +} + +export interface ExportShortcutDeps extends ExportMermaidCommandDependencies { + /** Prompt the user to pick a symbol; resolves undefined if cancelled. */ + pickSymbol: () => Promise; +} + +/** + * The selection-aware Mermaid export shortcuts (callers / callees / class + * hierarchy / package). Each is a thin `pickSymbol → startInferredMermaidExport` + * delegation, so they all live in one factory instead of inline in `activate`. + * Returns the disposables to push onto `context.subscriptions`. + */ +export function createExportShortcutCommands(deps: ExportShortcutDeps): vscode.Disposable[] { + const exportDeps: ExportMermaidCommandDependencies = { + getIndexer: deps.getIndexer, + openMermaidPreview: deps.openMermaidPreview, + }; + + const fromPickedSymbol = (intent: "callers" | "callees" | "class-hierarchy") => async () => { + const symbol = await deps.pickSymbol(); + if (symbol === undefined) return; + await startInferredMermaidExport(exportDeps, intent, { + kind: "symbol", + symbolId: symbol.id, + filePath: symbol.filePath, + }); + }; + + return [ + vscode.commands.registerCommand("dextree.exportCallers", fromPickedSymbol("callers")), + vscode.commands.registerCommand("dextree.exportCallees", fromPickedSymbol("callees")), + vscode.commands.registerCommand( + "dextree.exportClassHierarchy", + fromPickedSymbol("class-hierarchy"), + ), + vscode.commands.registerCommand("dextree.exportPackage", async () => { + await startInferredMermaidExport(exportDeps, "package", { + kind: "folder", + relativePath: ".", + }); + }), + ]; +} diff --git a/packages/extension/src/commands/indexWorkspace.ts b/packages/extension/src/commands/indexWorkspace.ts index 0eb7afc..ea4311a 100644 --- a/packages/extension/src/commands/indexWorkspace.ts +++ b/packages/extension/src/commands/indexWorkspace.ts @@ -111,7 +111,7 @@ export function createIndexWorkspaceCommand( }); await indexer.clearWorkspace(root.uri.fsPath); - // Framework detection runs once before the per-file loop (slice 018). + // Framework detection runs once before the per-file loop. // Result is cached inside the indexer and consumed by each indexFile call. try { const frameworks = await indexer.detectWorkspaceFrameworks(root.uri.fsPath); diff --git a/packages/extension/src/commands/switchWorkspace.ts b/packages/extension/src/commands/switchWorkspace.ts index f5d135a..d79a104 100644 --- a/packages/extension/src/commands/switchWorkspace.ts +++ b/packages/extension/src/commands/switchWorkspace.ts @@ -1,5 +1,5 @@ /** - * `dextree.switchWorkspace` command (slice 024). + * `dextree.switchWorkspace` command. * * Reads the global workspace registry, shows a quick-pick of indexed * workspaces, and pushes the selected workspace's graph into the open panel. diff --git a/packages/extension/src/extension.ts b/packages/extension/src/extension.ts index 6ad1ef0..e8340c9 100644 --- a/packages/extension/src/extension.ts +++ b/packages/extension/src/extension.ts @@ -19,12 +19,12 @@ import { createExportMermaidCommand, createExportTraceSequenceCommand, executeInferredMermaidExport, - startInferredMermaidExport, } from "./commands/exportMermaid.js"; import { createExportCurrentViewCommand, createExportTraceCommand, } from "./commands/exportCurrentView.js"; +import { createExportShortcutCommands } from "./commands/exportShortcuts.js"; import { createIndexFileCommand } from "./commands/indexFile.js"; import { createIndexWorkspaceCommand, @@ -248,7 +248,7 @@ export async function activate(context: ActivationContext): Promise { })); }); - // Slice 029 PR-B — wire inline-control rerenders. The webview posts + // Wire inline-control rerenders. The webview posts // `requestMermaidPreview` whenever the user changes a control; the handler // pulls the latest indexed subgraph for the active workspace and runs it // through the same preview router the `dextree.exportMermaid` command uses. @@ -422,41 +422,12 @@ export async function activate(context: ActivationContext): Promise { }, }), ), - // Slice 030 — selection-aware and focused Mermaid export commands. - // All commands delegate to the same inferred-export path so behavior - // stays consistent and fail-closed. - vscode.commands.registerCommand("dextree.exportCallers", async () => { - const symbol = await pickSymbol(); - if (symbol === undefined) return; - await startInferredMermaidExport({ getIndexer, openMermaidPreview: openPreview }, "callers", { - kind: "symbol", - symbolId: symbol.id, - filePath: symbol.filePath, - }); - }), - vscode.commands.registerCommand("dextree.exportCallees", async () => { - const symbol = await pickSymbol(); - if (symbol === undefined) return; - await startInferredMermaidExport({ getIndexer, openMermaidPreview: openPreview }, "callees", { - kind: "symbol", - symbolId: symbol.id, - filePath: symbol.filePath, - }); - }), - vscode.commands.registerCommand("dextree.exportClassHierarchy", async () => { - const symbol = await pickSymbol(); - if (symbol === undefined) return; - await startInferredMermaidExport( - { getIndexer, openMermaidPreview: openPreview }, - "class-hierarchy", - { kind: "symbol", symbolId: symbol.id, filePath: symbol.filePath }, - ); - }), - vscode.commands.registerCommand("dextree.exportPackage", async () => { - await startInferredMermaidExport({ getIndexer, openMermaidPreview: openPreview }, "package", { - kind: "folder", - relativePath: ".", - }); + // Selection-aware Mermaid export shortcuts (callers / callees / class + // hierarchy / package) — all delegate to the same inferred-export path. + ...createExportShortcutCommands({ + getIndexer, + openMermaidPreview: openPreview, + pickSymbol, }), vscode.commands.registerCommand( "dextree.exportTrace", diff --git a/packages/extension/src/resolution/lspCallResolver.ts b/packages/extension/src/resolution/lspCallResolver.ts index 48a6065..db79679 100644 --- a/packages/extension/src/resolution/lspCallResolver.ts +++ b/packages/extension/src/resolution/lspCallResolver.ts @@ -1,38 +1,20 @@ +import type { NodeLocation, PreciseCallEdge, PreciseLocationResolver } from "@dextree/core"; import * as vscode from "vscode"; -/** A precise caller/callee edge resolved by the user's language server. */ -export interface PreciseCallEdge { - /** Display name of the related symbol (the caller or callee). */ - name: string; - /** Absolute file path of the related symbol. */ - filePath: string; - /** 0-based start line of the related symbol. */ - line: number; - /** Resolution tier — always "precise" for LSP results. */ - tier: "precise"; - confidence: number; -} - -/** Where the selected node lives, so the LSP can be queried at that position. */ -export interface NodeLocation { - filePath: string; - /** 0-based line. */ - line: number; - /** 0-based column. */ - column: number; -} +export type { NodeLocation, PreciseCallEdge }; /** * Resolves precise callers/callees for a selected node using the language server - * the user already has installed — zero per-language code on our side - * (RULE-ARCH-010, the injected precise tier). Degrades gracefully: if no server - * answers (not installed, not warmed, no call-hierarchy support) it returns an - * empty list and the heuristic tier stands. + * the user already has installed — zero per-language code on our side. Implements + * core's {@link PreciseLocationResolver} (RULE-ARCH-010, the injected precise + * tier): `core` owns the contract; the VS Code host implements it. Degrades + * gracefully — if no server answers (not installed, not warmed, no call-hierarchy + * support) it returns an empty list and the heuristic tier stands. * * Host-only: depends on the VS Code command bridge, so it lives in the extension, * never in `core` (RULE-ARCH-005). */ -export class LspCallResolver { +export class LspCallResolver implements PreciseLocationResolver { readonly tier = "precise" as const; async resolve(node: NodeLocation, direction: "in" | "out"): Promise { diff --git a/packages/extension/src/tree/SymbolsTreeProvider.test.ts b/packages/extension/src/tree/SymbolsTreeProvider.test.ts index acde20c..cca1506 100644 --- a/packages/extension/src/tree/SymbolsTreeProvider.test.ts +++ b/packages/extension/src/tree/SymbolsTreeProvider.test.ts @@ -394,6 +394,89 @@ describe("SymbolsTreeProvider — file children (US1)", () => { const children = await provider.getChildren(symbolNode); expect(children).toHaveLength(0); }); + + it("nests methods under their enclosing class via enclosingSymbolId", async () => { + const indexer = makeIndexer(); + indexer.getSymbols.mockResolvedValue([ + { + id: "cls", + fqn: "src/svc.ts:Service", + name: "Service", + kind: "class", + fileId: "f1", + range: { startLine: 0, startCol: 0, endLine: 20, endCol: 1 }, + language: "typescript", + }, + { + id: "m1", + fqn: "src/svc.ts:Service.run", + name: "run", + kind: "method", + fileId: "f1", + enclosingSymbolId: "cls", + range: { startLine: 2, startCol: 2, endLine: 4, endCol: 3 }, + language: "typescript", + }, + { + id: "free", + fqn: "src/svc.ts:helper", + name: "helper", + kind: "function", + fileId: "f1", + range: { startLine: 22, startCol: 0, endLine: 24, endCol: 1 }, + language: "typescript", + }, + ]); + const fileNode = new TreeFileNode({ + id: "f1", + relativePath: "src/svc.ts", + language: "typescript", + path: "/workspace/src/svc.ts", + hash: "abc123", + }); + const provider = makeProvider(indexer); + + // Top level: the class and the free function, not the method. + const top = await provider.getChildren(fileNode); + expect(top).toHaveLength(2); + expect(top[0]?.treeItem.label).toBe("Service (class)"); + expect(top[1]?.treeItem.label).toBe("helper (function)"); + // Collapsed (1) because it has a member; mirrors the mock's enum mapping. + expect(top[0]?.treeItem.collapsibleState).toBe(1); + + // The class node yields its method as a child. + const classChildren = await provider.getChildren(top[0]); + expect(classChildren).toHaveLength(1); + expect(classChildren[0]?.treeItem.label).toBe("run (method)"); + expect(classChildren[0]?.treeItem.collapsibleState).toBe(0); // None — leaf + }); + + it("surfaces a symbol whose enclosing id is absent from the file as top-level", async () => { + const indexer = makeIndexer(); + indexer.getSymbols.mockResolvedValue([ + { + id: "orphan", + fqn: "src/svc.ts:orphan", + name: "orphan", + kind: "method", + fileId: "f1", + enclosingSymbolId: "not-in-file", + range: { startLine: 0, startCol: 0, endLine: 1, endCol: 1 }, + language: "typescript", + }, + ]); + const fileNode = new TreeFileNode({ + id: "f1", + relativePath: "src/svc.ts", + language: "typescript", + path: "/workspace/src/svc.ts", + hash: "abc123", + }); + const provider = makeProvider(indexer); + const top = await provider.getChildren(fileNode); + expect(top).toHaveLength(1); + expect(top[0]?.treeItem.label).toBe("orphan (method)"); + }); }); // --------------------------------------------------------------------------- diff --git a/packages/extension/src/tree/SymbolsTreeProvider.ts b/packages/extension/src/tree/SymbolsTreeProvider.ts index 35cd61c..3312b6c 100644 --- a/packages/extension/src/tree/SymbolsTreeProvider.ts +++ b/packages/extension/src/tree/SymbolsTreeProvider.ts @@ -105,9 +105,19 @@ export class TreeSymbolNode { readonly kind = "symbol" as const; readonly treeItem: vscode.TreeItem; - constructor(symbol: StoredSymbol, fileUri: vscode.Uri) { + constructor( + symbol: StoredSymbol, + fileUri: vscode.Uri, + readonly children: TreeSymbolNode[] = [], + ) { const label = `${symbol.name} (${symbol.kind})`; - const item = new vscode.TreeItem(label, vscode.TreeItemCollapsibleState.None); + // A symbol with members (e.g. a class with methods) is collapsible so its + // members nest under it; member-less symbols stay leaves. + const collapsible = + children.length > 0 + ? vscode.TreeItemCollapsibleState.Collapsed + : vscode.TreeItemCollapsibleState.None; + const item = new vscode.TreeItem(label, collapsible); item.iconPath = new vscode.ThemeIcon(kindCodicon(symbol.kind)); item.contextValue = "dextreeSymbol"; item.tooltip = `${symbol.name} (${symbol.kind}) — ${symbol.id}`; @@ -174,6 +184,49 @@ function buildRootTree(files: StoredFile[], workspaceUri?: vscode.Uri): TreeNode ]; } +/** + * Nest a file's symbols by `enclosingSymbolId`: methods/members render under + * their enclosing class/interface instead of as flat siblings. A symbol is + * top-level when it has no enclosing id, or its enclosing id is not among this + * file's symbols (a dangling reference — surfaced rather than dropped). Order is + * preserved within each level so the tree mirrors `getSymbols` ordering. + */ +function buildSymbolTree(symbols: StoredSymbol[], fileUri: vscode.Uri): TreeSymbolNode[] { + const childrenByParent = new Map(); + const presentIds = new Set(symbols.map((s) => s.id)); + + for (const symbol of symbols) { + const parentId = symbol.enclosingSymbolId; + if (parentId === undefined || parentId === symbol.id || !presentIds.has(parentId)) continue; + const bucket = childrenByParent.get(parentId); + if (bucket === undefined) { + childrenByParent.set(parentId, [symbol]); + } else { + bucket.push(symbol); + } + } + + // `seen` guards against a malformed enclosing cycle (A→B→A) producing + // infinite recursion; a symbol already on the current path renders as a leaf. + const build = (symbol: StoredSymbol, seen: ReadonlySet): TreeSymbolNode => { + if (seen.has(symbol.id)) return new TreeSymbolNode(symbol, fileUri); + const nextSeen = new Set(seen).add(symbol.id); + const childSymbols = childrenByParent.get(symbol.id) ?? []; + return new TreeSymbolNode( + symbol, + fileUri, + childSymbols.map((child) => build(child, nextSeen)), + ); + }; + + return symbols + .filter((symbol) => { + const parentId = symbol.enclosingSymbolId; + return parentId === undefined || parentId === symbol.id || !presentIds.has(parentId); + }) + .map((symbol) => build(symbol, new Set())); +} + // --------------------------------------------------------------------------- // TreeDataProvider // --------------------------------------------------------------------------- @@ -237,17 +290,15 @@ export class SymbolsTreeProvider implements vscode.TreeDataProvider { } try { const symbols = await indexer.getSymbols(element.file.relativePath); - return symbols.map((symbol) => { - const fileUri = vscode.Uri.joinPath(workspaceUri, element.file.relativePath); - return new TreeSymbolNode(symbol, fileUri); - }); + const fileUri = vscode.Uri.joinPath(workspaceUri, element.file.relativePath); + return buildSymbolTree(symbols, fileUri); } catch (error) { this.logger.error(`Failed to load symbols for ${element.file.relativePath}`, error); return []; } } - // Symbol nodes are leaf nodes - return []; + // Symbol node — return its nested members (empty for leaf symbols) + return element.children; } } diff --git a/packages/extension/src/watcher/workspaceWatcher.ts b/packages/extension/src/watcher/workspaceWatcher.ts index ed36f20..2edeaef 100644 --- a/packages/extension/src/watcher/workspaceWatcher.ts +++ b/packages/extension/src/watcher/workspaceWatcher.ts @@ -37,7 +37,7 @@ export function createWorkspaceWatcher( async function processEvent(event: WatcherEvent): Promise { const filePath = event.uri.fsPath; - // FR-002 guard: silently skip if workspace has not been indexed yet + // Silently skip if workspace has not been indexed yet try { const indexer = await getIndexer(); const allFiles = await indexer.getAllFiles(); @@ -123,7 +123,9 @@ export function createWorkspaceWatcher( onIndexed(); logger.debug(`[watcher] re-indexed: ${fileName}`); } catch (err) { - logger.debug(`[watcher] error processing ${fileName}: ${String(err)}`); + // A failed background re-index leaves the graph stale vs disk — surface it + // at error level, not debug, so the staleness is diagnosable. + logger.error(`[watcher] error processing ${fileName}`, err); } } diff --git a/packages/extension/src/webview/App.tsx b/packages/extension/src/webview/App.tsx index a7b671b..80f5933 100644 --- a/packages/extension/src/webview/App.tsx +++ b/packages/extension/src/webview/App.tsx @@ -27,7 +27,7 @@ import type { MermaidPreviewFileFormat } from "./preview/exportPreview.js"; type AppScene = "graph" | "workspaces" | "mermaid-preview"; /** - * Tab identity for the editor-style strip (slice 033 US1). Distinct from + * Tab identity for the editor-style strip. Distinct from * {@link AppScene}: "trace" is a *variant* of the graph scene (driven by * GraphView's internal trace state), not a separate scene — so it maps back to * the "graph" scene when activated. @@ -35,7 +35,7 @@ type AppScene = "graph" | "workspaces" | "mermaid-preview"; type TabKey = "graph" | "mermaid" | "trace" | "workspaces"; // --------------------------------------------------------------------------- -// State model — discriminated union (FR-002, FR-008) +// State model — discriminated union // --------------------------------------------------------------------------- interface AppState { @@ -88,7 +88,7 @@ function reducer(state: AppState, action: AppAction): AppState { } // --------------------------------------------------------------------------- -// Tab strip (slice 033 US1) +// Tab strip // --------------------------------------------------------------------------- interface TabDescriptor { @@ -351,10 +351,10 @@ export function App({ vscodeApi }: AppProps) { [showSourceOnly, state.edges, displayNodeIds], ); - // ---- Tab strip model (slice 033 US1) -------------------------------------- + // ---- Tab strip model ------------------------------------------------------ // The active tab derives from activeScene. "trace" is a graph-scene variant // owned by GraphView; at the App level it stays disabled until trace wiring - // lands (phase 5 / T032), so it never shows as the active tab here. + // lands, so it never shows as the active tab here. const activeTabKey: TabKey = activeScene === "workspaces" ? "workspaces" @@ -413,8 +413,8 @@ export function App({ vscodeApi }: AppProps) { } if (activeScene === "mermaid-preview") { - // The "Back to graph" action moved into the panel toolbar (slice 033 - // Phase 4); App no longer renders a separate preview topbar. + // The "Back to graph" action moved into the panel toolbar; App no + // longer renders a separate preview topbar. return (
void; onZoomReset: () => void; /** - * Workspace actions relocated from the legacy right-rail panel (slice 033). + * Workspace actions relocated from the legacy right-rail panel. * The whole group renders only when `onReindex` is provided, so GraphView * usages that don't own these handlers keep the mockup-clean toolbar. */ @@ -239,7 +239,7 @@ export function GraphToolbar({
- {/* Group: Workspace actions (relocated from legacy right panel, slice 033) */} + {/* Group: Workspace actions (relocated from legacy right panel) */} {onReindex !== undefined && (