diff --git a/.changeset/predicate-evaluator-2785.md b/.changeset/predicate-evaluator-2785.md new file mode 100644 index 000000000..5b8c3b4d8 --- /dev/null +++ b/.changeset/predicate-evaluator-2785.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 2816 +--- +**Gate predicate `artifact-frontmatter-equals` is now implemented** — declared gates that use it are evaluated instead of erroring on an unrecognized kind. (#2785) diff --git a/CONTEXT.md b/CONTEXT.md index bfd1617cc..2ec6f4c5c 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -232,7 +232,7 @@ A bundle delivering one optional GSD feature, toggled as a unit at install or af Generated description of what the five-step loop (Discuss → Plan → Execute → Verify → Ship) exposes as extension points: per-step loop points, agent roles, and core artifacts. Sourced from structured `` HTML-comment markers embedded near the top of each of the five step workflow files (`discuss-phase.md`, `plan-phase.md`, `execute-phase.md`, `verify-work.md`, `ship.md`). Generated by `scripts/gen-loop-host-contract.cjs` → `gsd-core/bin/lib/loop-host-contract.cjs` (ADR-894 §3 phase 3a-impl-2). Covers exactly the 12 canonical points (discuss:pre/post, plan:pre/post, execute:pre/wave:pre/wave:post/post, verify:pre/post, ship:pre/post). The generator enforces a drift guard: every declared non-orchestrator agent role must correspond to an actual agent reference in the workflow file. Consumed by `gen-capability-registry.cjs` (replaces the former inline `LOOP_HOST_CONTRACT` constant). Run `node scripts/gen-loop-host-contract.cjs --write` after editing a workflow step marker. ### Gate Predicate Evaluator Module -Pure, deps-injected evaluator for capability gate `check.predicate` blocks (#2008, ADR-2008). Prior to #2008 the registry validator accepted `check.predicate` (one of exactly-one-of `query`/`predicate`/`agentVerdict`) and the loop-resolver rendered it, but nothing EVALUATED a declared predicate — only `check.query` was enforced (dispatched via `gsd_run check `), and built-in gates like `security` worked only via hard-coded `capId` prose branches in `ship.md`/`execute-phase.md`/`verify-work.md`. This module is the generic evaluation path: `evaluatePredicate(predicate, context, deps) → { block, message, details? }` dispatches by `predicate.kind` through a `KIND_TABLE`. Built-in kind (v1): `command-exit-zero` — runs a declared command in a bounded `sh -c` subprocess (production binding: `shell-command-projection.execTool`) at the project root, inheriting env; exit 0 ⇒ pass, non-zero ⇒ block, timeout (SIGTERM) ⇒ block; interpolates `${PHASE_NUMBER}`/`${PHASE_DIR}`/`${PHASE_REQ_IDS}` from gate context. A THROWN error (malformed predicate, non-positive/non-finite timeout, command >4096 chars, unknown kind) maps at the CLI seam to a non-zero check-command exit, which the workflow's two-step gate contract treats as a step-1 command failure routed per `onError` — so an evaluator bug is never conflated with a legitimate block decision. Leaf pure module (no fs/child_process/config — subprocess seam injected). CLI entry: `gsd_run check predicate --predicate '' [--phase-dir …] [--phase-number …] [--phase-req-ids …] --raw`, wired into `check-command-router.cts:cmdCheckPredicate`; the three generic workflow gate-dispatch sites (`execute:wave:post`, `execute:post`, `plan:post`) branch on `check` shape (`query` vs `predicate`). Source of truth: `src/gate-predicate-evaluator.cts`. Docs: `docs/reference/gate-predicates.md`, `docs/how-to/command-exit-zero-gate.md`. +Pure, deps-injected evaluator for capability gate `check.predicate` blocks (#2008, ADR-2008). Prior to #2008 the registry validator accepted `check.predicate` (one of exactly-one-of `query`/`predicate`/`agentVerdict`) and the loop-resolver rendered it, but nothing EVALUATED a declared predicate — only `check.query` was enforced (dispatched via `gsd_run check `), and built-in gates like `security` worked only via hard-coded `capId` prose branches in `ship.md`/`execute-phase.md`/`verify-work.md`. This module is the generic evaluation path: `evaluatePredicate(predicate, context, deps) → { block, message, details? }` dispatches by `predicate.kind` through a `KIND_TABLE`. Built-in kinds: `command-exit-zero` runs a declared command in a bounded `sh -c` subprocess (production binding: `shell-command-projection.execTool`) at the project root, inheriting env; exit 0 ⇒ pass, non-zero ⇒ block, timeout (SIGTERM) ⇒ block; `artifact-frontmatter-equals` resolves a phase or project-level Markdown artifact through injected dependencies and compares a frontmatter field by scalar string value. Command predicates interpolate `${PHASE_NUMBER}`/`${PHASE_DIR}`/`${PHASE_REQ_IDS}` from gate context. A THROWN error (malformed predicate, non-positive/non-finite timeout, command >4096 chars, missing dependencies, unknown kind) maps at the CLI seam to a non-zero check-command exit, which the workflow's two-step gate contract treats as a step-1 command failure routed per `onError` — so an evaluator bug is never conflated with a legitimate block decision. Leaf pure module (no fs/child_process/config — subprocess and artifact seams injected). CLI entry: `gsd_run check predicate --predicate '' [--phase-dir …] [--phase-number …] [--phase-req-ids …] --raw`, wired into `check-command-router.cts:cmdCheckPredicate`; the three generic workflow gate-dispatch sites (`execute:wave:post`, `execute:post`, `plan:post`) branch on `check` shape (`query` vs `predicate`). Source of truth: `src/gate-predicate-evaluator.cts`. Docs: `docs/reference/gate-predicates.md`, `docs/how-to/command-exit-zero-gate.md`. ### Capability Registry Generated central manifest projecting all co-located Capability declarations into one validated artifact for runtime resolution and for the install, surface, config, and loop-extension adapters. Mirrors the research-profiles / package-identity generation pattern (co-located source → generated central file). Generated by `scripts/gen-capability-registry.cjs` → `gsd-core/bin/lib/capability-registry.cjs` (ADR-894 §5 phase 3a-impl). Role-partitioned indexes: `bySkill`, `byAgent`, `byLoopPoint` (hook ordering materialized), `configKeys` (ownership map: key→capId), `configSchema` (full per-key schema: key→{ owner, type, default, description }), `runtimes`, `requiresClosure(id)`. Each feature capability's entry in `capabilities` now includes the optional `activationKey` field (the dotted config key that gates the whole capability, e.g. `"graphify.enabled"`; absent means no config gate). ADR-857 phase 3b adds `configSchema` with validated type/default/description per key, sourced from each capability's `.config` slice. ADR-857 phase 4a adds two derived views: `capabilityClusters` (`{ : [] }` — each cap's skills array, sorted, derived from the capability's `skills` declaration; consistency-gated against the hand-authored `CLUSTERS`) and `profileMembership` (`{ : { tier, profiles: [...] } }` — the tier-derived index: suffix of `PROFILE_RANK` starting at the capability's tier). Both views cover the same capability set: only capabilities that own skills (non-empty `skills` array). The generator enforces a HARD gate (throws) if a capId matching a `CLUSTERS` key has a mismatched skill set, and emits SOFT `⚠ pending-reconciliation` warnings to stderr (never to the file) for skills not yet in the hand-authored profile at the capability's tier. `install` and `surface` are UNTOUCHED (still read hand-authored constants; derived views are emitted and tested but unconsumed until cutover). Validated against the Loop Host Contract (12 points; generated by `gen-loop-host-contract.cjs` from workflow markers, phase 3a-impl-2). Run `node scripts/gen-capability-registry.cjs --write` after editing any `capabilities//capability.json`. diff --git a/docs/adr/2008-command-exit-zero-gate.md b/docs/adr/2008-command-exit-zero-gate.md index fbdaccf53..238e1c277 100644 --- a/docs/adr/2008-command-exit-zero-gate.md +++ b/docs/adr/2008-command-exit-zero-gate.md @@ -1,4 +1,4 @@ -# ADR-2008: Generic gate-predicate evaluator (`command-exit-zero`) +# ADR-2008: Generic gate-predicate evaluator | | | |---|---| @@ -39,8 +39,8 @@ The maintainer chose **Option 2** (issue comment, 2026-07-04). ## Decision -Add a **generic gate-predicate evaluation path** with one built-in kind, -`command-exit-zero`, scoped as follows. +Add a **generic gate-predicate evaluation path** with two built-in kinds, +`command-exit-zero` and `artifact-frontmatter-equals`, scoped as follows. ### Declaration shape @@ -140,9 +140,6 @@ hiding the mechanism. ## Out of scope -- Implementing the `artifact-frontmatter-equals` kind (the maintainer chose - Option 2 over Option 1; the kind table is structured for it but it is not - registered). - Retiring the hard-coded `security` branch in `ship.md`. - `check.agentVerdict` evaluation (advisory, `blocking:false`-forced, separate concern). diff --git a/docs/adr/README.md b/docs/adr/README.md index 299ab6747..429f0849b 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -169,7 +169,7 @@ These govern the system as it stands. Cite these. | [ADR-1820](1820-spec-optional-predicate-rail.md) | Spec-Optional Predicate Rail — the Spec-Section Detection Module, the fallback toggle, and the SPEC↔probe precedence contract | Accepted | — | | [ADR-1866](1866-agent-skills-dual-injection-contract.md) | agent_skills dual injection — orchestrator-side + agent-side self-load | Accepted | — | | [ADR-1990](1990-existing-code-onboarding.md) | Existing Code Onboarding Module owns deterministic repo-state detection and onboarding route selection | Accepted | — | -| [ADR-2008](2008-command-exit-zero-gate.md) | Generic gate-predicate evaluator (`command-exit-zero`) | Accepted | — | +| [ADR-2008](2008-command-exit-zero-gate.md) | Generic gate-predicate evaluator | Accepted | — | | [ADR-2121](2121-phase-identifier-parsing-consolidation.md) | Phase-Identifier Parsing Consolidation | Accepted | — | | [ADR-2143](2143-markdown-table-and-mutation-consolidation.md) | Markdown Table Model, Bounded Mutation, and Fail-Loud Consolidation (#1372 part 2) | Accepted | — | | [ADR-2164](2164-statusline-scope-boundary.md) | Statusline draws its data boundary at local, read-only sources | Accepted | — | diff --git a/docs/reference/gate-predicates.md b/docs/reference/gate-predicates.md index 145d6bbdc..1e44a0d4a 100644 --- a/docs/reference/gate-predicates.md +++ b/docs/reference/gate-predicates.md @@ -102,6 +102,32 @@ model". - `timeout` present but not a positive finite number. - Unknown `kind`. +### `artifact-frontmatter-equals` + +Reads a Markdown file with YAML frontmatter from the current phase directory (or falls back to the project root for project-level artifacts) and compares a field's value to the declared expectation. The value is matched using loosely typed string comparison or exact matching, where numeric expectations will safely match stringified numeric frontmatter values. + +| Field | Type | Required | Default | Notes | +|---|---|---|---|---| +| `kind` | string | yes | — | Must be `"artifact-frontmatter-equals"` | +| `artifact` | string | yes | — | Suffix or exact filename (e.g. `WINDOWS.md`) | +| `field` | string | yes | — | Frontmatter key to read | +| `equals` | any | yes | — | Expected value (compared with string coercion) | + +**Result mapping.** + +| Command outcome | `block` | `message` | +|---|---|---| +| Value matches `equals` | `false` | `Frontmatter field "" matches expected value ()` | +| Value mismatch | `true` | `Frontmatter field "" in is , expected ` | +| Artifact file not found | `true` | `Artifact matching not found in ` | + +**Validation errors (throw → check-command failure → Step-1 / `onError`).** + +- Missing or empty `artifact` string. +- Missing or empty `field` string. +- Missing `equals` value. +- File read or YAML parsing failure (I/O errors). + ## Extensibility The evaluator dispatches through a `KIND_TABLE`. Adding a new built-in kind is diff --git a/src/check-command-router.cts b/src/check-command-router.cts index 560d1d089..556cabe75 100644 --- a/src/check-command-router.cts +++ b/src/check-command-router.cts @@ -20,7 +20,11 @@ import phaseLocatorMod = require('./phase-locator.cjs'); const { findPhaseInternal } = phaseLocatorMod; import { extractDecisions } from './decisions.cjs'; import type { Decision } from './decisions.cjs'; +// eslint-disable-next-line @typescript-eslint/no-require-imports +import frontmatterMod = require('./frontmatter.cjs'); +const { extractFrontmatter } = frontmatterMod; import { stripFencedCode, collectSections } from './markdown-sectionizer.cjs'; +import { validatePath } from './security.cjs'; import { checkUiPresence } from './ui-safety-gate.cjs'; // eslint-disable-next-line @typescript-eslint/no-require-imports import verifyModule = require('./verify.cjs'); @@ -38,7 +42,7 @@ const { evaluatePredicate } = gatePredicateEval; // eslint-disable-next-line @typescript-eslint/no-require-imports import apiCoverageMod = require('./api-coverage.cjs'); const { detectApiIntegration, validateCoverageMatrix } = apiCoverageMod; -import { execTool, posixNormalize } from './shell-command-projection.cjs'; +import { execTool, platformReadSync, posixNormalize } from './shell-command-projection.cjs'; // ─── Helpers ────────────────────────────────────────────────────────────────── @@ -967,6 +971,42 @@ function buildPredicateDeps() { timedOut: r.signal === 'SIGTERM', }; }, + findPhaseArtifact(phaseDir: string, artifactSuffix: string): string | null { + if (!fs.existsSync(phaseDir)) return null; + if ( + artifactSuffix === '.' || + artifactSuffix === '..' || + artifactSuffix.includes('\0') || + path.basename(artifactSuffix) !== artifactSuffix || + path.win32.basename(artifactSuffix) !== artifactSuffix + ) { + return null; + } + const directPath = validatePath(artifactSuffix, phaseDir); + if (directPath.safe && fs.existsSync(directPath.resolved) && fs.statSync(directPath.resolved).isFile()) { + return directPath.resolved; + } + const planningPath = validatePath(path.join('.planning', artifactSuffix), phaseDir); + if (planningPath.safe && fs.existsSync(planningPath.resolved) && fs.statSync(planningPath.resolved).isFile()) { + return planningPath.resolved; + } + try { + const files = fs.readdirSync(phaseDir); + for (const f of files) { + if (f.endsWith('-' + artifactSuffix) || f === artifactSuffix) { + const candidate = validatePath(f, phaseDir); + if (candidate.safe && fs.statSync(candidate.resolved).isFile()) return candidate.resolved; + } + } + } catch { /* ignore */ } + return null; + }, + readFrontmatter(filePath: string): Record { + const content = platformReadSync(filePath); + if (content === null) throw new Error(`predicate artifact disappeared before it could be read: ${filePath}`); + const parsed = extractFrontmatter(content, filePath) as Record; + return parsed; + } }; } diff --git a/src/gate-predicate-evaluator.cts b/src/gate-predicate-evaluator.cts index a1cabe04e..f1f1ac503 100644 --- a/src/gate-predicate-evaluator.cts +++ b/src/gate-predicate-evaluator.cts @@ -17,11 +17,10 @@ * non-zero check-command exit, which the workflow's two-step gate contract treats * as a step-1 command failure (routed per the gate's `onError`). * - * Built-in kind (v1): `command-exit-zero` — run a declared command in a bounded - * `sh -c` subprocess at the project root, inheriting the process env; exit 0 => - * pass, non-zero => block, timeout => block. The production runBoundedShell - * binding is shell-command-projection.execTool (bounded spawnSync). See ADR-2008 - * for the full sandbox contract. + * Built-in kinds: `command-exit-zero` runs a declared command in a bounded + * `sh -c` subprocess; `artifact-frontmatter-equals` compares a declared value + * with frontmatter read through injected artifact dependencies. See ADR-2008 + * for the full contracts. * * This is a leaf pure module: no fs, no child_process, no config — the subprocess * seam is injected so the evaluator is trivially testable without spawning. @@ -39,7 +38,7 @@ const COMMAND_MAX_OUTPUT_CHARS = 2000; const COMMAND_MAX_LENGTH = 4096; /** Predicate kinds this evaluator recognises (extensible — add to KIND_TABLE). */ -const EVALUATOR_KINDS = Object.freeze(['command-exit-zero']); +const EVALUATOR_KINDS = Object.freeze(['command-exit-zero', 'artifact-frontmatter-equals']); /** Placeholders interpolated into a declared command, in addition to sh's own vars. */ const INTERPOLATION_VAR_NAMES = Object.freeze(['PHASE_NUMBER', 'PHASE_DIR', 'PHASE_REQ_IDS']); @@ -64,6 +63,8 @@ interface BoundedShellResult { interface PredicateDeps { runBoundedShell(opts: { command: string; cwd: string; timeoutMs: number }): BoundedShellResult; + findPhaseArtifact(phaseDir: string, artifactSuffix: string): string | null; + readFrontmatter(filePath: string): Record; } interface PredicateResult { @@ -149,10 +150,64 @@ function evaluateCommandExitZero( }; } +// ─── Kind: artifact-frontmatter-equals ────────────────────────────────────────── + +function evaluateArtifactFrontmatterEquals( + predicate: Record, + ctx: PredicateContext, + deps: PredicateDeps, +): PredicateResult { + const artifactSuffix = predicate['artifact']; + if (!isNonEmptyString(artifactSuffix)) { + throw new Error('artifact-frontmatter-equals predicate requires a non-empty string "artifact"'); + } + const field = predicate['field']; + if (!isNonEmptyString(field)) { + throw new Error('artifact-frontmatter-equals predicate requires a non-empty string "field"'); + } + const expectedValue = predicate['equals']; + if (expectedValue === undefined) { + throw new Error('artifact-frontmatter-equals predicate requires an "equals" key'); + } + const targetDir = isNonEmptyString(ctx.phaseDir) ? ctx.phaseDir : ctx.cwd; + const filePath = deps.findPhaseArtifact(targetDir, artifactSuffix); + if (!filePath) { + return { + block: true, + message: `Artifact matching ${artifactSuffix} not found in ${targetDir}`, + details: { kind: 'artifact-frontmatter-equals', artifactNotFound: true }, + }; + } + + const fm = deps.readFrontmatter(filePath); + + const actualValue = fm[field]; + // eslint-disable-next-line @typescript-eslint/no-base-to-string + const expectedStr = typeof expectedValue === 'object' ? JSON.stringify(expectedValue) : String(expectedValue); + // eslint-disable-next-line @typescript-eslint/no-base-to-string + const actualStr = typeof actualValue === 'object' ? JSON.stringify(actualValue) : String(actualValue); + + const matches = actualValue === expectedValue || (actualValue !== undefined && actualValue !== null && actualStr === expectedStr); + if (matches) { + return { + block: false, + message: `Frontmatter field "${field}" matches expected value (${expectedStr})`, + details: { kind: 'artifact-frontmatter-equals', match: true }, + }; + } + + return { + block: true, + message: `Frontmatter field "${field}" in ${artifactSuffix} is ${actualStr}, expected ${expectedStr}`, + details: { kind: 'artifact-frontmatter-equals', match: false, actual: actualValue, expected: expectedValue }, + }; +} + // ─── Kind dispatch table ────────────────────────────────────────────────────── const KIND_TABLE: Record, ctx: PredicateContext, deps: PredicateDeps) => PredicateResult> = { 'command-exit-zero': evaluateCommandExitZero, + 'artifact-frontmatter-equals': evaluateArtifactFrontmatterEquals, }; // ─── Public entry point ─────────────────────────────────────────────────────── @@ -184,6 +239,14 @@ function evaluatePredicate(predicate: unknown, context: unknown, deps: unknown): if (typeof kind !== 'string' || kind.length === 0) { throw new Error('predicate.kind must be a non-empty string'); } + if (kind === 'artifact-frontmatter-equals') { + if (typeof d.findPhaseArtifact !== 'function') { + throw new Error('predicate deps require a "findPhaseArtifact" function'); + } + if (typeof d.readFrontmatter !== 'function') { + throw new Error('predicate deps require a "readFrontmatter" function'); + } + } const handler = KIND_TABLE[kind]; if (!handler) { diff --git a/tests/gate-predicate-evaluator-missing.test.cjs b/tests/gate-predicate-evaluator-missing.test.cjs new file mode 100644 index 000000000..d1c59aca1 --- /dev/null +++ b/tests/gate-predicate-evaluator-missing.test.cjs @@ -0,0 +1,199 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const fc = require('fast-check'); +const { evaluatePredicate } = require('../gsd-core/bin/lib/gate-predicate-evaluator.cjs'); +const { buildPredicateDeps } = require('../gsd-core/bin/lib/check-command-router.cjs'); + +const dummyCtx = { cwd: '/fake/project' }; +const basePredicate = { + kind: 'artifact-frontmatter-equals', + artifact: 'WINDOWS.md', + field: 'open_count', + equals: 0, +}; + +function dummyDeps(overrides = {}) { + return { + runBoundedShell: () => ({ exitCode: 0, stdout: '', stderr: '', signal: null, timedOut: false }), + readFrontmatter: () => ({ open_count: '0' }), + findPhaseArtifact: (dir, suffix) => path.join(dir, suffix), + ...overrides, + }; +} + +test('evaluatePredicate handles artifact-frontmatter-equals kind with numeric string coercion', () => { + const deps = dummyDeps({ + readFrontmatter: (filePath) => { + const base = path.basename(filePath); + if (base === 'clean-WINDOWS.md') { + return { open_count: '0' }; + } + if (base === 'unclean-WINDOWS.md') { + return { open_count: '2' }; + } + return {}; + }, + }); + + const cleanPred = { + kind: 'artifact-frontmatter-equals', + artifact: 'clean-WINDOWS.md', + field: 'open_count', + equals: 0, + }; + + const passRes = evaluatePredicate(cleanPred, dummyCtx, deps); + assert.strictEqual(passRes.block, false, 'pass on matching numeric frontmatter field even if read as string'); + + const uncleanPred = { + kind: 'artifact-frontmatter-equals', + artifact: 'unclean-WINDOWS.md', + field: 'open_count', + equals: 0, + }; + + const blockRes = evaluatePredicate(uncleanPred, dummyCtx, deps); + assert.strictEqual(blockRes.block, true, 'block on non-matching frontmatter field'); +}); + +test('artifact-frontmatter-equals rejects malformed declarations', () => { + for (const artifact of [undefined, '']) { + assert.throws( + () => evaluatePredicate({ ...basePredicate, artifact }, dummyCtx, dummyDeps()), + /non-empty string "artifact"/, + ); + } + for (const field of [undefined, '']) { + assert.throws( + () => evaluatePredicate({ ...basePredicate, field }, dummyCtx, dummyDeps()), + /non-empty string "field"/, + ); + } + const { equals: _equals, ...withoutEquals } = basePredicate; + assert.throws( + () => evaluatePredicate(withoutEquals, dummyCtx, dummyDeps()), + /requires an "equals" key/, + ); +}); + +test('artifact-frontmatter-equals fails closed for missing artifacts and fields', () => { + const missingArtifact = evaluatePredicate( + basePredicate, + dummyCtx, + dummyDeps({ findPhaseArtifact: () => null }), + ); + assert.equal(missingArtifact.block, true); + assert.equal(missingArtifact.details.artifactNotFound, true); + + const missingField = evaluatePredicate( + basePredicate, + dummyCtx, + dummyDeps({ readFrontmatter: () => ({}) }), + ); + assert.equal(missingField.block, true); + assert.equal(missingField.details.match, false); + assert.equal(missingField.details.actual, undefined); +}); + +test('artifact-frontmatter-equals blocks non-numeric values and surfaces parse failures', () => { + const nonNumeric = evaluatePredicate( + basePredicate, + dummyCtx, + dummyDeps({ readFrontmatter: () => ({ open_count: 'clean' }) }), + ); + assert.equal(nonNumeric.block, true); + assert.equal(nonNumeric.details.actual, 'clean'); + + assert.throws( + () => evaluatePredicate( + basePredicate, + dummyCtx, + dummyDeps({ readFrontmatter: () => { throw new Error('invalid frontmatter'); } }), + ), + /invalid frontmatter/, + ); +}); + +test('artifact-frontmatter-equals validates its kind-specific dependencies', () => { + const deps = dummyDeps(); + delete deps.findPhaseArtifact; + assert.throws( + () => evaluatePredicate(basePredicate, dummyCtx, deps), + /predicate deps require a "findPhaseArtifact" function/, + ); + + const depsWithoutReader = dummyDeps(); + delete depsWithoutReader.readFrontmatter; + assert.throws( + () => evaluatePredicate(basePredicate, dummyCtx, depsWithoutReader), + /predicate deps require a "readFrontmatter" function/, + ); +}); + +test('property: stringified scalar frontmatter equals its JSON scalar declaration', () => { + const scalar = fc.oneof( + fc.integer(), + fc.boolean(), + fc.string({ maxLength: 40 }), + fc.constant(null), + ); + fc.assert( + fc.property(scalar, (value) => { + const result = evaluatePredicate( + { ...basePredicate, equals: value }, + dummyCtx, + dummyDeps({ readFrontmatter: () => ({ open_count: String(value) }) }), + ); + assert.equal(result.block, false); + }), + ); +}); + +test('evaluatePredicate with real buildPredicateDeps resolves project-level .planning/WINDOWS.md', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-pred-')); + try { + const planningDir = path.join(tmpDir, '.planning'); + fs.mkdirSync(planningDir, { recursive: true }); + const windowsPath = path.join(planningDir, 'WINDOWS.md'); + fs.writeFileSync(windowsPath, '---\nopen_count: 0\n---\n# Windows\n', 'utf8'); + + const pred = { + kind: 'artifact-frontmatter-equals', + artifact: 'WINDOWS.md', + field: 'open_count', + equals: 0, + }; + + const ctx = { cwd: tmpDir }; + const deps = buildPredicateDeps(); + + const res = evaluatePredicate(pred, ctx, deps); + assert.strictEqual(res.block, false, 'real buildPredicateDeps must find .planning/WINDOWS.md and pass on open_count: 0'); + + const parentDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-pred-parent-')); + try { + const projectDir = path.join(parentDir, 'project'); + fs.mkdirSync(projectDir); + fs.writeFileSync(path.join(parentDir, 'secret.md'), '---\nopen_count: 0\n---\n', 'utf8'); + assert.equal(deps.findPhaseArtifact(projectDir, '../secret.md'), null); + assert.equal(deps.findPhaseArtifact(projectDir, '..\\secret.md'), null); + } finally { + const { cleanup } = require('./helpers.cjs'); + cleanup(parentDir); + } + } finally { + const { cleanup } = require('./helpers.cjs'); + cleanup(tmpDir); + } +}); + +test('buildPredicateDeps reports an artifact removed before its frontmatter read', () => { + const missingPath = path.join(os.tmpdir(), 'gsd-test-predicate-artifact-missing.md'); + assert.throws( + () => buildPredicateDeps().readFrontmatter(missingPath), + /predicate artifact disappeared before it could be read/, + ); +}); diff --git a/tests/gate-predicate-evaluator.test.cjs b/tests/gate-predicate-evaluator.test.cjs index 7d5c9fb39..955a37043 100644 --- a/tests/gate-predicate-evaluator.test.cjs +++ b/tests/gate-predicate-evaluator.test.cjs @@ -274,9 +274,10 @@ describe('evaluatePredicate — malformed predicate throws (maps to check-cmd fa // ─── contract surface ───────────────────────────────────────────────────────── describe('evaluatePredicate — exported contract surface', () => { - test('EVALUATOR_KINDS advertises command-exit-zero', () => { + test('EVALUATOR_KINDS advertises every built-in predicate kind', () => { assert.ok(Array.isArray(EVALUATOR_KINDS)); assert.ok(EVALUATOR_KINDS.includes('command-exit-zero')); + assert.ok(EVALUATOR_KINDS.includes('artifact-frontmatter-equals')); }); test('default timeout is 30s', () => {