Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "zest-dev",
"version": "1.0.11",
"version": "1.0.12",
"description": "A lightweight, human-interactive development workflow for AI-assisted coding",
"author": {
"name": "nettee",
Expand Down
28 changes: 28 additions & 0 deletions scripts/ci/archive-old-specs.js
Original file line number Diff line number Diff line change
Expand Up @@ -100,8 +100,34 @@ function writeGitHubOutput(result) {
fs.appendFileSync(process.env.GITHUB_OUTPUT, `archive_issue_lines<<EOF\n${issueLines}\nEOF\n`);
}

function commandErrorDetails(error) {
const details = [error && error.stderr, error && error.stdout, error && error.message]
.map(value => {
if (Buffer.isBuffer(value)) return value.toString('utf8').trim();
return typeof value === 'string' ? value.trim() : '';
})
.filter(Boolean);

return details[0] || 'unknown error';
}

function preflightSpecs(specIds, { specsDir = DEFAULT_SPECS_DIR, runner = execFileSync } = {}) {
// Date-named directories are candidates by contract; invalid candidates are
// rejected here instead of being silently skipped or partially archived.
for (const specId of specIds) {
try {
runner('zest-dev', ['dump', specId, '--dry-run'], { encoding: 'utf8' });
} catch (error) {
const candidatePath = path.join(specsDir, specId);
throw new Error(`Archive preflight failed for ${candidatePath}: ${commandErrorDetails(error)}`);
}
}
}

function archiveSpecs({ specsDir = DEFAULT_SPECS_DIR, now = new Date(), limit = 10, maxAgeDays = 10, runner = execFileSync, postDumpIssueLookupAttempts = POST_DUMP_ISSUE_LOOKUP_ATTEMPTS, postDumpIssueLookupDelayMs = POST_DUMP_ISSUE_LOOKUP_DELAY_MS, sleep = sleepMs } = {}) {
const specIds = selectSpecsToArchive({ specsDir, now, limit, maxAgeDays });
preflightSpecs(specIds, { specsDir, runner });

const archived = [];
const skippedExistingIssue = [];
const associatedIssues = [];
Expand Down Expand Up @@ -148,11 +174,13 @@ if (require.main === module) {
module.exports = {
archiveIssueExists,
archiveSpecs,
commandErrorDetails,
cutoffTimestamp,
findArchiveIssue,
findArchiveIssueWithRetry,
formatIssueLine,
listEarliestSpecs,
preflightSpecs,
parseUtcDatePrefix,
selectSpecsToArchive
};
59 changes: 52 additions & 7 deletions scripts/ci/test-archive-old-specs.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ function noExistingArchiveRunner(calls = []) {
if ([...dumpedSpecs].some(specId => args[5].includes(specId))) return '[{"number":456}]';
return '[]';
}
if (cmd === 'zest-dev' && args[0] === 'dump') dumpedSpecs.add(args[1]);
if (cmd === 'zest-dev' && args[0] === 'dump' && !args.includes('--dry-run')) dumpedSpecs.add(args[1]);
return '';
};
}
Expand All @@ -30,6 +30,12 @@ function makeSpec(specsDir, specId) {
fs.writeFileSync(path.join(dir, 'spec.md'), '# Test\n');
}

function makeInvalidSpecDirectory(specsDir, specId) {
const dir = path.join(specsDir, specId);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'notes.md'), '# Runbook\n');
}

function fixture() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'zest-archive-specs-'));
const specsDir = path.join(root, 'specs', 'change');
Expand Down Expand Up @@ -86,22 +92,57 @@ function testDeletesOnlyAfterSuccessfulDump() {
if (args[5].includes('20260601-ok') && dumpedSpecs.has('20260601-ok')) return '[{"number":456}]';
return '[]';
}
if (cmd === 'zest-dev' && args[1] === '20260602-fails') {
if (cmd === 'zest-dev' && args[1] === '20260602-fails' && !args.includes('--dry-run')) {
throw new Error('dump failed');
}
if (cmd === 'zest-dev' && args[0] === 'dump') dumpedSpecs.add(args[1]);
if (cmd === 'zest-dev' && args[0] === 'dump' && !args.includes('--dry-run')) dumpedSpecs.add(args[1]);
return '';
}
}), /dump failed/);

assert.deepStrictEqual(
calls.filter(call => call.cmd === 'zest-dev').map(call => call.args),
[['dump', '20260601-ok'], ['dump', '20260602-fails']]
[
['dump', '20260601-ok', '--dry-run'],
['dump', '20260602-fails', '--dry-run'],
['dump', '20260601-ok'],
['dump', '20260602-fails']
]
);
assert.strictEqual(fs.existsSync(path.join(specsDir, '20260601-ok')), false);
assert.strictEqual(fs.existsSync(path.join(specsDir, '20260602-fails')), true);
}

function testPreflightRejectsMixedValidAndInvalidBatchWithoutSideEffects() {
const { specsDir } = fixture();
makeSpec(specsDir, '20260601-valid');
makeInvalidSpecDirectory(specsDir, '20260602-runbook');
const calls = [];

assert.throws(() => archiveSpecs({
specsDir,
now: new Date('2026-07-04T00:00:00Z'),
runner: (cmd, args) => {
calls.push({ cmd, args });
if (cmd === 'zest-dev' && args[1] === '20260602-runbook') {
throw new Error('Issue Spec Representation requires spec.md');
}
if (cmd === 'gh') throw new Error('GitHub must not be touched during preflight');
return '';
}
}), /specs[\\/]change[\\/]20260602-runbook: Issue Spec Representation requires spec\.md/);

assert.deepStrictEqual(
calls.map(call => call.args),
[
['dump', '20260601-valid', '--dry-run'],
['dump', '20260602-runbook', '--dry-run']
]
);
assert.strictEqual(fs.existsSync(path.join(specsDir, '20260601-valid')), true);
assert.strictEqual(fs.existsSync(path.join(specsDir, '20260602-runbook')), true);
}

function testExistingArchiveIssueDeletesWithoutDumpingAgain() {
const { specsDir } = fixture();
makeSpec(specsDir, '20260601-already-archived');
Expand All @@ -115,6 +156,7 @@ function testExistingArchiveIssueDeletesWithoutDumpingAgain() {
if (cmd === 'gh' && args[0] === 'issue' && args[1] === 'list') {
return '[{"number":123}]';
}
if (cmd === 'zest-dev' && args[0] === 'dump' && args.includes('--dry-run')) return '';
throw new Error(`unexpected command: ${cmd} ${args.join(' ')}`);
}
});
Expand All @@ -124,7 +166,8 @@ function testExistingArchiveIssueDeletesWithoutDumpingAgain() {
skippedExistingIssue: ['20260601-already-archived'],
associatedIssues: [{ specId: '20260601-already-archived', issueNumber: 123 }]
});
assert.strictEqual(calls.length, 1);
assert.strictEqual(calls.length, 2);
assert.deepStrictEqual(calls[0].args, ['dump', '20260601-already-archived', '--dry-run']);
assert.strictEqual(fs.existsSync(path.join(specsDir, '20260601-already-archived')), false);
}

Expand Down Expand Up @@ -159,7 +202,8 @@ function testRetriesIssueLookupAfterDumpUntilSearchCatchesUp() {
lookups += 1;
return lookups < 3 ? '[]' : '[{"number":789}]';
}
if (cmd === 'zest-dev' && args[0] === 'dump') { dumped = true; return ''; }
if (cmd === 'zest-dev' && args[0] === 'dump' && !args.includes('--dry-run')) { dumped = true; return ''; }
if (cmd === 'zest-dev' && args[0] === 'dump' && args.includes('--dry-run')) return '';
throw new Error(`unexpected command: ${cmd}`);
} });
assert.deepStrictEqual(result.associatedIssues, [{ specId: '20260601-delayed-index', issueNumber: 789 }]);
Expand Down Expand Up @@ -187,14 +231,15 @@ function testRunsGlobalZestDevDump() {

assert.deepStrictEqual(
calls.filter(call => call.cmd === 'zest-dev').map(call => call.args),
[['dump', '20260601-eligible']]
[['dump', '20260601-eligible', '--dry-run'], ['dump', '20260601-eligible']]
);
}

function main() {
testListsEarliestTenAndIgnoresActiveSymlinkEntry();
testSelectsOnlyMoreThanTenDaysOld();
testDeletesOnlyAfterSuccessfulDump();
testPreflightRejectsMixedValidAndInvalidBatchWithoutSideEffects();
testExistingArchiveIssueDeletesWithoutDumpingAgain();
testRecordsIssueCreatedByDump();
testFailsWhenDumpDoesNotCreateArchiveIssue();
Expand Down
Loading