Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .claude/skills/agent-eval/corpus.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
]
}
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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

Expand Down
152 changes: 152 additions & 0 deletions __tests__/extraction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down Expand Up @@ -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 <BOSL2/std.scad>
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 <BOSL2/std.scad>
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<typeof extractFromSource>;
expect(() => { result = extractFromSource('broken.scad', code); }).not.toThrow();
expect(result.nodes.some((n) => n.kind === 'function' && n.name === 'a')).toBe(true);
});
});
Loading