mirror of https://github.com/garrytan/gstack.git
269 lines
13 KiB
TypeScript
269 lines
13 KiB
TypeScript
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import { parseFrontmatterDocument, type YamlObject, type YamlValue } from './yaml-subset';
|
|
import {
|
|
EVIDENCE_KINDS,
|
|
LENS_STATUSES,
|
|
type EvidenceKind,
|
|
type InvocationTriggers,
|
|
type LensSpec,
|
|
type LensStatus,
|
|
type SemanticTrigger,
|
|
} from './types';
|
|
|
|
const LENS_FILE_EXCLUSIONS = new Set(['shared-behavior.md', 'registry.md']);
|
|
const NAME_RE = /^[a-z][a-z0-9-]*$/;
|
|
const SEVERITY_RE = /^[A-Z][A-Z0-9_]*$/;
|
|
const SKILL_RE = /^\/[a-z][a-z0-9-]*$/;
|
|
|
|
// These patterns reject direct stakeholder roleplay instructions while allowing
|
|
// ordinary domain nouns such as "user impersonation" or "insider threat".
|
|
const PERSONA_FRAMING_PATTERNS: Array<{ pattern: RegExp; description: string }> = [
|
|
{ pattern: /\bpretend\s+(?:that\s+)?you\s+are\b/i, description: 'pretend-you-are framing' },
|
|
{ pattern: /\bassume\s+you\s+are\s+(?:an?\s+)?(?:[a-z-]+\s+){0,3}(?:investor|regulator|buyer|insider|user|competitor)\b/i, description: 'assume-you-are-stakeholder framing' },
|
|
{ pattern: /\bact\s+as\s+(?:an?\s+)?(?:[a-z-]+\s+){0,3}(?:investor|regulator|buyer|insider|user|competitor)\b/i, description: 'act-as-stakeholder framing' },
|
|
{ pattern: /\byou\s+are\s+(?:an?\s+)?(?:[a-z-]+\s+){0,3}(?:investor|regulator|buyer|insider|user|competitor)\s+(?:reviewing|evaluating|trying)\b/i, description: 'stakeholder-role assignment' },
|
|
];
|
|
|
|
const REQUIRED_READY_HEADINGS = [
|
|
'## When I use this lens',
|
|
'## Objective',
|
|
'## Search strategy',
|
|
];
|
|
|
|
function isObject(value: YamlValue | undefined): value is Record<string, YamlValue> {
|
|
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
}
|
|
|
|
function expectString(source: YamlObject, key: string, file: string, allowEmpty = false): string {
|
|
const value = source[key];
|
|
if (typeof value !== 'string' || (!allowEmpty && value.trim() === '')) {
|
|
throw new Error(`${file}: '${key}' must be a non-empty string`);
|
|
}
|
|
return value.trim();
|
|
}
|
|
|
|
function expectNullableString(source: YamlObject, key: string, file: string): string | null {
|
|
const value = source[key];
|
|
if (value === null) return null;
|
|
if (typeof value !== 'string') throw new Error(`${file}: '${key}' must be a string or null`);
|
|
return value.trim() || null;
|
|
}
|
|
|
|
function expectStringArray(source: YamlObject, key: string, file: string, allowEmpty = true): string[] {
|
|
const value = source[key];
|
|
if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) {
|
|
throw new Error(`${file}: '${key}' must be an array of strings`);
|
|
}
|
|
const result = (value as string[]).map((item) => item.trim()).filter(Boolean);
|
|
if (!allowEmpty && result.length === 0) throw new Error(`${file}: '${key}' must not be empty`);
|
|
return result;
|
|
}
|
|
|
|
function expectEnum<T extends readonly string[]>(source: YamlObject, key: string, allowed: T, file: string): T[number] {
|
|
const value = expectString(source, key, file);
|
|
if (!(allowed as readonly string[]).includes(value)) {
|
|
throw new Error(`${file}: '${key}' must be one of ${allowed.join(', ')}, got '${value}'`);
|
|
}
|
|
return value as T[number];
|
|
}
|
|
|
|
function parseSemanticTrigger(value: string, file: string): SemanticTrigger {
|
|
const separator = value.indexOf('=');
|
|
if (separator <= 0 || separator === value.length - 1) {
|
|
throw new Error(`${file}: semantic trigger '${value}' must use kind=value`);
|
|
}
|
|
const kind = value.slice(0, separator);
|
|
const triggerValue = value.slice(separator + 1);
|
|
if (!['pr_label', 'file_metadata', 'user_declared'].includes(kind)) {
|
|
throw new Error(`${file}: semantic trigger kind '${kind}' is not supported in V0.5`);
|
|
}
|
|
return { kind: kind as SemanticTrigger['kind'], value: triggerValue };
|
|
}
|
|
|
|
function parseInvocationTriggers(frontmatter: YamlObject, file: string): InvocationTriggers {
|
|
const raw = frontmatter.invocation_triggers;
|
|
if (!isObject(raw)) throw new Error(`${file}: 'invocation_triggers' must be a mapping`);
|
|
const pathGlobs = expectStringArray(raw, 'path_globs', file);
|
|
const rawSemantic = expectStringArray(raw, 'semantic_triggers', file);
|
|
return {
|
|
path_globs: pathGlobs,
|
|
semantic_triggers: rawSemantic.map((value) => parseSemanticTrigger(value, file)),
|
|
};
|
|
}
|
|
|
|
function validateNameList(values: string[], label: string, file: string, regex: RegExp): void {
|
|
for (const value of values) {
|
|
if (!regex.test(value)) throw new Error(`${file}: invalid ${label} '${value}'`);
|
|
}
|
|
if (new Set(values).size !== values.length) throw new Error(`${file}: duplicate values in '${label}'`);
|
|
}
|
|
|
|
function markerName(lens: string): string {
|
|
return lens.replace(/-/g, ' ').toUpperCase();
|
|
}
|
|
|
|
function validateObjectiveConditionedPrompt(body: string, file: string): void {
|
|
for (const { pattern, description } of PERSONA_FRAMING_PATTERNS) {
|
|
if (pattern.test(body)) {
|
|
throw new Error(`${file}: persona framing prohibited (${description}); encode an objective function, evidence standard, materiality threshold, and escalation policy instead`);
|
|
}
|
|
}
|
|
}
|
|
|
|
function validateReadyPromptShape(body: string, file: string): void {
|
|
for (const heading of REQUIRED_READY_HEADINGS) {
|
|
if (!body.includes(heading)) throw new Error(`${file}: READY lens prompt must include '${heading}'`);
|
|
}
|
|
if (!body.includes('## Lens-specific output fields')) {
|
|
throw new Error(`${file}: READY lens prompt must include '## Lens-specific output fields'`);
|
|
}
|
|
}
|
|
|
|
export function parseLensFile(filePath: string): LensSpec {
|
|
const content = fs.readFileSync(filePath, 'utf8');
|
|
const { frontmatter, body } = parseFrontmatterDocument(content);
|
|
const file = path.basename(filePath);
|
|
const lens = expectString(frontmatter, 'lens', file);
|
|
if (!NAME_RE.test(lens)) throw new Error(`${file}: invalid lens name '${lens}'`);
|
|
if (path.basename(filePath, '.md') !== lens) {
|
|
throw new Error(`${file}: file name must match lens '${lens}'`);
|
|
}
|
|
|
|
const aliases = expectStringArray(frontmatter, 'cli_aliases', file);
|
|
validateNameList(aliases, 'cli_aliases', file, NAME_RE);
|
|
if (aliases.includes(lens)) throw new Error(`${file}: cli_aliases must not repeat the canonical name`);
|
|
|
|
const status = expectEnum(frontmatter, 'status', LENS_STATUSES, file) as LensStatus;
|
|
const summary = expectString(frontmatter, 'summary', file);
|
|
const primarySkill = expectStringArray(frontmatter, 'primary_skill', file, false);
|
|
const supportedSkills = expectStringArray(frontmatter, 'supported_skills', file);
|
|
validateNameList(primarySkill, 'primary_skill', file, SKILL_RE);
|
|
validateNameList(supportedSkills, 'supported_skills', file, SKILL_RE);
|
|
if (primarySkill.some((skill) => supportedSkills.includes(skill))) {
|
|
throw new Error(`${file}: primary_skill and supported_skills must not overlap`);
|
|
}
|
|
|
|
const severity = expectStringArray(frontmatter, 'severity', file, false);
|
|
validateNameList(severity, 'severity', file, SEVERITY_RE);
|
|
if (status === 'READY' && severity.length !== 5) {
|
|
throw new Error(`${file}: READY lenses must define exactly 5 severity categories`);
|
|
}
|
|
|
|
const evidenceKinds = expectStringArray(frontmatter, 'allowed_evidence_kinds', file, false);
|
|
for (const kind of evidenceKinds) {
|
|
if (!(EVIDENCE_KINDS as readonly string[]).includes(kind)) {
|
|
throw new Error(`${file}: unsupported evidence kind '${kind}'`);
|
|
}
|
|
}
|
|
|
|
const expectedMarkerName = markerName(lens);
|
|
const startMarker = `==== LENS PROMPT START | ${expectedMarkerName} ====`;
|
|
const endMarker = `==== LENS PROMPT END | ${expectedMarkerName} ====`;
|
|
if (!body.includes(startMarker) || !body.includes(endMarker)) {
|
|
throw new Error(`${file}: prompt body must include exact START and END markers for ${expectedMarkerName}`);
|
|
}
|
|
if (body.indexOf(startMarker) > body.indexOf(endMarker)) {
|
|
throw new Error(`${file}: prompt END marker appears before START marker`);
|
|
}
|
|
validateObjectiveConditionedPrompt(body, file);
|
|
if (status === 'READY') validateReadyPromptShape(body, file);
|
|
|
|
const spec: LensSpec = {
|
|
lens,
|
|
cli_aliases: aliases,
|
|
status,
|
|
summary,
|
|
primary_skill: primarySkill,
|
|
supported_skills: supportedSkills,
|
|
severity,
|
|
ranking: expectString(frontmatter, 'ranking', file),
|
|
scope_disclaimer: expectString(frontmatter, 'scope_disclaimer', file),
|
|
required_artifacts: expectStringArray(frontmatter, 'required_artifacts', file),
|
|
optional_artifacts: expectStringArray(frontmatter, 'optional_artifacts', file),
|
|
required_context: expectStringArray(frontmatter, 'required_context', file),
|
|
optional_context: expectStringArray(frontmatter, 'optional_context', file),
|
|
allowed_evidence_kinds: evidenceKinds as EvidenceKind[],
|
|
on_missing_required_evidence: expectEnum(frontmatter, 'on_missing_required_evidence', ['INSUFFICIENT_EVIDENCE'] as const, file),
|
|
invocation_triggers: parseInvocationTriggers(frontmatter, file),
|
|
evidence_threshold: expectEnum(frontmatter, 'evidence_threshold', ['STRONG_ONLY', 'STRONG_OR_MODERATE', 'ANY'] as const, file),
|
|
materiality_threshold: expectEnum(frontmatter, 'materiality_threshold', ['BLOCKING_ONLY', 'MATERIAL_OR_BLOCKING', 'ANY'] as const, file),
|
|
escalation_policy: expectEnum(frontmatter, 'escalation_policy', ['ADVISORY', 'MATERIAL', 'BLOCKING', 'REQUIRES_DOMAIN_VALIDATION', 'ADVISORY_PLUS_MATERIAL'] as const, file),
|
|
autofix_policy: expectEnum(frontmatter, 'autofix_policy', ['ask_always', 'mechanical_only'] as const, file),
|
|
safety_directive: expectNullableString(frontmatter, 'safety_directive', file),
|
|
prompt_marker_name: expectedMarkerName,
|
|
body,
|
|
path: filePath,
|
|
};
|
|
|
|
if (spec.status === 'READY' && ![...spec.primary_skill, ...spec.supported_skills].includes('/review')) {
|
|
throw new Error(`${file}: V0.5 READY lenses must support /review`);
|
|
}
|
|
if (spec.status === 'READY' && spec.autofix_policy !== 'ask_always') {
|
|
throw new Error(`${file}: V0.5 READY lenses must use autofix_policy: ask_always`);
|
|
}
|
|
return spec;
|
|
}
|
|
|
|
export function defaultLensRoot(repoRoot: string): string {
|
|
return path.join(repoRoot, 'review', 'lenses');
|
|
}
|
|
|
|
export function loadLensRegistry(repoRoot: string): LensSpec[] {
|
|
const lensRoot = defaultLensRoot(repoRoot);
|
|
if (!fs.existsSync(lensRoot)) throw new Error(`Lens directory not found: ${lensRoot}`);
|
|
const specs = fs.readdirSync(lensRoot)
|
|
.filter((name: string) => name.endsWith('.md') && !LENS_FILE_EXCLUSIONS.has(name))
|
|
.sort()
|
|
.map((name: string) => parseLensFile(path.join(lensRoot, name)));
|
|
if (specs.length === 0) throw new Error(`No lens specifications found in ${lensRoot}`);
|
|
|
|
const names = new Map<string, string>();
|
|
for (const spec of specs) {
|
|
for (const name of [spec.lens, ...spec.cli_aliases]) {
|
|
const existing = names.get(name);
|
|
if (existing) throw new Error(`Lens name or alias '${name}' is shared by '${existing}' and '${spec.lens}'`);
|
|
names.set(name, spec.lens);
|
|
}
|
|
}
|
|
return specs;
|
|
}
|
|
|
|
export function resolveLensName(specs: LensSpec[], name: string): LensSpec | undefined {
|
|
return specs.find((spec) => spec.lens === name || spec.cli_aliases.includes(name));
|
|
}
|
|
|
|
export function readyLenses(specs: LensSpec[]): LensSpec[] {
|
|
return specs.filter((spec) => spec.status === 'READY');
|
|
}
|
|
|
|
export function renderRegistryMarkdown(specs: LensSpec[]): string {
|
|
const rows = [...specs]
|
|
.sort((a, b) => a.lens.localeCompare(b.lens))
|
|
.map((spec) => `| \`${spec.lens}\` | ${spec.cli_aliases.map((a) => `\`${a}\``).join(', ') || 'none'} | ${spec.status} | ${spec.primary_skill.join(', ')} | ${spec.summary} |`)
|
|
.join('\n');
|
|
return `<!-- AUTO-GENERATED from review/lenses/*.md frontmatter. Do not edit directly. -->\n<!-- Regenerate: bun run gen:skill-docs -->\n\n# Stakeholder Lens Registry\n\nThe validated lens frontmatter is the source of truth. READY lenses may run normally. DRAFT lenses require explicit naming plus \`--lens-draft\`. DEFERRED lenses are specifications only.\n\n| Lens | CLI aliases | Status | Primary skill | Objective |\n|------|-------------|--------|---------------|-----------|\n${rows}\n`;
|
|
}
|
|
|
|
export function writeGeneratedRegistry(repoRoot: string, specs = loadLensRegistry(repoRoot)): string {
|
|
const output = path.join(defaultLensRoot(repoRoot), 'registry.md');
|
|
fs.writeFileSync(output, renderRegistryMarkdown(specs));
|
|
return output;
|
|
}
|
|
|
|
export interface RegistrySyncResult {
|
|
changed: boolean;
|
|
outputPath: string;
|
|
specs: LensSpec[];
|
|
}
|
|
|
|
export function syncGeneratedRegistry(repoRoot: string, dryRun = false): RegistrySyncResult {
|
|
const specs = loadLensRegistry(repoRoot);
|
|
const outputPath = path.join(defaultLensRoot(repoRoot), 'registry.md');
|
|
const generated = renderRegistryMarkdown(specs);
|
|
const current = fs.existsSync(outputPath) ? fs.readFileSync(outputPath, 'utf8') : null;
|
|
const changed = current !== generated;
|
|
if (changed && !dryRun) fs.writeFileSync(outputPath, generated);
|
|
return { changed, outputPath, specs };
|
|
}
|