Install eslint@9 + typescript-eslint@8 + globals@16 + eslint-plugin-n@17 + eslint-plugin-no-only-tests@3 + typescript as devDependencies. eslint.config.mjs (flat config): - Global ignores: node_modules, dist, .worktrees, .claude, coverage, the 12 generated get-shit-done/bin/lib/*.cjs files - Block for get-shit-done/bin/**/*.cjs + scripts/**/*.cjs: js.recommended + eslint-plugin-n + local plugin; generic quality rules (no-var, prefer-const, no-unused-vars, no-empty, n/no-process-exit) - Block for tests/**/*.test.cjs: no-only-tests (error), local timing rules, no-restricted-syntax timing bans eslint-rules/ local plugin (three AST rules, all at warn pending cleanup): - no-source-grep: flag readFileSync on source .cjs/.js/.ts + text methods - no-magic-sleep-in-tests: flag Atomics.wait and await-new-Promise(setTimeout) - no-elapsed-assertion: flag assert*() on timing props (elapsed/duration/took/ms) tsconfig.lint.json: allowJs + checkJs + noEmit for future type-aware passes. tests/eslint-rules.test.cjs: 15 RuleTester unit tests (all pass, 0 fail). package.json: add lint/lint:fix scripts; remove lint:tests (subsumed by ESLint local/no-source-grep). Rules that produced pre-existing errors downgraded to warn: no-useless-escape, no-unsafe-finally, no-regex-spaces, no-control-regex, no-irregular-whitespace. ESLint exits 0 (warnings ok). .github/workflows/test.yml lint-tests job: add npm ci + ESLint step; remove "Lint — no source-grep tests" step (now covered by ESLint); bump timeout 3→5 min. .gitignore: add node_modules/.cache/eslint/ entry. eslint --fix auto-cleaned: no-regex-spaces in tests, prefer-const in state.cjs, redundant eslint-disable-next-line comments. Co-authored-by: CI Rebase Check <ci@gsd-redux> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
188 lines
6.4 KiB
JavaScript
188 lines
6.4 KiB
JavaScript
// allow-test-rule: source-text-is-the-product
|
|
// The GitHub Actions YAML is the deployed runtime contract. These tests assert
|
|
// on the parsed IR (line-tokenised YAML structure) — not on prose or rendered
|
|
// output — to lock the wiring without testing GHA execution semantics.
|
|
|
|
'use strict';
|
|
|
|
const { describe, test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
|
|
const WORKFLOW_PATH = path.join(__dirname, '..', '.github', 'workflows', 'install-smoke.yml');
|
|
|
|
/**
|
|
* Minimal structural extractor for this specific YAML shape.
|
|
* Returns an object with:
|
|
* { onTriggers: string[], steps: Array<{ name?: string, run?: string }> }
|
|
*
|
|
* Strategy:
|
|
* 1. Collect top-level `on:` keys by scanning lines after `^on:` until the
|
|
* next top-level key.
|
|
* 2. Collect step `run:` blocks by scanning all ` - name:` / ` run:`
|
|
* entries. No full YAML parse needed — we only need substring presence.
|
|
*/
|
|
function parseWorkflowStructure(src) {
|
|
const lines = src.split('\n');
|
|
|
|
// --- Extract on: trigger keys ---
|
|
const onTriggers = [];
|
|
let inOn = false;
|
|
for (const line of lines) {
|
|
if (/^on:/.test(line)) { inOn = true; continue; }
|
|
if (inOn) {
|
|
// A new top-level key ends the `on:` block
|
|
if (/^[a-zA-Z]/.test(line) && !line.startsWith(' ')) { inOn = false; continue; }
|
|
// Direct children of `on:` are trigger names at 2-space indent
|
|
const m = line.match(/^ {2}([a-zA-Z_][a-zA-Z0-9_]*):/);
|
|
if (m) onTriggers.push(m[1]);
|
|
}
|
|
}
|
|
|
|
// --- Extract PR path filters ---
|
|
const prPaths = [];
|
|
let inPrPaths = false;
|
|
let inPullRequest = false;
|
|
for (const line of lines) {
|
|
if (/^ {2}pull_request:/.test(line)) { inPullRequest = true; continue; }
|
|
if (inPullRequest) {
|
|
if (/^ {4}paths:/.test(line)) { inPrPaths = true; continue; }
|
|
if (inPrPaths) {
|
|
const m = line.match(/^ {6}- '(.+)'/);
|
|
if (m) { prPaths.push(m[1]); continue; }
|
|
// End of paths list
|
|
if (/^ {4}[a-zA-Z]/.test(line)) inPrPaths = false;
|
|
}
|
|
// End of pull_request block
|
|
if (/^ {2}[a-zA-Z]/.test(line) && !line.startsWith(' ')) inPullRequest = false;
|
|
}
|
|
}
|
|
|
|
// --- Extract all step names + run blocks ---
|
|
const steps = [];
|
|
let currentStep = null;
|
|
let inRun = false;
|
|
let runLines = [];
|
|
|
|
for (const line of lines) {
|
|
// Step boundary: 6-space "- name:" or "- uses:"
|
|
if (/^ {6}- name:/.test(line)) {
|
|
if (currentStep && runLines.length) {
|
|
currentStep.run = runLines.join('\n');
|
|
}
|
|
if (currentStep) steps.push(currentStep);
|
|
currentStep = { name: line.replace(/^ {6}- name:\s*/, '').trim() };
|
|
inRun = false;
|
|
runLines = [];
|
|
continue;
|
|
}
|
|
if (/^ {6}- uses:/.test(line)) {
|
|
if (currentStep && runLines.length) {
|
|
currentStep.run = runLines.join('\n');
|
|
}
|
|
if (currentStep) steps.push(currentStep);
|
|
currentStep = { uses: line.replace(/^ {6}- uses:\s*/, '').trim() };
|
|
inRun = false;
|
|
runLines = [];
|
|
continue;
|
|
}
|
|
// uses: field inside a named step (e.g. "- name: Foo\n uses: actions/...")
|
|
if (currentStep && /^ {8}uses:\s/.test(line)) {
|
|
currentStep.uses = line.replace(/^ {8}uses:\s*/, '').trim();
|
|
continue;
|
|
}
|
|
// run: block inside a step
|
|
if (currentStep && /^ {8}run:\s*\|/.test(line)) {
|
|
inRun = true;
|
|
runLines = [];
|
|
continue;
|
|
}
|
|
if (currentStep && /^ {8}run:\s*(?!\|)/.test(line)) {
|
|
// Inline run (no |)
|
|
currentStep.run = line.replace(/^ {8}run:\s*/, '').trim();
|
|
inRun = false;
|
|
continue;
|
|
}
|
|
if (inRun) {
|
|
// Lines deeper than 8 spaces belong to the run block
|
|
if (/^ {10}/.test(line) || line.trim() === '') {
|
|
runLines.push(line);
|
|
} else {
|
|
inRun = false;
|
|
}
|
|
}
|
|
}
|
|
// Flush final step
|
|
if (currentStep) {
|
|
if (runLines.length) currentStep.run = runLines.join('\n');
|
|
steps.push(currentStep);
|
|
}
|
|
|
|
return { onTriggers, prPaths, steps };
|
|
}
|
|
|
|
describe('install-smoke.yml structural wiring', () => {
|
|
const src = fs.readFileSync(WORKFLOW_PATH, 'utf-8');
|
|
const { onTriggers, prPaths, steps } = parseWorkflowStructure(src);
|
|
|
|
test('workflow_call trigger is present (release.yml integration preserved)', () => {
|
|
assert.ok(
|
|
onTriggers.includes('workflow_call'),
|
|
`Expected 'workflow_call' in on: triggers. Found: ${JSON.stringify(onTriggers)}`
|
|
);
|
|
});
|
|
|
|
test('lifecycle smoke step calls release-tarball-smoke.cjs', () => {
|
|
const smokeStep = steps.find(s => s.run && s.run.includes('release-tarball-smoke.cjs'));
|
|
assert.ok(
|
|
smokeStep,
|
|
'Expected a step whose run block includes "release-tarball-smoke.cjs". ' +
|
|
'Steps found: ' + JSON.stringify(steps.map(s => s.name || s.uses))
|
|
);
|
|
});
|
|
|
|
test('lifecycle smoke step invokes script with --json flag', () => {
|
|
const smokeStep = steps.find(s => s.run && s.run.includes('release-tarball-smoke.cjs'));
|
|
assert.ok(smokeStep, 'No lifecycle smoke step found');
|
|
assert.ok(
|
|
smokeStep.run.includes('--json'),
|
|
`Expected --json in lifecycle smoke run block. Got: ${smokeStep.run}`
|
|
);
|
|
});
|
|
|
|
test('lifecycle smoke result is checked via jq', () => {
|
|
const smokeStep = steps.find(s => s.run && s.run.includes('release-tarball-smoke.cjs'));
|
|
assert.ok(smokeStep, 'No lifecycle smoke step found');
|
|
assert.ok(
|
|
smokeStep.run.includes('jq'),
|
|
`Expected jq invocation in lifecycle smoke run block. Got: ${smokeStep.run}`
|
|
);
|
|
});
|
|
|
|
test('PR path filter includes scripts/release-tarball-smoke.cjs', () => {
|
|
assert.ok(
|
|
prPaths.includes('scripts/release-tarball-smoke.cjs'),
|
|
`Expected 'scripts/release-tarball-smoke.cjs' in PR paths filter. Found: ${JSON.stringify(prPaths)}`
|
|
);
|
|
});
|
|
|
|
test('PR path filter includes tests/release-tarball-smoke.install.test.cjs', () => {
|
|
assert.ok(
|
|
prPaths.includes('tests/release-tarball-smoke.install.test.cjs'),
|
|
`Expected 'tests/release-tarball-smoke.install.test.cjs' in PR paths filter. Found: ${JSON.stringify(prPaths)}`
|
|
);
|
|
});
|
|
|
|
test('artifact upload step is present for failure debugging', () => {
|
|
const uploadStep = steps.find(
|
|
s => s.uses && s.uses.startsWith('actions/upload-artifact')
|
|
);
|
|
assert.ok(
|
|
uploadStep,
|
|
'Expected an actions/upload-artifact step for lifecycle smoke failure debugging. ' +
|
|
'Steps (name + uses): ' + JSON.stringify(steps.map(s => ({ name: s.name, uses: s.uses })))
|
|
);
|
|
});
|
|
});
|