mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-02 15:04:37 +01:00
* G1: gstack-plan-reality check opens every file:line receipt (#973) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * G1: office-hours spec reviewer checks paths and flags through a bin-enforced probe (#973) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * G2: plan-review reruns reconcile the last task list (gstack-tasks prior/supersede) (#1791) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * G2: plan reviews write task lists through gstack-tasks start/add/close The emit block shrinks to three calls: prior (now defaulting --dir and --branch, and printing how to reconcile), start (creates the empty branch-named list) and add (one validated row per task). close records a done or dropped prior task. The plan-review result codes move to lib/result-codes-plan.ts so lib/result-codes.ts stays under the module-size ratchet; generated plan-review sections shrink, so their size pins go down. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * F2: office-hours saves the sketch beside the design doc; /plan-design-review reads it Step 5 copies sketch.png/html to <PROJECT_DIR>/{user}-{branch}-sketch-{datetime}.* and writes a Wireframe: line (state-root relative) or Wireframe: none (<reason>). The design review section finds the design doc before Pass 1 and opens the wireframe, text-only when the file is missing. G4's signals line rides along in the same generated office-hours files; tests in planning-wave-prose. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * G3: plan reviews tie storage to queries, not file count (#1048) One bullet in each of plan-eng-review and plan-ceo-review: fields queried, constrained or updated on their own get explicit columns or tables; JSON suits a document read and written whole. Written fresh; the reporter's patch is not ported. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * evals: GSTA-228 planning-wave cases with branch, main and no-gstack arms planted facts (G1, behavior), data model (G3, behavior), task-list rerun (G2, rule) and closing signals (G4, behavior). GSTACK_WAVE_ARM picks the arm; only the branch arm asserts the preregistered bar, every arm writes a score row. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * changelog: GSTA-228 planning wave fragment Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * G1: the probe takes subcommand words for fixed-subcommand CLIs The planted-facts eval's one branch miss: the reviewer asked to probe "git log", the probe refused it, and git's top-level help cannot show a log flag, so the fabricated --since-commit stayed "unverified". probe --help <command> [<subcommand> [<subcommand>]] now runs <cli> <sub> --help for git, gh, docker, cargo, go, npm and uv (never node, bun, python3, make, yarn, pnpm or a gstack bin, where the word could name a script), in an empty temporary directory with pagers set to cat, falling back to -h when --help has no usage line (a minimized system's missing man page). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * G2: task text goes through a rows file, never a command line The free suite's generated-bash lint (INV-3) refused the add command's <title>/<finding> placeholders: free text reaches a command only as a file the agent writes with its file-write tool. start now also creates an empty rows file in the repository's git-excluded .gstack/tmp and records the run; the agent writes task and disposition rows there; add validates every row, stamps phase, run, branch and commit, appends tasks to the list and dispositions to task-dispositions-<phase>-<branch>.jsonl, then supersedes the branch's earlier lists. close is gone (dispositions are rows). The new planning-wave cases get measured periodic walls in scripts/paid-test-durations.json so the periodic census still packs into 40 slices, the schema case declares the preamble sources its eng-template owners need, and the paid file count is 114. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * G2: fold start into add so each plan review emits one bash fence The rows file lives at .gstack/tmp/tasks-<phase>-rows.jsonl; add writes this run's list (empty when there are no rows), appends dispositions, deletes the rows file and supersedes the branch's earlier lists. Lowers 27 size pins. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * G2 tests: give gstack-tasks a private HOME and state root CI shard 13 home containment caught gstack-slug writing ~/.gstack/slug-cache entries from the tasks-prior fixtures. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * changelog: gstack-tasks has no start command Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * G1/G2: tighten the probe and the rows file after ship review The probe now accepts only each CLI's listed built-in subcommands (no uv run, docker run, go test, npm exec or gh extensions), refuses gstack bins that do not handle --help and never retries them with -h, and runs with a throwaway HOME and an environment without keys. gstack-tasks resolves the slug through runScriptSync (Windows) and prior deletes a rows file an unfinished run left. Adds tests for the dropped disposition and the stale rows file. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * G2: add refuses while an earlier task is neither carried nor closed Ship review (state matrix): a rerun that skipped RECONCILE silently retired open tasks. add now names each undecided <run_id>/<id> and writes nothing. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * G1/G2: fixes from the ship fix-check and Step 11 voices Probe: find the repository root without running git, drop relative and in-repository PATH entries for lookup and for the child, keep HOME and toolchain locations (rustup, mise, asdf) but no keys, drop cargo fmt/clippy (external cargo-* programs), restore planted env in the test. gstack-tasks: resolve the exclude path against the cwd (subdirectories), resolve --dir, stamp an empty branch on a detached HEAD like the aggregator, name the file from --branch, and refuse a same-second second list instead of appending. Receipts: line 0, reversed ranges, dangling links and paths through files are unresolved instead of crashing. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * changelog: probe limits and the reconcile refusal Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: probe subcommand counts per CLI (ship docs audit) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
110 lines
6.1 KiB
TypeScript
110 lines
6.1 KiB
TypeScript
/**
|
|
* Receipt resolution for plan reality rows (G1, #973).
|
|
*
|
|
* `checkRealityRows` (lib/plan-reality.ts) stays pure: it only asks whether a
|
|
* row carries a receipt. This module asks whether each receipt points at
|
|
* something real, against explicit roots (the checkout a review is about), so
|
|
* only `gstack-plan-reality check` reads the filesystem.
|
|
*
|
|
* resolved the file exists inside a root and the cited lines exist,
|
|
* or the sha names a commit in a root's repository
|
|
* unresolved a relative path into a root's tree that is missing, a
|
|
* directory, a dangling link, line 0, a reversed range or a
|
|
* line past the end: the claim is false here
|
|
* unverifiable this checkout cannot judge it (outside every root, a bare
|
|
* name no root has, a symlink leaving the root, a file over
|
|
* the size cap, a sha a shallow clone lacks): reported only
|
|
*
|
|
* Reads are bounded: only regular files whose real path stays inside the
|
|
* root, at most `maxBytes`, and no file content ever appears in a result.
|
|
*/
|
|
import { spawnSync } from 'node:child_process';
|
|
import * as fs from 'node:fs';
|
|
import * as path from 'node:path';
|
|
import { RECEIPT_RE, type RealityLine } from './plan-reality';
|
|
|
|
export type ReceiptOutcome = 'resolved' | 'unresolved' | 'unverifiable';
|
|
export interface ReceiptResult { row: string; receipt: string; outcome: ReceiptOutcome; reason: string }
|
|
export interface ResolveOptions { roots: readonly string[]; maxBytes?: number }
|
|
|
|
const MAX_BYTES = 8 * 1024 * 1024;
|
|
const PATH_TOKEN_RE = /^(?<file>.+):(?<from>\d+)(?:-(?<to>\d+))?$/;
|
|
|
|
/** Every receipt token on a row; a `host:port` (no `/` and no `.` before the colon) is not a file receipt. */
|
|
export function receiptTokens(rest: string): string[] {
|
|
const re = new RegExp(RECEIPT_RE.source, 'g');
|
|
return [...rest.matchAll(re)].map(m => m[0].replace(/^`|`$/g, '').replace(/^commit /, ''))
|
|
.filter(t => { const m = PATH_TOKEN_RE.exec(t); return !m || /[./]/.test(m.groups!.file!); });
|
|
}
|
|
|
|
const inside = (root: string, p: string) => p === root || p.startsWith(root.endsWith(path.sep) ? root : root + path.sep);
|
|
|
|
function lineCount(file: string, size: number): number {
|
|
if (size === 0) return 0;
|
|
const buf = fs.readFileSync(file);
|
|
let n = 0;
|
|
for (const b of buf) if (b === 10) n++;
|
|
return buf[buf.length - 1] === 10 ? n : n + 1;
|
|
}
|
|
|
|
function resolvePath(token: string, roots: readonly string[], maxBytes: number): { outcome: ReceiptOutcome; reason: string } {
|
|
const m = PATH_TOKEN_RE.exec(token)!;
|
|
const file = m.groups!.file!.replace(/^\.\//, '');
|
|
const first = Number(m.groups!.from), last = Number(m.groups!.to ?? m.groups!.from);
|
|
if (first < 1 || last < first) return { outcome: 'unresolved', reason: `line range ${first}-${last} is not a range of lines` };
|
|
if (!roots.length) return { outcome: 'unverifiable', reason: 'no root to resolve against (not in a git checkout; pass --root)' };
|
|
if (/^[~$]/.test(file)) return { outcome: 'unverifiable', reason: 'path outside every root' };
|
|
let intoTree = false;
|
|
for (const root of roots) {
|
|
const candidate = path.resolve(root, file);
|
|
if (!inside(root, candidate)) continue;
|
|
const top = file.includes('/') ? path.join(root, file.split('/')[0]!) : candidate;
|
|
if (path.isAbsolute(file) || fs.existsSync(top)) intoTree = true;
|
|
let real: string;
|
|
try {
|
|
if (!fs.lstatSync(candidate, { throwIfNoEntry: false })) continue;
|
|
real = fs.realpathSync(candidate);
|
|
} catch (e) {
|
|
return { outcome: 'unresolved', reason: `cannot open (${(e as NodeJS.ErrnoException).code ?? 'error'})` };
|
|
}
|
|
if (!inside(fs.realpathSync(root), real)) return { outcome: 'unverifiable', reason: 'symlink leaves the root' };
|
|
const target = fs.statSync(real);
|
|
if (!target.isFile()) return { outcome: 'unresolved', reason: 'not a regular file' };
|
|
if (target.size > maxBytes) return { outcome: 'unverifiable', reason: `file over ${maxBytes} bytes` };
|
|
const lines = lineCount(real, target.size);
|
|
if (last > lines) return { outcome: 'unresolved', reason: `line ${last} is past the end (${lines} lines)` };
|
|
return { outcome: 'resolved', reason: path.relative(root, candidate) };
|
|
}
|
|
return intoTree ? { outcome: 'unresolved', reason: 'no such file' } : { outcome: 'unverifiable', reason: 'path outside every root' };
|
|
}
|
|
|
|
/** One `git cat-file --batch-check` per root for every sha token; a sha is resolved when any root's repository has the commit. */
|
|
function resolveShas(shas: string[], roots: readonly string[]): Map<string, { outcome: ReceiptOutcome; reason: string }> {
|
|
const out = new Map<string, { outcome: ReceiptOutcome; reason: string }>();
|
|
for (const sha of shas) out.set(sha, { outcome: 'unverifiable', reason: 'no root repository has this commit (shallow or another repository)' });
|
|
if (!shas.length) return out;
|
|
for (const root of roots) {
|
|
const r = spawnSync('git', ['cat-file', '--batch-check'], { cwd: root, input: shas.map(s => `${s}^{commit}`).join('\n') + '\n', encoding: 'utf8', timeout: 10_000 });
|
|
if (r.status !== 0) continue;
|
|
r.stdout.split('\n').filter(Boolean).forEach((line, i) => {
|
|
if (!/ missing$| ambiguous$/.test(line) && shas[i]) out.set(shas[i]!, { outcome: 'resolved', reason: 'commit' });
|
|
});
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export function resolveReceipts(lines: readonly RealityLine[], o: ResolveOptions): ReceiptResult[] {
|
|
const maxBytes = o.maxBytes ?? MAX_BYTES;
|
|
const roots = o.roots.map(r => path.resolve(r));
|
|
const tokens = lines.filter(l => l.status !== 'n/a').flatMap(l => receiptTokens(l.rest).map(receipt => ({ row: l.id, receipt })));
|
|
const shas = [...new Set(tokens.filter(t => !PATH_TOKEN_RE.test(t.receipt)).map(t => t.receipt))];
|
|
const bySha = resolveShas(shas, roots);
|
|
return tokens.map(t => ({ ...t, ...(PATH_TOKEN_RE.test(t.receipt) ? resolvePath(t.receipt, roots, maxBytes) : bySha.get(t.receipt)!) }));
|
|
}
|
|
|
|
/** The git top level of `cwd`, or none outside a checkout. */
|
|
export function defaultRoots(cwd: string): string[] {
|
|
const r = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8', timeout: 10_000 });
|
|
return r.status === 0 && r.stdout.trim() ? [r.stdout.trim()] : [];
|
|
}
|