* 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:
5
.changeset/predicate-evaluator-2785.md
Normal file
5
.changeset/predicate-evaluator-2785.md
Normal 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)
|
||||
@@ -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`.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 | — |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
199
tests/gate-predicate-evaluator-missing.test.cjs
Normal file
199
tests/gate-predicate-evaluator-missing.test.cjs
Normal 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/,
|
||||
);
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user