securevibe 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.
@@ -0,0 +1,333 @@
1
+ /**
2
+ * Fix session orchestrator (doc 05). Scans, routes each finding to a
3
+ * deterministic or LLM fixer, then VERIFIES every candidate rewrite by
4
+ * re-running the exact same detectors on the new content before accepting it.
5
+ *
6
+ * Honesty note baked into the design: "accepted" means our detector no longer
7
+ * flags the issue AND no new issue was introduced AND the file still parses —
8
+ * it does NOT mean the app is proven secure. Default is dry-run; --apply always
9
+ * backs up originals first (there is no git here to fall back on).
10
+ */
11
+ import { promises as fs } from "node:fs";
12
+ import path from "node:path";
13
+ import { scan } from "../scan.js";
14
+ import { detectFile } from "../scan.js";
15
+ import { computeScore } from "../score.js";
16
+ import { parse, langForExtension } from "../parser.js";
17
+ import { hasError } from "../ast.js";
18
+ import { SEVERITY_ORDER } from "../types.js";
19
+ import { deterministicFix } from "./deterministic.js";
20
+ import { llmFix, llmAvailability, LLM_DETECTORS } from "./llm.js";
21
+ import { loadDepDb } from "../deps/db.js";
22
+ const MEDIUM = SEVERITY_ORDER["medium"];
23
+ export async function runFix(root, options = {}) {
24
+ const start = Date.now();
25
+ const progress = options.onProgress ?? (() => { });
26
+ const absRoot = path.resolve(root);
27
+ const llm = llmAvailability(!!options.noLlm);
28
+ const depDb = await loadDepDb();
29
+ progress("Scanning…");
30
+ const before = await scan(absRoot);
31
+ const byFile = groupByFile(before.findings);
32
+ const outcomes = [];
33
+ const patches = [];
34
+ const sideEffects = [];
35
+ // relPath → findings, used to recompute the projected score.
36
+ const projected = new Map(byFile);
37
+ for (const [relPath, fileFindings] of byFile) {
38
+ progress(`Fixing ${relPath}…`);
39
+ const abs = path.join(absRoot, ...relPath.split("/"));
40
+ let original;
41
+ try {
42
+ original = await fs.readFile(abs, "utf8");
43
+ }
44
+ catch {
45
+ for (const f of fileFindings)
46
+ outcomes.push(manual(f, "Could not read the file to fix it."));
47
+ continue;
48
+ }
49
+ // 1) Deterministic pass (rewrites all its patterns at once).
50
+ const det = await deterministicFix({
51
+ relPath,
52
+ code: original,
53
+ inventory: before.inventory,
54
+ findings: fileFindings,
55
+ depDb,
56
+ });
57
+ sideEffects.push(...det.sideEffects);
58
+ for (const [id, note] of det.manual) {
59
+ const f = fileFindings.find((x) => x.id === id);
60
+ if (f)
61
+ outcomes.push({ finding: f, status: "manual", strategy: "deterministic", note });
62
+ }
63
+ // 2) LLM pass for the findings no deterministic fixer claimed.
64
+ const handled = new Set([...det.attempted, ...det.manual.keys()]);
65
+ const llmTargets = fileFindings.filter((f) => !handled.has(f.id) && LLM_DETECTORS.has(f.detector));
66
+ const lang = langForExtension(path.extname(relPath));
67
+ let candidate = det.code;
68
+ const llmAttempted = new Set();
69
+ if (llm.available && llmTargets.length > 0 && lang) {
70
+ const rewritten = await llmFix({ relPath, lang, code: candidate, findings: llmTargets });
71
+ if (rewritten && rewritten !== candidate) {
72
+ candidate = rewritten;
73
+ for (const f of llmTargets)
74
+ llmAttempted.add(f.id);
75
+ }
76
+ }
77
+ // 3) Verify + accept/reject the combined candidate.
78
+ const targeted = fileFindings.filter((f) => det.attempted.has(f.id) || llmAttempted.has(f.id));
79
+ if (candidate !== original && targeted.length > 0) {
80
+ const verdict = await verify(relPath, original, candidate, fileFindings);
81
+ if (verdict.accepted) {
82
+ patches.push({ relPath, before: original, after: candidate, targeted, accepted: true });
83
+ projected.set(relPath, verdict.newFindings);
84
+ assignOutcomes(targeted, verdict, det, llmAttempted, outcomes);
85
+ }
86
+ else {
87
+ // Rejected → every targeted finding falls back to a manual instruction.
88
+ for (const f of targeted) {
89
+ outcomes.push(manual(f, `Automatic fix rejected (${verdict.reason}). Apply manually: ${oneLine(f.fix)}`, det.attempted.has(f.id) ? "deterministic" : "llm"));
90
+ }
91
+ }
92
+ }
93
+ // 4) Findings with no fix attempt → manual (unfixable here, or LLM unavailable).
94
+ for (const f of fileFindings) {
95
+ if (det.attempted.has(f.id) || llmAttempted.has(f.id) || det.manual.has(f.id))
96
+ continue;
97
+ if (outcomes.some((o) => o.finding.id === f.id))
98
+ continue;
99
+ const note = LLM_DETECTORS.has(f.detector) && !llm.available
100
+ ? `Needs an AI-assisted fix — set ANTHROPIC_API_KEY to auto-fix (currently ${llm.reason}). Manual: ${oneLine(f.fix)}`
101
+ : `No safe automatic fix for this class yet. Manual: ${oneLine(f.fix)}`;
102
+ outcomes.push(manual(f, note));
103
+ }
104
+ }
105
+ const dedupedSideEffects = dedupeSideEffects(sideEffects);
106
+ // Approval gate (only on an --apply run with a confirm callback). Declined
107
+ // patches are restored in the projected score and reported `skipped`.
108
+ let finalPatches = patches;
109
+ let finalEffects = dedupedSideEffects;
110
+ if (options.apply && options.confirm) {
111
+ const approval = await runApprovals(patches, dedupedSideEffects, options.confirm);
112
+ finalPatches = approval.patches;
113
+ finalEffects = approval.effects;
114
+ for (const p of patches) {
115
+ if (finalPatches.includes(p))
116
+ continue;
117
+ projected.set(p.relPath, byFile.get(p.relPath) ?? []);
118
+ for (const f of p.targeted) {
119
+ const o = outcomes.find((x) => x.finding.id === f.id && x.status === "fixed");
120
+ if (o) {
121
+ o.status = "skipped";
122
+ o.note = "You declined this change; left unfixed.";
123
+ }
124
+ }
125
+ }
126
+ }
127
+ // Projected score from re-detected findings, AFTER approval adjustments.
128
+ const projectedFindings = [...projected.values()].flat();
129
+ const scoreAfter = computeScore(projectedFindings);
130
+ // Apply to disk only when asked; always back up first.
131
+ let backupDir = null;
132
+ if (options.apply && (finalPatches.length > 0 || finalEffects.length > 0)) {
133
+ backupDir = await applyToDisk(absRoot, finalPatches, finalEffects, progress);
134
+ }
135
+ return {
136
+ root: absRoot,
137
+ apply: !!options.apply,
138
+ llmAvailable: llm.available,
139
+ scoreBefore: before.score,
140
+ scoreAfter,
141
+ outcomes: sortOutcomes(outcomes),
142
+ patches: finalPatches,
143
+ sideEffects: finalEffects,
144
+ backupDir,
145
+ durationMs: Date.now() - start,
146
+ };
147
+ }
148
+ /** Walk accepted patches through the confirm gate; return the approved subset. */
149
+ async function runApprovals(patches, effects, confirm) {
150
+ const approvedPatches = [];
151
+ let all = false;
152
+ let quit = false;
153
+ for (let i = 0; i < patches.length; i++) {
154
+ const ans = all ? "yes" : await confirm({ kind: "patch", patch: patches[i], index: i, total: patches.length });
155
+ if (ans === "all") {
156
+ all = true;
157
+ approvedPatches.push(patches[i]);
158
+ }
159
+ else if (ans === "yes") {
160
+ approvedPatches.push(patches[i]);
161
+ }
162
+ else if (ans === "quit") {
163
+ quit = true;
164
+ break;
165
+ }
166
+ // "no" → skip this patch
167
+ }
168
+ let approvedEffects = [];
169
+ if (!quit && effects.length > 0) {
170
+ const ans = await confirm({ kind: "side-effects", effects });
171
+ if (ans === "yes" || ans === "all")
172
+ approvedEffects = effects;
173
+ }
174
+ return { patches: approvedPatches, effects: approvedEffects };
175
+ }
176
+ /** Re-detect on the candidate and decide whether to accept the rewrite. */
177
+ async function verify(relPath, _original, candidate, fileFindings) {
178
+ const lang = langForExtension(path.extname(relPath));
179
+ if (lang) {
180
+ try {
181
+ const { root } = await parse(candidate, lang);
182
+ if (hasError(root))
183
+ return reject("the rewrite no longer parses");
184
+ }
185
+ catch {
186
+ return reject("the rewrite no longer parses");
187
+ }
188
+ }
189
+ const newFindings = await detectFile(relPath, candidate, { depDb: await loadDepDb() });
190
+ const beforeBy = countByDetector(fileFindings);
191
+ const afterBy = countByDetector(newFindings);
192
+ // Regression: a new finding of medium+ severity that wasn't there before.
193
+ for (const [det, after] of afterBy) {
194
+ const beforeN = beforeBy.get(det)?.length ?? 0;
195
+ if (after.length > beforeN && after.some((f) => SEVERITY_ORDER[f.severity] >= MEDIUM)) {
196
+ return { accepted: false, reason: "it introduced a new issue", newFindings, resolvedBudget: new Map() };
197
+ }
198
+ }
199
+ const resolvedBudget = new Map();
200
+ for (const [det, arr] of beforeBy) {
201
+ const afterN = afterBy.get(det)?.length ?? 0;
202
+ resolvedBudget.set(det, Math.max(0, arr.length - afterN));
203
+ }
204
+ const anyResolved = [...resolvedBudget.values()].some((v) => v > 0);
205
+ if (!anyResolved)
206
+ return { accepted: false, reason: "it did not resolve the finding", newFindings, resolvedBudget };
207
+ return { accepted: true, reason: "verified", newFindings, resolvedBudget };
208
+ }
209
+ function reject(reason) {
210
+ return { accepted: false, reason, newFindings: [], resolvedBudget: new Map() };
211
+ }
212
+ /** Map each targeted finding to fixed (within the resolve budget) or manual. */
213
+ function assignOutcomes(targeted, verdict, det, llmAttempted, outcomes) {
214
+ const budget = new Map(verdict.resolvedBudget);
215
+ for (const f of targeted) {
216
+ const strategy = det.attempted.has(f.id) ? "deterministic" : "llm";
217
+ const left = budget.get(f.detector) ?? 0;
218
+ if (left > 0) {
219
+ budget.set(f.detector, left - 1);
220
+ outcomes.push({
221
+ finding: f,
222
+ status: "fixed",
223
+ strategy,
224
+ note: strategy === "deterministic"
225
+ ? "Rewritten by a deterministic transform; detector no longer flags it."
226
+ : "Rewritten by Claude; detector no longer flags it.",
227
+ });
228
+ }
229
+ else {
230
+ outcomes.push(manual(f, `Change applied but not confirmed resolved. Verify manually: ${oneLine(f.fix)}`, strategy));
231
+ }
232
+ }
233
+ }
234
+ async function applyToDisk(absRoot, patches, sideEffects, progress) {
235
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
236
+ const backupDir = path.join(absRoot, ".securevibe-backup", stamp);
237
+ await fs.mkdir(backupDir, { recursive: true });
238
+ const backup = async (relPath) => {
239
+ const src = path.join(absRoot, ...relPath.split("/"));
240
+ try {
241
+ const content = await fs.readFile(src);
242
+ const dest = path.join(backupDir, ...relPath.split("/"));
243
+ await fs.mkdir(path.dirname(dest), { recursive: true });
244
+ await fs.writeFile(dest, content);
245
+ }
246
+ catch {
247
+ /* file may not exist yet (write-if-absent side effect) — nothing to back up */
248
+ }
249
+ };
250
+ for (const p of patches) {
251
+ await backup(p.relPath);
252
+ const dest = path.join(absRoot, ...p.relPath.split("/"));
253
+ await fs.writeFile(dest, p.after, "utf8");
254
+ progress(`Wrote ${p.relPath}`);
255
+ }
256
+ for (const fx of sideEffects) {
257
+ const dest = path.join(absRoot, ...fx.relPath.split("/"));
258
+ if (fx.kind === "write-if-absent") {
259
+ try {
260
+ await fs.access(dest);
261
+ }
262
+ catch {
263
+ await fs.mkdir(path.dirname(dest), { recursive: true });
264
+ await fs.writeFile(dest, fx.content, "utf8");
265
+ progress(`Wrote ${fx.relPath}`);
266
+ }
267
+ }
268
+ else if (fx.kind === "ensure-line") {
269
+ await backup(fx.relPath);
270
+ let existing = "";
271
+ try {
272
+ existing = await fs.readFile(dest, "utf8");
273
+ }
274
+ catch {
275
+ /* create fresh */
276
+ }
277
+ const lines = existing.split(/\r?\n/).map((l) => l.trim());
278
+ if (!lines.includes(fx.content.trim())) {
279
+ const sep = existing.length > 0 && !existing.endsWith("\n") ? "\n" : "";
280
+ await fs.mkdir(path.dirname(dest), { recursive: true });
281
+ await fs.writeFile(dest, existing + sep + fx.content + "\n", "utf8");
282
+ progress(`Updated ${fx.relPath}`);
283
+ }
284
+ }
285
+ }
286
+ return backupDir;
287
+ }
288
+ // ── helpers ─────────────────────────────────────────────────────────────────
289
+ function groupByFile(findings) {
290
+ const m = new Map();
291
+ for (const f of findings) {
292
+ const arr = m.get(f.file) ?? [];
293
+ arr.push(f);
294
+ m.set(f.file, arr);
295
+ }
296
+ return m;
297
+ }
298
+ function countByDetector(findings) {
299
+ const m = new Map();
300
+ for (const f of findings) {
301
+ const arr = m.get(f.detector) ?? [];
302
+ arr.push(f);
303
+ m.set(f.detector, arr);
304
+ }
305
+ return m;
306
+ }
307
+ function dedupeSideEffects(effects) {
308
+ const seen = new Set();
309
+ const out = [];
310
+ for (const e of effects) {
311
+ const key = `${e.kind}:${e.relPath}:${e.content}`;
312
+ if (seen.has(key))
313
+ continue;
314
+ seen.add(key);
315
+ out.push(e);
316
+ }
317
+ return out;
318
+ }
319
+ function manual(finding, note, strategy) {
320
+ return { finding, status: "manual", strategy, note };
321
+ }
322
+ function oneLine(s) {
323
+ return s.replace(/\s+/g, " ").trim();
324
+ }
325
+ const STATUS_ORDER = { fixed: 0, manual: 1, rejected: 2, skipped: 3 };
326
+ function sortOutcomes(outcomes) {
327
+ return outcomes.sort((a, b) => {
328
+ const s = (STATUS_ORDER[a.status] ?? 9) - (STATUS_ORDER[b.status] ?? 9);
329
+ if (s !== 0)
330
+ return s;
331
+ return SEVERITY_ORDER[b.finding.severity] - SEVERITY_ORDER[a.finding.severity];
332
+ });
333
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Minimal git helpers for the workflow-integration features (doc 12). Used by
3
+ * the `--staged` pre-commit guard and `init`. All calls are read-only and fail
4
+ * soft: if git isn't present or this isn't a repo, callers fall back gracefully.
5
+ */
6
+ import { execFileSync } from "node:child_process";
7
+ import { promises as fs } from "node:fs";
8
+ import path from "node:path";
9
+ function git(root, args) {
10
+ try {
11
+ return execFileSync("git", args, {
12
+ cwd: root,
13
+ encoding: "utf8",
14
+ stdio: ["ignore", "pipe", "ignore"],
15
+ });
16
+ }
17
+ catch {
18
+ return null;
19
+ }
20
+ }
21
+ /** True if `root` is inside a git working tree. */
22
+ export function isGitRepo(root) {
23
+ const out = git(root, ["rev-parse", "--is-inside-work-tree"]);
24
+ return out?.trim() === "true";
25
+ }
26
+ /** Absolute path to the repo's .git directory, or null. */
27
+ export function gitDir(root) {
28
+ const out = git(root, ["rev-parse", "--git-dir"]);
29
+ if (!out)
30
+ return null;
31
+ const dir = out.trim();
32
+ return path.isAbsolute(dir) ? dir : path.join(root, dir);
33
+ }
34
+ /**
35
+ * Staged files (added/copied/modified) as absolute paths, filtered to those
36
+ * that exist on disk. Returns null if not a git repo / git unavailable.
37
+ */
38
+ export async function listStagedFiles(root) {
39
+ if (!isGitRepo(root))
40
+ return null;
41
+ const out = git(root, ["diff", "--cached", "--name-only", "--diff-filter=ACM", "-z"]);
42
+ if (out == null)
43
+ return null;
44
+ const rels = out.split("\0").filter((s) => s.length > 0);
45
+ const abs = [];
46
+ for (const rel of rels) {
47
+ const p = path.join(root, rel);
48
+ try {
49
+ const st = await fs.stat(p);
50
+ if (st.isFile())
51
+ abs.push(p);
52
+ }
53
+ catch {
54
+ /* staged-but-removed or unreadable — skip */
55
+ }
56
+ }
57
+ return abs;
58
+ }
@@ -0,0 +1,161 @@
1
+ /**
2
+ * `securevibe init` (doc 12) — make SecureVibe always-on in a project with one
3
+ * command. Installs a git pre-commit guard (blocks commits that introduce a
4
+ * BLOCK-level issue, including committed secrets), a GitHub Actions workflow
5
+ * (PR scan + SARIF upload to code scanning), gitignores `.env` and backups, and
6
+ * drops a minimal config. Idempotent: existing files are left alone unless
7
+ * `--force`. Everything runs locally — no account, no upload, no API key.
8
+ */
9
+ import { promises as fs } from "node:fs";
10
+ import path from "node:path";
11
+ import { isGitRepo, gitDir } from "./git.js";
12
+ const GITIGNORE_LINES = [".env", ".env.local", ".env.*.local", ".securevibe-backup/"];
13
+ const CONFIG = `{
14
+ "failOn": "block",
15
+ "ignore": []
16
+ }
17
+ `;
18
+ const PRE_COMMIT_HOOK = `#!/bin/sh
19
+ # SecureVibe pre-commit guard — blocks commits that introduce a blocking security
20
+ # issue (incl. committed secrets). Managed by \`securevibe init\`. Delete to remove.
21
+ if command -v securevibe >/dev/null 2>&1; then
22
+ RUN="securevibe"
23
+ elif [ -x "node_modules/.bin/securevibe" ]; then
24
+ RUN="node_modules/.bin/securevibe"
25
+ else
26
+ echo "securevibe not found — skipping guard (install: npm i -D securevibe)"
27
+ exit 0
28
+ fi
29
+ "$RUN" scan --staged --ci
30
+ if [ "$?" -eq 2 ]; then
31
+ echo ""
32
+ echo "✗ SecureVibe blocked this commit: a blocking issue is in your staged changes."
33
+ echo " Review above, then run \\\`securevibe fix --apply\\\` and re-stage, or fix by hand."
34
+ exit 1
35
+ fi
36
+ exit 0
37
+ `;
38
+ const WORKFLOW = `# SecureVibe — scan every PR and upload findings to GitHub code scanning.
39
+ # Managed by \`securevibe init\`.
40
+ name: SecureVibe
41
+ on:
42
+ pull_request:
43
+ push:
44
+ branches: [main, master]
45
+ permissions:
46
+ contents: read
47
+ security-events: write
48
+ jobs:
49
+ securevibe:
50
+ runs-on: ubuntu-latest
51
+ steps:
52
+ - uses: actions/checkout@v4
53
+ - uses: actions/setup-node@v4
54
+ with:
55
+ node-version: 20
56
+ - name: Install SecureVibe
57
+ run: npm install -g securevibe
58
+ - name: Scan (SARIF)
59
+ run: securevibe scan . --sarif > securevibe.sarif
60
+ - name: Upload SARIF
61
+ if: always()
62
+ uses: github/codeql-action/upload-sarif@v3
63
+ with:
64
+ sarif_file: securevibe.sarif
65
+ - name: Gate the build on deployment readiness
66
+ run: securevibe scan . --ci
67
+ `;
68
+ export async function runInit(root, opts = {}) {
69
+ const absRoot = path.resolve(root);
70
+ const repo = isGitRepo(absRoot);
71
+ const actions = [];
72
+ // 1) .gitignore — append any missing protective entries (always safe).
73
+ actions.push(await ensureGitignore(absRoot));
74
+ // 2) securevibe.config.json — write if absent.
75
+ actions.push(await writeIfAbsent(absRoot, "securevibe.config.json", CONFIG, opts.force, "scan policy"));
76
+ // 3) Pre-commit hook — only meaningful in a git repo.
77
+ if (repo) {
78
+ actions.push(await installHook(absRoot, opts.force));
79
+ }
80
+ else {
81
+ actions.push({
82
+ status: "skipped",
83
+ target: ".git/hooks/pre-commit",
84
+ note: "not a git repo — run `git init` then `securevibe init` again to install the guard",
85
+ });
86
+ }
87
+ // 4) GitHub Actions workflow.
88
+ actions.push(await writeIfAbsent(absRoot, ".github/workflows/securevibe.yml", WORKFLOW, opts.force, "PR scan + SARIF upload"));
89
+ return { root: absRoot, isGitRepo: repo, actions };
90
+ }
91
+ async function ensureGitignore(root) {
92
+ const target = ".gitignore";
93
+ const dest = path.join(root, target);
94
+ let existing = "";
95
+ let existed = false;
96
+ try {
97
+ existing = await fs.readFile(dest, "utf8");
98
+ existed = true;
99
+ }
100
+ catch {
101
+ /* will create */
102
+ }
103
+ const present = new Set(existing.split(/\r?\n/).map((l) => l.trim()));
104
+ const missing = GITIGNORE_LINES.filter((l) => !present.has(l));
105
+ if (missing.length === 0) {
106
+ return { status: "skipped", target, note: "already ignores .env and backups" };
107
+ }
108
+ const header = existed ? "" : "# Managed by SecureVibe\n";
109
+ const sep = existing.length > 0 && !existing.endsWith("\n") ? "\n" : "";
110
+ await fs.writeFile(dest, existing + sep + header + missing.join("\n") + "\n", "utf8");
111
+ return {
112
+ status: existed ? "updated" : "created",
113
+ target,
114
+ note: `ignore ${missing.join(", ")}`,
115
+ };
116
+ }
117
+ async function installHook(root, force) {
118
+ const gd = gitDir(root);
119
+ const target = ".git/hooks/pre-commit";
120
+ if (!gd)
121
+ return { status: "skipped", target, note: "could not resolve .git directory" };
122
+ const hooksDir = path.join(gd, "hooks");
123
+ const dest = path.join(hooksDir, "pre-commit");
124
+ let exists = false;
125
+ try {
126
+ await fs.access(dest);
127
+ exists = true;
128
+ }
129
+ catch {
130
+ /* absent */
131
+ }
132
+ if (exists && !force) {
133
+ return { status: "skipped", target, note: "hook exists — re-run with --force to replace" };
134
+ }
135
+ await fs.mkdir(hooksDir, { recursive: true });
136
+ await fs.writeFile(dest, PRE_COMMIT_HOOK, "utf8");
137
+ try {
138
+ await fs.chmod(dest, 0o755);
139
+ }
140
+ catch {
141
+ /* Windows: exec bit is a no-op; Git for Windows runs the hook via sh anyway */
142
+ }
143
+ return { status: exists ? "updated" : "created", target, note: "blocks commits with blocking issues" };
144
+ }
145
+ async function writeIfAbsent(root, rel, content, force, note) {
146
+ const dest = path.join(root, ...rel.split("/"));
147
+ let exists = false;
148
+ try {
149
+ await fs.access(dest);
150
+ exists = true;
151
+ }
152
+ catch {
153
+ /* absent */
154
+ }
155
+ if (exists && !force) {
156
+ return { status: "skipped", target: rel, note: "exists — re-run with --force to replace" };
157
+ }
158
+ await fs.mkdir(path.dirname(dest), { recursive: true });
159
+ await fs.writeFile(dest, content, "utf8");
160
+ return { status: exists ? "updated" : "created", target: rel, note };
161
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * tree-sitter parsing layer (doc 02). Loads WASM grammars lazily and parses
3
+ * source into an AST. JS/TS/TSX + Python today; new grammars are additive.
4
+ */
5
+ import { Parser, Language } from "web-tree-sitter";
6
+ import { createRequire } from "node:module";
7
+ const require = createRequire(import.meta.url);
8
+ const GRAMMAR_WASM = {
9
+ javascript: "tree-sitter-wasms/out/tree-sitter-javascript.wasm",
10
+ typescript: "tree-sitter-wasms/out/tree-sitter-typescript.wasm",
11
+ tsx: "tree-sitter-wasms/out/tree-sitter-tsx.wasm",
12
+ python: "tree-sitter-wasms/out/tree-sitter-python.wasm",
13
+ };
14
+ let initialized = false;
15
+ const languageCache = new Map();
16
+ async function ensureInit() {
17
+ if (!initialized) {
18
+ await Parser.init();
19
+ initialized = true;
20
+ }
21
+ }
22
+ async function loadLanguage(lang) {
23
+ const cached = languageCache.get(lang);
24
+ if (cached)
25
+ return cached;
26
+ const wasmPath = require.resolve(GRAMMAR_WASM[lang]);
27
+ const language = await Language.load(wasmPath);
28
+ languageCache.set(lang, language);
29
+ return language;
30
+ }
31
+ /** Parse a source string for the given language into an AST. */
32
+ export async function parse(code, lang) {
33
+ await ensureInit();
34
+ const language = await loadLanguage(lang);
35
+ const parser = new Parser();
36
+ parser.setLanguage(language);
37
+ const tree = parser.parse(code);
38
+ if (!tree)
39
+ throw new Error(`failed to parse ${lang} source`);
40
+ return { root: tree.rootNode };
41
+ }
42
+ /** Map a file extension to a parseable language, or null if unsupported. */
43
+ export function langForExtension(ext) {
44
+ switch (ext.toLowerCase()) {
45
+ case ".js":
46
+ case ".jsx":
47
+ case ".mjs":
48
+ case ".cjs":
49
+ return "javascript";
50
+ case ".ts":
51
+ case ".mts":
52
+ case ".cts":
53
+ return "typescript";
54
+ case ".tsx":
55
+ return "tsx";
56
+ case ".py":
57
+ return "python";
58
+ default:
59
+ return null;
60
+ }
61
+ }