mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 14:06:50 +01:00
* v1.91.79.0 feat(eval-plan): /eval-plan tier 1, lane-check, evidence verify, coordinator contract, owner brief, contributor-PR mode, banned terms (multi-agent wave PR E) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(spec,review): fail closed when the coordinator contract cannot be read; restore the omitted-Army sentence The spec --execute spawn captures `gstack-autoplan contract` before piping the archive, so a lane never starts without the contract; the C9 fixture installs its gstack-like checkout (bin, lib, a contract stub) at every host's global root. Review Step 5.8 keeps the pinned 'without claiming specialist coverage' wording. 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>
168 lines
10 KiB
TypeScript
168 lines
10 KiB
TypeScript
/**
|
|
* banned-terms — words a repository forbids outside named locations (plan
|
|
* E3). `.gstack/banned-terms.json` holds `terms` (plain strings, matched
|
|
* case-insensitively on word boundaries) and `allowed_paths` (repo-relative
|
|
* globs, per term or for every term) where a term may still appear: a
|
|
* glossary, a CHANGELOG, a vendored file. `bin/gstack-banned-terms` scans the
|
|
* lines a diff adds and prints one finding per hit naming the term, the file
|
|
* and line, and the allowed locations. Containment rules are C1's: no `..`,
|
|
* no absolute paths, globs confined to the repository, size caps. A missing
|
|
* file means no check (`none`), never a failure.
|
|
*/
|
|
import * as fs from 'node:fs';
|
|
import * as path from 'node:path';
|
|
import { spawnSync } from 'node:child_process';
|
|
import type { ResultCodeName } from './result-codes';
|
|
import { assertInsideRoot, containedRelative, globToRegExp, matchesAny } from './ship-policy';
|
|
|
|
export const BANNED_TERMS_PATH = '.gstack/banned-terms.json';
|
|
export const BANNED_TERMS_SCHEMA = 'https://github.com/garrytan/gstack/docs/ship-policy.md#banned-terms';
|
|
const MAX_TERMS = 200;
|
|
const MAX_TERM = 80;
|
|
const MAX_PATHS = 200;
|
|
|
|
export interface BannedTermRule { term: string; allowed_paths: string[]; reason?: string }
|
|
export interface BannedTermsConfig {
|
|
$schema?: string;
|
|
/** A plain string, or an object with its own `allowed_paths` and `reason`. */
|
|
terms: Array<string | BannedTermRule>;
|
|
/** Globs where every term is allowed (a glossary, CHANGELOG.md, vendored code). */
|
|
allowed_paths: string[];
|
|
}
|
|
export interface BannedTermsError { code: ResultCodeName; path: string; message: string }
|
|
export interface BannedTermFinding { term: string; file: string; line: number; text: string; allowed_paths: string[]; reason?: string }
|
|
export interface BannedTermsResult { source: string; terms: number; scanned_files: number; findings: BannedTermFinding[]; skipped: string[] }
|
|
|
|
export function exampleBannedTermsJson(): string {
|
|
return JSON.stringify({
|
|
$schema: BANNED_TERMS_SCHEMA,
|
|
terms: [
|
|
'blazing fast',
|
|
{ term: 'legacy-name', allowed_paths: ['CHANGELOG.md', 'docs/migration/**'], reason: 'the product was renamed; the old name belongs to history only' },
|
|
],
|
|
allowed_paths: ['docs/glossary.md', 'vendor/**'],
|
|
}, null, 2) + '\n';
|
|
}
|
|
|
|
function globError(g: unknown): string | null {
|
|
if (typeof g !== 'string') return 'must be a string';
|
|
const why = containedRelative(g);
|
|
if (why) return why;
|
|
try { globToRegExp(g); } catch (e: any) { return e.message; }
|
|
return null;
|
|
}
|
|
|
|
/** Schema and containment: `terms` a non-empty list, every glob repo-relative. Unknown keys are errors. */
|
|
export function validateBannedTerms(raw: unknown): BannedTermsError[] {
|
|
const errors: BannedTermsError[] = [];
|
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return [{ code: 'BANNED_TERMS_INVALID', path: '', message: 'must be a JSON object' }];
|
|
const obj = raw as Record<string, unknown>;
|
|
for (const key of Object.keys(obj)) if (!['$schema', 'terms', 'allowed_paths'].includes(key)) errors.push({ code: 'BANNED_TERMS_INVALID', path: key, message: 'unknown key; valid keys: terms, allowed_paths' });
|
|
if (!Array.isArray(obj.terms) || obj.terms.length === 0) errors.push({ code: 'BANNED_TERMS_INVALID', path: 'terms', message: 'must be a non-empty array' });
|
|
else {
|
|
if (obj.terms.length > MAX_TERMS) errors.push({ code: 'BANNED_TERMS_INVALID', path: 'terms', message: `more than ${MAX_TERMS} entries` });
|
|
obj.terms.forEach((t, i) => {
|
|
const at = `terms[${i}]`;
|
|
const term = typeof t === 'string' ? t : (t && typeof t === 'object' && !Array.isArray(t)) ? (t as BannedTermRule).term : undefined;
|
|
if (typeof term !== 'string' || !term.trim()) errors.push({ code: 'BANNED_TERMS_INVALID', path: at, message: 'must be a non-empty string or {term, allowed_paths}' });
|
|
else if (term.length > MAX_TERM) errors.push({ code: 'BANNED_TERMS_INVALID', path: at, message: `longer than ${MAX_TERM} characters` });
|
|
else if (/[\0\r\n]/.test(term)) errors.push({ code: 'BANNED_TERMS_INVALID', path: at, message: 'contains a control character' });
|
|
if (t && typeof t === 'object' && !Array.isArray(t)) {
|
|
const rule = t as Record<string, unknown>;
|
|
for (const key of Object.keys(rule)) if (!['term', 'allowed_paths', 'reason'].includes(key)) errors.push({ code: 'BANNED_TERMS_INVALID', path: `${at}.${key}`, message: 'unknown key; valid keys: term, allowed_paths, reason' });
|
|
if (rule.allowed_paths !== undefined) checkPaths(`${at}.allowed_paths`, rule.allowed_paths, errors);
|
|
}
|
|
});
|
|
}
|
|
if (obj.allowed_paths !== undefined) checkPaths('allowed_paths', obj.allowed_paths, errors);
|
|
return errors;
|
|
}
|
|
|
|
function checkPaths(at: string, value: unknown, errors: BannedTermsError[]): void {
|
|
if (!Array.isArray(value)) { errors.push({ code: 'BANNED_TERMS_INVALID', path: at, message: 'must be an array of globs' }); return; }
|
|
if (value.length > MAX_PATHS) errors.push({ code: 'BANNED_TERMS_INVALID', path: at, message: `more than ${MAX_PATHS} entries` });
|
|
value.forEach((g, i) => { const why = globError(g); if (why) errors.push({ code: 'BANNED_TERMS_INVALID', path: `${at}[${i}]`, message: `${JSON.stringify(g)} ${why}` }); });
|
|
}
|
|
|
|
export function normalizeRules(config: BannedTermsConfig): BannedTermRule[] {
|
|
return config.terms.map(t => typeof t === 'string' ? { term: t, allowed_paths: [...(config.allowed_paths ?? [])] } : { term: t.term, allowed_paths: [...(config.allowed_paths ?? []), ...(t.allowed_paths ?? [])], ...(t.reason ? { reason: t.reason } : {}) });
|
|
}
|
|
|
|
export type ConfigLoad = { config: BannedTermsConfig; source: string } | { config: null; source: string; errors: BannedTermsError[] } | { config: null; source: string; absent: true };
|
|
|
|
/** The committed copy on `<treeish>` when given (C1's reviewed-base posture), else the working tree. */
|
|
export function loadBannedTerms(repoRoot: string, treeish?: string): ConfigLoad {
|
|
let text: string | null = null;
|
|
const source = treeish ? `${treeish}:${BANNED_TERMS_PATH}` : BANNED_TERMS_PATH;
|
|
if (treeish) {
|
|
const r = spawnSync('git', ['show', source], { cwd: repoRoot, encoding: 'utf8', timeout: 30_000 });
|
|
text = r.status === 0 ? r.stdout : null;
|
|
} else {
|
|
const file = path.join(repoRoot, BANNED_TERMS_PATH);
|
|
const escape = assertInsideRoot(repoRoot, BANNED_TERMS_PATH);
|
|
if (escape) return { config: null, source, errors: [{ code: 'BANNED_TERMS_INVALID', path: BANNED_TERMS_PATH, message: escape }] };
|
|
text = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : null;
|
|
}
|
|
if (text === null) return { config: null, source, absent: true };
|
|
let parsed: unknown;
|
|
try { parsed = JSON.parse(text); } catch (e: any) { return { config: null, source, errors: [{ code: 'BANNED_TERMS_INVALID', path: '', message: `not valid JSON: ${e.message}` }] }; }
|
|
const errors = validateBannedTerms(parsed);
|
|
if (errors.length) return { config: null, source, errors };
|
|
return { config: parsed as BannedTermsConfig, source };
|
|
}
|
|
|
|
function termRegExp(term: string): RegExp {
|
|
const escaped = term.trim().replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\s+/g, '\\s+');
|
|
const edge = (s: string) => (/^[A-Za-z0-9_]/.test(s) ? '\\b' : '');
|
|
return new RegExp(`${edge(term.trim())}${escaped}${edge(term.trim().slice(-1))}`, 'i');
|
|
}
|
|
|
|
/** Added lines per file from a unified diff (new-side line numbers). */
|
|
export function addedLines(diff: string): Map<string, Array<{ line: number; text: string }>> {
|
|
const out = new Map<string, Array<{ line: number; text: string }>>();
|
|
let file = ''; let line = 0;
|
|
for (const raw of diff.split('\n')) {
|
|
if (raw.startsWith('+++ ')) { file = raw.slice(4).replace(/^b\//, '').replace(/\t.*$/, ''); if (file === '/dev/null') file = ''; continue; }
|
|
if (raw.startsWith('--- ') || raw.startsWith('diff --git') || raw.startsWith('index ')) continue;
|
|
const hunk = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(raw);
|
|
if (hunk) { line = Number(hunk[1]); continue; }
|
|
if (!file) continue;
|
|
if (raw.startsWith('+')) { (out.get(file) ?? out.set(file, []).get(file)!).push({ line, text: raw.slice(1) }); line += 1; }
|
|
else if (!raw.startsWith('-') && !raw.startsWith('\\')) line += 1;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** Scan added lines (or whole files) for each term outside its allowed paths. */
|
|
export function scanBannedTerms(config: BannedTermsConfig, source: string, files: Map<string, Array<{ line: number; text: string }>>): BannedTermsResult {
|
|
const rules = normalizeRules(config).map(r => ({ ...r, re: termRegExp(r.term) }));
|
|
const findings: BannedTermFinding[] = [];
|
|
const skipped: string[] = [];
|
|
for (const [file, lines] of files) {
|
|
if (file === BANNED_TERMS_PATH) { skipped.push(file); continue; }
|
|
for (const rule of rules) {
|
|
if (matchesAny(rule.allowed_paths, file)) continue;
|
|
for (const l of lines) if (rule.re.test(l.text)) findings.push({ term: rule.term, file, line: l.line, text: l.text.trim().slice(0, 160), allowed_paths: rule.allowed_paths, ...(rule.reason ? { reason: rule.reason } : {}) });
|
|
}
|
|
}
|
|
return { source, terms: rules.length, scanned_files: files.size, findings, skipped };
|
|
}
|
|
|
|
export function wholeFiles(repoRoot: string, rels: string[]): Map<string, Array<{ line: number; text: string }>> {
|
|
const out = new Map<string, Array<{ line: number; text: string }>>();
|
|
for (const rel of rels) {
|
|
const abs = path.join(repoRoot, rel);
|
|
if (!fs.existsSync(abs) || fs.statSync(abs).isDirectory()) continue;
|
|
const buf = fs.readFileSync(abs);
|
|
if (buf.subarray(0, 1024).includes(0)) continue;
|
|
out.set(rel, buf.toString('utf8').split('\n').map((text, i) => ({ line: i + 1, text })));
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export function renderBannedTerms(r: BannedTermsResult): string {
|
|
const lines = r.findings.map(f => `BANNED_TERM: ${JSON.stringify(f.term)} ${f.file}:${f.line} allowed: ${f.allowed_paths.length ? f.allowed_paths.join(', ') : 'nowhere'}${f.reason ? ` (${f.reason})` : ''} — ${f.text}`);
|
|
lines.push(`BANNED_TERMS: ${r.findings.length ? `found=${r.findings.length}` : 'ok'} terms=${r.terms} files=${r.scanned_files} source=${r.source}`);
|
|
return lines.join('\n') + '\n';
|
|
}
|