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/__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/__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/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) | 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 000000000..4cd048f2f Binary files /dev/null and b/src/extraction/wasm/tree-sitter-openscad.wasm differ 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 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;