@esneiderbravo/speclaw 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/ATTRIBUTION.md +34 -0
  2. package/LICENSE +21 -0
  3. package/README.md +134 -0
  4. package/dist/cli/commands/agent.js +42 -0
  5. package/dist/cli/commands/doctor.js +21 -0
  6. package/dist/cli/commands/index-build.js +24 -0
  7. package/dist/cli/commands/init.js +121 -0
  8. package/dist/cli/commands/query.js +69 -0
  9. package/dist/cli/commands/spec.js +71 -0
  10. package/dist/cli/index.js +77 -0
  11. package/dist/cli/lib/args.js +44 -0
  12. package/dist/cli/lib/ui.js +89 -0
  13. package/dist/modules/compass/db.js +84 -0
  14. package/dist/modules/compass/embedder.js +87 -0
  15. package/dist/modules/compass/extract.js +91 -0
  16. package/dist/modules/compass/indexer.js +158 -0
  17. package/dist/modules/compass/languages.js +67 -0
  18. package/dist/modules/compass/parser.js +37 -0
  19. package/dist/modules/compass/query.js +260 -0
  20. package/dist/modules/compass/register.js +72 -0
  21. package/dist/modules/compass/watcher.js +95 -0
  22. package/dist/modules/foundation/assets/AGENTS.template.md +61 -0
  23. package/dist/modules/foundation/assets/CLAUDE.template.md +72 -0
  24. package/dist/modules/foundation/assets/LAWS.template.md +39 -0
  25. package/dist/modules/foundation/assets/docs/compass.template.md +43 -0
  26. package/dist/modules/foundation/assets/docs/standards/architecture.template.md +36 -0
  27. package/dist/modules/foundation/assets/docs/standards/backend-standards.template.md +50 -0
  28. package/dist/modules/foundation/assets/docs/standards/base-standards.template.md +47 -0
  29. package/dist/modules/foundation/assets/docs/standards/conventions.template.md +31 -0
  30. package/dist/modules/foundation/assets/docs/standards/documentation.template.md +50 -0
  31. package/dist/modules/foundation/assets/docs/standards/frontend-standards.template.md +46 -0
  32. package/dist/modules/foundation/assets/docs/standards/spec-workflow.template.md +46 -0
  33. package/dist/modules/foundation/assets/docs/standards/testing-standards.template.md +34 -0
  34. package/dist/modules/foundation/doctor.js +107 -0
  35. package/dist/modules/foundation/register.js +86 -0
  36. package/dist/modules/foundation/scaffold.js +103 -0
  37. package/dist/modules/spec/assets/commands/archive.md +10 -0
  38. package/dist/modules/spec/assets/commands/build.md +11 -0
  39. package/dist/modules/spec/assets/commands/draft.md +12 -0
  40. package/dist/modules/spec/assets/commands/explore.md +10 -0
  41. package/dist/modules/spec/assets/commands/sync.md +9 -0
  42. package/dist/modules/spec/assets/rules/spec-tasks-mandatory-steps.md +37 -0
  43. package/dist/modules/spec/assets/skills/archive/SKILL.md +22 -0
  44. package/dist/modules/spec/assets/skills/build/SKILL.md +49 -0
  45. package/dist/modules/spec/assets/skills/draft/SKILL.md +64 -0
  46. package/dist/modules/spec/assets/skills/explore/SKILL.md +28 -0
  47. package/dist/modules/spec/assets/skills/sync/SKILL.md +21 -0
  48. package/dist/modules/spec/engine.js +227 -0
  49. package/dist/modules/spec/register.js +53 -0
  50. package/dist/modules/tools/assets/packs/agents/backend-developer.md +61 -0
  51. package/dist/modules/tools/assets/packs/agents/frontend-developer.md +62 -0
  52. package/dist/modules/tools/assets/packs/agents/product-strategy-analyst.md +56 -0
  53. package/dist/modules/tools/assets/packs.json +6 -0
  54. package/dist/modules/tools/packs.js +44 -0
  55. package/dist/modules/tools/register.js +27 -0
  56. package/dist/server.js +21 -0
  57. package/dist/shared/agents.js +94 -0
  58. package/dist/shared/install.js +66 -0
  59. package/dist/shared/mcp.js +16 -0
  60. package/dist/shared/paths.js +10 -0
  61. package/dist/shared/render.js +21 -0
  62. package/package.json +49 -0
@@ -0,0 +1,89 @@
1
+ // Brand-themed terminal UI. Colors come from the speclaw palette (cyan #2EE6E6
2
+ // = the "law", cream text, muted gray, green/amber for status) rendered as
3
+ // 24-bit truecolor ANSI — no dependency needed. Colors auto-disable when the
4
+ // output is not a TTY or NO_COLOR is set.
5
+ const PALETTE = {
6
+ cyan: [46, 230, 230], // #2EE6E6 — the accent / "law"
7
+ cyanDim: [23, 193, 193], // #17C1C1
8
+ cream: [244, 241, 234], // #F4F1EA — primary text
9
+ muted: [110, 123, 128], // #6E7B80 — secondary text
10
+ green: [63, 185, 80], // #3FB950 — success
11
+ amber: [227, 179, 65], // #E3B341 — warning
12
+ red: [235, 90, 90],
13
+ };
14
+ const colorOn = (Boolean(process.stdout.isTTY) || process.env.FORCE_COLOR === "1") &&
15
+ !process.env.NO_COLOR;
16
+ function paint(rgb, s) {
17
+ if (!colorOn)
18
+ return s;
19
+ return `\x1b[38;2;${rgb[0]};${rgb[1]};${rgb[2]}m${s}\x1b[0m`;
20
+ }
21
+ function bold(s) {
22
+ return colorOn ? `\x1b[1m${s}\x1b[0m` : s;
23
+ }
24
+ /** Brand color helpers for composing styled strings. */
25
+ export const c = {
26
+ cyan: (s) => paint(PALETTE.cyan, s),
27
+ cyanDim: (s) => paint(PALETTE.cyanDim, s),
28
+ cream: (s) => paint(PALETTE.cream, s),
29
+ muted: (s) => paint(PALETTE.muted, s),
30
+ green: (s) => paint(PALETTE.green, s),
31
+ amber: (s) => paint(PALETTE.amber, s),
32
+ red: (s) => paint(PALETTE.red, s),
33
+ bold,
34
+ };
35
+ /** Styled output primitives used across the CLI. */
36
+ export const ui = {
37
+ heading: (s) => console.log("\n" + bold(c.cyan(s))),
38
+ step: (s) => console.log("\n" + c.cyan("◇ ") + bold(c.cream(s))),
39
+ ok: (s) => console.log(" " + c.green("✓") + " " + c.cream(s)),
40
+ info: (s) => console.log(" " + c.muted(s)),
41
+ warn: (s) => console.log(" " + c.amber("!") + " " + c.cream(s)),
42
+ err: (s) => console.error(" " + c.red("✗") + " " + c.cream(s)),
43
+ plain: (s = "") => console.log(s),
44
+ code: (s) => c.cyan(s),
45
+ };
46
+ /**
47
+ * The speclaw wordmark + logo mark (a document whose bottom line — the law — is
48
+ * highlighted in cyan). Printed at the top of `speclaw init`.
49
+ */
50
+ export function banner() {
51
+ const bar = c.cyan("▇▇▇▇▇▇");
52
+ const line = c.muted("──────");
53
+ const edge = c.muted;
54
+ console.log();
55
+ console.log(" " + edge("╭────────╮"));
56
+ console.log(" " + edge("│ ") + line + edge(" │") + " " + bold(c.cream("s p e c l a w")));
57
+ console.log(" " + edge("│ ") + c.muted("──── ") + edge(" │") + " " + c.muted("where specs become law"));
58
+ console.log(" " + edge("│ ") + c.muted("─────") + " " + edge(" │"));
59
+ console.log(" " + edge("│ ") + bar + edge(" │"));
60
+ console.log(" " + edge("╰────────╯"));
61
+ console.log();
62
+ }
63
+ /** Render a single-line progress bar on stderr (so stdout stays clean). */
64
+ export function renderProgress(done, total, label) {
65
+ if (!process.stderr.isTTY)
66
+ return;
67
+ const width = 26;
68
+ const ratio = total > 0 ? done / total : 1;
69
+ const filled = Math.round(ratio * width);
70
+ const bar = c.cyan("█".repeat(filled)) + c.muted("░".repeat(width - filled));
71
+ const pct = c.cyanDim(String(Math.round(ratio * 100)).padStart(3) + "%");
72
+ const shortLabel = label.length > 38 ? "…" + label.slice(-37) : label;
73
+ process.stderr.write(`\r ${bar} ${pct} ${c.muted(shortLabel.padEnd(38))}`);
74
+ }
75
+ export function clearProgress() {
76
+ if (process.stderr.isTTY)
77
+ process.stderr.write("\r" + " ".repeat(80) + "\r");
78
+ }
79
+ /** Draw a cyan-bordered block (used for the copy-paste agent prompt). */
80
+ export function box(lines, title) {
81
+ const width = Math.min(72, Math.max(...lines.map((l) => l.length), title?.length ?? 0) + 2);
82
+ const top = title
83
+ ? "╭─ " + c.cyanDim(title) + " " + "─".repeat(Math.max(0, width - title.length - 3))
84
+ : "╭" + "─".repeat(width);
85
+ console.log(" " + c.muted(top) + c.muted("╮"));
86
+ for (const l of lines)
87
+ console.log(" " + c.muted("│ ") + c.cream(l.padEnd(width - 2)) + c.muted(" │"));
88
+ console.log(" " + c.muted("╰" + "─".repeat(width) + "╯"));
89
+ }
@@ -0,0 +1,84 @@
1
+ import { DatabaseSync } from "node:sqlite";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ const SCHEMA = `
5
+ CREATE TABLE IF NOT EXISTS meta (
6
+ key TEXT PRIMARY KEY,
7
+ value TEXT
8
+ );
9
+ CREATE TABLE IF NOT EXISTS files (
10
+ id INTEGER PRIMARY KEY,
11
+ path TEXT UNIQUE NOT NULL,
12
+ hash TEXT NOT NULL,
13
+ lang TEXT NOT NULL
14
+ );
15
+ -- nodes: the definitions in the codebase (functions, classes, methods, types).
16
+ CREATE TABLE IF NOT EXISTS nodes (
17
+ id INTEGER PRIMARY KEY,
18
+ file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE,
19
+ name TEXT NOT NULL,
20
+ kind TEXT NOT NULL,
21
+ start_line INTEGER NOT NULL,
22
+ end_line INTEGER NOT NULL,
23
+ start_byte INTEGER NOT NULL,
24
+ end_byte INTEGER NOT NULL,
25
+ parent_id INTEGER,
26
+ signature TEXT
27
+ );
28
+ CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
29
+ CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(file_id);
30
+ -- edges: a reference from one node to a named target, resolved lazily.
31
+ CREATE TABLE IF NOT EXISTS edges (
32
+ id INTEGER PRIMARY KEY,
33
+ src_node_id INTEGER REFERENCES nodes(id) ON DELETE CASCADE,
34
+ src_file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE,
35
+ dst_name TEXT NOT NULL,
36
+ dst_node_id INTEGER,
37
+ kind TEXT NOT NULL,
38
+ line INTEGER NOT NULL
39
+ );
40
+ CREATE INDEX IF NOT EXISTS idx_edges_dst ON edges(dst_name);
41
+ CREATE INDEX IF NOT EXISTS idx_edges_src ON edges(src_node_id);
42
+ CREATE INDEX IF NOT EXISTS idx_edges_dstid ON edges(dst_node_id);
43
+ -- node_embeddings: the local vector store (one embedding per node).
44
+ CREATE TABLE IF NOT EXISTS node_embeddings (
45
+ node_id INTEGER PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE,
46
+ dim INTEGER NOT NULL,
47
+ model TEXT NOT NULL,
48
+ vec BLOB NOT NULL
49
+ );
50
+ `;
51
+ /** Schema version stamped into the `meta` table on first creation. */
52
+ export const SCHEMA_VERSION = "2";
53
+ /**
54
+ * Open (creating if needed) the index database at `<projectPath>/.speclaw/index.db`.
55
+ *
56
+ * Ensures the `.speclaw` directory exists, applies the schema (idempotently),
57
+ * enables WAL journaling and foreign keys, and stamps the schema version on a
58
+ * fresh database.
59
+ *
60
+ * @param projectPath - Absolute path to the project root.
61
+ * @returns An open connection to the index database.
62
+ */
63
+ export function openDb(projectPath) {
64
+ const dir = path.join(projectPath, ".speclaw");
65
+ fs.mkdirSync(dir, { recursive: true });
66
+ const db = new DatabaseSync(path.join(dir, "index.db"));
67
+ db.exec("PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;");
68
+ db.exec(SCHEMA);
69
+ const row = db
70
+ .prepare("SELECT value FROM meta WHERE key = 'schema_version'")
71
+ .get();
72
+ if (!row) {
73
+ db.prepare("INSERT INTO meta(key, value) VALUES ('schema_version', ?)").run(SCHEMA_VERSION);
74
+ }
75
+ return db;
76
+ }
77
+ /** Absolute path to the index database file for a project. */
78
+ export function indexPath(projectPath) {
79
+ return path.join(projectPath, ".speclaw", "index.db");
80
+ }
81
+ /** Whether an index database already exists for the project. */
82
+ export function indexExists(projectPath) {
83
+ return fs.existsSync(indexPath(projectPath));
84
+ }
@@ -0,0 +1,87 @@
1
+ import { createHash } from "node:crypto";
2
+ /**
3
+ * Split identifiers into lowercase subtokens.
4
+ *
5
+ * Splits on camelCase boundaries, snake_case, dots, and other non-word
6
+ * characters, dropping tokens of length 1 or less.
7
+ *
8
+ * @param text - Raw identifier or free text to tokenize.
9
+ * @returns The list of subtokens, lowercased.
10
+ */
11
+ export function tokenize(text) {
12
+ return text
13
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
14
+ .split(/[^A-Za-z0-9]+/)
15
+ .map((t) => t.toLowerCase())
16
+ .filter((t) => t.length > 1);
17
+ }
18
+ /**
19
+ * Zero-dependency, offline lexical embedder. Hashes subtokens into a fixed-dim
20
+ * bag-of-tokens vector, L2-normalized. Captures token overlap similarity —
21
+ * weaker than a neural model but instant, deterministic, and requires no
22
+ * download. The default; swap in a model-backed Embedder for true semantics.
23
+ */
24
+ export class LexicalEmbedder {
25
+ dim;
26
+ id = "lexical-hash-v1";
27
+ constructor(dim = 256) {
28
+ this.dim = dim;
29
+ }
30
+ /**
31
+ * Embed text as an L2-normalized bag-of-hashed-subtokens vector.
32
+ *
33
+ * @param text - Text to embed.
34
+ * @returns A `dim`-length unit vector suitable for cosine comparison.
35
+ */
36
+ embed(text) {
37
+ const vec = new Float32Array(this.dim);
38
+ for (const tok of tokenize(text)) {
39
+ // two hashed buckets per token (signed) reduce collisions
40
+ const h = createHash("md5").update(tok).digest();
41
+ const bucket = ((h[0] << 8) | h[1]) % this.dim;
42
+ const sign = h[2] & 1 ? 1 : -1;
43
+ vec[bucket] += sign;
44
+ const bucket2 = ((h[3] << 8) | h[4]) % this.dim;
45
+ vec[bucket2] += sign;
46
+ }
47
+ let norm = 0;
48
+ for (const v of vec)
49
+ norm += v * v;
50
+ norm = Math.sqrt(norm) || 1;
51
+ for (let i = 0; i < vec.length; i++)
52
+ vec[i] /= norm;
53
+ return vec;
54
+ }
55
+ }
56
+ let active = new LexicalEmbedder();
57
+ /** The currently active embedder (defaults to {@link LexicalEmbedder}). */
58
+ export function getEmbedder() {
59
+ return active;
60
+ }
61
+ /** Replace the active embedder used for indexing and recall. */
62
+ export function setEmbedder(e) {
63
+ active = e;
64
+ }
65
+ /** Serialize a vector to a raw little-endian `Float32` blob for storage. */
66
+ export function toBlob(vec) {
67
+ return Buffer.from(vec.buffer, vec.byteOffset, vec.byteLength);
68
+ }
69
+ /** Deserialize a stored blob back into a `Float32Array` vector. */
70
+ export function fromBlob(blob) {
71
+ const buf = Buffer.isBuffer(blob) ? blob : Buffer.from(blob);
72
+ return new Float32Array(buf.buffer, buf.byteOffset, buf.byteLength / 4);
73
+ }
74
+ /**
75
+ * Cosine similarity between two vectors.
76
+ *
77
+ * @remarks Assumes both vectors are pre-normalized, so this is just their dot
78
+ * product over the shared prefix length.
79
+ * @returns Similarity in `[-1, 1]`; higher means more similar.
80
+ */
81
+ export function cosine(a, b) {
82
+ const n = Math.min(a.length, b.length);
83
+ let dot = 0;
84
+ for (let i = 0; i < n; i++)
85
+ dot += a[i] * b[i];
86
+ return dot; // vectors are pre-normalized
87
+ }
@@ -0,0 +1,91 @@
1
+ import { parse } from "./parser.js";
2
+ const DEF_LOOKUP = new WeakMap();
3
+ function defKindMap(lang) {
4
+ let m = DEF_LOOKUP.get(lang);
5
+ if (!m) {
6
+ m = new Map(lang.defs.map((d) => [d.node, d.kind]));
7
+ DEF_LOOKUP.set(lang, m);
8
+ }
9
+ return m;
10
+ }
11
+ /** Resolve the identifier name a definition node declares. */
12
+ function defName(node) {
13
+ const field = node.childForFieldName("name");
14
+ return field ? field.text : null;
15
+ }
16
+ /** Resolve the final callee name from a call node's function field. */
17
+ function calleeName(node, lang) {
18
+ const fn = node.childForFieldName(lang.callField);
19
+ if (!fn)
20
+ return null;
21
+ // a.b.c() -> c ; foo() -> foo
22
+ if (fn.type === "member_expression" || fn.type === "attribute") {
23
+ const prop = fn.childForFieldName("property") ?? fn.childForFieldName("attribute");
24
+ return prop ? prop.text : fn.text;
25
+ }
26
+ if (fn.type === "identifier")
27
+ return fn.text;
28
+ return fn.text.split(/[.\s(]/)[0] || null;
29
+ }
30
+ /** First line of the node's text, trimmed — a lightweight signature. */
31
+ function signatureOf(node) {
32
+ return node.text.split("\n")[0].trim().slice(0, 200);
33
+ }
34
+ /**
35
+ * Walk a parsed tree extracting definitions (with nesting) and the call/import
36
+ * references each definition contains. Single traversal, O(nodes).
37
+ *
38
+ * @param source - The full source text of the file.
39
+ * @param lang - Language configuration describing definition/call/import nodes.
40
+ * @returns The extracted symbols and references; `parentIndex`/`ownerIndex`
41
+ * fields index back into the `symbols` array to express nesting and ownership.
42
+ * @throws If the source cannot be parsed for the given language.
43
+ */
44
+ export async function extract(source, lang) {
45
+ const tree = await parse(source, lang);
46
+ const kinds = defKindMap(lang);
47
+ const importSet = new Set(lang.importNodes);
48
+ const symbols = [];
49
+ const refs = [];
50
+ const walk = (node, ownerIndex) => {
51
+ let nextOwner = ownerIndex;
52
+ if (kinds.has(node.type)) {
53
+ const name = defName(node);
54
+ if (name) {
55
+ const index = symbols.length;
56
+ symbols.push({
57
+ name,
58
+ kind: kinds.get(node.type),
59
+ startLine: node.startPosition.row + 1,
60
+ endLine: node.endPosition.row + 1,
61
+ startByte: node.startIndex,
62
+ endByte: node.endIndex,
63
+ parentIndex: ownerIndex,
64
+ signature: signatureOf(node),
65
+ });
66
+ nextOwner = index;
67
+ }
68
+ }
69
+ else if (node.type === lang.callNode) {
70
+ const name = calleeName(node, lang);
71
+ if (name)
72
+ refs.push({ name, kind: "call", line: node.startPosition.row + 1, ownerIndex });
73
+ }
74
+ else if (importSet.has(node.type)) {
75
+ refs.push({
76
+ name: node.text.split("\n")[0].trim().slice(0, 200),
77
+ kind: "import",
78
+ line: node.startPosition.row + 1,
79
+ ownerIndex,
80
+ });
81
+ }
82
+ for (let i = 0; i < node.childCount; i++) {
83
+ const child = node.child(i);
84
+ if (child)
85
+ walk(child, nextOwner);
86
+ }
87
+ };
88
+ walk(tree.rootNode, null);
89
+ tree.delete();
90
+ return { symbols, refs };
91
+ }
@@ -0,0 +1,158 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { createHash } from "node:crypto";
4
+ import { openDb } from "./db.js";
5
+ import { langForPath } from "./languages.js";
6
+ import { extract } from "./extract.js";
7
+ import { getEmbedder, toBlob } from "./embedder.js";
8
+ const SKIP_DIRS = new Set([
9
+ ".git", "node_modules", "dist", "build", ".next", "out", "coverage",
10
+ "__pycache__", ".venv", "venv", ".speclaw", ".mypy_cache", ".pytest_cache",
11
+ "vendor", "target", ".turbo", ".cache",
12
+ ]);
13
+ const MAX_FILE_BYTES = 1_500_000;
14
+ function hashOf(content) {
15
+ return createHash("sha256").update(content).digest("hex");
16
+ }
17
+ function* walkFiles(root) {
18
+ const stack = [root];
19
+ while (stack.length) {
20
+ const dir = stack.pop();
21
+ let entries;
22
+ try {
23
+ entries = fs.readdirSync(dir, { withFileTypes: true });
24
+ }
25
+ catch {
26
+ continue;
27
+ }
28
+ for (const entry of entries) {
29
+ const full = path.join(dir, entry.name);
30
+ if (entry.isDirectory()) {
31
+ if (!SKIP_DIRS.has(entry.name))
32
+ stack.push(full);
33
+ }
34
+ else if (entry.isFile()) {
35
+ if (langForPath(full))
36
+ yield full;
37
+ }
38
+ }
39
+ }
40
+ }
41
+ /**
42
+ * Build or incrementally refresh the index for a project.
43
+ *
44
+ * Walks the project's source files (skipping vendored/build directories and
45
+ * oversized files), and for each file whose content hash changed, re-parses it,
46
+ * replacing its nodes and edges and re-embedding each node. Files whose hash is
47
+ * unchanged are skipped; files that disappeared are pruned. Finally resolves
48
+ * call edges to their target node definitions by name. The whole run executes
49
+ * in a single transaction, rolled back on any error.
50
+ *
51
+ * @param projectPath - Absolute path to the project root.
52
+ * @param onProgress - Optional callback invoked once per scanned file.
53
+ * @returns Counts of files, nodes, edges, embeddings, and pruned/unchanged files.
54
+ * @throws Re-throws any error encountered mid-run after rolling back the transaction.
55
+ */
56
+ export async function buildIndex(projectPath, onProgress) {
57
+ const db = openDb(projectPath);
58
+ const embedder = getEmbedder();
59
+ const stats = {
60
+ files: 0, nodes: 0, edges: 0, embeddings: 0,
61
+ unchanged: 0, removed: 0, embedder: embedder.id,
62
+ };
63
+ const existing = new Map();
64
+ for (const row of db.prepare("SELECT id, path, hash FROM files").all()) {
65
+ existing.set(row.path, { id: row.id, hash: row.hash });
66
+ }
67
+ const seen = new Set();
68
+ const insFile = db.prepare("INSERT INTO files(path, hash, lang) VALUES (?, ?, ?)");
69
+ const updFile = db.prepare("UPDATE files SET hash = ?, lang = ? WHERE id = ?");
70
+ const delNodes = db.prepare("DELETE FROM nodes WHERE file_id = ?");
71
+ const delEdges = db.prepare("DELETE FROM edges WHERE src_file_id = ?");
72
+ const insNode = db.prepare(`INSERT INTO nodes(file_id, name, kind, start_line, end_line, start_byte, end_byte, parent_id, signature)
73
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`);
74
+ const insEdge = db.prepare(`INSERT INTO edges(src_node_id, src_file_id, dst_name, kind, line) VALUES (?, ?, ?, ?, ?)`);
75
+ const insEmbed = db.prepare(`INSERT OR REPLACE INTO node_embeddings(node_id, dim, model, vec) VALUES (?, ?, ?, ?)`);
76
+ const allFiles = [...walkFiles(projectPath)];
77
+ db.exec("BEGIN");
78
+ try {
79
+ let done = 0;
80
+ for (const filePath of allFiles) {
81
+ const rel = path.relative(projectPath, filePath);
82
+ done++;
83
+ if (onProgress)
84
+ onProgress({ file: rel, done, total: allFiles.length });
85
+ seen.add(rel);
86
+ const lang = langForPath(filePath);
87
+ let content;
88
+ try {
89
+ const stat = fs.statSync(filePath);
90
+ if (stat.size > MAX_FILE_BYTES)
91
+ continue;
92
+ content = fs.readFileSync(filePath, "utf8");
93
+ }
94
+ catch {
95
+ continue;
96
+ }
97
+ const hash = hashOf(content);
98
+ const prior = existing.get(rel);
99
+ if (prior && prior.hash === hash) {
100
+ stats.unchanged++;
101
+ continue;
102
+ }
103
+ let fileId;
104
+ if (prior) {
105
+ updFile.run(hash, lang.id, prior.id);
106
+ delNodes.run(prior.id);
107
+ delEdges.run(prior.id);
108
+ fileId = prior.id;
109
+ }
110
+ else {
111
+ fileId = Number(insFile.run(rel, hash, lang.id).lastInsertRowid);
112
+ }
113
+ const { symbols, refs } = await extract(content, lang);
114
+ const nodeIds = [];
115
+ for (const s of symbols) {
116
+ const parentId = s.parentIndex !== null ? nodeIds[s.parentIndex] : null;
117
+ const id = Number(insNode.run(fileId, s.name, s.kind, s.startLine, s.endLine, s.startByte, s.endByte, parentId, s.signature).lastInsertRowid);
118
+ nodeIds.push(id);
119
+ // embed the node from its name + signature (cheap, meaningful text)
120
+ const vec = await embedder.embed(`${s.kind} ${s.name} ${s.signature ?? ""}`);
121
+ insEmbed.run(id, embedder.dim, embedder.id, toBlob(vec));
122
+ stats.embeddings++;
123
+ }
124
+ for (const r of refs) {
125
+ const srcId = r.ownerIndex !== null ? nodeIds[r.ownerIndex] : null;
126
+ insEdge.run(srcId, fileId, r.name, r.kind, r.line);
127
+ stats.edges++;
128
+ }
129
+ stats.files++;
130
+ stats.nodes += symbols.length;
131
+ }
132
+ // prune files that no longer exist
133
+ for (const [rel, row] of existing) {
134
+ if (!seen.has(rel)) {
135
+ db.prepare("DELETE FROM files WHERE id = ?").run(row.id);
136
+ stats.removed++;
137
+ }
138
+ }
139
+ // resolve call edges to node definitions by name match
140
+ db.exec(`
141
+ UPDATE edges SET dst_node_id = (
142
+ SELECT n.id FROM nodes n
143
+ WHERE n.name = edges.dst_name
144
+ LIMIT 1
145
+ )
146
+ WHERE kind = 'call' AND dst_node_id IS NULL
147
+ `);
148
+ db.exec("COMMIT");
149
+ }
150
+ catch (err) {
151
+ db.exec("ROLLBACK");
152
+ throw err;
153
+ }
154
+ finally {
155
+ db.close();
156
+ }
157
+ return stats;
158
+ }
@@ -0,0 +1,67 @@
1
+ import { createRequire } from "node:module";
2
+ import path from "node:path";
3
+ const require = createRequire(import.meta.url);
4
+ /** Directory holding the pre-built tree-sitter WASM grammars. */
5
+ function wasmDir() {
6
+ return path.join(path.dirname(require.resolve("tree-sitter-wasms/package.json")), "out");
7
+ }
8
+ export const LANGUAGES = [
9
+ {
10
+ id: "python",
11
+ grammar: "tree-sitter-python",
12
+ extensions: [".py", ".pyi"],
13
+ defs: [
14
+ { node: "function_definition", kind: "function" },
15
+ { node: "class_definition", kind: "class" },
16
+ ],
17
+ callNode: "call",
18
+ callField: "function",
19
+ importNodes: ["import_statement", "import_from_statement"],
20
+ },
21
+ {
22
+ id: "javascript",
23
+ grammar: "tree-sitter-javascript",
24
+ extensions: [".js", ".jsx", ".mjs", ".cjs"],
25
+ defs: [
26
+ { node: "function_declaration", kind: "function" },
27
+ { node: "class_declaration", kind: "class" },
28
+ { node: "method_definition", kind: "method" },
29
+ ],
30
+ callNode: "call_expression",
31
+ callField: "function",
32
+ importNodes: ["import_statement"],
33
+ },
34
+ {
35
+ id: "typescript",
36
+ grammar: "tree-sitter-typescript",
37
+ extensions: [".ts", ".tsx", ".mts", ".cts"],
38
+ defs: [
39
+ { node: "function_declaration", kind: "function" },
40
+ { node: "class_declaration", kind: "class" },
41
+ { node: "method_definition", kind: "method" },
42
+ { node: "interface_declaration", kind: "interface" },
43
+ { node: "type_alias_declaration", kind: "type" },
44
+ { node: "enum_declaration", kind: "enum" },
45
+ ],
46
+ callNode: "call_expression",
47
+ callField: "function",
48
+ importNodes: ["import_statement"],
49
+ },
50
+ ];
51
+ const BY_EXT = new Map();
52
+ for (const lang of LANGUAGES) {
53
+ for (const ext of lang.extensions)
54
+ BY_EXT.set(ext, lang);
55
+ }
56
+ /**
57
+ * Resolve the language configuration for a file by its extension.
58
+ *
59
+ * @returns The matching {@link LangConfig}, or `undefined` for unsupported files.
60
+ */
61
+ export function langForPath(filePath) {
62
+ return BY_EXT.get(path.extname(filePath).toLowerCase());
63
+ }
64
+ /** Absolute path to the tree-sitter WASM grammar file for a language. */
65
+ export function grammarPath(lang) {
66
+ return path.join(wasmDir(), `${lang.grammar}.wasm`);
67
+ }
@@ -0,0 +1,37 @@
1
+ import { Parser, Language } from "web-tree-sitter";
2
+ import { grammarPath } from "./languages.js";
3
+ let initialized = false;
4
+ const languageCache = new Map();
5
+ async function ensureInit() {
6
+ if (!initialized) {
7
+ await Parser.init();
8
+ initialized = true;
9
+ }
10
+ }
11
+ async function loadLanguage(lang) {
12
+ const cached = languageCache.get(lang.id);
13
+ if (cached)
14
+ return cached;
15
+ const loaded = await Language.load(grammarPath(lang));
16
+ languageCache.set(lang.id, loaded);
17
+ return loaded;
18
+ }
19
+ /**
20
+ * Parse source into a tree-sitter tree for the given language.
21
+ *
22
+ * Lazily initializes tree-sitter and caches the loaded grammar per language.
23
+ *
24
+ * @param source - The source text to parse.
25
+ * @param lang - Language configuration selecting the grammar.
26
+ * @returns The parsed syntax tree; the caller owns it and must `delete()` it.
27
+ * @throws If parsing yields no tree.
28
+ */
29
+ export async function parse(source, lang) {
30
+ await ensureInit();
31
+ const parser = new Parser();
32
+ parser.setLanguage(await loadLanguage(lang));
33
+ const tree = parser.parse(source);
34
+ if (!tree)
35
+ throw new Error(`failed to parse ${lang.id} source`);
36
+ return tree;
37
+ }