From 367c82bee3a45a69fe8bb8c6bde34ed514374cba Mon Sep 17 00:00:00 2001 From: ErQrYfkrju <65288684+ErQrYfkrju@users.noreply.github.com> Date: Fri, 28 Aug 2026 01:13:08 +0300 Subject: [PATCH 1/3] feat(extraction): OpenSCAD language support (#openscad) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Index parametric CAD projects. `.scad` files are detected, parsed with the OpenSCAD organisation's own tree-sitter grammar, and turned into symbols. Grammar: @openscad/tree-sitter-openscad 0.6.1, vendored as wasm because it is absent from tree-sitter-wasms and the package publishes no prebuilt binary. ABI 15, no external scanner; built from the pinned package with tree-sitter-cli 0.25.10, matching how every other vendored grammar here is produced. Mappings follow the language rather than forcing it into a shape it lacks: - `module` and `function` both become `function` nodes. A module is a named, parameterised, callable definition whose result is geometry — structurally a function. NOT the `module` kind, which already means a file-level module and would collide with that concept in every cross-language query. - classTypes/methodTypes/interfaceTypes/structTypes/enumTypes/typeAliasTypes are empty. OpenSCAD has none of these, and an approximated class is wrong in a way the caller cannot detect. - `assignment` is deliberately NOT the variable type: the grammar reuses it for default parameter values, named call arguments and let/for bindings, so mapping it would mint a variable for every `cube(center = true)`. A real declaration is `var_declaration`, handled in the visitNode hook. - resolveBody: a named `function` has no body field — its body is an unnamed expression child — so without this, calls inside `function total(v) = sum(scale(v));` produce no edges. - extractImport reads the `include_path` child and records the path VERBATIM. Nothing is joined onto a filesystem path or opened here; path handling lives in the resolver. Transform chains nest as transform_chain -> module_call + transform_chain, so the body walker reaches every operator in `translate(…) rotate(…) cube(…)`, and a leading `!`/`#`/`%`/`*` modifier cannot hide the call it decorates. Validated on BOSL2 (67 .scad, 86k lines): 1,855 functions, 17,295 edges, 66 of 67 files parse with no ERROR node. 9 tests. Co-Authored-By: Claude Opus 5 --- __tests__/extraction.test.ts | 152 ++++++++++++++++++ src/extraction/grammars.ts | 12 ++ src/extraction/languages/index.ts | 2 + src/extraction/languages/openscad.ts | 130 +++++++++++++++ src/extraction/wasm/tree-sitter-openscad.wasm | Bin 0 -> 44888 bytes src/types.ts | 1 + 6 files changed, 297 insertions(+) create mode 100644 src/extraction/languages/openscad.ts create mode 100644 src/extraction/wasm/tree-sitter-openscad.wasm diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index ad0ba2374..327995ad3 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -150,6 +150,13 @@ describe('Language Detection', () => { expect(isSourceFile('default.nix')).toBe(true); }); + it('should detect OpenSCAD files', () => { + expect(detectLanguage('parts/bracket.scad')).toBe('openscad'); + // isSourceFile is the file-scan allowlist. Detection without it means a + // project of .scad files indexes as zero files. + expect(isSourceFile('parts/bracket.scad')).toBe(true); + }); + it('should detect a .h whose only C++ signal is an export-macro class as cpp', () => { // Lean Unreal-Engine style header: the class is annotated with an export // macro and carries no explicit `public:`/`virtual`/`namespace`/`template`, @@ -11919,3 +11926,148 @@ describe('C/C++ kernel-port preParse blanks (R7a)', () => { expect(result.nodes.some((n) => n.kind === 'method' && n.name === 'size')).toBe(true); }); }); + +describe('OpenSCAD Extraction', () => { + it('should extract modules and functions as function symbols', () => { + const code = ` +module bracket(width, height=10) { + cube([width, height, 2]); +} + +function area(w, h) = w * h; +`; + const result = extractFromSource('parts.scad', code); + + // A module is a named, parameterised, callable definition whose result is + // geometry — structurally a function. Both map to the `function` kind. + const bracket = result.nodes.find((n) => n.kind === 'function' && n.name === 'bracket'); + const area = result.nodes.find((n) => n.kind === 'function' && n.name === 'area'); + + expect(bracket).toBeDefined(); + expect(bracket?.signature).toBe('(width, height=10)'); + expect(area).toBeDefined(); + expect(area?.signature).toBe('(w, h)'); + }); + + it('should extract calls from a function expression body', () => { + // A named `function` has no `body` field — its body is an unnamed + // expression child. Without the extractor's resolveBody hook these calls + // produce no edges at all. + const code = `function total(v) = sum(scale(v));`; + const result = extractFromSource('math.scad', code); + + const calls = result.unresolvedReferences + .filter((r) => r.referenceKind === 'calls') + .map((r) => r.referenceName); + + expect(calls).toContain('sum'); + expect(calls).toContain('scale'); + }); + + it('should extract a call edge per operator in a transform chain', () => { + // `translate(...) rotate(...) cube(...)` nests as transform_chain → + // module_call + transform_chain, so every operator must yield an edge — + // not just the outermost one. A `#`/`!`/`%`/`*` modifier must not hide the + // call it decorates. + const code = ` +module part() { + translate([1,0,0]) rotate([0,0,90]) cube(5); + #sphere(3); +} +`; + const result = extractFromSource('part.scad', code); + + const calls = result.unresolvedReferences + .filter((r) => r.referenceKind === 'calls') + .map((r) => r.referenceName); + + expect(calls).toContain('translate'); + expect(calls).toContain('rotate'); + expect(calls).toContain('cube'); + expect(calls).toContain('sphere'); + }); + + it('should extract include and use directives as imports, without resolving them', () => { + const code = ` +include +use <../lib/gears.scad> +`; + const result = extractFromSource('parts.scad', code); + + const imports = result.nodes.filter((n) => n.kind === 'import').map((n) => n.name); + // The path text keeps its angle brackets in the grammar; the import name + // must not. + expect(imports).toContain('BOSL2/std.scad'); + expect(imports).toContain('../lib/gears.scad'); + }); + + it('should record a traversal-shaped include path verbatim and never dereference it', () => { + // Import path text is attacker-controlled: an indexed repository can carry + // any `.scad` file. Resolution is deliberately not implemented, so the path + // is stored as a name and nothing else. This guards the day someone adds + // resolution without the containment that belongs with it. + const result = extractFromSource('evil.scad', 'include <../../../../etc/passwd>\n'); + + // Stored exactly as written: not normalized, not joined onto the importing + // file's directory, not resolved to anything. + const imported = result.nodes.find((n) => n.kind === 'import'); + expect(imported?.name).toBe('../../../../etc/passwd'); + // The `../` sequences are still there — nothing collapsed them into an + // absolute path, which is what resolving would have produced. + expect(result.nodes.every((n) => !n.name.startsWith('/'))).toBe(true); + + // And the extractor cannot dereference a path even if it wanted to — it + // holds no filesystem capability. This is the assertion that fails first if + // someone adds resolution here instead of in the resolver, where the + // containment belongs. + const extractorSource = fs.readFileSync( + path.join(__dirname, '..', 'src', 'extraction', 'languages', 'openscad.ts'), + 'utf8' + ); + expect(extractorSource).not.toMatch(/from\s+'(node:)?fs'|require\(\s*'(node:)?fs'/); + }); + + it('should extract top-level assignments as variables, keeping the $ sigil', () => { + // `assignment` is NOT the variable node type here: the grammar reuses it + // for default parameter values and named call arguments. Only a + // var_declaration is a real declaration, so `center` below must not + // become a variable. + const code = ` +$fn = 64; +wall_thickness = 2.4; + +module plate() { cube(10, center = true); } +`; + const result = extractFromSource('vars.scad', code); + + const variables = result.nodes.filter((n) => n.kind === 'variable').map((n) => n.name); + expect(variables).toContain('$fn'); + expect(variables).toContain('wall_thickness'); + expect(variables).not.toContain('center'); + }); + + it('should not invent node kinds OpenSCAD has no concept of', () => { + // Empty mappings are deliberate. An approximated class is wrong in a way + // the caller cannot detect; an empty one is honestly empty. + const code = ` +include +thickness = 3; +function area(w, h) = w * h; +module plate() { cube(10); } +`; + const result = extractFromSource('parts.scad', code); + + const absent = ['class', 'method', 'interface', 'struct', 'enum', 'enum_member', 'type_alias']; + expect(result.nodes.filter((n) => absent.includes(n.kind))).toEqual([]); + }); + + it('should survive a truncated file and still extract what parsed', () => { + // The parser is reached by file content CodeGraph does not control, so a + // tree carrying ERROR/MISSING nodes must degrade rather than throw. + const code = `module a() { cube(5); } module b(`; + + let result!: ReturnType; + expect(() => { result = extractFromSource('broken.scad', code); }).not.toThrow(); + expect(result.nodes.some((n) => n.kind === 'function' && n.name === 'a')).toBe(true); + }); +}); diff --git a/src/extraction/grammars.ts b/src/extraction/grammars.ts index 84647c3e4..d7e0803e4 100644 --- a/src/extraction/grammars.ts +++ b/src/extraction/grammars.ts @@ -50,6 +50,7 @@ const WASM_GRAMMAR_FILES: Record = { terraform: 'tree-sitter-terraform.wasm', arkts: 'tree-sitter-arkts.wasm', nix: 'tree-sitter-nix.wasm', + openscad: 'tree-sitter-openscad.wasm', }; /** @@ -170,6 +171,9 @@ export const EXTENSION_MAP: Record = { '.tf': 'terraform', '.tfvars': 'terraform', '.tofu': 'terraform', + // OpenSCAD parametric CAD scripts. `module`/`function` definitions and + // `include`/`use` directives; no classes or types in the language. + '.scad': 'openscad', }; /** @@ -338,6 +342,13 @@ const VENDORED_WASM_LANGS: ReadonlySet = new Set([ // kernel compiles the same-commit vendored C (codegraph-kernel/grammars/ // dart); crates.io tree-sitter-dart is a different-lineage fork (rejected). 'dart', + // OpenSCAD: @openscad/tree-sitter-openscad 0.6.1, the grammar published by + // the OpenSCAD org itself. ABI 15, no external scanner. Vendored because + // tree-sitter-wasms ships no openscad build and the upstream package + // publishes no prebuilt wasm — only grammar.js + the checked-in parser.c, + // which is what `tree-sitter-cli 0.25.10 build --wasm` compiles here (no + // `generate`), matching how every other language above was vendored. + 'openscad', ]); /** Absolute path of a language's grammar WASM (vendored or tree-sitter-wasms). */ @@ -694,6 +705,7 @@ export function getLanguageDisplayName(language: Language): string { erlang: 'Erlang', terraform: 'Terraform', arkts: 'ArkTS', + openscad: 'OpenSCAD', unknown: 'Unknown', }; return names[language] || language; diff --git a/src/extraction/languages/index.ts b/src/extraction/languages/index.ts index 6b760b01d..2bd658ada 100644 --- a/src/extraction/languages/index.ts +++ b/src/extraction/languages/index.ts @@ -36,6 +36,7 @@ import { solidityExtractor } from './solidity'; import { terraformExtractor } from './terraform'; import { arktsExtractor } from './arkts'; import { nixExtractor } from './nix'; +import { openscadExtractor } from './openscad'; export const EXTRACTORS: Partial> = { typescript: typescriptExtractor, @@ -69,4 +70,5 @@ export const EXTRACTORS: Partial> = { terraform: terraformExtractor, arkts: arktsExtractor, nix: nixExtractor, + openscad: openscadExtractor, }; diff --git a/src/extraction/languages/openscad.ts b/src/extraction/languages/openscad.ts new file mode 100644 index 000000000..45152d01e --- /dev/null +++ b/src/extraction/languages/openscad.ts @@ -0,0 +1,130 @@ +import { getNodeText, getChildByField } from '../tree-sitter-helpers'; +import type { LanguageExtractor } from '../tree-sitter-types'; +import { Node as SyntaxNode } from 'web-tree-sitter'; + +/** First named child of a given type, or null. Tolerates a partial parse. */ +function namedChildOfType(node: SyntaxNode, type: string): SyntaxNode | null { + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child?.type === type) return child; + } + return null; +} + +/** + * OpenSCAD — parametric CAD scripts (`.scad`). + * + * Grammar: @openscad/tree-sitter-openscad 0.6.1 (the OpenSCAD org's own), + * vendored as wasm. Two things about the language shape the mappings here: + * + * 1. There are no classes, methods, interfaces, structs, enums or type + * aliases — none, not "rarely used". Those mappings stay EMPTY rather than + * being approximated with some available construct, so a class query over + * an OpenSCAD project returns nothing instead of something misleading. + * + * 2. A `module` is a named, parameterised, callable definition whose result is + * geometry — structurally a function. Both it and `function` map to the + * `function` kind. NOT to the `module` kind, which in CodeGraph means a + * file-level module (a Python module, a Kotlin package); reusing it would + * collide with a different concept in every cross-language query. + */ +export const openscadExtractor: LanguageExtractor = { + functionTypes: ['module_item', 'function_item'], + classTypes: [], + methodTypes: [], + interfaceTypes: [], + structTypes: [], + enumTypes: [], + typeAliasTypes: [], + importTypes: ['include_statement', 'use_statement'], + // `translate(…) rotate(…) cube(5);` nests as transform_chain → module_call + + // transform_chain → …, so the body walker's generic recursion reaches every + // operator in a chain. A leading `!`/`#`/`%`/`*` is a sibling `modifier` + // node, not a wrapper, so it cannot hide the call it decorates. + callTypes: ['module_call', 'function_call'], + // Deliberately empty: `assignment` is NOT a variable declaration here. The + // grammar reuses it for default parameter values (`module m(size = 10)`), + // named call arguments (`cube(center = true)`) and let/for bindings, so + // mapping it would mint a variable for every named argument in the file. A + // real top-level declaration is `var_declaration`, whose name sits one level + // down on the inner assignment — out of reach of the core's generic + // fallback, so the visitNode hook below owns it. + variableTypes: [], + nameField: 'name', + bodyField: 'body', + paramsField: 'parameters', + + getSignature: (node, source) => { + const params = getChildByField(node, 'parameters'); + return params ? getNodeText(params, source) : undefined; + }, + + /** + * `module_item` exposes a `body` field and needs nothing here. A named + * `function` has no body field at all — `function area(w, h) = w * h;` puts + * its body in an unnamed expression child. Without this, calls inside a + * function body (`function total(v) = sum(scale(v));`) produce no edges. + */ + resolveBody: (node) => { + if (node.type !== 'function_item') return null; + const nameNode = getChildByField(node, 'name'); + const paramsNode = getChildByField(node, 'parameters'); + for (let i = node.namedChildCount - 1; i >= 0; i--) { + const child = node.namedChild(i); + if (!child) continue; + if (child.id === nameNode?.id || child.id === paramsNode?.id) continue; + return child; + } + return null; + }, + + /** + * `include ` / `use `. Neither carries fields; the path is an + * `include_path` child whose text keeps the angle brackets. + * + * The path is recorded VERBATIM and is never joined onto a filesystem path, + * opened, or resolved. It is attacker-controlled — `include <../../../etc/ + * passwd>` is a legal directive — and OpenSCAD's real search order + * (OPENSCADPATH, user library dirs, relative to the including file) reaches + * outside the indexed project, so resolving it safely is its own problem and + * is deliberately not started here. + */ + extractImport: (node, source) => { + const pathNode = namedChildOfType(node, 'include_path'); + if (!pathNode) return null; + const moduleName = getNodeText(pathNode, source).replace(/^$/, ''); + if (!moduleName) return null; + return { moduleName, signature: getNodeText(node, source).trim() }; + }, + + /** + * Top-level `x = 5;` parses as var_declaration → assignment(name, value). + * The core's generic variable fallback only reads direct `identifier` + * children, so it finds nothing one level down; handling it here keeps the + * change out of CodeGraph's shared core. + * + * A special variable keeps its sigil: `$fn` is a `special_variable` node + * spanning the `$`, so its text is `$fn` and not `fn`. + */ + visitNode: (node, ctx) => { + if (node.type !== 'var_declaration') return false; + + const assignment = namedChildOfType(node, 'assignment'); + if (!assignment) return false; // partial parse — let default dispatch try + + const nameNode = getChildByField(assignment, 'name'); + if (!nameNode) return false; + + const name = getNodeText(nameNode, ctx.source); + if (!name) return false; + + ctx.createNode('variable', name, node); + + // Keep walking the assigned value: `plate_area = area(2, 3);` must still + // record the call. Returning true without this would drop those edges. + const value = getChildByField(assignment, 'value'); + if (value) ctx.visitNode(value); + + return true; + }, +}; diff --git a/src/extraction/wasm/tree-sitter-openscad.wasm b/src/extraction/wasm/tree-sitter-openscad.wasm new file mode 100644 index 0000000000000000000000000000000000000000..4cd048f2fd10edce4c13a9620c2a7465d439c126 GIT binary patch literal 44888 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 literal 0 HcmV?d00001 diff --git a/src/types.ts b/src/types.ts index 186f57adc..74c40a9e3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -117,6 +117,7 @@ export const LANGUAGES = [ 'vbnet', 'erlang', 'terraform', + 'openscad', 'unknown', ] as const; From a02f3a9c13e92fcb9985a8933f277ed8ee76b308 Mon Sep 17 00:00:00 2001 From: ErQrYfkrju <65288684+ErQrYfkrju@users.noreply.github.com> Date: Fri, 28 Aug 2026 01:13:27 +0300 Subject: [PATCH 2/3] feat(resolution): resolve OpenSCAD include/use by path, not by name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `include

` / `use

` name a PATH, not a symbol, so they now route through path resolution and never fall back to the name-matcher — the same treatment PHP includes, COBOL copybooks and Nix path imports already get. Name-matching got this mostly right, and the measurement says so: on a fixture vendoring BOSL2 + MCAD + dotSCAD with eight colliding basenames, 1,627 of 1,633 imports already resolved correctly with zero wrong edges. Two defects survived, and they are the reason for this change: 1. An INVENTED edge. `include ` with no sibling and no library-root match still produced an edge to some indexed math.scad, while OpenSCAD itself reports "Can't open include file". A wrong edge is worse than none (#660). 2. A MISSING edge. `include <../polyhedra.scad>` resolved to nothing because two files share that basename — though the written path names exactly one. After: the invented edges are gone, the relative path resolves, and all 1,627 correct edges are byte-identical. Nothing else moved. Search order is the language's own — the including file's directory first, then the project's library roots. That order is normative: a sibling must beat a same-named file in a library, because that is what the renderer does. Library-root discovery deliberately UNDER-reaches. dotSCAD's examples/ and test/ import as though dotSCAD/src were a declared root; a probe accepting any directory that looks like one would resolve them and assert five edges the actual build does not have — real OpenSCAD refuses them too unless OPENSCADPATH says otherwise. Discovery follows what the project declares, not what the tree suggests. Two tests pin both directions. Path validation reuses validatePathWithinRoot at the INDEXING tier (allowSymlinkEscape), not a new check: the lexical `../` guard still rejects a traversal out of the project, while an in-root symlink to a vendored library is followed — refusing it would leave discovery and resolution disagreeing, which is the defect #935 fixed for the indexing read sites. 10 tests, one per behaviour, escape targets are real fixture files rather than system paths so no assertion can pass vacuously. Co-Authored-By: Claude Opus 5 --- __tests__/openscad-resolution.test.ts | 222 ++++++++++++++++++++++++++ src/resolution/import-resolver.ts | 156 ++++++++++++++++++ src/resolution/index.ts | 11 +- 3 files changed, 387 insertions(+), 2 deletions(-) create mode 100644 __tests__/openscad-resolution.test.ts diff --git a/__tests__/openscad-resolution.test.ts b/__tests__/openscad-resolution.test.ts new file mode 100644 index 000000000..7ca1e9e7e --- /dev/null +++ b/__tests__/openscad-resolution.test.ts @@ -0,0 +1,222 @@ +/** + * OpenSCAD import resolution. + * + * `include

` / `use

` name a PATH, not a symbol. Before this was wired, + * OpenSCAD imports fell through to the name-matcher, which picks a candidate by + * basename similarity. Measured on a three-library fixture that got 1627 of + * 1633 right — and still produced two failures of opposite kinds: + * + * - an INVENTED edge for `include ` where no search root holds one + * and OpenSCAD itself reports "Can't open include file"; + * - a MISSING edge for `include <../polyhedra.scad>`, whose written path is + * unambiguous but whose basename is not. + * + * Resolution now walks the language's own search order — the including file's + * directory, then the project's library roots — and declines when nothing + * matches, because a wrong edge is worse than none (#660). + * + * No test here asserts against a system path. `/etc/...` becomes a + * non-existent `C:\etc` on Windows and would pass for the wrong reason, and a + * target that could never become a node makes an escape assertion vacuous + * everywhere. Escape targets are real `.scad` files in a sibling directory, so + * an unguarded resolver would genuinely reach them. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { CodeGraph } from '../src'; +import { clearOpenscadLibraryRootCache } from '../src/resolution/import-resolver'; + +const posixOnly = it.runIf(process.platform !== 'win32'); + +describe('openscad import resolution', () => { + let root: string; // holds the project AND an out-of-project sibling + let dir: string; // the indexed project + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'openscad-res-')); + dir = path.join(root, 'proj'); + fs.mkdirSync(dir, { recursive: true }); + clearOpenscadLibraryRootCache(); + delete process.env.OPENSCADPATH; + }); + + afterEach(() => { + delete process.env.OPENSCADPATH; + clearOpenscadLibraryRootCache(); + fs.rmSync(root, { recursive: true, force: true }); + }); + + function write(rel: string, body: string): void { + const full = path.join(dir, rel); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, body); + } + + /** Every resolved `imports` edge from an OpenSCAD file, as `from -> to`. */ + async function importEdges(): Promise { + const cg = await CodeGraph.init(dir, { silent: true }); + await cg.indexAll(); + const db = (cg as any).db.db; + const rows = db + .prepare( + `SELECT s.file_path sf, t.file_path tf + FROM edges e JOIN nodes s ON s.id = e.source JOIN nodes t ON t.id = e.target + WHERE e.kind = 'imports' AND s.language = 'openscad' AND t.kind = 'file'` + ) + .all() as Array<{ sf: string; tf: string }>; + cg.destroy(); + return rows.map((r) => `${r.sf} -> ${r.tf}`).sort(); + } + + // Two libraries whose basenames collide, which is the normal state of the + // OpenSCAD ecosystem: almost every library ships a math.scad. + function twoLibraries(): void { + write('lib/AAA/math.scad', 'function aaa_math() = 1;\n'); + write('lib/AAA/std.scad', 'include \nfunction aaa_std() = 2;\n'); + write('lib/BBB/math.scad', 'function bbb_math() = 3;\n'); + write('lib/BBB/util.scad', 'include \nfunction bbb_util() = 4;\n'); + } + + it('prefers a sibling over a same-named file in another library', async () => { + twoLibraries(); + write('src/part.scad', 'include \nmodule part() { cube(1); }\n'); + + const edges = await importEdges(); + + // The language resolves the including file's directory first, so AAA/std + // must reach ITS OWN math.scad. An edge to BBB's would be one the renderer + // contradicts. + expect(edges).toContain('lib/AAA/std.scad -> lib/AAA/math.scad'); + expect(edges).toContain('lib/BBB/util.scad -> lib/BBB/math.scad'); + expect(edges).not.toContain('lib/AAA/std.scad -> lib/BBB/math.scad'); + expect(edges).not.toContain('lib/BBB/util.scad -> lib/AAA/math.scad'); + }); + + it('sends a library-qualified path to the library it names', async () => { + twoLibraries(); + write('src/part.scad', 'include \nmodule part() { cube(1); }\n'); + + expect(await importEdges()).toContain('src/part.scad -> lib/BBB/math.scad'); + }); + + it('resolves a parent-relative path even when the basename collides', async () => { + // The defect this change fixes: the written path names exactly one file, + // but two files share its basename, so name-matching declined. + write('lib/AAA/shape.scad', 'function outer_shape() = 1;\n'); + write('lib/AAA/tests/shape.scad', 'include <../shape.scad>\nfunction test_shape() = 2;\n'); + write('lib/BBB/shape.scad', 'function other_shape() = 3;\n'); + + const edges = await importEdges(); + + expect(edges).toContain('lib/AAA/tests/shape.scad -> lib/AAA/shape.scad'); + expect(edges).not.toContain('lib/AAA/tests/shape.scad -> lib/BBB/shape.scad'); + }); + + it('produces no edge for an ambiguous bare name no search root satisfies', async () => { + // The other defect, and the reason this change exists. `math.scad` sits in + // neither src/ nor lib/, so OpenSCAD reports "Can't open include file" and + // the correct number of edges is zero — not "whichever math.scad scored + // highest". + twoLibraries(); + write('src/part.scad', 'include \nmodule part() { cube(1); }\n'); + + const edges = await importEdges(); + + expect(edges.filter((e) => e.startsWith('src/part.scad ->'))).toEqual([]); + }); + + it('never resolves an import to a same-named symbol', async () => { + // `gears.scad` exists nowhere, but a module named `gears` does. An import + // names a file; resolving it to the module would be a category error. + write('src/shapes.scad', 'module gears() { cube(1); }\n'); + write('src/part.scad', 'use \nmodule part() { gears(); }\n'); + + const cg = await CodeGraph.init(dir, { silent: true }); + await cg.indexAll(); + const db = (cg as any).db.db; + const bad = db + .prepare( + `SELECT count(*) c FROM edges e JOIN nodes t ON t.id = e.target + WHERE e.kind = 'imports' AND t.kind != 'file'` + ) + .get() as { c: number }; + cg.destroy(); + + expect(bad.c).toBe(0); + }); + + it('does not resolve a path that climbs out of the project root', async () => { + // The escape target is a real, readable .scad file one level above the + // project, so an unguarded resolver would reach it — the assertion is not + // vacuous. + fs.mkdirSync(path.join(root, 'outside'), { recursive: true }); + fs.writeFileSync(path.join(root, 'outside', 'secret.scad'), 'function secret() = 1;\n'); + write('src/part.scad', 'include <../../outside/secret.scad>\nmodule part() { cube(1); }\n'); + + const edges = await importEdges(); + + expect(edges.filter((e) => e.startsWith('src/part.scad ->'))).toEqual([]); + expect(edges.some((e) => e.includes('secret.scad'))).toBe(false); + }); + + posixOnly('follows an in-root symlink whose target is outside the project', async () => { + // The inverse of the test above, and the one that guards against + // "tightening" resolution to the strict path-validation tier. The directory + // walk already follows such a symlink to enumerate the files under it; a + // resolver that refused would leave discovery and resolution disagreeing, + // which is the defect #935 fixed for the indexing read sites. + const external = path.join(root, 'external-lib'); + fs.mkdirSync(external, { recursive: true }); + fs.writeFileSync(path.join(external, 'vendored.scad'), 'function vendored() = 1;\n'); + fs.mkdirSync(path.join(dir, 'lib'), { recursive: true }); + fs.symlinkSync(external, path.join(dir, 'lib', 'EXT'), 'dir'); + write('src/part.scad', 'include \nmodule part() { cube(1); }\n'); + + expect(await importEdges()).toContain('src/part.scad -> lib/EXT/vendored.scad'); + }); + + it('ignores an OPENSCADPATH root that lies outside the project', async () => { + // The variable is set by the user running the indexer, not by an indexed + // file, so it may inform discovery — but never widen it past the project + // root. Pointed at a sibling of the fixture rather than a system path, so + // the assertion holds on every platform. + fs.mkdirSync(path.join(root, 'elsewhere'), { recursive: true }); + fs.writeFileSync(path.join(root, 'elsewhere', 'remote.scad'), 'function remote() = 1;\n'); + write('src/part.scad', 'include \nmodule part() { cube(1); }\n'); + process.env.OPENSCADPATH = path.join(root, 'elsewhere'); + clearOpenscadLibraryRootCache(); + + const edges = await importEdges(); + + expect(edges.filter((e) => e.startsWith('src/part.scad ->'))).toEqual([]); + }); + + it('does not treat an undeclared directory as a library root', async () => { + // Discovery under-reaches on purpose. dotSCAD's shape: its examples import + // as though `src/` were on OPENSCADPATH, and real OpenSCAD declines them + // unless it is. A probe that accepted any directory holding .scad files + // would assert edges the actual build does not have. + write('lib/CCC/src/helper.scad', 'function helper() = 1;\n'); + write('lib/CCC/examples/demo.scad', 'use \nmodule demo() { cube(1); }\n'); + + const edges = await importEdges(); + + expect(edges.filter((e) => e.startsWith('lib/CCC/examples/demo.scad ->'))).toEqual([]); + }); + + it('resolves once the project declares that root', async () => { + // The same tree as above, with the root declared. Resolution follows the + // configuration rather than guessing at it — which is why the previous test + // is a correctness assertion and not a limitation. + write('lib/CCC/src/helper.scad', 'function helper() = 1;\n'); + write('lib/CCC/examples/demo.scad', 'use \nmodule demo() { cube(1); }\n'); + process.env.OPENSCADPATH = path.join(dir, 'lib', 'CCC', 'src'); + clearOpenscadLibraryRootCache(); + + expect(await importEdges()).toContain( + 'lib/CCC/examples/demo.scad -> lib/CCC/src/helper.scad' + ); + }); +}); diff --git a/src/resolution/import-resolver.ts b/src/resolution/import-resolver.ts index 60c7b3008..6ea954e75 100644 --- a/src/resolution/import-resolver.ts +++ b/src/resolution/import-resolver.ts @@ -10,6 +10,7 @@ import { Language, Node } from '../types'; import { UnresolvedRef, ResolvedRef, ResolutionContext, ImportMapping, ReExport } from './types'; import { applyAliases } from './path-aliases'; import { resolveWorkspaceImport } from './workspace-packages'; +import { validatePathWithinRoot } from '../utils'; import { resolveMethodOnType, resolveObjectLiteralMember, @@ -47,6 +48,10 @@ const EXTENSION_RESOLUTION: Record = { ruby: ['.rb'], objc: ['.h', '.m', '.mm'], nix: ['.nix', '/default.nix'], + // OpenSCAD `include

` / `use

` always spells the extension, so this + // list only backs the trailing-extension probe; the path is tried as-is + // first and normally matches there. + openscad: ['.scad'], }; export function isNixPathImportRef(ref: UnresolvedRef): boolean { @@ -58,6 +63,23 @@ export function isNixPathImportRef(ref: UnresolvedRef): boolean { ); } +/** + * Is this an OpenSCAD `include

` / `use

` directive? The text between the + * angle brackets is a PATH, not a symbol name, so these resolve to files only — + * never to a same-named module or function via the name-matcher. + * + * Without this, OpenSCAD imports fell through to the name-matcher, which picks + * a candidate by basename similarity. That resolved 1627 of 1633 imports + * correctly on a three-library fixture — but it also invented an edge for + * `include ` where no search root holds a `math.scad` and OpenSCAD + * itself reports "Can't open include file", and it declined + * `include <../polyhedra.scad>` whose written path is unambiguous but whose + * basename is not. A wrong edge is worse than none (#660). + */ +export function isOpenscadIncludeRef(ref: UnresolvedRef): boolean { + return ref.language === 'openscad' && ref.referenceKind === 'imports'; +} + /** * Resolve an import path to an actual file */ @@ -155,6 +177,14 @@ function resolveImportPathUncached( const projectRoot = context.getProjectRoot(); const fromDir = path.dirname(path.join(projectRoot, fromFile)); + // OpenSCAD resolves EVERY import path against the including file's directory + // first, not only the dot-prefixed ones — `include ` is + // relative too. It also has its own library roots, and its own containment. + // So it takes the whole path rather than the generic relative/aliased pair. + if (language === 'openscad') { + return resolveOpenscadImport(importPath, fromFile, context); + } + // Handle relative imports if (importPath.startsWith('.')) { return resolveRelativeImport(importPath, fromDir, language, context); @@ -174,6 +204,114 @@ function resolveImportPathUncached( return null; } +/** + * OpenSCAD library roots for a project, relative to the project root. + * + * Discovery deliberately UNDER-reaches. Measured on a three-library fixture, + * five imports in dotSCAD's own `examples/`/`test/` name paths that would + * resolve if `dotSCAD/src` were treated as a library root — and real OpenSCAD + * does not resolve them either, because nothing puts it on OPENSCADPATH. A + * probe that accepted any directory containing `.scad` files would assert five + * edges the actual build does not have. So this follows what the project + * declares, not what the tree suggests. + * + * Roots outside the project root are dropped here, at discovery time rather + * than at use, so the invariant lives in one place. + */ +const openscadLibraryRootCache = new Map(); + +/** Clear the OpenSCAD library-root cache (call between indexing runs). */ +export function clearOpenscadLibraryRootCache(): void { + openscadLibraryRootCache.clear(); +} + +export function loadOpenscadLibraryRoots(projectRoot: string): string[] { + const cached = openscadLibraryRootCache.get(projectRoot); + if (cached) return cached; + + const roots: string[] = []; + const add = (absolute: string) => { + // Every root is validated as it is discovered. `allowSymlinkEscape` matches + // the indexing tier: an in-root symlink to a vendored library is legitimate + // and the directory walk already followed it (#935). + if (!validatePathWithinRoot(projectRoot, absolute, { allowSymlinkEscape: true })) return; + const rel = path.relative(projectRoot, absolute).replace(/\\/g, '/'); + if (rel && !roots.includes(rel)) roots.push(rel); + }; + + // The vendoring convention: a `lib/` beside the sources, which is what a + // project pointing OPENSCADPATH at its own tree uses. + for (const name of ['lib', 'libraries']) { + const candidate = path.join(projectRoot, name); + try { + if (fs.statSync(candidate).isDirectory()) add(candidate); + } catch { + // absent — nothing to add + } + } + + // OPENSCADPATH is set by the user running the indexer and cannot be set by an + // indexed file, so it is a legitimate hint — but it never widens resolution + // beyond the project root: entries outside are dropped by `add`. + const declared = process.env.OPENSCADPATH; + if (declared) { + for (const entry of declared.split(path.delimiter)) { + if (entry) add(path.resolve(projectRoot, entry)); + } + } + + openscadLibraryRootCache.set(projectRoot, roots); + return roots; +} + +/** + * Resolve an OpenSCAD `include

` / `use

` to a file. + * + * Search order is the language's own: the directory of the including file + * first, then the project's library roots. The order is normative — a sibling + * must beat a same-named file in a library, because that is what the renderer + * does, and an edge the renderer contradicts is worse than no edge. + * + * Returns null when nothing matches. It does NOT fall back to picking a + * basename candidate; that fallback is what invented an edge for a path + * OpenSCAD itself cannot open. + */ +function resolveOpenscadImport( + importPath: string, + fromFile: string, + context: ResolutionContext +): string | null { + const projectRoot = context.getProjectRoot(); + const searchRoots = [ + path.dirname(path.join(projectRoot, fromFile)), + ...loadOpenscadLibraryRoots(projectRoot).map((r) => path.join(projectRoot, r)), + ]; + const extensions = EXTENSION_RESOLUTION.openscad ?? []; + + for (const root of searchRoots) { + // Lexical resolution first, then the host's own validator. The lexical + // `../` guard inside it applies on every tier, so a path climbing out of + // the project is rejected here; an in-root symlink to a vendored library + // is allowed through, matching the indexing read sites. + const candidateAbs = path.resolve(root, importPath); + if (!validatePathWithinRoot(projectRoot, candidateAbs, { allowSymlinkEscape: true })) { + continue; + } + + // Look the file up by its LOGICAL project-relative path, not the realpath + // the validator returns: a library reached through a symlink is indexed + // under the path the walk saw, which is the logical one. + const rel = path.relative(projectRoot, candidateAbs).replace(/\\/g, '/'); + if (!rel) continue; + if (context.fileExists(rel)) return rel; + for (const ext of extensions) { + if (context.fileExists(rel + ext)) return rel + ext; + } + } + + return null; +} + /** * COBOL copybook lookup: `COPY CVACT01Y` (or `EXEC SQL INCLUDE X`) names a * library member resolved by the compiler's copybook search path, so we match @@ -1350,6 +1488,24 @@ export function resolveViaImport( return null; } + // OpenSCAD `include

` / `use

` resolve directly to the included FILE, + // for the same reason as the C/C++ branch above: the symbol lookup below + // would search the resolved file for a symbol named like the path and fail. + // The search order (including file's directory, then library roots) lives in + // resolveOpenscadImport, so no separate sibling probe is needed here. + if (ref.language === 'openscad' && ref.referenceKind === 'imports') { + const resolvedPath = resolveImportPath(ref.referenceName, ref.filePath, ref.language, context); + if (!resolvedPath) return null; + const basename = resolvedPath.split('/').pop()!; + const fileNode = context + .getNodesByName(basename) + .find((n) => n.kind === 'file' && n.filePath === resolvedPath); + if (!fileNode) return null; + // Path-exact: the file was found by walking the language's own search + // order, not by name similarity, so this outranks the 0.9 early-return bar. + return { original: ref, targetNodeId: fileNode.id, confidence: 0.95, resolvedBy: 'import' }; + } + // COBOL COPY / EXEC SQL INCLUDE — resolve the copybook member to a // file→file edge, mirroring the C/C++ include branch above. A member that // matches no indexed file (compiler-supplied copybooks like SQLCA/DFHAID) diff --git a/src/resolution/index.ts b/src/resolution/index.ts index 7b4bccc18..58f0e85dc 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -17,7 +17,7 @@ import { ImportMapping, } from './types'; import { matchReference, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, sameLanguageFamily, crossesKnownFamily, dumpNameMatcherProfile, clearNameMatcherMemos } from './name-matcher'; -import { resolveViaImport, resolveJvmImport, extractImportMappings, extractReExports, loadCppIncludeDirs, isPhpIncludePathRef, isCobolCopybookRef, isNixPathImportRef, clearImportResolverMemos } from './import-resolver'; +import { resolveViaImport, resolveJvmImport, extractImportMappings, extractReExports, loadCppIncludeDirs, isPhpIncludePathRef, isCobolCopybookRef, isNixPathImportRef, isOpenscadIncludeRef, clearImportResolverMemos } from './import-resolver'; import { ResolverPool, minRefsForPool } from './resolver-pool'; import { detectFrameworks } from './frameworks'; import { synthesizeCallbackEdges } from './callback-synthesizer'; @@ -896,6 +896,9 @@ export class ReferenceResolver { const tPre = this.profileStages ? process.hrtime.bigint() : 0n; const preFilterPass = isNixPathImportRef(ref) || + // OpenSCAD `include

`/`use

` names a FILE, so the symbol-existence + // gate does not apply — same reason as Nix static path imports above. + isOpenscadIncludeRef(ref) || this.hasAnyPossibleMatch(existenceName) || this.matchesAnyImport(ref) || this.frameworks.some((f) => f.claimsReference?.(ref.referenceName)); @@ -992,7 +995,11 @@ export class ReferenceResolver { // qualified-name fallback would only ever add wrong cross-module edges. // Nix static path imports are file references for the same reason — // falling through would let "./x.nix" name-match an unrelated node. - if (isPhpIncludePathRef(ref) || isCobolCopybookRef(ref) || isNixPathImportRef(ref) || ref.language === 'terraform') { + // OpenSCAD include/use are paths too: resolved against the including file's + // directory then the project's library roots, and never name-matched. The + // fallback is what invented an edge for `include ` where no + // search root holds one and OpenSCAD reports "Can't open include file". + if (isPhpIncludePathRef(ref) || isCobolCopybookRef(ref) || isNixPathImportRef(ref) || isOpenscadIncludeRef(ref) || ref.language === 'terraform') { return candidates.length > 0 ? candidates.reduce((best, curr) => curr.confidence > best.confidence ? curr : best From ee81cffc4d073b8d2d87e4ab3d6cf27f14981c11 Mon Sep 17 00:00:00 2001 From: ErQrYfkrju <65288684+ErQrYfkrju@users.noreply.github.com> Date: Fri, 28 Aug 2026 01:13:50 +0300 Subject: [PATCH 3/3] docs(openscad): validation results, coverage row, corpus and release notes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Runs the REQUIRED per-language validation methodology and records what it found, including where it deviates from the letter of it and why. Results (sonnet/high, n=2 per arm, 18 runs, 0 contaminated, every run served by the pinned build and index integrity asserted before and after): repo dur with dur without Read with Read without explore KeyV2 14-27s 31-47s 0-1 1-3 1-2 NopSCADlib 10-23s 21-46s 0 1-4 1 dotSCAD 14-31s 23-56s 0-1 1-5 1 Read is 0 in 15 of 18 runs, one explore call answers 16 of 18, and the with-arm is faster in 18 of 18. Control (express, JS, same harness/model/day): Read 0,0 vs 1,2, faster both runs — and its sufficiency is lower than OpenSCAD's. Coverage was probed before anything paid: all 9 flow questions connect end-to-end, node counts stable across re-index. Deviation, recorded rather than papered over: the Large tier (>1500 source files) DOES NOT EXIST for OpenSCAD. The repositories large enough by file count are multi-CAD distribution trees — VoronUsers is 6,644 files of which 12 are .scad (2,413 STL, 170 Fusion .f3d); fosscad-repo is 4,686 of which 8 (772 SolidWorks .sldprt, 128 Inventor .ipt). GitHub labels them language:OpenSCAD because Linguist counts only recognised text sources and is blind to binary CAD formats. The largest genuine OpenSCAD codebase is dotSCAD at 695 .scad. The tier is left declared empty rather than filled with a repository that would measure STL distribution instead of this language. The coverage row also carries a methodology warning that cost two full 36-run campaigns to learn: run-all.sh falls back to `command -v codegraph` when CG_BIN is unset, and a `readonly CG_BIN` passed as a command prefix silently fails to export. Both campaigns therefore ran the RELEASED build, which has no OpenSCAD support — explore answered "No relevant code found" and its file watcher deleted every .scad symbol from the index under measurement (KeyV2 5,873->23, NopSCADlib 6,046->276, dotSCAD 5,499->0, each exactly that repo's non-OpenSCAD node count). Verify the binary from the mcp-codegraph.json the harness writes, and assert the index node count is unchanged across each run. Known frontier, stated plainly: children() — OpenSCAD's dynamic dispatch — is uncovered; module and function are indistinguishable in the graph by design; import("part.stl")/surface() asset dependencies are not edges. Co-Authored-By: Claude Opus 5 --- .claude/skills/agent-eval/corpus.json | 23 +++++++++++++++++++ CHANGELOG.md | 17 ++++++++++++++ README.md | 3 ++- .../dynamic-dispatch-coverage-playbook.md | 1 + 4 files changed, 43 insertions(+), 1 deletion(-) diff --git a/.claude/skills/agent-eval/corpus.json b/.claude/skills/agent-eval/corpus.json index 150b4a601..08b3188ce 100644 --- a/.claude/skills/agent-eval/corpus.json +++ b/.claude/skills/agent-eval/corpus.json @@ -607,5 +607,28 @@ "files": "~3390", "question": "How does programs.git.enable produce the final git config file in the user's home directory? Trace the flow from the git program module to the home-files machinery that links generated files into place." } + ], + "OpenSCAD": [ + { + "name": "KeyV2", + "repo": "https://github.com/rsheldiii/KeyV2", + "size": "Small", + "files": "~110", + "question": "How does a key's dish get shaped? Trace _dish through dish to spherical_dish." + }, + { + "name": "NopSCADlib", + "repo": "https://github.com/nophead/NopSCADlib", + "size": "Medium", + "files": "~390", + "question": "How does box_assembly reach box_screw? Trace the path through _box_assembly." + }, + { + "name": "dotSCAD", + "repo": "https://github.com/JustinSDK/dotSCAD", + "size": "Medium", + "files": "~695", + "question": "How are contours computed? Trace image_slicer through contours to _marching_squares_isolines." + } ] } diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ca5b9ff1..64ae392f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,23 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added + +- **OpenSCAD support** (`.scad`) — CodeGraph now indexes parametric CAD projects. + `module` and `function` definitions become symbols with their parameters, + module instantiations and function calls become call edges (including every + operator in a `translate(…) rotate(…) cube(…)` transform chain), top-level + assignments become variables with `$fn`-style special variables keeping their + sigil, and `include <…>` / `use <…>` resolve **by path** — the including + file's directory first, then the project's library roots — so a sibling wins + over a same-named file in a vendored library, and a path nothing satisfies + produces no edge instead of a guess. + + Measured on KeyV2, NopSCADlib and dotSCAD (sonnet/high, 2 runs per arm, + 18 runs): an agent answering a flow question with CodeGraph read **0 files in + 15 of 18 runs** (0–1 overall, against 1–5 without) and finished **faster in + 18 of 18**, usually from a single `codegraph_explore` call. + ## [1.6.0] - 2026-08-26 diff --git a/README.md b/README.md index 48323f6fd..eae3c0912 100644 --- a/README.md +++ b/README.md @@ -278,7 +278,7 @@ CodeGraph's parsing engine is a **native Rust kernel**: 20 languages — TypeScr | **Full-Text Search** | Find code by name instantly across your entire codebase, powered by FTS5 | | **Impact Analysis** | Trace callers, callees, and the full impact radius of any symbol before making changes | | **Always Fresh** | File watcher uses native OS events (FSEvents/inotify/ReadDirectoryChangesW) with debounced auto-sync — the graph stays current as you code, zero config | -| **20+ Languages** | TypeScript, JavaScript, ArkTS, Python, Go, Rust, Java, C#, VB.NET, PHP, Ruby, C, C++, CUDA, Objective-C, Metal, Swift, Kotlin, Scala, Dart, Lua, Luau, R, Nix, Erlang, CFML, COBOL, Solidity, Terraform/OpenTofu, Svelte, Vue, Astro, Liquid, Pascal/Delphi | +| **20+ Languages** | TypeScript, JavaScript, ArkTS, Python, Go, Rust, Java, C#, VB.NET, PHP, Ruby, C, C++, CUDA, Objective-C, Metal, Swift, Kotlin, Scala, Dart, Lua, Luau, R, Nix, Erlang, CFML, COBOL, Solidity, Terraform/OpenTofu, OpenSCAD, Svelte, Vue, Astro, Liquid, Pascal/Delphi | | **Framework-aware Routes** | Recognizes web-framework routing files and links URL patterns to their handlers across 17 frameworks | | **Mixed iOS / React Native / Expo** | Closes cross-language flows that static parsing misses: Swift ↔ ObjC bridging, React Native legacy bridge + TurboModules + Fabric view components, native → JS event emitters, Expo Modules | | **100% Local** | No data leaves your machine. No API keys. No external services. SQLite database only | @@ -825,6 +825,7 @@ is written): | Solidity | `.sol` | Full support (contracts, libraries, interfaces, structs, enums, modifiers, events, errors, state variables, `import`/`using` directives, `emit`/`revert` calls) | | Terraform / OpenTofu | `.tf`, `.tfvars`, `.tofu` | Full support (resources, data sources, modules, variables, outputs, providers incl. aliases, `locals`; `var.`/`local.`/`module.`/resource references with Terraform's per-directory scoping enforced; module calls bridged across the boundary — inputs to the child module's variables, `module.M.out` to the child's output, `source` to the module's files; cloudposse/atmos `remote-state` cross-component wiring when the component is statically named; `provider = aws.east` selections resolved up the module tree; `moved`/`import`/`removed`/`check` block references; `.tfvars` assignments linked to the variables they set) | | Nix | `.nix` | Full support (functions with simple/destructured/curried params, `let`/attrset bindings, `inherit`, `import ./path` file edges — `./dir` resolving through `default.nix` — plus NixOS module `imports = [ ./x.nix ]` lists and `callPackage ./pkg.nix` file edges; call edges; module-system option wiring — a config write like `launchd.user.agents.x = { ... }` links to the module declaring `options.launchd.user.agents`, so option flows trace across modules) | +| OpenSCAD | `.scad` | Full support (`module` and `function` definitions with parameters, module instantiations and function calls incl. every operator in a `translate(…) rotate(…) cube(…)` transform chain, `!`/`#`/`%`/`*` modifiers, top-level assignments and `$`-prefixed special variables, `include`/`use` resolved **by path** — the including file's directory first, then the project's library roots — so a sibling beats a same-named library file and an unresolvable path yields no edge rather than a guess) | ## Measured cross-file coverage diff --git a/docs/design/dynamic-dispatch-coverage-playbook.md b/docs/design/dynamic-dispatch-coverage-playbook.md index c61fb9f31..cc3aff2aa 100644 --- a/docs/design/dynamic-dispatch-coverage-playbook.md +++ b/docs/design/dynamic-dispatch-coverage-playbook.md @@ -268,6 +268,7 @@ Status legend: ✅ done+validated · 🔬 hole identified · ⬜ not started. | Lua / Luau | Neovim / Roblox | module dispatch (require→mod, mod.fn); event/callback | — | ✅ **already covered for the dominant flow (measure-first, no code change)** — Neovim is module-heavy (`require('x')` + `x.fn()`), and the general import + name resolution already handles it: telescope.nvim **220 imports + 335 cross-file `mod.fn` calls**, traces end-to-end (`map_entries ← init.lua → get_current_picker (state.lua)`). Luau instance-path `require(game:GetService(...))` handled by the extractor. 🔬 event-callback registration (`vim.keymap.set(…, fn)`, autocmd `callback=`, Roblox `signal:Connect(fn)`) is predominantly INLINE anonymous closures (corpus ~12 inline vs ~2 named) — the anonymous-handler frontier; named handlers too rare to justify a synthesizer | | Erlang | OTP behaviours | request → behaviour dispatch (`Var:callback(...)` folds) → implementer callback | S | ✅ **behaviour-callback dispatch synthesizer** (`erlangBehaviourDispatchEdges`) — a behaviour declares `-callback fn/N`, implementers declare `-behaviour(B)`, and the framework dispatches through a VARIABLE module (`Handler:init`, `Middleware:execute` folds), a hop extraction deliberately leaves silent. Bridge: each `Var:fn(args)` site → every implementer of the ONE in-repo behaviour declaring (fn, site-arity) that defines+exports fn; a name+arity collision across behaviours bails (cowboy's `init/2` is declared by FIVE handler-flavored behaviours → correctly silent), and above the fan-out cap (24) the site is skipped entirely (ejabberd's `gen_mod`, ~230 mod_* implementers, stays a visibly dynamic boundary rather than 24 arbitrary edges). Behaviour discovery scans `-callback` decls in every module (not just `implements` targets) so implementer-less behaviours still gate ambiguity. Validated: cowboy S — 38 edges, all real contracts (middleware chain `cowboy_stream_h::execute → cowboy_router/cowboy_handler::execute`, stream-handler `init/data/early_error` folds → all 5 core + 2 test handlers, sub-protocol `upgrade`, `websocket_init`); ejabberd M — 598 edges (listener/auth/pubsub/MIX backends, max per-site fan-out 9); emqx L — 843 edges (gateway codec/channel families, max fan-out 20); **precision spot-check 36/36** (every sampled target declares the via-behaviour + exports the callback); node counts unchanged; erl-sample 0-control clean (dispatch with no valid implementer → no edge); index cost +~1.4s on emqx's 2,273 files. The cowboy request flow now connects END-TO-END in one explore: `cowboy_stream:init → [erlang behaviour] cowboy_stream_h:init → request_process → execute → [erlang behaviour] cowboy_handler:execute`. 🔬 gen_server registered-name cross-module targets (atom == module-name convention); the terminal `Handler:init` hop where multiple sub-protocol behaviours share the contract (genuinely ambiguous — the dispatch site's body is the answer) | | Scala | Play / Akka | request → conf/routes → controller action | R + X | ✅ **Play `conf/routes` → controller** — the extensionless `conf/routes` wasn't indexed; added narrow file-walk opt-in (`isPlayRoutesFile`) + a Play resolver parsing `METHOD /path Controller.action(args)` → the action method (computer-database **0→8, 7/8**; starter 0→4, 3/4 — the unresolved are Play's framework `Assets` controller, external). Scala general controller→DAO dispatch already resolves. No-regression: the file-walk change only ADDS Play routes files (excalidraw 9,290 / suite 800 unchanged). 🔬 SIRD programmatic router (`-> /v1 Router` include + `case GET(p"/x")` in code) + Akka actor `receive`/`Behaviors.receiveMessage` message→handler | +| OpenSCAD | (no framework — the language itself) | parameter → module → transform chain → library primitive | X | ✅ **validated, passes on all three criteria.** Language support is extraction-only (official `@openscad/tree-sitter-openscad` 0.6.1 vendored as wasm; `include`/`use` routed through path resolution at the indexing tier of `validatePathWithinRoot`, so a sibling beats a same-named library file and an unresolvable path yields no edge). No synthesizer needed — OpenSCAD flows are static calls. **Coverage:** all 9 flow questions across 3 repos connect end-to-end in `probe-explore` (KeyV2 `_dish→dish→spherical_dish`; NopSCADlib `box_assembly→_box_assembly→box_screw`; dotSCAD `image_slicer→contours→_marching_squares_isolines`); re-index node counts stable. **A/B (sonnet/high, n=2/arm, 18 runs, 0 contaminated): WITH Read 0–1 (15/18 runs exactly 0) vs WITHOUT 1–5; faster in 18/18 runs** — KeyV2 14–27s vs 31–47s, NopSCADlib 10–23s vs 21–46s, dotSCAD 14–31s vs 23–56s; tool calls 1–3 vs 3–10; **1 explore call answers it in 16/18 runs**, sufficiency `moved on / answered` 100% in 15/18 (the 3 misses are recall, not allocation). Control (express JS, same harness/model/day): WITH Read 0,0 vs WITHOUT 1,2, faster both — and its sufficiency is *worse* than OpenSCAD's (grep follow-up in both runs). **Methodology warning, learned expensively:** two full 36-run campaigns were void before this one. `run-all.sh` falls back to `command -v codegraph` when `CG_BIN` is unset, and a `readonly CG_BIN` passed as a command prefix silently fails to export — so the campaign ran the RELEASED build, which has no OpenSCAD, answered "No relevant code found", and its file watcher deleted every `.scad` symbol from the index it was measuring (KeyV2 5,873→23, NopSCADlib 6,046→276, dotSCAD 5,499→0 — each exactly the repo's non-OpenSCAD node count). Verify the binary from the `mcp-codegraph.json` run-all.sh writes, and assert the index node count is unchanged across each run. 🔬 `children()` (OpenSCAD's dynamic dispatch) is uncovered; `module` and `function` are indistinguishable in the graph by design; `import("part.stl")`/`surface()` asset deps are not edges. | | Swift × Objective-C | mixed iOS apps | Swift `obj.foo(bar:)` → ObjC `-fooWithBar:`; ObjC `[obj fooWithBar:]` → Swift `@objc func foo(bar:)` | R | ✅ **Swift↔ObjC cross-language bridge** — `frameworks/swift-objc.ts` implements Apple's `@objc` auto-bridging name math (incl. init forms `initWith:`, property getter+setter pairs, `@objc(custom:)` override) and the reverse direction strips Cocoa preposition prefixes (`With`/`For`/`By`/`In`/`On`/`At`/`From`/`To`/`Of`/`As`) to derive Swift base-name candidates. Validated on Charts S **28/1 obj→swift / swift→objc**, realm-swift M **36/1185**, wikipedia-ios L **52/983**. Genericname blocklist (`init`, `description`, `count`, …) keeps precision. Confidence 0.6 (name-match's 1.0 wins ties) — bridge only fires when name-match has no result. 🔬 Swift generics over ObjC protocols, Swift extensions on ObjC classes (silently miss; matches Java/Kotlin generics frontier) | | JS × native | React Native legacy bridge | JS `NativeModules.X.fn(...)` → ObjC `RCT_EXPORT_METHOD` / Java/Kotlin `@ReactMethod` | R | ✅ **RN legacy bridge** — `frameworks/react-native.ts` parses `RCT_EXPORT_MODULE` (default-name from `RCT`-prefix-stripped class name) + `RCT_EXPORT_METHOD(selector:(...))` + `RCT_REMAP_METHOD(jsName, selector)` on the ObjC side and `@ReactMethod` + `getName()` literal on Java/Kotlin. AsyncStorage S **8/8 precise** (`setItem`→`legacy_multiSet`, etc.), react-native-firebase L **18 precise after `RCTEventEmitter` built-in blocklist** (initial 78 included 60 `addListener:`/`remove:` false positives — every emitter subclass declares those via `RCT_EXPORT_METHOD`, JS callers route through the `NativeEventEmitter` abstraction not the native method directly). 🔬 dynamic bridge keys (`NativeModules[someVar]`) — literal-key only | | JS × native | React Native TurboModules | JS spec interface ↔ native impl | R (spec as ground truth) | ✅ partial — parses `TurboModuleRegistry.get*('Name')` + the `Spec` interface methods. Each spec method matches to a native impl by selector first-keyword (ObjC) / identifier (JVM). react-native-svg S **9 precise** (`getTotalLength`, `getPointAtLength`, `getCTM`, `isPointInFill`, …) bridging to Java impls (the iOS side is Codegen-auto-generated without `RCT_EXPORT_METHOD` declarations). 🔬 TurboModule native impl classes that don't use legacy macros (RNSvg iOS — would need inheritance-aware bridging via the Codegen-generated `NativeFooSpec` superclass) |