129 lines
4.8 KiB
TypeScript
129 lines
4.8 KiB
TypeScript
// Rule frontmatter schema — machine-checkable realisation of the rule-file
|
|
// frontmatter spec in docs/reference/08-rule-system.md. Sibling of
|
|
// aidlc-stage-schema.ts. Consumed by aidlc-graph compile (loadRules) and
|
|
// the future doctor rule-drift check (which imports loadRules from
|
|
// aidlc-graph.ts, not this file directly — single walking surface, no
|
|
// parser duplication).
|
|
//
|
|
// Hand-rolled, zero-dep — reuses scalarField from aidlc-lib.ts as a
|
|
// zero-dep YAML primitive. Pure functions; no I/O.
|
|
//
|
|
// Schema (strict-additive runtime + pull authoring):
|
|
// - pairing: string — sensor cross-reference; "feedforward-only" or
|
|
// a sensor id matching ^aidlc-
|
|
// - status: string — lifecycle state; active, deprecated, or draft
|
|
// - stale_after: string — ISO calendar date (YYYY-MM-DD)
|
|
//
|
|
// Deleted from the schema:
|
|
// - enforcement: enforced (no two-mode keyword; all rules are guardrails)
|
|
// - overrides: { rule, reason, approved_by } (no governance attestation
|
|
// keyword; conflicts rejected at admission gates instead)
|
|
// - paths: string[] (push-side scoping; pull authoring puts the
|
|
// phase→rule import on the stage's existing phase: field, so phase
|
|
// rules attach to every stage in their phase — no glob filter needed)
|
|
|
|
import { scalarField } from "./aidlc-lib.ts";
|
|
|
|
export interface RuleFrontmatter {
|
|
// pairing: "feedforward-only" or a sensor-id starting with "aidlc-".
|
|
// Compile-time check is shape-only; sensor cross-validation happens
|
|
// at doctor time (separate concern, separate code path).
|
|
pairing?: string;
|
|
status?: string;
|
|
stale_after?: string;
|
|
}
|
|
|
|
function hasTopLevelField(frontmatter: string, key: string): boolean {
|
|
return new RegExp(`^${key}:`, "m").test(frontmatter);
|
|
}
|
|
|
|
// parseRuleFrontmatter — extract YAML frontmatter from a rule-file body.
|
|
// Returns {} when no `---...---` block is present. Differs from
|
|
// parseStageFrontmatter (which throws on missing frontmatter) because rule
|
|
// files routinely ship with no frontmatter (aidlc-org.md, aidlc-team.md,
|
|
// aidlc-project.md, all 4 phase rules carry zero frontmatter; only the
|
|
// optional pairing: case introduces it).
|
|
//
|
|
// Tolerates unknown keys (forward-compat per 08-rule-system.md "additive
|
|
// extension"). Validation runs separately via validateRuleFrontmatter.
|
|
//
|
|
// Strips a UTF-8 BOM (U+FEFF) before matching. macOS and Windows editors
|
|
// occasionally save markdown files with a leading BOM; without this, the
|
|
// `^---\r?\n` regex anchor wouldn't match and the file would parse as
|
|
// frontmatter-less, silently dropping `pairing:`.
|
|
export function parseRuleFrontmatter(raw: string): RuleFrontmatter {
|
|
const cleaned = raw.charCodeAt(0) === 0xFEFF ? raw.slice(1) : raw;
|
|
const m = cleaned.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
if (!m) return {};
|
|
const fm = m[1];
|
|
|
|
const obj: RuleFrontmatter = {};
|
|
|
|
const pairing = scalarField(fm, "pairing");
|
|
if (pairing !== "") obj.pairing = pairing;
|
|
|
|
const status = scalarField(fm, "status");
|
|
if (status !== "" || hasTopLevelField(fm, "status")) obj.status = status;
|
|
|
|
const staleAfter = scalarField(fm, "stale_after");
|
|
if (staleAfter !== "" || hasTopLevelField(fm, "stale_after")) {
|
|
obj.stale_after = staleAfter;
|
|
}
|
|
|
|
return obj;
|
|
}
|
|
|
|
// validateRuleFrontmatter — schema check on a parsed rule frontmatter.
|
|
// Throws "<file>: <message>" on the first violation, mirroring
|
|
// compileStageGraph's error pattern. Compile fails loud and names the file.
|
|
export function validateRuleFrontmatter(
|
|
obj: RuleFrontmatter,
|
|
file: string,
|
|
): void {
|
|
if (obj.pairing !== undefined) {
|
|
if (typeof obj.pairing !== "string" || obj.pairing.length === 0) {
|
|
throw new Error(`${file}: pairing must be a non-empty string`);
|
|
}
|
|
if (obj.pairing !== "feedforward-only" && !obj.pairing.startsWith("aidlc-")) {
|
|
throw new Error(
|
|
`${file}: pairing must be "feedforward-only" or start with "aidlc-" ` +
|
|
`(sensor id shape); got "${obj.pairing}"`,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (
|
|
obj.status !== undefined &&
|
|
obj.status !== "active" &&
|
|
obj.status !== "deprecated" &&
|
|
obj.status !== "draft"
|
|
) {
|
|
throw new Error(
|
|
`${file}: status must be one of "active", "deprecated", or "draft"; ` +
|
|
`got "${obj.status}"`,
|
|
);
|
|
}
|
|
|
|
if (obj.stale_after !== undefined) {
|
|
const date = new Date(`${obj.stale_after}T00:00:00.000Z`);
|
|
if (
|
|
!/^\d{4}-\d{2}-\d{2}$/.test(obj.stale_after) ||
|
|
Number.isNaN(date.getTime()) ||
|
|
date.toISOString().slice(0, 10) !== obj.stale_after
|
|
) {
|
|
throw new Error(
|
|
`${file}: stale_after must be a real calendar date in YYYY-MM-DD format; ` +
|
|
`got "${obj.stale_after}"`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
export function isRuleStale(
|
|
obj: RuleFrontmatter,
|
|
today: string,
|
|
): boolean {
|
|
return obj.status === "deprecated" ||
|
|
(obj.stale_after !== undefined && today > obj.stale_after);
|
|
}
|