gstack/scripts/lenses/routing.ts

233 lines
8.2 KiB
TypeScript

import * as fs from 'fs';
import { parseYamlSubset, type YamlValue } from './yaml-subset';
import { readyLenses, resolveLensName } from './registry';
import type { LensSpec } from './types';
export interface MandatoryLensRule {
name: string;
surface_globs: string[];
lenses: string[];
}
export interface LensPolicy {
mandatory_lenses: MandatoryLensRule[];
todo_target?: 'plan_file' | 'todos_md' | 'pr_checklist' | 'issue';
}
export type LensRouteMode = 'mandatory' | 'recommended' | 'all' | 'explicit';
export interface RouteInput {
mode: LensRouteMode;
requested?: string[];
allow_draft?: boolean;
changed_paths: string[];
pr_labels?: string[];
added_lines?: string[];
declared_surfaces?: string[];
policy?: LensPolicy;
no_mandatory?: boolean;
no_mandatory_reason?: string;
}
export interface RoutedLens {
lens: string;
requested_as?: string;
reasons: string[];
mandatory: boolean;
status: LensSpec['status'];
}
export interface MandatoryMatch {
rule: string;
lens: string;
paths: string[];
}
export interface RouteOutput {
mode: LensRouteMode;
selected: RoutedLens[];
skipped: Array<{ lens: string; reason: string }>;
unmatched_requested: string[];
mandatory_matches: MandatoryMatch[];
mandatory_bypassed: boolean;
mandatory_bypass_reason?: string;
confirmation_required: boolean;
}
function asObject(value: YamlValue | undefined): Record<string, YamlValue> | undefined {
return value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, YamlValue>
: undefined;
}
function stringArray(value: YamlValue | undefined, label: string): string[] {
if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) {
throw new Error(`${label} must be an array of strings`);
}
return (value as string[]).map((item) => item.trim()).filter(Boolean);
}
export function loadLensPolicy(policyPath: string): LensPolicy {
if (!fs.existsSync(policyPath)) return { mandatory_lenses: [] };
const raw = parseYamlSubset(fs.readFileSync(policyPath, 'utf8'));
const mandatoryRaw = asObject(raw.mandatory_lenses);
const mandatory: MandatoryLensRule[] = [];
if (mandatoryRaw) {
for (const [name, value] of Object.entries(mandatoryRaw)) {
const rule = asObject(value);
if (!rule) throw new Error(`${policyPath}: mandatory_lenses.${name} must be a mapping`);
mandatory.push({
name,
surface_globs: stringArray(rule.surface_globs, `${policyPath}: mandatory_lenses.${name}.surface_globs`),
lenses: stringArray(rule.lenses, `${policyPath}: mandatory_lenses.${name}.lenses`),
});
}
}
const todoTarget = raw.todo_target;
if (todoTarget !== undefined && !['plan_file', 'todos_md', 'pr_checklist', 'issue'].includes(String(todoTarget))) {
throw new Error(`${policyPath}: todo_target must be plan_file, todos_md, pr_checklist, or issue`);
}
return {
mandatory_lenses: mandatory,
todo_target: todoTarget as LensPolicy['todo_target'],
};
}
export function globToRegExp(glob: string): RegExp {
let regex = '^';
for (let i = 0; i < glob.length; i++) {
const ch = glob[i];
if (ch === '*') {
if (glob[i + 1] === '*') {
i += 1;
if (glob[i + 1] === '/') {
i += 1;
regex += '(?:.*/)?';
} else {
regex += '.*';
}
} else {
regex += '[^/]*';
}
continue;
}
if (ch === '?') {
regex += '[^/]';
continue;
}
regex += /[\\.^$+{}()|[\]]/.test(ch) ? `\\${ch}` : ch;
}
return new RegExp(`${regex}$`);
}
export function matchesAnyGlob(filePath: string, globs: string[]): string[] {
const normalized = filePath.replace(/\\/g, '/').replace(/^\.\//, '');
return globs.filter((glob) => globToRegExp(glob).test(normalized));
}
function matchLens(spec: LensSpec, input: RouteInput): string[] {
const reasons: string[] = [];
for (const changedPath of input.changed_paths) {
for (const glob of matchesAnyGlob(changedPath, spec.invocation_triggers.path_globs)) {
reasons.push(`path:${changedPath} matched ${glob}`);
}
}
const labels = new Set(input.pr_labels ?? []);
const surfaces = new Set(input.declared_surfaces ?? []);
const added = (input.added_lines ?? []).join('\n');
for (const trigger of spec.invocation_triggers.semantic_triggers) {
if (trigger.kind === 'pr_label' && labels.has(trigger.value)) reasons.push(`pr_label:${trigger.value}`);
if (trigger.kind === 'user_declared' && surfaces.has(trigger.value)) reasons.push(`surface:${trigger.value}`);
if (trigger.kind === 'file_metadata' && added.includes(trigger.value)) reasons.push(`file_metadata:${trigger.value}`);
}
return [...new Set(reasons)];
}
function findMandatoryMatches(specs: LensSpec[], input: RouteInput): MandatoryMatch[] {
const result: MandatoryMatch[] = [];
for (const rule of input.policy?.mandatory_lenses ?? []) {
const matchedPaths = input.changed_paths.filter((changedPath) => matchesAnyGlob(changedPath, rule.surface_globs).length > 0);
if (matchedPaths.length === 0) continue;
for (const name of rule.lenses) {
const spec = resolveLensName(specs, name);
if (!spec) throw new Error(`lens-policy references unknown lens '${name}' in rule '${rule.name}'`);
if (spec.status !== 'READY') throw new Error(`lens-policy cannot mandate ${spec.status} lens '${spec.lens}'`);
result.push({ rule: rule.name, lens: spec.lens, paths: matchedPaths });
}
}
return result;
}
export function routeLenses(specs: LensSpec[], input: RouteInput): RouteOutput {
const selected = new Map<string, RoutedLens>();
const skipped: RouteOutput['skipped'] = [];
const unmatched: string[] = [];
const mandatoryMatches = findMandatoryMatches(specs, input);
const bypassReason = input.no_mandatory_reason?.trim();
if (input.no_mandatory && mandatoryMatches.length > 0 && !bypassReason) {
throw new Error('Bypassing a matching mandatory lens policy requires --no-mandatory-lenses with a non-empty rationale');
}
function add(spec: LensSpec, reasons: string[], mandatory = false, requestedAs?: string): void {
const existing = selected.get(spec.lens);
if (existing) {
existing.reasons = [...new Set([...existing.reasons, ...reasons])];
existing.mandatory = existing.mandatory || mandatory;
return;
}
selected.set(spec.lens, {
lens: spec.lens,
requested_as: requestedAs,
reasons: [...new Set(reasons)],
mandatory,
status: spec.status,
});
}
if (input.mode === 'explicit') {
for (const requested of input.requested ?? []) {
const spec = resolveLensName(specs, requested);
if (!spec) {
unmatched.push(requested);
continue;
}
if (spec.status === 'DEFERRED') {
skipped.push({ lens: spec.lens, reason: 'DEFERRED lenses are specifications only in V0.5' });
continue;
}
if (spec.status === 'DRAFT' && !input.allow_draft) {
skipped.push({ lens: spec.lens, reason: 'DRAFT lens requires --lens-draft' });
continue;
}
add(spec, [`explicit:${requested}`], false, requested);
}
} else if (input.mode === 'all') {
for (const spec of readyLenses(specs)) add(spec, ['all-ready-lenses']);
} else if (input.mode === 'recommended') {
for (const spec of readyLenses(specs)) {
const reasons = matchLens(spec, input);
if (reasons.length > 0) add(spec, reasons);
else skipped.push({ lens: spec.lens, reason: 'no invocation trigger matched' });
}
}
if (!input.no_mandatory) {
for (const match of mandatoryMatches) {
const spec = resolveLensName(specs, match.lens)!;
add(spec, [`mandatory:${match.rule} matched ${match.paths.join(',')}`], true);
}
}
return {
mode: input.mode,
selected: [...selected.values()].sort((a, b) => a.lens.localeCompare(b.lens)),
skipped: skipped.filter((entry) => !selected.has(entry.lens)),
unmatched_requested: unmatched,
mandatory_matches: mandatoryMatches,
mandatory_bypassed: Boolean(input.no_mandatory && mandatoryMatches.length > 0),
mandatory_bypass_reason: input.no_mandatory && mandatoryMatches.length > 0 ? bypassReason : undefined,
confirmation_required: input.mode === 'recommended' && [...selected.values()].some((entry) => !entry.mandatory),
};
}