fix(#2785): implement missing gate predicate evaluators (#2816)

* fix(#2785): implement missing gate predicate evaluators

* fix(#2785): gate predicate numerical coercion

* fix(#2785): address evaluator review findings

* fix(#2785): use safe frontmatter read seam

---------

Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
This commit is contained in:
Dennis Kim
2026-08-03 12:16:56 -04:00
committed by GitHub
parent 88f6d9bd1b
commit d3ddcaba1c
9 changed files with 347 additions and 16 deletions

View File

@@ -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)

View File

@@ -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 `<!-- gsd:loop-host ... -->` 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 <query>`), 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 '<json>' [--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 <query>`), 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 '<json>' [--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` (`{ <capId>: [<skill stems>] }` — each cap's skills array, sorted, derived from the capability's `skills` declaration; consistency-gated against the hand-authored `CLUSTERS`) and `profileMembership` (`{ <capId>: { 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/<id>/capability.json`.

View File

@@ -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).

View File

@@ -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 | — |

View File

@@ -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 "<field>" matches expected value (<expected>)` |
| Value mismatch | `true` | `Frontmatter field "<field>" in <artifact> is <actual>, expected <expected>` |
| Artifact file not found | `true` | `Artifact matching <artifact> not found in <targetDir>` |
**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

View File

@@ -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<string, unknown> {
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<string, unknown>;
return parsed;
}
};
}

View File

@@ -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<string, unknown>;
}
interface PredicateResult {
@@ -149,10 +150,64 @@ function evaluateCommandExitZero(
};
}
// ─── Kind: artifact-frontmatter-equals ──────────────────────────────────────────
function evaluateArtifactFrontmatterEquals(
predicate: Record<string, unknown>,
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<string, (p: Record<string, unknown>, 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) {

View File

@@ -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/,
);
});

View File

@@ -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', () => {