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,118 @@
1
+ /**
2
+ * Discovery: walk the worktree, filter out unwanted directories, classify files
3
+ * by language and return a deterministic file order.
4
+ */
5
+
6
+ import { spawnSync } from "node:child_process";
7
+ import * as fs from "node:fs";
8
+ import * as path from "node:path";
9
+ import { isIgnoredDir } from "./paths.ts";
10
+ import type { Language } from "./types.ts";
11
+
12
+ export interface DiscoveredFile {
13
+ fileDir: string;
14
+ fileName: string;
15
+ absolutePath: string;
16
+ language: Language;
17
+ }
18
+
19
+ const LANGUAGE_BY_EXT: Record<string, Language> = {
20
+ ".js": "javascript",
21
+ ".mjs": "javascript",
22
+ ".cjs": "javascript",
23
+ ".ts": "typescript",
24
+ ".tsx": "tsx",
25
+ ".py": "python",
26
+ };
27
+
28
+ function classify(filename: string): Language | null {
29
+ const ext = path.extname(filename).toLowerCase();
30
+ return LANGUAGE_BY_EXT[ext] ?? null;
31
+ }
32
+
33
+ /** Whether a relative POSIX path would be indexed (used by update-graph/drift path filtering). */
34
+ export function isIndexablePath(relativePath: string): boolean {
35
+ const base = relativePath.split("/").pop() ?? relativePath;
36
+ return classify(base) !== null && !hasIgnoredDirectory(relativePath);
37
+ }
38
+
39
+ function hasIgnoredDirectory(relativePath: string): boolean {
40
+ const segments = relativePath.split(/[\\/]+/).filter(Boolean);
41
+ return segments.slice(0, -1).some((segment) => isIgnoredDir(segment));
42
+ }
43
+
44
+ function runGitLsFiles(cwd: string): string[] | null {
45
+ const env: Record<string, string | undefined> = { ...process.env };
46
+ for (const key of ["GIT_DIR", "GIT_COMMON_DIR", "GIT_WORK_TREE"]) delete env[key];
47
+ const result = spawnSync("git", ["ls-files", "-z", "--others", "--exclude-standard"], {
48
+ cwd,
49
+ env,
50
+ encoding: "utf8",
51
+ });
52
+ if (result.status !== 0) return null;
53
+ const raw = result.stdout ?? "";
54
+ const files = raw.split("\0").filter((entry) => entry.length > 0);
55
+ if (!files.length) return null;
56
+ return files;
57
+ }
58
+
59
+ function walkFs(root: string, onFile: (abs: string) => void): void {
60
+ const stack = ["."];
61
+ while (stack.length) {
62
+ const rel = stack.pop()!;
63
+ const abs = path.join(root, rel);
64
+ let stat;
65
+ try {
66
+ stat = fs.lstatSync(abs);
67
+ } catch {
68
+ continue;
69
+ }
70
+ if (stat.isSymbolicLink()) continue;
71
+ if (stat.isDirectory()) {
72
+ const name = path.basename(abs);
73
+ if (rel !== "." && isIgnoredDir(name)) continue;
74
+ for (const entry of fs.readdirSync(abs)) stack.push(path.join(rel, entry));
75
+ continue;
76
+ }
77
+ if (stat.isFile()) onFile(abs);
78
+ }
79
+ }
80
+
81
+ export interface DiscoverOptions {
82
+ worktreeRoot: string;
83
+ }
84
+
85
+ export function discoverFiles(options: DiscoverOptions): DiscoveredFile[] {
86
+ const gitFiles = runGitLsFiles(options.worktreeRoot);
87
+ const out = new Map<string, DiscoveredFile>();
88
+ const register = (abs: string) => {
89
+ if (!abs.startsWith(options.worktreeRoot + path.sep) && abs !== options.worktreeRoot) return;
90
+ const rel = path.relative(options.worktreeRoot, abs);
91
+ if (hasIgnoredDirectory(rel)) return;
92
+ const lang = classify(path.basename(rel));
93
+ if (!lang) return;
94
+ const posix = rel.split(path.sep).join("/");
95
+ const parts = posix.split("/");
96
+ const fileName = parts[parts.length - 1];
97
+ const fileDir = parts.length === 1 ? "." : parts.slice(0, -1).join("/");
98
+ out.set(posix, {
99
+ fileDir,
100
+ fileName,
101
+ absolutePath: abs,
102
+ language: lang,
103
+ });
104
+ };
105
+ if (gitFiles && gitFiles.length > 0) {
106
+ for (const rel of gitFiles) register(path.resolve(options.worktreeRoot, rel));
107
+ }
108
+ walkFs(options.worktreeRoot, (abs) => {
109
+ if (!out.has(path.relative(options.worktreeRoot, abs).split(path.sep).join("/"))) {
110
+ register(abs);
111
+ }
112
+ });
113
+ return [...out.values()].sort((a, b) => {
114
+ const aKey = `${a.fileDir}/${a.fileName}`;
115
+ const bKey = `${b.fileDir}/${b.fileName}`;
116
+ return aKey.localeCompare(bKey);
117
+ });
118
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Git porcelain helpers for the code-graph loop. All calls go through
3
+ * spawnSync with scrubbed environment (mirrors paths.ts) and never introduce
4
+ * new dependencies.
5
+ */
6
+
7
+ import { spawnSync } from "node:child_process";
8
+
9
+ function runGit(cwd: string, args: string[]): { code: number; stdout: string; stderr: string } {
10
+ const env: Record<string, string | undefined> = { ...process.env };
11
+ for (const key of ["GIT_DIR", "GIT_COMMON_DIR", "GIT_WORK_TREE"]) delete env[key];
12
+ // -c core.quotepath=false keeps non-ASCII paths literal instead of octal-escaped.
13
+ const result = spawnSync("git", ["-c", "core.quotepath=false", ...args], { cwd, env, encoding: "utf8" });
14
+ return { code: result.status ?? 1, stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
15
+ }
16
+
17
+ export class GitError extends Error {}
18
+
19
+ function must(cwd: string, args: string[], what: string): string {
20
+ const result = runGit(cwd, args);
21
+ if (result.code !== 0) {
22
+ throw new GitError(`${what} failed: ${result.stderr.trim() || result.stdout.trim() || `exit ${result.code}`}`);
23
+ }
24
+ return result.stdout;
25
+ }
26
+
27
+ /** Current HEAD commit SHA (empty string when no commits exist). */
28
+ export function gitHead(cwd: string): string {
29
+ const result = runGit(cwd, ["rev-parse", "HEAD"]);
30
+ if (result.code !== 0) return "";
31
+ return result.stdout.trim();
32
+ }
33
+
34
+ export interface PorcelainEntry {
35
+ /** Single-letter + optional sub-status code, e.g. "M", " M", "A", "??", "R ". */
36
+ status: string;
37
+ /** Path for normal entries; NEW path for rename/copy entries. */
38
+ path: string;
39
+ /** Original path for rename (R) / copy (C) entries; null otherwise. */
40
+ origPath: string | null;
41
+ }
42
+
43
+ /** Parse `git status --porcelain` output, including rename (R old -> new). */
44
+ export function parsePorcelain(stdout: string): PorcelainEntry[] {
45
+ const entries: PorcelainEntry[] = [];
46
+ for (const line of stdout.split("\n")) {
47
+ if (!line.trim()) continue;
48
+ const status = line.slice(0, 2);
49
+ const rest = line.slice(3);
50
+ if (rest.startsWith('"') && rest.endsWith('"')) {
51
+ // Quoted path with possible embedded quotes; renames use "old" -> "new".
52
+ const inner = rest.slice(1, -1);
53
+ const arrow = inner.indexOf('" -> "');
54
+ if ((status[0] === "R" || status[0] === "C") && arrow >= 0) {
55
+ entries.push({ status, path: inner.slice(arrow + 6), origPath: inner.slice(0, arrow) });
56
+ } else {
57
+ entries.push({ status, path: inner, origPath: null });
58
+ }
59
+ continue;
60
+ }
61
+ const arrow = rest.indexOf(" -> ");
62
+ if ((status[0] === "R" || status[0] === "C") && arrow >= 0) {
63
+ entries.push({ status, path: rest.slice(arrow + 4), origPath: rest.slice(0, arrow) });
64
+ } else {
65
+ entries.push({ status, path: rest, origPath: null });
66
+ }
67
+ }
68
+ return entries;
69
+ }
70
+
71
+ /** `git status --porcelain` parsed; includes untracked (`??`) and rename entries. */
72
+ export function gitStatusPorcelain(cwd: string): PorcelainEntry[] {
73
+ return parsePorcelain(must(cwd, ["status", "--porcelain"], "git status"));
74
+ }
75
+
76
+ /** `git diff --name-only` against HEAD (or --base commit); excludes untracked. */
77
+ export function gitDiffNameOnly(cwd: string, base?: string): string[] {
78
+ const args = base ? ["diff", "--name-only", base] : ["diff", "--name-only", "HEAD"];
79
+ const stdout = must(cwd, args, "git diff --name-only");
80
+ return stdout.split("\n").map((line) => line.trim()).filter(Boolean);
81
+ }
82
+
83
+ /** Stage everything and commit; returns the new HEAD (or "" when nothing to commit). */
84
+ export function gitAddAllAndCommit(cwd: string, message: string): string {
85
+ const status = gitStatusPorcelain(cwd);
86
+ if (status.length === 0) return "";
87
+ must(cwd, ["add", "-A"], "git add -A");
88
+ must(cwd, ["commit", "-m", message], "git commit");
89
+ return gitHead(cwd);
90
+ }
91
+
92
+ /** Parse "--flag" booleans and "--key value" pairs from a slash-command arg string. */
93
+ export function parseCommandArgs(args: string): { flags: Set<string>; values: Map<string, string> } {
94
+ const flags = new Set<string>();
95
+ const values = new Map<string, string>();
96
+ const tokens = args.split(/\s+/).filter(Boolean);
97
+ for (let i = 0; i < tokens.length; i++) {
98
+ const token = tokens[i]!;
99
+ if (!token.startsWith("--")) continue;
100
+ if (i + 1 < tokens.length && !tokens[i + 1]!.startsWith("--")) {
101
+ values.set(token.slice(2), tokens[i + 1]!);
102
+ i++;
103
+ } else {
104
+ flags.add(token.slice(2));
105
+ }
106
+ }
107
+ return { flags, values };
108
+ }
@@ -0,0 +1,59 @@
1
+ /** Deterministic function identity normalization for a parsed file. */
2
+
3
+ import type { ParsedFile } from "./parser.ts";
4
+ import type { FunctionRecord, RenderUnit } from "./types.ts";
5
+
6
+ function spanKey(startByte: number, endByte: number): string {
7
+ return `${startByte}:${endByte}`;
8
+ }
9
+
10
+ function finalNames(functions: FunctionRecord[]): Map<string, string> {
11
+ const counts = new Map<string, number>();
12
+ const names = new Map<string, string>();
13
+ for (const fn of functions) {
14
+ const ordinal = (counts.get(fn.functionName) ?? 0) + 1;
15
+ counts.set(fn.functionName, ordinal);
16
+ const name = ordinal === 1 ? fn.functionName : `${fn.functionName}#${ordinal}`;
17
+ names.set(spanKey(fn.provenance.startByte, fn.provenance.endByte), name);
18
+ }
19
+ return names;
20
+ }
21
+
22
+ function renameUnits(units: RenderUnit[], names: Map<string, string>): RenderUnit[] {
23
+ return units.map((unit) => ({
24
+ ...unit,
25
+ label: names.get(spanKey(unit.startByte, unit.endByte)) ?? unit.label,
26
+ children: unit.children ? renameUnits(unit.children, names) : unit.children,
27
+ }));
28
+ }
29
+
30
+ /**
31
+ * Normalize parser output before calls are resolved or rows are written. The
32
+ * parser order is structural and deterministic; provenance is only used to
33
+ * associate an existing render unit with its function record.
34
+ */
35
+ export function normalizeFunctionIdentities(parsed: ParsedFile): ParsedFile {
36
+ const names = finalNames(parsed.functions);
37
+ const functions = parsed.functions.map((fn) => ({
38
+ ...fn,
39
+ functionName: names.get(spanKey(fn.provenance.startByte, fn.provenance.endByte)) ?? fn.functionName,
40
+ }));
41
+ return {
42
+ ...parsed,
43
+ functions,
44
+ renderUnits: renameUnits(parsed.renderUnits, names),
45
+ };
46
+ }
47
+
48
+ export function assertUniqueFunctionKeys(functions: FunctionRecord[], fileDir: string, fileName: string): void {
49
+ const seen = new Set<string>();
50
+ for (const fn of functions) {
51
+ const key = `${fileDir}\0${fileName}\0${fn.functionName}`;
52
+ if (seen.has(key)) {
53
+ throw new Error(
54
+ `duplicate normalized function identity for ${fileDir}/${fileName}/${fn.functionName} at ${fn.provenance.startByte}`,
55
+ );
56
+ }
57
+ seen.add(key);
58
+ }
59
+ }
@@ -0,0 +1,281 @@
1
+ /**
2
+ * Staged indexing pipeline: discovery → parse → resolve calls → write records
3
+ * inside a single SQLite write transaction. Each stage produces immutable
4
+ * snapshots so a parser crash cannot leave the DB in an inconsistent state.
5
+ */
6
+
7
+ import * as fs from "node:fs";
8
+ import * as path from "node:path";
9
+ import { discoverFiles, type DiscoveredFile } from "./discovery.ts";
10
+ import { Store } from "./store.ts";
11
+ import type { ParserBackend } from "./parser.ts";
12
+ import { hashText } from "./parser.ts";
13
+ import type { Language } from "./types.ts";
14
+ import { resolveCalls, type CallSite } from "./resolver.ts";
15
+ import { assertUniqueFunctionKeys, normalizeFunctionIdentities } from "./identity.ts";
16
+
17
+ export interface IndexerOptions {
18
+ store: Store;
19
+ worktreeRoot: string;
20
+ parsers: Record<Language, ParserBackend>;
21
+ reindex?: boolean;
22
+ /** When provided, only index these POSIX "dir/name" paths; paths that no
23
+ * longer exist on disk are purged from the DB (deletions and rename-old). */
24
+ paths?: string[];
25
+ }
26
+
27
+ export interface IndexReport {
28
+ filesScanned: number;
29
+ functionsIndexed: number;
30
+ edgesResolved: number;
31
+ edgesUnresolved: number;
32
+ conflicts: number;
33
+ durationMs: number;
34
+ reindexedPaths: string[];
35
+ purgedPaths: string[];
36
+ }
37
+
38
+ export async function runIndex(opts: IndexerOptions): Promise<IndexReport> {
39
+ const started = Date.now();
40
+ const files = discoverFiles({ worktreeRoot: opts.worktreeRoot });
41
+ const pathFilter = opts.paths ? new Set(opts.paths) : null;
42
+ if (pathFilter) {
43
+ for (const requested of pathFilter) {
44
+ if (!files.some((file) => `${file.fileDir}/${file.fileName}` === requested)) {
45
+ // Requested path no longer exists on disk (deletion or rename-old):
46
+ // purge its DB rows inside the same transaction below.
47
+ files.push({
48
+ absolutePath: path.join(opts.worktreeRoot, requested),
49
+ fileDir: requested.slice(0, requested.lastIndexOf("/")) || ".",
50
+ fileName: requested.slice(requested.lastIndexOf("/") + 1),
51
+ language: "javascript",
52
+ }) as DiscoveredFile;
53
+ }
54
+ }
55
+ }
56
+ const staged: Array<{
57
+ file: DiscoveredFile;
58
+ parsed: ReturnType<ParserBackend["parse"]>;
59
+ calls: CallSite[];
60
+ sourceText: string;
61
+ sourceHash: string;
62
+ exists: boolean;
63
+ }> = [];
64
+ let preflightConflict = 0;
65
+ const existingByFile = new Map<string, { sourceHash: string }>();
66
+ for (const row of opts.store
67
+ .read(() => opts.store.db.prepare("SELECT file_dir, file_name, source_hash FROM files").all()) as Array<{
68
+ file_dir: string;
69
+ file_name: string;
70
+ source_hash: string;
71
+ }>) {
72
+ existingByFile.set(`${row.file_dir}/${row.file_name}`, { sourceHash: row.source_hash });
73
+ }
74
+ for (const file of files) {
75
+ const relativeKey = `${file.fileDir}/${file.fileName}`;
76
+ if (pathFilter && !pathFilter.has(relativeKey)) continue;
77
+ let sourceText: string;
78
+ let exists = true;
79
+ try {
80
+ sourceText = fs.readFileSync(file.absolutePath, "utf8");
81
+ } catch {
82
+ if (pathFilter) {
83
+ // Path was requested but is gone from disk: stage a purge.
84
+ staged.push({ file, parsed: { functions: [], renderUnits: [] } as ReturnType<ParserBackend["parse"]>, calls: [], sourceText: "", sourceHash: "", exists: false });
85
+ }
86
+ continue;
87
+ }
88
+ const sourceHash = hashText(sourceText);
89
+ const backend = opts.parsers[file.language];
90
+ if (!backend) continue;
91
+ let parsed = backend.parse(sourceText);
92
+ parsed = normalizeFunctionIdentities(parsed);
93
+ parsed.functions.forEach((fn) => {
94
+ fn.fileDir = file.fileDir;
95
+ fn.fileName = file.fileName;
96
+ });
97
+ assertUniqueFunctionKeys(parsed.functions, file.fileDir, file.fileName);
98
+ const calls: CallSite[] = [];
99
+ for (const fn of parsed.functions) {
100
+ const fromText = fn.fullCode;
101
+ resolveCalls(fn.functionName, fromText, file, calls);
102
+ }
103
+ staged.push({ file, parsed, calls, sourceText, sourceHash, exists: true });
104
+ }
105
+ const now = new Date().toISOString();
106
+ const report: IndexReport = {
107
+ filesScanned: staged.filter((entry) => entry.exists).length,
108
+ functionsIndexed: 0,
109
+ edgesResolved: 0,
110
+ edgesUnresolved: 0,
111
+ conflicts: 0,
112
+ durationMs: 0,
113
+ reindexedPaths: [],
114
+ purgedPaths: [],
115
+ };
116
+ try {
117
+ opts.store.tx(() => {
118
+ for (const { file, parsed, calls, sourceText, sourceHash, exists } of staged) {
119
+ if (!exists) {
120
+ opts.store
121
+ .prepare("delete_functions_purge", `DELETE FROM functions WHERE file_dir = ? AND file_name = ?`)
122
+ .run(file.fileDir, file.fileName);
123
+ opts.store
124
+ .prepare("delete_entries_purge", `DELETE FROM file_entries WHERE file_dir = ? AND file_name = ?`)
125
+ .run(file.fileDir, file.fileName);
126
+ opts.store
127
+ .prepare("delete_edges_purge", `DELETE FROM call_edges WHERE from_file_dir = ? AND from_file_name = ?`)
128
+ .run(file.fileDir, file.fileName);
129
+ opts.store
130
+ .prepare("delete_file_purge", `DELETE FROM files WHERE file_dir = ? AND file_name = ?`)
131
+ .run(file.fileDir, file.fileName);
132
+ report.purgedPaths.push(`${file.fileDir}/${file.fileName}`);
133
+ continue;
134
+ }
135
+ report.reindexedPaths.push(`${file.fileDir}/${file.fileName}`);
136
+ const stmt = opts.store.prepare(
137
+ "insert_file",
138
+ `INSERT INTO files (file_dir, file_name, language, source_hash, source_text, updated_at)
139
+ VALUES (?, ?, ?, ?, ?, ?)
140
+ ON CONFLICT(file_dir, file_name) DO UPDATE SET
141
+ language = excluded.language,
142
+ source_hash = excluded.source_hash,
143
+ source_text = excluded.source_text,
144
+ updated_at = excluded.updated_at`,
145
+ );
146
+ stmt.run(file.fileDir, file.fileName, file.language, sourceHash, sourceText, now);
147
+ const oldHash = existingByFile.get(`${file.fileDir}/${file.fileName}`)?.sourceHash;
148
+ if (opts.reindex && oldHash && oldHash !== sourceHash) {
149
+ opts.store
150
+ .prepare(
151
+ "insert_conflict",
152
+ `INSERT INTO reindex_conflicts (file_dir, file_name, kind, detail, recorded_at)
153
+ VALUES (?, ?, ?, ?, ?)`,
154
+ )
155
+ .run(file.fileDir, file.fileName, "external-change", `was ${oldHash}, now ${sourceHash}`, now);
156
+ report.conflicts++;
157
+ }
158
+ opts.store
159
+ .prepare("delete_functions", `DELETE FROM functions WHERE file_dir = ? AND file_name = ?`)
160
+ .run(file.fileDir, file.fileName);
161
+ opts.store
162
+ .prepare("delete_entries", `DELETE FROM file_entries WHERE file_dir = ? AND file_name = ?`)
163
+ .run(file.fileDir, file.fileName);
164
+ opts.store
165
+ .prepare("delete_edges", `DELETE FROM call_edges WHERE from_file_dir = ? AND from_file_name = ?`)
166
+ .run(file.fileDir, file.fileName);
167
+ const insertFn = opts.store.prepare(
168
+ "insert_function",
169
+ `INSERT INTO functions (
170
+ file_dir, file_name, function_name, language, kind,
171
+ full_code, full_code_hash, render_code, render_code_hash,
172
+ parent, container, move_supported, is_primary, overload_signatures,
173
+ provenance_start_byte, provenance_end_byte,
174
+ provenance_start_line, provenance_start_col,
175
+ provenance_end_line, provenance_end_col,
176
+ summary_description, summary_inputs, summary_outputs,
177
+ summary_status, summary_model, summary_schema_version,
178
+ summary_effective_effort, summary_error, summary_updated_at,
179
+ version
180
+ ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
181
+ );
182
+ for (const fn of parsed.functions) {
183
+ const summary = fn.summary;
184
+ insertFn.run(
185
+ fn.fileDir,
186
+ fn.fileName,
187
+ fn.functionName,
188
+ fn.language,
189
+ fn.kind,
190
+ fn.fullCode,
191
+ fn.fullCodeHash,
192
+ fn.renderCode,
193
+ fn.renderCodeHash,
194
+ fn.parent ?? null,
195
+ fn.container ?? null,
196
+ fn.moveSupported ? 1 : 0,
197
+ fn.isPrimary ? 1 : 0,
198
+ fn.overloadSignatures ? JSON.stringify(fn.overloadSignatures) : null,
199
+ fn.provenance.startByte,
200
+ fn.provenance.endByte,
201
+ fn.provenance.startLine,
202
+ fn.provenance.startColumn,
203
+ fn.provenance.endLine,
204
+ fn.provenance.endColumn,
205
+ summary?.description ?? null,
206
+ summary ? JSON.stringify(summary.inputs) : null,
207
+ summary ? JSON.stringify(summary.outputs) : null,
208
+ summary?.status ?? null,
209
+ summary?.model ?? null,
210
+ summary?.schemaVersion ?? null,
211
+ summary?.effectiveEffort ?? null,
212
+ summary?.errorMessage ?? null,
213
+ summary?.updatedAt ?? null,
214
+ fn.version,
215
+ );
216
+ report.functionsIndexed++;
217
+ }
218
+ let ordinal = 0;
219
+ const insertEntry = opts.store.prepare(
220
+ "insert_entry",
221
+ `INSERT INTO file_entries (file_dir, file_name, ordinal, kind, function_name, start_byte, end_byte, text)
222
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
223
+ );
224
+ for (const unit of parsed.renderUnits) {
225
+ insertEntry.run(
226
+ file.fileDir,
227
+ file.fileName,
228
+ ordinal,
229
+ unit.kind,
230
+ unit.label ?? null,
231
+ unit.startByte,
232
+ unit.endByte,
233
+ unit.text ?? "",
234
+ );
235
+ ordinal++;
236
+ }
237
+ const insertEdge = opts.store.prepare(
238
+ "insert_edge",
239
+ `INSERT INTO call_edges (
240
+ from_file_dir, from_file_name, from_function,
241
+ to_file_dir, to_file_name, to_function,
242
+ to_callee_text, kind, resolution, reason,
243
+ provenance_start_byte, provenance_end_byte,
244
+ provenance_start_line, provenance_start_col,
245
+ provenance_end_line, provenance_end_col
246
+ ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
247
+ );
248
+ for (const call of calls) {
249
+ insertEdge.run(
250
+ file.fileDir,
251
+ file.fileName,
252
+ call.fromFunction,
253
+ call.target?.fileDir ?? null,
254
+ call.target?.fileName ?? null,
255
+ call.target?.functionName ?? null,
256
+ call.calleeText,
257
+ call.kind,
258
+ call.resolution,
259
+ call.reason ?? null,
260
+ call.provenance.startByte,
261
+ call.provenance.endByte,
262
+ call.provenance.startLine,
263
+ call.provenance.startColumn,
264
+ call.provenance.endLine,
265
+ call.provenance.endColumn,
266
+ );
267
+ if (call.resolution === "resolved") report.edgesResolved++;
268
+ else report.edgesUnresolved++;
269
+ }
270
+ }
271
+ });
272
+ } catch (error) {
273
+ if (!opts.reindex && staged.length === 0) {
274
+ preflightConflict++;
275
+ }
276
+ throw error;
277
+ }
278
+ report.durationMs = Date.now() - started;
279
+ report.conflicts += preflightConflict;
280
+ return report;
281
+ }