mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-02 17:40:02 +02:00
* feat: add surface-aware exploratory QA and ship documentation gates * test: preserve delegated QA setup authority after main integration * fix(qa): clarify exploration order and preserve report artifacts * test(qa): follow the shared setup reference directly * refactor(ship): make verification and recovery routes explicit * test(ship): align evidence and review guards with explicit routes * fix(workflows): clarify ship recovery and functional QA evidence * fix(workflows): clarify approval recovery and full QA coverage * refactor(workflows): order review transactions and clarify ship state * fix(ship): clarify final verification and fail closed at publication * fix(evals): attribute native atomic documentation writes * fix(ship): clarify recovery and documentation lifecycle guidance * fix(test): preserve observed native placeholder styling in CI * fix(codex): report watchdog timeouts without a process-exit race * Checkpoint functional QA implementation and workflow validation repairs * Fix documentation and shared-review fixture contracts * docs: clarify judge reuse and evaluation supervision * test: align review evidence and selected case contracts * test: verify append-only documentation checkpoints and recovery * fix: qualify QA workflows and CI validation repairs * fix: launch shared-libs fixture scripts on Windows * fix: qualify QA deadlines, fixture isolation, and shard cleanup * fix: preserve qualified QA and cancellation repairs * fix: enforce functional fixture authority and share strict event decoding * fix: retain free-test evidence and explain recovery * fix: reject malformed native evidence after decoder consolidation * test: use reliable capture for telemetry privacy filters * test: refresh measured quick coverage and document validation costs * Fix native fixture receipts and preserve VM validation evidence * Align negative judge controls with upstream clarity policy * Fix report-only QA preparation and public evidence handling * Clarify QA-only preparation and current-report preservation * Stream Ship quality judgments with an explicit 64k response contract * Validate compact judge reasoning locally with supported wire schema * Align functional QA fixture instructions with evidence acceptance * Bind native browser diagnostics to execution evidence and align review verdicts * Preserve native diagnostic line boundaries * Serialize functional QA evidence from native captures * Keep large QA evidence fixture payload out of Windows argv
316 lines
23 KiB
TypeScript
316 lines
23 KiB
TypeScript
import * as fs from 'node:fs';
|
||
import * as path from 'node:path';
|
||
import { createHash } from 'node:crypto';
|
||
import { observeQAWrites, type QAWriteObservation } from './qa-functional-observer';
|
||
import { nativeCalls } from './qa-checkpoint-evidence';
|
||
import type { SkillTestResult } from './session-runner';
|
||
import { DOC_PATH, type DocsScenario, type fixtureDocs } from './docsync-fixture';
|
||
import { sliceBetween } from './skill-fixture';
|
||
|
||
export async function observeDocsWrites(fixture: ReturnType<typeof fixtureDocs>) {
|
||
return observeQAWrites(fixture.repo);
|
||
}
|
||
|
||
type DocsWriteContext = {
|
||
result: SkillTestResult;
|
||
fixture: ReturnType<typeof fixtureDocs>;
|
||
scripts?: string[];
|
||
readOnly?: boolean;
|
||
};
|
||
|
||
function docsAtomicSources(observation: QAWriteObservation, allowed: string[], context?: DocsWriteContext): Set<string> {
|
||
const denied = new Set<string>();
|
||
if (!context || context.readOnly || !allowed.includes(DOC_PATH) || !observation.complete || observation.failures.length) return denied;
|
||
const { result, fixture, scripts = [] } = context;
|
||
if (result.exitReason !== 'success' || !Array.isArray(result.transcript) || docsToolFailures(result, fixture, scripts).length) return denied;
|
||
const failures: string[] = [];
|
||
const target = path.join(fixture.repo, DOC_PATH);
|
||
const native = nativeCalls(result.transcript, failures);
|
||
const calls = native.filter(call => ['Write', 'Edit'].includes(call.name)
|
||
&& typeof call.input.file_path === 'string' && path.resolve(fixture.repo, call.input.file_path) === target);
|
||
if (failures.length || !calls.length || calls.some((call, index) => call.failed || call.end <= call.start || (index > 0 && call.start <= calls[index - 1].end))) return denied;
|
||
const before = observation.before[DOC_PATH];
|
||
const after = observation.after[DOC_PATH];
|
||
if (!/^\d+:[a-f0-9]{64}$/.test(before ?? '') || !/^\d+:[a-f0-9]{64}$/.test(after ?? '') || before.split(':')[0] !== after.split(':')[0]) return denied;
|
||
const hash = (text: string) => createHash('sha256').update(text).digest('hex');
|
||
const encoded = fixture.before?.contents[DOC_PATH];
|
||
if (typeof encoded !== 'string') return denied;
|
||
const baseline = Buffer.from(encoded, 'base64');
|
||
let content = baseline.toString('utf8');
|
||
let contentHash = before.split(':')[1];
|
||
if (baseline.toString('base64') !== encoded || !Buffer.from(content).equals(baseline) || hash(content) !== contentHash) return denied;
|
||
const seen = new Set([contentHash]);
|
||
for (const call of calls) {
|
||
const event = result.transcript[call.end];
|
||
const payload = event.tool_use_result;
|
||
const results = event.message.content.filter((block: any) => block?.type === 'tool_result');
|
||
if (results.length !== 1 || (results[0].is_error !== undefined && results[0].is_error !== false)) return denied;
|
||
const omitted = !Object.hasOwn(event, 'tool_use_result');
|
||
if (omitted) {
|
||
if (call.parent === null) return denied;
|
||
let child = call;
|
||
const ancestors = new Set<typeof call>();
|
||
while (child.parent !== null) {
|
||
const parents = native.filter(candidate => {
|
||
const blocks = result.transcript[candidate.end]?.message?.content?.filter((block: any) => block?.type === 'tool_result');
|
||
return blocks?.length === 1 && blocks[0].tool_use_id === child.parent;
|
||
});
|
||
if (parents.length !== 1) return denied;
|
||
const parent = parents[0];
|
||
const completion = result.transcript[parent.end];
|
||
const block = completion.message.content.find((block: any) => block?.type === 'tool_result');
|
||
if (!['Agent', 'Task'].includes(parent.name) || parent.failed || parent.input.run_in_background === true
|
||
|| parent.start >= child.start || parent.end <= child.end || ancestors.has(parent)
|
||
|| (block.is_error !== undefined && block.is_error !== false)) return denied;
|
||
if (Object.hasOwn(completion, 'tool_use_result')) {
|
||
if (completion.tool_use_result?.status !== 'completed') return denied;
|
||
} else if (parent.parent === null) return denied;
|
||
ancestors.add(parent);
|
||
child = parent;
|
||
}
|
||
} else if (!payload || payload.filePath !== target || payload.userModified !== false || payload.originalFile !== content) return denied;
|
||
if (call.name === 'Write') {
|
||
if (typeof call.input.content !== 'string' || (!omitted && (payload.type !== 'update' || payload.content !== call.input.content))) return denied;
|
||
content = call.input.content;
|
||
} else {
|
||
const { old_string: old, new_string: replacement, replace_all: all = false } = call.input;
|
||
if (typeof old !== 'string' || !old || typeof replacement !== 'string' || typeof all !== 'boolean'
|
||
|| (!omitted && (payload.oldString !== old || payload.newString !== replacement || payload.replaceAll !== all))) return denied;
|
||
const parts = content.split(old);
|
||
if (parts.length < 2 || (!all && parts.length !== 2)) return denied;
|
||
content = parts.join(replacement);
|
||
}
|
||
contentHash = hash(content);
|
||
if (seen.has(contentHash)) return denied;
|
||
seen.add(contentHash);
|
||
}
|
||
if (contentHash !== after.split(':')[1]) return denied;
|
||
const events = observation.events;
|
||
const destinations = events.flatMap((event, index) => event.path === DOC_PATH && event.mask === 0x80 ? [index] : []);
|
||
if (destinations.length !== calls.length) return denied;
|
||
const sources = new Set<string>();
|
||
let previous = -1;
|
||
for (const destination of destinations) {
|
||
const move = events[destination];
|
||
if (!Number.isInteger(move.cookie) || move.cookie <= 0 || move.cookie > 0xffffffff) return denied;
|
||
const pair = events.flatMap((event, index) => event.cookie === move.cookie ? [index] : []);
|
||
if (pair.length !== 2 || pair[1] !== destination) return denied;
|
||
const source = events[pair[0]];
|
||
if (source.mask !== 0x40 || source.path === DOC_PATH || path.dirname(source.path) !== path.dirname(DOC_PATH)
|
||
|| Object.hasOwn(observation.before, source.path) || Object.hasOwn(observation.after, source.path) || sources.has(source.path)) return denied;
|
||
const lifecycle = events.flatMap((event, index) => event.path === source.path ? [{ event, index }] : []);
|
||
if (lifecycle[0]?.event.mask !== 0x100 || lifecycle[0].index <= previous || lifecycle.at(-1)?.index !== pair[0]) return denied;
|
||
let modified = false;
|
||
let closed = false;
|
||
for (const { event, index } of lifecycle) {
|
||
if (index === pair[0]) { if (!modified || !closed) return denied; continue; }
|
||
if (event.cookie !== 0) return denied;
|
||
if (index === lifecycle[0].index) continue;
|
||
if (event.mask === 0x2 && !closed) modified = true;
|
||
else if (event.mask === 0x4 && !closed) continue;
|
||
else if (event.mask === 0x8 && modified) closed = true;
|
||
else return denied;
|
||
}
|
||
sources.add(source.path);
|
||
previous = destination;
|
||
}
|
||
if (events.some((event, index) => event.path === DOC_PATH && (index < destinations[0]
|
||
|| ![0x80, 0x4, 0x400, 0x800].includes(event.mask) || (event.mask !== 0x80 && event.cookie !== 0)))) return denied;
|
||
for (const [index, destination] of destinations.entries()) {
|
||
const replaced = events.slice(destination + 1, destinations[index + 1]).filter(event => event.path === DOC_PATH);
|
||
if (replaced.filter(event => event.mask === 0x4).length !== 1 || replaced.filter(event => event.mask === 0x400).length !== 1
|
||
|| replaced.filter(event => event.mask === 0x800).length > 1) return denied;
|
||
}
|
||
return sources;
|
||
}
|
||
|
||
export function docsWriteFailures(observation: QAWriteObservation, allowed: string[], context?: DocsWriteContext): string[] {
|
||
const failures = [...observation.failures];
|
||
if (!observation.complete) failures.push('incomplete docs write observation');
|
||
const atomicSources = docsAtomicSources(observation, allowed, context);
|
||
for (const file of new Set([...observation.events.map(e => e.path), ...observation.changed])) {
|
||
if (file !== '.qa-state/.observer-check' && !allowed.includes(file) && !atomicSources.has(file)) failures.push(`forbidden docs write: ${file}`);
|
||
if (allowed.includes(file) && observation.before[file] && observation.after[file] &&
|
||
observation.before[file].split(':')[0] !== observation.after[file].split(':')[0]) failures.push(`document mode changed: ${file}`);
|
||
}
|
||
return failures;
|
||
}
|
||
|
||
export function docsPreambleCommands(fixture: ReturnType<typeof fixtureDocs>): string[] {
|
||
const source = fs.readFileSync(path.join(fixture.skills, 'document-release/SKILL.md'), 'utf8');
|
||
const generated = fs.readFileSync(path.join(process.env.DOCSYNC_GENERATED_ROOT || path.resolve(import.meta.dir, '../..'),
|
||
'document-release/SKILL.md'), 'utf8');
|
||
const [command, expected] = [source, generated].map(text => {
|
||
if ((text.match(/^## Preamble \(run first\)[ \t]*\r?$/gm) ?? []).length !== 1) return undefined;
|
||
return /^## Preamble \(run first\)[ \t]*\r?\n(?:[ \t]*\r?\n)*```bash[ \t]*\r?\n([\s\S]*?)\r?\n```[ \t]*$/m.exec(text)?.[1];
|
||
});
|
||
if (!command || command !== expected) return [];
|
||
return [command, command.replace(/(^|\n)("\$_SS" --skill)/, '$1GSTACK_SESSION_KIND=spawned $2')];
|
||
}
|
||
|
||
export function docsShipPhase(skeleton: string, prBody: string, scenario: DocsScenario, storePointer: string): string {
|
||
if (scenario === 'store') return storePointer;
|
||
return `${sliceBetween(skeleton, '## Step 14.5: Documentation audit (every ship)', '## Step 15: Commit')}\n\n${sliceBetween(prBody, '## Documentation', '## Test plan')}`;
|
||
}
|
||
|
||
type DocsSessionOptionsInput = {
|
||
fixture: ReturnType<typeof fixtureDocs>;
|
||
phase: string;
|
||
report: string;
|
||
publish: string;
|
||
scenario: DocsScenario;
|
||
testName: string;
|
||
runId: string;
|
||
timeout: number;
|
||
};
|
||
|
||
export function docsSessionOptions(input: DocsSessionOptionsInput): Parameters<typeof import('./session-runner').runSkillTest>[0] {
|
||
const { fixture, phase, report, publish, scenario, testName, runId, timeout } = input;
|
||
return {
|
||
prompt: `Load gstack's /ship workflow. Steps 0–14 are complete in this isolated fixture. Execute the next phase from ${phase}, then stop before the next numbered phase. Skill assets are installed under ${fixture.skills}; HOME=${fixture.home}. Base: main. ${scenario === 'current' ? 'This is a second /ship invocation for an existing open PR; the docs-only branch is already pushed. Earlier audit results are not evidence for this invocation.' : ''} ${scenario === 'store' ? 'The selected store-release source is the current working tree on main. All App Store operations are mocked and out of scope; no permissions to edit source are granted.' : ''} After the phase, write the ship outcome to ${report}. Only if the workflow gate actually allows continuing, run the isolated publication stand-in: bun ${publish}. No real PR, push, store action or later ship phase is authorized. If a decision is required, record the exact blocker and stop; no risk exception is granted. Preserve all partial content.\n\n${docsNativeInterface(fixture, [publish])}`,
|
||
workingDirectory: fixture.repo,
|
||
maxTurns: 30,
|
||
allowedTools: ['Bash', 'Read', 'Grep', 'Glob', 'Write', 'Edit', 'Agent', 'Task'],
|
||
timeout,
|
||
env: fixture.env,
|
||
testName,
|
||
runId,
|
||
};
|
||
}
|
||
|
||
export function docsBoundedStageInterface(fixture: ReturnType<typeof fixtureDocs>): string {
|
||
return `Read and continue the supplied invocation record at ${fixture.invocation}. Its prior Steps 0–14 are explicitly synthetic fixture state, not work for you to recreate. Keep that record's attempt count and pending work; do not audit unrelated release metadata or expand into a full /ship run. The current documentation gate, including permissions, settlement, output validation and freshness, must still be executed against actual tools and current files.
|
||
|
||
Private artifact filenames must end in .json, .md or .markdown; .txt and .log filenames are not supported. This is a filename restriction, not just a description of the content. Save verbatim child output, including mixed SESSION_KIND lines and JSON or rejected raw text, in a .md file without changing its bytes or reconstructing JSON. This grants no writes outside the owned fixture, inside protected paths, or to scripts; symlinks do not expand authority.
|
||
|
||
Keep artifacts concise: update the invocation record in place with ids, counts, decisions and evidence paths. Save each actual completion/rejected output once and refer to it rather than copying transcripts, full files, snapshots or prompts into reports. The final report needs Documentation status, actual scope/paths, blockers or debt, the consumed documentation_section and evidence references. Preserve all consumed evidence; omit repeated narration. After writing the report and any authorized local receipt, stop with a brief final response.`;
|
||
}
|
||
|
||
export function docsCommandAllowed(command: string, fixture: ReturnType<typeof fixtureDocs>, scripts: string[] = []): boolean {
|
||
const text = command.trim();
|
||
if (docsPreambleCommands(fixture).some(block => block.trim() === text)) return true;
|
||
if (/[\x00-\x08\x0a-\x1f\x7f;&|<>`$\\()]/.test(text)) return false;
|
||
const args: string[] = [];
|
||
const literal = /(?:'([^']*)'|"([^"]*)"|([^\s'"]+))(?:[ \t]+|$)/y;
|
||
while (literal.lastIndex < text.length) {
|
||
const token = literal.exec(text);
|
||
if (!token) return false;
|
||
const value = token[1] ?? token[2] ?? token[3];
|
||
if (token[3] !== undefined && (/[*?\[#]/.test(value) || value.startsWith('~') || /\{[^{}]*(?:,|\.\.)[^{}]*\}/.test(value))) return false;
|
||
args.push(value);
|
||
}
|
||
if (!args.length) return false;
|
||
const [commandName, ...rest] = args;
|
||
if (args.some((arg, index) => /[{}]/.test(arg) && (commandName !== 'git' || index < 2 ||
|
||
/[{}]/.test(arg.replace(/(?:\^|@)\{[^{}]*\}/g, ''))))) return false;
|
||
if (args.some(arg => path.basename(arg) === 'actor-state.json') &&
|
||
!((commandName === 'bun' || commandName === process.execPath) && scripts.includes(rest[0]))) return false;
|
||
if (['pwd', 'ls', 'cat', 'sha256sum', 'stat'].includes(commandName)) return true;
|
||
if (commandName === 'git') {
|
||
if (rest.some(arg => /^(?:--output|--ext-diff|--textconv|-w)(?:=|$)/.test(arg))) return false;
|
||
if (rest[0] === 'hash-object' && rest.some(arg => /^-[^-]*w/.test(arg))) return false;
|
||
if (rest[0] === 'branch') return rest.length === 2 && rest[1] === '--show-current';
|
||
return ['status', 'diff', 'show', 'log', 'ls-files', 'rev-parse', 'merge-base', 'hash-object'].includes(rest[0]);
|
||
}
|
||
if (commandName === 'bun' || commandName === process.execPath) {
|
||
return scripts.includes(rest[0]);
|
||
}
|
||
const marker = 'GSTACK_SESSION_KIND=spawned';
|
||
const start = path.join(fixture.skills, 'bin/gstack-skill-start').split(path.sep).join('/');
|
||
const end = path.join(fixture.skills, 'bin/gstack-skill-end').split(path.sep).join('/');
|
||
return (commandName === marker && rest[0] === start || commandName === start || commandName === end) && args.includes('document-release');
|
||
}
|
||
|
||
export function docsNativeInterface(fixture: Pick<ReturnType<typeof fixtureDocs>, 'home' | 'repo' | 'skills'>, scripts: string[] = [], transport = false): string {
|
||
const skills = fixture.skills.split(path.sep).join('/');
|
||
return `Fixture observation interface (applies to parent and every child; include this interface in child prompts): Bash may execute only separate literal pwd, ls, cat, stat, sha256sum, Git read commands (status, diff, show, log, ls-files, rev-parse, merge-base, hash-object without -w, branch --show-current), the exact generated Preamble block with its spawned prefix, or literal installed gstack-skill-start/gstack-skill-end commands for document-release (start requires GSTACK_SESSION_KIND=spawned). No shell composition, custom interpreters, arbitrary scripts, inline eval or memory-mapped writes. The only additional scripts are ${scripts.length ? scripts.join(', ') : 'none'}. Read/Glob/Grep remain available. Use Write/Edit for permitted docs and private JSON/Markdown artifacts under ${fixture.home}; do not rewrite installed skills, config, actor state or scripts. No effects outside the owned fixture. The owner preserves evidence and cleans up. Missing observer coverage blocks acceptance; the Linux kernel monitor covers syscall writes in the product tree, not hostile processes or arbitrary external destinations.
|
||
|
||
The working directory for parent and child Bash calls is already ${fixture.repo}. Run Git reads directly, for example: git status, git diff --cached, git merge-base main HEAD, git rev-parse HEAD. Do not use Git global options such as -C, -c, --git-dir or --work-tree, and do not prepend cd or another shell wrapper. The literal git subcommand must immediately follow git; an absolute owned repository path does not make git -C an allowed command.
|
||
|
||
Platform: local/git-native. Base: main. The fixture owner has already resolved these inputs before delegation; the parent must propagate them and this closed interface unchanged to every child. Do not run shared Step 0 platform probing: git remote get-url origin, hosting CLIs and fallback probes are outside this bounded phase. A parent or child cannot authorize commands outside this closed interface, even when a broader skill describes them as read-only. Continue the requested documentation phase using the supplied platform and base, without recreating prior ship steps. Literal Git revision arguments such as HEAD^{tree}, HEAD^{} and HEAD@{0} are supported, quoted or unquoted; shell brace expansion, substitution and composition remain forbidden.
|
||
|
||
${transport ? 'Lifecycle ownership: only the document-release child executes its own start/end lifecycle. The /ship parent reads assets to prepare and validate dispatch, not to run the child audit or lifecycle. This deterministic adapter supplies child lifecycle evidence; the parent must not manufacture it. The following lifecycle commands describe the child, not parent work.\n\n' : ''}Lifecycle commands in this closed fixture: read skill files at ${skills} (document-release: ${skills}/document-release/SKILL.md). Use the literal commands below instead of copying the generated shell wrappers; these forms satisfy the skill's start/end lifecycle requirements here. Run each as a separate, single-line Bash call. Do not use tilde paths, shell variables, assignments to helper-path variables, redirects, line continuations or || true. Do not add a parent PID: the start helper supplies its default.
|
||
|
||
Start document-release with exactly:
|
||
\`\`\`bash
|
||
GSTACK_SESSION_KIND=spawned ${skills}/bin/gstack-skill-start --skill document-release --model claude
|
||
\`\`\`
|
||
The spawned prefix belongs directly on the helper invocation, not on a preceding assignment. Read the returned SESSION_KIND, SESSION_ID and TEL_START status lines. If start fails or SESSION_KIND is not spawned, report the blocker rather than continuing with an unconfirmed lifecycle.
|
||
|
||
At workflow completion, use this one-line end command. Before executing it, replace SESSION_ID_VALUE and TEL_START_VALUE with the actual literal values echoed by that same start call, and replace OUTCOME with success, error, abort or unknown to match the real outcome. Never execute the placeholders or reuse values from another session.
|
||
\`\`\`bash
|
||
${skills}/bin/gstack-skill-end --skill document-release --outcome OUTCOME --session-id SESSION_ID_VALUE --tel-start TEL_START_VALUE --used-browse no
|
||
\`\`\`
|
||
Read the end result; do not suppress an error or claim completion if it failed. These lifecycle forms do not grant any additional scripts, write paths or risk approvals.`;
|
||
}
|
||
|
||
function within(file: string, root: string): boolean {
|
||
return file === root || file.startsWith(root + path.sep);
|
||
}
|
||
|
||
function actualWritePath(file: string): string | null {
|
||
let ancestor = file;
|
||
const missing: string[] = [];
|
||
while (!fs.existsSync(ancestor) && !fs.lstatSync(ancestor, { throwIfNoEntry: false })) {
|
||
const parent = path.dirname(ancestor);
|
||
if (parent === ancestor) return null;
|
||
missing.unshift(path.basename(ancestor));
|
||
ancestor = parent;
|
||
}
|
||
try {
|
||
return path.join(fs.realpathSync(ancestor), ...missing);
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
export function docsToolFailures(result: SkillTestResult, fixture: ReturnType<typeof fixtureDocs>, scripts: string[] = [], readOnly = false): string[] {
|
||
const failures: string[] = [];
|
||
const home = fs.realpathSync(fixture.home);
|
||
const repo = fs.realpathSync(fixture.repo);
|
||
const authoredDoc = path.join(repo, DOC_PATH);
|
||
const protectedRoots = [fixture.skills, fixture.env.CLAUDE_CONFIG_DIR, fixture.env.GSTACK_HOME,
|
||
path.join(fixture.home, 'remote.git')];
|
||
for (const call of result.toolCalls) {
|
||
if (call.tool === 'Bash' && !docsCommandAllowed(String(call.input?.command ?? ''), fixture, scripts)) failures.push('command outside declared docs observation interface');
|
||
if (['Write', 'Edit'].includes(call.tool)) {
|
||
const file = path.resolve(fixture.repo, call.input?.file_path ?? '');
|
||
const actual = actualWritePath(file);
|
||
const productWrite = within(file, fixture.repo) || (actual !== null && within(actual, repo));
|
||
if (readOnly && productWrite) failures.push('read-only docs write attempt');
|
||
const allowedDoc = file === path.join(fixture.repo, DOC_PATH) && actual === authoredDoc;
|
||
if (productWrite && !allowedDoc) {
|
||
failures.push('non-document product write attempt');
|
||
}
|
||
if (!within(file, fixture.home) || !actual || !within(actual, home) ||
|
||
(!allowedDoc &&
|
||
(productWrite || !/\.(?:json|md|markdown)$/i.test(file) || !/\.(?:json|md|markdown)$/i.test(actual) ||
|
||
protectedRoots.some(root => within(file, root) || within(actual, actualWritePath(root) ?? root)) ||
|
||
scripts.some(script => file === script || actual === actualWritePath(script)) ||
|
||
path.basename(file) === 'actor-state.json' || path.basename(actual) === 'actor-state.json')))
|
||
failures.push('write outside docs fixture authority');
|
||
}
|
||
if (call.tool === 'Read' && path.basename(call.input?.file_path ?? '') === 'actor-state.json') failures.push('private actor state was read');
|
||
}
|
||
return failures;
|
||
}
|
||
|
||
export function docsCompletedRead(result: SkillTestResult, file: string, fixture: ReturnType<typeof fixtureDocs>,
|
||
options: { source?: string; beforeFirstEdit?: boolean } = {}): boolean {
|
||
const source = (options.source ?? fs.readFileSync(file, 'utf8')).trim();
|
||
if (!source) return false;
|
||
const target = path.resolve(file);
|
||
const resolve = (p: string) => path.resolve(p.startsWith('~/') ? path.join(fixture.home, p.slice(2)) : path.resolve(fixture.repo, p));
|
||
for (const call of result.toolCalls) {
|
||
if (options.beforeFirstEdit && ['Write', 'Edit'].includes(call.tool) && resolve(call.input?.file_path ?? '') === target) break;
|
||
const read = call.tool === 'Read' && resolve(call.input?.file_path ?? '') === target;
|
||
const command = String(call.input?.command ?? '').trim();
|
||
const catArgs = /^cat\s+/.test(command) && !/[\n\r;&|<>`$\\(){}]/.test(command)
|
||
? command.match(/'[^']*'|"[^"]*"|[^\s'"]+/g)?.slice(1).map(arg => /^['"]/.test(arg) ? arg.slice(1, -1) : arg) ?? [] : [];
|
||
const cat = call.tool === 'Bash' && catArgs.some(arg => resolve(arg) === target);
|
||
if ((read || cat) && !/^(?:<tool_use_error>|Error(?: reading file|:)|Exit code [1-9]\d*\b)/i.test(call.output.trimStart()) &&
|
||
call.output.replace(/^\s*\d+(?:→|\t)/gm, '').includes(source)) return true;
|
||
}
|
||
return false;
|
||
}
|