mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-10 18:50:18 +01:00
* Let pull requests that only change human-facing docs skip the test suites (GST-1951) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Quote the docs test list in the docs lane so shellcheck passes Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Keep the Windows smoke check green on docs-only pull requests and update the workflow contract tests Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Keep the merged test check when the base gained a reader of a changed doc, hold the land policy key until a release reads it, and register two more docs reader tests Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Check links in untracked docs and docs with quoted or spaced names, and report an undecodable anchor instead of crashing Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Point the flaky-exception anchor back at its section and test the link checker's exit codes Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
268 lines
15 KiB
TypeScript
268 lines
15 KiB
TypeScript
/**
|
||
* R81 docs-only class (lib/docs-only.ts, bin/gstack-docs-only; docs/pregate.md#docs-only). The bypass corpus comes
|
||
* first: every way a pull request could pass code or agent instructions off as docs must classify "no".
|
||
*/
|
||
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
||
import { spawnSync } from 'node:child_process';
|
||
import * as fs from 'node:fs';
|
||
import * as os from 'node:os';
|
||
import * as path from 'node:path';
|
||
import { classifyDocsOnly, DOCS_READER_EXEMPT, DOCS_READER_TESTS, DocsOnlyError, referencesTo } from '../lib/docs-only';
|
||
import { addedLines, brokenLinks } from '../lib/doc-links';
|
||
import { mergedChecksFor } from '../lib/land-docs-only';
|
||
import { landPolicyErrors } from '../lib/land-merged-checks';
|
||
import { docsOnlyLane } from '../lib/pregate/docs-only-lane';
|
||
import type { CheckResult, PregateContext } from '../lib/pregate/types';
|
||
|
||
const ROOT = path.resolve(import.meta.dir, '..');
|
||
const ENV = { ...process.env, GIT_AUTHOR_NAME: 'Fixture', GIT_AUTHOR_EMAIL: 'fixture@example.test', GIT_COMMITTER_NAME: 'Fixture', GIT_COMMITTER_EMAIL: 'fixture@example.test' };
|
||
let repo = '';
|
||
|
||
function git(...args: string[]): string {
|
||
const r = spawnSync('git', args, { cwd: repo, encoding: 'utf8', env: ENV, timeout: 30_000 });
|
||
if (r.status !== 0) throw new Error(`git ${args.join(' ')}: ${r.stderr}`);
|
||
return r.stdout;
|
||
}
|
||
function put(rel: string, text = `# ${rel}\n\nprose\n`) {
|
||
fs.mkdirSync(path.dirname(path.join(repo, rel)), { recursive: true });
|
||
fs.writeFileSync(path.join(repo, rel), text);
|
||
}
|
||
/** Commit `change` on top of the seed and classify seed..HEAD; resets afterwards. */
|
||
function classify(change: () => void, worktree = false) {
|
||
try {
|
||
change();
|
||
if (worktree) return classifyDocsOnly({ root: repo, base: 'seed' });
|
||
git('add', '-A');
|
||
git('commit', '-q', '--allow-empty', '-m', 'change');
|
||
return classifyDocsOnly({ root: repo, base: 'seed', head: 'HEAD' });
|
||
} finally {
|
||
git('reset', '-q', '--hard', 'seed');
|
||
git('clean', '-qfdx');
|
||
}
|
||
}
|
||
|
||
beforeAll(() => {
|
||
repo = fs.mkdtempSync(path.join(os.tmpdir(), 'docs-only-'));
|
||
git('init', '-q', '-b', 'main');
|
||
for (const f of ['README.md', 'docs/guide.md', 'docs/other.md', 'docs/run-me.md', 'docs/troubleshooting.md', 'docs/captain.md', 'docs/ship-policy.md',
|
||
'docs/evals/result.md', 'docs/generated.md', 'review/SKILL.md', 'review/README.md', 'changelog.d/a.md', 'test/fixtures/x/README.md']) put(f);
|
||
put('docs/other.md', 'See [the guide](guide.md).\n');
|
||
put('CHANGELOG.md', '- edited docs/guide.md\n');
|
||
put('lib/x.ts', "export const doc = 'docs/troubleshooting.md#x';\n");
|
||
put('scripts/run.ts', "Bun.spawn(['sh', 'docs/run-me.md']);\n");
|
||
put('.gstack/generated.json', JSON.stringify({ entries: [{ id: 'g', command: 'gen', outputs: ['docs/generated.md'], inputs: [] }] }));
|
||
git('add', '-A');
|
||
git('commit', '-q', '-m', 'seed');
|
||
git('tag', 'seed');
|
||
});
|
||
afterAll(() => { if (repo) fs.rmSync(repo, { recursive: true, force: true }); });
|
||
|
||
const reasons = (r: ReturnType<typeof classifyDocsOnly>) => r.refusals.map(x => `${x.path}: ${x.reason}`).join('; ');
|
||
|
||
describe('bypass corpus: never docs-only', () => {
|
||
const cases: [string, () => void][] = [
|
||
['docs plus code', () => { put('docs/guide.md', 'new\n'); put('lib/foo.ts', 'export {};\n'); }],
|
||
['a doc a script executes', () => put('docs/run-me.md', 'echo pwned\n')],
|
||
['a doc code names by path', () => put('docs/troubleshooting.md', 'changed\n')],
|
||
['agent prose under docs/', () => put('docs/captain.md', 'changed\n')],
|
||
['a policy doc', () => put('docs/ship-policy.md', 'changed\n')],
|
||
['a case variant of an agent-prose name', () => put('docs/Captain.md')],
|
||
['a lowercase CLAUDE.md', () => put('docs/claude.md')],
|
||
['SKILL.md', () => put('review/SKILL.md', 'changed\n')],
|
||
['prose in a skill directory', () => put('review/README.md', 'changed\n')],
|
||
['a template', () => put('docs/page.md.tmpl')],
|
||
['a sections file', () => put('docs/sections/a.md')],
|
||
['factory.md, CREW.md, AGENTS.md, ETHOS.md', () => { put('docs/factory.md'); put('docs/CREW.md'); put('docs/AGENTS.md'); put('docs/ETHOS.md'); }],
|
||
['preferences', () => put('docs/preferences.md')],
|
||
['a generated output', () => put('docs/generated.md', 'changed\n')],
|
||
['pinned results', () => put('docs/evals/result.md', 'changed\n')],
|
||
['a preregistration', () => put('docs/runaway-prereg.md')],
|
||
['root markdown off the allowlist', () => put('NOTES-x.md')],
|
||
['a wrong-case docs tree', () => put('Docs/x.md')],
|
||
['a wrong-case README', () => put('readme.MD')],
|
||
['a look-alike README', () => put('READМE.md')],
|
||
['code under a .md directory', () => put('docs/x.md/evil.ts', 'export {};\n')],
|
||
['a fixture README', () => put('test/fixtures/x/README.md', 'changed\n')],
|
||
['the classifier itself', () => put('lib/docs-only.ts', 'export {};\n')],
|
||
['a workflow', () => put('.github/workflows/x.yml', 'on: push\n')],
|
||
['an executable doc', () => { fs.chmodSync(path.join(repo, 'docs/guide.md'), 0o755); }],
|
||
['a symlinked doc', () => fs.symlinkSync('../lib/x.ts', path.join(repo, 'docs/link.md'))],
|
||
['code renamed to docs', () => git('mv', 'lib/x.ts', 'docs/x.md')],
|
||
['a deleted doc that code names', () => fs.rmSync(path.join(repo, 'docs/troubleshooting.md'))],
|
||
['an empty diff', () => {}],
|
||
];
|
||
for (const [name, change] of cases) test(name, () => expect(classify(change).docsOnly).toBe(false));
|
||
|
||
test('worktree mode sees untracked code', () => {
|
||
expect(classify(() => { put('docs/guide.md', 'x\n'); put('lib/new.ts', 'export {};\n'); }, true).docsOnly).toBe(false);
|
||
});
|
||
test('a path that looks like a git option is searched, never parsed', () => {
|
||
expect(referencesTo(repo, '-Ofoo.md')).toEqual([]);
|
||
});
|
||
});
|
||
|
||
describe('docs-only', () => {
|
||
test('a human doc and a changelog fragment', () => {
|
||
const r = classify(() => { put('docs/guide.md', 'new prose\n'); put('changelog.d/b.md', '- note\n'); });
|
||
expect(reasons(r)).toBe('');
|
||
expect(r.docsOnly).toBe(true);
|
||
expect(r.tests).toContain('test/docs-config-keys.test.ts');
|
||
});
|
||
test('CHANGELOG.md and other docs naming a doc do not pin it', () => {
|
||
expect(classify(() => put('docs/guide.md', 'x\n')).docsOnly).toBe(true);
|
||
});
|
||
test('a new doc and a deleted unreferenced doc', () => {
|
||
expect(classify(() => { put('docs/new.md'); fs.rmSync(path.join(repo, 'docs/other.md')); }).docsOnly).toBe(true);
|
||
});
|
||
test('the reference scan is a substring match, so a path inside a longer one pins conservatively', () => {
|
||
expect(reasons(classify(() => put('README.md', 'x\n')))).toContain('README.md: named by');
|
||
});
|
||
test('worktree mode counts untracked docs', () => {
|
||
expect(classify(() => put('docs/new.md'), true).docsOnly).toBe(true);
|
||
});
|
||
test('a pull request merge commit classifies against its first parent, so a base that moved on does not leak in', () => {
|
||
git('checkout', '-q', '-b', 'pr', 'seed');
|
||
put('docs/guide.md', 'pr prose\n');
|
||
git('commit', '-qam', 'docs');
|
||
git('checkout', '-q', '-b', 'base', 'seed');
|
||
put('lib/moved.ts', 'export {};\n');
|
||
git('add', '-A');
|
||
git('commit', '-qm', 'base moved');
|
||
git('merge', '-q', '--no-ff', '-m', 'merge', 'pr');
|
||
try {
|
||
expect(classifyDocsOnly({ root: repo, base: 'HEAD^1', head: 'HEAD' }).docsOnly).toBe(true);
|
||
} finally {
|
||
git('checkout', '-q', 'main');
|
||
git('branch', '-qD', 'pr', 'base');
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('errors fail closed', () => {
|
||
test('an unknown ref throws DOCS_ONLY_ERROR, and the CLI exits 2 and writes docs_only=false', () => {
|
||
expect(() => classifyDocsOnly({ root: repo, base: 'nope', head: 'HEAD' })).toThrow(DocsOnlyError);
|
||
const out = path.join(repo, '..', `${path.basename(repo)}-out`);
|
||
const r = spawnSync(path.join(ROOT, 'bin/gstack-docs-only'), ['--repo', repo, '--base', 'nope', '--head', 'HEAD', '--github-output', out], { encoding: 'utf8', env: { ...ENV, GITHUB_EVENT_NAME: 'pull_request' }, timeout: 30_000 });
|
||
expect(r.status).toBe(2);
|
||
expect(r.stdout).toContain('DOCS_ONLY: error (DOCS_ONLY_ERROR:');
|
||
expect(fs.readFileSync(out, 'utf8')).toContain('docs_only=false');
|
||
fs.rmSync(out, { force: true });
|
||
});
|
||
test('only a pull_request event may write docs_only=true', () => {
|
||
put('docs/guide.md', 'x\n');
|
||
git('commit', '-qam', 'docs');
|
||
const out = path.join(repo, '..', `${path.basename(repo)}-ev`);
|
||
try {
|
||
for (const [event, want, code] of [['push', 'false', 1], ['workflow_dispatch', 'false', 1], ['merge_group', 'false', 1], ['pull_request', 'true', 0]] as const) {
|
||
fs.rmSync(out, { force: true });
|
||
const r = spawnSync(path.join(ROOT, 'bin/gstack-docs-only'), ['--repo', repo, '--base', 'HEAD^1', '--head', 'HEAD', '--github-output', out], { encoding: 'utf8', env: { ...ENV, GITHUB_EVENT_NAME: event }, timeout: 30_000 });
|
||
expect([event, r.status]).toEqual([event, code]);
|
||
expect(fs.readFileSync(out, 'utf8')).toContain(`docs_only=${want}`);
|
||
}
|
||
} finally {
|
||
fs.rmSync(out, { force: true });
|
||
git('reset', '-q', '--hard', 'seed');
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('link check', () => {
|
||
test('missing files and anchors fail; headings with code, explicit ids, line suffixes and URLs pass', () => {
|
||
put('docs/a.md', '# Who reads (R65, the `review` key)\n<a id="x-y"></a>\n\n[ok](#who-reads-r65-the-review-key) [id](#x-y) [file](../lib/x.ts:3) [web](https://e.com/z)\n[gone](missing.md) [bad](#nope)\n`[code](skip.md)`\n');
|
||
try {
|
||
expect(brokenLinks(repo, ['docs/a.md']).map(b => b.target)).toEqual(['missing.md', '#nope']);
|
||
expect(brokenLinks(repo, ['docs/a.md'], new Map([['docs/a.md', new Set([4])]]))).toEqual([]);
|
||
} finally { fs.rmSync(path.join(repo, 'docs/a.md')); }
|
||
});
|
||
|
||
test('added lines cover quoted and spaced names and untracked files, an undecodable anchor is broken, and an unknown base throws', () => {
|
||
const names = ['docs/café.md', 'docs/with space.md'];
|
||
try {
|
||
for (const n of names) put(n, '# T\n\n[gone](never-there.md) [pct](guide.md#100%)\n');
|
||
const added = addedLines(repo, 'seed', names);
|
||
expect(names.map(n => [...(added.get(n) ?? [])])).toEqual([[1, 2, 3, 4], [1, 2, 3, 4]]);
|
||
expect(brokenLinks(repo, names, added).map(b => `${b.file}:${b.target}:${b.reason}`)).toEqual(names.flatMap(n => [`${n}:never-there.md:no such file`, `${n}:guide.md#100%:undecodable anchor`]));
|
||
fs.appendFileSync(path.join(repo, 'docs/guide.md'), '[late](nope-late.md)\n');
|
||
const lines = fs.readFileSync(path.join(repo, 'docs/guide.md'), 'utf8').split('\n').length - 1;
|
||
expect([...(addedLines(repo, 'seed', ['docs/guide.md']).get('docs/guide.md') ?? [])]).toEqual([lines]);
|
||
expect(() => addedLines(repo, 'no-such-ref', ['docs/guide.md'])).toThrow('unknown revision');
|
||
} finally {
|
||
git('checkout', '-q', '--', 'docs/guide.md');
|
||
for (const n of names) fs.rmSync(path.join(repo, n), { force: true });
|
||
}
|
||
});
|
||
|
||
test('scripts/check-doc-links.ts exits 0 on resolving added links, 1 on a broken one and 2 on an unknown base', () => {
|
||
const cli = (...args: string[]) => {
|
||
const r = spawnSync(process.execPath, ['--no-env-file', '--config=/dev/null', path.join(ROOT, 'scripts/check-doc-links.ts'), ...args], { cwd: repo, encoding: 'utf8', timeout: 30_000 });
|
||
return [r.status, (r.stdout ?? '').trim().split('\n').at(-1)];
|
||
};
|
||
try {
|
||
put('docs/cli.md', '[ok](guide.md)\n');
|
||
expect(cli('--base', 'seed', 'docs/cli.md')).toEqual([0, 'DOC_LINKS: ok (1 file(s))']);
|
||
put('docs/cli.md', '[gone](nope.md)\n');
|
||
expect(cli('--base', 'seed', 'docs/cli.md')).toEqual([1, 'DOC_LINKS: broken 1']);
|
||
expect(cli('--base', 'no-such-ref', 'docs/cli.md')[0]).toBe(2);
|
||
} finally { fs.rmSync(path.join(repo, 'docs/cli.md'), { force: true }); }
|
||
});
|
||
});
|
||
|
||
test('every test that reads docs by directory or lists every tracked file is registered', () => {
|
||
const READER = /\['ls-files', '-z'\]|git\(\['ls-files', '-z'\]\)|git\('ls-files', '-z'\)|'ls-files', '-z', '--cached', '--others'|join\(ROOT, '(?:docs|changelog\.d)'\)/;
|
||
const listed = new Set([...DOCS_READER_TESTS.flatMap(t => t.tests), ...Object.keys(DOCS_READER_EXEMPT)]);
|
||
for (const f of listed) expect(fs.existsSync(path.join(ROOT, f)), f).toBe(true);
|
||
const files = (spawnSync('git', ['ls-files', '-z', '--', 'test'], { cwd: ROOT, encoding: 'utf8', timeout: 30_000 }).stdout ?? '').split('\0')
|
||
.filter(f => f.endsWith('.ts') && f !== 'test/docs-only.test.ts' && !f.startsWith('test/fixtures/'));
|
||
const unregistered = files.filter(f => READER.test(fs.readFileSync(path.join(ROOT, f), 'utf8')) && !listed.has(f));
|
||
expect(unregistered.map(f => `${f} reads docs by directory or every tracked file; add it to DOCS_READER_TESTS or DOCS_READER_EXEMPT in lib/docs-only.ts`)).toEqual([]);
|
||
});
|
||
|
||
describe('land and pregate use the same class', () => {
|
||
test('land skips only the declared commands, only for a docs-only pin, and never when the base it lands on gained a reader of a changed doc', () => {
|
||
const land = { merged_checks: ['typecheck', 'tests'], docs_only_skip: ['tests'] };
|
||
put('docs/guide.md', 'land prose\n');
|
||
git('commit', '-qam', 'docs pin');
|
||
const docsPin = git('rev-parse', 'HEAD').trim();
|
||
put('lib/x.ts', 'export const y = 1;\n');
|
||
git('commit', '-qam', 'code pin');
|
||
const codePin = git('rev-parse', 'HEAD').trim();
|
||
const branch = git('rev-parse', '--abbrev-ref', 'HEAD').trim();
|
||
git('checkout', '-q', '--detach', 'seed');
|
||
put('test/reads-guide.test.ts', "export const doc = 'docs/guide.md';\n");
|
||
git('add', '-A');
|
||
git('commit', '-qm', 'base gains a reader');
|
||
const readerTip = git('rev-parse', 'HEAD').trim();
|
||
git('checkout', '-q', branch);
|
||
try {
|
||
expect(mergedChecksFor(repo, land, 'seed', docsPin)).toEqual(['typecheck']);
|
||
expect(mergedChecksFor(repo, land, readerTip, docsPin)).toEqual(['typecheck', 'tests']);
|
||
expect(mergedChecksFor(repo, land, 'seed', codePin)).toEqual(['typecheck', 'tests']);
|
||
expect(mergedChecksFor(repo, land, 'nope', docsPin)).toEqual(['typecheck', 'tests']);
|
||
expect(mergedChecksFor(repo, { merged_checks: ['tests'] }, 'seed', docsPin)).toEqual(['tests']);
|
||
} finally { git('reset', '-q', '--hard', 'seed'); }
|
||
expect(landPolicyErrors({ merged_checks: ['a', 'b'], docs_only_skip: ['b'] })).toEqual([]);
|
||
expect(landPolicyErrors({ merged_checks: ['a'], docs_only_skip: ['a'] }).map(e => e.path)).toEqual(['land.docs_only_skip']);
|
||
expect(landPolicyErrors({ merged_checks: ['a', 'b'], docs_only_skip: ['c'] }).map(e => e.path)).toEqual(['land.docs_only_skip']);
|
||
});
|
||
|
||
test('pregate passes a docs-only change with no remote obligation, fails a broken added link, and leaves code to the lanes', () => {
|
||
git('update-ref', 'refs/remotes/origin/main', 'seed');
|
||
const ctx = (allow = true) => ({ repoRoot: repo, base: 'main', explain: [] as string[], allowRepoCommands: allow }) as unknown as PregateContext;
|
||
const base = { id: 'lanes', stage: 'tests', status: 'pass', inputs: {}, lines: [], remote: [{ lane: 'x' }] } as unknown as CheckResult;
|
||
try {
|
||
put('docs/guide.md', 'fine [x](other.md)\n');
|
||
const ok = docsOnlyLane(ctx(), base)!;
|
||
expect([ok.status, ok.remote]).toEqual(['pass', []]);
|
||
expect(ok.detail).toContain('docs-only: 1 file(s)');
|
||
put('docs/guide.md', 'broken [x](nope.md)\n');
|
||
expect(docsOnlyLane(ctx(), base)!.status).toBe('fail');
|
||
put('lib/x.ts', 'export {};\n');
|
||
expect(docsOnlyLane(ctx(), base)).toBeNull();
|
||
} finally {
|
||
git('reset', '-q', '--hard', 'seed');
|
||
git('clean', '-qfdx');
|
||
git('update-ref', '-d', 'refs/remotes/origin/main');
|
||
}
|
||
});
|
||
});
|