diff --git a/lib/qa-evidence.ts b/lib/qa-evidence.ts index efabc5c83..e32212410 100644 --- a/lib/qa-evidence.ts +++ b/lib/qa-evidence.ts @@ -138,12 +138,37 @@ function completeReceipts(root: string): Record[] { const completeCaptures = (root: string): string[] => completeReceipts(root).map(receipt => receipt.id); const latestCompleteCapture = (root: string): string | undefined => completeCaptures(root).at(-1); -/** Required native probes the caller declared (GSTACK_QA_REQUIRED_PROBES, a JSON array of child commands) that no complete capture has run yet. Informational only. */ +function latestMtimeMs(target: string): number { + let stat: fs.Stats; + try { stat = fs.lstatSync(target); } catch { return 0; } + if (!stat.isDirectory()) return stat.mtimeMs; + return Math.max(stat.mtimeMs, ...fs.readdirSync(target).map(name => latestMtimeMs(path.join(target, name)))); +} + +/** + * Earliest start time a capture needs to count toward required probes. The caller + * may declare GSTACK_QA_REQUIRED_AFTER_EDIT={"paths":[absolute paths],"since":ISO}: + * only captures started after the last edit under those paths count, and none + * count before the first edit after since. Without it every capture counts. + */ +function requiredBoundaryMs(): number { + let declared: unknown; + try { declared = JSON.parse(process.env.GSTACK_QA_REQUIRED_AFTER_EDIT ?? 'null'); } catch { return Infinity; } + if (declared === null) return -Infinity; + if (!object(declared) || !Array.isArray(declared.paths) || !declared.paths.every(item => typeof item === 'string' && path.isAbsolute(item)) + || !Number.isFinite(Date.parse(declared.since))) return Infinity; + const edited = Math.max(0, ...declared.paths.map(latestMtimeMs)); + return edited > Date.parse(declared.since) ? Math.floor(edited) : Infinity; +} + +/** Required native probes the caller declared (GSTACK_QA_REQUIRED_PROBES, a JSON array of child commands) that no counted complete capture has run yet. Informational only. */ function requiredRemaining(root: string): { requiredRemaining?: string[] } { let required: unknown; try { required = JSON.parse(process.env.GSTACK_QA_REQUIRED_PROBES ?? 'null'); } catch { return {}; } if (!Array.isArray(required) || !required.every(item => typeof item === 'string')) return {}; - const run = new Set(completeReceipts(root).map(receipt => Array.isArray(receipt.argv) ? receipt.argv.join(' ') : '')); + const boundary = requiredBoundaryMs(); + const run = new Set(completeReceipts(root).filter(receipt => Date.parse(receipt.startedAt) >= boundary) + .map(receipt => Array.isArray(receipt.argv) ? receipt.argv.join(' ') : '')); return { requiredRemaining: required.filter(command => !run.has(command)) }; } diff --git a/test/helpers/qa-functional-eval.ts b/test/helpers/qa-functional-eval.ts index 1f0fe0759..b21aab9e2 100644 --- a/test/helpers/qa-functional-eval.ts +++ b/test/helpers/qa-functional-eval.ts @@ -104,6 +104,7 @@ export async function runQAFunctionalCase(entry: { id: string; family: QAFamily; fixtureGit(fixture.root, ['commit', '-m', 'Bind current QA instructions to fixture']); fixture.revision = fixtureGit(fixture.root, ['rev-parse', 'HEAD']); if (fixtureGit(fixture.root, ['status', '--porcelain'])) throw new Error('QA fixture must start clean'); + const fixtureCleanAt = new Date().toISOString(); observer = await observeQAWrites(fixture.root, { evidenceProducer: true, atomicWriteMode: entry.mode }); await runRecordedOfficeHoursAttempt({ collector, name: entry.id, suite: 'Functional QA native E2E', @@ -117,7 +118,8 @@ export async function runQAFunctionalCase(entry: { id: string; family: QAFamily; timeout, completionReserveMs: timeout / 4, testName: entry.id, runId, signal, env: { CLAUDE_CONFIG_DIR: fixture.config, GIT_OPTIONAL_LOCKS: '0', QA_STATE_ROOT: path.join(fixture.root, '.qa-state'), - ...(qaFunctionalRequiredProbes(entry) ? { GSTACK_QA_REQUIRED_PROBES: JSON.stringify(qaFunctionalRequiredProbes(entry)) } : {}) }, + ...(qaFunctionalRequiredProbes(entry) ? { GSTACK_QA_REQUIRED_PROBES: JSON.stringify(qaFunctionalRequiredProbes(entry)) } : {}), + ...(entry.mode === 'qa' && qaFunctionalRequiredProbes(entry) ? { GSTACK_QA_REQUIRED_AFTER_EDIT: JSON.stringify({ paths: [path.join(fixture.root, 'src')], since: fixtureCleanAt }) } : {}) }, }); return result; }, diff --git a/test/qa-evidence.test.ts b/test/qa-evidence.test.ts index 83d42805c..3707751d8 100644 --- a/test/qa-evidence.test.ts +++ b/test/qa-evidence.test.ts @@ -523,6 +523,35 @@ test('captures list declared-but-unrun required probes without judging them', () expect(receipt(result.stdout).requiredRemaining).toEqual(['bun run probe -- reject']); }); +test('post-repair required probes count only captures started after the last fixture-declared source edit', () => { + const f = fixture(); + const src = path.join(f.root, 'src'); + fs.mkdirSync(src); + fs.writeFileSync(path.join(src, 'worker.ts'), 'export const effect = 1;\n'); + const old = new Date(Date.now() - 60_000); + fs.utimesSync(path.join(src, 'worker.ts'), old, old); + fs.utimesSync(src, old, old); + const probe = (program: string) => [process.execPath, '-e', program]; + const [duplicate, partial] = [probe('console.log(JSON.stringify({scenario:"duplicate"}))'), probe('console.log(JSON.stringify({scenario:"partial"}))')]; + const env = { ...process.env, GSTACK_QA_REQUIRED_PROBES: JSON.stringify([duplicate.join(' '), partial.join(' ')]), + GSTACK_QA_REQUIRED_AFTER_EDIT: JSON.stringify({ paths: [src], since: new Date(Date.now() - 30_000).toISOString() }) }; + const capture = (captureId: string, argv: string[]) => { + const after = captureId === '001' ? [] : ['--after', String(Number(captureId) - 1).padStart(3, '0'), '--hypothesis', 'The previous scenario result shows which invariant the next probe must check.']; + const result = spawnSync(process.execPath, [CLI, 'capture', f.root, captureId, '--timeout-ms', '4000', ...after, '--', ...argv], { cwd: f.root, encoding: 'utf8', timeout: 10_000, env }); + expect(result.status, result.stderr).toBe(0); + return receipt(result.stdout).requiredRemaining as string[]; + }; + expect(capture('001', duplicate)).toEqual([duplicate.join(' '), partial.join(' ')]); + expect(capture('002', partial)).toEqual([duplicate.join(' '), partial.join(' ')]); + fs.writeFileSync(path.join(src, 'worker.ts'), 'export const effect = 2;\n'); + expect(capture('003', duplicate)).toEqual([partial.join(' ')]); + expect(capture('004', partial)).toEqual([]); + const secondEdit = new Date(Date.now() + 60_000); + fs.writeFileSync(path.join(src, 'worker.ts'), 'export const effect = 3;\n'); + fs.utimesSync(path.join(src, 'worker.ts'), secondEdit, secondEdit); + expect(capture('005', probe('console.log(JSON.stringify({scenario:"happy"}))'))).toEqual([duplicate.join(' '), partial.join(' ')]); +}); + test('materialize accepts a single limits string as a one-item list', () => { const f = fixture(); expect(f.capture('001', 'console.log(JSON.stringify({ step: 1 }))').status).toBe(0);