diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..836b526 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,19 @@ +name: CI +on: + push: + branches: [main, 'codex/**'] + pull_request: +permissions: + contents: read +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '26.7.0' + cache: npm + - run: npm ci --ignore-scripts + - run: npm run typecheck + - run: npm test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c938c74 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +coverage/ +dist/ +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..9c5b0cf --- /dev/null +++ b/README.md @@ -0,0 +1,57 @@ +# codeboost + +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. + +**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. + +## Development + +Requires Node 26.7 or later and Git. + +```sh +npm ci --ignore-scripts +npm run typecheck +npm test +``` + +Tests create disposable local repositories. They do not invoke agents, access GitHub, or execute plan acceptance commands. + +## Library + +- `core/plan.ts`: schema validation; YAML/JSON import; projected file-state and dependency checks; literal command parsing; individual suggestion validation and application. +- `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. +- `core/linking.ts`: replays line changes using an explicit `Map` 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. +- `core/approvals.ts`: approval snapshots, dependency staleness, assignments, and accept-as-is choices keyed by content and duplicate occurrence/count. + +Example from TypeScript (Node can load these source modules): + +```ts +import { importPlan } from './core/plan.ts'; +import { readHistory } from './git/history.ts'; +import { linkHistory } from './core/linking.ts'; + +const { plan, warnings } = importPlan(planText, 'yaml', { + identity: storedPlanIdentity, // Stable repositoryId, taskId, and planId from storage. + baseEntries: entriesAtBaseCommit, // Typed file/gitlink entries; symlinks include target text. + pathKey: checkoutPathKey, // Actual checkout case/Unicode identity; fail if unknown. + allowedCommands: [['npm', 'test']], + issue: 412, +}, nextRevision); +const history = readHistory(repoPath, baseCommit, headCommit); +const segments = linkHistory(plan, history, trustedCommitLedger, checkoutPathKey); +``` + +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 load its captured identity/revision binding by opaque suggestion ID, reject canceled or consumed IDs, and compare-and-swap the plan revision plus consume/invalidate old suggestions in one transaction. The pure `applySuggestion` function requires that trusted binding and validates a copy, but cannot lock storage or prevent replay by itself. Applying one card stales its siblings; refresh and review regenerated cards before the next Apply. Approvals and choices likewise require the stored plan identity. + +## Current limits and safety + +- History must be linear and descend from the requested base. Merge histories are rejected with a rebase instruction; repositories using grafts, shallow ancestry, object alternates, or symlinked object storage are rejected (storage inspection is limited to 100,000 entries). Reads are bounded to 500 commits, 32 MiB per Git response, 64 MiB of unique blob bytes across the history (callers may lower `maxBlobBytes`), 8 MiB of cumulative diff output, 20,000 cumulative file records (including the final diff), and a shared 30-second monotonic deadline for a read. Git children receive only the remaining time and are killed on timeout. Filesystem inspections check the deadline between operations; a blocked filesystem syscall still requires an external worker supervisor. Oversized work fails explicitly. +- Linking separately bounds cumulative split lines and candidate segments to 100,000 each, reference/work operations to 1,000,000, and text/origin strings to 32 Mi UTF-16 code units. It checks before expanding lines/origin sets; grouping no longer repeatedly splits accumulated content. Callers may lower these budgets. Linking has a 30-second overall deadline and each line diff uses at most 2 seconds or the remaining total, whichever is smaller. +- The caller selects and trusts the repository and its Git administrative directory. Normal Git discovery, linked-worktree gitfiles, and symlinked gitdirs are supported; object-storage links and alternates inside that selected gitdir are rejected. This adapter is not a filesystem-containment boundary for untrusted repository roots. +- Git administrative metadata and object storage must remain unchanged during a read; these library checks do not isolate a concurrently hostile filesystem. +- 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. +- The importer requires accurate typed base entries, stable plan identity, a selected issue, and a trusted checkout path-identity function. 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. +- 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. +- No code here claims container isolation, vendor-only network access, credential protection, or safe dependency installation. Those controls must be implemented before running agents. + +See [implementation decisions and evidence](docs/implementation/build-step-1.md) and the [plan format](docs/plan-format.md). diff --git a/core/approvals.ts b/core/approvals.ts new file mode 100644 index 0000000..1e31fd5 --- /dev/null +++ b/core/approvals.ts @@ -0,0 +1,56 @@ +import { identityKey, type PlanIdentity } from './identity.ts'; +import type { Plan, PlanItem } from './plan.ts'; +import type { Segment } from './linking.ts'; + +/** Stable representation ignores object-key order and normalizes CRLF, not whitespace. */ +function stable(value: unknown): string { + if (typeof value === 'string') return JSON.stringify(value.replace(/\r\n/g, '\n')); + if (Array.isArray(value)) return `[${value.map(stable).join(',')}]`; + 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(',')}}`; + return JSON.stringify(value); +} +const contentKey = (s: Segment) => stable({ path: s.path, oldPath: s.oldPath, kind: s.kind, operation: s.operation, content: s.content }); +export interface SegmentChoice { key: string; action: 'assign' | 'accept'; item: string | null } +/** Position among identical segments and total copies prevent approval transfer. */ +export function choiceKeys(segments: readonly Segment[], identity: PlanIdentity): string[] { + const identityValue = identityKey(identity); + const counts = new Map(), seen = new Map(); + for (const segment of segments) { const key = contentKey(segment); counts.set(key, (counts.get(key) ?? 0) + 1); } + return segments.map(segment => { + const key = contentKey(segment), copy = (seen.get(key) ?? 0) + 1; seen.set(key, copy); + return stable([identityValue, key, copy, counts.get(key)]); + }); +} +export function applyChoices(plan: Plan, segments: readonly Segment[], choices: readonly SegmentChoice[], identity: PlanIdentity): Segment[] { + const keys = choiceKeys(segments, identity); + const byKey = new Map(choices.map(choice => [choice.key, choice])); + return segments.map((segment, i) => { + const choice = byKey.get(keys[i]!); + if (!choice || !['Ambiguous', 'Unplanned'].includes(segment.row)) return { ...segment }; + if (choice.action === 'accept') return { ...segment, row: 'Accepted' }; + if (!plan.items.some(item => item.id === choice.item)) throw new Error('Assigned item does not exist.'); + return { ...segment, row: choice.item! }; + }); +} +export interface Approval { item: string; fingerprint: string } +function fingerprint(item: PlanItem, segments: readonly Segment[], identity: PlanIdentity): string { + return stable({ identity: identityKey(identity), item, segments: segments.filter(s => s.row === item.id).map(s => ({ + path: s.path, oldPath: s.oldPath, kind: s.kind, operation: s.operation, + content: s.content, context: s.context, owners: [...s.owners].sort(), + })) }); +} +export function approveItem(plan: Plan, segments: readonly Segment[], itemId: string, identity: PlanIdentity, confirmNoChange = false): Approval { + const item = plan.items.find(item => item.id === itemId); + if (!item) throw new Error('Unknown item.'); + if (!segments.some(segment => segment.row === itemId) && !confirmNoChange) throw new Error('Confirm no change needed before approving.'); + return { item: itemId, fingerprint: fingerprint(item, segments, identity) }; +} +export function approvalStates(plan: Plan, segments: readonly Segment[], approvals: readonly Approval[], identity: PlanIdentity): Record { + const result: Record = Object.create(null); + for (const item of plan.items) { + const approval = approvals.find(approval => approval.item === item.id); + result[item.id] = !approval ? 'unreviewed' : approval.fingerprint !== fingerprint(item, segments, identity) || + item.depends_on.some(dep => result[dep] === 'stale') ? 'stale' : 'approved'; + } + return result; +} diff --git a/core/identity.ts b/core/identity.ts new file mode 100644 index 0000000..cc283bb --- /dev/null +++ b/core/identity.ts @@ -0,0 +1,7 @@ +/** Stable IDs from trusted application storage, never model output or UI selection. */ +export interface PlanIdentity { repositoryId: string; taskId: string; planId: string } +export function identityKey(identity: PlanIdentity): string { + const values = [identity?.repositoryId, identity?.taskId, identity?.planId]; + if (!values.every(v => typeof v === 'string' && v.length > 0)) throw new Error('Stable repository/task/plan identity is required.'); + return JSON.stringify(values); +} diff --git a/core/linking.ts b/core/linking.ts new file mode 100644 index 0000000..e9e4960 --- /dev/null +++ b/core/linking.ts @@ -0,0 +1,252 @@ +import { diffArrays } from 'diff'; +import type { Plan } from './plan.ts'; + +export interface FileVersion { oid: string; mode: string; text: string | null } +export interface ContextRange { oldStart: number; oldCount: number; newStart: number; newCount: number; name: string } +export interface FileDelta { + oldPath: string | null; newPath: string | null; + before: FileVersion | null; after: FileVersion | null; + contexts: ContextRange[]; +} +export interface CommitDelta { sha: string; parent: string; files: FileDelta[] } +export interface History { base: string; head: string; commits: CommitDelta[]; final: FileDelta[] } +export interface Segment { + path: string; oldPath: string | null; kind: 'text' | 'file'; + /** Null means a foreign commit. Caller-supplied ledger is the sole authority. */ + owners: (string | null)[]; + row: string; scope: 'in-scope' | 'out-of-scope' | 'unplanned' | 'ambiguous'; + oldLine: number | null; newLine: number | null; operation: '+' | '-' | null; + content: string; context: string; hunk: number; sharesHunkWith: string[]; +} +interface Evidence { owners: (string | null)[]; outOfScope: string[] } +interface TrackedLine { text: string; evidence: Evidence; origins: string[]; moved: Evidence } +interface TrackedFile { lines: TrackedLine[]; metadata: Evidence; metadataPaths: string[] } +const unique = (values: T[]): T[] => [...new Set(values)]; +const origin = (path: string, i: number) => `${path}\0${i}`; +function textFile(file: FileVersion | null): boolean { + return file !== null && file.text !== null && ['100644', '100755'].includes(file.mode); +} +function metadataChange(delta: FileDelta): boolean { + return delta.oldPath !== delta.newPath || delta.before?.mode !== delta.after?.mode || + !textFile(delta.before) || !textFile(delta.after); +} +const empty = (): Evidence => ({ owners: [], outOfScope: [] }); +function classify(evidence: Evidence): Pick { + const { owners, outOfScope } = evidence; + if (!owners.length || owners.includes(null)) return { row: 'Unplanned', scope: 'unplanned' }; + if (owners.length > 1) return { row: 'Ambiguous', scope: 'ambiguous' }; + const owner = owners[0]!; + return { row: owner, scope: outOfScope.includes(owner) ? 'out-of-scope' : 'in-scope' }; +} + +export interface LinkingLimits { maxLines?: number; maxSegments?: number; maxReferences?: number; maxDurationMs?: number } + +/** Replays a linear history. Commit messages and Plan-Item trailers are never trusted. */ +export function linkHistory(plan: Plan, history: History, ledger: ReadonlyMap, pathKey: (path: string) => string, limits: LinkingLimits = {}): Segment[] { + if (typeof pathKey !== 'function') throw new Error('Known checkout path identity is required.'); + const budget = (value: number | undefined, ceiling: number, name: string) => { + const limit = value ?? ceiling; + if (!Number.isSafeInteger(limit) || limit < 1 || limit > ceiling) throw new Error(`Invalid ${name} budget.`); + return limit; + }; + const maxLines = budget(limits.maxLines, 100_000, 'line'); + const maxSegments = budget(limits.maxSegments, 100_000, 'segment'); + const maxReferences = budget(limits.maxReferences, 1_000_000, 'reference'); + const deadline = performance.now() + budget(limits.maxDurationMs, 30_000, 'duration'); + const remaining = () => { + const ms = deadline - performance.now(); + if (ms <= 0) throw new Error('Linking exceeded its overall deadline.'); + return Math.max(1, Math.min(2000, Math.ceil(ms))); + }; + let lineCount = 0, segmentCount = 0, references = 0, characters = 0; + const charge = (count: number) => { + remaining(); references += count; + if (references > maxReferences) throw new Error('Linking exceeds its cumulative reference/work budget.'); + }; + const chargeText = (count: number) => { + characters += count; + if (characters > 32 * 1024 * 1024) throw new Error('Linking exceeds its cumulative text/origin character budget.'); + }; + const lines = (text: string | null | undefined): string[] => { + remaining(); + if (!text) return []; + chargeText(text.length); + const result: string[] = []; + for (let start = 0; start < text.length;) { + if (++lineCount > maxLines) throw new Error('Linking exceeds its cumulative line budget.'); + remaining(); + const newline = text.indexOf('\n', start), end = newline < 0 ? text.length : newline + 1; + result.push(text.slice(start, end)); start = end; + } + return result; + }; + const combine = (evidence: readonly Evidence[]): Evidence => { + const owners = new Set(), outOfScope = new Set(); + for (const entry of evidence) { + charge(entry.owners.length + entry.outOfScope.length); + for (const owner of entry.owners) owners.add(owner); + for (const owner of entry.outOfScope) outOfScope.add(owner); + } + return { owners: [...owners], outOfScope: [...outOfScope] }; + }; + const mergeOrigins = (tracked: readonly TrackedLine[]): string[] => { + const origins = new Set(); + for (const line of tracked) { charge(line.origins.length); for (const id of line.origins) origins.add(id); } + return [...origins]; + }; + const files = new Map(); + const removed = new Map(); + // Deletions retain metadata even after the file leaves the tree. + const metadata = new Map(); + let parent = history.base; + for (const commit of history.commits) { + remaining(); + if (commit.parent !== parent) throw new Error('Linking requires a contiguous linear history.'); + parent = commit.sha; + const owner = ledger.get(commit.sha) ?? null; + if (owner !== null && !plan.items.some(item => item.id === owner)) throw new Error(`Unknown ledger item: ${owner}`); + for (const delta of commit.files) { + remaining(); + const oldPath = delta.oldPath; + const item = plan.items.find(item => item.id === owner); + const declared = new Set(item?.files.flatMap(file => [file.path, ...(file.renamed_from ? [file.renamed_from] : [])]).map(pathKey)); + const touched = [delta.oldPath, delta.newPath].filter((path): path is string => path !== null); + const current: Evidence = { owners: [owner], outOfScope: owner !== null && touched.some(path => !declared.has(pathKey(path))) ? [owner] : [] }; + let previous = oldPath ? files.get(oldPath) : undefined; + if (!previous) previous = { + lines: lines(textFile(delta.before) ? delta.before!.text : '').map((text, i) => { + charge(1); chargeText((oldPath?.length ?? 0) + 12); + return { text, evidence: empty(), origins: [origin(oldPath!, i)], moved: empty() }; + }), + metadata: empty(), metadataPaths: oldPath ? [oldPath] : [], + }; + const next: TrackedLine[] = []; + const changes = diffArrays(previous.lines.map(line => line.text), lines(textFile(delta.after) ? delta.after!.text : ''), { timeout: remaining() }); + if (!changes) throw new Error('Line attribution exceeded the diff time budget.'); + let cursor = 0; + for (let n = 0; n < changes.length; n++) { + const change = changes[n]!; + if (!change.added && !change.removed) { for (const line of previous.lines.slice(cursor, cursor + change.value.length)) next.push(line); cursor += change.value.length; continue; } + const deleted = change.removed ? previous.lines.slice(cursor, cursor + change.value.length) : []; + if (change.removed) cursor += change.value.length; + const evidence = combine([...deleted.map(line => line.evidence), current]); + const origins = mergeOrigins(deleted); + for (const id of origins) removed.set(id, evidence); + const added = change.added ? change : changes[n + 1]?.added ? changes[++n]! : undefined; + if (added) { + const moved = combine(deleted.map(line => line.moved)); + for (const text of added.value) { charge(origins.length + 1); next.push({ text, evidence, origins, moved }); } + } + } + if (oldPath && delta.newPath && oldPath !== delta.newPath) { + for (let i = 0; i < next.length; i++) { + const line = next[i]!; + next[i] = { ...line, moved: combine([line.moved, current]) }; + charge(line.origins.length); + for (const id of line.origins) if (!removed.has(id)) removed.set(id, combine([line.evidence, current])); + } + } + const metadataEvidence = metadataChange(delta) + ? combine([previous.metadata, ...touched.map(path => metadata.get(path) ?? empty()), current]) + : previous.metadata; + const metadataPaths = unique([...previous.metadataPaths, ...touched]); + charge(metadataPaths.length); + for (const path of metadataPaths) metadata.set(path, combine([metadata.get(path) ?? empty(), metadataEvidence])); + if (oldPath) files.delete(oldPath); + if (delta.newPath) files.set(delta.newPath, { lines: next, metadata: metadataEvidence, metadataPaths }); + } + } + if (parent !== history.head) throw new Error('History does not end at the requested head.'); + const segments: Segment[] = []; + for (const delta of history.final) { + remaining(); + const path = delta.newPath ?? delta.oldPath!; + const tracked = delta.newPath ? files.get(delta.newPath) : undefined; + const oldLines = lines(textFile(delta.before) ? delta.before!.text : ''); + const newLines = lines(textFile(delta.after) ? delta.after!.text : ''); + let oldIndex = 0, newIndex = 0, hunk = 0; + const fileSegments: Segment[] = []; + const affectedPaths = unique([delta.oldPath, delta.newPath].filter((p): p is string => p !== null)); + const push = (part: Omit, evidence: Evidence) => { + remaining(); + if (++segmentCount > maxSegments) throw new Error('Linking exceeds its cumulative segment budget.'); + fileSegments.push({ ...part, owners: evidence.owners, ...classify(evidence), sharesHunkWith: [] }); + }; + if (metadataChange(delta)) { + const evidence = combine(affectedPaths.map(path => metadata.get(path) ?? empty())); + push({ path, oldPath: delta.oldPath, kind: 'file', oldLine: null, newLine: null, + operation: null, context: '', hunk: -1, + content: JSON.stringify({ oldPath: delta.oldPath, newPath: delta.newPath, + oldMode: delta.before?.mode ?? null, newMode: delta.after?.mode ?? null, + oldObject: delta.before ? { kind: delta.before.mode === '160000' ? 'commit' : 'blob', oid: delta.before.oid } : null, + newObject: delta.after ? { kind: delta.after.mode === '160000' ? 'commit' : 'blob', oid: delta.after.oid } : null }), + }, evidence); + } + const finalChanges = diffArrays(oldLines, newLines, { timeout: remaining() }); + if (!finalChanges) throw new Error('Final diff exceeded the time budget.'); + for (const change of finalChanges) { + if (!change.added && !change.removed) { + oldIndex += change.value.length; newIndex += change.value.length; + if (change.value.length > 6) hunk++; + continue; + } + for (const text of change.value) { + const trackedLine = tracked?.lines[newIndex]; + const evidence = change.added + ? trackedLine?.evidence.owners.length ? trackedLine.evidence : trackedLine?.moved ?? empty() + : removed.get(origin(delta.oldPath!, oldIndex)) ?? empty(); + const context = delta.contexts.find(range => change.added + ? newIndex + 1 >= range.newStart && newIndex + 1 < range.newStart + range.newCount + : oldIndex + 1 >= range.oldStart && oldIndex + 1 < range.oldStart + range.oldCount)?.name ?? ''; + push({ path, oldPath: delta.oldPath, kind: 'text', content: text, context, hunk, + oldLine: change.removed ? oldIndex + 1 : null, newLine: change.added ? newIndex + 1 : null, + operation: change.added ? '+' : '-', + }, evidence); + if (change.added) newIndex++; else oldIndex++; + } + } + // Adjacent lines with identical ownership/context form one segment. + const grouped: Segment[] = []; + let groupedLineCount = 0; + for (const part of fileSegments) { + remaining(); + const last = grouped.at(-1); + if (last && part.kind === 'text' && last.kind === 'text' && last.hunk === part.hunk && + last.operation === part.operation && last.context === part.context && last.scope === part.scope && + (part.operation === '+' ? last.newLine! + groupedLineCount === part.newLine : last.oldLine! + groupedLineCount === part.oldLine) && + JSON.stringify(last.owners) === JSON.stringify(part.owners)) { last.content += part.content; groupedLineCount++; } + else { grouped.push({ ...part }); groupedLineCount = part.kind === 'text' ? 1 : 0; } + } + const hunkRows = new Map>>(); + for (const part of grouped) { + charge(part.owners.length + 1); + let rows = hunkRows.get(part.hunk); + if (!rows) { rows = new Map(); hunkRows.set(part.hunk, rows); } + let owners = rows.get(part.row); + if (!owners) { owners = new Set(); rows.set(part.row, owners); } + for (const owner of part.owners) if (owner !== null) owners.add(owner); + } + const sharing = new Map>(); + for (const [hunk, rows] of hunkRows) { + const byRow = new Map(); sharing.set(hunk, byRow); + for (const row of rows.keys()) { + const owners = new Set(); + for (const [otherRow, otherOwners] of rows) { + charge(1); + if (row === otherRow) continue; + charge(otherOwners.size); + for (const owner of otherOwners) owners.add(owner); + } + byRow.set(row, [...owners]); + } + } + for (const part of grouped) { + const owners = sharing.get(part.hunk)!.get(part.row)!; + charge(owners.length); + part.sharesHunkWith = [...owners]; + } + for (const part of grouped) segments.push(part); + } + remaining(); + return segments; +} diff --git a/core/parse-v1.ts b/core/parse-v1.ts new file mode 100644 index 0000000..a1c74ad --- /dev/null +++ b/core/parse-v1.ts @@ -0,0 +1,118 @@ +import { isAlias, isMap, isScalar, isSeq, parseDocument } from 'yaml'; + +const MAX_BYTES = 1024 * 1024; +const number = /^-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?$/u; +function reject(message: string): never { throw new Error(message); } +function numeric(text: string): number { + if (!number.test(text)) reject('Non-JSON numeric scalar.'); + const n = Number(text); + if (!Number.isFinite(n) || (Number.isInteger(n) && !Number.isSafeInteger(n))) reject('Number is outside the safe range.'); + if (Number.isInteger(n)) { + const [mantissa, exponentText = '0'] = text.toLowerCase().split('e'); + const fractionLength = mantissa!.split('.')[1]?.length ?? 0; + let digits = mantissa!.replace(/[-.]/gu, '').replace(/^0+/u, ''); + let exponent = Number(exponentText) - fractionLength; + while (digits.endsWith('0')) { digits = digits.slice(0, -1); exponent++; } + if (digits && (exponent < 0 || exponent > 16 || digits.length + exponent > 16 || + Number((text.startsWith('-') ? '-' : '') + digits + '0'.repeat(exponent)) !== n)) + reject('Integer is not exactly representable.'); + } + return n; +} +/** Frozen v1 syntax contract. No runtime I/O or alias/object construction. */ +export function parseV1(input: string | Uint8Array, format: 'json' | 'yaml'): unknown { + if (typeof input === 'string' && input.length > MAX_BYTES) reject('Input size exceeds 1 MiB.'); + const bytes = typeof input === 'string' ? new TextEncoder().encode(input) : input; + if (bytes.byteLength > MAX_BYTES) reject('Input size exceeds 1 MiB.'); + const source = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes); + if (typeof input === 'string' && input !== source) reject('Invalid UTF-8 source string.'); + if (format === 'json') return parseJson(source); + if (format !== 'yaml') reject('Unknown input format.'); + const doc = parseDocument(source, { uniqueKeys: false, version: '1.2', strict: true }); + if (doc.directives?.yaml.version !== '1.2') reject('Only YAML 1.2 is supported.'); + if (doc.errors.length || doc.warnings.length) reject([...doc.errors, ...doc.warnings].map(e => e.message).join('; ')); + function convert(node: unknown, depth: number): unknown { + if (isAlias(node)) reject('Alias resolution is disabled.'); + if (!isScalar(node) && !isSeq(node) && !isMap(node)) reject('Empty or unsupported YAML scalar.'); + if (node.anchor || node.tag) reject('Anchors and explicit tags are prohibited.'); + if (isScalar(node)) { + const raw = node.source ?? ''; + if (node.type !== 'PLAIN') { + if (typeof node.value !== 'string') reject('Quoted scalar must be a string.'); + return node.value; + } + if (!raw) reject('Empty implicit YAML scalar.'); + if (raw === 'null') return null; + if (raw === 'true' || raw === 'false') return raw === 'true'; + if (typeof node.value === 'number') return numeric(raw); + if (typeof node.value !== 'string') reject('Only exact JSON literal scalars are permitted.'); + if (/^[+-]?0[bBoOxX][0-9a-fA-F_]+$/u.test(raw)) reject('Non-JSON numeric scalar.'); + // YAML implementations differ on numeric separators; v1 explicitly forbids them. + if (/^[+-]?(?:[0-9][0-9_]*(?:\.[0-9_]*)?|\.[0-9_]+)(?:[eE][+-]?[0-9_]+)?$/u.test(raw) && raw.includes('_')) reject('Non-JSON numeric scalar.'); + return node.value; + } + if (depth >= 50) reject('Container depth exceeds 50.'); + if (isSeq(node)) return node.items.map(child => convert(child, depth + 1)); + const result: Record = Object.create(null); + for (const pair of node.items) { + if (!isScalar(pair.key)) reject('Mapping keys must be strings.'); + const key = convert(pair.key, depth + 1); + if (typeof key !== 'string') reject('Mapping keys must be strings.'); + if (key === '<<') reject('Merge keys are prohibited.'); + if (Object.hasOwn(result, key)) reject(`Duplicate key: ${key}`); + result[key] = convert(pair.value, depth + 1); + } + return result; + } + return convert(doc.contents, 0); +} + +/** Recursive descent detects decoded duplicate keys before object creation. */ +function parseJson(source: string): unknown { + let i = 0; + const whitespace = () => { while (i < source.length && /[ \t\r\n]/u.test(source[i]!)) i++; }; + const string = (): string => { + const start = i++; + while (i < source.length) { + const c = source[i++]; + if (c === '"') return JSON.parse(source.slice(start, i)) as string; + if (c === '\\') i++; + } + return reject('Unterminated JSON string.'); + }; + function value(depth: number): unknown { + whitespace(); + const c = source[i]; + if (c === '"') return string(); + if (c === '{' || c === '[') { + if (depth >= 50) reject('Container depth exceeds 50.'); + i++; whitespace(); + const object = c === '{', close = object ? '}' : ']'; + const entries: Record = Object.create(null), items: unknown[] = []; + if (source[i] === close) { i++; return object ? entries : items; } + while (true) { + whitespace(); + if (object) { + if (source[i] !== '"') reject('JSON object key must be a string.'); + const key = string(); whitespace(); + if (source[i++] !== ':') reject('Expected JSON colon.'); + if (Object.hasOwn(entries, key)) reject(`Duplicate key: ${key}`); + entries[key] = value(depth + 1); + } else items.push(value(depth + 1)); + whitespace(); + if (source[i] === close) { i++; return object ? entries : items; } + if (source[i++] !== ',') reject('Expected JSON comma.'); + } + } + for (const [literal, parsed] of [['true', true], ['false', false], ['null', null]] as const) { + if (source.startsWith(literal, i)) { i += literal.length; return parsed; } + } + const token = /^-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?/u.exec(source.slice(i)); + if (!token) reject('Invalid JSON value.'); + i += token[0].length; + return numeric(token[0]); + } + const result = value(0); whitespace(); + if (i !== source.length) reject('Unexpected trailing JSON input.'); + return result; +} diff --git a/core/plan.ts b/core/plan.ts new file mode 100644 index 0000000..3bfec07 --- /dev/null +++ b/core/plan.ts @@ -0,0 +1,347 @@ +import { Ajv2020 } from 'ajv/dist/2020.js'; +import { identityKey, type PlanIdentity } from './identity.ts'; +import { parseV1 } from './parse-v1.ts'; +import registry from '../schema/versions.json' with { type: 'json' }; +import planSchema from '../schema/versions/1/plan.schema.json' with { type: 'json' }; +import editSchema from '../schema/versions/1/plan-edit.schema.json' with { type: 'json' }; + +export interface PlanFile { + path: string; kind: 'add' | 'edit' | 'delete' | 'rename'; + renamed_from: string | null; change: string; +} +export interface Check { type: 'cmd' | 'check'; text: string } +export interface PlanItem { + id: string; title: string; intent: string; files: PlanFile[]; + acceptance: Check[]; depends_on: string[]; +} +export interface Plan { + schema_version: 1; issue: number; revision: number; summary: string; + items: PlanItem[]; questions: string[]; +} +export interface PlanEdit { + op: 'add_item' | 'remove_item' | 'set_field' | 'add_file' | 'update_file' | + 'remove_file' | 'add_check' | 'remove_check' | 'set_depends'; + item: string; summary: string; reason: string; + field: 'title' | 'intent' | null; value: string | null; file: PlanFile | null; + check: Check | null; check_index: number | null; + depends_on: string[] | null; new_item: PlanItem | null; +} +export interface EditReply { + schema_version: 1; base_revision: number; reply: string; edits: PlanEdit[]; +} +export interface Diagnostic { code: string; message: string; item?: string } +export type BaseEntry = { path: string; kind: 'file' | 'gitlink' } | { path: string; kind: 'symlink'; target: string }; +export interface PlanContext { + identity: PlanIdentity; + /** Leaf entries from the trusted immutable base tree. No directories. */ + baseEntries: readonly BaseEntry[]; + /** Trusted checkout identity function; must preserve path components and separators. + * Supply actual filesystem case/Unicode equivalence, never a guessed platform default. */ + pathKey: (path: string) => string; + /** Exact complete argv arrays, already approved in Settings. */ + allowedCommands: readonly (readonly string[])[]; + issue: number; +} +export interface Validation { errors: Diagnostic[]; warnings: Diagnostic[] } +export class PlanError extends Error { + readonly diagnostics: Diagnostic[]; + constructor(diagnostics: Diagnostic[]) { + super(diagnostics.map(d => `${d.item ? `${d.item}: ` : ''}${d.message}`).join('\n')); + this.name = 'PlanError'; + this.diagnostics = diagnostics; + } +} +const ajv = new Ajv2020({ allErrors: true, strict: true }); +const v1 = registry.versions['1']; +if (v1.validator !== 'v1' || v1.plan !== 'versions/1/plan.schema.json' || + v1.edit !== 'versions/1/plan-edit.schema.json' || v1.semantics !== 'versions/1/semantics.md') + throw new Error('Unsupported v1 registry dispatch.'); +const planShape = ajv.compile(planSchema); +const replyShape = ajv.compile(editSchema); + +function fail(code: string, message: string): never { throw new PlanError([{ code, message }]); } +export function assertPlan(value: unknown): asserts value is Plan { + if (!planShape(value)) throw new PlanError((planShape.errors ?? []).map(e => ({ + code: 'schema', message: `${e.instancePath || '/'} ${e.message}`, + }))); +} +export function assertEditReply(value: unknown): asserts value is EditReply { + if (!replyShape(value)) throw new PlanError((replyShape.errors ?? []).map(e => ({ + code: 'edit-schema', message: `${e.instancePath || '/'} ${e.message}`, + }))); +} +/** Canonical paths make duplicate checks reliable and exclude Git metadata. */ +export function isRepoPath(path: string): boolean { + return path.length > 0 && !/[\\:\p{Cc}]/u.test(path) && + path.split('/').every(part => part !== '' && part !== '.' && part !== '..' && + part.toLowerCase() !== '.git'); +} + +/** Small literal-argv grammar, deliberately not a shell parser. Never executes. */ +export function commandArgv(command: string): string[] { + if (/[\\\p{Cc}]/u.test(command)) + fail('command-syntax', 'Commands must contain literal arguments, not shell syntax.'); + const argv: string[] = []; + let word = '', quote = '', started = false; + for (const char of command) { + if (quote) { + if (char === quote) quote = ''; else word += char; + started = true; + } else if (char === '"' || char === "'") { quote = char; started = true; } + else if (char === ' ') { + if (started) { argv.push(word); word = ''; started = false; } + } else { + if (/[;&|<>`$\\*?{}~\[\]()!#]/u.test(char)) fail('command-syntax', 'Shell syntax is not allowed.'); + word += char; started = true; + } + } + if (quote) fail('command-syntax', 'Unclosed quote in command.'); + if (started) argv.push(word); + if (!argv[0]) + fail('command-syntax', 'A command must start with an executable.'); + return argv; +} +export function commandAllowed(argv: readonly string[], allowed: PlanContext['allowedCommands']): boolean { + return allowed.some(entry => entry.length > 0 && entry.length === argv.length && entry.every((part, i) => argv[i] === part)); +} + +function parents(path: string): string[] { + const parts = path.split('/'); return parts.slice(0, -1).map((_, i) => parts.slice(0, i + 1).join('/')); +} +function linkTarget(path: string, target: string, key: (path: string) => string, entries: ReadonlyMap): string | null { + if (!target || target.startsWith('/') || /[\\:\p{Cc}]/u.test(target)) return null; + const parts = path.split('/').slice(0, -1); + const components = target.split('/'); + for (const [index, component] of components.entries()) { + if (component === '' || component === '.') continue; + if (component === '..') { if (!parts.length) return null; parts.pop(); } + else { + parts.push(component); + try { + const entry = entries.get(key(parts.join('/'))); + if (entry && (entry.kind === 'symlink' || index < components.length - 1)) return null; + } catch { return null; } + } + } + try { return parts.length ? key(parts.join('/')) : ''; } catch { return null; } +} + +export function validatePlan(value: unknown, context: PlanContext): Validation { + try { assertPlan(value); } catch (error) { + if (error instanceof PlanError) return { errors: error.diagnostics, warnings: [] }; + throw error; + } + const validators: Record Validation> = { v1: validateV1 }; + const validator = validators[registry.versions[value.schema_version].validator]; + if (!validator) return { errors: [{ code: 'version', message: 'Unsupported semantic validator.' }], warnings: [] }; + return validator(value, context); +} + +function validateV1(value: Plan, context: PlanContext): Validation { + try { assertPlan(value); } catch (error) { + if (error instanceof PlanError) return { errors: error.diagnostics, warnings: [] }; + throw error; + } + const errors: Diagnostic[] = [], warnings: Diagnostic[] = []; + const error = (code: string, message: string, item?: string) => errors.push({ code, message, item }); + try { identityKey(context.identity); } catch (err) { error('context', (err as Error).message); } + if (!Number.isSafeInteger(context.issue) || context.issue < 1) + error('context', 'A selected issue is required.'); + if (value.issue !== context.issue) + error('issue', 'Plan issue does not match the selected issue.'); + if (![value.issue, value.revision].every(Number.isSafeInteger)) error('integer-range', 'Issue and revision must be safe integers.'); + if (typeof context.pathKey !== 'function' || !Array.isArray(context.baseEntries)) { + error('context', 'Known checkout path identity and typed base entries are required.'); + return { errors, warnings }; + } + let projectedPlan: Plan; + const entries = new Map(); + const key = (path: string): string => { + if (!isRepoPath(path)) throw new Error(`Unsafe or non-canonical path: ${path}`); + const identity = context.pathKey(path); + if (!isRepoPath(identity) || identity.split('/').length !== path.split('/').length) + throw new Error(`Invalid filesystem identity for ${path}`); + return identity; + }; + // Validate on identity keys only; preserve source spelling in the returned plan. + try { + for (const entry of context.baseEntries) { + if (!['file', 'symlink', 'gitlink'].includes(entry.kind) || + (entry.kind === 'symlink' && typeof entry.target !== 'string')) throw new Error('Invalid base entry type.'); + const path = key(entry.path); + if (entries.has(path)) throw new Error(`Colliding base entries: ${entry.path}`); + entries.set(path, { ...entry, path }); + } + projectedPlan = { ...value, items: value.items.map(item => ({ ...item, files: item.files.map(file => ({ + ...file, path: key(file.path), renamed_from: file.renamed_from === null ? null : key(file.renamed_from), + })) })) }; + } catch (err) { + error('path', (err as Error).message); return { errors, warnings }; + } + const paths = new Set(entries.keys()); + for (const path of paths) { + if (parents(path).some(parent => paths.has(parent))) error('context', `Base leaf occupies a parent: ${path}`); + } + if (errors.some(e => e.code === 'context')) return { errors, warnings }; + const producers = new Map(); + const ancestry = new Map>(); + for (const item of projectedPlan.items) { + if (ancestry.has(item.id)) error('duplicate-id', `Duplicate item ID ${item.id}.`, item.id); + const ancestors = new Set(); + for (const dep of item.depends_on) { + if (dep === item.id || !ancestry.has(dep)) error('dependency', `${dep} must be an earlier item.`, item.id); + ancestors.add(dep); + for (const ancestor of ancestry.get(dep) ?? []) ancestors.add(ancestor); + } + if (new Set(item.depends_on).size !== item.depends_on.length) + error('dependency', 'Dependencies must be unique.', item.id); + ancestry.set(item.id, ancestors); + const touched = new Set(); + const beforeErrors = errors.length; + for (const file of item.files) { + const involved = file.kind === 'rename' ? [file.path, file.renamed_from ?? ''] : [file.path]; + if ((file.kind === 'rename') !== (file.renamed_from !== null)) + error('rename-source', 'Only renames require renamed_from.', item.id); + for (const path of involved) { + if (!isRepoPath(path)) error('path', `Unsafe or non-canonical path: ${path}`, item.id); + if (touched.has(path)) error('duplicate-path', `Path used twice in one item: ${path}`, item.id); + if ([...touched].some(other => other.startsWith(`${path}/`) || path.startsWith(`${other}/`))) + error('path-parent', `Overlapping file paths in one item: ${path}`, item.id); + touched.add(path); + // Parent entries (including symlinks and submodules) cannot be traversed. + if (path.split('/').slice(0, -1).some((_, i, parts) => paths.has(parts.slice(0, i + 1).join('/')))) + error('path-parent', `A file, symlink, or submodule blocks a parent of ${path}.`, item.id); + const producer = producers.get(path); + if (producer && !ancestors.has(producer)) + error('dependency', `${path} depends on ${producer}.`, item.id); + } + const source = file.kind === 'rename' ? file.renamed_from! : file.path; + if (entries.get(source)?.kind === 'gitlink') error('gitlink', 'Gitlinks are review-only in v1.', item.id); + if (file.kind !== 'add' && !paths.has(source)) error('missing-file', `Missing source: ${source}`, item.id); + if ((file.kind === 'add' || file.kind === 'rename') && + (paths.has(file.path) || [...paths].some(path => path.startsWith(`${file.path}/`)))) + error('existing-file', `Destination is occupied: ${file.path}`, item.id); + } + for (const file of item.files) { + const source = file.kind === 'rename' ? file.renamed_from! : file.path; + const entry = entries.get(source); + if (entry?.kind !== 'symlink') continue; + // New target values are intentionally not inferred from prose. Runtime must + // audit link lineage, old/new targets, and target mutations before commits. + for (const location of new Set([source, file.path])) { + const target = linkTarget(location, entry.target, key, entries); + const traversesLink = target !== null && [...parents(target), target].some(p => entries.get(p)?.kind === 'symlink'); + if (target === null || traversesLink) { + if ((file.kind !== 'delete' && file.kind !== 'edit') || item.files.length !== 1) + error('symlink-target', 'Unsafe link repair must be isolated in its own item; retained rename targets must be safe.', item.id); + continue; // Deletion or replacement may repair an unsafe old link. + } + for (const other of item.files) { + if (other === file) continue; + const otherPaths = other.kind === 'rename' ? [other.path, other.renamed_from!] : [other.path]; + if (otherPaths.some(p => p === target || target === '' || p.startsWith(`${target}/`) || target.startsWith(`${p}/`))) + error('symlink-target', 'A link and its writable target cannot share an invocation.', item.id); + } + } + } + if (errors.length === beforeErrors) for (const file of item.files) { + const sourceEntry = entries.get(file.kind === 'rename' ? file.renamed_from! : file.path); + if (file.kind === 'delete' || file.kind === 'rename') { + const source = file.kind === 'rename' ? file.renamed_from! : file.path; + paths.delete(source); entries.delete(source); producers.set(source, item.id); + } + if (file.kind === 'add' || file.kind === 'rename') { + paths.add(file.path); producers.set(file.path, item.id); + entries.set(file.path, file.kind === 'rename' ? { ...sourceEntry!, path: file.path } : { path: file.path, kind: 'file' }); + } + } + if (!item.acceptance.some(check => check.type === 'cmd')) + warnings.push({ code: 'no-test-command', message: 'No test command.', item: item.id }); + for (const check of item.acceptance.filter(check => check.type === 'cmd')) { + try { + const argv = commandArgv(check.text); + if (!commandAllowed(argv, context.allowedCommands)) warnings.push({ + code: 'command-not-allowed', message: `Command cannot run until allowed: ${check.text}`, item: item.id, + }); + } catch (err) { + if (!(err instanceof PlanError)) throw err; + errors.push(...err.diagnostics.map(d => ({ ...d, item: item.id }))); + } + } + } + if (value.questions.length) warnings.push({ code: 'open-questions', message: 'The plan has unanswered questions.' }); + return { errors, warnings }; +} + +export function importPlan(source: string | Uint8Array, format: 'json' | 'yaml', context: PlanContext, revision: number): { plan: Plan; warnings: Diagnostic[] } { + if (!Number.isSafeInteger(revision) || revision < 1) fail('revision', 'Revision must be a positive safe integer.'); + let data: unknown; + try { data = parseV1(source, format); } + catch (error) { fail('parse', `Cannot parse plan: ${(error as Error).message}`); } + assertPlan(data); // No migrations exist yet: only released v1 is accepted. + const result = validatePlan(data, context); + if (result.errors.length) throw new PlanError(result.errors); + return { plan: { ...data, revision }, warnings: result.warnings }; +} + +const payloads = ['field', 'value', 'file', 'check', 'check_index', 'depends_on', 'new_item'] as const; +const used: Record = { + add_item: ['new_item'], remove_item: [], set_field: ['field', 'value'], + add_file: ['file'], update_file: ['file'], remove_file: ['value'], + add_check: ['check'], remove_check: ['check_index'], set_depends: ['depends_on'], +}; +/** Captured by the trusted server when requesting suggestions, not when Apply is clicked. */ +export interface SuggestionBinding { + identity: PlanIdentity; schemaVersion: number; baseRevision: number; issue: number; +} +/** Pure transformation. The store must load this binding by opaque suggestion ID, + * verify cancellation/consumption, and CAS revision plus consume/invalidate IDs atomically. + * This function cannot provide persistence, replay prevention, or concurrency control. */ +export function applySuggestion(plan: Plan, reply: unknown, index: number, context: PlanContext, binding: SuggestionBinding): Plan { + assertPlan(plan); assertEditReply(reply); + if (!binding || identityKey(binding.identity) !== identityKey(context.identity) || + binding.schemaVersion !== plan.schema_version || binding.baseRevision !== reply.base_revision || + binding.issue !== plan.issue || context.issue !== plan.issue) + fail('suggestion-identity', 'Suggestion does not belong to this plan context.'); + if (reply.base_revision !== plan.revision) fail('stale-revision', 'Suggestion was drafted against a different revision.'); + if (!Number.isInteger(index) || !reply.edits[index]) fail('edit-index', 'Suggestion index is out of range.'); + const edit = reply.edits[index]!; + for (const key of payloads) { + if (used[edit.op].includes(key) ? edit[key] === null : edit[key] !== null) + fail('edit-payload', `${edit.op} has an invalid ${key} payload.`); + } + const next = structuredClone(plan); + const itemIndex = next.items.findIndex(item => item.id === edit.item); + if (edit.op === 'add_item') { + if (itemIndex !== -1 || edit.new_item!.id !== edit.item) fail('edit-item', 'New item ID must be unique and match item.'); + next.items.push(edit.new_item!); + } else { + if (itemIndex < 0) fail('edit-item', 'Target item does not exist.'); + const item = next.items[itemIndex]!; + switch (edit.op) { + case 'remove_item': next.items.splice(itemIndex, 1); break; + case 'set_field': item[edit.field!] = edit.value!; break; + case 'add_file': item.files.push(edit.file!); break; + case 'update_file': { + const i = item.files.findIndex(file => file.path === edit.file!.path); + if (i < 0) fail('edit-file', 'File to update does not exist.'); + item.files[i] = edit.file!; break; + } + case 'remove_file': { + const i = item.files.findIndex(file => file.path === edit.value); + if (i < 0) fail('edit-file', 'File to remove does not exist.'); + item.files.splice(i, 1); break; + } + case 'add_check': item.acceptance.push(edit.check!); break; + case 'remove_check': + if (edit.check_index! >= item.acceptance.length) fail('edit-check', 'Check index is out of range.'); + item.acceptance.splice(edit.check_index!, 1); break; + case 'set_depends': item.depends_on = edit.depends_on!; break; + } + } + const result = validatePlan(next, context); + if (result.errors.length) throw new PlanError(result.errors); + if (!Number.isSafeInteger(next.revision + 1)) fail('revision', 'Revision limit reached.'); + next.revision++; + return structuredClone(next); +} diff --git a/docs/implementation/build-step-1.md b/docs/implementation/build-step-1.md new file mode 100644 index 0000000..dd4d09d --- /dev/null +++ b/docs/implementation/build-step-1.md @@ -0,0 +1,110 @@ +# Build step 1: plan format and linking foundation + +Started from PR #1 and updated to its merged baseline `91fd2b4` on main. Work follows the approved build order; the user explicitly chose it over prioritizing plan drafting in the UI. + +## Delivered in this slice + +- A single private TypeScript package with pinned dependencies, Vitest, strict typechecking, and CI on Node 26.7.0. +- Draft 2020-12 validation against the existing v1 schemas. Imports assign the caller's next revision; unsupported versions fail instead of being silently converted. +- Projected file operations, dependency checks, path restrictions, command argv parsing, warnings, and safe individual suggestion transformations. +- A read-only Git adapter and a pure attribution engine. Ownership comes only from a supplied ledger, never a trailer. Line edits retain earlier owners; changes involving a foreign commit conservatively remain Unplanned. +- Text segments, shared-hunk labels, and evidence cards for path/mode/binary/empty/symlink/submodule changes. +- Approval fingerprints include item data, exact changed content (CRLF normalized), and Git function context. They ignore line numbers and commit IDs, and staleness propagates through dependencies. Duplicate-segment choices expire if copy count changes. + +## Decisions + +**Reuse.** Inspected AgentDiff's `agentdiff/plan_validator.py` and `agentdiff/diff_parser.py` on 2026-09-22. Its file grouping and plan format do not provide the ledger-backed line ancestry needed here. No AgentDiff code was copied. Use Ajv for JSON Schema, `yaml` for YAML, and `diff` for bounded Myers line comparison. + +**Module boundaries.** `core` has no runtime I/O. `git` reads repository objects, never the worktree's file targets. Future `runner/store` remains the sole persistent writer. Empty scaffolds for agents/github/web are intentionally not shipped. + +**History scope.** Linear histories only in this slice. Reject merges and non-ancestor bases rather than guessing ownership. Keep rename provenance so a later edit that defeats final rename detection cannot erase the move's owner. + +**Command grammar.** One executable with literal argv. Space-separated arguments and single/double quotes are supported; shell syntax outside quotes is rejected. Quoted punctuation (for example a test regex) is literal data. The library never executes a command. + +**Persistence.** Caller supplies typed immutable base entries, the actual checkout path-identity function, stable repository/task/plan IDs, selected issue, and trusted ledger. Suggested edits return a new revision; atomic compare-and-swap and revision allocation are requirements for the later store integration. + +## Validation + +`npm test` runs schema fixtures and real Git repositories: the documented invalid plans; projected add/edit/rename/delete chains; bad dependencies/paths; malformed suggestions; stale revisions; two owners in one hunk; forged trailers; out-of-scope changes; pure deletions; overlapping edits; reverted work; all six non-text change kinds; literal filenames; clean rebases with remapped ledger; stale checks/dependents; whitespace/context changes; assignment and duplicate-copy expiry; and rename provenance when final rename detection is lost. + +A focused independent review found BOM-only changes could disappear because the default UTF-8 decoder strips the mark. A real-Git regression first failed, then passed with BOM-preserving decoding. Invalid UTF-8 filenames fail instead of being silently replaced. A separate failing fixture showed `diff.ignoreSubmodules=all` could hide gitlinks; reads now force submodule visibility and repository-wide paths, with regression coverage for relative-diff settings too. + +The rename case first failed (moved lines became Unplanned), then passed after the provenance fix. `npm run typecheck` checks all source and tests. The public examples and shared schema definitions are checked on every test run. + +## Review round 1 + +Copilot reported two findings. Fixed scope after rename: scope now travels with each owning change at its actual path, instead of comparing all segments against both ends of the final rename. Real rename/edit and rename/delete cases failed before the fix and pass after it; declaring only the historical name does not authorize the destination. + +Declined the claim that repository aliases can override `rev-parse`, `rev-list`, `diff`, or `cat-file`: these are built-in commands, and Git ignores aliases that shadow them. A scratch probe and a permanent adapter regression confirmed the shell alias never ran. See [Git's alias documentation](https://git-scm.com/docs/git-config/2.54.0). + +## Review round 2 + +Fixed inherited Git environment redirection. A regression with `GIT_DIR` pointing at a second repository initially returned the foreign repository's content. The adapter now drops inherited `GIT_*` variables, ignores global/system Git configuration, and disables lazy fetch and transport access. The same regression now reads only the requested repository. + +## Review rounds 3–4 + +Round 3 reviewed the pre-fix commit and repeated the environment finding; the existing fix resolved it. Round 4 exposed repository-local object alternates, reproduced by reading borrowed history from a second repository. The adapter now rejects an alternates file before resolving commits. + +Declined adding rename-only ownership to later text edits. The approved design represents a no-content rename as a separate file-change segment. A real-Git test confirms that a foreign rename remains an Unplanned file card while the later P2 text edit belongs to P2; the rename has not disappeared from review. Text edits before a rename still retain their line ancestry. Scope tests now explicitly require nonempty P2 rows. + +## Review round 5 + +Fixed unbounded accumulation of unique blobs across a history. The adapter checks object size before loading it and enforces a cumulative 64 MiB byte budget; callers can choose a smaller positive limit. A small-budget fixture failed before the fix, then passed with explicit rejection below the required total and success at the exact total. Repeated references to the same blob do not count twice. + +## Review round 6 + +Fixed symlinked object storage bypassing the alternates check. Root, loose-directory, and pack-directory symlink regressions all reproduced the problem. The adapter now inspects the object store without following symlinks, rejects links at any depth, and bounds inspection to 100,000 entries before resolving commits. The caller must keep storage stable during reads; concurrent filesystem isolation belongs to the runner. + +## Review round 7 + +Fixed eager directory listing before the inspection limit: use incremental directory reads with a one-entry buffer and close handles on every exit. Callers may lower the entry budget; the regression first failed and now rejects explicitly. + +Declined the YAML finding: the pinned yaml 2.9.1 implementation and types explicitly define `maxAliasCount: 0` as rejecting all aliases (`-1` disables limits). An otherwise valid plan with an alias fails with “Alias resolution is disabled”. + +Clarified the gitdir policy rather than rejecting normal linked worktrees. The caller selects and trusts the repository and its administrative directory; gitfiles and symlinked gitdirs are supported, while object-storage symlinks and alternates within that gitdir are rejected. Real-Git fixtures exercise both administrative layouts. Filesystem containment of an untrusted repository root belongs to the runner, not this read-only library. + +## Review round 8 + +Fixed aggregate diff retention outside the blob budget. The adapter limits total raw-diff and context-patch output to 8 MiB and total file records across commit and final diffs to 20,000. Both budgets can be lowered by callers. Small-budget cases first failed and now reject explicitly; the exact file-record boundary succeeds. + +## Remaining gates + +This is a working foundation, not a completed application or a claim that all implementation tasks are done. T18's pure validation/edit core is present; its agent adapters, import UI, and persistence are pending. Ledger storage, rebase mappings, and the read-only review screen remain next. The already-fixed GitHub check belongs to the later GitHub/runner integration. + +The design's manual real-issue assignment and timed go/no-go experiment have not been performed. Disposable Git histories are engineering tests, not evidence that plan-indexed review beats raw review. Write and commit the experiment protocol before using the real review screen for that comparison. Do not proceed to merging, agent execution, planning UI, queue, or learning until the documented gate passes. + +## Alignment with the merged v1 contract (#6) + +This library slice now selects retained v1 schemas and dispatches the registered `v1` semantics. CLI schema copies are never registered separately. Version-specific parser fixtures remain in `test/plan-v1.test.ts`; future versions need separate semantics and fixtures rather than editing acceptance rules in place. + +The importer accepts strings or UTF-8 bytes, bounds input to 1 MiB and nesting to 50 containers, detects decoded duplicate keys in both formats, and inspects YAML nodes without alias expansion. It rejects anchors, aliases, tags, merge keys, implicit empty values, non-JSON numeric spellings, unsafe/lossy integers, and extra documents. Exact complete argv approval replaces prefix matching; tokenizer rules follow the retained v1 grammar. + +`PlanContext` now requires stable identity, selected issue, typed base entries, and a trusted `pathKey` function that implements the checkout's actual case/Unicode identity. Unknown rules fail closed. The library never guesses filesystem behavior from the OS. Caller-provided identity must preserve components/separators and throw for unrepresentable paths. Projected membership, collisions, leaf checks, dependencies, and linking scope use these keys. Gitlinks cannot be authored. Existing symlink types survive renames; parent traversal, unsafe retained rename targets, and declared-link/writable-target overlap are rejected. No target or file type is inferred from plan prose. + +Suggestion transformations require the trusted identity/revision binding captured when the request began. A delayed response cannot apply to another plan with matching local IDs. Applying a card increments revision and thereby makes siblings stale; callers must regenerate remaining cards. **This pure API is not a server endpoint:** the store must load the binding by opaque suggestion ID, enforce cancellation/consumption, and atomically CAS plus consume/invalidate IDs. A caller must never construct the binding from UI/model claims at Apply time. + +Approval fingerprints and standalone choice keys include stable plan identity; fingerprints retain item IDs and file-change metadata. File cards now record typed blob/commit object IDs. Explicit null ledger owners remain foreign. + +Validation began with 60 passing tests. The added v1 regressions reproduced 18 failures before fixes. The final suite also covers decoded duplicate keys, byte/depth boundaries, unsafe numbers, Unicode identity, symlink lineage, cross-plan suggestions/approvals/choices, refreshed suggestions, and real-Git identity/metadata cases. + +### Explicit remaining work + +Issue #6 remains open for runner/store integration: obtain typed base entries and actual filesystem identity from a trusted checkout; audit actual occupancy, new symlinks/conversions, link targets and target mutations after execution; provide persistent request IDs, cancellation, replay prevention and concurrent CAS; enforce prompt budgets/profiles, output limits, container mounts, and process termination. The library only checks declared/projected state and trusted supplied context. In particular an edit may repair an unsafe existing link, but only the future runtime audit can validate its new target and accepted filesystem state. No application, runtime safety boundary, or concurrent store has been added here. Issues #2 and #3 remain the next approved build steps. + +## Merged-contract review round 1 + +Updated README import/linking examples to supply the new required context. Declined the scored-rename report: the raw-diff regex captures only `([A-Z])` and consumes the score separately with `\d*`, so the existing branch receives `R`, not `R100`. A focused real-Git test observed a scored rename followed by an added-file record and loaded both correctly before any parser change. Retained that regression. A separate locally discovered C1-control regression failed first, then passed after using the complete Unicode control category for commands/paths. + +## Merged-contract review round 3 + +Reproduced seven failing fixtures covering six valid findings. Replay now rejects before cumulative line/segment/reference/text growth exceeds its budget; origin unions and hunk-sharing avoid unchecked flattening, and grouping no longer repeatedly re-splits accumulated text. Read and linking operations each use a monotonic overall deadline, with Git subprocess/diff timeouts clamped to the remaining budget. Filesystem checks are cooperative between calls; process-level containment remains the runner's job. + +File metadata retains the whole rename path lineage, so a later deletion includes its owner's evidence on the final original-path file card. Multiple metadata owners remain conservatively Ambiguous; foreign owners remain Unplanned. Link target validation rejects intermediate regular-file/gitlink entries, overlap in both ancestry directions, and multi-file unsafe-link repair. Repair of an unsafe existing link remains possible as its own item. Tests cover text, empty, and binary rename/deletion cards; exact line/segment boundaries; reference fanout; and deterministic deadline exhaustion. + +## Merged-contract review round 4 + +The review had no inline findings, but its summary identified lost metadata after deleting and recreating a path. A real binary-file regression reproduced only the recreating owner being retained. Metadata updates now combine existing path evidence before storing/propagating it, so prior deletion owners survive reuse and subsequent renames. Owned deletion plus recreation is Ambiguous; foreign deletion plus recreation stays Unplanned. Both regressions pass. + +## Merged-contract review round 5 + +Reproduced a repository-local graft making an unrelated root commit appear descended from the selected base. The adapter now rejects Git-resolved graft and shallow metadata before object/ancestry reads and pins the child graft file to `/dev/null` as defense in depth. Git resolves administrative paths so linked worktrees share the same check. Caller isolation must keep all Git metadata, not just blobs, stable during a read. Full shallow-clone support is deliberately outside this linear immutable-history slice. diff --git a/git/history.ts b/git/history.ts new file mode 100644 index 0000000..1ed1f4d --- /dev/null +++ b/git/history.ts @@ -0,0 +1,139 @@ +import { execFileSync } from 'node:child_process'; +import { lstatSync, opendirSync } from 'node:fs'; +import { resolve as resolvePath, join } from 'node:path'; +import type { FileDelta, FileVersion, History } from '../core/linking.ts'; + +/** Read-only Git adapter. Never follows working-tree symlinks or runs diff helpers. */ +export function readHistory(repo: string, baseRef: string, headRef = 'HEAD', limits: { maxBlobBytes?: number; maxObjectEntries?: number; maxDiffBytes?: number; maxFileEntries?: number; maxDurationMs?: number } = {}): History { + const maxDurationMs = limits.maxDurationMs ?? 30_000; + if (!Number.isSafeInteger(maxDurationMs) || maxDurationMs < 1 || maxDurationMs > 30_000) throw new Error('Duration budget must be a positive integer no larger than 30000 ms.'); + const deadline = performance.now() + maxDurationMs; + const remaining = () => { + const ms = deadline - performance.now(); + if (ms <= 0) throw new Error('History read exceeded its overall deadline.'); + return Math.max(1, Math.ceil(ms)); + }; + const maxBlobBytes = limits.maxBlobBytes ?? 64 * 1024 * 1024; + if (!Number.isSafeInteger(maxBlobBytes) || maxBlobBytes < 1 || maxBlobBytes > 64 * 1024 * 1024) throw new Error('Blob byte budget must be a positive integer no larger than 64 MiB.'); + const maxObjectEntries = limits.maxObjectEntries ?? 100_000; + if (!Number.isSafeInteger(maxObjectEntries) || maxObjectEntries < 1 || maxObjectEntries > 100_000) throw new Error('Object entry budget must be a positive integer no larger than 100000.'); + const maxDiffBytes = limits.maxDiffBytes ?? 8 * 1024 * 1024; + if (!Number.isSafeInteger(maxDiffBytes) || maxDiffBytes < 1 || maxDiffBytes > 8 * 1024 * 1024) throw new Error('Diff byte budget must be a positive integer no larger than 8 MiB.'); + const maxFileEntries = limits.maxFileEntries ?? 20_000; + if (!Number.isSafeInteger(maxFileEntries) || maxFileEntries < 1 || maxFileEntries > 20_000) throw new Error('File entry budget must be a positive integer no larger than 20000.'); + let blobBytes = 0, diffBytes = 0, fileEntries = 0; + const accountDiff = (data: Buffer): Buffer => { + diffBytes += data.length; + if (diffBytes > maxDiffBytes) throw new Error('Review history exceeds the cumulative diff byte budget; choose a narrower base.'); + return data; + }; + // Inherited Git variables can redirect repository, index, config, and object lookup. + const environment = Object.fromEntries(Object.entries(process.env).filter(([key]) => !/^GIT_/i.test(key))); + const run = (...args: string[]) => { + const timeout = remaining(); + let result: Buffer; + try { + result = execFileSync('git', ['--no-pager', '--no-replace-objects', '-c', 'core.hooksPath=/dev/null', '-c', 'protocol.allow=never', ...args], { + cwd: repo, maxBuffer: 32 * 1024 * 1024, timeout, killSignal: 'SIGKILL', + env: { ...environment, GIT_OPTIONAL_LOCKS: '0', GIT_TERMINAL_PROMPT: '0', + GIT_NO_LAZY_FETCH: '1', GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null', GIT_GRAFT_FILE: '/dev/null' }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + } catch (error) { + if ((error as { code?: string }).code === 'ETIMEDOUT') throw new Error('History read exceeded its overall deadline.', { cause: error }); + throw error; + } + remaining(); + return result; + }; + // Parent-rewriting files are not immutable commit ancestry. Resolve via Git so + // linked worktrees use the common administrative directory as well. + const commonDirectory = resolvePath(repo, run('rev-parse', '--git-common-dir').toString().trim()); + for (const name of ['info/grafts', 'shallow']) { + const path = join(commonDirectory, name); + if (lstatSync(path, { throwIfNoEntry: false })) + throw new Error(`Review repositories must not use graft or shallow ancestry metadata (${name}).`); + } + // Inspect storage without following links before any object-resolving command. + const objects = resolvePath(repo, run('rev-parse', '--git-path', 'objects').toString().trim()); + const pending = [objects]; + let inspected = 0; + while (pending.length) { + remaining(); + const path = pending.pop()!; + if (++inspected > maxObjectEntries) throw new Error('Object storage inspection exceeds its entry budget.'); + const stat = lstatSync(path); + if (stat.isSymbolicLink()) throw new Error('Review repositories must not use symlinked object storage.'); + if (stat.isDirectory()) { + const directory = opendirSync(path, { bufferSize: 1 }); + try { + for (let entry = directory.readSync(); entry; entry = directory.readSync()) { + remaining(); + if (pending.length + inspected >= maxObjectEntries) throw new Error('Object storage inspection exceeds its entry budget.'); + pending.push(join(path, entry.name)); + } + } finally { directory.closeSync(); } + } + } + const alternates = join(objects, 'info/alternates'); + if (lstatSync(alternates, { throwIfNoEntry: false })) throw new Error('Review repositories must not use object alternates.'); + const resolve = (ref: string) => run('rev-parse', '--verify', '--end-of-options', `${ref}^{commit}`).toString().trim(); + const base = resolve(baseRef), head = resolve(headRef); + const records = run('rev-list', '--reverse', '--parents', `${base}..${head}`).toString().trim().split('\n').filter(Boolean); + if (records.length > 500) throw new Error('Review history exceeds 500 commits; choose a narrower base.'); + let expectedParent = base; + const commits = records.map(record => { + const parts = record.split(' '), sha = parts[0]!, parent = parts[1]!; + if (parts.length !== 2 || parent !== expectedParent) throw new Error('Review requires a linear history descended from the base; rebase first.'); + expectedParent = sha; + return { sha, parent, files: [] as FileDelta[] }; + }); + if (expectedParent !== head) throw new Error('The base must be an ancestor of the head.'); + const blobs = new Map(); + const version = (oid: string, mode: string): FileVersion | null => { + if (/^0+$/.test(oid)) return null; + if (mode === '160000') return { oid, mode, text: null }; // gitlink is not a local blob + if (!blobs.has(oid)) { + const size = Number(run('cat-file', '-s', oid).toString().trim()); + if (!Number.isSafeInteger(size) || size < 0 || size > maxBlobBytes - blobBytes) throw new Error('Review history exceeds the cumulative blob byte budget; choose a narrower base.'); + const data = run('cat-file', 'blob', oid); + blobBytes += data.length; + let text: string | null = null; + if (!data.includes(0)) { try { text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(data); } catch { /* binary */ } } + blobs.set(oid, text); + } + return { oid, mode, text: blobs.get(oid)! }; + }; + const diff = (from: string, to: string, contexts: boolean): FileDelta[] => { + const raw = accountDiff(run('diff', '--ignore-submodules=none', '--no-relative', '--raw', '-z', '--no-abbrev', '--no-ext-diff', '--no-textconv', '-M', from, to, '--')); + const fields = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(raw).split('\0'); + const result: FileDelta[] = []; + for (let i = 0; i < fields.length && fields[i];) { + remaining(); + if (++fileEntries > maxFileEntries) throw new Error('Review history exceeds the cumulative file entry budget; choose a narrower base.'); + const match = /^:(\d+) (\d+) ([0-9a-f]+) ([0-9a-f]+) ([A-Z])\d*$/.exec(fields[i++]!); + if (!match) throw new Error('Unexpected Git raw diff record.'); + const [, oldMode, newMode, oldOid, newOid, status] = match; + const first = fields[i++]!; + const oldPath = status === 'A' ? null : first; + const newPath = status === 'D' ? null : status === 'R' ? fields[i++]! : first; + const before = version(oldOid!, oldMode!), after = version(newOid!, newMode!); + const ranges: FileDelta['contexts'] = []; + if (contexts && (before?.text !== null || after?.text !== null)) { + const paths = [...new Set([oldPath, newPath].filter((path): path is string => path !== null))]; + // Literal pathspecs preserve filenames containing Git pathspec metacharacters. + const patch = accountDiff(run('diff', '--ignore-submodules=none', '--no-relative', '--no-ext-diff', '--no-textconv', '--no-color', '--unified=0', '-M', from, to, '--', ...paths.map(path => `:(literal)${path}`))).toString(); + for (const line of patch.split('\n')) { + const hunk = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/.exec(line); + if (hunk) ranges.push({ oldStart: +hunk[1]!, oldCount: +(hunk[2] ?? 1), newStart: +hunk[3]!, newCount: +(hunk[4] ?? 1), name: hunk[5]!.trim() }); + } + } + result.push({ oldPath, newPath, before, after, contexts: ranges }); + } + return result; + }; + for (const commit of commits) commit.files = diff(commit.parent, commit.sha, false); + const final = diff(base, head, true); + remaining(); + return { base, head, commits, final }; +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..b9320b5 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1647 @@ +{ + "name": "codeboost", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "codeboost", + "version": "0.0.0", + "dependencies": { + "ajv": "8.20.0", + "diff": "9.0.0", + "yaml": "2.9.1" + }, + "devDependencies": { + "@types/node": "26.6.2", + "typescript": "7.0.2", + "vitest": "5.0.1" + }, + "engines": { + "node": ">=26.7.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.150.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.150.0.tgz", + "integrity": "sha512-rDS5/31E9HfPl/CIzGrn0DOlvBbXFseQ5URJ9sYMfstbKLD/c6Gm9vmRzRGDdAXyOIL4zmO37lc9RIwYqVruZw==", + "dev": true, + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.9.tgz", + "integrity": "sha512-tNISae1QEf/vkb3xkRcjV5SEdzPE97We5IVaa2Z8jSszQPZ8U60B/YCYpw4QI7VidYsBtKavczXf+DyDs9WGxw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.9.tgz", + "integrity": "sha512-YC8YsI30o606GTZi0VyzYlsDKFP8W61i/QzayHDkLbNEz/IShqAmTa+hsJRj13xTHA0H+6fk4b2UmGn+Q/cMlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.9.tgz", + "integrity": "sha512-IwhlH3qK5urrY8hZiEgGkHKEFN901p/p2bjxCxJlr4GyNnF7wYpUvK+Y43uaRYuC4hpfjzbR3SJC3arX1jGvmw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.9.tgz", + "integrity": "sha512-XxpJfVzFh+jilRxIXUqcfYAYcunIc/XEzIizsOL1fcJee5Sf7H3mH8WlLmfHfluz5amqR88QQo9izKtmMlavAw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.9.tgz", + "integrity": "sha512-kSfvhmgeWyfkbT3p/1s5vSgboogoah2zkm9fX2zjg2hHxSV7T4KhMWRUUaRk4OXNqoD3QAUeRqLcs1aZOK4U1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.9.tgz", + "integrity": "sha512-1RVzG17pxqbTfYLC352JlLt6kKLG+6Hr30n8DlIJqsnV5luUDd2Qdx9Ayw1Cabfyb1K9k0jXEZ7evxkRoT+uiw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.9.tgz", + "integrity": "sha512-BXqPvZ2drqVD+/Z8UpKwcs4Mp7grM+eGFku4CAEKrEtcbAsUpzREphK1sogCRZGreVPiMkiiBtw0n3TPteuqvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.9.tgz", + "integrity": "sha512-11vWvo8YDwLzukt27J3aYDWU+gg2P7J+ZOmiJ0hkF5BXZDW7pVya7r40MXDy6ya0i9KamoENSVKIugvJNgFXIA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.9.tgz", + "integrity": "sha512-a1tijMkdwsIARtc0F39ApURROkf3NwqinI6TOiSSWCTR7dT96dffNvMUtDHnq64wKNTIZOIlzKrFvvFUznJiyw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.9.tgz", + "integrity": "sha512-x6SQNdAvv4c3hWqTMaWuawzMX9myaCs/yEmlGsxJzkdClnHW7FbrjQuSiRDhuSYzEYoEMhsaJy9qHG/XNemJPQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.9.tgz", + "integrity": "sha512-9s0AZ8BFK5/n7B/TBoa2yJE3gI3KURrbXcPBlsAsvjU4VeJKgE90y1YtNxyEUIcHPQkg6/yfF3qihUrcM/Kf0Q==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.9.tgz", + "integrity": "sha512-P7VWAmV+WdJluH7ovnRGoiv2i8To7GAZ+kGzfGup635cyL7SyYl3lSUaA3Gp5THf0n/Co5EyEqb2zbqq+nMOHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.9.tgz", + "integrity": "sha512-1qixtsE4BK8h+yS3BfmZ09UhA7O/N4IACva6YBr7EBvCJraByTuRcgOTaiA62Tm0vey3UcKXLOaoGHtYmNGEVg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.9.tgz", + "integrity": "sha512-ok8IQjcEPs1AKZfuEUznVBrJw+gK4soq+bx8b1X2XoMqVClarc1q5JDmVtWXY1xfr6ZuHTAsPXHTgTrqKTZeww==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.9.tgz", + "integrity": "sha512-Ip2mXoU0hM0boq3Rf+ekuT653OROSo6aSYcPT1VHE4q52KvyxgFkQgrgb/IEsxOuvQ2fZZbs8khJAyCEPM24/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.2.tgz", + "integrity": "sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@vitest/mocker": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.1.tgz", + "integrity": "sha512-6K1DoBNAPGvuOcSsGA4D6x+5zEEff/KmOOP3uetT2TrGpVfI+HRHRnJJfKi5ib/g1vx8IYHQD8s0pbJz8WQI7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.1", + "estree-walker": "^3.0.3", + "magic-string": "^1.2.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.1.tgz", + "integrity": "sha512-rbto/mF/SGERxEgYOek7Xm6B9b+y+mVoo+f4b2LymYO8zM1b7uB5nHuhVMTP2hxdzgxvGiZYGxGIaMvL5y180Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/diff": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz", + "integrity": "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "peer": true, + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.1.tgz", + "integrity": "sha512-8lyCu36ErXR0J9uaGKlKQoiLZKmtI63YGLE8G2o9jyRPdr4X47LusSOwgOJOzcVtp81fTAAjxR7BwKz682Jhow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC", + "peer": true + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rolldown": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.9.tgz", + "integrity": "sha512-hx/Pv0N1haXRb11qkfnK5MXB/iqr7i0yjWQqmO9uHqZpBgQSqzc8UsSnEpalsh+j1I8qQ2CkXAkJC8Br3dKSlg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@oxc-project/types": "=0.150.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.9", + "@rolldown/binding-android-arm64": "1.2.9", + "@rolldown/binding-darwin-arm64": "1.2.9", + "@rolldown/binding-darwin-x64": "1.2.9", + "@rolldown/binding-freebsd-x64": "1.2.9", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.9", + "@rolldown/binding-linux-arm64-gnu": "1.2.9", + "@rolldown/binding-linux-arm64-musl": "1.2.9", + "@rolldown/binding-linux-ppc64-gnu": "1.2.9", + "@rolldown/binding-linux-s390x-gnu": "1.2.9", + "@rolldown/binding-linux-x64-gnu": "1.2.9", + "@rolldown/binding-linux-x64-musl": "1.2.9", + "@rolldown/binding-openharmony-arm64": "1.2.9", + "@rolldown/binding-win32-arm64-msvc": "1.2.9", + "@rolldown/binding-win32-x64-msvc": "1.2.9" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz", + "integrity": "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz", + "integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.6", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.1.tgz", + "integrity": "sha512-iA95lQbKEkvrtTkdAgnWbXfbipWiiWe/hDl2P5tMi6WFwD76G0NxXAGp/M9EOcYupeGJRr6wppMc7CoA41TQjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.1", + "chai": "^6.2.2", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.2.3", + "obug": "^2.1.4", + "picomatch": "^4.0.7", + "std-env": "^4.2.0", + "tinybench": "6.1.4", + "tinyexec": "1.3.0", + "tinyglobby": "^0.2.17", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.1", + "@vitest/browser-preview": "5.0.1", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.1", + "@vitest/coverage-v8": "5.0.1", + "@vitest/ui": "5.0.1", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yaml": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..ff7fed9 --- /dev/null +++ b/package.json @@ -0,0 +1,23 @@ +{ + "name": "codeboost", + "version": "0.0.0", + "private": true, + "type": "module", + "engines": { + "node": ">=26.7.0" + }, + "scripts": { + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "ajv": "8.20.0", + "diff": "9.0.0", + "yaml": "2.9.1" + }, + "devDependencies": { + "@types/node": "26.6.2", + "typescript": "7.0.2", + "vitest": "5.0.1" + } +} diff --git a/test/history.test.ts b/test/history.test.ts new file mode 100644 index 0000000..cc7f3e8 --- /dev/null +++ b/test/history.test.ts @@ -0,0 +1,330 @@ +import { mkdtempSync, readdirSync, mkdirSync, writeFileSync, existsSync, rmSync, chmodSync, symlinkSync, renameSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { afterEach, expect, it, vi } from 'vitest'; +import { readHistory } from '../git/history.ts'; +import { linkHistory } from '../core/linking.ts'; +import { approveItem as approveBound, approvalStates as statesBound, applyChoices as choicesBound, choiceKeys as keysBound } from '../core/approvals.ts'; +const identity = { repositoryId: 'repo', taskId: 'task', planId: 'plan' }; +const approveItem = (plan: Parameters[0], segments: Parameters[1], id: string, confirm = false) => approveBound(plan, segments, id, identity, confirm); +const approvalStates = (plan: Parameters[0], segments: Parameters[1], approvals: Parameters[2]) => statesBound(plan, segments, approvals, identity); +const applyChoices = (plan: Parameters[0], segments: Parameters[1], choices: Parameters[2]) => choicesBound(plan, segments, choices, identity); +const choiceKeys = (segments: Parameters[0]) => keysBound(segments, identity); +import type { Plan } from '../core/plan.ts'; + +const dirs: string[] = []; +afterEach(() => { for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); }); +function fixture(initial: Record = { 'a.txt': 'one\ntwo\nthree\n' }) { + const dir = mkdtempSync(join(tmpdir(), 'codeboost-history-')); dirs.push(dir); + const git = (...args: string[]) => execFileSync('git', args, { cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); + git('init', '-b', 'main'); git('config', 'user.name', 'Test'); git('config', 'user.email', 'test@example.invalid'); git('config', 'commit.gpgsign', 'false'); + const write = (path: string, text: string | Buffer) => { mkdirSync(dirname(join(dir, path)), { recursive: true }); writeFileSync(join(dir, path), text); }; + const ledger = new Map(); + const commit = (owner?: string, message = 'Change') => { git('add', '-A'); git('commit', '-m', message); const sha = git('rev-parse', 'HEAD'); if (owner) ledger.set(sha, owner); return sha; }; + for (const [path, text] of Object.entries(initial)) write(path, text); + const base = commit(); + const plan: Plan = { schema_version: 1, issue: 1, revision: 1, summary: 'Test', questions: [], items: ['P1', 'P2'].map(id => ({ + id, title: id, intent: 'Change', files: [{ path: 'a.txt', kind: 'edit', renamed_from: null, change: 'Change' }], acceptance: [{ type: 'check', text: 'Works' }], depends_on: [], + })) }; + const segments = () => linkHistory(plan, readHistory(dir, base), ledger, path => path); + return { dir, git, write, commit, base, ledger, plan, segments }; +} +it('splits a shared hunk by ledger owner; detects out-of-scope and forged trailers', () => { + const f = fixture(); f.write('a.txt', 'ONE\ntwo\nthree\n'); f.commit('P1'); + f.write('a.txt', 'ONE\ntwo\nTHREE\n'); f.write('outside.txt', 'outside\n'); f.commit('P2'); + f.write('foreign.txt', 'foreign\n'); f.commit(undefined, 'Forged\n\nPlan-Item: P1'); + const parts = f.segments(); + expect(parts.find(s => s.content === 'ONE\n')?.row).toBe('P1'); + expect(parts.find(s => s.content === 'THREE\n')?.row).toBe('P2'); + expect(parts.find(s => s.content === 'ONE\n')?.sharesHunkWith).toContain('P2'); + expect(parts.filter(s => s.path === 'outside.txt').every(s => s.scope === 'out-of-scope')).toBe(true); + expect(parts.filter(s => s.path === 'foreign.txt').every(s => s.row === 'Unplanned')).toBe(true); +}); +it('attributes pure deletions and marks repeated edits as ambiguous', () => { + const f = fixture(); f.write('a.txt', 'one\nthree\n'); f.commit('P1'); + expect(f.segments().find(s => s.content === 'two\n')?.row).toBe('P1'); + f.write('a.txt', 'ONE\nthree\n'); f.commit('P1'); f.write('a.txt', 'FIRST\nthree\n'); f.commit('P2'); + const changed = f.segments().find(s => s.content === 'FIRST\n'); + expect(changed?.row).toBe('Ambiguous'); expect(changed?.owners).toEqual(['P1', 'P2']); +}); +it('omits changes reverted back to the base', () => { + const f = fixture(); f.write('a.txt', 'changed\n'); f.commit('P1'); f.write('a.txt', 'one\ntwo\nthree\n'); f.commit('P2'); + expect(f.segments()).toEqual([]); +}); +it('represents binary, executable, empty, rename, symlink and submodule changes', () => { + const f = fixture({ 'a.txt': 'one\ntwo\nthree\n', 'rename.txt': 'rename\n' }); + chmodSync(join(f.dir, 'a.txt'), 0o755); f.write('binary.dat', Buffer.from([0, 1, 2])); f.write('empty', ''); + renameSync(join(f.dir, 'rename.txt'), join(f.dir, 'renamed.txt')); symlinkSync('a.txt', join(f.dir, 'link')); + f.git('add', '-A'); f.git('update-index', '--add', '--cacheinfo', `160000,${f.base},submodule`); + f.git('commit', '-m', 'File changes'); f.ledger.set(f.git('rev-parse', 'HEAD'), 'P1'); + f.git('config', 'diff.ignoreSubmodules', 'all'); + const cards = f.segments().filter(s => s.kind === 'file'); + expect(cards.map(s => s.path).sort()).toEqual(['a.txt', 'binary.dat', 'empty', 'link', 'renamed.txt', 'submodule']); + expect(cards.every(s => s.row === 'P1')).toBe(true); + expect(cards.find(s => s.path === 'renamed.txt')?.oldPath).toBe('rename.txt'); +}); +it('handles filenames with spaces and pathspec characters literally', () => { + const f = fixture({ 'a [1].txt': 'old\n' }); f.write('a [1].txt', 'new\n'); f.commit('P1'); + expect(f.segments().every(s => s.path === 'a [1].txt')).toBe(true); +}); +it('rejects a merge history rather than inventing attribution', () => { + const f = fixture(); f.git('switch', '-c', 'side'); f.write('side', 'side'); f.commit('P1'); f.git('switch', 'main'); + f.write('main', 'main'); f.commit('P2'); f.git('merge', '--no-ff', 'side', '-m', 'merge'); + expect(() => f.segments()).toThrow(/linear/); +}); +it('keeps approvals after a clean rebase and remapped ledger, but stales changed checks and dependents', () => { + const f = fixture(); f.git('switch', '-c', 'feature'); f.write('a.txt', 'one\nTWO\nthree\n'); const oldSha = f.commit('P1'); + const parts = f.segments(); const approvals = [approveItem(f.plan, parts, 'P1'), approveItem(f.plan, parts, 'P2', true)]; + f.git('switch', 'main'); f.write('unrelated', 'base update'); const newBase = f.commit(); f.git('switch', 'feature'); f.git('rebase', 'main'); + const newSha = f.git('rev-parse', 'HEAD'); f.ledger.delete(oldSha); f.ledger.set(newSha, 'P1'); + const rebased = linkHistory(f.plan, readHistory(f.dir, newBase), f.ledger, path => path); + expect(approvalStates(f.plan, rebased, approvals)).toEqual({ P1: 'approved', P2: 'approved' }); + f.plan.items[1]!.depends_on = ['P1']; const p2 = approveItem(f.plan, rebased, 'P2', true); + f.plan.items[0]!.acceptance[0]!.text = 'Different check'; + expect(approvalStates(f.plan, rebased, [approvals[0]!, p2])).toEqual({ P1: 'stale', P2: 'stale' }); +}); +it('stales whitespace and function-context changes, but not line numbers', () => { + const f = fixture(); f.write('a.txt', 'one\nTWO\nthree\n'); f.commit('P1'); + const parts = f.segments(); const approval = approveItem(f.plan, parts, 'P1'); + const shifted = parts.map(s => ({ ...s, oldLine: s.oldLine === null ? null : s.oldLine + 20, newLine: s.newLine === null ? null : s.newLine + 20 })); + expect(approvalStates(f.plan, shifted, [approval]).P1).toBe('approved'); + expect(approvalStates(f.plan, parts.map(s => ({ ...s, content: s.content + ' ' })), [approval]).P1).toBe('stale'); + expect(approvalStates(f.plan, parts.map(s => ({ ...s, context: 'other function' })), [approval]).P1).toBe('stale'); +}); +it('assignments stale the target and duplicate-copy count changes invalidate choices', () => { + const f = fixture(); f.write('a.txt', 'one\nTWO\nthree\n'); f.commit(); const parts = f.segments(); + const approval = approveItem(f.plan, parts, 'P1', true); + const choice = { key: choiceKeys(parts)[0]!, action: 'assign' as const, item: 'P1' }; + expect(approvalStates(f.plan, applyChoices(f.plan, parts, [choice]), [approval]).P1).toBe('stale'); + const copies = [parts[0]!, { ...parts[0]!, oldLine: 20 }]; + const accepted = { key: choiceKeys(copies)[0]!, action: 'accept' as const, item: null }; + expect(applyChoices(f.plan, copies, [accepted])[0]!.row).toBe('Accepted'); + expect(applyChoices(f.plan, [copies[1]!], [accepted])[0]!.row).toBe('Unplanned'); + const shifted = parts.map(s => ({ ...s, newLine: 50 })); + expect(applyChoices(f.plan, shifted, [choice])[0]!.row).toBe('P1'); +}); +it('retains line ancestry through a rename and subsequent edit', () => { + const f = fixture(); f.write('a.txt', 'ONE\ntwo\nthree\n'); f.commit('P1'); + renameSync(join(f.dir, 'a.txt'), join(f.dir, 'b.txt')); f.commit('P1'); + f.write('b.txt', 'FIRST\ntwo\nthree\n'); f.commit('P2'); + const parts = f.segments(); + expect(parts.find(s => s.content === 'FIRST\n')?.owners).toEqual(['P1', 'P2']); + expect(parts.some(s => s.kind === 'file')).toBe(true); +}); +it('keeps separated changes as separate segments and preserves EOF changes', () => { + const f = fixture(); f.write('a.txt', 'ONE\ntwo\nTHREE'); f.commit('P1'); + const additions = f.segments().filter(s => s.operation === '+'); + expect(additions.map(s => s.content)).toEqual(['ONE\n', 'THREE']); + expect(additions.map(s => s.newLine)).toEqual([1, 3]); +}); +it('reads real function context from Git hunk headers', () => { + const f = fixture({ 'a.py': 'def first():\n return 1\n\ndef second():\n return 2\n' }); + f.write('a.py', 'def first():\n return 3\n\ndef second():\n return 2\n'); f.commit('P1'); + expect(f.segments().find(s => s.operation === '+')?.context).toBe('def first():'); +}); +it('attributes unchanged moved lines when final rename detection is lost', () => { + const f = fixture({ 'a.txt': 'one\ntwo\nthree\nfour\nfive\n' }); + renameSync(join(f.dir, 'a.txt'), join(f.dir, 'b.txt')); f.commit('P1'); + f.write('b.txt', 'one\nnew2\nnew3\nnew4\nnew5\n'); f.commit('P2'); + const parts = f.segments(); + expect(parts.filter(s => s.kind === 'text').every(s => s.row !== 'Unplanned')).toBe(true); +}); +it('does not hide a UTF-8 byte-order-mark-only change', () => { + const f = fixture(); f.write('a.txt', '\uFEFFone\ntwo\nthree\n'); f.commit('P1'); + expect(f.segments().some(s => s.operation === '+' && s.content.startsWith('\uFEFF'))).toBe(true); +}); +it('reads the whole repository even when called from a subdirectory with relative diffs configured', () => { + const f = fixture({ 'a.txt': 'before\n', 'sub/b.txt': 'before\n' }); + f.write('a.txt', 'after\n'); f.write('sub/b.txt', 'after\n'); f.commit('P1'); + f.git('config', 'diff.relative', 'true'); + const parts = linkHistory(f.plan, readHistory(join(f.dir, 'sub'), f.base), f.ledger, path => path); + expect(new Set(parts.map(s => s.path))).toEqual(new Set(['a.txt', 'sub/b.txt'])); +}); +it('checks scope at each owning commit, not against both ends of a final rename', () => { + const f = fixture(); + f.plan.items[0]!.files = [{ path: 'b.txt', kind: 'rename', renamed_from: 'a.txt', change: 'Move' }]; + f.plan.items[1]!.files = [{ path: 'b.txt', kind: 'edit', renamed_from: null, change: 'Edit new name' }]; + renameSync(join(f.dir, 'a.txt'), join(f.dir, 'b.txt')); f.commit('P1'); + f.write('b.txt', 'ONE\ntwo\nthree\n'); f.commit('P2'); + expect(f.segments().some(s => s.row === 'P2')).toBe(true); + expect(f.segments().filter(s => s.row === 'P2').every(s => s.scope === 'in-scope')).toBe(true); + // Declaring only the old name must not authorize edits to the new one. + f.plan.items[1]!.files[0]!.path = 'a.txt'; + expect(f.segments().filter(s => s.row === 'P2').every(s => s.scope === 'out-of-scope')).toBe(true); +}); +it('checks scope of deletion after a rename using the deleted current path', () => { + const f = fixture(); f.plan.items[0]!.files = [{ path: 'b.txt', kind: 'rename', renamed_from: 'a.txt', change: 'Move' }]; + f.plan.items[1]!.files = [{ path: 'b.txt', kind: 'delete', renamed_from: null, change: 'Delete' }]; + renameSync(join(f.dir, 'a.txt'), join(f.dir, 'b.txt')); f.commit('P1'); + rmSync(join(f.dir, 'b.txt')); f.commit('P2'); + expect(f.segments().some(s => s.row === 'P2')).toBe(true); + expect(f.segments().filter(s => s.row === 'P2').every(s => s.scope === 'in-scope')).toBe(true); +}); + +it('Git built-ins cannot be overridden by repository shell aliases', () => { + const f = fixture(); f.write('a.txt', 'changed\n'); f.commit('P1'); + for (const name of ['rev-parse', 'rev-list', 'diff', 'cat-file']) f.git('config', `alias.${name}`, '!touch alias-executed'); + expect(f.segments().length).toBeGreaterThan(0); + expect(existsSync(join(f.dir, 'alias-executed'))).toBe(false); +}); +it('isolates repository selection from inherited Git environment variables', () => { + const expected = fixture(); expected.write('a.txt', 'expected repo\n'); expected.commit('P1'); + const foreign = fixture(); foreign.write('a.txt', 'foreign repo\n'); foreign.commit(); + const previous = process.env.GIT_DIR; + try { + process.env.GIT_DIR = join(foreign.dir, '.git'); + const history = readHistory(expected.dir, 'HEAD~1'); + expect(history.final[0]!.after!.text).toBe('expected repo\n'); + } finally { + if (previous === undefined) delete process.env.GIT_DIR; else process.env.GIT_DIR = previous; + } +}); + +it('rejects repository-local object alternates before reading borrowed history', () => { + const source = fixture(); source.write('a.txt', 'borrowed content\n'); source.commit(); + const borrower = fixture(); + writeFileSync(join(borrower.dir, '.git/objects/info/alternates'), join(source.dir, '.git/objects') + '\n'); + expect(() => readHistory(borrower.dir, source.base, source.git('rev-parse', 'HEAD'))).toThrow(/alternates/i); +}); +it('keeps a foreign rename on its file card while attributing later text edits', () => { + const f = fixture(); + renameSync(join(f.dir, 'a.txt'), join(f.dir, 'b.txt')); f.commit(); + f.plan.items[1]!.files[0]!.path = 'b.txt'; + f.write('b.txt', 'ONE\ntwo\nthree\n'); f.commit('P2'); + const parts = f.segments(); + expect(parts.find(s => s.kind === 'file')?.row).toBe('Unplanned'); + const text = parts.filter(s => s.kind === 'text'); + expect(text.length).toBeGreaterThan(0); + expect(text.every(s => s.row === 'P2' && s.scope === 'in-scope')).toBe(true); +}); + +it('fails explicitly when cumulative unique blob bytes exceed the history budget', () => { + const f = fixture(); f.write('a.txt', 'changed text\n'); f.commit('P1'); + expect(() => readHistory(f.dir, f.base, 'HEAD', { maxBlobBytes: 20 })).toThrow(/blob byte budget/i); + expect(readHistory(f.dir, f.base, 'HEAD', { maxBlobBytes: 27 }).final).toHaveLength(1); +}); + +it.each(['root', 'loose', 'pack'])('rejects symlinked %s object storage', kind => { + const f = fixture(); f.write('a.txt', 'changed\n'); f.commit('P1'); + const objects = join(f.dir, '.git/objects'); + const storage = kind === 'root' ? objects : join(objects, kind === 'pack' ? 'pack' : readdirSync(objects).find(name => /^[0-9a-f]{2}$/.test(name))!); + const borrowed = join(f.dir, 'borrowed-objects'); + renameSync(storage, borrowed); symlinkSync(borrowed, storage); + expect(() => readHistory(f.dir, f.base)).toThrow(/symlinked object storage/i); +}); + +it('enforces a caller-lowered object inspection entry limit', () => { + const f = fixture(); + expect(() => readHistory(f.dir, f.base, 'HEAD', { maxObjectEntries: 1 })).toThrow(/inspection exceeds/i); +}); +it('supports a caller-selected linked worktree gitdir', () => { + const f = fixture(); f.write('a.txt', 'changed\n'); f.commit('P1'); + const worktree = mkdtempSync(join(tmpdir(), 'codeboost-linked-')); dirs.push(worktree); + f.git('worktree', 'add', '--detach', worktree, 'HEAD'); + expect(readHistory(worktree, f.base).final[0]!.after!.text).toBe('changed\n'); +}); +it('supports a caller-selected symlinked gitdir with regular object storage', () => { + const f = fixture(); f.write('a.txt', 'changed\n'); f.commit('P1'); + const metadata = mkdtempSync(join(tmpdir(), 'codeboost-gitdir-')); dirs.push(metadata); + rmSync(metadata, { recursive: true }); renameSync(join(f.dir, '.git'), metadata); symlinkSync(metadata, join(f.dir, '.git')); + expect(readHistory(f.dir, f.base).final[0]!.after!.text).toBe('changed\n'); +}); + +it.each([ + [{ maxDiffBytes: 1 }, /diff byte budget/i], + [{ maxFileEntries: 1 }, /file entry budget/i], +] as const)('bounds accumulated diff records with %j', (limits, error) => { + const f = fixture(); f.write('a.txt', 'changed\n'); f.commit('P1'); + expect(() => readHistory(f.dir, f.base, 'HEAD', limits)).toThrow(error); + expect(readHistory(f.dir, f.base, 'HEAD', { maxFileEntries: 2 }).final).toHaveLength(1); +}); + +it('uses checkout identity for scope and treats explicit null ledger owners as foreign', () => { + const f = fixture(); f.plan.items[0]!.files[0]!.path = 'A.TXT'; + f.write('a.txt', 'ONE\ntwo\nthree\n'); const sha = f.commit('P1'); + const history = readHistory(f.dir, f.base); + const parts = linkHistory(f.plan, history, f.ledger, path => path.toLowerCase()); + expect(parts.length).toBeGreaterThan(0); + expect(parts.every(s => s.scope === 'in-scope')).toBe(true); + expect(linkHistory(f.plan, history, new Map([[sha, null]]), path => path).every(s => s.row === 'Unplanned')).toBe(true); +}); +it('records typed object identities on real mode-change cards', () => { + const f = fixture(); chmodSync(join(f.dir, 'a.txt'), 0o755); f.commit('P1'); + const card = f.segments().find(s => s.kind === 'file'); expect(card).toBeDefined(); + const metadata = JSON.parse(card!.content); + expect(metadata.oldObject.kind).toBe('blob'); expect(metadata.newObject.kind).toBe('blob'); + expect(metadata.oldObject.oid).toBe(metadata.newObject.oid); + expect(metadata.oldMode).toBe('100644'); expect(metadata.newMode).toBe('100755'); +}); + +it('parses scored renames and consumes both paths before the next raw record', () => { + const initial = Array.from({ length: 100 }, (_, i) => `line ${i}\n`).join(''); + const f = fixture({ 'a.txt': initial }); + renameSync(join(f.dir, 'a.txt'), join(f.dir, 'b.txt')); + f.write('b.txt', initial.replace('line 50\n', 'changed 50\n')); + f.write('z.txt', 'another record\n'); const head = f.commit('P1'); + const raw = f.git('diff', '--raw', '-z', '-M', f.base, head); + expect(raw).toMatch(/ R\d+\0a\.txt\0b\.txt\0/u); + const history = readHistory(f.dir, f.base); + expect(history.final.some(d => d.oldPath === 'a.txt' && d.newPath === 'b.txt')).toBe(true); + expect(history.final.some(d => d.oldPath === null && d.newPath === 'z.txt')).toBe(true); +}); + +it('bounds cumulative line and segment allocation before replay can grow', () => { + const f = fixture(); f.write('a.txt', 'changed\ntwo\nthree\n'); f.commit('P1'); + const history = readHistory(f.dir, f.base); + expect(() => linkHistory(f.plan, history, f.ledger, p => p, { maxLines: 2 })).toThrow(/line.*budget/i); + expect(() => linkHistory(f.plan, history, f.ledger, p => p, { maxSegments: 1 })).toThrow(/segment.*budget/i); + expect(() => linkHistory(f.plan, history, f.ledger, p => p, { maxLines: 12, maxSegments: 2 })).not.toThrow(); + expect(() => linkHistory(f.plan, history, f.ledger, p => p, { maxLines: 11 })).toThrow(/line.*budget/i); + expect(() => linkHistory(f.plan, history, f.ledger, p => p, { maxReferences: 1 })).toThrow(/reference.*budget/i); +}); +it.each(['one\n', '', '\0binary'])('carries rename lineage into a final deletion file card (%j)', content => { + const f = fixture({ 'a.txt': content }); f.plan.items[0]!.files = [{ path: 'b.txt', kind: 'rename', renamed_from: 'a.txt', change: 'Move' }]; + f.plan.items[1]!.files = [{ path: 'b.txt', kind: 'delete', renamed_from: null, change: 'Delete' }]; + renameSync(join(f.dir, 'a.txt'), join(f.dir, 'b.txt')); f.commit('P1'); + rmSync(join(f.dir, 'b.txt')); f.commit('P2'); + expect(f.segments().find(s => s.kind === 'file')!.owners).toContain('P2'); +}); +it('bounds the total Git read duration across subprocesses', () => { + const f = fixture(); f.write('a.txt', 'changed\n'); f.commit('P1'); + let elapsed = 0; + const clock = vi.spyOn(performance, 'now').mockImplementation(() => (elapsed += 1000)); + try { expect(() => readHistory(f.dir, f.base, 'HEAD', { maxDurationMs: 3000 })).toThrow(/deadline|duration/i); } + finally { clock.mockRestore(); } +}); + +it('bounds linking duration and replacement-origin fanout', () => { + const f = fixture({ 'a.txt': 'old\n'.repeat(100) }); + f.write('a.txt', 'new\n'.repeat(100)); f.commit('P1'); + const history = readHistory(f.dir, f.base); + expect(() => linkHistory(f.plan, history, f.ledger, p => p, { maxReferences: 1000 })).toThrow(/reference.*budget/i); + expect(() => linkHistory(f.plan, history, f.ledger, p => p)).not.toThrow(); + let elapsed = 0; + const clock = vi.spyOn(performance, 'now').mockImplementation(() => elapsed++); + try { expect(() => linkHistory(f.plan, history, f.ledger, p => p, { maxDurationMs: 3 })).toThrow(/deadline/i); } + finally { clock.mockRestore(); } +}); + +it.each(['P1', undefined])('retains deletion and recreation owners on reused-path file cards (%s)', owner => { + const f = fixture({ 'a.txt': '\0old binary' }); + rmSync(join(f.dir, 'a.txt')); f.commit(owner); + f.write('a.txt', '\0new binary'); f.commit('P2'); + const card = f.segments().find(s => s.kind === 'file')!; + expect(card.owners).toEqual([owner ?? null, 'P2']); + expect(card.row).toBe(owner ? 'Ambiguous' : 'Unplanned'); +}); + +it('rejects local grafts that make an unrelated commit appear descended from the base', () => { + const f = fixture(); f.git('checkout', '--orphan', 'unrelated'); f.git('rm', '-rf', '.'); + f.write('a.txt', 'unrelated history\n'); const head = f.commit('P1'); + f.write('.git/info/grafts', `${head} ${f.base}\n`); + expect(() => readHistory(f.dir, f.base, head)).toThrow(/graft/i); +}); + +it('rejects shallow parent rewriting before loading history', () => { + const f = fixture(); f.write('a.txt', 'changed\n'); const head = f.commit('P1'); + f.write('.git/shallow', `${head}\n`); + expect(() => readHistory(f.dir, f.base, head)).toThrow(/shallow/i); +}); diff --git a/test/identity.test.ts b/test/identity.test.ts new file mode 100644 index 0000000..af322fb --- /dev/null +++ b/test/identity.test.ts @@ -0,0 +1,44 @@ +import { expect, it } from 'vitest'; +import { applySuggestion, type Plan, type PlanContext } from '../core/plan.ts'; +import { approveItem, approvalStates, choiceKeys, applyChoices } from '../core/approvals.ts'; +import type { Segment } from '../core/linking.ts'; +const identity = { repositoryId: 'repo', taskId: 'task', planId: 'A' }; +const context: PlanContext = { identity, issue: 1, baseEntries: [{ path: 'a', kind: 'file' }], pathKey: p => p, allowedCommands: [] }; +const plan: Plan = { schema_version: 1, issue: 1, revision: 3, summary: 'Example', questions: [], items: [{ id: 'P1', title: 'Change', intent: 'Improve', files: [{ path: 'a', kind: 'edit', renamed_from: null, change: 'Change' }], acceptance: [{ type: 'check', text: 'Works' }], depends_on: [] }] }; +const edit = { op: 'set_field', item: 'P1', summary: 'Title', reason: 'Clarify', field: 'title', value: 'New title', file: null, check: null, check_index: null, depends_on: null, new_item: null }; +const reply = { schema_version: 1, base_revision: 3, reply: '', edits: [edit, { ...edit, field: 'intent', value: 'New intent' }] }; +const binding = { identity, schemaVersion: 1, baseRevision: 3, issue: 1 }; +it('rejects a delayed A suggestion on B even with identical issue, revision, and item IDs', () => { + expect(() => applySuggestion(plan, reply, 0, { ...context, identity: { ...identity, planId: 'B' } }, binding)).toThrow(/context/); + expect(applySuggestion(plan, reply, 0, context, binding).revision).toBe(4); +}); +it('stales sibling suggestions after Apply and permits explicitly regenerated suggestions', () => { + const next = applySuggestion(plan, reply, 0, context, binding); + expect(() => applySuggestion(next, reply, 1, context, binding)).toThrow(/revision/); + const refreshed = { ...reply, base_revision: 4, edits: [reply.edits[1]] }; + expect(applySuggestion(next, refreshed, 0, context, { ...binding, baseRevision: 4 }).items[0]!.intent).toBe('New intent'); + expect(plan.revision).toBe(3); +}); +it('update_file cannot silently add or rename a path', () => { + const update = { ...reply, edits: [{ ...edit, op: 'update_file', field: null, value: null, file: { ...plan.items[0]!.files[0], path: 'missing' } }] }; + expect(() => applySuggestion(plan, update, 0, context, binding)).toThrow(/does not exist/); +}); +const segment: Segment = { path: 'a', oldPath: 'a', kind: 'file', owners: ['P1'], row: 'P1', scope: 'in-scope', oldLine: null, newLine: null, operation: null, content: JSON.stringify({ oldMode: '100644', newMode: '100755', oldObject: { kind: 'blob', oid: 'a' }, newObject: { kind: 'blob', oid: 'a' } }), context: '', hunk: 0, sharesHunkWith: [] }; +it('binds approval to stable identity, item ID, and file metadata', () => { + const approval = approveItem(plan, [segment], 'P1', identity); + expect(approvalStates(plan, [segment], [approval], identity).P1).toBe('approved'); + expect(approvalStates(plan, [segment], [approval], { ...identity, planId: 'B' }).P1).toBe('stale'); + const renamed = structuredClone(plan); renamed.items[0]!.id = 'P2'; + expect(approvalStates(renamed, [{ ...segment, row: 'P2' }], [approval], identity).P2).toBe('unreviewed'); + for (const field of ['oldMode', 'newMode', 'oldObject', 'newObject']) { + const metadata = JSON.parse(segment.content); metadata[field] = null; + expect(approvalStates(plan, [{ ...segment, content: JSON.stringify(metadata) }], [approval], identity).P1).toBe('stale'); + } + expect(approvalStates(plan, [], [approval], identity).P1).toBe('stale'); +}); +it('cannot transfer standalone acceptance to another plan', () => { + const unplanned = { ...segment, row: 'Unplanned', owners: [null] }; + const choices = [{ key: choiceKeys([unplanned], identity)[0]!, action: 'accept' as const, item: null }]; + expect(applyChoices(plan, [unplanned], choices, identity)[0]!.row).toBe('Accepted'); + expect(applyChoices(plan, [unplanned], choices, { ...identity, planId: 'B' })[0]!.row).toBe('Unplanned'); +}); diff --git a/test/plan-v1.test.ts b/test/plan-v1.test.ts new file mode 100644 index 0000000..2777d48 --- /dev/null +++ b/test/plan-v1.test.ts @@ -0,0 +1,110 @@ +import { parseV1 } from '../core/parse-v1.ts'; +import { describe, expect, it } from 'vitest'; +import { stringify } from 'yaml'; +import { commandAllowed, commandArgv, importPlan, validatePlan, type Plan } from '../core/plan.ts'; +const plan = (): Plan => ({ schema_version: 1, issue: 1, revision: 1, summary: 'Example', questions: [], items: [{ id: 'P1', title: 'Change', intent: 'Improve', files: [{ path: 'a', kind: 'edit', renamed_from: null, change: 'Change' }], acceptance: [{ type: 'check', text: 'Works' }], depends_on: [] }] }); +const context = { identity: { repositoryId: 'repo', taskId: 'task', planId: 'plan' }, issue: 1, baseFiles: ['a'], baseEntries: [{ path: 'a', kind: 'file' as const }], pathKey: (p: string) => p, allowedCommands: [] }; +describe('frozen v1 input contract', () => { + it('requires exact complete argv approval', () => { + expect(commandAllowed(['go', 'test', '-exec', 'evil'], [['go', 'test']])).toBe(false); + expect(commandAllowed(['go', 'test'], [['go', 'test']])).toBe(true); + }); + it.each(['test (x)', 'test #x', 'test !x', "test 'a\\b'"])('rejects forbidden tokenizer spelling %s', text => expect(() => commandArgv(text)).toThrow()); + it('preserves empty args, adjacent quotes, and quoted punctuation', () => expect(commandArgv(`test '' ab" cd" '#!()'`)).toEqual(['test', '', 'ab cd', '#!()'])); + it('requires selected issue and known path identity', () => { + expect(validatePlan(plan(), { ...context, issue: undefined } as any).errors.length).toBeGreaterThan(0); + expect(validatePlan(plan(), { ...context, pathKey: undefined } as any).errors.length).toBeGreaterThan(0); + }); + it('rejects decoded duplicate JSON keys', () => { + const input = JSON.stringify(plan()).replace('"issue":1', '"issue":2,"iss\\u0075e":1'); + expect(() => importPlan(input, 'json', context, 2)).toThrow(/duplicate/i); + }); + it.each(['&unused Example', '!!str Example', '', '.nan', '0x10', '1_000'])('rejects prohibited YAML scalar %s before schema checks', scalar => { + const input = stringify(plan()).replace('summary: Example', 'summary: '+scalar); + expect(() => importPlan(input, 'yaml', context, 2)).toThrow(/parse|anchor|tag|scalar/i); + }); + it('rejects non-JSON numeric spelling even when it would be a valid issue', () => { + expect(() => importPlan(stringify(plan()).replace('issue: 1', 'issue: 0x1'), 'yaml', context, 2)).toThrow(); + }); + it('rejects nesting above 50 and measures UTF-8 bytes', () => { + expect(() => importPlan('['.repeat(51)+'0'+']'.repeat(51), 'json', context, 2)).toThrow(/depth/i); + const large = JSON.stringify({ ...plan(), summary: 'é'.repeat(530000) }); + expect(() => importPlan(large, 'json', context, 2)).toThrow(/size|MiB/i); + }); + it('accepts equivalent JSON/YAML and treats quoted punctuation as data', () => { + const p = plan(); p.summary = 'literal << &anchor'; + expect(importPlan(stringify(p), 'yaml', context, 2).plan).toEqual(importPlan(JSON.stringify(p), 'json', context, 2).plan); + }); + it('rejects plan authoring at a gitlink', () => { + expect(validatePlan(plan(), { ...context, baseEntries: [{ path: 'a', kind: 'gitlink' }] } as any).errors.length).toBeGreaterThan(0); + }); + it('uses checkout identity for occupied destinations and parent collisions', () => { + const p = plan(); p.items[0]!.files[0] = { path: 'A', kind: 'add', renamed_from: null, change: 'Create' }; + expect(validatePlan(p, { ...context, pathKey: (s: string) => s.normalize('NFC').toLowerCase() }).errors.length).toBeGreaterThan(0); + }); + it('rejects editing a link and its writable directory target in the same invocation', () => { + const p = plan(); p.items[0]!.files.push({ path: 'dir/file', kind: 'edit', renamed_from: null, change: 'Change' }); + expect(validatePlan(p, { ...context, baseFiles: ['a', 'dir/file'], baseEntries: [{ path: 'a', kind: 'symlink', target: 'dir' }, { path: 'dir/file', kind: 'file' }] } as any).errors.length).toBeGreaterThan(0); + }); +}); + +describe('v1 retained parser fixtures', () => { + it.each([ + '{"a":{"x":1,"\\u0078":2}}', '{"a":1,}', '[1,]', '01', '+1', 'true false', + '"raw\nnewline"', '{"issue":1.00000000000000001}', '{"issue":1e-9999}', + ])('rejects invalid or lossy JSON before schema validation: %s', text => expect(() => parseV1(text, 'json')).toThrow()); + it.each(['x: &a value', 'x: !!str value', 'x: !custom value', 'x: *alias', 'x: ~', 'x: True', 'x: 0o10', 'x: +1', 'x: .inf', 'x: 01', 'x: 1_000', '1: value', '? [a, b]\n: x', 'x: {a: 1, "\\u0061": 2}', '<<: {}', '%YAML 1.1\n---\nx: yes', '---\nx: 1\n---\nx: 2'])('rejects prohibited YAML: %s', text => expect(() => parseV1(text, 'yaml')).toThrow()); + it('accepts container depth 50 and rejects 51 in both formats', () => { + for (const format of ['json', 'yaml'] as const) { + expect(() => parseV1('['.repeat(50)+'0'+']'.repeat(50), format)).not.toThrow(); + expect(() => parseV1('['.repeat(51)+'0'+']'.repeat(51), format)).toThrow(/depth/); + } + }); + it('checks byte boundary and invalid UTF-8 before decoding', () => { + expect(parseV1('"'+ 'a'.repeat(1048574)+'"', 'json')).toHaveLength(1048574); + expect(() => parseV1('"'+ 'a'.repeat(1048575)+'"', 'json')).toThrow(/size/); + expect(() => parseV1(new Uint8Array([0xff]), 'json')).toThrow(); + expect(() => parseV1('"\ud800"', 'json')).toThrow(/UTF/); + }); + it('keeps ordinary strings and quoted syntax as data', () => { + expect(parseV1('x: 2026-09-22\ny: "&a << !tag"\nz: |\n text\n', 'yaml')).toEqual({ x: '2026-09-22', y: '&a << !tag', z: 'text\n' }); + }); +}); + +it('uses Unicode identity and fails closed on unknown identity rules', () => { + const p = plan(); p.items[0]!.files[0]!.path = 'café'; + const c = { ...context, baseEntries: [{ path: 'cafe\u0301', kind: 'file' as const }], pathKey: (s: string) => s.normalize('NFC') }; + expect(validatePlan(p, c).errors).toEqual([]); + expect(validatePlan(p, { ...c, pathKey: () => { throw new Error('Unknown filesystem'); } }).errors[0]?.message).toMatch(/Unknown/); +}); +it('retains link type through projected renames and rejects children beneath it', () => { + const p = plan(); p.items[0]!.files[0] = { path: 'moved', kind: 'rename', renamed_from: 'a', change: 'Move link' }; + p.items.push({ ...structuredClone(p.items[0]!), id: 'P2', depends_on: ['P1'], files: [{ path: 'moved/child', kind: 'add', renamed_from: null, change: 'Create' }] }); + expect(validatePlan(p, { ...context, baseEntries: [{ path: 'a', kind: 'symlink', target: 'target' }] }).errors.some(e => e.code === 'path-parent')).toBe(true); +}); +it('rejects directories, colliding base leaves, and retained links through hidden symlinks', () => { + const p = plan(); p.items[0]!.files[0]!.path = 'dir'; + expect(validatePlan(p, { ...context, baseEntries: [{ path: 'dir/file', kind: 'file' }] }).errors.some(e => e.code === 'missing-file')).toBe(true); + expect(validatePlan(plan(), { ...context, baseEntries: [{ path: 'a', kind: 'file' }, { path: 'a/child', kind: 'file' }] }).errors.some(e => e.code === 'context')).toBe(true); + p.items[0]!.files[0] = { path: 'b', kind: 'rename', renamed_from: 'a', change: 'Move' }; + expect(validatePlan(p, { ...context, baseEntries: [{ path: 'a', kind: 'symlink', target: 'other/../target' }, { path: 'other', kind: 'symlink', target: 'dir' }] }).errors.some(e => e.code === 'symlink-target')).toBe(true); +}); + +it('rejects C1 control characters in commands and paths', () => { + expect(() => commandArgv("test '\u0085'")).toThrow(); + const p = plan(); p.items[0]!.files[0]!.path = 'a\u0085'; + expect(validatePlan(p, { ...context, baseEntries: [{ path: 'a\u0085', kind: 'file' }] }).errors.length).toBeGreaterThan(0); +}); + +it.each(['file', 'gitlink'] as const)('rejects retained targets traversing a %s entry', kind => { + const p = plan(); p.items[0]!.files[0] = { path: 'moved', kind: 'rename', renamed_from: 'a', change: 'Move' }; + expect(validatePlan(p, { ...context, baseEntries: [{ path: 'a', kind: 'symlink', target: 'target/child' }, { path: 'target', kind }] }).errors.some(e => e.code === 'symlink-target')).toBe(true); +}); +it('requires unsafe-link repair to be isolated from other writes', () => { + const p = plan(); p.items[0]!.files.push({ path: 'dir', kind: 'edit', renamed_from: null, change: 'Repair' }); + expect(validatePlan(p, { ...context, baseEntries: [{ path: 'a', kind: 'symlink', target: 'dir' }, { path: 'dir', kind: 'symlink', target: 'elsewhere' }] }).errors.some(e => e.code === 'symlink-target')).toBe(true); +}); +it('rejects declared ancestors of a retained link target', () => { + const p = plan(); p.items[0]!.files.push({ path: 'target', kind: 'add', renamed_from: null, change: 'Create' }); + expect(validatePlan(p, { ...context, baseEntries: [{ path: 'a', kind: 'symlink', target: 'target/child' }] }).errors.some(e => e.code === 'symlink-target')).toBe(true); +}); diff --git a/test/plan.test.ts b/test/plan.test.ts new file mode 100644 index 0000000..a9ef3b1 --- /dev/null +++ b/test/plan.test.ts @@ -0,0 +1,119 @@ +import { stringify } from 'yaml'; +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; +import { applySuggestion as applyBoundSuggestion, assertEditReply, commandArgv, commandAllowed, importPlan, isRepoPath, validatePlan, type Plan, type PlanContext } from '../core/plan.ts'; +import planSchema from '../schema/plan.schema.json' with { type: 'json' }; +import editSchema from '../schema/plan-edit.schema.json' with { type: 'json' }; +export const basePlan = (): Plan => ({ schema_version: 1, issue: 1, revision: 1, summary: 'Change behavior.', questions: [], items: [ + { id: 'P1', title: 'Change', intent: 'Improve behavior', files: [{ path: 'a.txt', kind: 'edit', renamed_from: null, change: 'Update behavior.' }], acceptance: [{ type: 'cmd', text: 'npm test' }], depends_on: [] }, +] }); +const identity = { repositoryId: 'repo', taskId: 'task', planId: 'plan' }; +const applySuggestion = (plan: Plan, reply: unknown, index: number, context: PlanContext) => applyBoundSuggestion(plan, reply, index, context, { identity: context.identity, schemaVersion: plan.schema_version, baseRevision: (reply as { base_revision: number }).base_revision, issue: plan.issue }); +const context: PlanContext = { identity, baseEntries: [{ path: 'a.txt', kind: 'file' }], pathKey: p => p, allowedCommands: [['npm', 'test']], issue: 1 }; +const reply = (op: string, payload: object = {}) => ({ schema_version: 1, base_revision: 1, reply: '', edits: [{ + op, item: 'P1', summary: 'Improve plan', reason: 'Clarify it', field: null, value: null, file: null, + check: null, check_index: null, depends_on: null, new_item: null, ...payload, +}] }); + +describe('plan format', () => { + it('imports the shipped YAML example and replaces its revision', () => { + const source = readFileSync(new URL('../schema/examples/plan-412-r3.yaml', import.meta.url), 'utf8'); + const result = importPlan(source, 'yaml', { identity, baseEntries: ['src/retry/client.go', 'src/retry/backoff.go', 'src/retry/config.go', 'src/retry/client_test.go', 'docs/retry.md'].map(path => ({ path, kind: 'file' })), pathKey: p => p, issue: 412, allowedCommands: [['go', 'test', './src/retry/...', '-run', 'TestRetryKeepsKey'], ['go', 'test', './src/retry/...', '-count=3'], ['go', 'test', './src/retry/...', '-run', 'TestRetryAfter'], ['markdownlint', 'docs/retries.md']] }, 8); + expect(result.plan.revision).toBe(8); + expect(result.warnings.map(w => w.code)).toEqual(['open-questions']); + }); + it('validates the shipped suggestion reply', () => { + const reply = JSON.parse(readFileSync(new URL('../schema/examples/plan-edit-412-r3.json', import.meta.url), 'utf8')); + expect(() => assertEditReply(reply)).not.toThrow(); + }); + it('keeps strict object definitions in sync', () => { + for (const key of ['item', 'file', 'check'] as const) expect(editSchema.$defs[key]).toEqual(planSchema.$defs[key]); + function visit(value: unknown) { + if (!value || typeof value !== 'object') return; + const node = value as Record; + if (node.type === 'object') { + expect(node.additionalProperties).toBe(false); + expect(new Set(node.required as string[])).toEqual(new Set(Object.keys(node.properties as object))); + } + Object.values(node).forEach(visit); + } + visit(planSchema); visit(editSchema); + }); + it.each([ + (p: any) => p.items[0].acceptance = [], (p: any) => p.extra = true, + (p: any) => p.items[0].id = 'P0', (p: any) => p.items[0].files[0].path = '/tmp/x', + (p: any) => p.items[0].files[0].kind = 'copy', (p: any) => p.items[0].files = [], + (p: any) => p.schema_version = 2, (p: any) => delete p.summary, + ])('rejects the eight documented broken plans %#', mutate => { + const plan = basePlan(); mutate(plan); expect(() => importPlan(JSON.stringify(plan), 'json', context, 2)).toThrow(); + }); + it.each(['../a', 'a/../b', 'a//b', './a', 'C:/a', '/a', 'a\\b', '.git/config', 'a/\0b'])('rejects unsafe path %s', path => expect(isRepoPath(path)).toBe(false)); + it('rejects duplicate YAML keys, aliases, and extra documents', () => { + for (const text of ['issue: 1\nissue: 2', 'x: &a [1]\ny: *a', '---\nissue: 1\n---\nissue: 2']) + expect(() => importPlan(text, 'yaml', context, 1)).toThrow(); + }); + it('validates add, edit, rename, delete in projected order', () => { + const plan = basePlan(); plan.items[0]!.files[0]!.kind = 'add'; + const p2 = structuredClone(plan.items[0]!); p2.id = 'P2'; p2.depends_on = ['P1']; p2.files[0]!.kind = 'rename'; p2.files[0]!.renamed_from = 'a.txt'; p2.files[0]!.path = 'b.txt'; + const p3 = structuredClone(p2); p3.id = 'P3'; p3.depends_on = ['P2']; p3.files[0]!.kind = 'edit'; p3.files[0]!.renamed_from = null; + const p4 = structuredClone(p3); p4.id = 'P4'; p4.depends_on = ['P3']; p4.files[0]!.kind = 'delete'; + plan.items.push(p2, p3, p4); + expect(validatePlan(plan, { ...context, baseEntries: [] }).errors).toEqual([]); + p3.depends_on = []; expect(validatePlan(plan, { ...context, baseEntries: [] }).errors.some(e => e.code === 'dependency')).toBe(true); + }); + it('rejects collisions, parent files, duplicate paths, cycles, and issue mismatch', () => { + const plan = basePlan(); plan.items[0]!.files[0] = { path: 'b', kind: 'rename', renamed_from: 'a.txt', change: 'Move.' }; + expect(validatePlan(plan, { ...context, baseEntries: ['a.txt', 'b'].map(path => ({ path, kind: 'file' })) }).errors[0]?.code).toBe('existing-file'); + plan.items[0]!.files[0] = { path: 'link/x', kind: 'add', renamed_from: null, change: 'Add.' }; + expect(validatePlan(plan, { ...context, baseEntries: [{ path: 'link', kind: 'file' }] }).errors[0]?.code).toBe('path-parent'); + plan.items[0]!.files.push({ ...plan.items[0]!.files[0]! }); plan.items[0]!.depends_on = ['P1']; + const codes = validatePlan(plan, { ...context, issue: 2 }).errors.map(e => e.code); + expect(codes).toContain('duplicate-path'); expect(codes).toContain('dependency'); expect(codes).toContain('issue'); + }); + it('parses literal quoted test patterns but rejects executable shell syntax', () => { + expect(commandArgv("go test ./src/... -run 'Jitter|Retry' -count=3")).toEqual(['go', 'test', './src/...', '-run', 'Jitter|Retry', '-count=3']); + for (const text of ['npm test; echo bad', 'npm test && x', 'npm test | x', 'npm test > file', 'npm test $(x)', 'npm test `x`', 'npm test\nx', 'npm test *']) expect(() => commandArgv(text)).toThrow(); + expect(commandAllowed(['npm', 'test-extra'], [['npm', 'test']])).toBe(false); + expect(validatePlan(basePlan(), { ...context, allowedCommands: [] }).warnings[0]?.code).toBe('command-not-allowed'); + }); +}); + +describe('suggestions', () => { + it('returns a new revision without mutating inputs', () => { + const plan = basePlan(), original = structuredClone(plan); + const next = applySuggestion(plan, reply('set_field', { field: 'title', value: 'New title' }), 0, context); + expect(next.revision).toBe(2); expect(next.items[0]!.title).toBe('New title'); expect(plan).toEqual(original); + }); + it('rejects malformed payloads and invalid results', () => { + for (const r of [reply('add_item'), reply('remove_file'), reply('remove_check', { check_index: 4 }), reply('remove_check', { check_index: 0 }), reply('set_field', { field: 'title', value: '' }), reply('remove_item'), reply('set_depends', { depends_on: ['P1'] })]) + expect(() => applySuggestion(basePlan(), r, 0, context)).toThrow(); + }); + it('rejects stale suggestions and cannot accidentally apply shifted indexes', () => { + const r = reply('add_check', { check: { type: 'check', text: 'Works' } }); + const next = applySuggestion(basePlan(), r, 0, context); + expect(() => applySuggestion(next, r, 0, context)).toThrow(/different revision/); + }); + it('supports file and item operations with full post-validation', () => { + const added = { path: 'b.txt', kind: 'add', renamed_from: null, change: 'Add.' }; + let plan = applySuggestion(basePlan(), reply('add_file', { file: added }), 0, context); + const update = { ...reply('update_file', { file: { ...added, change: 'Refined' } }), base_revision: 2 }; + plan = applySuggestion(plan, update, 0, context); expect(plan.items[0]!.files[1]!.change).toBe('Refined'); + plan = applySuggestion(plan, { ...reply('remove_file', { value: 'b.txt' }), base_revision: 3 }, 0, context); + expect(plan.items[0]!.files).toHaveLength(1); + const newItem = { ...structuredClone(plan.items[0]!), id: 'P2', depends_on: ['P1'] }; + plan = applySuggestion(plan, { ...reply('add_item', { item: 'P2', new_item: newItem }), base_revision: 4 }, 0, context); + expect(plan.items).toHaveLength(2); + }); +}); +it('rejects file/parent collisions declared within the same item', () => { + const plan = basePlan(); plan.items[0]!.files = ['new', 'new/child'].map(path => ({ path, kind: 'add', renamed_from: null, change: 'Create' })); + expect(validatePlan(plan, context).errors.some(e => e.code === 'path-parent')).toBe(true); +}); + +it('rejects an alias in an otherwise valid plan before schema validation', () => { + const source = stringify(basePlan()); + expect(() => importPlan(source, 'yaml', context, 1)).not.toThrow(); + const aliased = source.replace('summary: Change behavior.', 'summary: &summary Change behavior.').replace('title: Change', 'title: *summary'); + expect(aliased).toContain('*summary'); + expect(() => importPlan(aliased, 'yaml', context, 1)).toThrow(/Alias resolution is disabled|Anchors/); +}); diff --git a/test/registry.test.ts b/test/registry.test.ts new file mode 100644 index 0000000..f1c89b0 --- /dev/null +++ b/test/registry.test.ts @@ -0,0 +1,19 @@ +import { readFileSync } from 'node:fs'; +import { expect, it } from 'vitest'; +import { Ajv2020 } from 'ajv/dist/2020.js'; +import registry from '../schema/versions.json' with { type: 'json' }; +it('registers retained schemas once and keeps CLI copies identical to the current version', () => { + const ajv = new Ajv2020({ strict: true }); + for (const [version, entry] of Object.entries(registry.versions)) { + expect(entry.validator).toBe('v'+version); + expect(readFileSync(new URL('../schema/'+entry.semantics, import.meta.url), 'utf8')).toContain('## Deterministic input parsing'); + for (const kind of ['plan', 'edit'] as const) { + const text = readFileSync(new URL('../schema/'+entry[kind], import.meta.url), 'utf8'); + const schema = JSON.parse(text); ajv.addSchema(schema); + expect(schema.$id).toBe('https://github.com/codeabovelab/codeboost/schema/'+entry[kind]); + const cli = kind === 'plan' ? 'plan' : 'plan-edit'; + if (+version === registry.current) expect(text).toBe(readFileSync(new URL('../schema/'+cli+'.schema.json', import.meta.url), 'utf8')); + expect(ajv.getSchema(schema.$id)).toBeDefined(); + } + } +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..cfef7d8 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "target": "ES2024", "module": "NodeNext", "moduleResolution": "NodeNext", + "strict": true, "noUncheckedIndexedAccess": true, "resolveJsonModule": true, + "esModuleInterop": true, "skipLibCheck": true, "noEmit": true, + "allowImportingTsExtensions": true, "types": ["node"] + }, + "include": ["core/**/*.ts", "git/**/*.ts", "test/**/*.ts"] +}