mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-05 08:23:59 +01:00
feat(pr-selection): exercised-path consumers and a shadow selection block (A2-1, A2-2, A2-3)
exercisedConsumers() unions touchfiles, the test-file import closure, executable skill shell and the bins recorded trials ran (test/helpers/exercised-commands.json). selectPrProfile reports what that map would select in prCoverage.shadow and never changes the real selection; a map-only diff selects no paid case. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
bb8caefda4
commit
ba4c5aea00
4 files changed
+2436
-4
No files matched your search
@@ -90,6 +90,8 @@ export const SELECTION_DATA_MODULES: readonly string[] = [
|
||||
'scripts/build.sh',
|
||||
// The reason-code table: its `fix:` prose names the command a person reruns, not a file the module consumes.
|
||||
'lib/result-codes.ts', 'lib/result-codes-release.ts',
|
||||
// The exercised-path map: observed commands per case, read only by exercisedConsumers() (shadow data; a change selects no paid case).
|
||||
'test/helpers/exercised-commands.json',
|
||||
];
|
||||
const DEFAULT_JUDGE_OWNER = 'test/skill-llm-eval.test.ts';
|
||||
|
||||
@@ -326,3 +328,295 @@ export function derivedDependencies(maps: DependencyMaps, root: string): Derived
|
||||
return cache.get(key)!;
|
||||
}
|
||||
|
||||
// ─── Exercised-path consumer map (W2-A2, shadow mode) ──────────────────────
|
||||
|
||||
/** The committed per-case observed commands (generated by scripts/exercised-map.ts; union-only refreshes). */
|
||||
export const EXERCISED_MAP_PATH = 'test/helpers/exercised-commands.json';
|
||||
|
||||
export interface ExercisedCase {
|
||||
/** Recorded trials (run, case) the commands were taken from. */
|
||||
observations: number;
|
||||
/** Of those, trials that recorded at least one command. */
|
||||
withCommands: number;
|
||||
/** Bins and compiled outfiles the case invoked (binsInCommand over every recorded command). */
|
||||
commands: string[];
|
||||
/** Mean known trial cost per run, for the shadow cost estimate. */
|
||||
costUsd?: number;
|
||||
costRuns?: number;
|
||||
}
|
||||
|
||||
export interface ExercisedMap {
|
||||
schema: 1;
|
||||
generatedAt: string;
|
||||
observedThrough: string;
|
||||
sourceRuns: string[];
|
||||
cases: Record<string, ExercisedCase>;
|
||||
}
|
||||
|
||||
export function isExercisedMap(value: unknown): value is ExercisedMap {
|
||||
const map = value as ExercisedMap;
|
||||
return !!map && map.schema === 1 && typeof map.generatedAt === 'string' && typeof map.observedThrough === 'string'
|
||||
&& Array.isArray(map.sourceRuns) && map.sourceRuns.every(run => typeof run === 'string')
|
||||
&& !!map.cases && typeof map.cases === 'object' && Object.values(map.cases).every(entry => !!entry
|
||||
&& Number.isSafeInteger(entry.observations) && Number.isSafeInteger(entry.withCommands)
|
||||
&& Array.isArray(entry.commands) && entry.commands.every(command => typeof command === 'string'));
|
||||
}
|
||||
|
||||
/** Parse an exercised map's text; throws on malformed input so callers fail closed. */
|
||||
export function parseExercisedMap(text: string): ExercisedMap {
|
||||
const parsed = JSON.parse(text);
|
||||
if (!isExercisedMap(parsed)) throw new Error(`${EXERCISED_MAP_PATH}: not a schema-1 exercised map`);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export interface ExercisedConsumers {
|
||||
/** Repository file -> case ids whose exercised closure contains it. */
|
||||
e2e: Map<string, Set<string>>;
|
||||
/** One case's exercised closure: file -> how it is reached (touchfile, import, skill bash via <file>, observed <command>, declared via <file>, host). */
|
||||
closure(caseId: string): Map<string, string>;
|
||||
/** Bins (repo paths) and compiled outfiles one recorded command invokes. */
|
||||
binsInCommand(command: string): string[];
|
||||
/** The tracked files a bin or compiled outfile runs. */
|
||||
commandFiles(command: string): string[];
|
||||
/** The observed map the consumers were built with. */
|
||||
map: ExercisedMap | null;
|
||||
}
|
||||
|
||||
const SHELL_SHEBANG = /^#![^\n]*\b(?:ba|z|da)?sh\b/;
|
||||
const MESSAGE_CALL = /^(?:echo|printf|say|warn|die|fail|err|log|info|note|_dc_row|row)\b/;
|
||||
const CMD_START = /(?:^|[;|&({\x60]|\$\(|\b(?:then|do|else|exec|command|nohup|bash|sh|bun|node|timeout\s+\S+|env(?:\s+\w+=\S*)*|if|while|until|!|time))\s*"?$/;
|
||||
const CMD_PREFIX = /(?:"?(?:\$\{?\w+\}?|\$\(dirname\s+"?\$0"?\)|~|\.)?(?:\/(?:[\w.@-]+\/)*|(?:[\w.@-]+\/)+))?"?$/;
|
||||
const INTERPRETER = /\b(?:bash|sh|zsh|bun|node|eval|source|python3?)\b/;
|
||||
|
||||
/** Text between `$(`…`)` and backticks in a span: substitutions run even inside a message. */
|
||||
function substitutions(span: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (let i = 0; i < span.length; i++) {
|
||||
if (span[i] === '\\') { i++; continue; }
|
||||
if (span[i] === '\x60') { const end = span.indexOf('\x60', i + 1); if (end < 0) break; out.push(`$(${span.slice(i + 1, end)})`); i = end; continue; }
|
||||
if (span[i] !== '$' || span[i + 1] !== '(') continue;
|
||||
let depth = 0, j = i + 1;
|
||||
for (; j < span.length; j++) { if (span[j] === '(') depth++; else if (span[j] === ')' && --depth === 0) break; }
|
||||
out.push(span.slice(i, j + 1));
|
||||
i = j;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Message calls (echo, printf, die …) are prose; their command substitutions still execute and are kept. */
|
||||
function stripMessages(text: string): string {
|
||||
let out = '', i = 0;
|
||||
while (i < text.length) {
|
||||
const prev = i === 0 ? '\n' : text[i - 1]!;
|
||||
const call = /[\s;|&(\x60{]/.test(prev) ? MESSAGE_CALL.exec(text.slice(i, i + 8)) : null;
|
||||
if (!call) { out += text[i]; i++; continue; }
|
||||
let j = i + call[0].length, quote: string | null = null, depth = 0;
|
||||
while (j < text.length) {
|
||||
const c = text[j]!;
|
||||
if (c === '\\') { j += 2; continue; }
|
||||
if (quote) { if (c === quote) quote = null; else if (quote === '"' && c === '$' && text[j + 1] === '(') { depth++; j++; } else if (c === ')' && depth) depth--; j++; continue; }
|
||||
if (c === '"' || c === "'") { quote = c; j++; continue; }
|
||||
if (c === '$' && text[j + 1] === '(') { depth++; j += 2; continue; }
|
||||
if (c === ')' && depth) { depth--; j++; continue; }
|
||||
if (c === '\n' || c === ';' || c === '|' || c === '&' || c === ')') break;
|
||||
j++;
|
||||
}
|
||||
out += ` ${substitutions(text.slice(i, j)).join('; ')} `;
|
||||
i = j;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Executable shell text: comments, case patterns and message calls are
|
||||
* prose. A heredoc an interpreter reads stays whole; an expanding (unquoted)
|
||||
* heredoc keeps its command substitutions; a quoted one fed to anything else
|
||||
* is dropped.
|
||||
*/
|
||||
export function executableShell(text: string): string {
|
||||
return stripMessages(text
|
||||
.replace(/^(.*?)<<-?\s*(['"]?)(\w+)\2([^\n]*)\n([\s\S]*?)\n\s*\3\s*$/gm, (whole, head: string, quote: string, _word: string, rest: string, body: string) =>
|
||||
INTERPRETER.test(head + rest) ? whole : quote ? head : [head, ...substitutions(body)].join('\n'))
|
||||
.split('\n').filter(line => !/^\s*#(?!!)/.test(line))
|
||||
.map(line => line.replace(/(^\s*|\bin\s+|;;\s*)[\w*."'-]+(?:\s*\|\s*[\w*."'-]+)*\)(?=\s|$)/g, '$1').replace(/\s#\s.*$/, ''))
|
||||
.join('\n'));
|
||||
}
|
||||
|
||||
function commandPosition(before: string): boolean {
|
||||
const head = before.replace(CMD_PREFIX, '');
|
||||
return CMD_START.test(head) || (/\/$/.test(before) && /\b\w+="?$/.test(head));
|
||||
}
|
||||
|
||||
/**
|
||||
* Consumers by exercised paths: a case consumes its touchfile matches, the
|
||||
* closure of its test file, the executable shell of its loaded skill
|
||||
* (template and generated SKILL.md) and of every bin, lib and script that
|
||||
* shell runs, and the bins its recorded trials invoked (`observed`). The
|
||||
* closure stops at `stopAt` (the full-gate files) and the global set,
|
||||
* which stay full.
|
||||
*/
|
||||
export function exercisedConsumers(maps: DependencyMaps, root: string, options: { observed: ExercisedMap | null; stopAt: readonly string[] }): ExercisedConsumers {
|
||||
const base = derivedDependencies(maps, root);
|
||||
const tracked = [...base.tracked];
|
||||
const texts = new Map<string, string>();
|
||||
const read = (file: string): string => {
|
||||
if (!texts.has(file)) {
|
||||
let text = '';
|
||||
try { if (base.tracked.has(file) && fs.statSync(path.join(root, file)).size < 3_000_000) text = fs.readFileSync(path.join(root, file), 'utf8'); } catch { /* unreadable */ }
|
||||
texts.set(file, text);
|
||||
}
|
||||
return texts.get(file)!;
|
||||
};
|
||||
const isShell = (file: string) => !isCodeFile(file, read(file)) && (/\.sh$/.test(file) || file === 'setup' || SHELL_SHEBANG.test(read(file)));
|
||||
const dirs = new Set(tracked.flatMap(file => file.split('/').slice(0, -1).map((_, i, parts) => parts.slice(0, i + 1).join('/'))));
|
||||
const expandRef = (ref: string): string[] => {
|
||||
const clean = ref.replace(/[.]+$/, '');
|
||||
if (base.tracked.has(clean)) return [clean];
|
||||
return dirs.has(clean) ? tracked.filter(file => file.startsWith(`${clean}/`)) : [];
|
||||
};
|
||||
const expanded = new Map<string, string[]>();
|
||||
const expand = (pattern: string): string[] => {
|
||||
if (!pattern.includes('*')) return base.tracked.has(pattern) ? [pattern] : [];
|
||||
if (!expanded.has(pattern)) expanded.set(pattern, tracked.filter(file => matchGlob(file, pattern)));
|
||||
return expanded.get(pattern)!;
|
||||
};
|
||||
const binByName = new Map<string, string>();
|
||||
for (const file of tracked.filter(file => file.startsWith('bin/') || file.startsWith('browse/bin/'))) {
|
||||
binByName.set(path.posix.basename(file), file);
|
||||
binByName.set(path.posix.basename(file).replace(/\.(?:ts|sh|js)$/, ''), file);
|
||||
}
|
||||
const binaries = [...read('scripts/build.sh').matchAll(/--compile\b[^\n]*?\s([\w./-]+\.ts)\s+--outfile\s+([\w./-]+)/g)]
|
||||
.map(match => ({ outfile: match[2]!, tree: match[1]!.startsWith('bin/') ? [match[1]!] : tracked.filter(file => matchGlob(file, `${path.posix.dirname(match[1]!)}/**`)) }));
|
||||
const outfileHit = (outfile: string, text: string) => text.includes(outfile)
|
||||
|| (outfile === 'browse/dist/browse' && /\$B\b|\$\{B\}|find-browse/.test(text)) || (outfile === 'design/dist/design' && /\$D\b|\$\{D\}/.test(text));
|
||||
let scripts: Record<string, string> = {};
|
||||
try { scripts = JSON.parse(read('package.json')).scripts ?? {}; } catch { /* no package scripts */ }
|
||||
|
||||
const shellEdges = (text: string, from: string, out: Set<string>) => {
|
||||
for (const match of text.matchAll(/gstack-[a-z0-9][a-z0-9-]*[a-z0-9](?:\.(?:sh|ts))?\b/g)) {
|
||||
const start = text.lastIndexOf('\n', match.index! - 1) + 1;
|
||||
const bin = binByName.get(match[0]);
|
||||
if (bin && commandPosition(text.slice(start, match.index))) out.add(bin);
|
||||
}
|
||||
for (const match of text.matchAll(/(?:bin|lib|scripts|browse|design|make-pdf)\/[\w.@-]+(?:\/[\w.@-]+)*/g)) {
|
||||
const before = text.slice(text.lastIndexOf('\n', match.index! - 1) + 1, match.index);
|
||||
if (/[\w.@-]$/.test(before)) continue;
|
||||
if (commandPosition(before) || /\bbun\s+(?:run\s+)?"?(?:\$\{?\w+\}?\/)?$/.test(before)) for (const file of expandRef(match[0])) out.add(file);
|
||||
}
|
||||
for (const match of text.matchAll(/(?:bin|lib|scripts|browse|design|make-pdf|hosts|capy|test\/fixtures|test\/helpers)\/[\w.@-]+(?:\/[\w.@-]+)*\.(?:json|jsonl|env|txt|ya?ml|toml|csv|tsv)\b/g)) for (const file of expandRef(match[0])) out.add(file);
|
||||
for (const match of text.matchAll(/(?:^|[\s;&|({])(?:\.|source)\s+([^;|&\n]+)/gm)) {
|
||||
for (const ref of match[1]!.matchAll(/(?:bin|lib|scripts)\/[\w.@-]+(?:\/[\w.@-]+)*\.sh\b/g)) for (const file of expandRef(ref[0])) out.add(file);
|
||||
const name = [...match[1]!.matchAll(/([\w.-]+\.sh|gstack-[\w.-]+)/g)].map(found => found[1]!).at(-1);
|
||||
if (!name) continue;
|
||||
const sibling = path.posix.join(path.posix.dirname(from), name);
|
||||
if (base.tracked.has(sibling)) out.add(sibling);
|
||||
else if (binByName.has(name)) out.add(binByName.get(name)!);
|
||||
}
|
||||
if (/(?:^|[\s;&|(])"?(?:\.|\$\{?\w+\}?)\/setup\b/m.test(text) && base.tracked.has('setup')) out.add('setup');
|
||||
for (const { outfile, tree } of binaries) if (outfileHit(outfile, text)) for (const file of tree) out.add(file);
|
||||
for (const match of text.matchAll(PACKAGE_RUN)) {
|
||||
const command = scripts[match[1]!];
|
||||
if (command) for (const ref of command.matchAll(REPO_PATH)) for (const file of expandRef(ref[1]!)) out.add(file);
|
||||
}
|
||||
};
|
||||
const edges = new Map<string, string[]>();
|
||||
const execRefs = (file: string): string[] => {
|
||||
if (edges.has(file)) return edges.get(file)!;
|
||||
const raw = read(file);
|
||||
const out = new Set<string>();
|
||||
if (raw && !(SELECTION_DATA_MODULES as readonly string[]).includes(file)) {
|
||||
if (isCodeFile(file, raw)) {
|
||||
try { for (const specifier of scanCodeImports(file, raw)) { const hit = resolveSpecifier(root, specifier, file); if (hit) out.add(hit); } }
|
||||
catch (error) { base.scanErrors.set(file, String(error)); }
|
||||
const code = raw.replace(/^#![^\n]*/, '').replace(/\/\*[\s\S]*?\*\//g, '').replace(/^[ \t]*\/\/.*$/gm, '');
|
||||
for (const match of code.matchAll(/['"\x60](?:\$\{[^}]+\}\/)?((?:bin|lib|scripts|browse|design|make-pdf|hosts|extension|model-overlays|agents|test\/helpers|test\/fixtures)\/[\w.@-]+(?:\/[\w.@-]+)*)['"\x60]/g)) for (const hit of expandRef(match[1]!)) out.add(hit);
|
||||
for (const match of code.matchAll(JOINED_PATH)) for (const hit of expandRef(`${match[1]}/${match[2]}`)) out.add(hit);
|
||||
if (/join\([^)\n]*['"]setup['"]\s*\)/.test(code) && base.tracked.has('setup')) out.add('setup');
|
||||
for (const { outfile, tree } of binaries) if (code.includes(`'${outfile}'`) || code.includes(`"${outfile}"`)) for (const hit of tree) out.add(hit);
|
||||
} else if (isShell(file)) {
|
||||
shellEdges(executableShell(raw), file, out);
|
||||
} else if (/\.(?:md|tmpl)$/.test(file)) {
|
||||
shellEdges(executableShell([...raw.matchAll(/\x60\x60\x60(?:bash|sh|shell)\n([\s\S]*?)\x60\x60\x60/g)].map(match => match[1]!).join('\n')), file, out);
|
||||
}
|
||||
}
|
||||
out.delete(file);
|
||||
edges.set(file, [...out]);
|
||||
return edges.get(file)!;
|
||||
};
|
||||
const stopped = new Map<string, boolean>();
|
||||
const stops = (file: string): boolean => {
|
||||
if (!stopped.has(file)) stopped.set(file, base.global.has(file) || options.stopAt.some(pattern => matchGlob(file, pattern)));
|
||||
return stopped.get(file)!;
|
||||
};
|
||||
const binsInCommand = (command: string): string[] => {
|
||||
const out = new Set<string>();
|
||||
const pty = command.startsWith('PTY:');
|
||||
for (const match of command.matchAll(/gstack-[a-z0-9-]*[a-z0-9](?:\.(?:sh|ts))?/g)) {
|
||||
let token = match[0];
|
||||
if (pty) while (token.length > 7 && !binByName.has(token)) { const cut = token.lastIndexOf('-'); token = cut > 6 ? token.slice(0, cut) : ''; }
|
||||
const at = match.index!;
|
||||
if (command[at - 1] === '/' && at >= 2 && /[\s"'\x60(]/.test(command[at - 2]!)) continue;
|
||||
const bin = binByName.get(token);
|
||||
if (bin) out.add(bin);
|
||||
}
|
||||
for (const match of command.matchAll(/browse\/bin\/([a-z-]+)/g)) { const bin = binByName.get(match[1]!); if (bin) out.add(bin); }
|
||||
for (const { outfile } of binaries) if (outfileHit(outfile, command) || (outfile === 'make-pdf/dist/pdf' && command.includes(outfile))) out.add(outfile);
|
||||
return [...out];
|
||||
};
|
||||
const commandFiles = (command: string) => base.tracked.has(command) ? [command] : binaries.find(binary => binary.outfile === command)?.tree ?? [];
|
||||
|
||||
const hostIds = tracked.filter(file => /^hosts\/[\w-]+\.ts$/.test(file) && file !== 'hosts/index.ts').map(file => path.posix.basename(file, '.ts'));
|
||||
const closures = new Map<string, Map<string, string>>();
|
||||
const caseClosure = (id: string): Map<string, string> => {
|
||||
if (closures.has(id)) return closures.get(id)!;
|
||||
const patterns = maps.e2eTouchfiles[id] ?? [];
|
||||
const owners = patterns.filter(pattern => OWNER.test(pattern) && isPaidTestFile(pattern));
|
||||
const declared = patterns.flatMap(expand);
|
||||
const generated = declared.filter(file => file.endsWith('.tmpl')).map(file => file.replace(/\.tmpl$/, '')).filter(file => base.tracked.has(file));
|
||||
const touchfiles = new Set([...declared, ...generated]);
|
||||
const skillDoc = (file: string) => /\.(?:md|tmpl)$/.test(file);
|
||||
const seeds: Array<[string, string]> = [
|
||||
...owners.map(file => [file, 'import'] as [string, string]),
|
||||
...[...declared, ...generated].map(file => [file, skillDoc(file) ? 'skill' : `declared via ${file}`] as [string, string]),
|
||||
...(options.observed?.cases[id]?.commands ?? []).flatMap(command => commandFiles(command).map(file => [file, `observed ${command}`] as [string, string])),
|
||||
];
|
||||
const ownerText = owners.map(read).join('\n');
|
||||
for (const host of hostIds) {
|
||||
if (new RegExp(`['"\x60]${host}['"\x60]|--host[ =]${host}\\b`).test(ownerText)) for (const file of [`hosts/${host}.ts`, ...expand(`hosts/${host}/**`)]) seeds.push([file, 'host']);
|
||||
}
|
||||
const reached = new Map<string, string>();
|
||||
const queue = seeds.filter(([file]) => base.tracked.has(file));
|
||||
for (let head = 0; head < queue.length; head++) {
|
||||
const [file, label] = queue[head]!;
|
||||
if (reached.has(file)) continue;
|
||||
reached.set(file, touchfiles.has(file) ? 'touchfile' : label);
|
||||
if (stops(file)) continue;
|
||||
for (const next of execRefs(file)) {
|
||||
if (reached.has(next) || (/\.test\.tsx?$/.test(next) && isPaidTestFile(next))) continue;
|
||||
queue.push([next, label === 'skill' ? `skill bash via ${next}` : label]);
|
||||
}
|
||||
}
|
||||
closures.set(id, reached);
|
||||
return reached;
|
||||
};
|
||||
const e2e = new Map<string, Set<string>>();
|
||||
for (const id of Object.keys(maps.e2eTouchfiles)) {
|
||||
for (const file of caseClosure(id).keys()) {
|
||||
if (!e2e.has(file)) e2e.set(file, new Set());
|
||||
e2e.get(file)!.add(id);
|
||||
}
|
||||
}
|
||||
return { e2e, closure: caseClosure, binsInCommand, commandFiles, map: options.observed };
|
||||
}
|
||||
|
||||
const exercisedCache = new Map<string, ExercisedConsumers>();
|
||||
|
||||
/** The exercised consumers of the checkout at `root` with its committed map, computed once per process; throws when the map is malformed. */
|
||||
export function checkoutExercisedConsumers(maps: DependencyMaps, root: string, stopAt: readonly string[]): ExercisedConsumers {
|
||||
const key = `${root}\0${Object.keys(maps.e2eTouchfiles).length}`;
|
||||
if (!exercisedCache.has(key)) {
|
||||
const observed = parseExercisedMap(fs.readFileSync(path.join(root, EXERCISED_MAP_PATH), 'utf8'));
|
||||
exercisedCache.set(key, exercisedConsumers(maps, root, { observed, stopAt }));
|
||||
}
|
||||
return exercisedCache.get(key)!;
|
||||
}
|
||||
@@ -5,7 +5,9 @@ import { E2E_TOUCHFILES, E2E_TIERS, GLOBAL_TOUCHFILES, LLM_JUDGE_TOUCHFILES } fr
|
||||
import { matchGlob, TOUCHFILES_DATA_PATH } from '../test/helpers/test-selection';
|
||||
import { isPaidTestFile } from '../test/helpers/paid-test-set';
|
||||
import { FREE_FIXTURES } from '../test/helpers/free-fixtures-data';
|
||||
import { derivedDependencies, type DerivedDependencies } from './pr-dependencies';
|
||||
import {
|
||||
checkoutExercisedConsumers, derivedDependencies, EXERCISED_MAP_PATH, type DerivedDependencies, type ExercisedConsumers,
|
||||
} from './pr-dependencies';
|
||||
|
||||
/** Existing short behavioral probes; intersect with changed-input selection. */
|
||||
export const PR_PROFILE_CASE_IDS = [
|
||||
@@ -157,6 +159,27 @@ export interface PrProfileSelection {
|
||||
missingCoverage: string[];
|
||||
needsFullValidation: boolean;
|
||||
reasons: string[];
|
||||
/** What the exercised-path map would select (shadow mode: never changes e2e or judges). */
|
||||
shadow?: ShadowSelection;
|
||||
}
|
||||
|
||||
/** The exercised-path map's selection beside the real one (docs/TESTING_INTERNALS.md#exercised-path-map). */
|
||||
export interface ShadowSelection {
|
||||
/** Gate cases the map would select. */
|
||||
e2e: string[];
|
||||
/** Selected gate cases the map would not select. */
|
||||
wouldDrop: string[];
|
||||
/** Gate cases the map would select that today's selection does not. */
|
||||
wouldAdd: string[];
|
||||
/** Per changed file with exercised consumers: how many gate cases it reaches, and through which edge. */
|
||||
provenance: Array<{ file: string; summary: string }>;
|
||||
/** Mean known cost of the dropped cases minus the added ones (the map's per-case costUsd; an unpriced case counts $0): a lower bound. */
|
||||
predictedSavingUsd: number;
|
||||
unpricedCases: number;
|
||||
/** Share of observed case runs whose trials carry no known cost (PTY trials); their spend is missing from every dollar figure. */
|
||||
unknownCostShare: number;
|
||||
/** Why no shadow could be computed (the real selection is unaffected). */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Reject stale profile registrations and deferred cases with no scheduled home. */
|
||||
@@ -296,6 +319,7 @@ export function knownNonBehaviorFile(file: string): boolean {
|
||||
}
|
||||
|
||||
export const FALLBACK_FIX_ANCHOR = 'docs/TESTING_INTERNALS.md#pr-paid-lane-fallback';
|
||||
export const EXERCISED_MAP_ANCHOR = 'docs/TESTING_INTERNALS.md#exercised-path-map';
|
||||
|
||||
/** Why one changed file restored the full gate, and the edit that narrows it next time (DX-6). */
|
||||
export function unknownFileLabel(file: string): { label: string; fix: string } {
|
||||
@@ -347,6 +371,8 @@ export function selectPrProfile(options: {
|
||||
readSource?: (file: string) => string;
|
||||
/** Derived references; default: derived from the checkout for the real maps, none for fixture maps. */
|
||||
derived?: DerivedDependencies | null;
|
||||
/** Exercised-path consumers for the shadow block; default: the checkout's committed map for the real maps, none for fixture maps. */
|
||||
exercised?: ExercisedConsumers | null;
|
||||
}): PrProfileSelection {
|
||||
const maps = options.maps ?? PR_PROFILE_MAPS;
|
||||
const profile = options.profile ?? PR_PROFILE_CASE_IDS;
|
||||
@@ -360,7 +386,7 @@ export function selectPrProfile(options: {
|
||||
...Object.values(maps.e2eTouchfiles).flat(), ...Object.values(maps.judgeTouchfiles).flat(),
|
||||
...maps.globalTouchfiles,
|
||||
])];
|
||||
const unmapped = files.filter(file => file !== TOUCHFILES_DATA_PATH
|
||||
const unmapped = files.filter(file => file !== TOUCHFILES_DATA_PATH && file !== EXERCISED_MAP_PATH
|
||||
&& !depends(file, dependencyPatterns) && !knownNonBehaviorFile(file));
|
||||
// Unmapped files are placed by derived references where the derivation covers them; the rest restore the full gate.
|
||||
const derived = options.derived !== undefined ? options.derived : maps === PR_PROFILE_MAPS ? derivedDependencies(maps, ROOT) : null;
|
||||
@@ -437,25 +463,80 @@ export function selectPrProfile(options: {
|
||||
if (sharedInputs.length) reasons.push(`Shared runtime/build inputs restore every gate case and judge: ${sharedInputs.join(', ')}`);
|
||||
if (dependentsMode) reasons.push(`Derived references select every dependent gate case: ${derivedFiles.map(file => `${file} (${consumers.get(file)!.e2e.size} cases)`).join(', ')}`);
|
||||
else if (!fallback) reasons.push('Changed-input selection intersected with the fast PR profile; selected judges retained');
|
||||
if (files.includes(EXERCISED_MAP_PATH)) reasons.push(`${EXERCISED_MAP_PATH} is shadow-only data (it changes no selection yet), so it selects no paid case (${EXERCISED_MAP_ANCHOR})`);
|
||||
if (derivedGlobal.length) reasons.push(`Paid-runner inputs (global touchfile import closure) select every case: ${derivedGlobal.join(', ')}`);
|
||||
if (noConsumerFiles.length) reasons.push(`No paid case's reference closure reaches (or, deleted, still references): ${noConsumerFiles.join(', ')}`);
|
||||
if (directCases.length) reasons.push(`Edited paid test files select their own gate cases: ${directCases.join(', ')}`);
|
||||
if (deferred.length) reasons.push(`${deferred.length} selected behaviors remain scheduled/release coverage, not PR passes`);
|
||||
if (deferredPromptFiles.length) reasons.push(`No quick live coverage; known broad prompt checks deferred: ${deferredPromptFiles.join(', ')}`);
|
||||
if (missingCoverage.length) reasons.push(`Full validation required for prompts without a relevant PR check: ${missingCoverage.join(', ')}`);
|
||||
const exercised = options.exercised !== undefined ? options.exercised : maps === PR_PROFILE_MAPS ? checkoutExercised() : null;
|
||||
const shadow = exercised instanceof Error ? emptyShadow(e2e, exercised.message)
|
||||
: exercised ? shadowSelection({ ...options, maps, profile }, { e2e, fallback, derived, exercised, files }) : undefined;
|
||||
return {
|
||||
mode: fallback ? 'full-fallback' : dependentsMode ? 'dependents' : 'pr', e2e, judges, deferred,
|
||||
unknownFiles, unknownFileLabels: unknownFiles.map(file => ({ file, ...(deletedLabels.get(file) ?? unknownFileLabel(file)) })), directCases,
|
||||
derivedFiles: derivedFiles.map(file => ({ file, e2e: consumers.get(file)!.e2e.size, judges: consumers.get(file)!.judges.size })), noConsumerFiles,
|
||||
deferredPromptFiles, missingCoverage, needsFullValidation: missingCoverage.length > 0, reasons,
|
||||
deferredPromptFiles, missingCoverage, needsFullValidation: missingCoverage.length > 0, reasons, ...(shadow ? { shadow } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function checkoutExercised(): ExercisedConsumers | Error {
|
||||
try { return checkoutExercisedConsumers(PR_PROFILE_MAPS, ROOT, PR_FULL_GATE_FILES); }
|
||||
catch (error) { return error instanceof Error ? error : new Error(String(error)); }
|
||||
}
|
||||
|
||||
function emptyShadow(e2e: string[], error: string): ShadowSelection {
|
||||
return { e2e, wouldDrop: [], wouldAdd: [], provenance: [], predictedSavingUsd: 0, unpricedCases: 0, unknownCostShare: 0, error };
|
||||
}
|
||||
|
||||
/**
|
||||
* Shadow selection (variant cu of the measured prototype): the same policy
|
||||
* with the exercised map in place of the derived closure, unioned with the
|
||||
* exercised consumers of every changed file that is not global or full-gate.
|
||||
* A full-gate fallback stays full.
|
||||
*/
|
||||
function shadowSelection(options: Parameters<typeof selectPrProfile>[0] & { maps: PrProfileMaps },
|
||||
today: { e2e: string[]; fallback: boolean; derived: DerivedDependencies | null; exercised: ExercisedConsumers; files: string[] }): ShadowSelection {
|
||||
const { maps } = options;
|
||||
const { exercised } = today;
|
||||
const observedCases = Object.values(exercised.map?.cases ?? {});
|
||||
const observations = observedCases.reduce((sum, entry) => sum + entry.observations, 0);
|
||||
const unknownCostShare = observations ? Math.round(1000 * (1 - observedCases.reduce((sum, entry) => sum + (entry.costRuns ?? 0), 0) / observations)) / 1000 : 0;
|
||||
if (today.fallback) return { e2e: today.e2e, wouldDrop: [], wouldAdd: [], provenance: [], predictedSavingUsd: 0, unpricedCases: 0, unknownCostShare };
|
||||
const gate = (ids: Iterable<string>) => [...ids].filter(id => maps.tiers[id] === 'gate');
|
||||
const reach = new Map<string, string[]>();
|
||||
const inner = selectPrProfile({ ...options, derived: today.derived ? { ...today.derived, e2e: exercised.e2e } : null, exercised: null });
|
||||
const sources = [...new Set([...today.files, ...Object.values(options.sourceAliases ?? {})])];
|
||||
const shadowSet = new Set(inner.e2e);
|
||||
for (const file of sources) {
|
||||
if (today.derived?.global.has(file) || matches(file, PR_FULL_GATE_FILES) || knownNonBehaviorFile(file)) continue;
|
||||
const via = !today.derived || today.derived.tracked.has(file) ? [file] : today.derived.referencers(file);
|
||||
const ids = gate(new Set(via.flatMap(source => [...exercised.e2e.get(source) ?? []])));
|
||||
if (ids.length) reach.set(file, ids);
|
||||
for (const id of ids) shadowSet.add(id);
|
||||
}
|
||||
const e2e = [...shadowSet].sort();
|
||||
const wouldDrop = today.e2e.filter(id => !shadowSet.has(id));
|
||||
const wouldAdd = e2e.filter(id => !today.e2e.includes(id));
|
||||
const cost = (ids: string[]) => ids.reduce((sum, id) => sum + (exercised.map?.cases[id]?.costUsd ?? 0), 0);
|
||||
const provenance = [...reach].map(([file, ids]) => {
|
||||
const groups = new Map<string, number>();
|
||||
for (const id of ids) {
|
||||
const label = exercised.closure(id).get(file) ?? 'referencer';
|
||||
groups.set(label, (groups.get(label) ?? 0) + 1);
|
||||
}
|
||||
return { file, summary: `selects ${ids.length} case(s): ${[...groups].sort((a, b) => b[1] - a[1]).slice(0, 6).map(([label, n]) => `${label} (${n})`).join(', ')}` };
|
||||
});
|
||||
return { e2e, wouldDrop, wouldAdd, provenance, predictedSavingUsd: Math.round((cost(wouldDrop) - cost(wouldAdd)) * 100) / 100,
|
||||
unpricedCases: [...wouldDrop, ...wouldAdd].filter(id => exercised.map?.cases[id]?.costUsd === undefined).length, unknownCostShare };
|
||||
}
|
||||
|
||||
/** The manifest fields the coverage summary reads (a PaidRunManifest subset). */
|
||||
export interface CoverageSummaryInput {
|
||||
profile?: string;
|
||||
selection?: { e2e: string[] | null; judges: string[] | null };
|
||||
prCoverage?: Pick<PrProfileSelection, 'mode' | 'deferred' | 'unknownFiles'> & Partial<Pick<PrProfileSelection, 'unknownFileLabels' | 'derivedFiles' | 'noConsumerFiles'>>;
|
||||
prCoverage?: Pick<PrProfileSelection, 'mode' | 'deferred' | 'unknownFiles'> & Partial<Pick<PrProfileSelection, 'unknownFileLabels' | 'derivedFiles' | 'noConsumerFiles' | 'shadow'>>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -476,6 +557,9 @@ export function formatPrCoverageSummary(manifest: CoverageSummaryInput, totals?:
|
||||
lines.push(`- Derived dependents (scripts/pr-dependencies.ts) of ${coverage.derivedFiles.length} unmapped file(s):`);
|
||||
for (const item of coverage.derivedFiles.slice(0, 40)) lines.push(` - \`${item.file}\`: ${item.e2e} case(s), ${item.judges} judge(s)`);
|
||||
}
|
||||
const shadow = coverage?.shadow;
|
||||
if (shadow) lines.push(shadow.error ? `- Shadow exercised-path map: unavailable (${shadow.error}); selection unaffected`
|
||||
: `- Shadow exercised-path map (not active): would select ${shadow.e2e.length} gate case(s), dropping ${shadow.wouldDrop.length} and adding ${shadow.wouldAdd.length}; predicted saving $${shadow.predictedSavingUsd.toFixed(2)}${shadow.unpricedCases ? ` (${shadow.unpricedCases} unpriced)` : ''}, a lower bound: known cost excludes the ${Math.round(100 * (shadow.unknownCostShare ?? 0))}% of observed case runs with unknown cost (PTY) (${EXERCISED_MAP_ANCHOR})`);
|
||||
if (coverage?.noConsumerFiles?.length) lines.push(`- No paid case reaches: ${coverage.noConsumerFiles.slice(0, 40).map(file => `\`${file}\``).join(', ')}`);
|
||||
if (coverage?.mode === 'full-fallback' && coverage.unknownFiles.length) {
|
||||
lines.push(`- Full gate restored by ${coverage.unknownFiles.length} file(s) (fix: ${FALLBACK_FIX_ANCHOR}):`);
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Frozen parity oracle for the exercised-path map (W2-A2): the measured
|
||||
* prototype (GSTA-45 investigation, exercised.ts at 521fd42, REPORT-2) with
|
||||
* only its module-level root and base derivation turned into parameters, its
|
||||
* path-prefix regex rewritten to an equivalent non-backtracking form, its
|
||||
* import scan switched to A1's scanCodeImports (the prototype ran on the
|
||||
* shebang-fixed base; A1 also fixed the TSX loader misreading .ts generics),
|
||||
* and the per-case build of its replay (variant cu) appended. Do not edit its
|
||||
* edge rules: test/exercised-map.test.ts pins that the shipped
|
||||
* exercisedConsumers() equals or is a reviewed superset of this map.
|
||||
*/
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { derivedDependencies, scanCodeImports, SELECTION_DATA_MODULES, type DependencyMaps, type ExercisedMap } from '../../scripts/pr-dependencies';
|
||||
import { matchGlob } from './test-selection';
|
||||
import { isPaidTestFile } from './paid-test-set';
|
||||
|
||||
export function prototypeConsumers(maps: DependencyMaps, ROOT: string, observedMap: ExercisedMap | null, PR_FULL_GATE_FILES: readonly string[]): Map<string, Set<string>> {
|
||||
const base = derivedDependencies(maps, ROOT);
|
||||
const tracked: string[] = [...base.tracked];
|
||||
const trackedSet = base.tracked as Set<string>;
|
||||
const cache = new Map<string, string>();
|
||||
const read = (f: string) => { if (!cache.has(f)) { let t = ''; try { if (trackedSet.has(f) && fs.statSync(path.join(ROOT, f)).size < 3_000_000) t = fs.readFileSync(path.join(ROOT, f), 'utf8'); } catch {} cache.set(f, t); } return cache.get(f)!; };
|
||||
const CODE = /\.(?:[cm]?[jt]s|tsx|jsx)$/;
|
||||
const isBunShebang = (f: string) => /^#!.*\b(?:bun|node)\b/.test(read(f).slice(0, 200));
|
||||
const isCode = (f: string) => CODE.test(f) || isBunShebang(f);
|
||||
const isShell = (f: string) => !isCode(f) && (/\.sh$/.test(f) || f === 'setup' || /^#!.*\b(?:ba|z|da)?sh\b/.test(read(f).slice(0, 200)));
|
||||
const resolveSpec = (spec: string, from: string) => {
|
||||
if (!spec.startsWith('.')) return null;
|
||||
const b = path.posix.normalize(path.posix.join(path.posix.dirname(from), spec));
|
||||
for (const c of [b, b + '.ts', b + '.tsx', b + '.js', b + '.mjs', b + '/index.ts', b + '/index.js']) if (trackedSet.has(c)) return c;
|
||||
return null;
|
||||
};
|
||||
const dirs = new Set(tracked.flatMap(f => f.split('/').slice(0, -1).map((_, i, p) => p.slice(0, i + 1).join('/'))));
|
||||
const REPO_PATH = /(?:^|[^\w.@-])((?:bin|lib|scripts|browse|design|make-pdf|hosts|extension|model-overlays|agents|test\/helpers|test\/fixtures|capy)\/[\w.@-]+(?:\/[\w.@-]+)*)/g;
|
||||
const JOINED = /['"\x60](bin|lib|scripts|browse|design|make-pdf|hosts|extension|model-overlays|agents)['"\x60]\s*,\s*['"\x60]([\w.@-]+)['"\x60]/g;
|
||||
const binByName = new Map<string, string>();
|
||||
for (const f of tracked) if (f.startsWith('bin/') || f.startsWith('browse/bin/')) { const b = path.posix.basename(f); binByName.set(b, f); binByName.set(b.replace(/\.(ts|sh|js)$/, ''), f); }
|
||||
const BINARIES = [...read('scripts/build.sh').matchAll(/--compile\b[^\n]*?\s([\w./-]+\.ts)\s+--outfile\s+([\w./-]+)/g)].map(m => ({ outfile: m[2]!, entry: m[1]!, tree: m[1]!.startsWith('bin/') ? [m[1]!] : tracked.filter(f => matchGlob(f, path.posix.dirname(m[1]!) + '/**')) }));
|
||||
let pkgScripts: Record<string, string> = {}; try { pkgScripts = JSON.parse(read('package.json')).scripts ?? {}; } catch {}
|
||||
const expandRef = (ref: string) => { ref = ref.replace(/[.]+$/, ''); if (trackedSet.has(ref)) return [ref]; if (dirs.has(ref)) return tracked.filter(f => f.startsWith(ref + '/')); return []; };
|
||||
const stripCode = (t: string) => t.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^[ \t]*\/\/.*$/gm, '');
|
||||
const stripMessages = (t: string) => {
|
||||
let out = '', i = 0;
|
||||
while (i < t.length) {
|
||||
const m = /^(?:echo|printf|say|warn|die|fail|err|log|info|note|_dc_row|row)\b/.exec(t.slice(i, i + 8));
|
||||
const prev = i === 0 ? '\n' : t[i - 1]!;
|
||||
if (m && /[\s;|&(\x60{]/.test(prev)) {
|
||||
let j = i + m[0].length, q: string | null = null;
|
||||
while (j < t.length) { const c = t[j]!; if (q) { if (c === '\\') { j += 2; continue; } if (c === q) q = null; j++; continue; } if (c === '"' || c === "'") { q = c; j++; continue; } if (c === '\n' || c === ';' || c === '|' || c === '&' || c === ')') break; j++; }
|
||||
out += ' '; i = j; continue;
|
||||
}
|
||||
out += t[i]; i++;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
const stripShell = (t: string) => stripMessages(t
|
||||
.replace(/<<-?\s*['"]?(\w+)['"]?[^\n]*\n[\s\S]*?\n\s*\1\s*$/gm, '')
|
||||
.split('\n').filter(l => !/^\s*#(?!!)/.test(l))
|
||||
.map(l => l.replace(/(^\s*|\bin\s+|;;\s*)[\w*."'-]+(?:\s*\|\s*[\w*."'-]+)*\)(?=\s|$)/g, '$1').replace(/\s#\s.*$/, ''))
|
||||
.join('\n'));
|
||||
const CMD_START = /(?:^|[;|&({\x60]|\$\(|\b(?:then|do|else|exec|command|nohup|bash|sh|bun|node|timeout\s+\S+|env(?:\s+\w+=\S*)*|if|while|until|!|time))\s*"?$/;
|
||||
// Changed from the prototype: its `(?:\/?[\w.@-]+)*\/` path prefix backtracks exponentially; this is the same language (words separated by single slashes, ending in a slash) in an unambiguous form.
|
||||
const PREFIX = /(?:"?(?:\$\{?\w+\}?|\$\(dirname\s+"?\$0"?\)|~|\.)?(?:\/(?:[\w.@-]+\/)*|(?:[\w.@-]+\/)+))?"?$/;
|
||||
const cmdOk = (before: string) => { const b = before.replace(PREFIX, ''); return CMD_START.test(b) || (/\/$/.test(before) && /\b\w+="?$/.test(b)); };
|
||||
const shellExec = (text: string, from: string, out: Set<string>) => {
|
||||
for (const m of text.matchAll(/gstack-[a-z0-9][a-z0-9-]*[a-z0-9](?:\.(?:sh|ts))?\b/g)) {
|
||||
const i = m.index!; const ls = text.lastIndexOf('\n', i - 1) + 1;
|
||||
if (!cmdOk(text.slice(ls, i))) continue;
|
||||
const b = binByName.get(m[0]); if (b) out.add(b);
|
||||
}
|
||||
for (const m of text.matchAll(/(?:bin|lib|scripts|browse|design|make-pdf)\/[\w.@-]+(?:\/[\w.@-]+)*/g)) {
|
||||
const i = m.index!; const ls = text.lastIndexOf('\n', i - 1) + 1; const before = text.slice(ls, i);
|
||||
if (/[\w.@-]$/.test(before)) continue;
|
||||
if (!cmdOk(before) && !/\bbun\s+(?:run\s+)?"?(?:\$\{?\w+\}?\/)?$/.test(before)) continue;
|
||||
for (const f of expandRef(m[0])) out.add(f);
|
||||
}
|
||||
for (const m of text.matchAll(/(?:bin|lib|scripts|browse|design|make-pdf|hosts|capy|test\/fixtures|test\/helpers)\/[\w.@-]+(?:\/[\w.@-]+)*\.(?:json|jsonl|env|txt|ya?ml|toml|csv|tsv)\b/g)) for (const f of expandRef(m[0])) out.add(f);
|
||||
for (const m of text.matchAll(/(?:^|[\s;&|({])(?:\.|source)\s+([^;|&\n]+)/gm)) { const names = [...m[1]!.matchAll(/([\w.-]+\.sh|gstack-[\w.-]+)/g)].map(x => x[1]!); if (!names.length) continue; const b = names[names.length - 1]!; const sib = path.posix.join(path.posix.dirname(from), b); if (trackedSet.has(sib)) out.add(sib); else if (binByName.get(b)) out.add(binByName.get(b)!); }
|
||||
for (const bin of BINARIES) if (text.includes(bin.outfile) || (bin.outfile === 'browse/dist/browse' && /\$B\b|find-browse/.test(text)) || (bin.outfile === 'design/dist/design' && /\$D\b/.test(text))) for (const f of bin.tree) out.add(f);
|
||||
for (const m of text.matchAll(/\b(?:bun|npm) run ([a-z][\w:.-]*)/g)) { const c = pkgScripts[m[1]!]; if (c) for (const r of c.matchAll(REPO_PATH)) for (const f of expandRef(r[1]!)) out.add(f); }
|
||||
};
|
||||
const bashBlocks = (md: string) => [...md.matchAll(/\x60\x60\x60(?:bash|sh|shell)\n([\s\S]*?)\x60\x60\x60/g)].map(m => m[1]!).join('\n');
|
||||
const edges = new Map<string, string[]>();
|
||||
const execRefs = (f: string): string[] => {
|
||||
if (edges.has(f)) return edges.get(f)!;
|
||||
const out = new Set<string>();
|
||||
const raw = read(f);
|
||||
if ((SELECTION_DATA_MODULES as readonly string[]).includes(f) || !raw) { edges.set(f, []); return []; }
|
||||
if (isCode(f)) {
|
||||
try { for (const spec of scanCodeImports(f, raw)) { const h = resolveSpec(spec, f); if (h) out.add(h); } } catch {}
|
||||
const code = stripCode(raw.replace(/^#!.*/, ''));
|
||||
for (const m of code.matchAll(/['"\x60](?:\$\{[^}]+\}\/)?((?:bin|lib|scripts|browse|design|make-pdf|hosts|extension|model-overlays|agents|test\/helpers|test\/fixtures)\/[\w.@-]+(?:\/[\w.@-]+)*)['"\x60]/g)) for (const x of expandRef(m[1]!)) out.add(x);
|
||||
for (const m of code.matchAll(JOINED)) for (const x of expandRef(m[1] + '/' + m[2])) out.add(x);
|
||||
for (const bin of BINARIES) if (code.includes("'" + bin.outfile + "'") || code.includes('"' + bin.outfile + '"')) for (const x of bin.tree) out.add(x);
|
||||
} else if (isShell(f)) {
|
||||
shellExec(stripShell(raw), f, out);
|
||||
} else if (/\.(md|tmpl)$/.test(f)) {
|
||||
shellExec(stripShell(bashBlocks(raw)), f, out);
|
||||
}
|
||||
out.delete(f);
|
||||
const list = [...out]; edges.set(f, list); return list;
|
||||
};
|
||||
const isFullGate = (f: string) => PR_FULL_GATE_FILES.some((p: string) => matchGlob(f, p));
|
||||
const closure = (entries: Iterable<string>) => {
|
||||
const seen = new Set<string>(); const q = [...entries].filter(f => trackedSet.has(f));
|
||||
while (q.length) { const f = q.pop()!; if (seen.has(f)) continue; seen.add(f); if (base.global.has(f) || isFullGate(f)) continue; for (const n of execRefs(f)) if (!seen.has(n) && !(/\.test\.tsx?$/.test(n) && isPaidTestFile(n))) q.push(n); }
|
||||
return seen;
|
||||
};
|
||||
const expand = (p: string) => p.includes('*') ? tracked.filter(f => matchGlob(f, p)) : trackedSet.has(p) ? [p] : [];
|
||||
const OWNER = /^test\/[^/]+\.test\.ts$/;
|
||||
function caseEntries(patterns: string[], defaultOwner: string | null) {
|
||||
const owners = patterns.filter(p => OWNER.test(p) && isPaidTestFile(p));
|
||||
const files = [...(owners.length ? owners : defaultOwner ? [defaultOwner] : []), ...patterns.flatMap(expand)];
|
||||
const gen = files.filter(f => f.endsWith('.tmpl')).map(f => f.replace(/\.tmpl$/, '')).filter(f => trackedSet.has(f));
|
||||
return [...files, ...gen];
|
||||
}
|
||||
function binsInCommand(cmd: string): string[] {
|
||||
const out = new Set<string>();
|
||||
const pty = cmd.startsWith('PTY:');
|
||||
for (const m of cmd.matchAll(/gstack-[a-z0-9-]*[a-z0-9](?:\.(?:sh|ts))?/g)) {
|
||||
let tok = m[0]!;
|
||||
if (pty) { while (tok.length > 7 && !binByName.has(tok)) { const k = tok.lastIndexOf('-'); tok = k > 6 ? tok.slice(0, k) : ''; } }
|
||||
const i = m.index!; if (cmd[i - 1] === '/' && i >= 2 && /[\s"'\x60(]/.test(cmd[i - 2]!)) continue;
|
||||
const b = binByName.get(tok); if (b) out.add(b);
|
||||
}
|
||||
for (const m of cmd.matchAll(/browse\/bin\/([a-z-]+)/g)) { const b = binByName.get(m[1]!); if (b) out.add(b); }
|
||||
for (const bin of BINARIES) {
|
||||
const hit = cmd.includes(bin.outfile) || (bin.outfile === 'browse/dist/browse' && /\$B\b|\$\{B\}|find-browse/.test(cmd)) || (bin.outfile === 'design/dist/design' && /\$D\b|\$\{D\}/.test(cmd)) || (bin.outfile === 'make-pdf/dist/pdf' && /make-pdf\/dist\/pdf/.test(cmd));
|
||||
if (hit) out.add(bin.outfile);
|
||||
}
|
||||
return [...out];
|
||||
}
|
||||
const binaryTree = (outfile: string) => BINARIES.find(b => b.outfile === outfile)?.tree ?? [];
|
||||
|
||||
const observed = observedMap?.cases ?? {};
|
||||
const hostIds = tracked.filter((f: string) => /^hosts\/[\w-]+\.ts$/.test(f) && f !== 'hosts/index.ts').map((f: string) => path.basename(f, '.ts'));
|
||||
const byFile = new Map<string, Set<string>>();
|
||||
for (const [id, patterns] of Object.entries(maps.e2eTouchfiles)) {
|
||||
const entries = caseEntries(patterns, null);
|
||||
for (const b of observed[id]?.commands ?? []) { if (trackedSet.has(b)) entries.push(b); else entries.push(...binaryTree(b)); }
|
||||
const files = closure(entries);
|
||||
const ownerText = entries.filter((f: string) => /^test\/[^/]+\.test\.ts$/.test(f)).map(read).join('\n');
|
||||
for (const h of hostIds) if (new RegExp('[\'"\x60]' + h + '[\'"\x60]|--host[ =]' + h + '\\b').test(ownerText)) for (const f of closure(['hosts/' + h + '.ts', ...expand('hosts/' + h + '/**')])) files.add(f);
|
||||
for (const f of files) { if (!byFile.has(f)) byFile.set(f, new Set()); byFile.get(f)!.add(id); }
|
||||
}
|
||||
void binsInCommand; void stripShell; void execRefs;
|
||||
return byFile;
|
||||
}
|
||||
Reference in new issue
Block a user