@flyingrobots/graft 0.3.5 → 0.5.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.
- package/ARCHITECTURE.md +386 -0
- package/CHANGELOG.md +69 -0
- package/CODE_OF_CONDUCT.md +65 -0
- package/README.md +153 -17
- package/bin/graft.js +4 -11
- package/docs/ADVANCED_GUIDE.md +49 -0
- package/docs/CLI.md +43 -0
- package/docs/GUIDE.md +321 -32
- package/docs/MCP.md +44 -0
- package/package.json +17 -4
- package/src/adapters/node-fs.ts +4 -0
- package/src/adapters/node-git.ts +47 -0
- package/src/adapters/node-process-runner.ts +27 -0
- package/src/cli/index-cmd.ts +86 -0
- package/src/cli/init.ts +808 -57
- package/src/cli/main.ts +437 -0
- package/src/contracts/capabilities.ts +341 -0
- package/src/contracts/causal-ontology.ts +622 -0
- package/src/contracts/causal-surface-next-action.ts +18 -0
- package/src/contracts/output-schemas.ts +1169 -0
- package/src/git/diff.ts +25 -21
- package/src/git/target-git-hook-bootstrap.ts +56 -0
- package/src/hooks/posttooluse-read.ts +21 -74
- package/src/hooks/pretooluse-read.ts +20 -56
- package/src/hooks/read-governor.ts +95 -0
- package/src/hooks/read-messages.ts +53 -0
- package/src/mcp/burden.ts +123 -0
- package/src/mcp/cache.ts +51 -0
- package/src/mcp/cached-file.ts +10 -8
- package/src/mcp/context.ts +67 -2
- package/src/mcp/daemon-control-plane.ts +554 -0
- package/src/mcp/daemon-job-scheduler.ts +279 -0
- package/src/mcp/daemon-repos.ts +216 -0
- package/src/mcp/daemon-server.ts +396 -0
- package/src/mcp/daemon-worker-pool.ts +310 -0
- package/src/mcp/daemon-worker-process.ts +52 -0
- package/src/mcp/metrics.ts +108 -1
- package/src/mcp/monitor-tick-job.ts +99 -0
- package/src/mcp/persisted-local-history.ts +1246 -0
- package/src/mcp/persistent-monitor-runtime.ts +549 -0
- package/src/mcp/policy.ts +84 -0
- package/src/mcp/receipt.ts +82 -12
- package/src/mcp/repo-concurrency.ts +318 -0
- package/src/mcp/repo-state.ts +777 -0
- package/src/mcp/repo-tool-job.ts +302 -0
- package/src/mcp/run-capture-config.ts +33 -0
- package/src/mcp/runtime-causal-context.ts +72 -0
- package/src/mcp/runtime-observability.ts +219 -0
- package/src/mcp/runtime-staged-target.ts +161 -0
- package/src/mcp/runtime-workspace-overlay.ts +255 -0
- package/src/mcp/semantic-transition-guidance.ts +60 -0
- package/src/mcp/semantic-transition-summary.ts +130 -0
- package/src/mcp/server.ts +704 -45
- package/src/mcp/stdio-server.ts +12 -0
- package/src/mcp/stdio.ts +2 -5
- package/src/mcp/tools/activity-view.ts +325 -0
- package/src/mcp/tools/causal-attach.ts +67 -0
- package/src/mcp/tools/causal-status.ts +58 -0
- package/src/mcp/tools/changed-since.ts +13 -11
- package/src/mcp/tools/code-find.ts +164 -0
- package/src/mcp/tools/code-refs.ts +466 -0
- package/src/mcp/tools/code-show.ts +252 -0
- package/src/mcp/tools/daemon-monitors.ts +14 -0
- package/src/mcp/tools/daemon-repos.ts +22 -0
- package/src/mcp/tools/daemon-sessions.ts +14 -0
- package/src/mcp/tools/daemon-status.ts +12 -0
- package/src/mcp/tools/doctor.ts +45 -2
- package/src/mcp/tools/explain.ts +4 -0
- package/src/mcp/tools/file-outline.ts +7 -3
- package/src/mcp/tools/git-files.ts +73 -0
- package/src/mcp/tools/graft-diff.ts +12 -4
- package/src/mcp/tools/map.ts +136 -0
- package/src/mcp/tools/monitor-pause.ts +18 -0
- package/src/mcp/tools/monitor-resume.ts +18 -0
- package/src/mcp/tools/monitor-start.ts +20 -0
- package/src/mcp/tools/monitor-stop.ts +18 -0
- package/src/mcp/tools/precision-match.ts +51 -0
- package/src/mcp/tools/precision-query.ts +127 -0
- package/src/mcp/tools/precision.ts +312 -0
- package/src/mcp/tools/run-capture.ts +126 -44
- package/src/mcp/tools/safe-read.ts +14 -12
- package/src/mcp/tools/since.ts +49 -0
- package/src/mcp/tools/state.ts +11 -3
- package/src/mcp/tools/stats.ts +5 -1
- package/src/mcp/tools/workspace-authorizations.ts +14 -0
- package/src/mcp/tools/workspace-authorize.ts +20 -0
- package/src/mcp/tools/workspace-bind.ts +25 -0
- package/src/mcp/tools/workspace-rebind.ts +25 -0
- package/src/mcp/tools/workspace-revoke.ts +18 -0
- package/src/mcp/tools/workspace-status.ts +12 -0
- package/src/mcp/warp-pool.ts +36 -0
- package/src/mcp/workspace-router.ts +984 -0
- package/src/operations/file-outline.ts +12 -2
- package/src/operations/graft-diff.ts +56 -10
- package/src/operations/safe-read.ts +27 -4
- package/src/operations/state.ts +6 -9
- package/src/parser/lang.ts +19 -3
- package/src/parser/outline.ts +191 -2
- package/src/parser/types.ts +9 -1
- package/src/policy/types.ts +4 -3
- package/src/ports/filesystem.ts +1 -0
- package/src/ports/git.ts +16 -0
- package/src/ports/process-runner.ts +22 -0
- package/src/release/security-gate.ts +102 -0
- package/src/session/tracker.ts +31 -0
- package/src/version.ts +3 -0
- package/src/warp/indexer.ts +513 -0
- package/src/warp/observers.ts +105 -0
- package/src/warp/open.ts +31 -0
- package/src/warp/plumbing.d.ts +15 -0
- package/src/warp/writer-id.ts +30 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
const severitySchema = z.enum([ "info", "low", "moderate", "high", "critical" ]);
|
|
4
|
+
|
|
5
|
+
const auditReportSchema = z.object({
|
|
6
|
+
metadata: z.object({
|
|
7
|
+
vulnerabilities: z.object({
|
|
8
|
+
info: z.number().int().nonnegative(),
|
|
9
|
+
low: z.number().int().nonnegative(),
|
|
10
|
+
moderate: z.number().int().nonnegative(),
|
|
11
|
+
high: z.number().int().nonnegative(),
|
|
12
|
+
critical: z.number().int().nonnegative(),
|
|
13
|
+
}),
|
|
14
|
+
}),
|
|
15
|
+
advisories: z.record(z.string(), z.object({
|
|
16
|
+
id: z.number().int().nonnegative(),
|
|
17
|
+
module_name: z.string(),
|
|
18
|
+
severity: severitySchema,
|
|
19
|
+
title: z.string(),
|
|
20
|
+
recommendation: z.string(),
|
|
21
|
+
github_advisory_id: z.string().nullable().optional(),
|
|
22
|
+
findings: z.array(z.object({
|
|
23
|
+
version: z.string(),
|
|
24
|
+
paths: z.array(z.string()),
|
|
25
|
+
})).optional(),
|
|
26
|
+
})),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
export interface SecurityGateViolation {
|
|
30
|
+
readonly id: number;
|
|
31
|
+
readonly moduleName: string;
|
|
32
|
+
readonly severity: "high" | "critical";
|
|
33
|
+
readonly title: string;
|
|
34
|
+
readonly advisoryId: string | null;
|
|
35
|
+
readonly recommendation: string;
|
|
36
|
+
readonly paths: readonly string[];
|
|
37
|
+
readonly versions: readonly string[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface SecurityGateResult {
|
|
41
|
+
readonly blocking: boolean;
|
|
42
|
+
readonly counts: {
|
|
43
|
+
readonly info: number;
|
|
44
|
+
readonly low: number;
|
|
45
|
+
readonly moderate: number;
|
|
46
|
+
readonly high: number;
|
|
47
|
+
readonly critical: number;
|
|
48
|
+
};
|
|
49
|
+
readonly blockingFindings: readonly SecurityGateViolation[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function evaluateSecurityGate(report: unknown): SecurityGateResult {
|
|
53
|
+
const parsed = auditReportSchema.parse(report);
|
|
54
|
+
const blockingFindings: SecurityGateViolation[] = [];
|
|
55
|
+
|
|
56
|
+
for (const advisory of Object.values(parsed.advisories)) {
|
|
57
|
+
if (advisory.severity !== "high" && advisory.severity !== "critical") {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
blockingFindings.push({
|
|
61
|
+
id: advisory.id,
|
|
62
|
+
moduleName: advisory.module_name,
|
|
63
|
+
severity: advisory.severity,
|
|
64
|
+
title: advisory.title,
|
|
65
|
+
advisoryId: advisory.github_advisory_id ?? null,
|
|
66
|
+
recommendation: advisory.recommendation,
|
|
67
|
+
paths: advisory.findings?.flatMap((finding) => finding.paths) ?? [],
|
|
68
|
+
versions: advisory.findings?.map((finding) => finding.version) ?? [],
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
blocking: blockingFindings.length > 0,
|
|
74
|
+
counts: parsed.metadata.vulnerabilities,
|
|
75
|
+
blockingFindings,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function formatSecurityGateResult(result: SecurityGateResult): string {
|
|
80
|
+
const lines = [
|
|
81
|
+
`audit summary: critical=${String(result.counts.critical)} high=${String(result.counts.high)} moderate=${String(result.counts.moderate)} low=${String(result.counts.low)} info=${String(result.counts.info)}`,
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
if (!result.blocking) {
|
|
85
|
+
lines.push("release security gate: pass");
|
|
86
|
+
return lines.join("\n");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
lines.push("release security gate: fail");
|
|
90
|
+
for (const finding of result.blockingFindings) {
|
|
91
|
+
const advisoryId = finding.advisoryId ?? `npm:${String(finding.id)}`;
|
|
92
|
+
const paths = finding.paths.length > 0 ? finding.paths.join(", ") : "unknown path";
|
|
93
|
+
const versions = finding.versions.length > 0 ? finding.versions.join(", ") : "unknown version";
|
|
94
|
+
lines.push(
|
|
95
|
+
`- ${finding.severity.toUpperCase()} ${advisoryId} ${finding.moduleName}@${versions} via ${paths}`,
|
|
96
|
+
);
|
|
97
|
+
lines.push(` ${finding.title}`);
|
|
98
|
+
lines.push(` ${finding.recommendation}`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return lines.join("\n");
|
|
102
|
+
}
|
package/src/session/tracker.ts
CHANGED
|
@@ -5,6 +5,15 @@ const EDIT_BASH_TOOLS = new Set(["Edit", "Bash"]);
|
|
|
5
5
|
const LATE_READ_BYTE_THRESHOLD = 20480;
|
|
6
6
|
const LATE_READ_MESSAGE_THRESHOLD = 300;
|
|
7
7
|
|
|
8
|
+
export interface SessionTrackerSnapshot {
|
|
9
|
+
readonly totalMessages: number;
|
|
10
|
+
readonly toolCallsSinceUser: number;
|
|
11
|
+
readonly editBashTransitions: number;
|
|
12
|
+
readonly lastEditBashTool: string | null;
|
|
13
|
+
readonly budgetBytes: number | null;
|
|
14
|
+
readonly consumedBytes: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
8
17
|
export class SessionTracker {
|
|
9
18
|
private totalMessages = 0;
|
|
10
19
|
private toolCallsSinceUser = 0;
|
|
@@ -13,6 +22,17 @@ export class SessionTracker {
|
|
|
13
22
|
private budgetBytes: number | null = null;
|
|
14
23
|
private consumedBytes = 0;
|
|
15
24
|
|
|
25
|
+
static fromSnapshot(snapshot: SessionTrackerSnapshot): SessionTracker {
|
|
26
|
+
const tracker = new SessionTracker();
|
|
27
|
+
tracker.totalMessages = snapshot.totalMessages;
|
|
28
|
+
tracker.toolCallsSinceUser = snapshot.toolCallsSinceUser;
|
|
29
|
+
tracker.editBashTransitions = snapshot.editBashTransitions;
|
|
30
|
+
tracker.lastEditBashTool = snapshot.lastEditBashTool;
|
|
31
|
+
tracker.budgetBytes = snapshot.budgetBytes;
|
|
32
|
+
tracker.consumedBytes = snapshot.consumedBytes;
|
|
33
|
+
return tracker;
|
|
34
|
+
}
|
|
35
|
+
|
|
16
36
|
getMessageCount(): number {
|
|
17
37
|
return this.totalMessages;
|
|
18
38
|
}
|
|
@@ -102,6 +122,17 @@ export class SessionTracker {
|
|
|
102
122
|
};
|
|
103
123
|
}
|
|
104
124
|
|
|
125
|
+
snapshot(): SessionTrackerSnapshot {
|
|
126
|
+
return {
|
|
127
|
+
totalMessages: this.totalMessages,
|
|
128
|
+
toolCallsSinceUser: this.toolCallsSinceUser,
|
|
129
|
+
editBashTransitions: this.editBashTransitions,
|
|
130
|
+
lastEditBashTool: this.lastEditBashTool,
|
|
131
|
+
budgetBytes: this.budgetBytes,
|
|
132
|
+
consumedBytes: this.consumedBytes,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
105
136
|
getSessionDepth(): SessionDepth {
|
|
106
137
|
if (this.totalMessages < 100) {
|
|
107
138
|
return "early";
|
package/src/version.ts
ADDED
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WARP Indexer — walks git history and writes structural delta
|
|
3
|
+
* patches into the WARP graph.
|
|
4
|
+
*
|
|
5
|
+
* Observer Law: this module WRITES facts. It never reads them back.
|
|
6
|
+
* Reading is done exclusively through observers (see observers.ts).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type WarpApp from "@git-stunts/git-warp";
|
|
10
|
+
import type { GitClient } from "../ports/git.js";
|
|
11
|
+
import { extractOutline } from "../parser/outline.js";
|
|
12
|
+
import { diffOutlines } from "../parser/diff.js";
|
|
13
|
+
import { detectLang } from "../parser/lang.js";
|
|
14
|
+
import { getFileAtRef } from "../git/diff.js";
|
|
15
|
+
import type { OutlineEntry, JumpEntry } from "../parser/types.js";
|
|
16
|
+
import type { DiffEntry } from "../parser/diff.js";
|
|
17
|
+
|
|
18
|
+
export interface IndexOptions {
|
|
19
|
+
readonly cwd: string;
|
|
20
|
+
readonly git: GitClient;
|
|
21
|
+
readonly from?: string;
|
|
22
|
+
readonly to?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface IndexResult {
|
|
26
|
+
readonly commitsIndexed: number;
|
|
27
|
+
readonly patchesWritten: number;
|
|
28
|
+
readonly commitTicks: ReadonlyMap<string, number>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Patch builder shape — matches PatchBuilderV2's fluent API.
|
|
32
|
+
interface PatchOps {
|
|
33
|
+
addNode(id: string): PatchOps;
|
|
34
|
+
removeNode(id: string): PatchOps;
|
|
35
|
+
setProperty(id: string, key: string, value: unknown): PatchOps;
|
|
36
|
+
addEdge(from: string, to: string, label: string): PatchOps;
|
|
37
|
+
removeEdge(from: string, to: string, label: string): PatchOps;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface PreparedChange {
|
|
41
|
+
readonly status: string;
|
|
42
|
+
readonly filePath: string;
|
|
43
|
+
readonly fileId: string;
|
|
44
|
+
readonly lang: string | null;
|
|
45
|
+
readonly parentExists: boolean;
|
|
46
|
+
readonly oldOutline: readonly OutlineEntry[];
|
|
47
|
+
readonly newOutline?: readonly OutlineEntry[] | undefined;
|
|
48
|
+
readonly jumpLookup?: Map<string, { start: number; end: number }> | undefined;
|
|
49
|
+
readonly diff?: ReturnType<typeof diffOutlines> | undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function git(gitClient: GitClient, cwd: string, args: readonly string[]): Promise<string> {
|
|
53
|
+
const result = await gitClient.run({ cwd, args });
|
|
54
|
+
if (result.error !== undefined || result.status !== 0) {
|
|
55
|
+
throw result.error ?? new Error(result.stderr.trim() || `git exited with status ${String(result.status)}`);
|
|
56
|
+
}
|
|
57
|
+
return result.stdout;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function listCommits(gitClient: GitClient, cwd: string, from?: string, to?: string): Promise<string[]> {
|
|
61
|
+
const range = from !== undefined ? `${from}..${to ?? "HEAD"}` : to ?? "HEAD";
|
|
62
|
+
const args = ["log", "--reverse", "--format=%H", range];
|
|
63
|
+
try {
|
|
64
|
+
return (await git(gitClient, cwd, args))
|
|
65
|
+
.trim().split("\n").filter((l) => l.length > 0);
|
|
66
|
+
} catch {
|
|
67
|
+
return [];
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function getCommitChanges(
|
|
72
|
+
gitClient: GitClient,
|
|
73
|
+
sha: string,
|
|
74
|
+
cwd: string,
|
|
75
|
+
): Promise<{ status: string; path: string }[]> {
|
|
76
|
+
// --root handles the initial commit (no parent to diff against)
|
|
77
|
+
const args = ["diff-tree", "--root", "--no-commit-id", "-r", "--name-status", sha];
|
|
78
|
+
try {
|
|
79
|
+
return (await git(gitClient, cwd, args))
|
|
80
|
+
.trim().split("\n").filter((l) => l.length > 0).map((line) => {
|
|
81
|
+
const parts = line.split("\t");
|
|
82
|
+
return { status: parts[0] ?? "", path: parts[1] ?? "" };
|
|
83
|
+
});
|
|
84
|
+
} catch {
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function getCommitMeta(
|
|
90
|
+
gitClient: GitClient,
|
|
91
|
+
sha: string,
|
|
92
|
+
cwd: string,
|
|
93
|
+
): Promise<{ message: string; author: string; email: string; timestamp: string }> {
|
|
94
|
+
try {
|
|
95
|
+
const output = await git(gitClient, cwd, ["log", "-1", "--format=%s%n%aN%n%aE%n%aI", sha]);
|
|
96
|
+
const lines = output.trim().split("\n");
|
|
97
|
+
return { message: lines[0] ?? "", author: lines[1] ?? "", email: lines[2] ?? "", timestamp: lines[3] ?? "" };
|
|
98
|
+
} catch {
|
|
99
|
+
return { message: "", author: "", email: "", timestamp: "" };
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Check if a commit has a parent (is not the root commit).
|
|
105
|
+
*/
|
|
106
|
+
async function hasParent(gitClient: GitClient, sha: string, cwd: string): Promise<boolean> {
|
|
107
|
+
try {
|
|
108
|
+
await git(gitClient, cwd, ["rev-parse", "--verify", `${sha}~1`]);
|
|
109
|
+
return true;
|
|
110
|
+
} catch {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function dirNodeId(dirPath: string): string {
|
|
116
|
+
return `dir:${dirPath}`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function fileNodeId(filePath: string): string {
|
|
120
|
+
return `file:${filePath}`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function symNodeId(filePath: string, name: string): string {
|
|
124
|
+
return `sym:${filePath}:${name}`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Build a lookup from symbol name → line range from the jump table.
|
|
129
|
+
*/
|
|
130
|
+
function buildJumpLookup(jumpTable: readonly JumpEntry[]): Map<string, { start: number; end: number }> {
|
|
131
|
+
const lookup = new Map<string, { start: number; end: number }>();
|
|
132
|
+
for (const entry of jumpTable) {
|
|
133
|
+
lookup.set(entry.symbol, { start: entry.start, end: entry.end });
|
|
134
|
+
}
|
|
135
|
+
return lookup;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Emit directory nodes + edges for all path components of a file.
|
|
140
|
+
*/
|
|
141
|
+
function emitDirectoryChain(patch: PatchOps, filePath: string): void {
|
|
142
|
+
const parts = filePath.split("/");
|
|
143
|
+
if (parts.length <= 1) return;
|
|
144
|
+
|
|
145
|
+
let current = "";
|
|
146
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
147
|
+
const parent = current;
|
|
148
|
+
const part = parts[i] ?? "";
|
|
149
|
+
current = current.length > 0 ? `${current}/${part}` : part;
|
|
150
|
+
const dirId = dirNodeId(current);
|
|
151
|
+
patch.addNode(dirId);
|
|
152
|
+
patch.setProperty(dirId, "path", current);
|
|
153
|
+
|
|
154
|
+
if (parent.length > 0) {
|
|
155
|
+
patch.addEdge(dirNodeId(parent), dirId, "contains");
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
patch.addEdge(dirNodeId(current), fileNodeId(filePath), "contains");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Emit symbol nodes + edges for all entries in a file outline.
|
|
164
|
+
*/
|
|
165
|
+
function emitSymbols(
|
|
166
|
+
patch: PatchOps,
|
|
167
|
+
filePath: string,
|
|
168
|
+
entries: readonly OutlineEntry[],
|
|
169
|
+
jumpLookup: Map<string, { start: number; end: number }>,
|
|
170
|
+
parentSymId?: string,
|
|
171
|
+
): void {
|
|
172
|
+
for (const entry of entries) {
|
|
173
|
+
const symId = symNodeId(filePath, entry.name);
|
|
174
|
+
patch.addNode(symId);
|
|
175
|
+
patch.setProperty(symId, "name", entry.name);
|
|
176
|
+
patch.setProperty(symId, "kind", entry.kind);
|
|
177
|
+
patch.setProperty(symId, "exported", entry.exported);
|
|
178
|
+
if (entry.signature !== undefined) {
|
|
179
|
+
patch.setProperty(symId, "signature", entry.signature);
|
|
180
|
+
}
|
|
181
|
+
const jump = jumpLookup.get(entry.name);
|
|
182
|
+
if (jump !== undefined) {
|
|
183
|
+
patch.setProperty(symId, "startLine", jump.start);
|
|
184
|
+
patch.setProperty(symId, "endLine", jump.end);
|
|
185
|
+
}
|
|
186
|
+
patch.addEdge(fileNodeId(filePath), symId, "contains");
|
|
187
|
+
if (parentSymId !== undefined) {
|
|
188
|
+
patch.addEdge(parentSymId, symId, "child_of");
|
|
189
|
+
}
|
|
190
|
+
if (entry.children !== undefined && entry.children.length > 0) {
|
|
191
|
+
emitSymbols(patch, filePath, entry.children, jumpLookup, symId);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Tombstone (remove) symbol nodes recursively including children.
|
|
198
|
+
*/
|
|
199
|
+
function removeSymbols(
|
|
200
|
+
patch: PatchOps,
|
|
201
|
+
filePath: string,
|
|
202
|
+
entries: readonly OutlineEntry[],
|
|
203
|
+
): void {
|
|
204
|
+
for (const entry of entries) {
|
|
205
|
+
// Recurse into children FIRST (bottom-up removal)
|
|
206
|
+
if (entry.children !== undefined && entry.children.length > 0) {
|
|
207
|
+
removeSymbols(patch, filePath, entry.children);
|
|
208
|
+
}
|
|
209
|
+
const symId = symNodeId(filePath, entry.name);
|
|
210
|
+
patch.removeEdge(fileNodeId(filePath), symId, "contains");
|
|
211
|
+
patch.removeNode(symId);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Remove symbols from DiffEntry (removed symbols in a diff).
|
|
217
|
+
* Also recursively handles childDiff if present.
|
|
218
|
+
*/
|
|
219
|
+
function removeDiffSymbols(
|
|
220
|
+
patch: PatchOps,
|
|
221
|
+
filePath: string,
|
|
222
|
+
fileId: string,
|
|
223
|
+
entries: readonly DiffEntry[],
|
|
224
|
+
): void {
|
|
225
|
+
for (const entry of entries) {
|
|
226
|
+
// Recurse into childDiff if present (remove grandchildren first)
|
|
227
|
+
if (entry.childDiff !== undefined) {
|
|
228
|
+
removeDiffSymbols(patch, filePath, fileId, [...entry.childDiff.removed]);
|
|
229
|
+
removeDiffSymbols(patch, filePath, fileId, [...entry.childDiff.added]);
|
|
230
|
+
removeDiffSymbols(patch, filePath, fileId, [...entry.childDiff.changed]);
|
|
231
|
+
}
|
|
232
|
+
const symId = symNodeId(filePath, entry.name);
|
|
233
|
+
patch.removeEdge(fileId, symId, "contains");
|
|
234
|
+
patch.removeNode(symId);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Apply child diffs for changed symbols (methods added/removed/changed
|
|
240
|
+
* inside a class that kept its name).
|
|
241
|
+
*/
|
|
242
|
+
function applyChildDiffs(
|
|
243
|
+
patch: PatchOps,
|
|
244
|
+
filePath: string,
|
|
245
|
+
fileId: string,
|
|
246
|
+
commitId: string,
|
|
247
|
+
changed: readonly DiffEntry[],
|
|
248
|
+
jumpLookup: Map<string, { start: number; end: number }>,
|
|
249
|
+
): void {
|
|
250
|
+
for (const entry of changed) {
|
|
251
|
+
if (entry.childDiff === undefined) continue;
|
|
252
|
+
const parentSymId = symNodeId(filePath, entry.name);
|
|
253
|
+
|
|
254
|
+
for (const added of entry.childDiff.added) {
|
|
255
|
+
const symId = symNodeId(filePath, added.name);
|
|
256
|
+
patch.addNode(symId);
|
|
257
|
+
patch.setProperty(symId, "name", added.name);
|
|
258
|
+
patch.setProperty(symId, "kind", added.kind);
|
|
259
|
+
patch.setProperty(symId, "exported", false);
|
|
260
|
+
if (added.signature !== undefined) {
|
|
261
|
+
patch.setProperty(symId, "signature", added.signature);
|
|
262
|
+
}
|
|
263
|
+
const jump = jumpLookup.get(added.name);
|
|
264
|
+
if (jump !== undefined) {
|
|
265
|
+
patch.setProperty(symId, "startLine", jump.start);
|
|
266
|
+
patch.setProperty(symId, "endLine", jump.end);
|
|
267
|
+
}
|
|
268
|
+
patch.addEdge(fileId, symId, "contains");
|
|
269
|
+
patch.addEdge(parentSymId, symId, "child_of");
|
|
270
|
+
patch.addEdge(commitId, symId, "adds");
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
for (const removed of entry.childDiff.removed) {
|
|
274
|
+
const symId = symNodeId(filePath, removed.name);
|
|
275
|
+
patch.addEdge(commitId, symId, "removes");
|
|
276
|
+
patch.removeEdge(fileId, symId, "contains");
|
|
277
|
+
patch.removeNode(symId);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Recurse into changed children that have their own childDiffs
|
|
281
|
+
applyChildDiffs(patch, filePath, fileId, commitId, [...entry.childDiff.changed], jumpLookup);
|
|
282
|
+
|
|
283
|
+
for (const child of entry.childDiff.changed) {
|
|
284
|
+
const symId = symNodeId(filePath, child.name);
|
|
285
|
+
patch.setProperty(symId, "kind", child.kind);
|
|
286
|
+
if (child.signature !== undefined) {
|
|
287
|
+
patch.setProperty(symId, "signature", child.signature);
|
|
288
|
+
}
|
|
289
|
+
patch.addEdge(commitId, symId, "changes");
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async function prepareChange(
|
|
295
|
+
gitClient: GitClient,
|
|
296
|
+
cwd: string,
|
|
297
|
+
sha: string,
|
|
298
|
+
parentRef: string,
|
|
299
|
+
parentExists: boolean,
|
|
300
|
+
change: { status: string; path: string },
|
|
301
|
+
): Promise<PreparedChange> {
|
|
302
|
+
const filePath = change.path;
|
|
303
|
+
const fileId = fileNodeId(filePath);
|
|
304
|
+
const lang = detectLang(filePath);
|
|
305
|
+
|
|
306
|
+
if (change.status === "D") {
|
|
307
|
+
let oldOutline: readonly OutlineEntry[] = [];
|
|
308
|
+
if (lang !== null && parentExists) {
|
|
309
|
+
const oldContent = await getFileAtRef(parentRef, filePath, { cwd, git: gitClient });
|
|
310
|
+
if (oldContent !== null) {
|
|
311
|
+
oldOutline = extractOutline(oldContent, lang).entries;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return {
|
|
315
|
+
status: change.status,
|
|
316
|
+
filePath,
|
|
317
|
+
fileId,
|
|
318
|
+
lang,
|
|
319
|
+
parentExists,
|
|
320
|
+
oldOutline,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (lang === null) {
|
|
325
|
+
return {
|
|
326
|
+
status: change.status,
|
|
327
|
+
filePath,
|
|
328
|
+
fileId,
|
|
329
|
+
lang,
|
|
330
|
+
parentExists,
|
|
331
|
+
oldOutline: [],
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const newContent = await getFileAtRef(sha, filePath, { cwd, git: gitClient });
|
|
336
|
+
if (newContent === null) {
|
|
337
|
+
return {
|
|
338
|
+
status: change.status,
|
|
339
|
+
filePath,
|
|
340
|
+
fileId,
|
|
341
|
+
lang,
|
|
342
|
+
parentExists,
|
|
343
|
+
oldOutline: [],
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const newResult = extractOutline(newContent, lang);
|
|
348
|
+
const newOutline = newResult.entries;
|
|
349
|
+
const jumpLookup = buildJumpLookup(newResult.jumpTable ?? []);
|
|
350
|
+
|
|
351
|
+
if (change.status === "A" || !parentExists) {
|
|
352
|
+
return {
|
|
353
|
+
status: change.status,
|
|
354
|
+
filePath,
|
|
355
|
+
fileId,
|
|
356
|
+
lang,
|
|
357
|
+
parentExists,
|
|
358
|
+
oldOutline: [],
|
|
359
|
+
newOutline,
|
|
360
|
+
jumpLookup,
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const oldContent = await getFileAtRef(parentRef, filePath, { cwd, git: gitClient });
|
|
365
|
+
if (oldContent === null) {
|
|
366
|
+
return {
|
|
367
|
+
status: change.status,
|
|
368
|
+
filePath,
|
|
369
|
+
fileId,
|
|
370
|
+
lang,
|
|
371
|
+
parentExists,
|
|
372
|
+
oldOutline: [],
|
|
373
|
+
newOutline,
|
|
374
|
+
jumpLookup,
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const oldOutline = extractOutline(oldContent, lang).entries;
|
|
379
|
+
return {
|
|
380
|
+
status: change.status,
|
|
381
|
+
filePath,
|
|
382
|
+
fileId,
|
|
383
|
+
lang,
|
|
384
|
+
parentExists,
|
|
385
|
+
oldOutline,
|
|
386
|
+
newOutline,
|
|
387
|
+
jumpLookup,
|
|
388
|
+
diff: diffOutlines(oldOutline, newOutline),
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Index a range of commits into the WARP graph.
|
|
394
|
+
*/
|
|
395
|
+
export async function indexCommits(
|
|
396
|
+
warp: WarpApp,
|
|
397
|
+
options: IndexOptions,
|
|
398
|
+
): Promise<IndexResult> {
|
|
399
|
+
const { cwd, git: gitClient } = options;
|
|
400
|
+
const commits = await listCommits(gitClient, cwd, options.from, options.to);
|
|
401
|
+
|
|
402
|
+
let patchesWritten = 0;
|
|
403
|
+
const commitTicks = new Map<string, number>();
|
|
404
|
+
|
|
405
|
+
for (const sha of commits) {
|
|
406
|
+
const changes = await getCommitChanges(gitClient, sha, cwd);
|
|
407
|
+
|
|
408
|
+
// Only materialize when removals are possible (D or M status).
|
|
409
|
+
// Materialization is expensive — O(n) replay of all prior patches.
|
|
410
|
+
// Add-only commits (A status) and no-change commits don't need it.
|
|
411
|
+
const hasRemovals = changes.some((c) => c.status === "D" || c.status === "M");
|
|
412
|
+
if (hasRemovals) {
|
|
413
|
+
await warp.core().materialize();
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const meta = await getCommitMeta(gitClient, sha, cwd);
|
|
417
|
+
const parentExists = await hasParent(gitClient, sha, cwd);
|
|
418
|
+
const parentRef = `${sha}~1`;
|
|
419
|
+
const preparedChanges = await Promise.all(changes.map((change) =>
|
|
420
|
+
prepareChange(gitClient, cwd, sha, parentRef, parentExists, change)
|
|
421
|
+
));
|
|
422
|
+
|
|
423
|
+
await warp.patch((p) => {
|
|
424
|
+
const patch = p as unknown as PatchOps;
|
|
425
|
+
|
|
426
|
+
const commitId = `commit:${sha}`;
|
|
427
|
+
patch.addNode(commitId);
|
|
428
|
+
patch.setProperty(commitId, "sha", sha);
|
|
429
|
+
patch.setProperty(commitId, "message", meta.message);
|
|
430
|
+
patch.setProperty(commitId, "author", meta.author);
|
|
431
|
+
patch.setProperty(commitId, "email", meta.email);
|
|
432
|
+
patch.setProperty(commitId, "timestamp", meta.timestamp);
|
|
433
|
+
|
|
434
|
+
for (const change of preparedChanges) {
|
|
435
|
+
if (change.status === "D") {
|
|
436
|
+
if (change.lang !== null) {
|
|
437
|
+
removeSymbols(patch, change.filePath, change.oldOutline);
|
|
438
|
+
}
|
|
439
|
+
patch.removeNode(change.fileId);
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Added or modified — ensure file + directory nodes exist
|
|
444
|
+
patch.addNode(change.fileId);
|
|
445
|
+
patch.setProperty(change.fileId, "path", change.filePath);
|
|
446
|
+
patch.setProperty(change.fileId, "lang", change.lang ?? "unknown");
|
|
447
|
+
patch.addEdge(commitId, change.fileId, "touches");
|
|
448
|
+
emitDirectoryChain(patch, change.filePath);
|
|
449
|
+
|
|
450
|
+
if (change.lang === null || change.newOutline === undefined || change.jumpLookup === undefined) continue;
|
|
451
|
+
|
|
452
|
+
if (change.status === "A" || !change.parentExists || change.diff === undefined) {
|
|
453
|
+
// New file or root commit — emit all symbols
|
|
454
|
+
emitSymbols(patch, change.filePath, change.newOutline, change.jumpLookup);
|
|
455
|
+
} else {
|
|
456
|
+
const diff = change.diff;
|
|
457
|
+
|
|
458
|
+
// Remove deleted symbols
|
|
459
|
+
for (const removed of diff.removed) {
|
|
460
|
+
const symId = symNodeId(change.filePath, removed.name);
|
|
461
|
+
patch.addEdge(commitId, symId, "removes");
|
|
462
|
+
removeDiffSymbols(patch, change.filePath, change.fileId, [removed]);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// Add new symbols (preserve actual exported status)
|
|
466
|
+
for (const added of diff.added) {
|
|
467
|
+
const symId = symNodeId(change.filePath, added.name);
|
|
468
|
+
patch.addNode(symId);
|
|
469
|
+
patch.setProperty(symId, "name", added.name);
|
|
470
|
+
patch.setProperty(symId, "kind", added.kind);
|
|
471
|
+
// DiffEntry doesn't carry exported — default false for safety.
|
|
472
|
+
// Full exported status comes from emitSymbols on initial add.
|
|
473
|
+
patch.setProperty(symId, "exported", false);
|
|
474
|
+
if (added.signature !== undefined) {
|
|
475
|
+
patch.setProperty(symId, "signature", added.signature);
|
|
476
|
+
}
|
|
477
|
+
const jump = change.jumpLookup.get(added.name);
|
|
478
|
+
if (jump !== undefined) {
|
|
479
|
+
patch.setProperty(symId, "startLine", jump.start);
|
|
480
|
+
patch.setProperty(symId, "endLine", jump.end);
|
|
481
|
+
}
|
|
482
|
+
patch.addEdge(change.fileId, symId, "contains");
|
|
483
|
+
patch.addEdge(commitId, symId, "adds");
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Update changed symbols
|
|
487
|
+
for (const changed of diff.changed) {
|
|
488
|
+
const symId = symNodeId(change.filePath, changed.name);
|
|
489
|
+
patch.setProperty(symId, "kind", changed.kind);
|
|
490
|
+
if (changed.signature !== undefined) {
|
|
491
|
+
patch.setProperty(symId, "signature", changed.signature);
|
|
492
|
+
}
|
|
493
|
+
patch.addEdge(commitId, symId, "changes");
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// Apply nested child diffs (methods in classes)
|
|
497
|
+
applyChildDiffs(
|
|
498
|
+
patch,
|
|
499
|
+
change.filePath,
|
|
500
|
+
change.fileId,
|
|
501
|
+
commitId,
|
|
502
|
+
[...diff.changed],
|
|
503
|
+
change.jumpLookup,
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
});
|
|
508
|
+
patchesWritten++;
|
|
509
|
+
commitTicks.set(sha, patchesWritten);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
return { commitsIndexed: commits.length, patchesWritten, commitTicks };
|
|
513
|
+
}
|