Merge remote-tracking branch 'origin/next' into feat/1279-fail-first-prover

# Conflicts:
#	docs/adr/550-spec-phase-probe-contract.md
#	gsd-core/workflows/verify-phase.md
#	tests/workflow-size-baseline.json
This commit is contained in:
Dave
2026-06-16 00:32:22 -04:00
31 changed files with 1934 additions and 166 deletions

View File

@@ -25,6 +25,7 @@
* - ./surface.cjs (resolveSurface)
* - ./config-loader.cjs (loadConfig)
* - ./runtime-homes.cjs (getGlobalConfigDir — for runtimeConfigDir auto-detection)
* - ./runtime-slash.cjs (resolveRuntime — GSD_RUNTIME > config.runtime > 'claude' precedence)
* - capability-registry.cjs (loaded at call time)
*/
@@ -89,6 +90,18 @@ interface CapabilityStateEntry {
surfaced: boolean;
/** True when the capability is both installed and surfaced. */
enabled: boolean;
/**
* True when the capability is enabled AND its config activation resolves to
* true. Config activation is determined by resolving the capability's
* `activationKey` (a dotted config key, e.g. `graphify.enabled`) via
* `_resolveActivationValue`. When `activationKey` is absent, configActivation
* defaults to `true` — the capability has no config gate.
*
* active = enabled && configActivation
*
* Note: `enabled` stays exactly `installed && surfaced` (unchanged).
*/
active: boolean;
/** Resolved hook activation state across steps, gates, and contributions */
hooks: HookEntry[];
}
@@ -211,6 +224,20 @@ function resolveCapabilityState(input: ResolveCapabilityStateInput): ResolveCapa
const enabled = installed && surfaced;
// ── per-capability config activation ──────────────────────────────────────
// Resolve the capability's own activationKey (if present). This is the
// config-level toggle that gates the whole capability — separate from the
// per-hook `when` keys that gate individual hooks. When activationKey is
// absent, configActivation defaults to true (no config gate on the cap).
// active = enabled && configActivation (enabled unchanged: installed && surfaced)
const activationKey = typeof capObj['activationKey'] === 'string' && capObj['activationKey'].length > 0
? capObj['activationKey']
: undefined;
const configActivation: boolean = activationKey !== undefined
? _resolveActivationValue(activationKey, config, cwd, registry)
: true;
const active = enabled && configActivation;
// ── hooks ──────────────────────────────────────────────────────────────────
// Collect from steps, gates, contributions. Each may have a `when` key.
// Activation semantics (mirrors loop-resolver.isActive exactly):
@@ -242,7 +269,12 @@ function resolveCapabilityState(input: ResolveCapabilityStateInput): ResolveCapa
// (mirrors loop-resolver.isActive: `typeof when !== 'string' || when.length === 0` → false)
configured = false;
}
hooks.push({ point, kind, when: whenRaw, configured, active: enabled && configured });
// Hook active = capability-level active AND hook's own config gate.
// The capability's `active` constant (= enabled && configActivation) is
// used here so that a config-disabled capability (active=false) cannot
// produce active hooks even when the hook's own `when` is unconditional
// (configured=true). The capability gate cascades to all its hooks.
hooks.push({ point, kind, when: whenRaw, configured, active: active && configured });
}
}
@@ -254,7 +286,7 @@ function resolveCapabilityState(input: ResolveCapabilityStateInput): ResolveCapa
processHooks(Array.isArray(gatesRaw) ? gatesRaw : [], 'gate');
processHooks(Array.isArray(contributionsRaw) ? contributionsRaw : [], 'contribution');
results.push({ id: capId, tier, skills, installed, surfaced, enabled, hooks });
results.push({ id: capId, tier, skills, installed, surfaced, enabled, active, hooks });
}
// Deterministic sort by id for stable output across calls
@@ -368,11 +400,15 @@ function _resolveManifest(commandsGsdDir: string, configDir: string): Map<string
* Envelope: { runtimeConfigDir, warnings?: string[], capabilities: CapabilityStateEntry[] }
*
* runtimeConfigDir resolution (when not provided or empty):
* Uses the canonical getGlobalConfigDir from runtime-homes.cjs to detect the
* active runtime's config dir — the same resolver used by install.js. This
* correctly handles all supported runtimes (claude, codex, cursor, gemini,
* opencode, grok, etc.) and their env-var overrides. Defaults to claude
* (falls back to ~/.claude) if the resolver throws.
* Detects the active runtime via the canonical precedence:
* process.env.GSD_RUNTIME → config.runtime → 'claude'
* (using resolveRuntime() from runtime-slash.cjs, the same precedence used
* by profile-output.cjs and the rest of the runtime resolution chain).
* Then calls getGlobalConfigDir(detectedRuntime) from runtime-homes.cjs —
* the same resolver used by install.js. This correctly handles all supported
* runtimes (claude, codex, cursor, gemini, opencode, grok, etc.) and their
* env-var overrides (CLAUDE_CONFIG_DIR, CODEX_HOME, CURSOR_CONFIG_DIR, …).
* Defaults to ~/.claude if either resolver throws.
*
* Failure surfacing: genuine resolution failures (manifest/profile/surface
* errors) are reported in the `warnings` array in the envelope. The output
@@ -398,9 +434,12 @@ function resolveCapabilityRuntimeState(
const warnings: string[] = [];
// Resolve runtimeConfigDir using the canonical runtime-homes resolver.
// When not provided, getGlobalConfigDir(runtime) is called with 'claude'
// as the default runtime — the same fallback as install.js. The canonical
// resolver handles all env-var overrides (CLAUDE_CONFIG_DIR, CODEX_HOME,
// When not provided, the active runtime is detected via the canonical
// precedence: process.env.GSD_RUNTIME → config.runtime → 'claude'
// (mirrors resolveRuntime() from runtime-slash.cjs and the precedence used
// by profile-output.cjs and the rest of the runtime resolution chain).
// getGlobalConfigDir(detectedRuntime) is then called, which honours the
// runtime-specific env-var override (CLAUDE_CONFIG_DIR, CODEX_HOME,
// CURSOR_CONFIG_DIR, GROK_AGENTS_HOME, etc.) correctly and without
// fabricating env vars that don't exist upstream.
let resolvedConfigDir: string = runtimeConfigDir || '';
@@ -410,13 +449,15 @@ function resolveCapabilityRuntimeState(
const runtimeHomes = require('./runtime-homes.cjs') as {
getGlobalConfigDir: (runtime: string) => string;
};
// Delegate runtime detection entirely to getGlobalConfigDir: calling it
// with 'claude' causes it to check CLAUDE_CONFIG_DIR first, falling back
// to ~/.claude. The canonical resolver already encodes the correct env-var
// precedence for each runtime — we do not re-implement that logic here.
// For non-claude runtimes, the caller should pass --config-dir explicitly
// (or set the runtime-specific env var, which getGlobalConfigDir honors).
resolvedConfigDir = runtimeHomes.getGlobalConfigDir('claude');
// eslint-disable-next-line @typescript-eslint/no-require-imports
const runtimeSlash = require('./runtime-slash.cjs') as {
resolveRuntime: (projectDir: string | null | undefined) => string;
};
// Detect the active runtime via GSD_RUNTIME → config.runtime → 'claude'.
// resolveRuntime reads config.json directly (no side effects) and returns
// a lowercased canonical runtime name.
const detectedRuntime = runtimeSlash.resolveRuntime(cwd);
resolvedConfigDir = runtimeHomes.getGlobalConfigDir(detectedRuntime);
} catch {
// Defensive fallback: use ~/.claude if the canonical resolver throws.
// eslint-disable-next-line @typescript-eslint/no-require-imports
@@ -533,9 +574,28 @@ function cmdCapabilityState(
coreOutput(envelope, raw);
}
/**
* Convenience predicate: returns true if the capability identified by `capId`
* is active (installed && surfaced && config-enabled) in the current runtime
* environment at `cwd`.
*
* Internally calls `resolveCapabilityRuntimeState(cwd, undefined)` and returns
* the `active` field of the matching CapabilityStateEntry.
* Returns `false` when the capability is not found in the registry.
*
* @param capId Capability identifier (e.g. 'graphify', 'intel')
* @param cwd Project root directory for config resolution
*/
function isCapabilityActive(capId: string, cwd: string): boolean {
const result = resolveCapabilityRuntimeState(cwd, undefined);
const entry = result.capabilities.find((c) => c.id === capId);
return entry !== undefined ? entry.active : false;
}
export = {
resolveCapabilityState,
resolveCapabilityRuntimeState,
isCapabilityActive,
cmdCapabilityState,
// Exported for tests
_resolveCommandsGsdDir,

View File

@@ -11,32 +11,11 @@ import fs from 'node:fs';
import path from 'node:path';
import { execTool, execGit, platformWriteSync } from './shell-command-projection.cjs';
// ─── Config Gate ─────────────────────────────────────────────────────────────
// eslint-disable-next-line @typescript-eslint/no-require-imports
import capabilityStateMod = require('./capability-state.cjs');
const { isCapabilityActive } = capabilityStateMod;
/**
* Check whether graphify is enabled in the project config.
* Reads config.json directly via fs. Returns false by default
* (when no config, no graphify key, or on error).
*/
function isGraphifyEnabled(planningDir: string): boolean {
try {
const configPath = path.join(planningDir, 'config.json');
if (!fs.existsSync(configPath)) return false;
const config: unknown = JSON.parse(fs.readFileSync(configPath, 'utf8'));
if (
config &&
typeof config === 'object' &&
'graphify' in config &&
config.graphify &&
typeof config.graphify === 'object' &&
'enabled' in config.graphify &&
(config.graphify as Record<string, unknown>).enabled === true
) return true;
return false;
} catch {
return false;
}
}
// ─── Config Gate ─────────────────────────────────────────────────────────────
interface DisabledResponse {
disabled: true;
@@ -396,7 +375,7 @@ function countCommitsBetween(cwd: string, from: string, to: string): number | nu
*/
function graphifyQuery(cwd: string, term: string, options: { budget?: number | null } = {}): unknown {
const planningDir = path.join(cwd, '.planning');
if (!isGraphifyEnabled(planningDir)) return disabledResponse();
if (!isCapabilityActive('graphify', cwd)) return disabledResponse();
const graphPath = path.join(planningDir, 'graphs', 'graph.json');
if (!fs.existsSync(graphPath)) {
@@ -435,7 +414,7 @@ function graphifyQuery(cwd: string, term: string, options: { budget?: number | n
*/
function graphifyStatus(cwd: string): unknown {
const planningDir = path.join(cwd, '.planning');
if (!isGraphifyEnabled(planningDir)) return disabledResponse();
if (!isCapabilityActive('graphify', cwd)) return disabledResponse();
const graphPath = path.join(planningDir, 'graphs', 'graph.json');
if (!fs.existsSync(graphPath)) {
@@ -498,7 +477,7 @@ function graphifyStatus(cwd: string): unknown {
*/
function graphifyDiff(cwd: string): unknown {
const planningDir = path.join(cwd, '.planning');
if (!isGraphifyEnabled(planningDir)) return disabledResponse();
if (!isCapabilityActive('graphify', cwd)) return disabledResponse();
const snapshotPath = path.join(planningDir, 'graphs', '.last-build-snapshot.json');
const graphPath = path.join(planningDir, 'graphs', 'graph.json');
@@ -554,7 +533,7 @@ function graphifyDiff(cwd: string): unknown {
*/
function graphifyBuild(cwd: string): unknown {
const planningDir = path.join(cwd, '.planning');
if (!isGraphifyEnabled(planningDir)) return disabledResponse();
if (!isCapabilityActive('graphify', cwd)) return disabledResponse();
const installed = checkGraphifyInstalled();
if (!installed.installed) return { error: installed.message };
@@ -619,7 +598,6 @@ function writeSnapshot(cwd: string): SnapshotResult | { error: string } {
export = {
// Config gate
isGraphifyEnabled,
disabledResponse,
// Subprocess
execGraphify,

View File

@@ -5,7 +5,8 @@
* Intel files live in .planning/intel/ and store structured data about
* the project's files, APIs, dependencies, architecture, and tech stack.
*
* All public functions gate on intel.enabled config (no-op when false).
* All public functions gate on isCapabilityActive('intel', cwd) — the shared
* tri-state resolver (installed + surfaced + intel.enabled config key).
*
* ADR-457 build-at-publish: the hand-written bin/lib/intel.cjs collapsed
* to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour
@@ -17,6 +18,10 @@ import path from 'node:path';
import crypto from 'node:crypto';
import { platformWriteSync, platformReadSync, platformEnsureDir } from './shell-command-projection.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import capabilityStateMod = require('./capability-state.cjs');
const { isCapabilityActive } = capabilityStateMod;
// ─── Constants ───────────────────────────────────────────────────────────────
const INTEL_DIR = '.planning/intel';
@@ -41,29 +46,20 @@ function ensureIntelDir(planningDir: string): string {
}
/**
* Check whether intel is enabled in the project config.
* Reads config.json directly via fs. Returns false by default
* (when no config, no intel key, or on error).
* Check whether intel is active (installed, surfaced, and config-enabled) for the project at cwd.
* Delegates to the shared tri-state capability resolver (isCapabilityActive) which honours the
* install profile, runtime surface, and activationKey (intel.enabled config gate).
*
* NOTE: planningDir is the legacy entry-point; cwd is derived as path.dirname(planningDir).
* Callers that have cwd directly may call isCapabilityActive('intel', cwd) themselves.
*
* INVARIANT: planningDir is always `<cwd>/.planning` (i.e. path.join(cwd, '.planning')).
* The intel-command-router always constructs planningDir as path.join(cwd, '.planning'),
* so path.dirname(planningDir) === cwd is guaranteed. If a workstream-aware planningDir
* were ever passed here, the dirname would be wrong — but no caller does that.
*/
function isIntelEnabled(planningDir: string): boolean {
try {
const configPath = path.join(planningDir, 'config.json');
const raw = platformReadSync(configPath);
if (raw === null) return false;
const config: unknown = JSON.parse(raw);
if (
config &&
typeof config === 'object' &&
'intel' in config &&
config.intel &&
typeof config.intel === 'object' &&
'enabled' in config.intel &&
(config.intel as Record<string, unknown>).enabled === true
) return true;
return false;
} catch {
return false;
}
function isIntelCapabilityActive(planningDir: string): boolean {
return isCapabilityActive('intel', path.dirname(planningDir));
}
interface DisabledResponse {
@@ -188,7 +184,7 @@ interface IntelQueryResult {
* Searches across all JSON intel files in INTEL_FILES (keys and values), including arch-decisions.json (parsed as JSON, not as text).
*/
function intelQuery(term: string, planningDir: string): IntelQueryResult | DisabledResponse {
if (!isIntelEnabled(planningDir)) return disabledResponse();
if (!isIntelCapabilityActive(planningDir)) return disabledResponse();
const matches: Array<{ source: string; entries: SearchMatch[] }> = [];
let total = 0;
@@ -225,7 +221,7 @@ interface IntelStatusResult {
* A file is considered stale if its updated_at is older than 24 hours.
*/
function intelStatus(planningDir: string): IntelStatusResult | DisabledResponse {
if (!isIntelEnabled(planningDir)) return disabledResponse();
if (!isIntelCapabilityActive(planningDir)) return disabledResponse();
const STALE_MS = 24 * 60 * 60 * 1000; // 24 hours
const now = Date.now();
@@ -273,7 +269,7 @@ interface IntelDiffResult {
* Show changes since the last full refresh by comparing file hashes.
*/
function intelDiff(planningDir: string): IntelDiffResult | { no_baseline: true } | DisabledResponse {
if (!isIntelEnabled(planningDir)) return disabledResponse();
if (!isIntelCapabilityActive(planningDir)) return disabledResponse();
const snapshotPath = intelFilePath(planningDir, '.last-refresh.json');
const snapshot = safeReadJson(snapshotPath);
@@ -309,7 +305,7 @@ function intelDiff(planningDir: string): IntelDiffResult | { no_baseline: true }
* The actual update is performed by the intel-updater agent (PLAN-02).
*/
function intelUpdate(planningDir: string): { action: string; message: string } | DisabledResponse {
if (!isIntelEnabled(planningDir)) return disabledResponse();
if (!isIntelCapabilityActive(planningDir)) return disabledResponse();
return {
action: 'spawn_agent',
@@ -359,7 +355,7 @@ function saveRefreshSnapshot(planningDir: string): SaveRefreshResult {
* Writes .last-refresh.json with accurate timestamps and hashes.
*/
function intelSnapshot(planningDir: string): SaveRefreshResult | DisabledResponse {
if (!isIntelEnabled(planningDir)) return disabledResponse();
if (!isIntelCapabilityActive(planningDir)) return disabledResponse();
return saveRefreshSnapshot(planningDir);
}
@@ -373,7 +369,7 @@ interface IntelValidateResult {
* Validate all intel files for correctness and freshness.
*/
function intelValidate(planningDir: string): IntelValidateResult | DisabledResponse {
if (!isIntelEnabled(planningDir)) return disabledResponse();
if (!isIntelCapabilityActive(planningDir)) return disabledResponse();
const errors: string[] = [];
const warnings: string[] = [];
@@ -470,7 +466,7 @@ interface IntelApiSurfaceResult {
* mistake silence for "nothing exists".
*/
function intelApiSurface(planningDir: string): IntelApiSurfaceResult | DisabledResponse {
if (!isIntelEnabled(planningDir)) return disabledResponse();
if (!isIntelCapabilityActive(planningDir)) return disabledResponse();
const intelPath = ensureIntelDir(planningDir);
const apiMapPath = path.join(intelPath, INTEL_FILES.apis);
@@ -535,7 +531,7 @@ interface IntelPatchMetaResult {
* Patch _meta.updated_at in a JSON intel file to the current timestamp.
* Reads the file, updates _meta.updated_at, increments version, writes back.
*
* NOTE: Does not gate on isIntelEnabled — operates on arbitrary file paths
* NOTE: Does not gate on isCapabilityActive — operates on arbitrary file paths
* for use by agents patching individual files outside the intel store.
*/
function intelPatchMeta(filePath: string): IntelPatchMetaResult {
@@ -576,7 +572,7 @@ interface IntelExtractExportsResult {
/**
* Extract exports from a JS/CJS file by parsing module.exports or exports.X patterns.
*
* NOTE: Does not gate on isIntelEnabled — operates on arbitrary source files
* NOTE: Does not gate on isCapabilityActive — operates on arbitrary source files
* for use by agents building intel data from project files.
*/
function intelExtractExports(filePath: string): IntelExtractExportsResult {
@@ -711,7 +707,7 @@ export = {
// Utilities
ensureIntelDir,
isIntelEnabled,
isIntelCapabilityActive,
// Constants
INTEL_FILES,

View File

@@ -269,8 +269,16 @@ interface ResolveLoopHooksInput {
config: Record<string, unknown>;
/** Optional cwd — enables raw config.json fallback reads (FIX 1 precedence level 2). */
cwd?: string;
/** Optional capability-state map; when present, disabled capabilities do not render hooks. */
capabilityStatesById?: Map<string, { enabled?: boolean }> | Record<string, { enabled?: boolean }>;
/**
* Optional capability-state map; when present, inactive capabilities do not render hooks.
* Each entry carries both `enabled` (installed+surfaced) and `active` (enabled+configActivation).
* The resolver gates on `active` so that the config activation key (activationKey) is
* honoured even when no per-hook `when` guard is present (Phase 4 tri-state alignment).
*
* `active` is REQUIRED (not optional) so the gate is fail-closed: a missing or undefined
* `active` field is a compile error, never silently treated as truthy.
*/
capabilityStatesById?: Map<string, { enabled?: boolean; active: boolean }> | Record<string, { enabled?: boolean; active: boolean }>;
}
interface ResolveLoopHooksResult {
@@ -330,13 +338,18 @@ function resolveLoopHooks(input: ResolveLoopHooksInput): ResolveLoopHooksResult
return _resolveActivationValue(when, config, cwd, registry);
}
function isCapabilityEnabled(capId: string): boolean {
function isCapabilityActive(capId: string): boolean {
if (!capabilityStatesById) return true;
const state = capabilityStatesById instanceof Map
? capabilityStatesById.get(capId)
: capabilityStatesById[capId];
if (!state) return false;
return state.enabled !== false;
// Fail-closed gate: only render the hook when active is explicitly true.
// A capability can be installed and surfaced (enabled=true) but config-disabled
// (active=false); in that case the hook must not render.
// Phase 4 tri-state alignment: `active` is now required (not optional), so
// `=== true` is the correct fail-closed check (not `!== false`).
return state.active === true;
}
// Helper: safe string array
@@ -401,7 +414,7 @@ function resolveLoopHooks(input: ResolveLoopHooksInput): ResolveLoopHooksResult
for (const hook of steps) {
if (!hook || typeof hook !== 'object') continue;
const capId = typeof hook['capId'] === 'string' ? hook['capId'] : '';
if (!isCapabilityEnabled(capId)) continue;
if (!isCapabilityActive(capId)) continue;
if (!isActive(hook)) continue;
const ref = (typeof hook['ref'] === 'object' && hook['ref'] !== null)
? (hook['ref'] as HookRef)
@@ -427,7 +440,7 @@ function resolveLoopHooks(input: ResolveLoopHooksInput): ResolveLoopHooksResult
for (const hook of contributions) {
if (!hook || typeof hook !== 'object') continue;
const capId = typeof hook['capId'] === 'string' ? hook['capId'] : '';
if (!isCapabilityEnabled(capId)) continue;
if (!isCapabilityActive(capId)) continue;
if (!isActive(hook)) continue;
const into = typeof hook['into'] === 'string' ? hook['into'] : undefined;
const fragment = toFragment(hook['fragment']);
@@ -453,7 +466,7 @@ function resolveLoopHooks(input: ResolveLoopHooksInput): ResolveLoopHooksResult
for (const hook of gates) {
if (!hook || typeof hook !== 'object') continue;
const capId = typeof hook['capId'] === 'string' ? hook['capId'] : '';
if (!isCapabilityEnabled(capId)) continue;
if (!isCapabilityActive(capId)) continue;
if (!isActive(hook)) continue;
const when = typeof hook['when'] === 'string' ? hook['when'] : undefined;
const check = hook['check'] !== undefined ? hook['check'] : undefined;
@@ -613,11 +626,11 @@ function cmdLoopRenderHooks(
warnings?: string[];
registry: Record<string, unknown>;
config: Record<string, unknown>;
capabilities: Array<{ id: string; enabled?: boolean }>;
capabilities: Array<{ id: string; enabled?: boolean; active: boolean }>;
};
const registry = state.registry;
const config = state.config || loadConfig(cwd);
const capabilityStatesById = new Map<string, { enabled?: boolean }>();
const capabilityStatesById = new Map<string, { enabled?: boolean; active: boolean }>();
for (const cap of state.capabilities || []) {
capabilityStatesById.set(cap.id, cap);
}

View File

@@ -290,6 +290,15 @@ export interface Prohibition {
resolution: string | null;
reason: string | null;
statement: string;
// Optional flat-scalar wired-check descriptor (#1278). A resolved test-tier prohibition may carry
// these before projection; `projectProhibitions` emits them as the LOCKED flat scalar keys
// `check_kind`/`check_target`/`check_rule` that round-trip the EXISTING flat `parseMustHavesBlock`
// (a nested `check:{}` object is rejected per IMPL-SCOPING §3 — it flattens through the shared
// parser). These mirror `CheckDescriptor.kind/target/rule` (prohibition-enforcement.cts:62) MINUS
// the caller-attested `failFirst`, which is deliberately NOT a Prohibition field (#1279).
check_kind?: 'node-test' | 'lint-rule';
check_target?: string;
check_rule?: string;
}
/**
@@ -329,6 +338,15 @@ export function validateProhibitionResolution(resolution: Resolution<Prohibition
* and are intentionally NOT projected into the plan block (which is keyed on the must-NOT
* statement, not the source requirement). A non-array input projects to `[]` (fail-soft on the
* empty/zero-prohibition case), never a throw.
*
* An OPTIONAL wired-check descriptor (#1278) projects as the LOCKED flat scalar keys
* `check_kind`/`check_target`/`check_rule` (NEVER a nested `check:{}` object; `failFirst` is never
* projected). These ride the EXISTING continuation-KV path of `parseMustHavesBlock`
* (src/frontmatter.cts:344) with NO shared-parser rewrite (IMPL-SCOPING §3 Option 1). The keys are
* emitted ONLY for a well-formed descriptor (valid `check_kind` + non-empty `check_target`; plus
* `check_rule` only for a lint-rule that carries one); a descriptor-less or under-specified item is
* byte-identical to today (CHK-07), so an under-specified descriptor projects absent and fails closed
* at the producer downstream (CHK-06), never as a partial-but-locatable green.
*/
export function projectProhibitions(
items: unknown,
@@ -345,6 +363,20 @@ export function projectProhibitions(
};
if (p.verification != null) entry.verification = String(p.verification);
if (p.reason != null && String(p.reason).trim()) entry.reason = String(p.reason);
// Optional wired-check descriptor (#1278): emit flat scalars ONLY when well-formed. A valid kind
// plus a non-empty target is the minimum; under that bar nothing is emitted (CHK-07 byte-identity,
// and the producer fails closed on the absent descriptor — CHK-06).
const kind = p.check_kind;
const targetOk = typeof p.check_target === 'string' && p.check_target.trim() !== '';
if ((kind === 'node-test' || kind === 'lint-rule') && targetOk) {
entry.check_kind = kind;
entry.check_target = String(p.check_target);
// `check_rule` rides only the lint-rule path (node-test never carries one); a lint-rule missing
// its rule leaves check_rule absent so the producer's fail-closed locate rejects it (CHK-06).
if (kind === 'lint-rule' && typeof p.check_rule === 'string' && p.check_rule.trim() !== '') {
entry.check_rule = String(p.check_rule);
}
}
out.push(entry);
}
return out;

View File

@@ -78,6 +78,52 @@ export interface CheckDescriptor {
violationFixture?: string;
}
/**
* READ-BACK ADAPTER (#1278, plan 01-03): reconstruct a `CheckDescriptor` from the flat scalar keys
* `projectProhibitions` emits onto a prohibition item (`check_kind` / `check_target` / `check_rule`,
* src/probe-core.cts). This is the deterministic bridge from the projected descriptor back into the
* merged #1259 producer request — the verify-phase caller reads the projected scalars, this rebuilds
* the `{ kind, target, rule? }` request, and `runProhibitionEnforcement`'s EXISTING fail-closed LOCATE
* guard (validKind/validTarget/validRule, below) is the single source of fail-closed truth.
*
* Contract:
* - `null`/`undefined`/non-object input -> `null`.
* - `check_kind` ABSENT -> `null` (no descriptor -> producer locates nothing -> fail-closed).
* - `check_kind` present -> `{ kind: check_kind, target: check_target }`, adding `rule: check_rule`
* ONLY when `check_rule` is a non-empty string.
* - `failFirst` is NEVER sourced from the projection — it stays a verify-time caller attestation
* (#1279 machine-proves it; out of scope here). The returned descriptor carries no `failFirst`.
* - The adapter does NOT strictly validate kind/target/rule: it faithfully reconstructs whatever
* scalars are present (e.g. `{check_kind:'lint-rule', check_target:'src/'}` with no `check_rule`
* reconstructs to `{kind:'lint-rule', target:'src/'}`), letting the existing LOCATE guard reject
* an under-specified descriptor (located:false, never green). It does NOT re-implement that guard.
* - Pure, deterministic, no-throw.
*/
export function descriptorFromProjection(
projected: Record<string, unknown> | null | undefined,
): CheckDescriptor | null {
if (!projected || typeof projected !== 'object') return null;
if (!('check_kind' in projected)) return null;
// The shared `parseMustHavesBlock` (src/frontmatter.cts) coerces /^\d+$/ scalar values to NUMBERS on
// round-trip, so a numeric-looking check_kind/check_target/check_rule arrives here as a number. Normalize
// ONLY string|number scalars back to string — a non-scalar (object/array/bool) or absent value yields ''
// (never an `[object Object]` stringification, no `as string` lie over a number). This keeps the
// descriptor honestly typed and the round-trip lossless across the full string domain; an under-specified
// '' target/kind is rejected by the producer's locate guard (fail-closed; never green).
const scalar = (v: unknown): string =>
typeof v === 'string' ? v : typeof v === 'number' ? String(v) : '';
const kind = scalar(projected.check_kind) as CheckKind;
const target = scalar(projected.check_target);
const descriptor: CheckDescriptor = { kind, target };
// `rule` belongs only to the lint-rule kind; a stray check_rule on a node-test descriptor is dropped
// (the projector never emits one there — defense in depth). failFirst is NOT sourced here (#1279).
if (kind === 'lint-rule') {
const rule = scalar(projected.check_rule);
if (rule.trim().length > 0) descriptor.rule = rule;
}
return descriptor;
}
/**
* The result a check-runner returns: whether the check genuinely, non-vacuously PASSED. The runner
* reports only what it can OBSERVE (a real clean pass) — it does NOT determine fail-first. Whether the