pi-plans 0.2.0 → 0.3.1

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 (75) hide show
  1. package/README.md +90 -26
  2. package/agents/ref-analyst.md +18 -0
  3. package/index.ts +121 -9
  4. package/package.json +16 -1
  5. package/references/pi-planning-workflow.md +21 -6
  6. package/references/state-and-config.md +52 -5
  7. package/scripts/validate.ts +5 -0
  8. package/skills/plan-with-refs/SKILL.md +3 -3
  9. package/src/code-graph/commands.ts +483 -0
  10. package/src/code-graph/discovery.ts +118 -0
  11. package/src/code-graph/git.ts +108 -0
  12. package/src/code-graph/identity.ts +59 -0
  13. package/src/code-graph/indexer.ts +281 -0
  14. package/src/code-graph/materialize.ts +166 -0
  15. package/src/code-graph/mode.ts +28 -0
  16. package/src/code-graph/mutations.ts +160 -0
  17. package/src/code-graph/parser.ts +51 -0
  18. package/src/code-graph/parsers/javascript.ts +35 -0
  19. package/src/code-graph/parsers/python.ts +160 -0
  20. package/src/code-graph/parsers/tree-sitter.ts +316 -0
  21. package/src/code-graph/paths.ts +85 -0
  22. package/src/code-graph/prompts.ts +18 -0
  23. package/src/code-graph/resolver.ts +69 -0
  24. package/src/code-graph/runtime.ts +158 -0
  25. package/src/code-graph/schema.ts +135 -0
  26. package/src/code-graph/screening.ts +82 -0
  27. package/src/code-graph/store.ts +278 -0
  28. package/src/code-graph/summary.ts +435 -0
  29. package/src/code-graph/types.ts +163 -0
  30. package/src/compaction.ts +1125 -371
  31. package/src/config-command.ts +361 -0
  32. package/src/exec.ts +508 -693
  33. package/src/guard.ts +14 -1
  34. package/src/refine-prompts.ts +109 -0
  35. package/src/refine-ui-helpers.ts +71 -18
  36. package/src/refine-ui-state.ts +88 -22
  37. package/src/refine-ui.ts +210 -102
  38. package/src/state.ts +36 -7
  39. package/src/subagent.ts +164 -61
  40. package/src/termination-prompt.ts +22 -0
  41. package/tests/analyze-refs.test.ts +265 -0
  42. package/tests/ask-choice.test.ts +264 -0
  43. package/tests/autocomplete.test.ts +6 -1
  44. package/tests/code-graph-apply-action.test.ts +173 -0
  45. package/tests/code-graph-apply.test.ts +185 -0
  46. package/tests/code-graph-commands.test.ts +211 -0
  47. package/tests/code-graph-db.test.ts +166 -0
  48. package/tests/code-graph-discovery.test.ts +38 -0
  49. package/tests/code-graph-git.test.ts +94 -0
  50. package/tests/code-graph-index.test.ts +175 -0
  51. package/tests/code-graph-loop.e2e.test.ts +159 -0
  52. package/tests/code-graph-mutations.test.ts +117 -0
  53. package/tests/code-graph-parser.test.ts +85 -0
  54. package/tests/code-graph-rollback.test.ts +100 -0
  55. package/tests/code-graph-summary-batching.test.ts +518 -0
  56. package/tests/code-graph-summary.test.ts +148 -0
  57. package/tests/compaction.test.ts +371 -57
  58. package/tests/config-command.test.ts +263 -0
  59. package/tests/exec.test.ts +808 -241
  60. package/tests/fixtures/code-graph/sample.js +36 -0
  61. package/tests/fixtures/code-graph/sample.py +20 -0
  62. package/tests/fixtures/code-graph/sample.ts +15 -0
  63. package/tests/graph-aware-file-tools.test.ts +411 -0
  64. package/tests/guard.test.ts +27 -1
  65. package/tests/plans.test.ts +10 -0
  66. package/tests/refine-prompts.test.ts +101 -2
  67. package/tests/refine-ui.test.ts +371 -72
  68. package/tests/state.test.ts +32 -0
  69. package/tests/subagent.test.ts +48 -20
  70. package/tools/analyze-refs.ts +263 -0
  71. package/tools/ask-choice.ts +159 -11
  72. package/tools/code-graph.ts +277 -0
  73. package/tools/graph-aware-file-tools.ts +392 -0
  74. package/tools/plans.ts +97 -2
  75. package/tools/refine.ts +61 -15
@@ -0,0 +1,166 @@
1
+ /**
2
+ * DB-to-source materialization. `files.source_text` is the canonical content
3
+ * for every row. Files whose `pending_kind` is set (DB-first mutations) are
4
+ * applied with convergence semantics: 'update' writes/creates the file;
5
+ * 'delete' removes the disk file and purges its DB rows. Pending-NULL files
6
+ * keep the stale-hash guard and are never rewritten from manifest entries —
7
+ * render-unit texts are not a faithful mirror of the source (empty texts,
8
+ * overlapping spans), so reassembly from `file_entries` is unsafe.
9
+ */
10
+
11
+ import * as fs from "node:fs";
12
+ import * as path from "node:path";
13
+ import * as os from "node:os";
14
+ import { Store } from "./store.ts";
15
+
16
+ export interface MaterializeOptions {
17
+ store: Store;
18
+ worktreeRoot: string;
19
+ force?: boolean;
20
+ }
21
+
22
+ export interface MaterializeReport {
23
+ files: Array<{
24
+ fileDir: string;
25
+ fileName: string;
26
+ absolutePath: string;
27
+ status: "ok" | "stale" | "error" | "deleted" | "skipped-missing";
28
+ reason?: string;
29
+ }>;
30
+ }
31
+
32
+ function loadSourceText(store: Store, fileDir: string, fileName: string): { text: string; hash: string } | null {
33
+ const row = store
34
+ .read(() =>
35
+ store.db
36
+ .prepare(
37
+ `SELECT source_text, source_hash FROM files WHERE file_dir = ? AND file_name = ?`,
38
+ )
39
+ .get(fileDir, fileName),
40
+ ) as { source_text: string; source_hash: string } | undefined;
41
+ return row ? { text: row.source_text, hash: row.source_hash } : null;
42
+ }
43
+
44
+ function hashText(text: string): string {
45
+ let h = 0x811c9dc5;
46
+ for (let i = 0; i < text.length; i++) {
47
+ h ^= text.charCodeAt(i);
48
+ h = Math.imul(h, 0x01000193);
49
+ }
50
+ return (h >>> 0).toString(16).padStart(8, "0");
51
+ }
52
+
53
+ export function materializeFile(
54
+ store: Store,
55
+ worktreeRoot: string,
56
+ fileDir: string,
57
+ fileName: string,
58
+ opts: { force?: boolean } = {},
59
+ ): { status: "ok" | "stale" | "error" | "deleted" | "skipped-missing"; reason?: string; written?: string } {
60
+ const file = loadSourceText(store, fileDir, fileName);
61
+ if (!file) return { status: "error", reason: "file not indexed" };
62
+ const absolute = path.join(worktreeRoot, fileDir === "." ? fileName : path.join(fileDir, fileName));
63
+ const pendingKind = store.read(() =>
64
+ store.db.prepare(`SELECT pending_kind FROM files WHERE file_dir = ? AND file_name = ?`).get(fileDir, fileName),
65
+ ) as { pending_kind: string | null } | undefined;
66
+ const pending = pendingKind?.pending_kind ?? null;
67
+
68
+ if (pending === "delete") {
69
+ try {
70
+ if (fs.existsSync(absolute)) fs.rmSync(absolute);
71
+ } catch (error) {
72
+ return { status: "error", reason: (error as Error).message };
73
+ }
74
+ store.tx(() => {
75
+ store.db.prepare(`DELETE FROM file_entries WHERE file_dir = ? AND file_name = ?`).run(fileDir, fileName);
76
+ store.db.prepare(`DELETE FROM call_edges WHERE from_file_dir = ? AND from_file_name = ?`).run(fileDir, fileName);
77
+ store.db.prepare(`DELETE FROM functions WHERE file_dir = ? AND file_name = ?`).run(fileDir, fileName);
78
+ store.db.prepare(`DELETE FROM files WHERE file_dir = ? AND file_name = ?`).run(fileDir, fileName);
79
+ });
80
+ return { status: "deleted", written: absolute };
81
+ }
82
+
83
+ let currentText: string | null = null;
84
+ try {
85
+ currentText = fs.readFileSync(absolute, "utf8");
86
+ } catch {
87
+ currentText = null;
88
+ }
89
+ if (pending === "update") {
90
+ // DB-first convergence: files.source_text IS the canonical new content
91
+ // (mutations maintain it; the loop-end reindex rebuilds the manifest).
92
+ // Write (or create) regardless of the on-disk hash.
93
+ const write = writeAtomically(absolute, file.text);
94
+ if (write.error) return { status: "error", reason: write.error };
95
+ store.tx(() => {
96
+ store.db
97
+ .prepare(`UPDATE files SET pending_kind = NULL, updated_at = ? WHERE file_dir = ? AND file_name = ?`)
98
+ .run(new Date().toISOString(), fileDir, fileName);
99
+ });
100
+ return { status: "ok", written: absolute };
101
+ }
102
+ if (currentText === null) {
103
+ // Pending-NULL and missing on disk: never resurrect deletions.
104
+ return { status: "skipped-missing", reason: "file missing on disk and not pending; skipped (not resurrected)" };
105
+ }
106
+ const currentHash = hashText(currentText);
107
+ if (currentHash !== file.hash) {
108
+ if (!opts.force) {
109
+ return { status: "stale", reason: `current hash ${currentHash} != indexed hash ${file.hash}` };
110
+ }
111
+ // Force converge from the canonical DB content, not the manifest.
112
+ const write = writeAtomically(absolute, file.text);
113
+ if (write.error) return { status: "error", reason: write.error };
114
+ return { status: "ok", written: absolute };
115
+ }
116
+ // Disk already matches the canonical source_text: nothing to converge.
117
+ // Never rewrite pending-NULL files from manifest entries — render-unit
118
+ // texts are empty/overlapping for real indexes, so reassembly corrupts.
119
+ return { status: "ok" };
120
+ }
121
+
122
+ function writeAtomically(absolute: string, output: string): { error?: string } {
123
+ try {
124
+ fs.mkdirSync(path.dirname(absolute), { recursive: true });
125
+ const tmp = path.join(os.tmpdir(), `code-graph-apply-${process.pid}-${Date.now()}-${path.basename(absolute)}`);
126
+ fs.writeFileSync(tmp, output);
127
+ try {
128
+ fs.renameSync(tmp, absolute);
129
+ } catch (error) {
130
+ try {
131
+ fs.unlinkSync(tmp);
132
+ } catch {
133
+ /* ignore */
134
+ }
135
+ return { error: (error as Error).message };
136
+ }
137
+ return {};
138
+ } catch (error) {
139
+ return { error: (error as Error).message };
140
+ }
141
+ }
142
+
143
+ export function materialize(opts: MaterializeOptions): MaterializeReport {
144
+ const rows = opts.store
145
+ .read(() =>
146
+ opts.store.db
147
+ .prepare(
148
+ `SELECT file_dir, file_name FROM files ORDER BY file_dir, file_name`,
149
+ )
150
+ .all(),
151
+ ) as Array<{ file_dir: string; file_name: string }>;
152
+ const files: MaterializeReport["files"] = [];
153
+ for (const row of rows) {
154
+ const result = materializeFile(opts.store, opts.worktreeRoot, row.file_dir, row.file_name, {
155
+ force: opts.force === true,
156
+ });
157
+ files.push({
158
+ fileDir: row.file_dir,
159
+ fileName: row.file_name,
160
+ absolutePath: path.join(opts.worktreeRoot, row.file_dir === "." ? row.file_name : path.join(row.file_dir, row.file_name)),
161
+ status: result.status,
162
+ reason: result.reason,
163
+ });
164
+ }
165
+ return { files };
166
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Shared graph-enabled tri-state resolution. Both the graph-aware file-tool
3
+ * wrappers (tools/) and the execution prompt injection (src/exec.ts) must read
4
+ * the SAME state through this helper, so the injected guidance and the actual
5
+ * tool behavior can never diverge.
6
+ *
7
+ * Three states, deliberately not a boolean:
8
+ * - "enabled" — graph_enabled === true: wrappers and graph reads active.
9
+ * - "off" — flag explicitly false/absent: native tools, no markers
10
+ * (the injected prompt already announces the disabled state).
11
+ * - "config-unavailable" — state root missing config or config.json unreadable:
12
+ * treated as an unexpected fallback and surfaced with a
13
+ * marker line (never silently collapsed into "off").
14
+ */
15
+
16
+ import { loadConfig, resolveStateRootOrNull } from "../state.ts";
17
+
18
+ export type GraphMode = "enabled" | "off" | "config-unavailable";
19
+
20
+ export function resolveGraphMode(workdir: string): GraphMode {
21
+ const stateRoot = resolveStateRootOrNull(workdir);
22
+ if (!stateRoot) return "off";
23
+ try {
24
+ return loadConfig(stateRoot).graph_enabled === true ? "enabled" : "off";
25
+ } catch {
26
+ return "config-unavailable";
27
+ }
28
+ }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * DB-first mutation core for the code_graph tool. Each mutation edits graph
3
+ * rows, marks the file pending_materialization, and appends to change_log.
4
+ * /apply-graph later converges the worktree to the DB state.
5
+ */
6
+
7
+ import { Store } from "./store.ts";
8
+ import { hashText } from "./parser.ts";
9
+ import type { Language } from "./types.ts";
10
+
11
+ export interface MutationResult {
12
+ ok: boolean;
13
+ reason?: string;
14
+ updated?: string;
15
+ pending?: "update" | "delete";
16
+ created?: boolean;
17
+ }
18
+
19
+ interface ManifestRow {
20
+ ordinal: number;
21
+ start_byte: number;
22
+ end_byte: number;
23
+ text: string;
24
+ function_name: string | null;
25
+ }
26
+
27
+ function loadManifest(store: Store, fileDir: string, fileName: string): ManifestRow[] {
28
+ return store.read(() =>
29
+ store.db
30
+ .prepare(
31
+ `SELECT ordinal, start_byte, end_byte, text, function_name FROM file_entries
32
+ WHERE file_dir = ? AND file_name = ? ORDER BY ordinal ASC`,
33
+ )
34
+ .all(fileDir, fileName),
35
+ ) as ManifestRow[];
36
+ }
37
+
38
+ export function updateFunction(
39
+ store: Store,
40
+ opts: { fileDir: string; fileName: string; functionName: string; fullCode: string },
41
+ ): MutationResult {
42
+ const fnRow = store.read(() =>
43
+ store.db
44
+ .prepare(
45
+ `SELECT provenance_start_byte, provenance_end_byte FROM functions
46
+ WHERE file_dir = ? AND file_name = ? AND function_name = ? LIMIT 1`,
47
+ )
48
+ .get(opts.fileDir, opts.fileName, opts.functionName),
49
+ ) as { provenance_start_byte: number; provenance_end_byte: number } | undefined;
50
+ if (!fnRow) return { ok: false, reason: "function not found" };
51
+
52
+ // Reads hoisted OUT of the tx: Store.read begins its own transaction.
53
+ const manifest = loadManifest(store, opts.fileDir, opts.fileName);
54
+ const fileRow = store.read(() =>
55
+ store.db.prepare(`SELECT source_text FROM files WHERE file_dir = ? AND file_name = ?`).get(opts.fileDir, opts.fileName),
56
+ ) as { source_text: string } | undefined;
57
+ const matched = manifest.filter((row) => row.function_name === opts.functionName);
58
+ if (!fileRow || matched.length === 0) {
59
+ return {
60
+ ok: false,
61
+ reason: !fileRow
62
+ ? "file row missing; run /update-graph first"
63
+ : "no manifest entry matched the function name (qualified/overload names like name#2 may differ); re-check via code_graph get-function",
64
+ };
65
+ }
66
+
67
+ const newHash = hashText(opts.fullCode);
68
+ const first = matched[0]!;
69
+ // Splice by the function's provenance byte range — the authoritative source
70
+ // of the callable's extent. Manifest entry texts from the current parser are
71
+ // empty, so reassembly-by-manifest cannot rebuild function bodies; the range
72
+ // splice is deterministic and reindex-independent.
73
+ const start = fnRow.provenance_start_byte;
74
+ const end = fnRow.provenance_end_byte;
75
+ const rebuilt = fileRow.source_text.slice(0, start) + opts.fullCode + fileRow.source_text.slice(end);
76
+ const delta = opts.fullCode.length - (end - start);
77
+ for (const row of manifest) {
78
+ if (row.ordinal === first.ordinal) row.text = opts.fullCode;
79
+ else if (row.start_byte >= end) {
80
+ // Persist the shift so later reassembly aligns; the loop-end reindex
81
+ // rebuilds entries authoritatively anyway.
82
+ row.start_byte += delta;
83
+ row.end_byte += delta;
84
+ }
85
+ }
86
+ store.tx(() => {
87
+ store.db
88
+ .prepare(
89
+ `UPDATE functions SET full_code = ?, full_code_hash = ?, render_code = ?, render_code_hash = ?
90
+ WHERE file_dir = ? AND file_name = ? AND function_name = ?`,
91
+ )
92
+ .run(opts.fullCode, newHash, opts.fullCode, newHash, opts.fileDir, opts.fileName, opts.functionName);
93
+ for (const row of manifest) {
94
+ store.db
95
+ .prepare(`UPDATE file_entries SET text = ?, start_byte = ?, end_byte = ? WHERE file_dir = ? AND file_name = ? AND ordinal = ?`)
96
+ .run(row.text, row.start_byte, row.ordinal === first.ordinal ? row.end_byte + delta : row.end_byte, opts.fileDir, opts.fileName, row.ordinal);
97
+ }
98
+ store.db
99
+ .prepare(`UPDATE files SET source_text = ?, source_hash = ?, pending_kind = 'update', updated_at = ? WHERE file_dir = ? AND file_name = ?`)
100
+ .run(rebuilt, hashText(rebuilt), new Date().toISOString(), opts.fileDir, opts.fileName);
101
+ store.db
102
+ .prepare(`INSERT INTO change_log (kind, detail, recorded_at) VALUES (?, ?, ?)`)
103
+ .run("update-function", `${opts.fileDir}/${opts.fileName}:${opts.functionName}`, new Date().toISOString());
104
+ });
105
+ return { ok: true, updated: `${opts.fileDir}/${opts.fileName}:${opts.functionName}`, pending: "update" };
106
+ }
107
+
108
+ export function updateFile(
109
+ store: Store,
110
+ opts: { fileDir: string; fileName: string; text: string; language?: Language },
111
+ ): MutationResult {
112
+ const exists = store.read(() =>
113
+ store.db.prepare(`SELECT 1 AS one FROM files WHERE file_dir = ? AND file_name = ?`).get(opts.fileDir, opts.fileName),
114
+ );
115
+ const now = new Date().toISOString();
116
+ store.tx(() => {
117
+ if (exists) {
118
+ // Whole-file replace: entries would be stale; drop them so the
119
+ // materializer falls back to source_text. The loop-end reindex rebuilds.
120
+ store.db.prepare(`DELETE FROM file_entries WHERE file_dir = ? AND file_name = ?`).run(opts.fileDir, opts.fileName);
121
+ store.db
122
+ .prepare(`UPDATE files SET source_text = ?, source_hash = ?, pending_kind = 'update', updated_at = ? WHERE file_dir = ? AND file_name = ?`)
123
+ .run(opts.text, hashText(opts.text), now, opts.fileDir, opts.fileName);
124
+ } else {
125
+ store.db
126
+ .prepare(
127
+ `INSERT INTO files (file_dir, file_name, language, source_hash, source_text, pending_kind, updated_at)
128
+ VALUES (?, ?, ?, ?, ?, 'update', ?)`,
129
+ )
130
+ .run(opts.fileDir, opts.fileName, opts.language ?? "javascript", hashText(opts.text), opts.text, now);
131
+ }
132
+ store.db
133
+ .prepare(`INSERT INTO change_log (kind, detail, recorded_at) VALUES (?, ?, ?)`)
134
+ .run("update-file", `${opts.fileDir}/${opts.fileName}`, now);
135
+ });
136
+ return { ok: true, updated: `${opts.fileDir}/${opts.fileName}`, pending: "update", created: !exists };
137
+ }
138
+
139
+ export function deleteFile(store: Store, opts: { fileDir: string; fileName: string }): MutationResult {
140
+ const exists = store.read(() =>
141
+ store.db.prepare(`SELECT 1 AS one FROM files WHERE file_dir = ? AND file_name = ?`).get(opts.fileDir, opts.fileName),
142
+ );
143
+ if (!exists) return { ok: false, reason: "file not indexed" };
144
+ store.tx(() => {
145
+ store.db.prepare(`UPDATE files SET pending_kind = 'delete' WHERE file_dir = ? AND file_name = ?`).run(opts.fileDir, opts.fileName);
146
+ store.db
147
+ .prepare(`INSERT INTO change_log (kind, detail, recorded_at) VALUES (?, ?, ?)`)
148
+ .run("delete-file", `${opts.fileDir}/${opts.fileName}`, new Date().toISOString());
149
+ });
150
+ return { ok: true, updated: `${opts.fileDir}/${opts.fileName}`, pending: "delete" };
151
+ }
152
+
153
+ export function listPending(store: Store): Array<{ path: string; kind: string }> {
154
+ const rows = store.read(() =>
155
+ store.db
156
+ .prepare(`SELECT file_dir, file_name, pending_kind FROM files WHERE pending_kind IS NOT NULL ORDER BY file_dir, file_name`)
157
+ .all(),
158
+ ) as Array<{ file_dir: string; file_name: string; pending_kind: string }>;
159
+ return rows.map((row) => ({ path: `${row.file_dir}/${row.file_name}`, kind: row.pending_kind }));
160
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Parser backend interface. Each backend returns a list of render units plus a
3
+ * list of (callable) function records. Callable spans do not overlap with
4
+ * each other; overlapping spans are flattened through the parent linkage.
5
+ */
6
+
7
+ import type {
8
+ FunctionRecord,
9
+ Language,
10
+ ParseDiagnostic,
11
+ RenderUnit,
12
+ SourceLocation,
13
+ } from "./types.ts";
14
+
15
+ export interface ParsedFile {
16
+ language: Language;
17
+ renderUnits: RenderUnit[];
18
+ functions: FunctionRecord[];
19
+ diagnostics: ParseDiagnostic[];
20
+ }
21
+
22
+ export interface ParserBackend {
23
+ readonly language: Language;
24
+ parse(source: Buffer | string): ParsedFile;
25
+ }
26
+
27
+ export interface ParserContext {
28
+ ParserCtor: new () => unknown;
29
+ grammar: unknown;
30
+ }
31
+
32
+ export function makeLocation(
33
+ startByte: number,
34
+ endByte: number,
35
+ startLine: number,
36
+ startColumn: number,
37
+ endLine: number,
38
+ endColumn: number,
39
+ ): SourceLocation {
40
+ return { startByte, endByte, startLine, startColumn, endLine, endColumn };
41
+ }
42
+
43
+ export function hashText(text: string): string {
44
+ // Simple non-cryptographic hash, fast and stable for fingerprinting.
45
+ let h = 0x811c9dc5;
46
+ for (let i = 0; i < text.length; i++) {
47
+ h ^= text.charCodeAt(i);
48
+ h = Math.imul(h, 0x01000193);
49
+ }
50
+ return (h >>> 0).toString(16).padStart(8, "0");
51
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * JavaScript / ECMAScript backend. Implements JS function declaration,
3
+ * expression, arrow, method, generator and async functions.
4
+ */
5
+
6
+ import { TreeSitterBackend } from "./tree-sitter.ts";
7
+ import type { FunctionRecord, Language } from "../types.ts";
8
+
9
+ export interface JavaScriptGrammar {
10
+ default?: unknown;
11
+ [key: string]: unknown;
12
+ }
13
+
14
+ export class JavaScriptBackend extends TreeSitterBackend {
15
+ readonly language: Language = "javascript";
16
+ }
17
+
18
+ export class TypeScriptBackend extends TreeSitterBackend {
19
+ readonly language: Language = "typescript";
20
+ }
21
+
22
+ export class TsxBackend extends TreeSitterBackend {
23
+ readonly language: Language = "tsx";
24
+ }
25
+
26
+ export function makeBackend(
27
+ language: "javascript" | "typescript" | "tsx",
28
+ ParserCtor: new () => unknown,
29
+ grammar: unknown,
30
+ ): TreeSitterBackend {
31
+ const opts = { ParserCtor: ParserCtor as never, language: grammar };
32
+ if (language === "javascript") return new JavaScriptBackend(opts);
33
+ if (language === "typescript") return new TypeScriptBackend(opts);
34
+ return new TsxBackend(opts);
35
+ }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Python backend. Recognizes `def`, `async def`, and `lambda` expressions,
3
+ * class methods, and decorators as parent-relative nested entries.
4
+ */
5
+
6
+ import type { ParserBackend, ParsedFile } from "../parser.ts";
7
+ import type { FunctionRecord, ParseDiagnostic, RenderUnit } from "../types.ts";
8
+ import { hashText, makeLocation } from "../parser.ts";
9
+
10
+ interface PythonNode {
11
+ type: string;
12
+ startIndex: number;
13
+ endIndex: number;
14
+ startPosition: { row: number; column: number };
15
+ endPosition: { row: number; column: number };
16
+ namedChildren: PythonNode[];
17
+ children: PythonNode[];
18
+ text?: string;
19
+ parent?: PythonNode;
20
+ isMissing?: boolean;
21
+ hasError?: boolean;
22
+ }
23
+
24
+ interface ParserLike {
25
+ parse(input: string | Buffer): { rootNode: PythonNode };
26
+ setLanguage(language: unknown): void;
27
+ }
28
+
29
+ export class PythonBackend implements ParserBackend {
30
+ readonly language = "python" as const;
31
+ private readonly ParserCtor: new () => ParserLike;
32
+ private readonly grammar: unknown;
33
+
34
+ constructor(ParserCtor: new () => unknown, grammar: unknown) {
35
+ this.ParserCtor = ParserCtor as never;
36
+ this.grammar = grammar;
37
+ }
38
+
39
+ parse(source: Buffer | string): ParsedFile {
40
+ const text = typeof source === "string" ? source : source.toString("utf8");
41
+ const buf = Buffer.from(text, "utf8");
42
+ const parser = new this.ParserCtor();
43
+ parser.setLanguage(this.grammar);
44
+ const tree = parser.parse(text);
45
+ const diagnostics: ParseDiagnostic[] = [];
46
+ const collectDiag = (node: PythonNode) => {
47
+ if (node.hasError || node.isMissing) {
48
+ diagnostics.push({
49
+ message: node.isMissing ? `missing ${node.type}` : "syntax error",
50
+ severity: node.isMissing ? "missing" : "error",
51
+ startByte: node.startIndex,
52
+ endByte: node.endIndex,
53
+ });
54
+ }
55
+ for (const child of node.namedChildren ?? []) collectDiag(child);
56
+ };
57
+ collectDiag(tree.rootNode);
58
+ const functions: FunctionRecord[] = [];
59
+ const renderUnits: RenderUnit[] = [
60
+ { kind: "raw", startByte: 0, endByte: buf.length, moveSupported: false },
61
+ ];
62
+ const anonymousOrdinals = new Map<string, number>();
63
+ const visit = (node: PythonNode, parentName: string | null) => {
64
+ if (node.type === "decorated_definition") {
65
+ for (const child of node.namedChildren ?? []) visit(child, parentName);
66
+ return;
67
+ }
68
+ if (node.type === "function_definition" || node.type === "lambda") {
69
+ const nameNode = (node.namedChildren ?? []).find(
70
+ (c) => c.type === "identifier" || c.type === "name",
71
+ );
72
+ let id = nameNode?.text;
73
+ if (!id) {
74
+ const scope = parentName ?? findClass(node) ?? "<module>";
75
+ const key = `${scope}\0${node.type}`;
76
+ const ordinal = (anonymousOrdinals.get(key) ?? 0) + 1;
77
+ anonymousOrdinals.set(key, ordinal);
78
+ id = `<anonymous:${node.type}#${ordinal}>`;
79
+ }
80
+ const qualified = parentName ? `${parentName}.${id}` : id;
81
+ const callableText = text.slice(node.startIndex, node.endIndex);
82
+ const container = findClass(node);
83
+ functions.push({
84
+ fileDir: "",
85
+ fileName: "",
86
+ functionName: qualified,
87
+ language: "python",
88
+ kind: node.type === "lambda" ? "lambda" : "declaration",
89
+ fullCode: callableText,
90
+ fullCodeHash: hashText(callableText),
91
+ renderCode: callableText,
92
+ renderCodeHash: hashText(callableText),
93
+ parent: parentName ?? undefined,
94
+ container,
95
+ moveSupported: node.type !== "lambda" && !!nameNode,
96
+ isPrimary: true,
97
+ provenance: locator(node),
98
+ summary: null,
99
+ version: 1,
100
+ });
101
+ renderUnits.push({
102
+ kind: node.type === "lambda" ? "lambda" : "function",
103
+ startByte: node.startIndex,
104
+ endByte: node.endIndex,
105
+ label: qualified,
106
+ moveSupported: node.type !== "lambda",
107
+ children: [],
108
+ });
109
+ return;
110
+ }
111
+ if (node.type === "class_definition") {
112
+ const classNameNode = (node.namedChildren ?? []).find(
113
+ (c) => c.type === "identifier" || c.type === "name",
114
+ );
115
+ const className = classNameNode?.text ?? "<anonymous>";
116
+ const children: RenderUnit[] = [];
117
+ for (const child of node.namedChildren ?? []) {
118
+ if (child.type === "block") {
119
+ for (const inner of child.namedChildren ?? []) visit(inner, className);
120
+ }
121
+ }
122
+ renderUnits.push({
123
+ kind: "raw",
124
+ startByte: node.startIndex,
125
+ endByte: node.endIndex,
126
+ label: className,
127
+ moveSupported: false,
128
+ children,
129
+ });
130
+ return;
131
+ }
132
+ for (const child of node.namedChildren ?? []) visit(child, parentName);
133
+ };
134
+ visit(tree.rootNode, null);
135
+ return { language: "python", renderUnits, functions, diagnostics };
136
+ }
137
+ }
138
+
139
+ function findClass(node: PythonNode): string | undefined {
140
+ let p: PythonNode | undefined = node.parent;
141
+ while (p) {
142
+ if (p.type === "class_definition") {
143
+ const nameNode = (p.namedChildren ?? []).find((c) => c.type === "identifier" || c.type === "name");
144
+ return nameNode?.text ?? "<anonymous>";
145
+ }
146
+ p = p.parent;
147
+ }
148
+ return undefined;
149
+ }
150
+
151
+ function locator(node: PythonNode) {
152
+ return makeLocation(
153
+ node.startIndex,
154
+ node.endIndex,
155
+ node.startPosition.row + 1,
156
+ node.startPosition.column,
157
+ node.endPosition.row + 1,
158
+ node.endPosition.column,
159
+ );
160
+ }