Skip to content

Commit c9e5260

Browse files
committed
Implement plan validation and ledger-backed Git review core
1 parent 2229e88 commit c9e5260

13 files changed

Lines changed: 2529 additions & 0 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
name: CI
2+
on:
3+
push:
4+
branches: [main, 'codex/**']
5+
pull_request:
6+
permissions:
7+
contents: read
8+
jobs:
9+
test:
10+
runs-on: ubuntu-latest
11+
steps:
12+
- uses: actions/checkout@v4
13+
- uses: actions/setup-node@v4
14+
with:
15+
node-version: '26.7.0'
16+
cache: npm
17+
- run: npm ci --ignore-scripts
18+
- run: npm run typecheck
19+
- run: npm test

‎.gitignore‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
node_modules/
2+
coverage/
3+
dist/
4+
.DS_Store

‎README.md‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# codeboost
2+
3+
Review agent-made Git changes one plan item at a time. The approved plan lists each item's files and acceptance checks; the review engine shows which item produced each change and flags foreign or overlapping work.
4+
5+
**Status:** the first library slice is implemented. There is no application, server, agent runner, database, or merge command yet. Follow the [build order](docs/designs/codeboost-plan-indexed-review.md#build-order-and-the-gono-go-check); the read-only screen and real-PR go/no-go experiment come before agent execution.
6+
7+
## Development
8+
9+
Requires Node 26.7 or later and Git.
10+
11+
```sh
12+
npm ci --ignore-scripts
13+
npm run typecheck
14+
npm test
15+
```
16+
17+
Tests create disposable local repositories. They do not invoke agents, access GitHub, or execute plan acceptance commands.
18+
19+
## Library
20+
21+
- `core/plan.ts`: schema validation; YAML/JSON import; projected file-state and dependency checks; literal command parsing; individual suggestion validation and application.
22+
- `git/history.ts`: reads an immutable base-to-head commit range and file blobs. Uses argv, disables external diff/textconv helpers, hooks, and replacement objects.
23+
- `core/linking.ts`: replays line changes using an explicit `Map<commitSha, planItemId>` supplied by the caller. Trailers never establish ownership. Foreign work is Unplanned; overlapping item edits are Ambiguous; undeclared edits stay on their owner's row as out of scope.
24+
- `core/approvals.ts`: approval snapshots, dependency staleness, assignments, and accept-as-is choices keyed by content and duplicate occurrence/count.
25+
26+
Example from TypeScript (Node can load these source modules):
27+
28+
```ts
29+
import { importPlan } from './core/plan.ts';
30+
import { readHistory } from './git/history.ts';
31+
import { linkHistory } from './core/linking.ts';
32+
33+
const { plan, warnings } = importPlan(planText, 'yaml', {
34+
baseFiles: pathsAtBaseCommit, // Include symlink and submodule entries, never their targets.
35+
allowedCommands: [['npm', 'test']],
36+
issue: 412,
37+
}, nextRevision);
38+
const history = readHistory(repoPath, baseCommit, headCommit);
39+
const segments = linkHistory(plan, history, trustedCommitLedger);
40+
```
41+
42+
Inputs such as `planText` and the ledger must come from the caller. The future `runner/store` owns the database and ledger; this library does not infer them from commit messages. Before saving a suggested edit, the store must compare-and-swap the plan revision in one transaction. The pure `applySuggestion` function validates a copy but cannot lock storage.
43+
44+
## Current limits and safety
45+
46+
- History must be linear and descend from the requested base. Merge histories are rejected with a rebase instruction. Reads are bounded to 500 commits, 32 MiB per Git response, and a 2-second budget per line diff; oversized work fails explicitly.
47+
- Ownership uses line diffs, not semantic inference. Within one replacement block, new lines inherit all affected owners conservatively. Function context comes from Git hunk headers, not an AST.
48+
- The importer requires an accurate base-file list. It rejects path traversal, Git metadata paths, and traversal through a listed file/symlink/submodule. Runtime symlink and write-scope enforcement belong to the future container/runner; plan validation alone is not a sandbox.
49+
- Allowed commands restrict accidents, not hostile programs or changed scripts. Parsing returns argv and never executes it. An unlisted valid command is a warning and must not run until allowed.
50+
- No code here claims container isolation, vendor-only network access, credential protection, or safe dependency installation. Those controls must be implemented before running agents.
51+
52+
See [implementation decisions and evidence](docs/implementation/build-step-1.md) and the [plan format](docs/plan-format.md).

‎core/approvals.ts‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import type { Plan, PlanItem } from './plan.ts';
2+
import type { Segment } from './linking.ts';
3+
4+
/** Stable representation ignores object-key order and normalizes CRLF, not whitespace. */
5+
function stable(value: unknown): string {
6+
if (typeof value === 'string') return JSON.stringify(value.replace(/\r\n/g, '\n'));
7+
if (Array.isArray(value)) return `[${value.map(stable).join(',')}]`;
8+
if (value !== null && typeof value === 'object') return `{${Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, val]) => `${JSON.stringify(key)}:${stable(val)}`).join(',')}}`;
9+
return JSON.stringify(value);
10+
}
11+
const contentKey = (s: Segment) => stable({ path: s.path, oldPath: s.oldPath, kind: s.kind, operation: s.operation, content: s.content });
12+
export interface SegmentChoice { key: string; action: 'assign' | 'accept'; item: string | null }
13+
/** Position among identical segments and total copies prevent approval transfer. */
14+
export function choiceKeys(segments: readonly Segment[]): string[] {
15+
const counts = new Map<string, number>(), seen = new Map<string, number>();
16+
for (const segment of segments) { const key = contentKey(segment); counts.set(key, (counts.get(key) ?? 0) + 1); }
17+
return segments.map(segment => {
18+
const key = contentKey(segment), copy = (seen.get(key) ?? 0) + 1; seen.set(key, copy);
19+
return stable([key, copy, counts.get(key)]);
20+
});
21+
}
22+
export function applyChoices(plan: Plan, segments: readonly Segment[], choices: readonly SegmentChoice[]): Segment[] {
23+
const keys = choiceKeys(segments);
24+
const byKey = new Map(choices.map(choice => [choice.key, choice]));
25+
return segments.map((segment, i) => {
26+
const choice = byKey.get(keys[i]!);
27+
if (!choice || !['Ambiguous', 'Unplanned'].includes(segment.row)) return { ...segment };
28+
if (choice.action === 'accept') return { ...segment, row: 'Accepted' };
29+
if (!plan.items.some(item => item.id === choice.item)) throw new Error('Assigned item does not exist.');
30+
return { ...segment, row: choice.item! };
31+
});
32+
}
33+
export interface Approval { item: string; fingerprint: string }
34+
function fingerprint(item: PlanItem, segments: readonly Segment[]): string {
35+
return stable({ item, segments: segments.filter(s => s.row === item.id).map(s => ({
36+
path: s.path, oldPath: s.oldPath, kind: s.kind, operation: s.operation,
37+
content: s.content, context: s.context, owners: [...s.owners].sort(),
38+
})) });
39+
}
40+
export function approveItem(plan: Plan, segments: readonly Segment[], itemId: string, confirmNoChange = false): Approval {
41+
const item = plan.items.find(item => item.id === itemId);
42+
if (!item) throw new Error('Unknown item.');
43+
if (!segments.some(segment => segment.row === itemId) && !confirmNoChange) throw new Error('Confirm no change needed before approving.');
44+
return { item: itemId, fingerprint: fingerprint(item, segments) };
45+
}
46+
export function approvalStates(plan: Plan, segments: readonly Segment[], approvals: readonly Approval[]): Record<string, 'unreviewed' | 'approved' | 'stale'> {
47+
const result: Record<string, 'unreviewed' | 'approved' | 'stale'> = Object.create(null);
48+
for (const item of plan.items) {
49+
const approval = approvals.find(approval => approval.item === item.id);
50+
result[item.id] = !approval ? 'unreviewed' : approval.fingerprint !== fingerprint(item, segments) ||
51+
item.depends_on.some(dep => result[dep] === 'stale') ? 'stale' : 'approved';
52+
}
53+
return result;
54+
}

‎core/linking.ts‎

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import { diffArrays } from 'diff';
2+
import type { Plan } from './plan.ts';
3+
4+
export interface FileVersion { oid: string; mode: string; text: string | null }
5+
export interface ContextRange { oldStart: number; oldCount: number; newStart: number; newCount: number; name: string }
6+
export interface FileDelta {
7+
oldPath: string | null; newPath: string | null;
8+
before: FileVersion | null; after: FileVersion | null;
9+
contexts: ContextRange[];
10+
}
11+
export interface CommitDelta { sha: string; parent: string; files: FileDelta[] }
12+
export interface History { base: string; head: string; commits: CommitDelta[]; final: FileDelta[] }
13+
export interface Segment {
14+
path: string; oldPath: string | null; kind: 'text' | 'file';
15+
/** Null means a foreign commit. Caller-supplied ledger is the sole authority. */
16+
owners: (string | null)[];
17+
row: string; scope: 'in-scope' | 'out-of-scope' | 'unplanned' | 'ambiguous';
18+
oldLine: number | null; newLine: number | null; operation: '+' | '-' | null;
19+
content: string; context: string; hunk: number; sharesHunkWith: string[];
20+
}
21+
interface TrackedLine { text: string; owners: (string | null)[]; origins: string[]; movedOwners: (string | null)[] }
22+
interface TrackedFile { lines: TrackedLine[]; metadataOwners: (string | null)[] }
23+
const lines = (text: string | null | undefined): string[] => text?.match(/[^\n]*\n|[^\n]+$/g) ?? [];
24+
const unique = <T>(values: T[]): T[] => [...new Set(values)];
25+
const origin = (path: string, i: number) => `${path}\0${i}`;
26+
function textFile(file: FileVersion | null): boolean {
27+
return file !== null && file.text !== null && ['100644', '100755'].includes(file.mode);
28+
}
29+
function metadataChange(delta: FileDelta): boolean {
30+
return delta.oldPath !== delta.newPath || delta.before?.mode !== delta.after?.mode ||
31+
!textFile(delta.before) || !textFile(delta.after);
32+
}
33+
function classify(plan: Plan, owners: (string | null)[], paths: string[]): Pick<Segment, 'row' | 'scope'> {
34+
if (!owners.length || owners.includes(null)) return { row: 'Unplanned', scope: 'unplanned' };
35+
if (owners.length > 1) return { row: 'Ambiguous', scope: 'ambiguous' };
36+
const item = plan.items.find(item => item.id === owners[0]);
37+
if (!item) throw new Error(`Ledger references unknown plan item ${owners[0]}.`);
38+
const declared = new Set(item.files.flatMap(file => [file.path, ...(file.renamed_from ? [file.renamed_from] : [])]));
39+
return { row: item.id, scope: paths.every(path => declared.has(path)) ? 'in-scope' : 'out-of-scope' };
40+
}
41+
42+
/** Replays a linear history. Commit messages and Plan-Item trailers are never trusted. */
43+
export function linkHistory(plan: Plan, history: History, ledger: ReadonlyMap<string, string>): Segment[] {
44+
const files = new Map<string, TrackedFile>();
45+
const removed = new Map<string, (string | null)[]>();
46+
// Deletions retain metadata even after the file leaves the tree.
47+
const metadata = new Map<string, (string | null)[]>();
48+
let parent = history.base;
49+
for (const commit of history.commits) {
50+
if (commit.parent !== parent) throw new Error('Linking requires a contiguous linear history.');
51+
parent = commit.sha;
52+
const owner = ledger.get(commit.sha) ?? null;
53+
if (owner !== null && !plan.items.some(item => item.id === owner)) throw new Error(`Unknown ledger item: ${owner}`);
54+
for (const delta of commit.files) {
55+
const oldPath = delta.oldPath;
56+
const path = delta.newPath ?? oldPath!;
57+
let previous = oldPath ? files.get(oldPath) : undefined;
58+
if (!previous) previous = {
59+
lines: lines(textFile(delta.before) ? delta.before!.text : '').map((text, i) => ({ text, owners: [], origins: [origin(oldPath!, i)], movedOwners: [] })),
60+
metadataOwners: [],
61+
};
62+
const next: TrackedLine[] = [];
63+
const changes = diffArrays(previous.lines.map(line => line.text), lines(textFile(delta.after) ? delta.after!.text : ''), { timeout: 2000 });
64+
if (!changes) throw new Error('Line attribution exceeded the diff time budget.');
65+
let cursor = 0;
66+
for (let n = 0; n < changes.length; n++) {
67+
const change = changes[n]!;
68+
if (!change.added && !change.removed) { next.push(...previous.lines.slice(cursor, cursor + change.value.length)); cursor += change.value.length; continue; }
69+
const deleted = change.removed ? previous.lines.slice(cursor, cursor + change.value.length) : [];
70+
if (change.removed) cursor += change.value.length;
71+
const owners = unique([...deleted.flatMap(line => line.owners), owner]);
72+
const origins = unique(deleted.flatMap(line => line.origins));
73+
for (const id of origins) removed.set(id, owners);
74+
const added = change.added ? change : changes[n + 1]?.added ? changes[++n]! : undefined;
75+
if (added) for (const text of added.value) next.push({ text, owners, origins, movedOwners: unique(deleted.flatMap(line => line.movedOwners)) });
76+
}
77+
if (oldPath && delta.newPath && oldPath !== delta.newPath) {
78+
for (let i = 0; i < next.length; i++) {
79+
const line = next[i]!;
80+
next[i] = { ...line, movedOwners: unique([...line.movedOwners, owner]) };
81+
for (const id of line.origins) if (!removed.has(id)) removed.set(id, unique([...line.owners, owner]));
82+
}
83+
}
84+
const metadataOwners = metadataChange(delta) ? unique([...previous.metadataOwners, owner]) : previous.metadataOwners;
85+
if (oldPath) { files.delete(oldPath); metadata.set(oldPath, metadataOwners); }
86+
if (delta.newPath) { files.set(delta.newPath, { lines: next, metadataOwners }); metadata.set(delta.newPath, metadataOwners); }
87+
}
88+
}
89+
if (parent !== history.head) throw new Error('History does not end at the requested head.');
90+
const segments: Segment[] = [];
91+
for (const delta of history.final) {
92+
const path = delta.newPath ?? delta.oldPath!;
93+
const tracked = delta.newPath ? files.get(delta.newPath) : undefined;
94+
const oldLines = lines(textFile(delta.before) ? delta.before!.text : '');
95+
const newLines = lines(textFile(delta.after) ? delta.after!.text : '');
96+
let oldIndex = 0, newIndex = 0, hunk = 0;
97+
const fileSegments: Segment[] = [];
98+
const affectedPaths = unique([delta.oldPath, delta.newPath].filter((p): p is string => p !== null));
99+
const push = (part: Omit<Segment, 'row' | 'scope' | 'sharesHunkWith'>) => fileSegments.push({
100+
...part, ...classify(plan, part.owners, affectedPaths), sharesHunkWith: [],
101+
});
102+
if (metadataChange(delta)) {
103+
const owners = unique(affectedPaths.flatMap(path => metadata.get(path) ?? []));
104+
push({ path, oldPath: delta.oldPath, kind: 'file', owners, oldLine: null, newLine: null,
105+
operation: null, context: '', hunk: -1,
106+
content: JSON.stringify({ oldPath: delta.oldPath, newPath: delta.newPath,
107+
oldMode: delta.before?.mode ?? null, newMode: delta.after?.mode ?? null,
108+
oldOid: delta.before?.oid ?? null, newOid: delta.after?.oid ?? null }),
109+
});
110+
}
111+
const finalChanges = diffArrays(oldLines, newLines, { timeout: 2000 });
112+
if (!finalChanges) throw new Error('Final diff exceeded the time budget.');
113+
for (const change of finalChanges) {
114+
if (!change.added && !change.removed) {
115+
oldIndex += change.value.length; newIndex += change.value.length;
116+
if (change.value.length > 6) hunk++;
117+
continue;
118+
}
119+
for (const text of change.value) {
120+
const trackedLine = tracked?.lines[newIndex];
121+
const owners = change.added
122+
? trackedLine?.owners.length ? trackedLine.owners : trackedLine?.movedOwners ?? []
123+
: removed.get(origin(delta.oldPath!, oldIndex)) ?? [];
124+
const context = delta.contexts.find(range => change.added
125+
? newIndex + 1 >= range.newStart && newIndex + 1 < range.newStart + range.newCount
126+
: oldIndex + 1 >= range.oldStart && oldIndex + 1 < range.oldStart + range.oldCount)?.name ?? '';
127+
push({ path, oldPath: delta.oldPath, kind: 'text', owners, content: text, context, hunk,
128+
oldLine: change.removed ? oldIndex + 1 : null, newLine: change.added ? newIndex + 1 : null,
129+
operation: change.added ? '+' : '-',
130+
});
131+
if (change.added) newIndex++; else oldIndex++;
132+
}
133+
}
134+
// Adjacent lines with identical ownership/context form one segment.
135+
const grouped: Segment[] = [];
136+
for (const part of fileSegments) {
137+
const last = grouped.at(-1);
138+
if (last && part.kind === 'text' && last.kind === 'text' && last.hunk === part.hunk &&
139+
last.operation === part.operation && last.context === part.context &&
140+
(part.operation === '+' ? last.newLine! + lines(last.content).length === part.newLine : last.oldLine! + lines(last.content).length === part.oldLine) &&
141+
JSON.stringify(last.owners) === JSON.stringify(part.owners)) last.content += part.content;
142+
else grouped.push({ ...part });
143+
}
144+
for (const part of grouped) part.sharesHunkWith = unique(grouped
145+
.filter(other => other !== part && other.hunk === part.hunk && other.row !== part.row)
146+
.flatMap(other => other.owners.filter((owner): owner is string => owner !== null)));
147+
segments.push(...grouped);
148+
}
149+
return segments;
150+
}

0 commit comments

Comments
 (0)