From a8e0281d41c9955d8d8a7a17d738f48976a6aabc Mon Sep 17 00:00:00 2001 From: autogame-17 <17@evomap.ai> Date: Tue, 20 Oct 2026 22:03:42 +0800 Subject: [PATCH 1/3] fix: fail closed on incomplete GitHub checks Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/validate.yml | 2 + scripts/check-links.js | 40 ++++-- scripts/monitor-github.js | 37 +++++- scripts/sync-link-cleanup-issue.js | 34 +++-- scripts/test-fail-closed.js | 198 +++++++++++++++++++++++++++++ 5 files changed, 286 insertions(+), 25 deletions(-) create mode 100755 scripts/test-fail-closed.js diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index c761720..8538b4f 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -29,6 +29,8 @@ jobs: node-version: '24' - name: Validate curated data and generated README run: node scripts/validate.js + - name: Test failure-closed automation + run: node scripts/test-fail-closed.js - name: Confirm generated README is clean run: | node scripts/generate-readme.js diff --git a/scripts/check-links.js b/scripts/check-links.js index 57a8db3..ddd014f 100644 --- a/scripts/check-links.js +++ b/scripts/check-links.js @@ -15,21 +15,32 @@ const fs = require('fs'); const path = require('path'); const PROJECTS_PATH = path.join(__dirname, '..', 'data', 'projects.json'); +const FAIL_STATUSES = new Set(['not_found', 'archived', 'disabled']); + +function errorMessage(error) { + const stderr = error.stderr ? String(error.stderr).trim() : ''; + const message = stderr || String(error.message || 'Unknown GitHub API error').trim(); + return message.split('\n')[0]; +} function checkRepo(repo) { try { - const result = JSON.parse(execFileSync('gh', ['api', `repos/${repo}`, '--jq', '{archived: .archived, disabled: .disabled, full_name: .full_name}'], { encoding: 'utf-8', timeout: 15000 })); + const output = execFileSync('gh', ['api', `repos/${repo}`, '--jq', '{archived: .archived, disabled: .disabled, full_name: .full_name}'], { encoding: 'utf-8', timeout: 15000 }); + const result = JSON.parse(output); + if (!result || typeof result !== 'object' || typeof result.full_name !== 'string') { + return { status: 'error', repo, error: 'Invalid response from GitHub API' }; + } if (result.archived) return { status: 'archived', repo }; if (result.disabled) return { status: 'disabled', repo }; if (result.full_name !== repo) return { status: 'redirected', repo, target: result.full_name }; return { status: 'ok', repo }; - } catch (e) { - const msg = e.message || ''; - if (msg.includes('404') || msg.includes('Not Found')) { + } catch (error) { + const message = errorMessage(error); + if (/(?:HTTP\s+404\b|\b404:\s*Not Found\b)/i.test(message)) { return { status: 'not_found', repo }; } - return { status: 'error', repo, error: msg.split('\n')[0] }; + return { status: 'error', repo, error: message }; } } @@ -60,11 +71,11 @@ function main() { } } - const FAIL_STATUSES = new Set(['not_found', 'archived', 'disabled']); - const failures = issues.filter(i => FAIL_STATUSES.has(i.status)); - const warnings = issues.filter(i => !FAIL_STATUSES.has(i.status)); + const failures = issues.filter(issue => FAIL_STATUSES.has(issue.status)); + const warnings = issues.filter(issue => issue.status === 'redirected'); + const validationErrors = issues.filter(issue => issue.status === 'error'); - console.log(`\nResults: ${ok} ok, ${failures.length} failures, ${warnings.length} warnings`); + console.log(`\nResults: ${ok} ok, ${failures.length} failures, ${warnings.length} warnings, ${validationErrors.length} validation errors`); const issuesOut = process.env.LINK_ISSUES_OUT; if (issuesOut) { @@ -80,12 +91,23 @@ function main() { } } + if (validationErrors.length > 0) { + console.log('\nValidation errors (link validation incomplete):'); + for (const issue of validationErrors) { + console.log(` [${issue.status}] ${issue.name} (${issue.repo})`); + if (issue.error) console.log(` -> ${issue.error}`); + } + } + if (failures.length > 0) { console.log('\nFailures:'); for (const issue of failures) { console.log(` [${issue.status}] ${issue.name} (${issue.repo})`); if (issue.error) console.log(` -> ${issue.error}`); } + } + + if (failures.length > 0 || validationErrors.length > 0) { process.exit(1); } } diff --git a/scripts/monitor-github.js b/scripts/monitor-github.js index 60c40a3..2153e60 100755 --- a/scripts/monitor-github.js +++ b/scripts/monitor-github.js @@ -51,6 +51,12 @@ function parseArgs() { return config; } +function errorMessage(error) { + const stderr = error.stderr ? String(error.stderr).trim() : ''; + const message = stderr || String(error.message || 'Unknown GitHub API error').trim(); + return message.split('\n')[0]; +} + function ghSearch(query, maxAgeDays = 7) { const since = new Date(); since.setDate(since.getDate() - maxAgeDays); @@ -61,10 +67,15 @@ function ghSearch(query, maxAgeDays = 7) { try { const cmd = `gh api search/issues --method GET -f q="${fullQuery}" -f sort=created -f order=desc -f per_page=${MAX_RESULTS_PER_QUERY} --jq '.items'`; const result = execSync(cmd, { encoding: 'utf-8', timeout: 30000 }); - return JSON.parse(result || '[]'); - } catch (e) { - console.error(`Search failed for "${query}": ${e.message}`); - return []; + const items = JSON.parse(result || '[]'); + if (!Array.isArray(items)) { + throw new Error('Invalid GitHub search response: expected an array'); + } + return { items }; + } catch (error) { + const message = errorMessage(error); + console.error(`Search failed for "${query}": ${message}`); + return { error: message }; } } @@ -135,15 +146,29 @@ function main() { console.log(`Monitoring GitHub for relevant discussions (max age: ${config.maxAgeDays} days)`); let allItems = []; + const searchFailures = []; for (const query of SEARCH_QUERIES) { process.stdout.write(` Searching: "${query}" ... `); - const items = ghSearch(query, config.maxAgeDays); - const filtered = filterResults(items); + const result = ghSearch(query, config.maxAgeDays); + if (result.error) { + console.log('FAILED'); + searchFailures.push({ query, error: result.error }); + continue; + } + const filtered = filterResults(result.items); console.log(`${filtered.length} results`); allItems.push(...filtered); } + if (searchFailures.length > 0) { + console.error(`\nMonitor incomplete: ${searchFailures.length}/${SEARCH_QUERIES.length} search query(ies) failed. Previous output was not modified.`); + for (const failure of searchFailures) { + console.error(` ${failure.query}: ${failure.error}`); + } + process.exit(1); + } + const unique = dedup(allItems); const ranked = formatForReview(unique); diff --git a/scripts/sync-link-cleanup-issue.js b/scripts/sync-link-cleanup-issue.js index 382725b..7eed30e 100644 --- a/scripts/sync-link-cleanup-issue.js +++ b/scripts/sync-link-cleanup-issue.js @@ -6,11 +6,10 @@ * close the open queue issue when the report is empty. * * Reads the issues array written by scripts/check-links.js (path comes from - * env LINK_ISSUES_FILE, defaults to /tmp/link-issues.json). A missing file - * or empty array is treated as a green report: any open auto-link-cleanup - * issue whose title matches the auto queue is closed. Unrelated human - * issues are never closed. Transient `error` statuses stay on the - * create/update path and are listed as API errors, not as missing repos. + * env LINK_ISSUES_FILE, defaults to /tmp/link-issues.json). A verified empty + * array closes an open auto-link-cleanup issue. Missing, malformed, or + * incomplete reports refuse all external mutations so a transient scan cannot + * close or refresh a cleanup issue. * * Requires: gh CLI authenticated; repo permissions issues: write. */ @@ -23,6 +22,7 @@ const LABEL_COLOR = 'fbca04'; const LABEL_DESC = 'Automated repository link cleanup queue'; const ISSUE_FILE = process.env.LINK_ISSUES_FILE || '/tmp/link-issues.json'; const CLEANUP_TITLE_RE = /^\[auto\] Repository link cleanup /i; +const KNOWN_STATUSES = new Set(['not_found', 'archived', 'disabled', 'redirected', 'error']); function gh(args, opts = {}) { return execFileSync('gh', args, { encoding: 'utf-8', stdio: opts.stdio ?? ['ignore', 'pipe', 'inherit'] }); @@ -123,12 +123,20 @@ function closeComment(url) { function loadIssues() { if (!fs.existsSync(ISSUE_FILE)) { - console.log('No issues file at ' + ISSUE_FILE + ', treating as 0 link issues.'); - return []; + console.error('No issues file at ' + ISSUE_FILE + '; refusing cleanup issue mutations without a completed link scan.'); + process.exit(1); } - const issues = JSON.parse(fs.readFileSync(ISSUE_FILE, 'utf-8')); - if (!Array.isArray(issues)) { - console.error('Issues file is not an array: ' + ISSUE_FILE); + + let issues; + try { + issues = JSON.parse(fs.readFileSync(ISSUE_FILE, 'utf-8')); + } catch (error) { + console.error('Could not read issues file ' + ISSUE_FILE + '; refusing cleanup issue mutations: ' + error.message); + process.exit(1); + } + + if (!Array.isArray(issues) || issues.some(issue => !issue || !KNOWN_STATUSES.has(issue.status))) { + console.error('Issues file is not a valid completed link report: ' + ISSUE_FILE); process.exit(1); } return issues; @@ -193,6 +201,12 @@ function createOrUpdateIssue(issues, url) { function main() { const issues = loadIssues(); + const incomplete = issues.filter(issue => issue.status === 'error'); + if (incomplete.length > 0) { + console.error('Link scan is incomplete (' + incomplete.length + ' API error(s)); refusing cleanup issue mutations.'); + process.exit(1); + } + const url = runUrl(); if (issues.length === 0) { diff --git a/scripts/test-fail-closed.js b/scripts/test-fail-closed.js new file mode 100755 index 0000000..39f0dfb --- /dev/null +++ b/scripts/test-fail-closed.js @@ -0,0 +1,198 @@ +#!/usr/bin/env node + +const assert = require('assert'); +const { spawnSync } = require('child_process'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const ROOT = path.resolve(__dirname, '..'); +let passed = 0; +let failed = 0; + +function test(name, fn) { + try { + fn(); + passed++; + console.log(`PASS ${name}`); + } catch (error) { + failed++; + console.error(`FAIL ${name}: ${error.stack || error.message}`); + } +} + +function makeWorkspace() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'awesome-fail-closed-')); + const bin = path.join(dir, 'bin'); + fs.mkdirSync(bin); + const shim = path.join(bin, 'gh'); + fs.writeFileSync(shim, `#!/usr/bin/env node +const fs = require('fs'); +const path = require('path'); +const args = process.argv.slice(2); +const scenario = process.env.GH_SHIM_SCENARIO || 'default'; +const statePath = process.env.GH_SHIM_STATE; +const logPath = process.env.GH_SHIM_LOG; +let state = { count: 0 }; +if (statePath && fs.existsSync(statePath)) state = JSON.parse(fs.readFileSync(statePath, 'utf8')); +state.count += 1; +if (statePath) fs.writeFileSync(statePath, JSON.stringify(state)); +if (logPath) fs.appendFileSync(logPath, JSON.stringify(args) + '\\n'); +const count = state.count; +function fail(message) { process.stderr.write(message + '\\n'); process.exit(1); } +function repo() { return (args.find(arg => arg.startsWith('repos/')) || 'repos/unknown').slice(6); } +function okRepo(fullName = repo()) { process.stdout.write(JSON.stringify({ archived: false, disabled: false, full_name: fullName }) + '\\n'); } +function emptySearch() { process.stdout.write('[]\\n'); } +function hang() { setTimeout(() => process.exit(0), 60000); } +if (scenario === 'check-ok') okRepo(); +else if (scenario === 'check-redirect') count === 1 ? okRepo('CanonicalOrg/canonical-repository') : okRepo(); +else if (scenario === 'check-mixed') { + if (count === 2) fail('HTTP 404: Not Found'); + if (count === 3) fail('HTTP 502: Bad Gateway'); + okRepo(); +} else if (scenario === 'check-auth') fail('HTTP 401: Bad credentials'); +else if (scenario === 'check-timeout') count === 1 ? hang() : okRepo(); +else if (scenario === 'check-invalid') count === 1 ? process.stdout.write('{not-json}\\n') : okRepo(); +else if (scenario === 'monitor-empty') emptySearch(); +else if (scenario === 'monitor-mixed') count === 2 ? fail('HTTP 502: Bad Gateway') : emptySearch(); +else if (scenario === 'monitor-auth') fail('HTTP 401: Bad credentials'); +else if (scenario === 'monitor-timeout') count === 1 ? hang() : emptySearch(); +else if (scenario === 'monitor-invalid') count === 1 ? process.stdout.write('{not-json}\\n') : emptySearch(); +else emptySearch(); +`); + fs.chmodSync(shim, 0o755); + return { dir, bin, statePath: path.join(dir, 'state.json'), logPath: path.join(dir, 'gh.log') }; +} + +function run(workspace, script, args = [], env = {}, timeout = 90000) { + const result = spawnSync(process.execPath, [path.join(ROOT, script), ...args], { + cwd: ROOT, + encoding: 'utf8', + timeout, + env: { + ...process.env, + ...env, + GH_SHIM_STATE: workspace.statePath, + GH_SHIM_LOG: workspace.logPath, + PATH: workspace.bin + path.delimiter + process.env.PATH, + }, + }); + if (result.error) throw result.error; + return result; +} + +function withWorkspace(fn) { + const workspace = makeWorkspace(); + try { + fn(workspace); + } finally { + fs.rmSync(workspace.dir, { recursive: true, force: true }); + } +} + +function reportAt(file) { + return JSON.parse(fs.readFileSync(file, 'utf8')); +} + +function ghCalls(workspace) { + return fs.existsSync(workspace.logPath) ? fs.readFileSync(workspace.logPath, 'utf8').trim() : ''; +} + +test('check-links accepts a fully verified catalog', () => withWorkspace(workspace => { + const output = path.join(workspace.dir, 'issues.json'); + const result = run(workspace, 'scripts/check-links.js', [], { GH_SHIM_SCENARIO: 'check-ok', LINK_ISSUES_OUT: output }); + assert.strictEqual(result.status, 0, result.stderr); + assert.deepStrictEqual(reportAt(output), []); + assert.match(result.stdout, /0 failures, 0 warnings, 0 validation errors/); +})); + +test('check-links keeps a canonical redirect nonfatal', () => withWorkspace(workspace => { + const output = path.join(workspace.dir, 'issues.json'); + const result = run(workspace, 'scripts/check-links.js', [], { GH_SHIM_SCENARIO: 'check-redirect', LINK_ISSUES_OUT: output }); + assert.strictEqual(result.status, 0, result.stderr); + const issues = reportAt(output); + assert.strictEqual(issues.length, 1); + assert.strictEqual(issues[0].status, 'redirected'); +})); + +test('check-links fails closed for mixed missing and transient results', () => withWorkspace(workspace => { + const output = path.join(workspace.dir, 'issues.json'); + const result = run(workspace, 'scripts/check-links.js', [], { GH_SHIM_SCENARIO: 'check-mixed', LINK_ISSUES_OUT: output }); + assert.notStrictEqual(result.status, 0); + const statuses = reportAt(output).map(issue => issue.status); + assert.ok(statuses.includes('not_found')); + assert.ok(statuses.includes('error')); + assert.match(result.stdout, /validation errors/); +})); + +test('check-links fails closed for authentication errors without calling them missing projects', () => withWorkspace(workspace => { + const output = path.join(workspace.dir, 'issues.json'); + const result = run(workspace, 'scripts/check-links.js', [], { GH_SHIM_SCENARIO: 'check-auth', LINK_ISSUES_OUT: output }); + assert.notStrictEqual(result.status, 0); + const issues = reportAt(output); + assert.ok(issues.length > 0); + assert.ok(issues.every(issue => issue.status === 'error')); + assert.doesNotMatch(result.stdout, /NOT_FOUND/); +})); + +test('check-links fails closed after a real child-process timeout', () => withWorkspace(workspace => { + const output = path.join(workspace.dir, 'issues.json'); + const result = run(workspace, 'scripts/check-links.js', [], { GH_SHIM_SCENARIO: 'check-timeout', LINK_ISSUES_OUT: output }, 40000); + assert.notStrictEqual(result.status, 0); + assert.ok(reportAt(output).some(issue => issue.status === 'error')); +})); + +test('check-links fails closed for an invalid GitHub payload', () => withWorkspace(workspace => { + const output = path.join(workspace.dir, 'issues.json'); + const result = run(workspace, 'scripts/check-links.js', [], { GH_SHIM_SCENARIO: 'check-invalid', LINK_ISSUES_OUT: output }); + assert.notStrictEqual(result.status, 0); + assert.ok(reportAt(output).some(issue => issue.status === 'error')); +})); + +test('monitor writes a verified empty search result', () => withWorkspace(workspace => { + const output = path.join(workspace.dir, 'monitor.json'); + const result = run(workspace, 'scripts/monitor-github.js', ['--output', output], { GH_SHIM_SCENARIO: 'monitor-empty' }); + assert.strictEqual(result.status, 0, result.stderr); + const monitor = reportAt(output); + assert.strictEqual(monitor.query_count, 8); + assert.strictEqual(monitor.total_results, 0); + assert.deepStrictEqual(monitor.items, []); +})); + +for (const scenario of ['monitor-mixed', 'monitor-auth', 'monitor-timeout', 'monitor-invalid']) { + test(`monitor preserves its previous snapshot on ${scenario.replace('monitor-', '')} failure`, () => withWorkspace(workspace => { + const output = path.join(workspace.dir, 'monitor.json'); + const previous = '{"previous":true}\n'; + fs.writeFileSync(output, previous); + const result = run(workspace, 'scripts/monitor-github.js', ['--output', output], { GH_SHIM_SCENARIO: scenario }, 70000); + assert.notStrictEqual(result.status, 0); + assert.strictEqual(fs.readFileSync(output, 'utf8'), previous); + assert.match(result.stderr, /Previous output was not modified/); + })); +} + +test('cleanup issue sync refuses an incomplete report without invoking gh', () => withWorkspace(workspace => { + const report = path.join(workspace.dir, 'issues.json'); + fs.writeFileSync(report, JSON.stringify([{ name: 'Example', repo: 'example/repo', status: 'error', error: 'HTTP 502: Bad Gateway' }])); + const result = run(workspace, 'scripts/sync-link-cleanup-issue.js', [], { LINK_ISSUES_FILE: report, LINK_ISSUE_DRY_RUN: '1' }); + assert.notStrictEqual(result.status, 0); + assert.strictEqual(ghCalls(workspace), ''); + assert.match(result.stderr, /refusing cleanup issue mutations/); +})); + +test('cleanup issue sync rejects a missing completion report without invoking gh', () => withWorkspace(workspace => { + const report = path.join(workspace.dir, 'missing.json'); + const result = run(workspace, 'scripts/sync-link-cleanup-issue.js', [], { LINK_ISSUES_FILE: report, LINK_ISSUE_DRY_RUN: '1' }); + assert.notStrictEqual(result.status, 0); + assert.strictEqual(ghCalls(workspace), ''); +})); + +test('cleanup issue sync accepts a verified empty report', () => withWorkspace(workspace => { + const report = path.join(workspace.dir, 'issues.json'); + fs.writeFileSync(report, '[]\n'); + const result = run(workspace, 'scripts/sync-link-cleanup-issue.js', [], { LINK_ISSUES_FILE: report, LINK_ISSUE_DRY_RUN: '1' }); + assert.strictEqual(result.status, 0, result.stderr); +})); + +console.log(`\n${passed} passed, ${failed} failed`); +if (failed > 0) process.exit(1); From 345973c93b5b8a2f068bcde0f68122a8b1b30fce Mon Sep 17 00:00:00 2001 From: autogame-17 <17@evomap.ai> Date: Tue, 20 Oct 2026 22:14:04 +0800 Subject: [PATCH 2/3] test: cover malformed cleanup reports Co-Authored-By: Claude Sonnet 5.5 --- scripts/test-fail-closed.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/scripts/test-fail-closed.js b/scripts/test-fail-closed.js index 39f0dfb..25e6685 100755 --- a/scripts/test-fail-closed.js +++ b/scripts/test-fail-closed.js @@ -187,6 +187,14 @@ test('cleanup issue sync rejects a missing completion report without invoking gh assert.strictEqual(ghCalls(workspace), ''); })); +test('cleanup issue sync rejects a malformed report without invoking gh', () => withWorkspace(workspace => { + const report = path.join(workspace.dir, 'issues.json'); + fs.writeFileSync(report, '{not-json}\n'); + const result = run(workspace, 'scripts/sync-link-cleanup-issue.js', [], { LINK_ISSUES_FILE: report, LINK_ISSUE_DRY_RUN: '1' }); + assert.notStrictEqual(result.status, 0); + assert.strictEqual(ghCalls(workspace), ''); +})); + test('cleanup issue sync accepts a verified empty report', () => withWorkspace(workspace => { const report = path.join(workspace.dir, 'issues.json'); fs.writeFileSync(report, '[]\n'); From 945e720090d6c9624678f92cd2d45e7c37123dcc Mon Sep 17 00:00:00 2001 From: autogame-17 <17@evomap.ai> Date: Tue, 20 Oct 2026 22:23:10 +0800 Subject: [PATCH 3/3] fix: validate complete GitHub responses Co-Authored-By: Claude Sonnet 5.5 --- scripts/check-links.js | 9 ++++++++- scripts/monitor-github.js | 2 +- scripts/test-fail-closed.js | 26 +++++++++++++++++++++++++- 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/scripts/check-links.js b/scripts/check-links.js index ddd014f..d3a94b7 100644 --- a/scripts/check-links.js +++ b/scripts/check-links.js @@ -28,7 +28,14 @@ function checkRepo(repo) { const output = execFileSync('gh', ['api', `repos/${repo}`, '--jq', '{archived: .archived, disabled: .disabled, full_name: .full_name}'], { encoding: 'utf-8', timeout: 15000 }); const result = JSON.parse(output); - if (!result || typeof result !== 'object' || typeof result.full_name !== 'string') { + if ( + !result || + typeof result !== 'object' || + typeof result.archived !== 'boolean' || + typeof result.disabled !== 'boolean' || + typeof result.full_name !== 'string' || + !/^[^/\s]+\/[^/\s]+$/.test(result.full_name) + ) { return { status: 'error', repo, error: 'Invalid response from GitHub API' }; } if (result.archived) return { status: 'archived', repo }; diff --git a/scripts/monitor-github.js b/scripts/monitor-github.js index 2153e60..66c1583 100755 --- a/scripts/monitor-github.js +++ b/scripts/monitor-github.js @@ -67,7 +67,7 @@ function ghSearch(query, maxAgeDays = 7) { try { const cmd = `gh api search/issues --method GET -f q="${fullQuery}" -f sort=created -f order=desc -f per_page=${MAX_RESULTS_PER_QUERY} --jq '.items'`; const result = execSync(cmd, { encoding: 'utf-8', timeout: 30000 }); - const items = JSON.parse(result || '[]'); + const items = JSON.parse(result); if (!Array.isArray(items)) { throw new Error('Invalid GitHub search response: expected an array'); } diff --git a/scripts/test-fail-closed.js b/scripts/test-fail-closed.js index 25e6685..31d85d8 100755 --- a/scripts/test-fail-closed.js +++ b/scripts/test-fail-closed.js @@ -53,7 +53,12 @@ else if (scenario === 'check-mixed') { } else if (scenario === 'check-auth') fail('HTTP 401: Bad credentials'); else if (scenario === 'check-timeout') count === 1 ? hang() : okRepo(); else if (scenario === 'check-invalid') count === 1 ? process.stdout.write('{not-json}\\n') : okRepo(); +else if (scenario === 'check-missing-flags') count === 1 ? process.stdout.write(JSON.stringify({ full_name: repo() }) + '\\n') : okRepo(); +else if (scenario === 'check-empty-canonical') count === 1 ? process.stdout.write(JSON.stringify({ archived: false, disabled: false, full_name: '' }) + '\\n') : okRepo(); +else if (scenario === 'check-archived') count === 1 ? process.stdout.write(JSON.stringify({ archived: true, disabled: false, full_name: repo() }) + '\\n') : okRepo(); +else if (scenario === 'check-disabled') count === 1 ? process.stdout.write(JSON.stringify({ archived: false, disabled: true, full_name: repo() }) + '\\n') : okRepo(); else if (scenario === 'monitor-empty') emptySearch(); +else if (scenario === 'monitor-empty-stdout') {} else if (scenario === 'monitor-mixed') count === 2 ? fail('HTTP 502: Bad Gateway') : emptySearch(); else if (scenario === 'monitor-auth') fail('HTTP 401: Bad credentials'); else if (scenario === 'monitor-timeout') count === 1 ? hang() : emptySearch(); @@ -149,6 +154,25 @@ test('check-links fails closed for an invalid GitHub payload', () => withWorkspa assert.ok(reportAt(output).some(issue => issue.status === 'error')); })); +for (const scenario of ['check-missing-flags', 'check-empty-canonical']) { + test(`check-links fails closed for ${scenario.replace('check-', '').replace(/-/g, ' ')} metadata`, () => withWorkspace(workspace => { + const output = path.join(workspace.dir, 'issues.json'); + const result = run(workspace, 'scripts/check-links.js', [], { GH_SHIM_SCENARIO: scenario, LINK_ISSUES_OUT: output }); + assert.notStrictEqual(result.status, 0); + assert.ok(reportAt(output).some(issue => issue.status === 'error')); + assert.doesNotMatch(result.stdout, /REDIRECTED/); + })); +} + +for (const scenario of ['check-archived', 'check-disabled']) { + test(`check-links preserves ${scenario.replace('check-', '')} failures`, () => withWorkspace(workspace => { + const output = path.join(workspace.dir, 'issues.json'); + const result = run(workspace, 'scripts/check-links.js', [], { GH_SHIM_SCENARIO: scenario, LINK_ISSUES_OUT: output }); + assert.notStrictEqual(result.status, 0); + assert.ok(reportAt(output).some(issue => issue.status === scenario.replace('check-', ''))); + })); +} + test('monitor writes a verified empty search result', () => withWorkspace(workspace => { const output = path.join(workspace.dir, 'monitor.json'); const result = run(workspace, 'scripts/monitor-github.js', ['--output', output], { GH_SHIM_SCENARIO: 'monitor-empty' }); @@ -159,7 +183,7 @@ test('monitor writes a verified empty search result', () => withWorkspace(worksp assert.deepStrictEqual(monitor.items, []); })); -for (const scenario of ['monitor-mixed', 'monitor-auth', 'monitor-timeout', 'monitor-invalid']) { +for (const scenario of ['monitor-mixed', 'monitor-auth', 'monitor-timeout', 'monitor-invalid', 'monitor-empty-stdout']) { test(`monitor preserves its previous snapshot on ${scenario.replace('monitor-', '')} failure`, () => withWorkspace(workspace => { const output = path.join(workspace.dir, 'monitor.json'); const previous = '{"previous":true}\n';