mirror of
https://github.com/garrytan/gstack.git
synced 2026-08-21 21:47:32 +02:00
touchfiles.ts listed ITSELF in GLOBAL_TOUCHFILES, so adding one test's dep entry forced the full ~$38 / 30-45min suite — measured on 21.9% of recent commits (42/192). The self-reference existed because data and logic shared a file: any edit COULD be a selection-logic change. Now: touchfiles-data.ts (the four maps, literals only, zero imports — the future map-diff target), test-selection.ts (matchGlob/detectBase Branch/getChangedFiles/selectTests), and touchfiles.ts as a re-export facade so all ~12 import sites are untouched. GLOBAL_TOUCHFILES drops the self-ref, adds test-selection.ts (logic stays maximally conservative), and TEMPORARILY adds touchfiles-data.ts until the map-diff change lands. New free test pins the literal-only property (comment-aware state-machine scan with a self-test) and facade export parity (===), so neither can silently rot. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
176 lines
6.8 KiB
TypeScript
176 lines
6.8 KiB
TypeScript
/**
|
|
* Pins the touchfiles three-file split (facade + data + logic).
|
|
* Free (no API calls), runs with `bun test`.
|
|
*
|
|
* (a) touchfiles-data.ts stays LITERALS ONLY — no imports/requires, no call
|
|
* expressions, no spreads, no template literals. Map-diff selection
|
|
* evaluates old git versions of that file standalone; any logic breaks it.
|
|
* (b) the ./helpers/touchfiles facade re-exports EVERY export of both halves
|
|
* by identity (===), so existing import sites see the same objects.
|
|
* (c) the data file's exports are importable and non-empty.
|
|
*/
|
|
|
|
import { describe, test, expect } from 'bun:test';
|
|
import { readFileSync } from 'fs';
|
|
import * as path from 'path';
|
|
|
|
import * as data from './helpers/touchfiles-data';
|
|
import * as logic from './helpers/test-selection';
|
|
import * as facade from './helpers/touchfiles';
|
|
|
|
const DATA_PATH = path.join(import.meta.dir, 'helpers', 'touchfiles-data.ts');
|
|
|
|
// Single-pass scanner: returns the source with comments AND string literals
|
|
// removed (only code-position characters survive), plus whether any backtick
|
|
// appeared in code position.
|
|
//
|
|
// Why a state machine instead of regexes: the data strings contain glob
|
|
// patterns like 'browse/src/' + '**' (a block-comment OPENER to a naive
|
|
// regex) and '*' + '/SKILL.md.tmpl' (a block-comment CLOSER), so regex
|
|
// comment-stripping would treat string content as comment delimiters.
|
|
// Conversely, comments contain apostrophes ("the model's interpretation"),
|
|
// so regex string-stripping applied first would eat code. Tracking state
|
|
// character-by-character handles both without false positives.
|
|
function stripCommentsAndStrings(src: string): { code: string; sawBacktick: boolean } {
|
|
let code = '';
|
|
let sawBacktick = false;
|
|
let state: 'code' | 'line' | 'block' | 'single' | 'double' = 'code';
|
|
let i = 0;
|
|
while (i < src.length) {
|
|
const c = src[i];
|
|
const n = src[i + 1];
|
|
if (state === 'code') {
|
|
if (c === '/' && n === '/') { state = 'line'; i += 2; continue; }
|
|
if (c === '/' && n === '*') { state = 'block'; i += 2; continue; }
|
|
if (c === "'") { state = 'single'; i += 1; continue; }
|
|
if (c === '"') { state = 'double'; i += 1; continue; }
|
|
if (c === '`') { sawBacktick = true; i += 1; continue; }
|
|
code += c;
|
|
i += 1;
|
|
continue;
|
|
}
|
|
if (state === 'line') {
|
|
if (c === '\n') { state = 'code'; code += '\n'; }
|
|
i += 1;
|
|
continue;
|
|
}
|
|
if (state === 'block') {
|
|
if (c === '*' && n === '/') { state = 'code'; i += 2; } else { i += 1; }
|
|
continue;
|
|
}
|
|
// single- or double-quoted string
|
|
if (c === '\\') { i += 2; continue; }
|
|
if ((state === 'single' && c === "'") || (state === 'double' && c === '"')) {
|
|
state = 'code';
|
|
}
|
|
i += 1;
|
|
}
|
|
return { code, sawBacktick };
|
|
}
|
|
|
|
describe('touchfiles-data.ts literal-only tripwire', () => {
|
|
const src = readFileSync(DATA_PATH, 'utf-8');
|
|
const { code, sawBacktick } = stripCommentsAndStrings(src);
|
|
const explain =
|
|
'touchfiles-data.ts must stay LITERALS ONLY (map-diff selection evaluates ' +
|
|
'old git versions of it standalone). Move any logic to test-selection.ts.';
|
|
|
|
test('scanner sanity: keeps code, strips comments and strings', () => {
|
|
const sample =
|
|
"export const X = { 'a(b)': ['c/**'] }; // call() here\n" +
|
|
'/* import */ const Y = 1; // `tpl`\n';
|
|
const out = stripCommentsAndStrings(sample);
|
|
expect(out.code).toContain('export const X');
|
|
expect(out.code).toContain('const Y = 1');
|
|
expect(out.code).not.toContain('call(');
|
|
expect(out.code).not.toContain('import');
|
|
expect(out.code).not.toContain('a(b)'); // string content stripped
|
|
expect(out.sawBacktick).toBe(false); // backticks inside comments don't count
|
|
});
|
|
|
|
test('no import or require statements', () => {
|
|
expect(code, explain).not.toMatch(/\bimport\b/);
|
|
expect(code, explain).not.toMatch(/\brequire\b/);
|
|
});
|
|
|
|
test('no spread operator', () => {
|
|
expect(code, explain).not.toContain('...');
|
|
});
|
|
|
|
test('no call expressions', () => {
|
|
expect(code, explain).not.toMatch(/[A-Za-z_$][A-Za-z0-9_$]*\s*\(/);
|
|
});
|
|
|
|
test('no template literals or interpolation', () => {
|
|
expect(sawBacktick, explain).toBe(false);
|
|
expect(code, explain).not.toContain('${');
|
|
});
|
|
});
|
|
|
|
describe('facade export parity', () => {
|
|
test('every touchfiles-data export is re-exported by identity', () => {
|
|
const dataExports = Object.keys(data);
|
|
expect(dataExports.length).toBeGreaterThan(0);
|
|
for (const name of dataExports) {
|
|
expect(
|
|
(facade as Record<string, unknown>)[name],
|
|
`facade must re-export '${name}' from touchfiles-data by identity`,
|
|
).toBe((data as Record<string, unknown>)[name] as never);
|
|
}
|
|
});
|
|
|
|
test('every test-selection export is re-exported by identity', () => {
|
|
const logicExports = Object.keys(logic);
|
|
expect(logicExports.length).toBeGreaterThan(0);
|
|
for (const name of logicExports) {
|
|
expect(
|
|
(facade as Record<string, unknown>)[name],
|
|
`facade must re-export '${name}' from test-selection by identity`,
|
|
).toBe((logic as Record<string, unknown>)[name] as never);
|
|
}
|
|
});
|
|
|
|
test('facade exports exactly the union of both halves', () => {
|
|
const union = new Set([...Object.keys(data), ...Object.keys(logic)]);
|
|
expect(new Set(Object.keys(facade))).toEqual(union);
|
|
});
|
|
|
|
test('no export name collisions between data and logic', () => {
|
|
const overlap = Object.keys(data).filter((k) => k in logic);
|
|
expect(overlap).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('touchfiles-data exports are importable and non-empty', () => {
|
|
test('E2E_TOUCHFILES has entries with non-empty pattern lists', () => {
|
|
const keys = Object.keys(data.E2E_TOUCHFILES);
|
|
expect(keys.length).toBeGreaterThan(0);
|
|
for (const key of keys) {
|
|
expect(data.E2E_TOUCHFILES[key].length, `E2E_TOUCHFILES['${key}'] is empty`).toBeGreaterThan(0);
|
|
}
|
|
});
|
|
|
|
test('E2E_TIERS has entries with valid tier values', () => {
|
|
const entries = Object.entries(data.E2E_TIERS);
|
|
expect(entries.length).toBeGreaterThan(0);
|
|
for (const [key, tier] of entries) {
|
|
expect(['gate', 'periodic'], `E2E_TIERS['${key}'] has invalid tier`).toContain(tier);
|
|
}
|
|
});
|
|
|
|
test('LLM_JUDGE_TOUCHFILES has entries with non-empty pattern lists', () => {
|
|
const keys = Object.keys(data.LLM_JUDGE_TOUCHFILES);
|
|
expect(keys.length).toBeGreaterThan(0);
|
|
for (const key of keys) {
|
|
expect(data.LLM_JUDGE_TOUCHFILES[key].length, `LLM_JUDGE_TOUCHFILES['${key}'] is empty`).toBeGreaterThan(0);
|
|
}
|
|
});
|
|
|
|
test('GLOBAL_TOUCHFILES is non-empty and covers the selection logic', () => {
|
|
expect(data.GLOBAL_TOUCHFILES.length).toBeGreaterThan(0);
|
|
// The logic file must stay a global touchfile: a bug in selectTests /
|
|
// matchGlob mis-selects every test, so any change to it forces a full run.
|
|
expect(data.GLOBAL_TOUCHFILES).toContain('test/helpers/test-selection.ts');
|
|
});
|
|
});
|