// `dz guard` — a declarative constraint layer that runs BEFORE self-mutating operations (publish, teach, // consolidate, reindex) and refuses when a HARD invariant is violated. Convergent shape from two RuvNet sources // (SAFLA's Safety & Validation Framework + daa-rules v0.2.1): declarative rules + a pure evaluator + an // append-only audit log. We DEPEND ON NEITHER — the engine is dz-native and reuses dz's own existing checks // (skill-drift, publish gate) rather than reinventing them. // // COMPLEX INSIDE, SIMPLE OUTSIDE: the engine below is a fail-safe, op-scoped, HARD/SOFT rule evaluator; the CLI // surface is just `dz guard check --op ` with built-in defaults that need zero configuration. `.dz/guard.json` // only exists if you want to override a severity, disable a rule, or tune a parameter. // // PURE: `evaluateGuard` operates over INJECTED FACTS (package.json deps, a drift result, lesson text, README // counts, store size) that the CLI gathers. No filesystem here → deterministic + unit-testable without a repo. import { type RuleTemplate, type TemplateParams, type ChangeSet, templateFires, validTemplateParams } from './guard-promotion.js'; import { STUB_MARKERS, STUB_PHRASES, checkNoStubs, type StubWaiver } from './no-stubs.js'; export type GuardSeverity = 'hard' | 'soft'; export type GuardOp = 'publish' | 'teach' | 'consolidate' | 'reindex'; export type GuardVerdict = 'pass' | 'warn' | 'block'; /** A declarative rule. Built-in rules ship with a checker (below); config can only tune/disable them. */ export interface GuardRule { readonly id: string; readonly severity: GuardSeverity; readonly ops: readonly GuardOp[]; readonly description: string; /** false ⇒ the rule is disabled (config override). */ readonly enabled?: boolean; /** * A PROMOTED rule (`dz guard promote`) carries a template + params instead of a built-in checker. * This is the ONLY way a rule id the engine does not know may enter the rule set — and such a rule * is forced SOFT unconditionally (see {@link resolveRules}). */ readonly template?: RuleTemplate; readonly params?: TemplateParams; } export interface Violation { readonly rule: string; readonly severity: GuardSeverity; readonly detail: string; } export interface GuardResult { readonly op: GuardOp; readonly verdict: GuardVerdict; readonly violations: readonly Violation[]; /** ids of the rules that ran for this op (so a report can show what was checked, not just what failed). */ readonly checked: readonly string[]; /** * Informational notes (FN-7): things a rule wants ON THE RECORD that are NOT violations and never * touch the verdict — e.g. "no-stubs: N changed scannable file(s) not scanned". A fail-open skip * that leaves no trace is fail-SILENT, the worst kind by the gate's own cost-of-detection * argument; a note is the cheap fix. Present only when non-empty. */ readonly notes?: readonly string[]; } /** Facts the CLI injects; each rule reads only the fields it needs. Missing evidence ⇒ that rule is skipped. */ export interface GuardFacts { readonly op: GuardOp; /** for no-workspace-star: each publishable package's deps map. */ readonly packages?: readonly { readonly name: string; readonly deps: Readonly> }[]; /** for no-skill-drift: the names that byte-drift between copies (from sweepSkillDrift). */ readonly drift?: readonly string[]; /** for no-secrets: labelled blobs to scan (lesson text, staged files). */ readonly secretTargets?: readonly { readonly label: string; readonly text: string }[]; /** for readme-consistency: labelled (a,b) count pairs that must be equal. */ readonly counts?: readonly { readonly label: string; readonly a: number; readonly b: number }[]; /** for store-bloat-cap: current learned-store size vs its cap. */ readonly store?: { readonly count: number; readonly cap: number }; /** for skills-registrable: per skill pack, dirs that would ship un-registrable (no depth-1 SKILL.md). */ readonly skillPacks?: readonly { readonly name: string; readonly nonRegistrable: readonly string[] }[]; /** for readme-first: per publishable package, is a version bump staged without a README change? */ readonly readmeFirst?: readonly { readonly name: string; readonly versionBumped: boolean; readonly readmeChanged: boolean }[]; /** * for review-round: per publishable package, does this change bump a version AND touch SOURCE, and * did it bring a GRADED QE report with it? `undefined` (the whole fact absent) means the tree could * not be read — the rule then reports nothing, which is different from reporting "no review". */ readonly reviewRound?: { readonly packages: readonly { readonly name: string; readonly versionBumped: boolean; readonly sourceChanged: boolean }[]; /** grades parsed out of `features/*∕08_qe_report.md` files in this change set, in file order. */ readonly grades: readonly { readonly report: string; readonly grade: string }[]; /** * Optional floor from `.dz/guard.json` → `reviewRound.minGrade`. Carried in the FACT because a * rule body is a pure function of facts and takes no config — and because the owner reserved the * choice of threshold, so the DEFAULT must stay "a grade is present". */ readonly minGrade?: string | undefined; /** * `false` when the gatherer TRIED and could not read the tree. The note below fires only on * that, never on a caller that simply never gathered — otherwise every synthetic evaluation * carries a warning about evidence nobody asked for. */ readonly gathered?: boolean | undefined; }; /** * for agents-md-policy-sync: result of the pure policy drift detector, gathered by the CLI. * `applicable:false` is a repo whose canonical policy sources are unreadable; omission means the * gatherer could not obtain evidence. `fenced` says whether the repo OPTED IN — i.e. its AGENTS.md * already carries a `dz:policies` fence. The two combine to separate a repo that is out of scope * (never opted in — silent) from one that opted in and then lost its sources (loud), so the shared * `notes` channel does not carry a permanent "not applicable" line in every consumer repo. */ readonly policyDrift?: { readonly applicable: boolean; readonly drifted: readonly string[]; readonly fenced?: boolean; }; /** * for lockfile-in-sync: what each workspace package DECLARES vs what pnpm-lock.yaml RECORDS for that * importer. `parsed:false` (or the fact absent) ⇒ the rule reports nothing — fail-open by construction, * because a lockfile we could not read is not evidence of a defect. */ /** * for TEMPLATE rules (promoted by `dz guard promote`): the change under evaluation — the file list * of the working-tree diff, plus the text of those files when a `format-match` rule needs it. * ABSENT ⇒ every template rule reports NOTHING (fail-open on missing evidence, the same contract * `lockfile-in-sync` follows). */ readonly change?: { readonly files: readonly string[]; readonly contents?: Readonly>; /** * FN-7: how many changed STUB-SCANNABLE files the gatherer did NOT read (deleted, non-regular, * oversize, read error, or beyond the file cap). The no-stubs scan stays fail-open on each of * them — but the skip must be ON THE RECORD (a GuardResult note), never silent. */ readonly stubSkipped?: number; }; /** * for no-stubs: config waivers from `.dz/guard.json` `stubWaivers: [{path, reason}]` — path-keyed, * reason MANDATORY (the feature-adr-setup --guards shape). The scan itself reads `change.files` + * `change.contents`, the SAME working-tree diff every other diff-aware rule uses. */ readonly stubWaivers?: readonly StubWaiver[]; /** * for licence-hold: per package that DECLARES a licence hold (package.json `licenseHold` field — * ADR-001 of feature hermes-claude-adaptation), the raw evidence the checker needs. The checker * fires only for packs that are actually publishable (`privateFlag !== true`): while `private:true` * the npm layer itself refuses, and blocking every unrelated publish for a parked pack would train * the --no-guard habit. The moment `private` is dropped without the hold being satisfied, this rule * HARD-blocks publish. */ readonly licenceHold?: readonly { readonly name: string; readonly privateFlag: boolean; /** LICENSE file text; null ⇒ absent. */ readonly licenseText: string | null; /** THIRD_PARTY_NOTICES(.md) text; null ⇒ absent. */ readonly noticesText: string | null; /** package.json `license` field. */ readonly licenseField: string | null; }[]; readonly lockfile?: { readonly parsed: boolean; readonly importers?: readonly { /** importer path as pnpm keys it, e.g. `packages/@dzhechkov/harness-cli`. */ readonly importer: string; /** the `@dzhechkov/*` specs the package.json declares (deps + devDeps). */ readonly declared: Readonly>; /** the specs pnpm-lock.yaml records for this importer; `undefined` ⇒ the importer is absent. */ readonly locked?: Readonly> | undefined; }[]; }; } /** The lowest `lockfileVersion` whose importers carry the `specifier:`/`version:` pair this parser reads. */ export const MIN_RECOGNISED_LOCKFILE_VERSION = 9; /** * RECOGNISE-OR-REFUSE `pnpm-lock.yaml` importers parser — PURE, no YAML dependency. Reads exactly one * shape: `importers:` → `:` → `
:` → `'':` → `specifier: ` (lockfileVersion 9+). * * "Tolerant" must mean *refuses to guess*, NOT *guesses quietly*. A half-parse is the dangerous outcome: * a lockfileVersion-6 file lists deps as `dep: version` one-liners under a separate `specifiers:` map, so * a lenient reader finds the importer KEYS, records ZERO specifiers, and the rule then reports every real * dependency as *"not recorded in pnpm-lock.yaml"* — a false-positive storm dressed up as fail-open. So we * return `undefined` (⇒ the rule reports NOTHING) unless every one of these holds: * 1. `lockfileVersion` is present and ≥ {@link MIN_RECOGNISED_LOCKFILE_VERSION}; * 2. an `importers:` section exists and yields at least one importer; * 3. no legacy inline `dep: value` line appears at dependency depth (the v5/v6 shape); * 4. at least one `specifier:` was read, and NO importer came out empty (a truncated file, or a shape * we do not understand, always trips this). * * `dependencies` and `devDependencies` are merged: a dep appears in only one of them per importer, and * the rule compares specifier strings only. */ export function parsePnpmLockImporters(lockText: unknown): Record> | undefined { if (typeof lockText !== 'string' || lockText === '') return undefined; // (1) version gate — the ONLY layout this parser claims to understand. const versionLine = lockText.match(/^lockfileVersion:\s*['"]?([0-9]+(?:\.[0-9]+)?)['"]?\s*$/m); const version = versionLine?.[1] !== undefined ? Number.parseFloat(versionLine[1]) : Number.NaN; if (!Number.isFinite(version) || version < MIN_RECOGNISED_LOCKFILE_VERSION) return undefined; const importers: Record> = {}; let inImporters = false; let current: string | undefined; let currentDep: string | undefined; let specifiersSeen = 0; let sawImportersKey = false; for (const line of lockText.split('\n')) { if (/^importers:\s*$/.test(line)) { inImporters = true; sawImportersKey = true; continue; } if (!inImporters) continue; if (/^\S/.test(line)) break; // a new top-level key ends the importers section if (line.trim() === '') continue; const importer = line.match(/^ {2}(\S.*?):\s*$/); if (importer && importer[1] !== undefined) { current = unquoteYaml(importer[1]); importers[current] = importers[current] ?? {}; currentDep = undefined; continue; } if (current === undefined) continue; // (3) a dependency-depth line that carries an INLINE value is the pre-v9 shape → refuse outright // rather than silently recording nothing for this importer. if (/^ {6}\S.*?:\s+\S/.test(line)) return undefined; const dep = line.match(/^ {6}(\S.*?):\s*$/); if (dep && dep[1] !== undefined) { // A dep line while the PREVIOUS dep never got its specifier = a truncated/unrecognized shape — // refuse the whole parse rather than warn on a half-read (Codex re-QE: pending currentDep). if (currentDep !== undefined) return undefined; currentDep = unquoteYaml(dep[1]); continue; } const spec = line.match(/^ {8}specifier:\s*(.+?)\s*$/); if (spec && spec[1] !== undefined && currentDep !== undefined) { importers[current]![currentDep] = unquoteYaml(spec[1]); specifiersSeen += 1; currentDep = undefined; } } // EOF with a dep still awaiting its specifier: truncated — refuse, never warn on a half-parse. if (currentDep !== undefined) return undefined; // (2) + (4) structural confidence: no importers, no specifiers, or ANY importer that came out empty // (truncation, an unread section shape) means we did not really parse this file — report nothing. if (!sawImportersKey || Object.keys(importers).length === 0 || specifiersSeen === 0) return undefined; for (const deps of Object.values(importers)) if (Object.keys(deps).length === 0) return undefined; return importers; } function unquoteYaml(s: string): string { const t = s.trim(); if ((t.startsWith("'") && t.endsWith("'") && t.length >= 2) || (t.startsWith('"') && t.endsWith('"') && t.length >= 2)) { return t.slice(1, -1); } return t; } /** The built-in rule set (works with no config). Ops are the mutating operations each rule guards. */ export const DEFAULT_RULES: readonly GuardRule[] = [ { id: 'no-workspace-star', severity: 'hard', ops: ['publish'], description: 'a published package.json must carry no workspace:* dep (npm ships it verbatim → the install breaks)' }, { id: 'no-skill-drift', severity: 'hard', ops: ['publish', 'consolidate'], description: 'no unexpected byte-drift between shared skill copies' }, { id: 'no-secrets', severity: 'hard', ops: ['teach', 'publish'], description: 'no private key or API token in lesson text or a published file' }, { id: 'readme-consistency', severity: 'soft', ops: ['publish'], description: 'README counts agree (CJM header vs All Commands, etc.)' }, { id: 'skills-registrable', severity: 'soft', ops: ['publish'], description: 'every skill directory in a skill pack has a depth-1 SKILL.md (a buried or missing one ships un-registrable — the health-advisor 1.2.0 class)' }, { id: 'readme-first', severity: 'soft', ops: ['publish'], description: 'a package with a staged version bump must update its own README.md in the same change (README-first)' }, { id: 'agents-md-policy-sync', severity: 'soft', ops: ['publish'], description: 'proves the AGENTS.md copy is in SYNC with its source — not that the runtime read or obeyed it; heal drift with dz agents-sync' }, { id: 'lockfile-in-sync', severity: 'soft', ops: ['publish'], description: 'every workspace @dzhechkov/* dependency spec matches the specifier pnpm-lock.yaml records for that importer (a dep bump without a lockfile refresh breaks CI with ERR_PNPM_OUTDATED_LOCKFILE). SOFT-ONLY — a config cannot promote it to HARD' }, { id: 'store-bloat-cap', severity: 'soft', ops: ['teach', 'consolidate'], description: 'the learned store is within its size cap' }, // Description ASSEMBLED from STUB_MARKERS so guard.ts itself stays clean under the scan it defines // (structural self-exemption — tested in no-stubs.test.ts). { id: 'no-stubs', severity: 'soft', ops: ['publish'], description: `an unfinished-stub marker (${STUB_MARKERS.join('/')} / "${STUB_PHRASES.join('", "')}") left in a CHANGED file — any unwaived match means the change ships incomplete; waive per line with "no-stubs: " or per path in .dz/guard.json stubWaivers (reason MANDATORY)` }, { id: 'review-round', severity: 'hard', ops: ['publish'], description: 'a package publishing CHANGED SOURCE must bring a GRADED features/*/08_qe_report.md in the same change. Scoped to source so a docs-only republish is never blocked; the floor is PRESENCE of a grade unless .dz/guard.json sets reviewRound.minGrade. It proves a graded report EXISTS for this change — NOT that the review was independent, competent, or taken against this exact revision' }, { id: 'licence-hold', severity: 'hard', ops: ['publish'], description: 'a pack that declares a licence hold (package.json.licenseHold — ADR-001 hermes-claude-adaptation) must not become publishable until the hold is satisfied: LICENSE present without the PENDING grant placeholder, a Grant-Confirmation URL, non-empty THIRD_PARTY_NOTICES, and a clean SPDX license field' }, ]; /** The exact placeholder LICENSE marker the licence-hold rule looks for (shared with pack tests). */ export const LICENCE_HOLD_PENDING_MARKER = '