diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..e90622b --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +# These are hand-authored application sources, not build output. +web/public/*.js linguist-generated=false linguist-vendored=false +web/public/*.css linguist-generated=false linguist-vendored=false +web/public/*.html linguist-generated=false linguist-vendored=false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 836b526..ab3d4c5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,3 +17,5 @@ jobs: - run: npm ci --ignore-scripts - run: npm run typecheck - run: npm test + - run: npx playwright install --with-deps chromium + - run: npm run test:browser diff --git a/.gitignore b/.gitignore index c938c74..cad3c41 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,7 @@ node_modules/ coverage/ dist/ .DS_Store + +.codeboost-local/ +playwright-report/ +test-results/ diff --git a/README.md b/README.md index 9c5b0cf..9a1aca1 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 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. +**Status:** the plan/linking library, SQLite store, and local read-only review screen are implemented. Run `npm run demo` and open its private local URL. There is no agent execution or merge command. The human go/no-go experiment is still pending; see [the local review guide](docs/implementation/read-only-review.md). ## Development @@ -41,7 +41,7 @@ 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. +Inputs such as `planText` and the ledger must come from the trusted runner. `runner/store` owns the database, persistent request lifecycle, revision allocation, and atomic Apply. The browser sends review commands through `runner/review`; it cannot write ledger ownership or approval fingerprints. See [storage decisions](docs/implementation/persistent-review-store.md). ## Current limits and safety diff --git a/core/linking.ts b/core/linking.ts index e9e4960..945eab8 100644 --- a/core/linking.ts +++ b/core/linking.ts @@ -1,7 +1,7 @@ import { diffArrays } from 'diff'; import type { Plan } from './plan.ts'; -export interface FileVersion { oid: string; mode: string; text: string | null } +export interface FileVersion { oid: string; mode: string; text: string | null; byteSize?: number; preview?: string } export interface ContextRange { oldStart: number; oldCount: number; newStart: number; newCount: number; name: string } export interface FileDelta { oldPath: string | null; newPath: string | null; diff --git a/docs/experiments/review-protocol.md b/docs/experiments/review-protocol.md new file mode 100644 index 0000000..8291265 --- /dev/null +++ b/docs/experiments/review-protocol.md @@ -0,0 +1,40 @@ +# Plan-indexed review: go/no-go protocol + +Status: **not started; issue pairs and human reviewer still required**. This document does not claim a passed gate. Complete the pair table and commit its exact revision before any timed review. No pair or threshold may change once the first review begins. + +## Pair selection and order + +Choose four pairs of comparable, small real issues in a repository owned by the reviewer. Match each pair on scope, language, and estimated review complexity before implementing either issue. Record issue URLs, PR URLs, base/head SHAs, and which review method each gets. Do not reuse the demo fixture as an experimental issue. + +| Pair | Real issue A / PR | Real issue B / PR | A method | B method | +|---|---|---|---|---| +| 1 | Pending selection | Pending selection | Raw GitHub diff | codeboost | +| 2 | Pending selection | Pending selection | codeboost | Raw GitHub diff | +| 3 | Pending selection | Pending selection | Raw GitHub diff | codeboost | +| 4 | Pending selection | Pending selection | codeboost | Raw GitHub diff | + +The reviewer must not implement the paired changes or inspect the plants before deciding on the PR. An operator prepares plans and commits by hand, records trusted ledger ownership, and supplies both the plan and code to each review method. The earlier one-issue manual assignment also remains required: record how every change maps to the attribution table and turn unclear cases into fixtures. + +## Planting + +The operator runs `node scripts/plant.ts review.json NEW_DIRECTORY plant-input.json`. The input contains `declaredText`, `undeclaredText`, and a fresh top-level `undeclaredPath`. The helper creates a separate local clone and database, randomly selects eligible owned commits, amends those commits while preserving readable messages/trailers, records old/new mappings and ownership, and writes the returned `review.json` for the local UI. It never pushes or changes the source repository. Both plants are owned through the ledger, so the undeclared-file plant is out of scope rather than foreign. + +The helper supports linear histories and regular, top-level declared files retained through the remaining history. It refuses unsupported targets and stops on cherry-pick conflicts; the operator must inspect a failed disposable clone and choose a fresh output path, never silently resolve a conflict or alter the frozen issue pair. Plant text must represent a plausible unrelated code change, not an obvious marker. The automated test uses obvious markers only to verify the helper. + +`sealed.json` outside the clone records locations and selected commits with owner-only file permissions. It is unblinding data, not encryption. The operator must withhold it from the reviewer until that PR's decision is recorded. Do not show CLI input or sealed data to the reviewer. Open PRs only after inspecting the prepared branch; PR publication is a separate action. + +## Measurement + +Time each review from first opening to the recorded decision. Record elapsed seconds, unexplained changes, the undeclared-file plant found (yes/no), and declared-file plant found (yes/no). Record decisions before unblinding. Also record interruptions and tool failures without silently dropping trials. + +Pass only when all are true: + +- codeboost catches at least 3 of its 4 undeclared-file plants and more than raw-diff review catches; +- median codeboost time is no slower than median raw-diff time; +- no change is left unexplained. + +Report declared-file catch rate for both methods without a pass threshold. Stop after four pairs. If within one catch of the bar, perform four more preselected pairs once, then decide. Do not add further trials. Record the outcome in a committed results document before merge/agent/planning/queue/learning implementation begins. + +## Current evidence + +Engineering tests prove browser interactions, persistence, attribution, and planting mechanics. They do **not** establish human review speed or catch rates. No real issue pairs, timed decisions, or catch-rate results exist yet. diff --git a/docs/implementation/read-only-review.md b/docs/implementation/read-only-review.md new file mode 100644 index 0000000..269faae --- /dev/null +++ b/docs/implementation/read-only-review.md @@ -0,0 +1,52 @@ +# Read-only review screen (#3) + +Started from `dd6a1e7`, after merging the SQLite store PR. This delivers the local review surface and experiment tooling; issue #3 remains open until the human go/no-go work is complete. + +## Run it + +`npm ci --ignore-scripts`, then `npm run demo`. Open the private loopback URL printed by the command. The demo uses a real disposable Git repository and SQLite database under ignored `.codeboost-local/demo`; restart preserves approvals and notes. No source files in the codeboost checkout are edited by the demo. Stop with Ctrl+C. + +For an existing trusted store, use `npm start -- --config /absolute/path/review.json`. The JSON has `database`, `repository`, `identity` (`repositoryId`, `taskId`, `planId`), and `pathIdentity` (`caseSensitive` boolean, `unicodeNormalization` equal to `none` or `NFC`). Paths should be absolute. Supply actual checkout identity rules, not an OS guess. The demo probes its local filesystem; it uses only simple ASCII fixture paths. Unsupported or more complex filesystem equivalence must not be approximated by this adapter. + +The selected repository's current HEAD is compared to the stored base. Refresh observes new heads and creates an immutable snapshot; approvals are recomputed for the resulting changes. It does not fetch, rebase, run tests, invoke agents, or merge. Histories still obey the library's linear/complete/bounded-history constraints. + +## UI and storage + +The app follows the Evidence Desk tokens and self-hosts IBM Plex Sans/Mono. It has item and exception rows, four status checks, a provenance gutter, file metadata, shared-hunk labels, assignments, standalone acceptance, no-change confirmation, approval counts/staleness, before/after approval evidence, per-item questions/change requests, keyboard controls, resizable/collapsible side panes, desktop breakpoints, and loading/error/empty states. Questions are saved for discussion; no AI answer is fabricated. Tests/AI review are explicitly not run. Change requests remain pending for a future revision workflow. + +The server binds only 127.0.0.1 and requires its random private token for APIs. Host/origin checks, a restrictive CSP, bounded UTF-8 JSON bodies, and DOM escaping prevent another website from reading or writing local review state. Browser commands contain item/segment IDs and the reviewed state token, never approval fingerprints or ledger ownership. The runner derives those from Git/store data. A database review counter prevents concurrent review actions from approving unseen assignments; plan and snapshot CAS remain enforced. + +Store schema v2 adds the review counter and per-item notes through a transactional v1 migration. The test suite verifies existing revisions and ledger entries survive. This is an automatic local SQLite migration, not a migration against a shared environment. + +## Known limits and remaining gates + +- No agent answers, test execution, AI findings ingestion, send-to-agent action, or merge control is included. These belong after the go/no-go gate. Four checks distinguish unavailable evidence from success. +- File cards show mode/path/object IDs and blob byte sizes. PNG/JPEG/GIF/WebP previews are bounded to 1 MiB each and 4 MiB across a history; unsupported/oversized images say unavailable. Gitlink byte sizes are not applicable. SVG/HTML is never embedded. +- The protocol at `docs/experiments/review-protocol.md` must be filled with the real pairs and committed before the first timed review. The manual assignment, real paired PRs, human timing, and final result are pending. Do not mark issue #3 closed or claim the gate passed. +- The planting helper is intentionally limited to disposable clones and supported regular top-level paths; it never publishes PRs. + +## Validation + +Baseline before this slice: 157 tests. Run `npm run typecheck`, `npm test`, and `npm run test:browser`. Browser tests use real Git and SQLite with Chromium and cover persistence, assignments, metadata, no-change approval, stale views, unsafe origins, keyboard/breakpoints, error display, and untrusted text. A browser regression exposed false stale reasons from JSON field order; structural comparison replaced that check. The plant test verifies source HEAD stays unchanged and both plants retain ledger ownership. + +A large-change regression reproduced HTTP 413 when the browser sent the full content-based choice key for a 20 KB segment. Browser segment IDs are now bounded SHA-256 identifiers; the runner reconstructs the original identity/content/copy key before saving the choice. This retains choice expiry semantics without sending source content back in a review command. + +## Review round 1 + +Reproduced and fixed no-change approval with item-owned ambiguous segments; the runner refuses it and the UI directs the user to resolve attribution first. Previously approved no-change items become stale if ambiguous work appears. Reproduced malformed non-ASCII credentials returning a generic conflict instead of unauthorized; credentials now require the expected ASCII hex shape before constant-time comparison. Reproduced stale item controls surviving a failed refresh; errors now discard the loaded view and require refresh. Added acceptance persistence and whole-plan empty-state browser coverage. No findings declined. + +## Review round 2 + +Extended the attribution guard to mixed items with both owned and ambiguous segments. The regression reproduced ordinary approval succeeding with unresolved attribution. Approval now rejects any item-specific ambiguity, previously approved affected items become stale, and the UI routes every such item to attribution resolution. Added browser coverage for this mixed case. No findings declined. + +A toolbar-focus browser regression reproduced review shortcuts being disabled while a button had focus. Only text-entry/select controls now suppress the single-letter shortcuts; native Enter/Space button behavior is unchanged. The plan-row click case already worked because rendering replaces the focused row. + +Review round 3 identified demo fixture config escapes and inherited Git environment redirection. Regression checks first reproduced the config, symlink, and environment failures. Existing demos now require exact fixture paths and ordinary config/database/repository objects (including `.git` and SQLite sidecars). Demo and plant Git commands share a case-insensitive environment scrub and disable inherited global/system Git configuration. Four helper tests cover these boundaries, including mixed-case variable names for Windows. + +Review round 4 had no inline findings but raised two summary concerns. Both were reproduced: planting accepted a non-ASCII path under case-insensitive identity even though the viewer refuses it; accepted cards retained an unplanned scope label. Planting now applies the same path guard, and accepted cards explicitly say “Accepted outside plan.” The underlying provenance remains intact. Unit/browser regressions cover both fixes; no concerns were declined. + +Review round 5 found a symlinked-ancestor escape and raw-spelling collisions in planting guards. Both regression cases failed before the fix. Demo creation/reuse now checks every ancestor (canonicalizing only the OS temporary-directory prefix); planting compares canonical identities for declared files and every path in the base/commit trees before cloning. The summary's status-styling concern had no additional concrete example; accepted cards already use the explicit label added in round 4. + +Review round 6 reproduced destination-inside-source mutation; planting now resolves the existing destination ancestor and rejects canonical source descendants before creating files, including `.git` and symlink aliases. The Git-environment finding was declined: `gitRaw` already passes `{cwd, env, ...}` to `execFileSync`. A new end-to-end test with inherited `GIT_DIR`/`GIT_WORK_TREE` passed before any production change. The summary's snapshot-race claim supplied no concrete interleaving; the service already checks revision/snapshot/review version before publishing a view and writes use atomic store CAS, covered by concurrent-write tests. + +Review round 7 reproduced oversized-dimension raster previews and dangling SQLite sidecar symlinks. Preview metadata is now parsed from the bounded buffer with image-size: at most 8,192 pixels per side, 4 million pixels per image and 16 million pixels across unique preview blobs. Unknown dimensions omit the preview; existing compressed/response byte limits remain. Sidecars use lstat so dangling links are rejected. Both new regressions failed before the fixes. The summary mentioned rename-scope/literal-path concerns without specific findings; no additional behavior was inferred from that shorthand. diff --git a/git/history.ts b/git/history.ts index 1ed1f4d..ecc8dfe 100644 --- a/git/history.ts +++ b/git/history.ts @@ -1,3 +1,4 @@ +import { imageSize } from 'image-size'; import { execFileSync } from 'node:child_process'; import { lstatSync, opendirSync } from 'node:fs'; import { resolve as resolvePath, join } from 'node:path'; @@ -89,7 +90,8 @@ export function readHistory(repo: string, baseRef: string, headRef = 'HEAD', lim 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 blobs = new Map(); + let previewBytes = 0, previewPixels = 0; 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 @@ -100,9 +102,25 @@ export function readHistory(repo: string, baseRef: string, headRef = 'HEAD', lim 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); + let preview: string | undefined; + // Only bounded raster images; never embed SVG/HTML or fetch external references. + const mime = data.subarray(0,8).equals(Buffer.from([137,80,78,71,13,10,26,10])) ? 'image/png' : + data.subarray(0,3).equals(Buffer.from([255,216,255])) ? 'image/jpeg' : + /^GIF8[79]a$/.test(data.subarray(0,6).toString('ascii')) ? 'image/gif' : + data.subarray(0,4).toString('ascii') === 'RIFF' && data.subarray(8,12).toString('ascii') === 'WEBP' ? 'image/webp' : null; + if (mime && data.length <= 1024 * 1024 && previewBytes + data.length <= 4 * 1024 * 1024) { + try { + // Parse metadata from the already bounded buffer; never allocate decoded pixels. + const { width, height } = imageSize(data), pixels = width * height; + if (Number.isSafeInteger(pixels) && width > 0 && height > 0 && width <= 8192 && height <= 8192 && pixels <= 4_000_000 && previewPixels + pixels <= 16_000_000) { + preview = `data:${mime};base64,${data.toString('base64')}`; + previewBytes += data.length; previewPixels += pixels; + } + } catch { /* Unknown or malformed dimensions: metadata card only. */ } + } + blobs.set(oid, { text, byteSize: data.length, ...(preview ? { preview } : {}) }); } - return { oid, mode, text: blobs.get(oid)! }; + return { oid, mode, ...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, '--')); diff --git a/package-lock.json b/package-lock.json index b9320b5..fde54e9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,11 +8,15 @@ "name": "codeboost", "version": "0.0.0", "dependencies": { + "@fontsource/ibm-plex-mono": "5.3.0", + "@fontsource/ibm-plex-sans": "5.3.0", "ajv": "8.20.0", "diff": "9.0.0", + "image-size": "2.0.4", "yaml": "2.9.1" }, "devDependencies": { + "@playwright/test": "1.63.0", "@types/node": "26.6.2", "typescript": "7.0.2", "vitest": "5.0.1" @@ -21,6 +25,24 @@ "node": ">=26.7.0" } }, + "node_modules/@fontsource/ibm-plex-mono": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-mono/-/ibm-plex-mono-5.3.0.tgz", + "integrity": "sha512-eTgnZjZEGk1QtD3ZstF+Vclo2HLAni8YMy34/DxllwZvyz1lR/1RF/xTiAquOBO7MvqBx8D2Ig2WCPMVfdZu7Q==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/ibm-plex-sans": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-sans/-/ibm-plex-sans-5.3.0.tgz", + "integrity": "sha512-CbE4CbbEEZJX860XyUiRpsksXIQR8Rp2XDva2VO53NJox9tVNtusrysd2x5YkUEY3ErQ66W1IiiQL8/wihhw5w==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -60,6 +82,22 @@ "url": "https://github.com/sponsors/oxc-project" } }, + "node_modules/@playwright/test": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", + "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "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", @@ -908,6 +946,18 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/image-size": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/image-size/-/image-size-2.0.4.tgz", + "integrity": "sha512-QRUkFFsRV/6fuESxb9Vkq+a0LkSrgKXuc2NEqfikiXxxN/G3tjWt5EVUlMaImRBZRZK/jRBEbYvpPYZL8t08Zw==", + "license": "MIT", + "bin": { + "image-size": "bin/image-size.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -1264,6 +1314,35 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/postcss": { "version": "8.5.28", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", diff --git a/package.json b/package.json index ff7fed9..b169120 100644 --- a/package.json +++ b/package.json @@ -8,14 +8,21 @@ }, "scripts": { "test": "vitest run", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "start": "node web/cli.ts", + "demo": "node web/cli.ts --demo", + "test:browser": "playwright test" }, "dependencies": { + "@fontsource/ibm-plex-mono": "5.3.0", + "@fontsource/ibm-plex-sans": "5.3.0", "ajv": "8.20.0", "diff": "9.0.0", + "image-size": "2.0.4", "yaml": "2.9.1" }, "devDependencies": { + "@playwright/test": "1.63.0", "@types/node": "26.6.2", "typescript": "7.0.2", "vitest": "5.0.1" diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..79d8a1a --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,2 @@ +import { defineConfig } from '@playwright/test'; +export default defineConfig({ testDir: './test/browser', fullyParallel: false, workers: 1, use: { browserName:'chromium', viewport:{width:1512,height:982}, trace:'retain-on-failure' } }); diff --git a/runner/review.ts b/runner/review.ts new file mode 100644 index 0000000..38ce52b --- /dev/null +++ b/runner/review.ts @@ -0,0 +1,99 @@ +import { createHash } from 'node:crypto'; +import { basename } from 'node:path'; +import { isDeepStrictEqual } from 'node:util'; +import { Store, type ReviewState } from './store.ts'; +import type { PlanIdentity } from '../core/identity.ts'; +import { readHistory } from '../git/history.ts'; +import { linkHistory } from '../core/linking.ts'; +import { applyChoices, approvalStates, approveItem, choiceKeys } from '../core/approvals.ts'; + +export interface ReviewConfig { database: string; repository: string; identity: PlanIdentity; pathIdentity: { caseSensitive: boolean; unicodeNormalization: 'none' | 'NFC' }; demo?: boolean } +export class ReviewService { + store: Store; + config: ReviewConfig; + constructor(config: ReviewConfig) { + if (typeof config.pathIdentity?.caseSensitive !== 'boolean' || !['none', 'NFC'].includes(config.pathIdentity.unicodeNormalization)) throw new Error('Known checkout path identity is required.'); + this.config = config; this.store = new Store(config.database); + } + close() { this.store.close(); } + load() { + const { identity, repository, pathIdentity } = this.config; + const reviewVersion = this.store.reviewVersion(identity); + const plan = this.store.getPlan(identity); + let snapshot = this.store.getSnapshot(identity); + // HEAD changes are observed; no Git mutation is performed by the review service. + const history = readHistory(repository, snapshot.base, 'HEAD'); + if (history.head !== snapshot.head) snapshot = this.store.recordHistory(identity, { revision: plan.revision, snapshotId: snapshot.id, reviewVersion }, history.base, history.head, []); + const pathKey = (path: string) => { + if (!pathIdentity.caseSensitive && /[^\x20-\x7e]/.test(path)) throw new Error('Non-ASCII case-insensitive paths require a filesystem-specific identity adapter.'); + const normalized = pathIdentity.unicodeNormalization === 'NFC' ? path.normalize('NFC') : path; + return pathIdentity.caseSensitive ? normalized : normalized.toLowerCase(); + }; + const raw = linkHistory(plan, history, this.store.ownership(identity, plan.revision), pathKey); + const saved = this.store.getReview(identity), keys = choiceKeys(raw, identity); + const deltas = new Map(history.final.map(file => [JSON.stringify([file.newPath ?? file.oldPath, file.oldPath]), file])); + let previewBudget = 6 * 1024 * 1024; + const preview = (value: string | undefined) => { + if (!value || value.length > previewBudget) return null; + previewBudget -= value.length; return value; + }; + const segments = applyChoices(plan, raw, saved.choices, identity).map((segment, index) => { + const target = plan.items.find(item => item.id === segment.row); + if (target && segment.row !== raw[index]!.row) { + const declared = new Set(target.files.flatMap(file => [file.path, ...(file.renamed_from ? [file.renamed_from] : [])]).map(pathKey)); + segment.scope = declared.has(pathKey(segment.path)) ? 'in-scope' : 'out-of-scope'; + } + const delta = segment.kind === 'file' ? deltas.get(JSON.stringify([segment.path, segment.oldPath])) : undefined; + const file = delta ? { oldSize: delta.before?.byteSize ?? null, newSize: delta.after?.byteSize ?? null, beforePreview: preview(delta.before?.preview), afterPreview: preview(delta.after?.preview) } : null; + return { ...segment, file, key: createHash('sha256').update(keys[index]!).digest('hex'), originalRow: raw[index]!.row }; + }); + const states = approvalStates(plan, segments, saved.approvals, identity); + for (const item of plan.items) { + if (states[item.id] === 'approved' && (segments.some(segment => segment.row === 'Ambiguous' && segment.owners.includes(item.id)) || item.depends_on.some(id => states[id] === 'stale'))) states[item.id] = 'stale'; + } + const expected: ReviewState = { revision: plan.revision, snapshotId: snapshot.id, reviewVersion }; + const notes = this.store.getReviewNotes(identity); + if (this.store.reviewVersion(identity) !== reviewVersion || this.store.getPlan(identity).revision !== plan.revision || this.store.getSnapshot(identity).id !== snapshot.id) throw new Error('Stale review state. Reload before writing.'); + const items = plan.items.map(item => { + const owned = segments.filter(segment => segment.row === item.id); + const ambiguous = segments.filter(segment => segment.row === 'Ambiguous' && segment.owners.includes(item.id)).length; + const outside = owned.filter(segment => segment.scope === 'out-of-scope').map(segment => segment.path); + const prior = saved.approvals.find(approval => approval.item === item.id); + const before = prior ? JSON.parse(prior.fingerprint) : null; + const reasons: string[] = []; + if (states[item.id] === 'stale') { + if (before && !isDeepStrictEqual(before.item.acceptance, item.acceptance)) reasons.push('Acceptance checks changed'); + if (before && owned.some(segment => before.segments.some((old: { path: string; content: string; context: string }) => old.path === segment.path && old.content === segment.content && old.context !== segment.context))) reasons.push('Moved to another function'); + for (const dep of item.depends_on) if (states[dep] === 'stale') reasons.push(`Depends on ${dep}, which changed`); + if (!reasons.length) reasons.push('Code or plan definition changed'); + } + return { ...item, state: states[item.id], count: owned.length, ambiguousCount: ambiguous, reasons, before, + checks: { attributed: ambiguous ? `! ${ambiguous} ambiguous` : owned.length ? '✓ Attributed' : '– No changes', scope: outside.length ? `✕ ${new Set(outside).size} out of scope` : owned.length ? '✓ In scope' : '– No changes', tests: item.acceptance.some(check => check.type === 'cmd') ? '– Not run' : '– No tests defined', ai: '– Not run' }, outside: [...new Set(outside)], + }; + }); + const token = createHash('sha256').update(JSON.stringify({ expected, saved, plan, segments })).digest('hex'); + return { repository: basename(repository), demo: this.config.demo ?? false, plan, snapshot, expected, token, items, segments, notes, approved: items.filter(item => item.state === 'approved').length }; + } + act(input: unknown) { + if (!input || typeof input !== 'object') throw new Error('Invalid review command.'); + const command = input as Record; + const view = this.load(); + if (command.token !== view.token) throw new Error('Stale review state. Reload before writing.'); + const { identity } = this.config; + if (command.action === 'approve' && typeof command.item === 'string') { + const item = view.items.find(item => item.id === command.item); + if (item && item.ambiguousCount > 0) throw new Error('Resolve this item’s ambiguous changes before approval.'); + const approval = approveItem(view.plan, view.segments, command.item, identity, command.confirmNoChange === true); + this.store.saveReview(identity, view.expected, [approval], []); + } else if ((command.action === 'assign' || command.action === 'accept') && typeof command.key === 'string') { + const segment = view.segments.find(segment => segment.key === command.key); + if (!segment || !['Ambiguous', 'Unplanned'].includes(segment.row)) throw new Error('This change cannot be assigned or accepted.'); + const item = command.action === 'assign' && typeof command.item === 'string' ? command.item : null; + const storedKey = choiceKeys(view.segments, identity)[view.segments.indexOf(segment)]!; + this.store.saveReview(identity, view.expected, [], [{ key: storedKey, action: command.action, item }]); + } else if (command.action === 'note' && typeof command.item === 'string' && typeof command.text === 'string' && (command.kind === 'question' || command.kind === 'change')) { + this.store.addReviewNote(identity, view.expected, command.item, command.kind, command.text); + } else throw new Error('Unknown review command.'); + return this.load(); + } +} diff --git a/runner/store.ts b/runner/store.ts index f07b609..cc96c5e 100644 --- a/runner/store.ts +++ b/runner/store.ts @@ -11,7 +11,8 @@ export function requireSupportedNode(version = process.versions.node): void { throw new Error('codeboost requires Node 26.7.0 or later. Upgrade Node before opening the store.'); } export interface Snapshot { id: string; base: string; head: string } -export interface ReviewState { revision: number; snapshotId: string } +export interface ReviewState { revision: number; snapshotId: string; reviewVersion?: number } +export interface ReviewNote { id: string; item: string; kind: 'question' | 'change'; text: string; createdAt: string; revision: number; snapshotId: string } export interface LedgerEntry { sha: string; owner: string | null; origin: 'owned' | 'foreign'; sourceSha: string | null } export interface Checkpoint { id: string; revision: number; snapshotId: string; item: string; @@ -37,9 +38,9 @@ export class Store { this.#db.exec('PRAGMA foreign_keys=ON; PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL;'); this.#transaction(() => { const version = this.#get('PRAGMA user_version')!.user_version; - if (version !== 0 && version !== 1) throw new Error('Unsupported store schema version.'); - if (version === 1) return; - this.#db.exec(` + if (version !== 0 && version !== 1 && version !== 2) throw new Error('Unsupported store schema version.'); + if (version === 2) return; + if (version === 0) this.#db.exec(` CREATE TABLE plans (key TEXT PRIMARY KEY, issue INTEGER NOT NULL, revision INTEGER NOT NULL, snapshot_id TEXT); CREATE TABLE revisions (key TEXT NOT NULL REFERENCES plans(key), revision INTEGER NOT NULL, data TEXT NOT NULL, PRIMARY KEY(key,revision)); CREATE TABLE snapshots (key TEXT NOT NULL REFERENCES plans(key), id TEXT NOT NULL, data TEXT NOT NULL, PRIMARY KEY(key,id)); @@ -52,6 +53,9 @@ export class Store { CREATE TABLE continuations (key TEXT NOT NULL REFERENCES plans(key), checkpoint_id TEXT NOT NULL, revision INTEGER NOT NULL, PRIMARY KEY(key,checkpoint_id,revision)); PRAGMA user_version=1; `); + this.#db.exec(`ALTER TABLE plans ADD COLUMN review_version INTEGER NOT NULL DEFAULT 0; + CREATE TABLE review_notes (key TEXT NOT NULL REFERENCES plans(key), id TEXT NOT NULL, data TEXT NOT NULL, PRIMARY KEY(key,id)); + PRAGMA user_version=2;`); }); } catch (error) { this.#db.close(); throw error; } } @@ -70,7 +74,8 @@ export class Store { } #expect(key: string, expected: ReviewState): void { const row = this.#current(key); - if (row.revision !== expected.revision || row.snapshot_id !== expected.snapshotId) throw new Error('Stale review state. Reload before writing.'); + if (row.revision !== expected.revision || row.snapshot_id !== expected.snapshotId || + (expected.reviewVersion !== undefined && row.review_version !== expected.reviewVersion)) throw new Error('Stale review state. Reload before writing.'); } #context(key: string, context: PlanContext): void { if (identityKey(context.identity) !== key || context.issue !== this.#current(key).issue) throw new Error('Plan context identity/issue mismatch.'); @@ -93,7 +98,7 @@ export class Store { sha(base); sha(head); const key = identityKey(context.identity), plan = importPlan(source, format, context, 1).plan; return this.#transaction(() => { - this.#run('INSERT INTO plans VALUES (?,?,?,NULL)', key, plan.issue, 0); + this.#run('INSERT INTO plans (key,issue,revision,snapshot_id) VALUES (?,?,?,NULL)', key, plan.issue, 0); this.#savePlan(key, plan, 0); this.#snapshot(key, base, head); return plan; @@ -237,8 +242,24 @@ export class Store { if (!choice.key || !['assign','accept'].includes(choice.action) || (choice.action === 'assign' ? !plan.items.some(item => item.id === choice.item) : choice.item !== null)) throw new Error('Invalid segment choice.'); this.#run('INSERT OR REPLACE INTO choices VALUES (?,?,?)', key, choice.key, encode({ ...choice, ...expected })); } + this.#run('UPDATE plans SET review_version=review_version+1 WHERE key=?', key); }); } + reviewVersion(identity: PlanIdentity): number { return this.#current(identityKey(identity)).review_version as number; } + addReviewNote(identity: PlanIdentity, expected: ReviewState, item: string, kind: ReviewNote['kind'], text: string): ReviewNote { + const key = identityKey(identity); + return this.#transaction(() => { + this.#expect(key, expected); + if (!this.getPlan(identity).items.some(entry => entry.id === item) || !['question', 'change'].includes(kind) || typeof text !== 'string' || !text.trim() || text.length > 4000) throw new Error('Invalid review note.'); + const note = { id: randomUUID(), item, kind, text: text.trim(), createdAt: new Date().toISOString(), revision: expected.revision, snapshotId: expected.snapshotId }; + this.#run('INSERT INTO review_notes VALUES (?,?,?)', key, note.id, encode(note)); + this.#run('UPDATE plans SET review_version=review_version+1 WHERE key=?', key); + return note; + }); + } + getReviewNotes(identity: PlanIdentity): ReviewNote[] { + return this.#db.prepare('SELECT data FROM review_notes WHERE key=? ORDER BY rowid').all(identityKey(identity)).map(row => decode(row.data)); + } getReview(identity: PlanIdentity): { approvals: (Approval & ReviewState)[]; choices: (SegmentChoice & ReviewState)[] } { const key = identityKey(identity); this.#current(key); return { diff --git a/scripts/demo.ts b/scripts/demo.ts new file mode 100644 index 0000000..cd2abbb --- /dev/null +++ b/scripts/demo.ts @@ -0,0 +1,64 @@ +import { existsSync, mkdirSync, mkdtempSync, rmSync, readFileSync, writeFileSync, chmodSync, lstatSync, realpathSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { resolve, join, relative, isAbsolute, dirname } from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import { Store } from '../runner/store.ts'; +import type { ReviewConfig } from '../runner/review.ts'; +import type { Plan } from '../core/plan.ts'; +import { isolatedGitEnvironment } from './git-environment.ts'; +/** Disposable fixture only. Never runs against the user's working repository. */ +export function createDemo(directory: string): ReviewConfig { + const requested = resolve(directory), temporary = resolve(tmpdir()); + const tempRelative = relative(temporary, requested); + // The OS temp directory may itself use a system alias (e.g. /var on macOS). + // Canonicalize that trusted prefix only; reject user-created symlinks below it. + const root = !tempRelative.startsWith('..') && !isAbsolute(tempRelative) ? resolve(realpathSync(temporary), tempRelative) : requested; + for (let path = root; ; path = dirname(path)) { + const stat = lstatSync(path, { throwIfNoEntry: false }); + if (stat?.isSymbolicLink()) throw new Error('Demo fixture ancestors must not be symlinks.'); + if (dirname(path) === path) break; + } + const configPath = join(root, 'review.json'); + if (existsSync(root) && lstatSync(root).isSymbolicLink()) throw new Error('Demo fixture root must not be a symlink.'); + if (existsSync(configPath)) { + const check = (path: string, directory: boolean) => { + const stat = lstatSync(path); + if (stat.isSymbolicLink() || (directory ? !stat.isDirectory() : !stat.isFile())) throw new Error('Demo fixture paths must be ordinary local files and directories.'); + }; + check(configPath, false); + const config = JSON.parse(readFileSync(configPath, 'utf8')) as ReviewConfig; + if (config.demo !== true || config.repository !== join(root, 'retry-service') || config.database !== join(root, 'review.sqlite')) throw new Error('Demo configuration must refer to its own fixture.'); + check(config.repository, true); check(join(config.repository, '.git'), true); check(config.database, false); + for (const suffix of ['-wal', '-shm']) if (lstatSync(config.database + suffix, { throwIfNoEntry: false })) check(config.database + suffix, false); + return config; + } + if (existsSync(root)) throw new Error('Demo directory exists without a configuration. Choose a new empty path.'); + mkdirSync(root, { recursive: true }); const repository = join(root, 'retry-service'); mkdirSync(repository); + const git = (...args: string[]) => execFileSync('git', ['-c','core.hooksPath=/dev/null',...args], { cwd: repository, env: isolatedGitEnvironment(), encoding: 'utf8', stdio: ['ignore','pipe','pipe'] }).trim(); + git('init','-b','main'); git('config','user.name','Codeboost Demo'); git('config','user.email','demo@example.invalid'); git('config','commit.gpgsign','false'); + const write = (path: string, text: string | Buffer) => writeFileSync(join(repository,path),text); + const commit = (message: string) => { git('add','-A');git('commit','-m',message);return git('rev-parse','HEAD'); }; + write('retry.ts', 'export function delay(attempt: number) {\n return 100 * attempt;\n}\n'); + write('README.md','# Retry service\n\nRetries use a fixed delay.\n');write('run.sh','#!/bin/sh\nprintf "ready\\n"\n'); + const base=commit('Initial service'); + write('retry.ts','export function delay(attempt: number) {\n return Math.min(5000, 100 * 2 ** attempt);\n}\n');const first=commit('Bound exponential retries\n\nPlan-Item: P1'); + write('README.md','# Retry service\n\nRetries use bounded exponential backoff.\n');chmodSync(join(repository,'run.sh'),0o755);const second=commit('Document retry behavior\n\nPlan-Item: P2'); + write('debug.log','temporary debug output\n');const head=commit('Unrelated diagnostic output'); + const identity={repositoryId:randomUUID(),taskId:randomUUID(),planId:randomUUID()}; + const plan: Plan={schema_version:1,revision:1,issue:3,summary:'Make retries predictable and document the behavior',questions:[],items:[ + {id:'P1',title:'Bound exponential retries',intent:'Cap the backoff at five seconds so callers have a predictable upper bound.',files:[{path:'retry.ts',kind:'edit',renamed_from:null,change:'Use capped exponential backoff.'}],acceptance:[{type:'check',text:'The delay grows exponentially and never exceeds five seconds.'}],depends_on:[]}, + {id:'P2',title:'Document retry behavior',intent:'Describe the retry strategy for maintainers.',files:[{path:'README.md',kind:'edit',renamed_from:null,change:'Explain the capped exponential delay.'}],acceptance:[{type:'check',text:'Documentation agrees with the implementation.'}],depends_on:['P1']}, + {id:'P3',title:'Confirm API compatibility',intent:'Confirm the function signature is unchanged.',files:[{path:'retry.ts',kind:'edit',renamed_from:null,change:'Keep the public function signature.'}],acceptance:[{type:'check',text:'No API change is needed.'}],depends_on:['P1']}, + ]}; + const probe=mkdtempSync(join(root,'identity-')); + let pathIdentity:ReviewConfig['pathIdentity']; + try { writeFileSync(join(probe,'case'),'');writeFileSync(join(probe,'café'),'');pathIdentity={caseSensitive:!existsSync(join(probe,'CASE')),unicodeNormalization:existsSync(join(probe,'cafe\u0301'))?'NFC':'none'}; } + finally {rmSync(probe,{recursive:true});} + const config:ReviewConfig={database:join(root,'review.sqlite'),repository,identity,pathIdentity,demo:true}; + const store=new Store(config.database); + try { store.createPlan(JSON.stringify(plan),'json',{identity,issue:3,baseEntries:['retry.ts','README.md','run.sh'].map(path=>({path,kind:'file' as const})),pathKey:p=>p,allowedCommands:[]},base,head); + store.recordHistory(identity,{revision:1,snapshotId:store.getSnapshot(identity).id},base,head,[{sha:first,owner:'P1',origin:'owned',sourceSha:null},{sha:second,owner:'P2',origin:'owned',sourceSha:null}]); + } finally {store.close();} + writeFileSync(configPath,JSON.stringify(config,null,2)+'\n',{mode:0o600});return config; +} diff --git a/scripts/git-environment.ts b/scripts/git-environment.ts new file mode 100644 index 0000000..6013e8b --- /dev/null +++ b/scripts/git-environment.ts @@ -0,0 +1,9 @@ +/** Git environment names are case-insensitive on Windows. */ +export function isolatedGitEnvironment(): NodeJS.ProcessEnv { + return { + ...Object.fromEntries(Object.entries(process.env).filter(([key]) => !/^GIT_/i.test(key))), + GIT_CONFIG_NOSYSTEM: '1', + GIT_CONFIG_GLOBAL: '/dev/null', + GIT_TERMINAL_PROMPT: '0', + }; +} diff --git a/scripts/plant.ts b/scripts/plant.ts new file mode 100644 index 0000000..85e2bc2 --- /dev/null +++ b/scripts/plant.ts @@ -0,0 +1,92 @@ +import { isolatedGitEnvironment } from './git-environment.ts'; +import { execFileSync } from 'node:child_process'; +import { existsSync, mkdirSync, writeFileSync, appendFileSync, lstatSync, readFileSync, realpathSync } from 'node:fs'; +import { resolve, join, dirname, basename, relative, isAbsolute } from 'node:path'; +import { randomInt, randomUUID } from 'node:crypto'; +import { pathToFileURL } from 'node:url'; +import { Store } from '../runner/store.ts'; +import type { ReviewConfig } from '../runner/review.ts'; +import { readHistory } from '../git/history.ts'; +import { isRepoPath, type BaseEntry } from '../core/plan.ts'; +export interface PlantInput { declaredText: string; undeclaredText: string; undeclaredPath: string } +/** Prepare a separate local experiment clone. Never rewrites/pushes the source repository. + * The experiment operator runs this before the reviewer sees the PR; sealed.json is unblinding data. + */ +export function plant(config: ReviewConfig, destination: string, input: PlantInput): string { + const root=resolve(destination); + if(existsSync(root))throw new Error('Experiment destination must not exist.'); + let parent=root; const missing:string[]=[]; + while(!lstatSync(parent,{throwIfNoEntry:false})) { missing.unshift(basename(parent)); parent=dirname(parent); } + const canonicalDestination=resolve(realpathSync(parent),...missing); + const sourceRelative=relative(realpathSync(config.repository),canonicalDestination); + if(sourceRelative===''||(!sourceRelative.startsWith('..'+(process.platform==='win32'?'\\':'/'))&&sourceRelative!=='..'&&!isAbsolute(sourceRelative))) throw new Error('Experiment destination must be outside the source repository.'); + if((!isRepoPath(input.undeclaredPath)||input.undeclaredPath.includes('/'))||!input.declaredText?.trim()||!input.undeclaredText?.trim()||input.declaredText.length>4000||input.undeclaredText.length>4000)throw new Error('Invalid plant input.'); + const pathKey=(path:string)=>{ + if(!config.pathIdentity.caseSensitive && /[^\x20-\x7e]/.test(path)) throw new Error('Non-ASCII case-insensitive paths require a filesystem-specific identity adapter.'); + const p=config.pathIdentity.unicodeNormalization==='NFC'?path.normalize('NFC'):path; + return config.pathIdentity.caseSensitive?p:p.toLowerCase(); + }; + pathKey(input.undeclaredPath); + const source=new Store(config.database); + let plan, snapshot, entries; + try{plan=source.getPlan(config.identity);snapshot=source.getSnapshot(config.identity);entries=source.getLedger(config.identity);}finally{source.close();} + if(plan.items.some(item=>item.files.some(file=>pathKey(file.path)===pathKey(input.undeclaredPath)||(file.renamed_from!==null&&pathKey(file.renamed_from)===pathKey(input.undeclaredPath)))))throw new Error('Undeclared plant must be outside every declared file.'); + for(const item of plan.items) for(const file of item.files) { pathKey(file.path); if(file.renamed_from) pathKey(file.renamed_from); } + const history=readHistory(config.repository,snapshot.base,snapshot.head); + const owners=new Map(entries.map(entry=>[entry.sha,entry.owner])); + const candidates=history.commits.flatMap((commit,index)=>{ + const item=plan.items.find(item=>item.id===owners.get(commit.sha)); + return item?.files.filter(file=>file.kind==='edit'&&!file.path.includes('/')).map(file=>({index,item:item.id,path:file.path}))??[]; + }); + if(!candidates.length)throw new Error('No owned commit with an editable top-level file is available for planting.'); + const declared=candidates[randomInt(candidates.length)]!; + const owned=history.commits.map((commit,index)=>({index,owner:owners.get(commit.sha)})).filter(entry=>entry.owner&&plan!.items.some(item=>item.id===entry.owner)); + const outside=owned[randomInt(owned.length)]!; + const env=isolatedGitEnvironment(); + const gitRaw=(cwd:string,...args:string[])=>execFileSync('git',['-c','core.hooksPath=/dev/null','-c','commit.gpgsign=false',...args],{cwd,env,encoding:'utf8',stdio:['ignore','pipe','pipe'],timeout:30000,maxBuffer:32*1024*1024}); + const git=(cwd:string,...args:string[])=>gitRaw(cwd,...args).trim(); + // Refuse existing paths, symlink targets, and unsupported declared-file transitions before creating output. + for(const sha of [snapshot.base,...history.commits.map(commit=>commit.sha)]){ + const paths=gitRaw(config.repository,'ls-tree','-rz','--name-only',sha).split('\0').filter(Boolean); + if(paths.some(path=>pathKey(path)===pathKey(input.undeclaredPath)))throw new Error('Undeclared plant path already exists in the source history.'); + } + for(const commit of history.commits.slice(declared.index)){ + if(!/^100(?:644|755) blob /.test(git(config.repository,'ls-tree',commit.sha,'--',declared.path)))throw new Error('Declared plant needs a regular file retained through the remaining history.'); + } + mkdirSync(root,{recursive:true});const repository=join(root,'repository'); + git(root,'clone','--no-local','--no-checkout',resolve(config.repository),repository); + git(repository,'config','user.name','Codeboost Experiment');git(repository,'config','user.email','experiment@example.invalid'); + git(repository,'checkout','-b','review-experiment',snapshot.base); + const baseEntries:BaseEntry[]=git(repository,'ls-tree','-rz',snapshot.base).split('\0').filter(Boolean).map(record=>{ + const split=record.indexOf('\t'),[mode,,oid]=record.slice(0,split).split(' '),path=record.slice(split+1); + if(mode==='160000')return {path,kind:'gitlink'}; + if(mode==='120000')return {path,kind:'symlink',target:gitRaw(repository,'cat-file','blob',oid!)}; + return {path,kind:'file'}; + }); + const mappings=[]; + try{ + for(const [index,commit] of history.commits.entries()){ + git(repository,'cherry-pick','--no-commit',commit.sha); + if(index===declared.index){const path=join(repository,declared.path);if(!lstatSync(path).isFile())throw new Error('Declared plant target is not regular.');appendFileSync(path,'\n'+input.declaredText+'\n');} + if(index===outside.index){const path=join(repository,input.undeclaredPath);mkdirSync(dirname(path),{recursive:true});writeFileSync(path,input.undeclaredText+'\n',{flag:'wx'});} + git(repository,'add','-A');git(repository,'commit','--allow-empty','-C',commit.sha); + mappings.push({oldSha:commit.sha,newSha:git(repository,'rev-parse','HEAD')}); + } + const identity={repositoryId:config.identity.repositoryId,taskId:randomUUID(),planId:randomUUID()}; + const output:ReviewConfig={...config,repository,database:join(root,'review.sqlite'),identity,demo:false}; + const store=new Store(output.database); + try{ + store.createPlan(JSON.stringify(plan),'json',{identity,issue:plan.issue,baseEntries,pathKey,allowedCommands:[]},snapshot.base,snapshot.head); + const validEntries=entries.filter(entry=>entry.owner===null||plan.items.some(item=>item.id===entry.owner)); + store.recordHistory(identity,{revision:1,snapshotId:store.getSnapshot(identity).id},snapshot.base,snapshot.head,validEntries); + store.recordRebase(identity,{revision:1,snapshotId:store.getSnapshot(identity).id},snapshot.base,mappings.at(-1)!.newSha,mappings); + }finally{store.close();} + writeFileSync(join(root,'sealed.json'),JSON.stringify({declared,outside,undeclaredPath:input.undeclaredPath,mappings},null,2),{mode:0o600}); + const configPath=join(root,'review.json');writeFileSync(configPath,JSON.stringify(output,null,2),{mode:0o600});return configPath; + }catch(error){throw new Error(`Planting stopped. Source is unchanged; inspect the disposable clone at ${repository}. ${error instanceof Error?error.message:String(error)}`);} +} +if(process.argv[1]&&import.meta.url===pathToFileURL(resolve(process.argv[1])).href){ + const [configFile,destination,inputFile]=process.argv.slice(2); + if(!configFile||!destination||!inputFile)throw new Error('Usage: node scripts/plant.ts review.json NEW_DIRECTORY plant-input.json'); + console.log(plant(JSON.parse(readFileSync(configFile,'utf8')),destination,JSON.parse(readFileSync(inputFile,'utf8')))); +} diff --git a/test/browser/review.spec.ts b/test/browser/review.spec.ts new file mode 100644 index 0000000..9118065 --- /dev/null +++ b/test/browser/review.spec.ts @@ -0,0 +1,79 @@ +import { test, expect } from '@playwright/test'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { createDemo } from '../../scripts/demo.ts'; +import { startServer } from '../../web/server.ts'; +let root: string, app: Awaited>; +test.beforeEach(async () => { root=mkdtempSync(join(tmpdir(),'codeboost-browser-'));app=await startServer(createDemo(join(root,'demo')),0); }); +test.afterEach(async () => { await app.close();rmSync(root,{recursive:true,force:true}); }); +test('reviews real changes, persists approval and conversation, and assigns foreign code',async({page})=>{ + const errors:string[]=[];page.on('pageerror',e=>errors.push(e.message)); + await page.goto(app.url);await expect(page.getByRole('heading',{name:'Bound exponential retries'})).toBeVisible(); + await expect(page.getByText('0 of 3 approved')).toBeVisible();await expect(page.getByText('– No tests defined',{exact:false}).first()).toBeVisible(); + await page.getByRole('button',{name:'Approve P1',exact:true}).click();await expect(page.getByText('1 of 3 approved')).toBeVisible(); + await page.getByRole('button',{name:'Request change',exact:true}).click();await page.getByLabel('Change to request').fill('Add a test for the upper bound.');await page.getByRole('button',{name:'Save change request'}).click();await expect(page.getByText('Add a test for the upper bound.',{exact:true})).toBeVisible(); + await page.reload();await expect(page.getByText('1 of 3 approved')).toBeVisible();await expect(page.getByText('Add a test for the upper bound.',{exact:true})).toBeVisible(); + await page.getByRole('button',{name:/Unplanned changes/}).click();await page.getByLabel('Assign change 1 to').selectOption('P1');await page.getByRole('button',{name:'Assign',exact:true}).first().click(); + await page.getByRole('button',{name:/P1 Bound exponential retries/}).click();await expect(page.getByText('! Stale:',{exact:false})).toBeVisible();await expect(page.getByRole('heading',{name:'At approval'})).toBeVisible(); + await page.screenshot({path:'test-results/review-desktop.png',fullPage:true});expect(errors).toEqual([]); +}); +test('shows file metadata and no-change confirmation, supports narrow desktop and keyboard',async({page})=>{ + await page.goto(app.url);await page.getByRole('button',{name:/P2 Document retry behavior/}).click();await expect(page.getByText('File mode changed',{exact:false})).toBeVisible();await expect(page.getByText('✕ Out of scope',{exact:true})).toBeVisible(); + await page.getByRole('button',{name:/P3 Confirm API compatibility/}).click();await page.getByRole('button',{name:'Confirm no change needed',exact:true}).click();await expect(page.getByText('1 of 3 approved')).toBeVisible(); + await page.setViewportSize({width:1280,height:900});await expect(page.getByRole('complementary',{name:'Conversation',exact:true})).not.toBeVisible();await page.getByRole('button',{name:'Conversation',exact:true}).click();await expect(page.getByRole('complementary',{name:'Conversation',exact:true})).toBeVisible(); + await page.getByLabel('Question about this item').fill('npa');await expect(page.getByRole('heading',{name:'Confirm API compatibility'})).toBeVisible(); + await page.keyboard.press('Escape');await page.keyboard.press('p');await expect(page.getByRole('heading',{name:'Document retry behavior'})).toBeVisible(); + await page.setViewportSize({width:1000,height:800});await expect(page.getByRole('heading',{name:'A little more room to review'})).toBeVisible(); +}); +test('refresh makes changed code stale and stale browser actions cannot approve it',async({page,context})=>{ + await page.goto(app.url);await page.getByRole('button',{name:'Approve P1',exact:true}).click();await expect(page.getByText('1 of 3 approved')).toBeVisible(); + const other=await context.newPage();await other.goto(app.url);await expect(other.getByText('1 of 3 approved')).toBeVisible(); + const repository=app.service.config.repository;writeFileSync(join(repository,'retry.ts'),'export function delay(attempt: number) {\n return 42;\n}\n');execFileSync('git',['-c','core.hooksPath=/dev/null','commit','-am','External change'],{cwd:repository,stdio:'pipe'}); + await page.getByRole('button',{name:'Refresh',exact:true}).click();await expect(page.getByText('! Stale:',{exact:false}).first()).toBeVisible(); + await other.getByRole('button',{name:/P3 Confirm API compatibility/}).click();await other.getByRole('button',{name:'Confirm no change needed',exact:true}).click();await expect(other.getByRole('status').filter({hasText:'Stale review state'})).toBeVisible(); +}); +test('requires private credentials and rejects foreign origins',async({request})=>{ + const base=app.url.split('#')[0]!; + expect((await request.get(base+'api/review')).status()).toBe(403); + expect((await request.get(base+'api/review',{headers:{'x-codeboost-token':app.token,origin:'https://example.invalid'}})).status()).toBe(403); + expect((await request.get(base+'api/review',{headers:{'x-codeboost-token':app.token}})).status()).toBe(200); +}); +test('shows an honest history error and keeps markup in notes as text',async({page})=>{ + await page.goto(app.url);await page.getByLabel('Question about this item').fill('');await page.getByRole('button',{name:'Save question'}).click();await expect(page.getByText('',{exact:true})).toBeVisible();await expect(page.locator('#notes img')).toHaveCount(0); + const repository=app.service.config.repository;execFileSync('git',['checkout','--orphan','unrelated'],{cwd:repository,stdio:'pipe'});execFileSync('git',['-c','core.hooksPath=/dev/null','commit','-m','Unrelated history'],{cwd:repository,stdio:'pipe'}); + await page.getByRole('button',{name:'Refresh',exact:true}).click();await expect(page.getByText('Could not read this branch’s history.',{exact:false})).toBeVisible();await expect(page.getByRole('button',{name:'Approve P1',exact:true})).not.toBeVisible(); +}); +test('can assign a large foreign change without sending its content back in the command',async({request})=>{ + const repository=app.service.config.repository;writeFileSync(join(repository,'debug.log'),'x'.repeat(20000)+'\n');execFileSync('git',['-c','core.hooksPath=/dev/null','commit','-am','Large foreign change'],{cwd:repository,stdio:'pipe'}); + const base=app.url.split('#')[0]!,headers={'x-codeboost-token':app.token};const view=await(await request.get(base+'api/review',{headers})).json();const segment=view.segments.find((s:{content:string;row:string})=>s.row==='Unplanned'&&s.content.length>19000); + const response=await request.post(base+'api/action',{headers:{...headers,'Content-Type':'application/json'},data:{action:'assign',item:'P1',key:segment.key,token:view.token}}); + expect(response.status()).toBe(200); +}); +test('shows bounded raster previews and byte sizes for file-change cards',async({page})=>{ + const repository=app.service.config.repository;writeFileSync(join(repository,'pixel.png'),Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aRHsAAAAASUVORK5CYII=','base64'));execFileSync('git',['add','pixel.png'],{cwd:repository});execFileSync('git',['-c','core.hooksPath=/dev/null','commit','-m','Add image'],{cwd:repository,stdio:'pipe'}); + await page.goto(app.url);await page.getByRole('button',{name:/Unplanned changes/}).click();await expect(page.getByRole('img',{name:'Current image in pixel.png'})).toBeVisible();await expect(page.getByRole('img',{name:'Current image in pixel.png'})).toHaveJSProperty('naturalWidth',1);await expect(page.getByText('Size: N/A → 68 bytes',{exact:true})).toBeVisible(); +}); +test('rejects malformed non-ASCII credentials consistently',async({request})=>{ + const response=await request.get(app.url.split('#')[0]+'api/review',{headers:{'x-codeboost-token':'é'.repeat(64)}});expect(response.status()).toBe(403); +}); +test('keeps stale item controls unavailable after a failed refresh',async({page})=>{ + await page.goto(app.url);const repository=app.service.config.repository;execFileSync('git',['checkout','--orphan','unrelated'],{cwd:repository,stdio:'pipe'});execFileSync('git',['-c','core.hooksPath=/dev/null','commit','-m','Other history'],{cwd:repository,stdio:'pipe'}); + await page.getByRole('button',{name:'Refresh',exact:true}).click();await expect(page.getByText('Could not read this branch’s history.',{exact:false})).toBeVisible();await expect(page.getByRole('button',{name:/P3 Confirm API compatibility/})).toHaveCount(0); +}); +test('accepts a foreign segment and keeps that choice across reload',async({page})=>{ + await page.goto(app.url);await page.getByRole('button',{name:/Unplanned changes/}).click();await page.getByRole('button',{name:'Accept as is',exact:true}).first().click();await page.getByRole('button',{name:/Accepted 1/}).click();await expect(page.getByRole('article').first()).toBeVisible();await expect(page.getByText('Accepted outside plan',{exact:true})).toBeVisible();await page.reload();await page.getByRole('button',{name:/Accepted 1/}).click();await expect(page.getByRole('article').first()).toBeVisible(); +}); +test('shows the whole-plan empty state without claiming checks passed',async({page})=>{ + const {repository,identity}=app.service.config;const base=app.service.store.getSnapshot(identity).base;execFileSync('git',['reset','--hard',base],{cwd:repository,stdio:'pipe'}); + await page.goto(app.url);await expect(page.getByRole('heading',{name:'No code changes yet'})).toBeVisible();await expect(page.getByRole('button',{name:'Confirm no change needed',exact:true})).toBeVisible();await expect(page.getByRole('button',{name:'AI review: – Not run',exact:true})).toBeVisible(); +}); +test('routes mixed owned and ambiguous items to attribution resolution',async({page})=>{ + const {repository,identity}=app.service.config;writeFileSync(join(repository,'retry.ts'),'export function delay(attempt: number) {\n return Math.min(10000, 200 * 2 ** attempt);\n}\n');execFileSync('git',['-c','core.hooksPath=/dev/null','commit','-am','P2 changes retry'],{cwd:repository,stdio:'pipe'}); + const head=execFileSync('git',['rev-parse','HEAD'],{cwd:repository,encoding:'utf8'}).trim(),snapshot=app.service.store.getSnapshot(identity);app.service.store.recordHistory(identity,{revision:1,snapshotId:snapshot.id},snapshot.base,head,[{sha:head,owner:'P2',origin:'owned',sourceSha:null}]); + await page.goto(app.url);await page.getByRole('button',{name:/P2 Document retry behavior/}).click();await page.getByRole('button',{name:'Resolve ambiguous changes',exact:true}).click();await expect(page.getByRole('heading',{name:'Ambiguous',exact:true})).toBeVisible(); +}); +test('keeps review shortcuts active while a toolbar button has focus',async({page})=>{ + await page.goto(app.url);await expect(page.getByRole('heading',{name:'Bound exponential retries'})).toBeVisible();await page.getByRole('button',{name:'Refresh',exact:true}).focus();await page.keyboard.press('n');await expect(page.getByRole('heading',{name:'Document retry behavior'})).toBeVisible(); +}); diff --git a/test/demo.test.ts b/test/demo.test.ts new file mode 100644 index 0000000..1f91797 --- /dev/null +++ b/test/demo.test.ts @@ -0,0 +1,57 @@ +import { afterEach, expect, it, vi } from 'vitest'; +import { mkdtempSync, renameSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { isolatedGitEnvironment } from '../scripts/git-environment.ts'; +import { createDemo } from '../scripts/demo.ts'; +const roots: string[] = []; +afterEach(() => { vi.unstubAllEnvs(); roots.splice(0).forEach(root => rmSync(root, { recursive: true, force: true })); }); +function root() { const path = mkdtempSync(join(tmpdir(), 'codeboost-demo-')); roots.push(path); return path; } +it('rejects existing demo configs redirected outside their fixture', () => { + const base = root(), directory = join(base, 'demo'), config = createDemo(directory); + expect(createDemo(directory)).toEqual(config); + for (const key of ['repository', 'database'] as const) { + writeFileSync(join(directory, 'review.json'), JSON.stringify({ ...config, [key]: join(base, 'outside') })); + expect(() => createDemo(directory)).toThrow(/fixture/i); + } +}); +it('rejects symlinked demo roots, configs, repositories and databases', () => { + const base = root(), directory = join(base, 'demo'); createDemo(directory); + symlinkSync(directory, join(base, 'alias')); + expect(() => createDemo(join(base, 'alias'))).toThrow(/fixture/i); + for (const name of ['review.json', 'retry-service', 'review.sqlite']) { + const path = join(directory, name), backup = join(base, name); + // Move each object out of the fixture and replace it with a symlink. + renameSync(path, backup); symlinkSync(backup, path); + expect(() => createDemo(directory)).toThrow(/fixture/i); + rmSync(path); renameSync(backup, path); + } +}); +it('scrubs inherited Git repository and config environment in demo commands', () => { + const base = root(); + vi.stubEnv('GIT_DIR', join(base, 'outside.git')); + vi.stubEnv('GIT_WORK_TREE', join(base, 'outside')); + vi.stubEnv('GIT_CONFIG_COUNT', '1'); + vi.stubEnv('GIT_CONFIG_KEY_0', 'init.defaultBranch'); + vi.stubEnv('GIT_CONFIG_VALUE_0', 'inherited'); + const config = createDemo(join(base, 'demo')); + expect(readFileSync(join(config.repository, 'retry.ts'), 'utf8')).toContain('Math.min'); +}); + +it('scrubs Git environment names case-insensitively for Windows', () => { + vi.stubEnv('git_dir', '/outside'); vi.stubEnv('Git_Work_Tree', '/outside'); + const env = isolatedGitEnvironment(); + expect(env).not.toHaveProperty('git_dir'); expect(env).not.toHaveProperty('Git_Work_Tree'); + expect(env.GIT_CONFIG_NOSYSTEM).toBe('1'); +}); +it('rejects symlinked ancestors before creating a demo', () => { + const base = root(); symlinkSync(base, join(base, 'alias')); + expect(() => createDemo(join(base, 'alias', 'nested', 'demo'))).toThrow(/fixture/i); +}); +it('rejects dangling SQLite sidecar symlinks', () => { + const base=root(),directory=join(base,'demo');const config=createDemo(directory); + for(const suffix of ['-wal','-shm']) { + const path=config.database+suffix;rmSync(path,{force:true});symlinkSync(join(base,'missing'+suffix),path); + expect(()=>createDemo(directory)).toThrow(/fixture/i);rmSync(path); + } +}); diff --git a/test/history.test.ts b/test/history.test.ts index cc7f3e8..205a93f 100644 --- a/test/history.test.ts +++ b/test/history.test.ts @@ -328,3 +328,10 @@ it('rejects shallow parent rewriting before loading history', () => { f.write('.git/shallow', `${head}\n`); expect(() => readHistory(f.dir, f.base, head)).toThrow(/shallow/i); }); +it('omits raster previews with oversized declared dimensions', () => { + const f=fixture(); const base=f.git('rev-parse','HEAD'); + const image=Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/lXsAAAAASUVORK5CYII=','base64'); + image.writeUInt32BE(100000,16);image.writeUInt32BE(100000,20); + f.write('oversized.png',image);const head=f.commit(); + expect(readHistory(f.dir,base,head).final.find(file=>file.newPath==='oversized.png')?.after?.preview).toBeUndefined(); +}); diff --git a/test/plant.test.ts b/test/plant.test.ts new file mode 100644 index 0000000..a24985d --- /dev/null +++ b/test/plant.test.ts @@ -0,0 +1,42 @@ +import { it,expect,afterEach,vi } from 'vitest'; +import { mkdtempSync,readFileSync,rmSync,symlinkSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { createDemo } from '../scripts/demo.ts'; +import { plant } from '../scripts/plant.ts'; +import { ReviewService } from '../runner/review.ts'; +const roots:string[]=[];afterEach(()=>roots.splice(0).forEach(root=>rmSync(root,{recursive:true,force:true}))); +it('plants in an isolated clone, retains ledger attribution, and leaves source history unchanged',()=>{ + const root=mkdtempSync(join(tmpdir(),'codeboost-plant-'));roots.push(root);const config=createDemo(join(root,'source')); + const head=()=>execFileSync('git',['rev-parse','HEAD'],{cwd:config.repository,encoding:'utf8'}).trim();const before=head(); + const path=plant(config,join(root,'experiment'),{declaredText:'// planted extra behavior',undeclaredText:'unrelated diagnostic',undeclaredPath:'extra.txt'}); + expect(head()).toBe(before);const output=JSON.parse(readFileSync(path,'utf8'));const service=new ReviewService(output); + try {const view=service.load();expect(view.segments.some(s=>s.path==='extra.txt'&&s.scope==='out-of-scope')).toBe(true);expect(view.segments.some(s=>s.content.includes('// planted extra behavior')&&s.row.startsWith('P'))).toBe(true);}finally{service.close();} + expect(JSON.parse(readFileSync(join(root,'experiment','sealed.json'),'utf8')).mappings).toHaveLength(3); +},15000); +it('refuses non-ASCII plants on case-insensitive filesystems pending an identity adapter', () => { + const root=mkdtempSync(join(tmpdir(),'codeboost-plant-path-'));roots.push(root); + const config=createDemo(join(root,'source'));config.pathIdentity.caseSensitive=false; + expect(()=>plant(config,join(root,'experiment'),{declaredText:'// extra',undeclaredText:'diagnostic',undeclaredPath:'café.txt'})).toThrow(/filesystem-specific identity adapter/); +},15000); +it('rejects canonical declared and existing path collisions before creating a clone', () => { + const root=mkdtempSync(join(tmpdir(),'codeboost-plant-collision-'));roots.push(root); + const config=createDemo(join(root,'source'));config.pathIdentity.caseSensitive=false; + for (const path of ['RETRY.TS','RUN.SH']) expect(()=>plant(config,join(root,'experiment'),{declaredText:'// extra',undeclaredText:'diagnostic',undeclaredPath:path})).toThrow(/outside every declared file|already exists/); +},15000); + +it('rejects experiment destinations inside the source, including symlink aliases', () => { + const root=mkdtempSync(join(tmpdir(),'codeboost-plant-destination-'));roots.push(root); + const config=createDemo(join(root,'source'));symlinkSync(config.repository,join(root,'alias')); + for(const destination of [join(config.repository,'experiment'),join(config.repository,'.git','experiment'),join(root,'alias','nested','experiment')]) { + expect(()=>plant(config,destination,{declaredText:'// extra',undeclaredText:'diagnostic',undeclaredPath:'extra.txt'})).toThrow(/outside the source repository/); + } +},15000); +it('passes its isolated environment to every planting Git process', () => { + const root=mkdtempSync(join(tmpdir(),'codeboost-plant-env-'));roots.push(root); + const config=createDemo(join(root,'source')); + vi.stubEnv('GIT_DIR',join(root,'outside.git'));vi.stubEnv('GIT_WORK_TREE',join(root,'outside')); + try {expect(plant(config,join(root,'experiment'),{declaredText:'// extra',undeclaredText:'diagnostic',undeclaredPath:'extra.txt'})).toBe(join(root,'experiment','review.json'));} + finally {vi.unstubAllEnvs();} +},15000); diff --git a/test/review.test.ts b/test/review.test.ts new file mode 100644 index 0000000..d882be9 --- /dev/null +++ b/test/review.test.ts @@ -0,0 +1,44 @@ +import { afterEach, it, expect, vi } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; +import { createDemo } from '../scripts/demo.ts'; +import { ReviewService } from '../runner/review.ts'; +// Each integration case performs several bounded real-Git reads. +vi.setConfig({ testTimeout: 15000 }); +const roots:string[]=[];const services:ReviewService[]=[]; +afterEach(()=>{services.splice(0).forEach(service=>service.close());roots.splice(0).forEach(root=>rmSync(root,{recursive:true,force:true}));}); +function fixture(){const root=mkdtempSync(join(tmpdir(),'codeboost-review-'));roots.push(root);const config=createDemo(join(root,'demo'));const service=new ReviewService(config);services.push(service);return {service,config};} +it('expires a browser token after another view assigns a segment and recomputes scope honestly',()=>{ + const {service}=fixture();const view=service.load();const foreign=view.segments.find(s=>s.row==='Unplanned')!; + const next=service.act({action:'assign',key:foreign.key,item:'P1',token:view.token}); + expect(next.items[0]!.checks.scope).toContain('out of scope'); + expect(()=>service.act({action:'approve',item:'P1',token:view.token})).toThrow(/Stale/); +}); +it('persists bounded per-item notes without creating a plan revision',()=>{ + const {service,config}=fixture();const view=service.load();service.act({action:'note',item:'P1',kind:'question',text:'Why this limit?',token:view.token}); + const reopened=new ReviewService(config);services.push(reopened);expect(reopened.load().notes[0]!.text).toBe('Why this limit?');expect(reopened.load().plan.revision).toBe(1); + expect(()=>service.act({action:'note',item:'P2',kind:'change',text:'x'.repeat(4001),token:service.load().token})).toThrow(/Invalid/); +}); +it('migrates a v1 database without losing its plans or ledger',()=>{ + const {service,config}=fixture();service.close();services.splice(services.indexOf(service),1); + const db=new DatabaseSync(config.database);db.exec('ALTER TABLE plans DROP COLUMN review_version; DROP TABLE review_notes; PRAGMA user_version=1;');db.close(); + const migrated=new ReviewService(config);services.push(migrated);expect(migrated.load().plan.revision).toBe(1);expect(migrated.store.getLedger(config.identity)).toHaveLength(2);expect(migrated.load().notes).toEqual([]); +}); +it('rejects a concurrent store review edit through the atomic review counter',()=>{ + const {service,config}=fixture();const other=new ReviewService(config);services.push(other);const view=service.load(); + other.store.addReviewNote(config.identity,view.expected,'P1','change','Please explain'); + expect(()=>service.store.saveReview(config.identity,view.expected,[],[])).toThrow(/Stale/); +}); +it('refuses no-change confirmation while the item still owns ambiguous changes',async()=>{ + const {service,config}=fixture();const {writeFileSync}=await import('node:fs');const {execFileSync}=await import('node:child_process'); + writeFileSync(join(config.repository,'retry.ts'),'export function delay(attempt: number) {\n return Math.min(10000, 200 * 2 ** attempt);\n}\n'); + execFileSync('git',['-c','core.hooksPath=/dev/null','commit','-am','P2 changes retry'],{cwd:config.repository,stdio:'pipe'}); + const head=execFileSync('git',['rev-parse','HEAD'],{cwd:config.repository,encoding:'utf8'}).trim();const snapshot=service.store.getSnapshot(config.identity); + service.store.recordHistory(config.identity,{revision:1,snapshotId:snapshot.id},snapshot.base,head,[{sha:head,owner:'P2',origin:'owned',sourceSha:null}]); + const view=service.load();expect(view.items[0]!.count).toBe(0);expect(view.segments.some(s=>s.row==='Ambiguous'&&s.owners.includes('P1'))).toBe(true); + expect(()=>service.act({action:'approve',item:'P1',confirmNoChange:true,token:view.token})).toThrow(/ambiguous/i); + expect(view.items[1]!.count).toBeGreaterThan(0); + expect(()=>service.act({action:'approve',item:'P2',token:view.token})).toThrow(/ambiguous/i); +}); diff --git a/tsconfig.json b/tsconfig.json index 0f69925..420c965 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,9 +1,26 @@ { "compilerOptions": { - "target": "ES2024", "module": "NodeNext", "moduleResolution": "NodeNext", - "strict": true, "noUncheckedIndexedAccess": true, "resolveJsonModule": true, - "esModuleInterop": true, "skipLibCheck": true, "noEmit": true, - "allowImportingTsExtensions": true, "types": ["node"] + "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", "runner/**/*.ts", "test/**/*.ts"] + "include": [ + "core/**/*.ts", + "git/**/*.ts", + "runner/**/*.ts", + "test/**/*.ts", + "web/**/*.ts", + "scripts/**/*.ts", + "playwright.config.ts" + ] } diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..c7de951 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,2 @@ +import { defineConfig } from 'vitest/config'; +export default defineConfig({ test: { include: ['test/*.test.ts'] } }); diff --git a/web/cli.ts b/web/cli.ts new file mode 100644 index 0000000..50b39d3 --- /dev/null +++ b/web/cli.ts @@ -0,0 +1,19 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { parseArgs } from 'node:util'; +import { startServer } from './server.ts'; +import { createDemo } from '../scripts/demo.ts'; +import { requireSupportedNode } from '../runner/store.ts'; +requireSupportedNode(); +const { values } = parseArgs({ options: { demo: { type:'boolean' }, directory:{type:'string'}, config:{type:'string'}, port:{type:'string'}, help:{type:'boolean'} } }); +if (values.help || (!values.demo && !values.config)) { + console.log('codeboost local review\n\nDemo: npm run demo\nExisting store: npm start -- --config /absolute/path/review.json\nOptions: --port 4318 --directory /path/to/demo\n\nThe configuration binds a trusted repository, database, plan identity, and known path identity. No agent, tests, or merge commands run.'); +} else { + const port = Number(values.port ?? '4318'); + if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error('Invalid port.'); + const config = values.demo ? createDemo(values.directory ?? '.codeboost-local/demo') : JSON.parse(readFileSync(resolve(values.config!), 'utf8')); + const app = await startServer(config, port); + console.log(`Review ready: ${app.url}\nRepository: ${config.repository}\nDatabase: ${config.database}\nSource files are read-only. Press Ctrl+C to stop.`); + let stopping=false; + for(const signal of ['SIGINT','SIGTERM'] as const) process.on(signal,()=>{if(!stopping){stopping=true;void app.close().then(()=>process.exit(0));}}); +} diff --git a/web/public/app.js b/web/public/app.js new file mode 100644 index 0000000..92dfbf4 --- /dev/null +++ b/web/public/app.js @@ -0,0 +1,433 @@ +const $ = (id) => document.getElementById(id); +const esc = (value) => + String(value ?? "").replace( + /[&<>"']/g, + (c) => + ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[ + c + ], + ); +const credential = + location.hash.slice(1) || sessionStorage.getItem("codeboost-token") || ""; +if (location.hash) { + sessionStorage.setItem("codeboost-token", credential); + history.replaceState(null, "", location.pathname); +} +let data, + selected, + change = 0, + mode = "question", + since = false, + busy = false; +const drafts = new Map(); +const statusClass = (text) => + text.startsWith("✓") + ? "good" + : text.startsWith("✕") + ? "bad" + : text.startsWith("!") + ? "warn" + : "neutral"; +async function api(path, body) { + const response = await fetch(path, { + method: body ? "POST" : "GET", + headers: { + "x-codeboost-token": credential, + ...(body ? { "Content-Type": "application/json" } : {}), + }, + body: body ? JSON.stringify(body) : undefined, + }); + const value = await response.json(); + if (!response.ok) throw new Error(value.error || "Request failed."); + return value; +} +function rememberDraft() { + if (selected) drafts.set(`${selected}:${mode}`, $("message").value); +} +function showFailure(message) { + data = null; + $("banner").textContent = message; + $("progress").textContent = "Review unavailable"; + $("item-title").textContent = "Review unavailable"; + $("change-count").textContent = ""; + for (const id of [ + "items", + "attention", + "notes", + "checks", + "view-toggle", + "item-details", + ]) + $(id).innerHTML = ""; + $("approve").hidden = true; + $("message").disabled = true; + $("save-note").disabled = true; + $("previous").disabled = true; + $("next").disabled = true; + $("code").innerHTML = + '

Review could not load

Refresh after resolving the error above.

'; +} +async function refresh() { + if (busy) return; + busy = true; + $("banner").textContent = "Linking changes to plan items…"; + try { + rememberDraft(); + data = await api("/api/review"); + selected ??= data.items[0]?.id || "Unplanned"; + since = data.items.find((item) => item.id === selected)?.state === "stale"; + render(); + } catch (error) { + showFailure( + `Could not read this branch’s history. ${error.message} Use Refresh to retry.`, + ); + } finally { + busy = false; + } +} +async function act(command) { + if (busy || !data) return false; + busy = true; + try { + rememberDraft(); + data = await api("/api/action", { ...command, token: data.token }); + render(); + return true; + } catch (error) { + showFailure(`${error.message} Refresh to review the latest state.`); + return false; + } finally { + busy = false; + } +} +function select(id) { + rememberDraft(); + selected = id; + change = 0; + since = data.items.find((item) => item.id === id)?.state === "stale"; + render(); +} +function render() { + const item = data.items.find((item) => item.id === selected); + if (!item && !["Unplanned", "Ambiguous", "Accepted"].includes(selected)) { + selected = data.items[0]?.id || "Unplanned"; + return render(); + } + $("repository").textContent = data.repository; + $("issue").textContent = + `#${data.plan.issue} ${data.plan.summary} · r${data.plan.revision}`; + $("progress").textContent = + `${data.approved} of ${data.items.length} approved`; + $("banner").textContent = data.demo + ? "Demo repository · real Git changes and local SQLite storage. No tests or AI review have been run for this demo." + : ""; + const unplanned = data.segments.filter((s) => s.row === "Unplanned").length, + ambiguous = data.segments.filter((s) => s.row === "Ambiguous").length; + $("attention").innerHTML = + ` · `; + const symbols = { approved: "✓", stale: "!", unreviewed: "○" }; + $("items").innerHTML = + data.items + .map( + (entry) => + `${entry.id === selected ? `
Declared files${entry.files.map((file) => `${esc(file.path)}`).join("")}
` : ""}`, + ) + .join("") + + ["Ambiguous", "Unplanned", "Accepted"] + .map( + (row) => + ``, + ) + .join(""); + document + .querySelectorAll("[data-select]") + .forEach((button) => + button.addEventListener("click", () => select(button.dataset.select)), + ); + $("item-id").textContent = item?.id || "REVIEW EXCEPTIONS"; + $("item-title").textContent = item?.title || selected; + $("item-details").innerHTML = item + ? `

${esc(item.intent)}

${item.reasons.map((reason) => `

! Stale: ${esc(reason)}

`).join("")}` + : ""; + $("approve").hidden = !item; + $("approve").textContent = item?.ambiguousCount + ? "Resolve ambiguous changes" + : item?.count + ? `Approve ${item.id}` + : "Confirm no change needed"; + $("approve").disabled = item?.state === "approved"; + $("view-toggle").innerHTML = + item?.state === "stale" + ? `` + : ""; + document.querySelectorAll("[data-since]").forEach((button) => + button.addEventListener("click", () => { + since = button.dataset.since === "true"; + renderCode(); + }), + ); + $("checks").innerHTML = item + ? Object.entries(item.checks) + .map( + ([name, text]) => + ``, + ) + .join("") + : ""; + document.querySelectorAll("[data-check]").forEach((button) => + button.addEventListener("click", () => { + if ( + button.dataset.check === "attributed" && + item.checks.attributed.startsWith("!") + ) + select("Ambiguous"); + else + showDialog( + `

${esc(button.textContent)}

${esc(button.dataset.check === "scope" ? item.outside.join("\n") || "Every attributed change is inside this item’s declared files." : button.dataset.check === "tests" ? item.acceptance.map((check) => `${check.type}: ${check.text}`).join("\n") + "\n\nThis screen does not run commands." : button.dataset.check === "ai" ? "No AI review has run. This does not mean the change has no problems." : "Attribution comes from the commit ledger, not commit messages.")}
`, + ); + }), + ); + $("notes").innerHTML = item + ? data.notes + .filter((note) => note.item === selected) + .map( + (note) => + `
You · ${note.kind === "change" ? "Change requested" : "Question"}

${esc(note.text)}

r${note.revision} · ${esc(new Date(note.createdAt).toLocaleString())}${note.kind === "change" ? " · Pending" : " · Awaiting discussion"}
`, + ) + .join("") || + '

No conversation yet. Keep questions and requested changes beside the evidence.

' + : '

Select a plan item to add a question or request a change.

'; + $("message").disabled = !item; + $("save-note").disabled = !item; + $("message").value = drafts.get(`${selected}:${mode}`) || ""; + renderCode(); +} +function renderCode() { + const item = data.items.find((item) => item.id === selected), + segments = data.segments.filter((s) => s.row === selected); + change = Math.max(0, Math.min(change, segments.length - 1)); + $("change-count").textContent = segments.length + ? `Change ${change + 1} of ${segments.length}` + : "No changes"; + $("previous").disabled = !segments.length || change === 0; + $("next").disabled = !segments.length || change === segments.length - 1; + let comparison = ""; + if (since && item?.state === "stale" && item.before) { + const prior = item.before.segments + .map((s) => `${s.path} ${s.operation || ""}\n${s.content}`) + .join("\n"); + const now = segments + .map((s) => `${s.path} ${s.operation || ""}\n${s.content}`) + .join("\n"); + comparison = `

At approval

${esc(prior || "No changes")}
Approved plan item
${esc(JSON.stringify(item.before.item, null, 2))}

Now

${esc(now || "No changes")}
Current plan item
${esc(
+      JSON.stringify(
+        data.plan.items.find((p) => p.id === item.id),
+        null,
+        2,
+      ),
+    )}
`; + } + $("code").innerHTML = + comparison + + (segments + .map((segment, index) => { + const provenance = + segment.row === "Accepted" ? "Accepted" : segment.row; + let content; + if (segment.kind === "file") { + const meta = JSON.parse(segment.content); + const label = + meta.oldPath !== meta.newPath && meta.oldPath && meta.newPath + ? "Renamed file" + : meta.oldMode === "160000" || meta.newMode === "160000" + ? "Submodule pointer" + : meta.oldMode === "120000" || meta.newMode === "120000" + ? "Symbolic link" + : meta.oldMode !== meta.newMode && + meta.oldMode && + meta.newMode + ? "File mode changed" + : "File change"; + content = `
${esc(provenance)}
▧ ${label}
Path
${esc(meta.oldPath || "Absent")} → ${esc(meta.newPath || "Absent")}
Mode
${esc(meta.oldMode || "Absent")} → ${esc(meta.newMode || "Absent")}

Size: ${segment.file?.oldSize ?? "N/A"} → ${segment.file?.newSize ?? "N/A"} bytes

${segment.file?.beforePreview || segment.file?.afterPreview ? `
${segment.file.beforePreview ? `
Before
Previous image in ${esc(segment.path)}
` : ""}${segment.file.afterPreview ? `
After
Current image in ${esc(segment.path)}
` : ""}
` : '

No preview available

'}
Details · content IDs
${esc(JSON.stringify(meta, null, 2))}
`; + } else { + const lines = segment.content.split("\n"); + if (lines.at(-1) === "") lines.pop(); + const start = + segment.operation === "+" ? segment.newLine : segment.oldLine; + content = `
${esc(provenance)}
${lines.map((_, i) => (start === null ? "" : start + i)).join("\n")}
${esc(segment.operation)}
${esc(segment.content)}
`; + } + const choices = ["Unplanned", "Ambiguous"].includes(segment.row) + ? `

Assigning this change makes the selected item’s approval stale.

` + : ""; + return `
${esc(segment.path)}${segment.row === "Accepted" ? "Accepted outside plan" : segment.scope === "out-of-scope" ? "✕ Out of scope" : esc(segment.scope)}
${content}
${esc(segment.context || "File-level change")}${segment.sharesHunkWith.length ? ` · Shares a hunk with ${esc(segment.sharesHunkWith.join(", "))}` : ""}
${choices}
`; + }) + .join("") || + (data.segments.length + ? '

No changes in this row

There are no current segments here. An item with no changes requires explicit confirmation before approval.

' + : '

No code changes yet

This branch has no changes against the selected base. Open task shows the compared commits.

')); + document.querySelectorAll("[data-assign]").forEach((button) => + button.addEventListener("click", () => { + const index = Number(button.dataset.assign); + const item = document.querySelector(`[data-target="${index}"]`).value; + if (item) act({ action: "assign", key: segments[index].key, item }); + }), + ); + document + .querySelectorAll("[data-accept]") + .forEach((button) => + button.addEventListener("click", () => + act({ + action: "accept", + key: segments[Number(button.dataset.accept)].key, + }), + ), + ); + document + .querySelectorAll("[data-since]") + .forEach((button) => + button.setAttribute( + "aria-pressed", + String((button.dataset.since === "true") === since), + ), + ); +} +function moveChange(delta) { + change += delta; + renderCode(); + document + .querySelector(`[data-change="${change}"]`) + ?.scrollIntoView({ block: "nearest" }); +} +function setMode(value) { + rememberDraft(); + mode = value; + $("ask").setAttribute("aria-pressed", String(mode === "question")); + $("request").setAttribute("aria-pressed", String(mode === "change")); + $("composer-label").textContent = + mode === "change" ? "Change to request" : "Question about this item"; + $("save-note").textContent = + mode === "change" ? "Save change request" : "Save question"; + $("message").value = drafts.get(`${selected}:${mode}`) || ""; +} +function showDialog(html) { + $("dialog-body").innerHTML = html; + $("dialog").showModal(); +} +$("approve").onclick = () => { + const item = data?.items.find((item) => item.id === selected); + if (item?.ambiguousCount > 0) { + select("Ambiguous"); + return; + } + if (item) + act({ + action: "approve", + item: selected, + confirmNoChange: item.count === 0, + }); +}; +$("reload").onclick = refresh; +$("review-link").onclick = (event) => { + event.preventDefault(); + refresh(); +}; +$("next").onclick = () => moveChange(1); +$("previous").onclick = () => moveChange(-1); +$("ask").onclick = () => setMode("question"); +$("request").onclick = () => setMode("change"); +$("composer").onsubmit = async (event) => { + event.preventDefault(); + const item = selected, + kind = mode; + if (await act({ action: "note", item, kind, text: $("message").value })) { + drafts.delete(`${item}:${kind}`); + $("message").value = ""; + $("saved").textContent = + kind === "change" + ? "Saved for the next revision." + : "Question saved. No agent has been invoked."; + } +}; +$("conversation-toggle").onclick = () => { + document.body.classList.toggle("conversation-open"); + document.body.classList.remove("conversation-closed"); +}; +$("collapse-conversation").onclick = () => { + document.body.classList.remove("conversation-open"); + document.body.classList.add("conversation-closed"); +}; +$("collapse-plan").onclick = () => { + document.querySelector(".plan-pane").hidden = true; + $("show-plan").hidden = false; +}; +$("show-plan").onclick = () => { + document.querySelector(".plan-pane").hidden = false; + $("show-plan").hidden = true; +}; +$("help").onclick = () => + showDialog( + "

Keyboard shortcuts

j / k — next / previous change

n / p — next / previous item

a — approve item

r — request change

? — shortcuts

Esc — leave a text field or close this dialog

", + ); +$("close-dialog").onclick = () => $("dialog").close(); +$("task").onclick = () => + showDialog( + data + ? `

Task #${data.plan.issue}

${esc(data.plan.summary)}

Revision ${data.plan.revision} · ${data.demo ? "Demo repository" : "Local repository"}

Base: ${esc(data.snapshot.base)}\nHead: ${esc(data.snapshot.head)}

Source files are read-only in this review app. Approvals and discussion are saved locally.

` + : "

No task loaded

Check the CLI configuration and retry.

", + ); +document.addEventListener("keydown", (event) => { + if ( + event.key === "Escape" && + ["TEXTAREA", "INPUT", "SELECT"].includes(document.activeElement?.tagName) + ) { + document.activeElement.blur(); + return; + } + if ( + event.ctrlKey || + event.metaKey || + event.altKey || + $("dialog").open || + ["TEXTAREA", "INPUT", "SELECT"].includes( + document.activeElement?.tagName, + ) || + !data + ) + return; + if (!["j", "k", "n", "p", "a", "r", "?"].includes(event.key)) return; + event.preventDefault(); + if (event.key === "j") moveChange(1); + if (event.key === "k") moveChange(-1); + if (["n", "p"].includes(event.key)) { + const ids = data.items.map((p) => p.id); + select( + ids[ + Math.max( + 0, + Math.min( + ids.length - 1, + ids.indexOf(selected) + (event.key === "n" ? 1 : -1), + ), + ) + ], + ); + } + if (event.key === "a") $("approve").click(); + if (event.key === "r") { + document.body.classList.add("conversation-open"); + document.body.classList.remove("conversation-closed"); + setMode("change"); + $("message").focus(); + } + if (event.key === "?") $("help").click(); +}); +await refresh(); diff --git a/web/public/index.html b/web/public/index.html new file mode 100644 index 0000000..b530d7e --- /dev/null +++ b/web/public/index.html @@ -0,0 +1,135 @@ + + + + + + Review · codeboost + + + + +
+

A little more room to review

+

+ Widen this window to at least 1280px to see the code and its plan + together. +

+
+
+
+ codeboost + + Local review +
+
+
Review Loading…
+
+ +
+ +
+ +
+
+
+ REVIEW +

Linking changes to plan items…

+
+ +
+
+
+
+ +
+
+
+
+ +
+
+ j / k changen / p itema approver request changeReview only · source files stay unchanged +
+
+ +
+ +
+ + diff --git a/web/public/style.css b/web/public/style.css new file mode 100644 index 0000000..ce0a700 --- /dev/null +++ b/web/public/style.css @@ -0,0 +1,668 @@ +@font-face { + font-family: "IBM Plex Sans"; + src: url("/fonts/ibm-plex-sans-latin-400-normal.woff2") format("woff2"); + font-weight: 400; + font-display: swap; +} +@font-face { + font-family: "IBM Plex Sans"; + src: url("/fonts/ibm-plex-sans-latin-500-normal.woff2") format("woff2"); + font-weight: 500; + font-display: swap; +} +@font-face { + font-family: "IBM Plex Sans"; + src: url("/fonts/ibm-plex-sans-latin-600-normal.woff2") format("woff2"); + font-weight: 600; + font-display: swap; +} +@font-face { + font-family: "IBM Plex Mono"; + src: url("/fonts/ibm-plex-mono-latin-400-normal.woff2") format("woff2"); + font-weight: 400; + font-display: swap; +} +:root { + --canvas: #101216; + --surface: #171b21; + --raised: #20262e; + --selected: #213448; + --line: #343d49; + --outline: #758295; + --text: #e8ecf1; + --muted: #a5afbd; + --primary: #8abfff; + --on-primary: #101216; + --hover: #a6ceff; + --success: #8fdda8; + --warning: #e9be6e; + --error: #f2847e; + --neutral: #9aa3b0; + --added: #1b2c24; + --removed: #33201f; + color-scheme: dark; +} +* { + box-sizing: border-box; +} +body { + margin: 0; + background: var(--canvas); + color: var(--text); + font: + 13px/1.385 "IBM Plex Sans", + "Segoe UI", + sans-serif; +} +button, +input, +textarea, +select { + font: inherit; +} +button, +select { + min-height: 28px; + border: 1px solid var(--line); + border-radius: 4px; + background: var(--raised); + color: var(--text); + padding: 4px 10px; + cursor: pointer; +} +button:hover, +select:hover { + background: var(--selected); +} +button:disabled { + color: var(--muted); + cursor: default; + background: var(--surface); +} +:focus-visible { + outline: 2px solid var(--primary); + outline-offset: 2px; +} +button.primary, +#approve { + background: var(--primary); + color: var(--on-primary); + border-color: var(--primary); +} +#approve:hover { + background: var(--hover); +} +[hidden] { + display: none !important; +} +a { + color: var(--primary); +} +h1 { + font-size: 20px; + line-height: 1.4; + margin: 4px 0 0; + font-weight: 600; +} +h2 { + font-size: 16px; +} +p { + margin: 8px 0; +} +pre, +code, +.mono, +.eyebrow { + font-family: "IBM Plex Mono", Menlo, Consolas, monospace; + font-size: 12px; + font-variant-numeric: tabular-nums; +} +pre { + white-space: pre-wrap; + overflow-wrap: anywhere; +} +.muted, +.eyebrow { + color: var(--muted); +} +.good { + color: var(--success); +} +.warn { + color: var(--warning); +} +.bad { + color: var(--error); +} +.neutral { + color: var(--neutral); +} +#app { + height: 100vh; + display: flex; + flex-direction: column; +} +.app-bar { + height: 44px; + flex-shrink: 0; + border-bottom: 1px solid var(--line); + display: flex; + align-items: center; + padding: 0 16px; + gap: 24px; +} +.brand { + display: flex; + align-items: center; + gap: 8px; + font-weight: 600; +} +.app-bar nav { + display: flex; + gap: 20px; + align-items: center; + flex: 1; +} +.app-bar nav span { + color: var(--muted); +} +.app-bar nav a { + height: 44px; + display: flex; + align-items: center; + text-decoration: none; + border-bottom: 2px solid var(--primary); +} +#repository { + font-size: 12px; + color: var(--muted); +} +.review-strip { + height: 48px; + flex-shrink: 0; + display: flex; + align-items: center; + gap: 16px; + padding: 0 16px; + border-bottom: 1px solid var(--line); +} +#issue { + margin-left: 16px; +} +#progress { + margin-left: auto; +} +.workspace { + display: flex; + min-height: 0; + flex: 1; + overflow: hidden; +} +.plan-pane { + width: 232px; + min-width: 200px; + max-width: 420px; + resize: horizontal; + overflow: auto; + border-right: 1px solid var(--line); + background: var(--surface); + flex-shrink: 0; +} +.pane-heading { + height: 40px; + display: flex; + align-items: center; + justify-content: space-between; + font-size: 12px; + color: var(--muted); + padding: 8px 12px; + border-bottom: 1px solid var(--line); +} +.pane-heading button { + padding: 0 8px; +} +.plan-row { + width: 100%; + text-align: left; + border: 0; + border-radius: 0; + border-bottom: 1px solid var(--line); + padding: 12px; + background: transparent; + min-height: 64px; +} +.plan-row.selected { + background: var(--selected); +} +.row-title { + display: flex; + align-items: center; + gap: 8px; + font-weight: 500; +} +.row-title .count { + margin-left: auto; + color: var(--muted); +} +.row-sub { + display: flex; + gap: 12px; + margin-top: 8px; + font-size: 12px; +} +.row-sub span { + cursor: help; +} +.selected-files { + font-size: 12px; + color: var(--muted); + padding: 8px 12px; + border-bottom: 1px solid var(--line); + overflow-wrap: anywhere; +} +.selected-files code { + display: block; + margin-top: 4px; +} +#attention { + padding: 8px 12px; + border-bottom: 1px solid var(--line); + display: flex; + flex-wrap: wrap; + gap: 4px; +} +#attention button { + border: 0; + padding: 2px 0; + background: none; + color: var(--error); + font-size: 12px; +} +.code-pane { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + background: var(--canvas); +} +.code-heading { + display: flex; + gap: 12px; + align-items: center; + padding: 16px; + border-bottom: 1px solid var(--line); +} +.code-heading > div { + flex: 1; + min-width: 0; +} +.code-heading h1 { + font-size: 16px; +} +.change-toolbar { + height: 44px; + display: flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + border-bottom: 1px solid var(--line); +} +#view-toggle { + margin-right: auto; + display: flex; + gap: 4px; +} +#view-toggle button[aria-pressed="true"] { + color: var(--primary); + border-color: var(--primary); +} +#change-count { + font-size: 12px; + color: var(--muted); +} +#item-details { + padding: 0 16px; +} +#item-details p { + margin: 8px 0; +} +#code { + flex: 1; + overflow: auto; + padding: 16px; +} +#checks { + display: flex; + gap: 16px; + flex-wrap: wrap; + padding: 12px 16px; + border-top: 1px solid var(--line); + font-size: 12px; +} +#checks button { + background: none; + border: 0; + font-size: 12px; + padding: 0; + min-height: 20px; +} +.change { + border: 1px solid var(--line); + margin-bottom: 16px; + scroll-margin: 8px; +} +.change.active { + outline: 1px solid var(--outline); +} +.file-heading { + padding: 8px 12px; + background: var(--surface); + border-bottom: 1px solid var(--line); + display: flex; + flex-wrap: wrap; + gap: 8px; +} +.file-heading code { + overflow-wrap: anywhere; +} +.change-meta { + padding: 8px 12px; + border-top: 1px solid var(--line); + font-size: 12px; + color: var(--muted); +} +.diff { + display: grid; + grid-template-columns: 56px 44px 24px minmax(0, 1fr); + font: + 12px/1.5 "IBM Plex Mono", + Menlo, + monospace; +} +.provenance { + grid-column: 1; + grid-row: 1; + align-self: stretch; + padding: 8px 4px; + text-align: center; + color: var(--muted); + overflow-wrap: anywhere; + border-right: 1px solid var(--line); + font-size: 10px; +} +.provenance.unplanned { + background: repeating-linear-gradient( + 135deg, + transparent, + transparent 6px, + #f2847e24 6px, + #f2847e24 8px + ); + color: var(--error); +} +.line-numbers { + color: var(--muted); + text-align: right; + padding: 8px; +} +.sign { + padding-top: 8px; +} +.diff pre { + margin: 0; + padding: 8px 12px 8px 0; + white-space: pre; + overflow: auto; +} +.diff.added { + background: var(--added); +} +.diff.removed { + background: var(--removed); +} +.file-card { + display: flex; +} +.file-card .provenance { + width: 56px; + flex-shrink: 0; +} +.file-values { + padding: 12px; + overflow-wrap: anywhere; + flex: 1; + min-width: 0; +} +.file-values dl { + display: grid; + grid-template-columns: 72px 1fr; + gap: 8px; + margin: 12px 0; +} +.file-values dd { + margin: 0; +} +.choice-controls { + padding: 12px; + border-top: 1px solid var(--line); + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} +.choice-controls p { + width: 100%; + margin: 0 0 4px; + color: var(--warning); +} +.conversation-pane { + width: 344px; + min-width: 280px; + max-width: 480px; + flex-shrink: 0; + display: flex; + flex-direction: column; + background: var(--surface); + border-left: 1px solid var(--line); + resize: horizontal; + overflow: auto; +} +.tabs { + display: flex; + border-bottom: 1px solid var(--line); +} +.tabs button { + flex: 1; + background: none; + border: 0; + border-bottom: 2px solid transparent; + border-radius: 0; + height: 40px; +} +.tabs button[aria-pressed="true"] { + border-bottom-color: var(--primary); + color: var(--primary); +} +.conversation-help { + font-size: 12px; + padding: 4px 12px; +} +#notes { + flex: 1; + overflow: auto; + padding: 12px; + font-size: 14px; + line-height: 1.5; +} +.note { + padding-bottom: 16px; + margin-bottom: 16px; + border-bottom: 1px solid var(--line); +} +.note p { + white-space: pre-wrap; + overflow-wrap: anywhere; +} +.note small { + font-size: 12px; + color: var(--muted); +} +#composer { + padding: 12px; + border-top: 1px solid var(--line); +} +#composer label { + font-size: 12px; + display: block; + margin-bottom: 8px; +} +textarea { + background: var(--canvas); + color: var(--text); + border: 1px solid var(--outline); + border-radius: 4px; + width: 100%; + padding: 8px; + resize: vertical; + min-height: 90px; + margin-bottom: 8px; +} +footer { + height: 36px; + flex-shrink: 0; + border-top: 1px solid var(--line); + display: flex; + align-items: center; + gap: 24px; + padding: 0 16px; + font-size: 12px; +} +footer > span:last-child { + margin-left: auto; +} +footer button { + font-size: 12px; + min-height: 24px; +} +#banner:not(:empty) { + padding: 12px 16px; + background: var(--surface); + border-bottom: 1px solid var(--line); + color: var(--warning); +} +.empty { + padding: 48px 24px; + color: var(--muted); + max-width: 640px; +} +.comparison { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + margin-bottom: 16px; +} +.comparison section { + min-width: 0; + background: var(--surface); + padding: 12px; + border: 1px solid var(--line); +} +details > summary { + cursor: pointer; + color: var(--primary); +} +dialog { + background: var(--raised); + color: var(--text); + border: 1px solid var(--line); + border-radius: 6px; + width: 640px; + max-height: 80vh; + overflow: auto; +} +dialog::backdrop { + background: #101216aa; +} +.narrow { + display: none; +} +#conversation-toggle { + font-size: 12px; +} +#saved { + display: block; + font-size: 12px; + color: var(--success); + margin-top: 8px; +} +@media (min-width: 1440px) { + #conversation-toggle { + display: none; + } + body.conversation-closed #conversation-toggle { + display: block; + } + body.conversation-closed .conversation-pane { + display: none; + } +} +@media (min-width: 1280px) and (max-width: 1439px) { + .plan-pane { + width: 208px; + } + .conversation-pane { + display: none; + } + body.conversation-open .conversation-pane { + display: flex; + width: 300px; + } + .app-bar nav { + gap: 12px; + } + .app-bar { + gap: 16px; + } + #repository { + display: none; + } +} +@media (max-width: 1279px) { + #app { + display: none; + } + .narrow { + display: block; + margin: 15vh auto; + padding: 32px; + max-width: 600px; + } +} +@media (prefers-reduced-motion: no-preference) { + button { + transition: background-color 80ms ease-out; + } + .good { + transition: color 150ms ease-out; + } +} + +.image-previews { + display: flex; + gap: 12px; + flex-wrap: wrap; +} +.image-previews figure { + margin: 8px 0; + max-width: 100%; +} +.image-previews img { + max-width: 100%; + max-height: 240px; + object-fit: contain; +} +.image-previews figcaption { + font-size: 12px; + color: var(--muted); +} diff --git a/web/server.ts b/web/server.ts new file mode 100644 index 0000000..d086f54 --- /dev/null +++ b/web/server.ts @@ -0,0 +1,44 @@ +import { createServer } from 'node:http'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { randomBytes, timingSafeEqual } from 'node:crypto'; +import { ReviewService, type ReviewConfig } from '../runner/review.ts'; +const publicRoot = new URL('./public/', import.meta.url); +export async function startServer(config: ReviewConfig, port = 4318) { + const service = new ReviewService(config), token = randomBytes(32).toString('hex'); + const server = createServer(async (req, res) => { + const address = server.address(); const actualPort = address && typeof address !== 'string' ? address.port : port; + const origin = `http://127.0.0.1:${actualPort}`; + res.setHeader('Cache-Control', 'no-store'); res.setHeader('X-Content-Type-Options', 'nosniff'); + res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self'; style-src 'self'; font-src 'self'; connect-src 'self'; img-src 'self' data:; frame-ancestors 'none'; base-uri 'none'; form-action 'self'"); + const json = (status: number, value: unknown) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(value)); }; + try { + if (req.headers.host !== `127.0.0.1:${actualPort}` || (req.headers.origin && req.headers.origin !== origin)) { json(403, { error: 'Local origin required.' }); return; } + const path = new URL(req.url ?? '/', origin).pathname; + if (path.startsWith('/api/')) { + const supplied = req.headers['x-codeboost-token']; + if (typeof supplied !== 'string' || !/^[a-f0-9]{64}$/.test(supplied) || !timingSafeEqual(Buffer.from(supplied), Buffer.from(token))) { json(403, { error: 'Open the private local URL printed by the CLI.' }); return; } + if (req.method === 'GET' && path === '/api/review') { json(200, service.load()); return; } + if (req.method !== 'POST' || path !== '/api/action' || req.headers['content-type'] !== 'application/json') { json(405, { error: 'Unsupported request.' }); return; } + const chunks: Buffer[] = []; let size = 0; + for await (const chunk of req) { size += chunk.length; if (size > 16384) { json(413, { error: 'Request too large.' }); return; } chunks.push(chunk); } + const body = new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks)); + json(200, service.act(JSON.parse(body))); return; + } + if (req.method !== 'GET') { json(405, { error: 'Method not allowed.' }); return; } + const files: Record = { '/': ['index.html', 'text/html'], '/app.js': ['app.js', 'text/javascript'], '/style.css': ['style.css', 'text/css'] }; + const file = files[path]; + if (file) { res.writeHead(200, { 'Content-Type': `${file[1]}; charset=utf-8` }); res.end(readFileSync(new URL(file[0], publicRoot))); return; } + const font = /^\/fonts\/(ibm-plex-(?:sans|mono)-latin-(?:400|500|600)-normal\.woff2)$/.exec(path); + if (font) { + const family = font[1]!.startsWith('ibm-plex-sans') ? 'ibm-plex-sans' : 'ibm-plex-mono'; + res.writeHead(200, { 'Content-Type': 'font/woff2' }); res.end(readFileSync(fileURLToPath(new URL(`../node_modules/@fontsource/${family}/files/${font[1]}`, import.meta.url)))); return; + } + json(404, { error: 'Not found.' }); + } catch (error) { json(409, { error: error instanceof Error ? error.message : 'Review failed.' }); } + }); + server.requestTimeout = 15000; + await new Promise((resolve, reject) => { server.once('error', reject); server.listen(port, '127.0.0.1', () => { server.removeListener('error', reject); resolve(); }); }).catch(error => { service.close(); throw error; }); + const address = server.address(); if (!address || typeof address === 'string') throw new Error('Cannot determine local address.'); + return { server, service, token, url: `http://127.0.0.1:${address.port}/#${token}`, close: () => new Promise((resolve, reject) => server.close(error => { service.close(); error ? reject(error) : resolve(); })) }; +}