The v4.2/v4.3 bespoke playground SPA (~388 KB), the /trekrevise command, Handover 8 (annotation → revision), the supporting lib/ modules (anchor-parser, annotation-digest, markdown-write, revision-guard), the Playwright e2e suite, and the @playwright/test / @axe-core/playwright devDeps are removed. A browser walkthrough found the playground borderline unusable, and it duplicated the official /playground plugin's document-critique / diff-review templates. In their place: scripts/render-artifact.mjs — a small, zero-dependency renderer that turns a brief/plan/review .md into a self-contained, design-system-styled, zero-network .html (frontmatter folded into a <details> block). /trekbrief, /trekplan, and /trekreview call it on their last step and print the file:// link; to annotate, run /playground (document-critique) on the .md and paste the generated prompt back. Resolves the v4.3.1-deferred findings as moot (their target files are deleted). npm test green: 509 tests, 507 pass, 0 fail, 2 skipped. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
105 lines
4.3 KiB
JavaScript
105 lines
4.3 KiB
JavaScript
// lib/validators/plan-validator.mjs
|
|
// Wraps plan-schema (heading shape) + manifest-yaml (per-step Manifest blocks).
|
|
// This is the JS equivalent of Phase 5.5 grep checks in planning-orchestrator.
|
|
//
|
|
// Schema is forward-compatible: unknown top-level frontmatter keys are
|
|
// tolerated silently. Strict-key checks are intentionally avoided so new
|
|
// optional fields (jf. the source_findings precedent) can be added without
|
|
// a plan_version bump.
|
|
|
|
import { readFileSync, existsSync } from 'node:fs';
|
|
import { sliceSteps, validatePlanHeadings, extractPlanVersion } from '../parsers/plan-schema.mjs';
|
|
import { validateAllManifests } from '../parsers/manifest-yaml.mjs';
|
|
import { parseDocument } from '../util/frontmatter.mjs';
|
|
import { issue, fail } from '../util/result.mjs';
|
|
|
|
export function validatePlanContent(text, opts = {}) {
|
|
const strict = opts.strict !== false;
|
|
const headRes = validatePlanHeadings(text, { strict });
|
|
const errors = [...headRes.errors];
|
|
const warnings = [...headRes.warnings];
|
|
|
|
const steps = headRes.parsed?.steps || [];
|
|
const sections = sliceSteps(text);
|
|
const manRes = validateAllManifests(sections);
|
|
errors.push(...manRes.errors);
|
|
warnings.push(...manRes.warnings);
|
|
|
|
if (steps.length > 0 && manRes.parsed.length !== steps.length) {
|
|
errors.push(issue(
|
|
'PLAN_MANIFEST_COUNT_MISMATCH',
|
|
`Step count (${steps.length}) does not equal manifest count (${manRes.parsed.length})`,
|
|
));
|
|
}
|
|
|
|
const planVersion = extractPlanVersion(text);
|
|
if (planVersion === null) {
|
|
warnings.push(issue('PLAN_NO_VERSION', 'No plan_version detected; current target is 1.7'));
|
|
} else if (planVersion !== '1.7') {
|
|
warnings.push(issue('PLAN_VERSION_MISMATCH', `plan_version=${planVersion}, current target is 1.7`));
|
|
}
|
|
|
|
// v4.1 SC #20 — MANIFEST_PROFILE_DRIFT detection. Strict-mode only.
|
|
// If plan frontmatter declares `profile: <name>` and any step manifest
|
|
// declares `profile_used: <other>`, emit a warning (NOT an error) so
|
|
// operators see drift but parsing remains forward-compat.
|
|
if (strict) {
|
|
const planFm = parseDocument(text).parsed?.frontmatter;
|
|
const planProfile =
|
|
planFm && typeof planFm.profile === 'string' ? planFm.profile : null;
|
|
if (planProfile) {
|
|
for (const m of manRes.parsed) {
|
|
const stepProfile = m.manifest && m.manifest.profile_used;
|
|
if (typeof stepProfile === 'string' && stepProfile !== planProfile) {
|
|
warnings.push(issue(
|
|
'MANIFEST_PROFILE_DRIFT',
|
|
`step ${m.n}: profile_used = ${stepProfile}, plan profile = ${planProfile}`,
|
|
'A step manifest declares a different profile than the plan frontmatter; ' +
|
|
'verify whether this is intentional (manual override) or accidental drift.',
|
|
));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
valid: errors.length === 0,
|
|
errors,
|
|
warnings,
|
|
parsed: { steps, manifests: manRes.parsed, planVersion },
|
|
};
|
|
}
|
|
|
|
export function validatePlan(filePath, opts = {}) {
|
|
if (!existsSync(filePath)) return fail(issue('PLAN_NOT_FOUND', `File not found: ${filePath}`));
|
|
let text;
|
|
try { text = readFileSync(filePath, 'utf-8'); }
|
|
catch (e) { return fail(issue('PLAN_READ_ERROR', `Cannot read ${filePath}: ${e.message}`)); }
|
|
const r = validatePlanContent(text, opts);
|
|
return { ...r, parsed: { ...r.parsed, filePath } };
|
|
}
|
|
|
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
const args = process.argv.slice(2);
|
|
const strict = !args.includes('--soft');
|
|
const filePath = args.find(a => !a.startsWith('--'));
|
|
if (!filePath) {
|
|
process.stderr.write('Usage: plan-validator.mjs [--strict|--soft] <plan.md>\n');
|
|
process.exit(2);
|
|
}
|
|
const r = validatePlan(filePath, { strict });
|
|
if (args.includes('--json')) {
|
|
process.stdout.write(JSON.stringify({
|
|
valid: r.valid,
|
|
errors: r.errors,
|
|
warnings: r.warnings,
|
|
steps: r.parsed?.steps?.length ?? 0,
|
|
planVersion: r.parsed?.planVersion ?? null,
|
|
}, null, 2) + '\n');
|
|
} else {
|
|
process.stdout.write(`plan-validator: ${r.valid ? 'READY' : 'FAIL'} ${filePath} (${r.parsed?.steps?.length ?? 0} steps)\n`);
|
|
for (const e of r.errors) process.stderr.write(` ERROR [${e.code}] ${e.message}\n`);
|
|
for (const w of r.warnings) process.stderr.write(` WARN [${w.code}] ${w.message}\n`);
|
|
}
|
|
process.exit(r.valid ? 0 : 1);
|
|
}
|