diff --git a/.github/workflows/plugin-validate.yml b/.github/workflows/plugin-validate.yml new file mode 100644 index 0000000..84f507e --- /dev/null +++ b/.github/workflows/plugin-validate.yml @@ -0,0 +1,44 @@ +name: Plugin + schema validation + +# RFC-008 P2a (Rule 13: enforce in CI, not just docs). Wires the suites P2a +# ships or touches. NO path filters (approved-plan decision, step-6 review F3): +# the suites run in seconds, and a path-filtered required check leaves +# non-matching PRs hanging on "Expected" status once the check is required — +# while a hand-curated per-file filter silently stops running when a new +# scripts/lib helper joins the import graph. +# +# Issue #377 tracks the REMAINING plugin-family suites — still pending here: +# test-json-instance-validate.mjs, test-plugin-json.mjs, +# test-field-bindings.mjs, test-plugin-gauntlet.mjs, +# test-plugin-harness-binding.mjs. Add them as steps per #377; this comment is +# the marker that their absence is a tracked gap, not full coverage. + +on: + pull_request: + push: + branches: [main] + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Validate schema docs + shared negative corpus (M1/M2, #368) + run: node scripts/validate-schemas.mjs --project "$GITHUB_WORKSPACE" + + - name: Run validate-schemas self-tests + run: node tests/test-validate-schemas.mjs + + - name: Run P0 schema gate (corpus co-consumer — #368 divergence lock) + run: node tests/test-p0-schemas.mjs + + - name: Run path-contain axis tests + run: node tests/test-path-contain.mjs + + - name: Run plugin-registry conformance tests (path-contain regression lock) + run: node tests/test-plugin-registry.mjs diff --git a/scripts/lib/json-instance-validate.mjs b/scripts/lib/json-instance-validate.mjs index ad02916..c15249f 100644 --- a/scripts/lib/json-instance-validate.mjs +++ b/scripts/lib/json-instance-validate.mjs @@ -2,7 +2,7 @@ // INSTANCE validator with a fail-CLOSED closure guard (RFC-008 R0c P1b, §2.1). // // This is NOT a general 2020-12 validator and NOT the schema-DOC linter -// (tests/lib/mini-jsonschema.mjs asserts "a schema is a valid schema"; this +// (scripts/lib/mini-jsonschema.mjs asserts "a schema is a valid schema"; this // asserts "an instance satisfies a schema"). It models exactly the keyword // subset the P0 plugin/runtime schemas use, and is the FIRST consumer to // instance-validate plugins/_index.json + plugins/claude-code/manifest.json diff --git a/scripts/lib/mini-jsonschema.mjs b/scripts/lib/mini-jsonschema.mjs new file mode 100644 index 0000000..86bd886 --- /dev/null +++ b/scripts/lib/mini-jsonschema.mjs @@ -0,0 +1,320 @@ +// mini-jsonschema.mjs — JSON-Schema 2020-12 keyword-grammar lint engine. +// +// RFC-008 P0 validity-verification gate (v11.8), PROMOTED from tests/lib/ in +// P2a: the single engine behind both the P0 test gate +// (tests/test-p0-schemas.mjs, via the tests/lib/ re-export shim) and the +// shipped CI validator scripts/validate-schemas.mjs (M1/M2, RFC L493-498) — +// one engine, so the two consumers cannot drift (D2); the shared negative +// corpus tests/fixtures/schema-negative-corpus.json guards any future +// refactor that forks them (#368). +// +// This is NOT a meta-schema interpreter — it never loads the official 2020-12 +// meta-schema and never resolves $dynamicRef / $dynamicAnchor (replicating +// that machinery is the most error-prone corner of the spec and the wrong +// patch class — see the R0b plan review, rounds 2-3). It directly asserts that +// a document conforms to the 2020-12 keyword grammar, recursing into every +// subschema-bearing position. +// +// Two properties close the fail-open class the review identified: +// (a) ALLOWLIST + fail-on-unknown-keyword — a keyword not in the canonical +// 2020-12 set is a HARD FAIL (a typo like `requiredd` can never silently +// pass; the inverse of "ignore unknown keywords"). +// (b) The recurse-set is DERIVED from a single declared SUBSCHEMA_KEYWORDS +// table covering ALL 2020-12 subschema-bearing keywords, and the module +// SELF-ASSERTS on load that +// keys(SUBSCHEMA_KEYWORDS) ∪ keys(VALUE_GRAMMAR) === ALLOWLIST +// so a future allowlisted keyword that bears a subschema cannot be added +// without classifying it — the recurse-set cannot silently go incomplete +// (closing e.g. {"propertyNames":{"items":[]}} which would otherwise pass). + +// --------------------------------------------------------------------------- +// Canonical 2020-12 keyword set — the independent source of truth (57 keywords: +// Core 9, Applicator 17, Validation 20, Meta-data 7, Format 1, Content 3). +// --------------------------------------------------------------------------- +const ALLOWLIST = new Set([ + // Core (9) + "$schema", "$id", "$ref", "$anchor", "$dynamicRef", "$dynamicAnchor", + "$vocabulary", "$comment", "$defs", + // Applicator (17) + "prefixItems", "items", "contains", "additionalProperties", "properties", + "patternProperties", "dependentSchemas", "propertyNames", "if", "then", + "else", "allOf", "anyOf", "oneOf", "not", "unevaluatedItems", + "unevaluatedProperties", + // Validation (20) + "type", "enum", "const", "multipleOf", "maximum", "exclusiveMaximum", + "minimum", "exclusiveMinimum", "maxLength", "minLength", "pattern", + "maxItems", "minItems", "uniqueItems", "maxContains", "minContains", + "maxProperties", "minProperties", "required", "dependentRequired", + // Meta-data (7) + "title", "description", "default", "deprecated", "readOnly", "writeOnly", + "examples", + // Format (1) + "format", + // Content (3) + "contentEncoding", "contentMediaType", "contentSchema", +]); + +// --------------------------------------------------------------------------- +// SUBSCHEMA_KEYWORDS — the SINGLE declared recurse-set. Every 2020-12 keyword +// whose value contains one or more subschemas, classified by value shape. +// single : value IS a schema (object|boolean) +// array : value is an array of schemas +// map : value is an object whose every value is a schema +// --------------------------------------------------------------------------- +const SUBSCHEMA_KEYWORDS = { + // single-schema (11) + items: "single", + additionalProperties: "single", + unevaluatedItems: "single", + unevaluatedProperties: "single", + contains: "single", + propertyNames: "single", + if: "single", + then: "single", + else: "single", + not: "single", + contentSchema: "single", + // schema-array (4) + prefixItems: "array", + allOf: "array", + anyOf: "array", + oneOf: "array", + // object -> schema-map (4) + properties: "map", + patternProperties: "map", + $defs: "map", + dependentSchemas: "map", +}; + +const VALID_TYPES = new Set([ + "null", "boolean", "object", "array", "number", "string", "integer", +]); + +// --------------------------------------------------------------------------- +// VALUE_GRAMMAR — non-recursing keywords + their grammar check. Each returns +// null on success or an error string on failure. +// --------------------------------------------------------------------------- +const isString = (v) => (typeof v === "string" ? null : "must be a string"); +const isBoolean = (v) => (typeof v === "boolean" ? null : "must be a boolean"); +const isNumber = (v) => + typeof v === "number" && Number.isFinite(v) ? null : "must be a number"; +const isPositiveNumber = (v) => + typeof v === "number" && Number.isFinite(v) && v > 0 + ? null + : "must be a number > 0"; +const isNonNegInt = (v) => + Number.isInteger(v) && v >= 0 ? null : "must be a non-negative integer"; +const isArray = (v) => (Array.isArray(v) ? null : "must be an array"); +const isAny = () => null; + +function isEnum(v) { + if (!Array.isArray(v)) return "must be an array"; + if (v.length === 0) return "must be a non-empty array"; + return null; +} + +function isStringArrayUnique(v) { + if (!Array.isArray(v)) return "must be an array"; + if (!v.every((s) => typeof s === "string")) return "must be an array of strings"; + if (new Set(v).size !== v.length) return "must contain unique strings"; + return null; +} + +function isTypeKeyword(v) { + if (typeof v === "string") { + return VALID_TYPES.has(v) ? null : `unknown type name: ${JSON.stringify(v)}`; + } + if (Array.isArray(v)) { + if (v.length === 0) return "type array must be non-empty"; + if (new Set(v).size !== v.length) return "type array must be unique"; + for (const t of v) { + if (typeof t !== "string" || !VALID_TYPES.has(t)) { + return `unknown type name in array: ${JSON.stringify(t)}`; + } + } + return null; + } + return "must be a type name or array of type names"; +} + +function isPattern(v) { + if (typeof v !== "string") return "must be a string"; + try { + new RegExp(v); + return null; + } catch (e) { + return `not a compilable regex: ${e.message}`; + } +} + +function isVocabulary(v) { + if (v === null || typeof v !== "object" || Array.isArray(v)) { + return "must be an object"; + } + for (const val of Object.values(v)) { + if (typeof val !== "boolean") return "values must be booleans"; + } + return null; +} + +function isDependentRequired(v) { + if (v === null || typeof v !== "object" || Array.isArray(v)) { + return "must be an object"; + } + for (const val of Object.values(v)) { + const err = isStringArrayUnique(val); + if (err) return `value ${err}`; + } + return null; +} + +const VALUE_GRAMMAR = { + // Core (8 — $defs is a subschema map, handled separately) + $schema: isString, + $id: isString, + $ref: isString, + $anchor: isString, + $dynamicRef: isString, + $dynamicAnchor: isString, + $vocabulary: isVocabulary, + $comment: isString, + // Validation (20) + type: isTypeKeyword, + enum: isEnum, + const: isAny, + multipleOf: isPositiveNumber, + maximum: isNumber, + exclusiveMaximum: isNumber, + minimum: isNumber, + exclusiveMinimum: isNumber, + maxLength: isNonNegInt, + minLength: isNonNegInt, + pattern: isPattern, + maxItems: isNonNegInt, + minItems: isNonNegInt, + uniqueItems: isBoolean, + maxContains: isNonNegInt, + minContains: isNonNegInt, + maxProperties: isNonNegInt, + minProperties: isNonNegInt, + required: isStringArrayUnique, + dependentRequired: isDependentRequired, + // Meta-data (7) + title: isString, + description: isString, + default: isAny, + deprecated: isBoolean, + readOnly: isBoolean, + writeOnly: isBoolean, + examples: isArray, + // Format (1) + format: isString, + // Content (2 — contentSchema is a subschema) + contentEncoding: isString, + contentMediaType: isString, +}; + +// --------------------------------------------------------------------------- +// Structural self-assertion (runs on module load). This is the guard the R0b +// review demanded: the union of the two classification maps' keys MUST equal +// the canonical allowlist, in both directions. +// --------------------------------------------------------------------------- +export function assertSelfConsistent() { + const classified = new Set([ + ...Object.keys(SUBSCHEMA_KEYWORDS), + ...Object.keys(VALUE_GRAMMAR), + ]); + const missing = [...ALLOWLIST].filter((k) => !classified.has(k)); + const extra = [...classified].filter((k) => !ALLOWLIST.has(k)); + // A keyword in both maps is a classification bug too (ambiguous handling). + const both = Object.keys(SUBSCHEMA_KEYWORDS).filter( + (k) => k in VALUE_GRAMMAR, + ); + if (missing.length || extra.length || both.length) { + throw new Error( + "mini-jsonschema self-assertion FAILED: classification maps do not " + + "partition the allowlist.\n" + + ` unclassified (in allowlist, in neither map): ${JSON.stringify(missing)}\n` + + ` unknown (in a map, not in allowlist): ${JSON.stringify(extra)}\n` + + ` double-classified (in both maps): ${JSON.stringify(both)}`, + ); + } +} +assertSelfConsistent(); + +// --------------------------------------------------------------------------- +// The linter. A "schema" in 2020-12 is a boolean OR an object. +// --------------------------------------------------------------------------- +function isSchemaShaped(node) { + return typeof node === "boolean" || (node !== null && typeof node === "object" && !Array.isArray(node)); +} + +function lintNode(node, path, errors) { + if (typeof node === "boolean") return; // boolean schema — always valid + if (node === null || typeof node !== "object" || Array.isArray(node)) { + errors.push(`${path}: not a schema (expected object or boolean)`); + return; + } + for (const [key, value] of Object.entries(node)) { + const here = `${path}/${key}`; + if (!ALLOWLIST.has(key)) { + errors.push(`${here}: unknown keyword "${key}" (not in 2020-12 allowlist)`); + continue; + } + const kind = SUBSCHEMA_KEYWORDS[key]; + if (kind === "single") { + if (!isSchemaShaped(value)) { + errors.push(`${here}: "${key}" must be a schema (object or boolean), got ${describe(value)}`); + } else { + lintNode(value, here, errors); + } + } else if (kind === "array") { + if (!Array.isArray(value)) { + errors.push(`${here}: "${key}" must be an array of schemas, got ${describe(value)}`); + } else { + value.forEach((sub, i) => { + if (!isSchemaShaped(sub)) { + errors.push(`${here}/${i}: "${key}" element must be a schema, got ${describe(sub)}`); + } else { + lintNode(sub, `${here}/${i}`, errors); + } + }); + } + } else if (kind === "map") { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + errors.push(`${here}: "${key}" must be an object whose values are schemas, got ${describe(value)}`); + } else { + for (const [subKey, sub] of Object.entries(value)) { + if (!isSchemaShaped(sub)) { + errors.push(`${here}/${subKey}: "${key}" value must be a schema, got ${describe(sub)}`); + } else { + lintNode(sub, `${here}/${subKey}`, errors); + } + } + } + } else { + // value-grammar keyword + const check = VALUE_GRAMMAR[key]; + const err = check(value); + if (err) errors.push(`${here}: "${key}" ${err} (got ${describe(value)})`); + } + } +} + +function describe(v) { + if (Array.isArray(v)) return "array"; + if (v === null) return "null"; + return typeof v; +} + +/** + * Lint a parsed JSON value as a JSON-Schema 2020-12 document. + * @returns {{ valid: boolean, errors: string[] }} + */ +export function lintSchema(doc) { + const errors = []; + lintNode(doc, "#", errors); + return { valid: errors.length === 0, errors }; +} + +export { ALLOWLIST, SUBSCHEMA_KEYWORDS, VALUE_GRAMMAR }; diff --git a/scripts/lib/path-contain.mjs b/scripts/lib/path-contain.mjs new file mode 100644 index 0000000..68ca97a --- /dev/null +++ b/scripts/lib/path-contain.mjs @@ -0,0 +1,40 @@ +// path-contain.mjs — caller-injected-path containment under a canonical root +// (RFC-008 P2a, extracted verbatim from validate-plugin-registry.mjs so the +// P2b contract validator imports the SAME predicate instead of hand-rolling a +// second one). Callers own the UsageError -> exit-2 boundary (N-7): these +// helpers throw, they never exit. + +import fs from "node:fs"; +import path from "node:path"; + +export class UsageError extends Error { + constructor(message) { super(message); this.name = "UsageError"; } +} + +export function contained(abs, baseReal) { + return abs === baseReal || abs.startsWith(baseReal + path.sep); +} + +/** + * Resolve an INJECTED file path (e.g. --manifest / --index) and realpath-contain + * it under the project root (F7/F29). An injected path is caller-controlled, so + * it must not let the validator read outside the project: lexical escape + * (../, absolute) OR symlink escape -> UsageError (exit 2 at the caller's + * boundary). A not-yet-existing path keeps its lexically-contained abs (the + * reader surfaces ENOENT). `root` MUST already be canonical (realpathSync'd). + */ +export function resolveContained(root, p, label) { + const absLex = path.resolve(root, p); + // realpath FIRST for an existing path (canonicalizes e.g. macOS /var -> + // /private/var so an absolute in-project path is not falsely rejected by a + // lexical compare against the already-canonical root); lexical fallback only + // for a not-yet-existing path (the reader then surfaces ENOENT). + let real; + try { real = fs.realpathSync(absLex); } + catch { + if (!contained(absLex, root)) throw new UsageError(`--${label} ${JSON.stringify(p)} escapes --project authority`); + return absLex; + } + if (!contained(real, root)) throw new UsageError(`--${label} ${JSON.stringify(p)} resolves outside --project authority`); + return real; +} diff --git a/scripts/validate-plugin-registry.mjs b/scripts/validate-plugin-registry.mjs index dfde2bc..98bd030 100644 --- a/scripts/validate-plugin-registry.mjs +++ b/scripts/validate-plugin-registry.mjs @@ -30,9 +30,11 @@ import fs from "node:fs"; import path from "node:path"; +import { pathToFileURL } from "node:url"; import { execFileSync } from "node:child_process"; import { validateInstance, assertAllSchemasModeled } from "./lib/json-instance-validate.mjs"; import { taxonomyVersion, eventsVersion } from "./lib/version-hash.mjs"; +import { contained, resolveContained, UsageError } from "./lib/path-contain.mjs"; // --- closed vocabularies (re-asserted even where a schema already closes them) --- export const MAX_SUPPORTED = "1.0.0"; // byte-equal'd to _corpus-index.current_schema_version (a test asserts equality) @@ -95,39 +97,10 @@ export function gateSchemaVersion(raw, maxSupported = MAX_SUPPORTED) { } // --------------------------------------------------------------------------- -// Path helpers. +// Path helpers. contained/resolveContained/UsageError were extracted verbatim +// to ./lib/path-contain.mjs in P2a (shared with P2b's validate-bp-contract); +// the UsageError -> exit-2 boundary stays here in main() (N-7). // --------------------------------------------------------------------------- -function contained(abs, baseReal) { - return abs === baseReal || abs.startsWith(baseReal + path.sep); -} - -/** - * Resolve an INJECTED file path (--manifest / --index / --bypass-known) and - * realpath-contain it under the project root (F7/F29). An injected path is - * caller-controlled, so it must not let the validator read outside the project: - * lexical escape (../, absolute) OR symlink escape -> UsageError (exit 2). A - * not-yet-existing path keeps its lexically-contained abs (the reader surfaces - * ENOENT). `root` is already canonical (realpathSync in resolveProjectRoot). - */ -function resolveContained(root, p, label) { - const absLex = path.resolve(root, p); - // realpath FIRST for an existing path (canonicalizes e.g. macOS /var -> - // /private/var so an absolute in-project path is not falsely rejected by a - // lexical compare against the already-canonical root); lexical fallback only - // for a not-yet-existing path (the reader then surfaces ENOENT). - let real; - try { real = fs.realpathSync(absLex); } - catch { - if (!contained(absLex, root)) throw new UsageError(`--${label} ${JSON.stringify(p)} escapes --project authority`); - return absLex; - } - if (!contained(real, root)) throw new UsageError(`--${label} ${JSON.stringify(p)} resolves outside --project authority`); - return real; -} - -class UsageError extends Error { - constructor(message) { super(message); this.name = "UsageError"; } -} /** Resolve the project root. --project explicit -> realpath (git never consulted). */ function resolveProjectRoot(argProject, cwd) { @@ -704,4 +677,8 @@ function main() { } // Run as CLI only when invoked directly (not when imported by tests). -if (import.meta.url === `file://${process.argv[1]}`) main(); +// pathToFileURL, not `file://${argv[1]}`: a path needing URL-encoding (space, +// non-ASCII) makes the raw template compare false -> main() never runs -> +// exit 0 with empty output, a vacuous green for the CI gate (P2a step-6 F4; +// same fix as validate-schemas.mjs, pattern from test-plugin.mjs:361). +if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main(); diff --git a/scripts/validate-schemas.mjs b/scripts/validate-schemas.mjs new file mode 100644 index 0000000..510a872 --- /dev/null +++ b/scripts/validate-schemas.mjs @@ -0,0 +1,292 @@ +#!/usr/bin/env node +/** + * validate-schemas.mjs — shipped CI validator: every schema DOC in the repo is + * a valid JSON-Schema 2020-12 document (RFC-008 M1/M2, L493-498 — the F5 + * mirror at the doc layer), shipped in P2a. + * + * One engine (D2): the keyword-grammar linter scripts/lib/mini-jsonschema.mjs + * — the SAME module behind the P0 test gate (tests/test-p0-schemas.mjs), so + * the two consumers cannot drift. The shared negative corpus + * tests/fixtures/schema-negative-corpus.json (#368) is re-asserted here: every + * entry MUST be rejected by the engine, so any future refactor that forks the + * engines fails CI loudly instead of silently diverging. + * + * Discovery, not a hand-maintained list: a no-follow Dirent walk of patterns/, + * plugins/, schemas/ picks up any file named `*.schema.json` OR bare + * `schema.json` (the repo's own patterns/schema.json spelling). Fail-closed + * properties: + * - MIN_SCHEMA_DOCS non-vacuity: discovery collapsing to a near-empty set is + * a violation, not a pass. + * - Out-of-root sweep: a schema-doc-named file OUTSIDE the scan roots + * (excluding tests/fixtures/, node_modules/, dot-dirs) is a named + * violation — a doc someone drops in scripts/ cannot silently go unlinted. + * - Symlinked schema docs are violations ("must be a regular file"), never + * followed, never silently skipped. + * + * I/O surface is CLOSED: --project / --json / --help only. No injectable + * corpus or schema-dir paths (one fixed shared corpus is the #368 contract). + * All reads resolve from the canonical project root, never cwd/script-relative. + * + * Usage: + * node scripts/validate-schemas.mjs --project + * node scripts/validate-schemas.mjs --project --json + * + * With NO --project, the root is discovered via `git rev-parse --show-toplevel` + * from cwd; a non-git cwd fails CLEAR (exit 2), no silent caller-cwd fallback. + * + * Output (stdout): human one-liner, or full JSON with --json. + * Exit: 0 = pass, 1 = violations (ONLY via the violation tally), 2 = usage/IO + * error or internal crash (a crash must never read as "violations found"). + */ + +import fs from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { execFileSync } from "node:child_process"; +import { lintSchema, assertSelfConsistent } from "./lib/mini-jsonschema.mjs"; +import { UsageError } from "./lib/path-contain.mjs"; + +const SCAN_ROOTS = ["patterns", "plugins", "schemas"]; +const CORPUS_REL = "tests/fixtures/schema-negative-corpus.json"; +const MIN_SCHEMA_DOCS = 17; // the P0 contract floor (17 schema docs shipped in P0) +const MIN_CORPUS_ENTRIES = 14; // #368 non-vacuity floor (the 14 P0 negatives) + +function isSchemaDocName(name) { + // Bare `schema.json` is the repo's own precedent (patterns/schema.json) and + // fails the endsWith test at 11 < 12 chars — both spellings are schema docs. + return name.endsWith(".schema.json") || name === "schema.json"; +} + +/** + * No-follow walk (lstat semantics via Dirent): never traverses symlinked + * directories, never resolves symlinked files; skips dot-prefixed DIRECTORIES + * (harness-staged local state — .review-store/, .episodic-memory/ — is absent + * from CI checkouts; sweeping it would diverge local vs CI) and node_modules. + * + * Doc-NAMED entries are judged by KIND before any skip rule (step-6 review + * F1/F2 class fix — one guard, both branch leniencies): + * - regular file -> discovery hit, even dot-prefixed (a hidden + * .bad.schema.json must not lurk unlinted); + * - anything else (symlink, directory, fifo) -> violation, never a silent + * skip and never recursed into. + */ +function walkNoFollow(dir, hits, nonRegularHits) { + let entries; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch (e) { + if (e.code === "ENOENT") return; // absent scan root: MIN_SCHEMA_DOCS catches vacuity + throw e; + } + for (const ent of entries) { + const p = path.join(dir, ent.name); + if (isSchemaDocName(ent.name)) { + if (ent.isFile()) hits.push(p); + else nonRegularHits.push(p); + continue; + } + if (ent.name.startsWith(".") || ent.name === "node_modules") continue; + if (ent.isDirectory()) walkNoFollow(p, hits, nonRegularHits); + } +} + +/** Resolve the project root. --project explicit -> realpath (git never consulted). */ +function resolveProjectRoot(argProject, cwd) { + if (argProject != null) { + let real; + try { real = fs.realpathSync(argProject); } + catch (e) { throw new UsageError(`--project ${argProject} does not resolve: ${e.message}`); } + if (!fs.statSync(real).isDirectory()) throw new UsageError(`--project ${argProject} is not a directory`); + return real; + } + let top; + try { + top = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); + } catch { + throw new UsageError("no --project and cwd is not inside a git repository (no silent caller-cwd fallback)"); + } + return fs.realpathSync(top); +} + +export function validateSchemas({ projectRoot }) { + const root = resolveProjectRoot(projectRoot, process.cwd()); + const violations = []; + let checks = 0; + const violation = (check, detail) => violations.push({ check, severity: "error", detail }); + + // Engine self-assertion, re-run fail-closed (also runs at module load). + checks++; + assertSelfConsistent(); + + // --- discovery over the scan roots (no-follow, dedupe) --- + const hits = []; + const nonRegularHits = []; + for (const rel of SCAN_ROOTS) walkNoFollow(path.join(root, rel), hits, nonRegularHits); + for (const p of nonRegularHits) { + checks++; + violation("doc-regular-file", `${path.relative(root, p)} is doc-named but not a regular file (symlink/directory/other) — schema docs must be regular files (no-follow discovery would otherwise skip it silently)`); + } + const seenReal = new Set(); + const docs = []; + for (const p of hits) { + const real = fs.realpathSync(p); + if (seenReal.has(real)) continue; + seenReal.add(real); + docs.push(p); + } + docs.sort(); + + // --- non-vacuity: discovery collapsing is a failure, not a pass --- + checks++; + if (docs.length < MIN_SCHEMA_DOCS) { + violation("min-docs", `discovered ${docs.length} schema doc(s), expected >= ${MIN_SCHEMA_DOCS} — discovery vacuity is fail-closed`); + } + + // --- every doc parses and lints clean as a 2020-12 document --- + for (const p of docs) { + checks++; + const rel = path.relative(root, p); + let doc; + try { + doc = JSON.parse(fs.readFileSync(p, "utf8")); + } catch (e) { + violation("doc-parse", `${rel}: not parseable JSON — ${e.message}`); + continue; + } + const { valid, errors } = lintSchema(doc); + if (!valid) violation("doc-lint", `${rel}: ${errors.slice(0, 4).join(" | ")}`); + } + + // --- out-of-root sweep: same no-follow walker over the whole repo --- + checks++; + const sweepHits = []; + const sweepNonRegular = []; + walkNoFollow(root, sweepHits, sweepNonRegular); + const scanRootAbs = SCAN_ROOTS.map((r) => path.join(root, r)); + const fixturesAbs = path.join(root, "tests", "fixtures"); + const insideAllowed = (p) => + scanRootAbs.some((r) => p === r || p.startsWith(r + path.sep)) || + p.startsWith(fixturesAbs + path.sep); + for (const p of [...sweepHits, ...sweepNonRegular]) { + if (!insideAllowed(p)) { + violation("out-of-root-doc", `${path.relative(root, p)} is schema-doc-named but OUTSIDE the scan roots (${SCAN_ROOTS.join(", ")}) — it would never be linted; move it into a scan root`); + } + } + + // --- shared negative corpus (#368): engine must reject every entry --- + const corpusAbs = path.join(root, CORPUS_REL); + let corpusRaw; + try { + corpusRaw = fs.readFileSync(corpusAbs, "utf8"); + } catch (e) { + // Missing corpus is an IO failure of the validation INPUTS (exit 2), + // not a "checked and found violations" result — absence != vacuous pass. + throw new UsageError(`shared negative corpus unreadable at ${CORPUS_REL}: ${e.message}`); + } + let corpus = null; + try { + corpus = JSON.parse(corpusRaw); + } catch (e) { + violation("corpus-shape", `${CORPUS_REL}: not parseable JSON — ${e.message}`); + } + const entries = Array.isArray(corpus) ? corpus : []; + checks++; + if (corpus !== null && !Array.isArray(corpus)) { + violation("corpus-shape", `${CORPUS_REL}: must be an array of { name, schema } entries`); + } + checks++; + if (entries.length < MIN_CORPUS_ENTRIES) { + violation("corpus-non-vacuity", `${CORPUS_REL}: ${entries.length} entr(ies), expected >= ${MIN_CORPUS_ENTRIES} (#368 floor)`); + } + checks++; + if (!entries.every((e) => e !== null && typeof e === "object" && typeof e.name === "string" && "schema" in e)) { + violation("corpus-shape", `${CORPUS_REL}: every entry must carry string \`name\` + \`schema\` key`); + } + checks++; + if (new Set(entries.map((e) => e && e.name)).size !== entries.length) { + violation("corpus-shape", `${CORPUS_REL}: entry names must be unique — a duplicate keeps the >= ${MIN_CORPUS_ENTRIES} floor green while class coverage silently shrinks`); + } + for (const entry of entries) { + // Shape violation already recorded above; string-name guard keeps the + // divergence detail from printing `undefined` for a malformed entry. + if (entry === null || typeof entry !== "object" || typeof entry.name !== "string" || !("schema" in entry)) continue; + checks++; + const { valid } = lintSchema(entry.schema); + if (valid) { + violation("corpus-divergence", `corpus entry ${JSON.stringify(entry.name)} is ACCEPTED by the engine — entries must be engine-rejected. If the entry looks "obviously wrong" but is accepted, it may be vacuously valid: {} and true are valid 2020-12 schemas. Fix the entry, do not weaken this check.`); + } + } + + return { + status: violations.length === 0 ? "ok" : "violations", + project_root: root, + docs_checked: docs.length, + docs: docs.map((p) => path.relative(root, p)), + corpus_entries: entries.length, + checks, + violations, + exit: violations.length === 0 ? 0 : 1, + }; +} + +const HELP = `validate-schemas.mjs — every repo schema doc is a valid JSON-Schema 2020-12 document (RFC-008 M1/M2) + +Usage: + node scripts/validate-schemas.mjs --project [--json] + +Options: + --project explicit project root (realpath'd; git never consulted) + --json full JSON payload on stdout + --help this message + +Exit: 0 pass, 1 violations, 2 usage/IO error or internal crash.`; + +function parseArgs(argv) { + const args = { project: null, json: false, help: false }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === "--project") { args.project = argv[++i]; if (args.project == null) throw new UsageError("--project requires a value"); } + else if (a === "--json") args.json = true; + else if (a === "--help") args.help = true; + else throw new UsageError(`unknown argument ${JSON.stringify(a)}`); + } + return args; +} + +function main() { + let args; + try { args = parseArgs(process.argv.slice(2)); } + catch (e) { process.stderr.write(e.message + "\n"); process.exit(2); } + if (args.help) { process.stdout.write(HELP + "\n"); process.exit(0); } + + let result; + try { + result = validateSchemas({ projectRoot: args.project }); + } catch (e) { + if (e instanceof UsageError || e.name === "UsageError") { + process.stderr.write(e.message + "\n"); + process.exit(2); + } + // Internal crash — fail closed as usage/IO (exit 2), NEVER exit 1: a crash + // must not masquerade as "checked everything and found violations". + process.stdout.write(JSON.stringify({ status: "error", project_root: null, checks: 0, violations: [{ check: "internal", severity: "error", detail: e.message }] }) + "\n"); + process.exit(2); + } + + const { exit, ...payload } = result; + if (args.json) { + process.stdout.write(JSON.stringify(payload, null, 2) + "\n"); + } else if (payload.status === "ok") { + process.stdout.write(`OK validate-schemas: ${payload.docs_checked} doc(s) lint clean, ${payload.corpus_entries} corpus negative(s) rejected, ${payload.checks} check(s) for ${payload.project_root}\n`); + } else { + process.stderr.write(`FAIL validate-schemas (${payload.status}): ${payload.violations.length} violation(s)\n`); + for (const v of payload.violations) process.stderr.write(` ✗ [${v.check}] ${v.detail}\n`); + } + process.exit(exit); +} + +// Run as CLI only when invoked directly (not when imported by tests). +// pathToFileURL, not `file://${argv[1]}`: a path needing URL-encoding (space, +// non-ASCII) makes the raw template compare false -> main() never runs -> +// exit 0 with empty output, a vacuous green for the CI gate (step-6 F4). +if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main(); diff --git a/tests/fixtures/schema-negative-corpus.json b/tests/fixtures/schema-negative-corpus.json new file mode 100644 index 0000000..2647437 --- /dev/null +++ b/tests/fixtures/schema-negative-corpus.json @@ -0,0 +1,16 @@ +[ + { "name": "items-is-array (R2)", "schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", "items": [] } }, + { "name": "required-is-string", "schema": { "required": "x" } }, + { "name": "type-banana", "schema": { "type": "banana" } }, + { "name": "properties-is-array", "schema": { "properties": [] } }, + { "name": "unknown-keyword (requiredd)", "schema": { "requiredd": [] } }, + { "name": "properties-value-not-schema", "schema": { "properties": { "a": [] } } }, + { "name": "nested-bad-type-under-items", "schema": { "items": { "type": "strng" } } }, + { "name": "deep: propertyNames.items=[]", "schema": { "propertyNames": { "items": [] } } }, + { "name": "deep: unevaluatedProperties.type=banana", "schema": { "unevaluatedProperties": { "type": "banana" } } }, + { "name": "deep: dependentSchemas.a.items=[]", "schema": { "dependentSchemas": { "a": { "items": [] } } } }, + { "name": "deep: allOf element bad", "schema": { "allOf": [{ "type": "banana" }] } }, + { "name": "deep: contentSchema.items=[]", "schema": { "contentSchema": { "items": [] } } }, + { "name": "enum-not-array", "schema": { "enum": "x" } }, + { "name": "minLength-negative", "schema": { "minLength": -1 } } +] diff --git a/tests/lib/mini-jsonschema.mjs b/tests/lib/mini-jsonschema.mjs index cf677a7..ffb0f28 100644 --- a/tests/lib/mini-jsonschema.mjs +++ b/tests/lib/mini-jsonschema.mjs @@ -1,315 +1,7 @@ -// mini-jsonschema.mjs — TEST-ONLY JSON-Schema 2020-12 keyword-grammar linter. -// -// RFC-008 P0 validity-verification gate (v11.8). This is NOT a meta-schema -// interpreter — it never loads the official 2020-12 meta-schema and never -// resolves $dynamicRef / $dynamicAnchor (replicating that machinery is the most -// error-prone corner of the spec and the wrong patch class — see the R0b plan -// review, rounds 2-3). It directly asserts that a document conforms to the -// 2020-12 keyword grammar, recursing into every subschema-bearing position. -// -// Two properties close the fail-open class the review identified: -// (a) ALLOWLIST + fail-on-unknown-keyword — a keyword not in the canonical -// 2020-12 set is a HARD FAIL (a typo like `requiredd` can never silently -// pass; the inverse of "ignore unknown keywords"). -// (b) The recurse-set is DERIVED from a single declared SUBSCHEMA_KEYWORDS -// table covering ALL 2020-12 subschema-bearing keywords, and the module -// SELF-ASSERTS on load that -// keys(SUBSCHEMA_KEYWORDS) ∪ keys(VALUE_GRAMMAR) === ALLOWLIST -// so a future allowlisted keyword that bears a subschema cannot be added -// without classifying it — the recurse-set cannot silently go incomplete -// (closing e.g. {"propertyNames":{"items":[]}} which would otherwise pass). -// -// T17: test-only `.mjs` under tests/ — NOT a shipped runtime/CI validator. Full -// official-meta-schema validation is owned by P2's scripts/validate-schemas.mjs. - -// --------------------------------------------------------------------------- -// Canonical 2020-12 keyword set — the independent source of truth (57 keywords: -// Core 9, Applicator 17, Validation 20, Meta-data 7, Format 1, Content 3). -// --------------------------------------------------------------------------- -const ALLOWLIST = new Set([ - // Core (9) - "$schema", "$id", "$ref", "$anchor", "$dynamicRef", "$dynamicAnchor", - "$vocabulary", "$comment", "$defs", - // Applicator (17) - "prefixItems", "items", "contains", "additionalProperties", "properties", - "patternProperties", "dependentSchemas", "propertyNames", "if", "then", - "else", "allOf", "anyOf", "oneOf", "not", "unevaluatedItems", - "unevaluatedProperties", - // Validation (20) - "type", "enum", "const", "multipleOf", "maximum", "exclusiveMaximum", - "minimum", "exclusiveMinimum", "maxLength", "minLength", "pattern", - "maxItems", "minItems", "uniqueItems", "maxContains", "minContains", - "maxProperties", "minProperties", "required", "dependentRequired", - // Meta-data (7) - "title", "description", "default", "deprecated", "readOnly", "writeOnly", - "examples", - // Format (1) - "format", - // Content (3) - "contentEncoding", "contentMediaType", "contentSchema", -]); - -// --------------------------------------------------------------------------- -// SUBSCHEMA_KEYWORDS — the SINGLE declared recurse-set. Every 2020-12 keyword -// whose value contains one or more subschemas, classified by value shape. -// single : value IS a schema (object|boolean) -// array : value is an array of schemas -// map : value is an object whose every value is a schema -// --------------------------------------------------------------------------- -const SUBSCHEMA_KEYWORDS = { - // single-schema (11) - items: "single", - additionalProperties: "single", - unevaluatedItems: "single", - unevaluatedProperties: "single", - contains: "single", - propertyNames: "single", - if: "single", - then: "single", - else: "single", - not: "single", - contentSchema: "single", - // schema-array (4) - prefixItems: "array", - allOf: "array", - anyOf: "array", - oneOf: "array", - // object -> schema-map (4) - properties: "map", - patternProperties: "map", - $defs: "map", - dependentSchemas: "map", -}; - -const VALID_TYPES = new Set([ - "null", "boolean", "object", "array", "number", "string", "integer", -]); - -// --------------------------------------------------------------------------- -// VALUE_GRAMMAR — non-recursing keywords + their grammar check. Each returns -// null on success or an error string on failure. -// --------------------------------------------------------------------------- -const isString = (v) => (typeof v === "string" ? null : "must be a string"); -const isBoolean = (v) => (typeof v === "boolean" ? null : "must be a boolean"); -const isNumber = (v) => - typeof v === "number" && Number.isFinite(v) ? null : "must be a number"; -const isPositiveNumber = (v) => - typeof v === "number" && Number.isFinite(v) && v > 0 - ? null - : "must be a number > 0"; -const isNonNegInt = (v) => - Number.isInteger(v) && v >= 0 ? null : "must be a non-negative integer"; -const isArray = (v) => (Array.isArray(v) ? null : "must be an array"); -const isAny = () => null; - -function isEnum(v) { - if (!Array.isArray(v)) return "must be an array"; - if (v.length === 0) return "must be a non-empty array"; - return null; -} - -function isStringArrayUnique(v) { - if (!Array.isArray(v)) return "must be an array"; - if (!v.every((s) => typeof s === "string")) return "must be an array of strings"; - if (new Set(v).size !== v.length) return "must contain unique strings"; - return null; -} - -function isTypeKeyword(v) { - if (typeof v === "string") { - return VALID_TYPES.has(v) ? null : `unknown type name: ${JSON.stringify(v)}`; - } - if (Array.isArray(v)) { - if (v.length === 0) return "type array must be non-empty"; - if (new Set(v).size !== v.length) return "type array must be unique"; - for (const t of v) { - if (typeof t !== "string" || !VALID_TYPES.has(t)) { - return `unknown type name in array: ${JSON.stringify(t)}`; - } - } - return null; - } - return "must be a type name or array of type names"; -} - -function isPattern(v) { - if (typeof v !== "string") return "must be a string"; - try { - new RegExp(v); - return null; - } catch (e) { - return `not a compilable regex: ${e.message}`; - } -} - -function isVocabulary(v) { - if (v === null || typeof v !== "object" || Array.isArray(v)) { - return "must be an object"; - } - for (const val of Object.values(v)) { - if (typeof val !== "boolean") return "values must be booleans"; - } - return null; -} - -function isDependentRequired(v) { - if (v === null || typeof v !== "object" || Array.isArray(v)) { - return "must be an object"; - } - for (const val of Object.values(v)) { - const err = isStringArrayUnique(val); - if (err) return `value ${err}`; - } - return null; -} - -const VALUE_GRAMMAR = { - // Core (8 — $defs is a subschema map, handled separately) - $schema: isString, - $id: isString, - $ref: isString, - $anchor: isString, - $dynamicRef: isString, - $dynamicAnchor: isString, - $vocabulary: isVocabulary, - $comment: isString, - // Validation (20) - type: isTypeKeyword, - enum: isEnum, - const: isAny, - multipleOf: isPositiveNumber, - maximum: isNumber, - exclusiveMaximum: isNumber, - minimum: isNumber, - exclusiveMinimum: isNumber, - maxLength: isNonNegInt, - minLength: isNonNegInt, - pattern: isPattern, - maxItems: isNonNegInt, - minItems: isNonNegInt, - uniqueItems: isBoolean, - maxContains: isNonNegInt, - minContains: isNonNegInt, - maxProperties: isNonNegInt, - minProperties: isNonNegInt, - required: isStringArrayUnique, - dependentRequired: isDependentRequired, - // Meta-data (7) - title: isString, - description: isString, - default: isAny, - deprecated: isBoolean, - readOnly: isBoolean, - writeOnly: isBoolean, - examples: isArray, - // Format (1) - format: isString, - // Content (2 — contentSchema is a subschema) - contentEncoding: isString, - contentMediaType: isString, -}; - -// --------------------------------------------------------------------------- -// Structural self-assertion (runs on module load). This is the guard the R0b -// review demanded: the union of the two classification maps' keys MUST equal -// the canonical allowlist, in both directions. -// --------------------------------------------------------------------------- -export function assertSelfConsistent() { - const classified = new Set([ - ...Object.keys(SUBSCHEMA_KEYWORDS), - ...Object.keys(VALUE_GRAMMAR), - ]); - const missing = [...ALLOWLIST].filter((k) => !classified.has(k)); - const extra = [...classified].filter((k) => !ALLOWLIST.has(k)); - // A keyword in both maps is a classification bug too (ambiguous handling). - const both = Object.keys(SUBSCHEMA_KEYWORDS).filter( - (k) => k in VALUE_GRAMMAR, - ); - if (missing.length || extra.length || both.length) { - throw new Error( - "mini-jsonschema self-assertion FAILED: classification maps do not " + - "partition the allowlist.\n" + - ` unclassified (in allowlist, in neither map): ${JSON.stringify(missing)}\n` + - ` unknown (in a map, not in allowlist): ${JSON.stringify(extra)}\n` + - ` double-classified (in both maps): ${JSON.stringify(both)}`, - ); - } -} -assertSelfConsistent(); - -// --------------------------------------------------------------------------- -// The linter. A "schema" in 2020-12 is a boolean OR an object. -// --------------------------------------------------------------------------- -function isSchemaShaped(node) { - return typeof node === "boolean" || (node !== null && typeof node === "object" && !Array.isArray(node)); -} - -function lintNode(node, path, errors) { - if (typeof node === "boolean") return; // boolean schema — always valid - if (node === null || typeof node !== "object" || Array.isArray(node)) { - errors.push(`${path}: not a schema (expected object or boolean)`); - return; - } - for (const [key, value] of Object.entries(node)) { - const here = `${path}/${key}`; - if (!ALLOWLIST.has(key)) { - errors.push(`${here}: unknown keyword "${key}" (not in 2020-12 allowlist)`); - continue; - } - const kind = SUBSCHEMA_KEYWORDS[key]; - if (kind === "single") { - if (!isSchemaShaped(value)) { - errors.push(`${here}: "${key}" must be a schema (object or boolean), got ${describe(value)}`); - } else { - lintNode(value, here, errors); - } - } else if (kind === "array") { - if (!Array.isArray(value)) { - errors.push(`${here}: "${key}" must be an array of schemas, got ${describe(value)}`); - } else { - value.forEach((sub, i) => { - if (!isSchemaShaped(sub)) { - errors.push(`${here}/${i}: "${key}" element must be a schema, got ${describe(sub)}`); - } else { - lintNode(sub, `${here}/${i}`, errors); - } - }); - } - } else if (kind === "map") { - if (value === null || typeof value !== "object" || Array.isArray(value)) { - errors.push(`${here}: "${key}" must be an object whose values are schemas, got ${describe(value)}`); - } else { - for (const [subKey, sub] of Object.entries(value)) { - if (!isSchemaShaped(sub)) { - errors.push(`${here}/${subKey}: "${key}" value must be a schema, got ${describe(sub)}`); - } else { - lintNode(sub, `${here}/${subKey}`, errors); - } - } - } - } else { - // value-grammar keyword - const check = VALUE_GRAMMAR[key]; - const err = check(value); - if (err) errors.push(`${here}: "${key}" ${err} (got ${describe(value)})`); - } - } -} - -function describe(v) { - if (Array.isArray(v)) return "array"; - if (v === null) return "null"; - return typeof v; -} - -/** - * Lint a parsed JSON value as a JSON-Schema 2020-12 document. - * @returns {{ valid: boolean, errors: string[] }} - */ -export function lintSchema(doc) { - const errors = []; - lintNode(doc, "#", errors); - return { valid: errors.length === 0, errors }; -} - -export { ALLOWLIST, SUBSCHEMA_KEYWORDS, VALUE_GRAMMAR }; +// mini-jsonschema.mjs — thin re-export shim. The implementation was PROMOTED to +// scripts/lib/mini-jsonschema.mjs in RFC-008 P2a (it ships: the CI validator +// scripts/validate-schemas.mjs imports it for M1/M2, and a prod tool importing +// from tests/lib/ is a prod-depends-on-test inversion). This shim preserves the +// existing P0 importer (tests/test-p0-schemas.mjs) unchanged — same pattern as +// tests/lib/version-hash.mjs (P1b). +export { lintSchema, assertSelfConsistent, ALLOWLIST, SUBSCHEMA_KEYWORDS, VALUE_GRAMMAR } from "../../scripts/lib/mini-jsonschema.mjs"; diff --git a/tests/test-checkpoints-migration.mjs b/tests/test-checkpoints-migration.mjs index 852a879..4ff3eca 100644 --- a/tests/test-checkpoints-migration.mjs +++ b/tests/test-checkpoints-migration.mjs @@ -117,14 +117,20 @@ console.log('\nbp-001 signal is an advisory; em-recall never arms a marker (plan test('em-recall emits __BP1_ADVISORY__ and arms NO .checkpoint-required at either root', () => { const root = setupRepo() // Force activation by seeding a recent bp-001 violation in local store. + // The date MUST be computed: shouldArmBp001Checkpoint (em-recall.mjs) only + // matches violations inside a 30-day window (`e.date >= cutoffStr`). A + // hardcoded 2026-05-09 rotted out of the window on 2026-06-08 and failed CI + // on a zero-diff PR (#381) — seed yesterday's date so the fixture never ages + // out again. + const recentDate = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString().slice(0, 10) const localEpisodes = path.join(root, '.episodic-memory', 'episodes') fs.mkdirSync(localEpisodes, { recursive: true }) - const violationId = '20260509-000000-test-bp001-violation-aaaa' + const violationId = `${recentDate.replace(/-/g, '')}-000000-test-bp001-violation-aaaa` fs.writeFileSync( path.join(localEpisodes, `${violationId}.md`), `--- id: ${violationId} -date: 2026-05-09 +date: ${recentDate} project: test category: violation status: active @@ -137,7 +143,7 @@ test // Build a minimal index.jsonl so loadIndex picks it up. const indexLine = JSON.stringify({ id: violationId, - date: '2026-05-09', + date: recentDate, project: 'test', category: 'violation', status: 'active', diff --git a/tests/test-p0-schemas.mjs b/tests/test-p0-schemas.mjs index 6fd2649..9597c50 100644 --- a/tests/test-p0-schemas.mjs +++ b/tests/test-p0-schemas.mjs @@ -124,29 +124,42 @@ for (const rel of SCHEMA_DOCS) { } // --------------------------------------------------------------------------- -// 4. NEGATIVE corpus — each MUST be rejected. Includes the deep fail-open -// cases the R0b review (R3) demanded: subschema nested under propertyNames / +// 4. NEGATIVE corpus — each MUST be rejected. SHARED fixture (#368): the same +// file drives the shipped scripts/validate-schemas.mjs, so the P0 gate and +// the CI validator cannot drift. Includes the deep fail-open cases the R0b +// review (R3) demanded: subschema nested under propertyNames / // unevaluatedProperties / dependentSchemas. // --------------------------------------------------------------------------- -const NEGATIVE = [ - ["items-is-array (R2)", { $schema: "https://json-schema.org/draft/2020-12/schema", items: [] }], - ["required-is-string", { required: "x" }], - ["type-banana", { type: "banana" }], - ["properties-is-array", { properties: [] }], - ["unknown-keyword (requiredd)", { requiredd: [] }], - ["properties-value-not-schema", { properties: { a: [] } }], - ["nested-bad-type-under-items", { items: { type: "strng" } }], - ["deep: propertyNames.items=[]", { propertyNames: { items: [] } }], - ["deep: unevaluatedProperties.type=banana", { unevaluatedProperties: { type: "banana" } }], - ["deep: dependentSchemas.a.items=[]", { dependentSchemas: { a: { items: [] } } }], - ["deep: allOf element bad", { allOf: [{ type: "banana" }] }], - ["deep: contentSchema.items=[]", { contentSchema: { items: [] } }], - ["enum-not-array", { enum: "x" }], - ["minLength-negative", { minLength: -1 }], -]; -for (const [name, schema] of NEGATIVE) { - const { valid } = lintSchema(schema); - assert(!valid, `rejects: ${name}`, "linter accepted an invalid schema (fail-open)"); +let corpusRaw = null; +try { + corpusRaw = JSON.parse(readFileSync(join(REPO_ROOT, "tests/fixtures/schema-negative-corpus.json"), "utf8")); + ok("negative corpus parses"); +} catch (e) { + bad("negative corpus parses", e.message); +} +// Shape contract + non-vacuity guard run UNCONDITIONALLY (outside the read +// try/catch): a missing/unparseable fixture must fail these too, never skip. +const corpusEntries = Array.isArray(corpusRaw) ? corpusRaw : []; +assert(Array.isArray(corpusRaw), "negative corpus is an array", `got ${corpusRaw === null ? "null/unreadable" : typeof corpusRaw}`); +assert( + corpusEntries.length >= 14, + "negative corpus has >= 14 entries (non-vacuity, #368)", + `got ${corpusEntries.length}`, +); +assert( + corpusEntries.every((e) => e !== null && typeof e === "object" && typeof e.name === "string" && "schema" in e), + "every corpus entry has string `name` + `schema` key", +); +assert( + new Set(corpusEntries.map((e) => e && e.name)).size === corpusEntries.length, + "corpus entry names are unique (a duplicate shrinks class coverage silently)", +); +for (const entry of corpusEntries) { + // Shape-violating entries already failed the contract asserts above; skipping + // them here keeps a malformed entry from crashing the run before the summary. + if (entry === null || typeof entry !== "object" || typeof entry.name !== "string" || !("schema" in entry)) continue; + const { valid } = lintSchema(entry.schema); + assert(!valid, `rejects: ${entry.name}`, "linter accepted an invalid schema (fail-open)"); } // --------------------------------------------------------------------------- diff --git a/tests/test-path-contain.mjs b/tests/test-path-contain.mjs new file mode 100644 index 0000000..5f541f7 --- /dev/null +++ b/tests/test-path-contain.mjs @@ -0,0 +1,161 @@ +// test-path-contain.mjs — RFC-008 P2a: direct axis tests for the extracted +// scripts/lib/path-contain.mjs (contained / resolveContained / UsageError). +// The extraction is verbatim (behavior-preserving; tests/test-plugin-registry.mjs +// is the integration regression lock) — this suite pins the containment +// semantics per axis so the NEXT consumer (P2b validate-bp-contract) inherits +// a tested predicate, not an assumed one. +// +// Symlink-dependent axes loud-skip on platforms where symlink creation is not +// permitted (win32 without Developer Mode) — skipped count is printed, never +// silent. +// +// Run: node tests/test-path-contain.mjs (exit 0 = pass, non-zero = fail) + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { contained, resolveContained, UsageError } from "../scripts/lib/path-contain.mjs"; + +let pass = 0; +let fail = 0; +let skipped = 0; +const failures = []; + +function ok(name) { + pass++; +} +function bad(name, detail) { + fail++; + failures.push(`${name}${detail ? " — " + detail : ""}`); +} +function assert(cond, name, detail) { + if (cond) ok(name); + else bad(name, detail); +} + +// Sandbox: root MUST be canonical (the resolveContained contract) — realpath +// the tmpdir (macOS os.tmpdir() is /var/..., canonically /private/var/...). +const SANDBOX = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "path-contain-"))); +const ROOT = path.join(SANDBOX, "project"); +const OUTSIDE = path.join(SANDBOX, "outside"); +fs.mkdirSync(path.join(ROOT, "sub"), { recursive: true }); +fs.mkdirSync(OUTSIDE, { recursive: true }); +fs.writeFileSync(path.join(ROOT, "sub", "in.json"), "{}"); +fs.writeFileSync(path.join(OUTSIDE, "ext.json"), "{}"); + +/** Create a symlink; returns false (and counts a loud skip) if not permitted. */ +function trySymlink(target, linkPath, kind) { + try { + fs.symlinkSync(target, linkPath, kind); + return true; + } catch (e) { + if (e.code === "EPERM" || e.code === "EACCES") { + skipped++; + return false; + } + throw e; + } +} + +function expectUsageError(name, fn) { + try { + fn(); + bad(name, "expected UsageError, got success"); + } catch (e) { + assert(e instanceof UsageError && e.name === "UsageError", name, `threw ${e.name}: ${e.message}`); + } +} + +// --------------------------------------------------------------------------- +// contained() — pure lexical predicate. +// --------------------------------------------------------------------------- +assert(contained(ROOT, ROOT), "contained: root equals root"); +assert(contained(path.join(ROOT, "sub", "in.json"), ROOT), "contained: child under root"); +assert(!contained(OUTSIDE, ROOT), "contained: sibling dir rejected"); +assert(!contained(ROOT + "2", ROOT), "contained: prefix-without-separator rejected (/root2 vs /root)"); + +// --------------------------------------------------------------------------- +// False-positive controls — legitimate paths must be accepted. +// --------------------------------------------------------------------------- +assert( + resolveContained(ROOT, "sub/in.json", "t") === path.join(ROOT, "sub", "in.json"), + "FP control: relative in-project path accepted", +); +assert( + resolveContained(ROOT, path.join(ROOT, "sub", "in.json"), "t") === path.join(ROOT, "sub", "in.json"), + "FP control: absolute in-project path accepted (root pre-canonicalized)", +); + +// --------------------------------------------------------------------------- +// ENOENT branch — not-yet-existing paths: lexical containment decides. +// --------------------------------------------------------------------------- +assert( + resolveContained(ROOT, "sub/new-not-yet.json", "t") === path.join(ROOT, "sub", "new-not-yet.json"), + "ENOENT: lexically-contained nonexistent path returns absLex (reader surfaces ENOENT)", +); +expectUsageError("ENOENT: ../ lexical escape rejected", () => + resolveContained(ROOT, "../outside/new-not-yet.json", "t"), +); +expectUsageError("absolute outside path rejected", () => + resolveContained(ROOT, path.join(OUTSIDE, "ext.json"), "t"), +); +expectUsageError("traversal to existing outside file rejected", () => + resolveContained(ROOT, "../outside/ext.json", "t"), +); + +// --------------------------------------------------------------------------- +// Symlink axes (loud-skip where symlinks cannot be created). +// --------------------------------------------------------------------------- + +// Axis: internal symlink -> outside target (escape) — realpath lands outside. +const linkOut = path.join(ROOT, "link-out.json"); +if (trySymlink(path.join(OUTSIDE, "ext.json"), linkOut, "file")) { + expectUsageError("internal symlink to outside file rejected", () => + resolveContained(ROOT, "link-out.json", "t"), + ); +} + +// Axis: external symlink -> existing in-project file — realpath lands inside. +const linkIn = path.join(OUTSIDE, "link-in.json"); +if (trySymlink(path.join(ROOT, "sub", "in.json"), linkIn, "file")) { + assert( + resolveContained(ROOT, linkIn, "t") === path.join(ROOT, "sub", "in.json"), + "external symlink resolving INTO project accepted (canonical target is what is read)", + ); +} + +// Axis: symlinked external DIRECTORY -> traversal through it stays judged on realpath. +const dirLink = path.join(ROOT, "dir-link"); +if (trySymlink(OUTSIDE, dirLink, "dir")) { + expectUsageError("file under internal dir-symlink to outside rejected", () => + resolveContained(ROOT, "dir-link/ext.json", "t"), + ); +} + +// Axis: symlink loop — realpathSync throws ELOOP -> lexical fallback branch. +const loopA = path.join(ROOT, "loop-a"); +const loopB = path.join(ROOT, "loop-b"); +if (trySymlink(loopB, loopA, "file") && trySymlink(loopA, loopB, "file")) { + assert( + resolveContained(ROOT, "loop-a", "t") === loopA, + "symlink loop: ELOOP falls back to lexical check; in-project loop returns absLex", + ); + // The loop members are unreadable; the reader surfaces the error — containment + // never resolved them outside the root, so no authority escape. +} + +// --------------------------------------------------------------------------- +// Summary. +// --------------------------------------------------------------------------- +fs.rmSync(SANDBOX, { recursive: true, force: true }); +console.log(`\ntest-path-contain: ${pass} passed, ${fail} failed, ${skipped} symlink-axis skipped`); +if (fail > 0) { + console.error("\nFAILURES:"); + for (const f of failures) console.error(` ✗ ${f}`); + process.exit(1); +} +if (pass === 0) { + console.error("✗ zero checks executed (vacuous run)"); + process.exit(1); +} +console.log("✓ all path-contain checks passed"); diff --git a/tests/test-validate-schemas.mjs b/tests/test-validate-schemas.mjs new file mode 100644 index 0000000..09dbcb1 --- /dev/null +++ b/tests/test-validate-schemas.mjs @@ -0,0 +1,351 @@ +// test-validate-schemas.mjs — RFC-008 P2a: the shipped scripts/validate-schemas.mjs +// is fail-CLOSED. Positive run against the real repo, then the Class A negative +// matrix in an os.tmpdir() sandbox built by copying the real scan roots with +// { verbatimSymlinks: true } — load-bearing: the cpSync DEFAULT rewrites +// relative symlink targets to absolute paths into the SOURCE tree, so sandbox +// links would silently resolve back into the real repo and the dangling-symlink +// axis would never be exercised (P2a plan review F-A, empirically probed). +// Verbatim copy preserves relative targets, which then dangle in the sandbox +// exactly as the no-follow walk must tolerate. +// +// Symlink-dependent axes loud-skip (printed count) where symlink creation/copy +// is not permitted (win32 without Developer Mode) — never a wholesale error. +// +// Run: node tests/test-validate-schemas.mjs (exit 0 = pass, non-zero = fail) + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; + +const REPO_ROOT = fs.realpathSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "..")); +const VALIDATOR = path.join(REPO_ROOT, "scripts", "validate-schemas.mjs"); +const CORPUS_REL = path.join("tests", "fixtures", "schema-negative-corpus.json"); +const SCAN_ROOTS = ["patterns", "plugins", "schemas"]; + +// The explicit P0 17-doc contract (mirrors tests/test-p0-schemas.mjs SCHEMA_DOCS). +// Rule-14 cross-check: the test asserts the CONTRACT list, the validator +// DISCOVERS — every contract doc must appear in the discovered set. +const P0_SCHEMA_DOCS = [ + "patterns/taxonomy.schema.json", + "patterns/events.schema.json", + "patterns/schema.json", + "plugins/manifest.schema.json", + "plugins/_index.schema.json", + "plugins/installed-state.schema.json", + "plugins/bypass_known.schema.json", + "schemas/events/event-pre-tool-use.schema.json", + "schemas/events/event-tool-result.schema.json", + "schemas/events/event-stop.schema.json", + "schemas/events/event-session-start.schema.json", + "schemas/events/event-session-end.schema.json", + "schemas/runtime/classifier-output.schema.json", + "schemas/runtime/adapter-call.schema.json", + "schemas/runtime/adapter-response.schema.json", + "schemas/runtime/structured-alert.schema.json", + "schemas/runbook-agent-manifest.schema.json", +]; + +let pass = 0; +let fail = 0; +let skipped = 0; +const failures = []; + +function ok(name) { + pass++; +} +function bad(name, detail) { + fail++; + failures.push(`${name}${detail ? " — " + detail : ""}`); +} +function assert(cond, name, detail) { + if (cond) ok(name); + else bad(name, detail); +} + +function run(args) { + const r = spawnSync(process.execPath, [VALIDATOR, ...args], { encoding: "utf8" }); + let payload = null; + try { payload = JSON.parse(r.stdout); } catch { /* non-JSON output (human/usage) */ } + return { exit: r.status, stdout: r.stdout, stderr: r.stderr, payload }; +} + +function hasViolation(payload, check, substr) { + return !!payload && Array.isArray(payload.violations) && + payload.violations.some((v) => v.check === check && (!substr || v.detail.includes(substr))); +} + +// --------------------------------------------------------------------------- +// 1. POSITIVE — the real repo passes, discovery covers the P0 contract. +// --------------------------------------------------------------------------- +{ + const r = run(["--project", REPO_ROOT, "--json"]); + assert(r.exit === 0, "real repo: exit 0", `exit=${r.exit} stderr=${r.stderr.slice(0, 200)}`); + assert(r.payload && r.payload.status === "ok", "real repo: status ok"); + assert(r.payload && r.payload.docs_checked >= 17, "real repo: docs_checked >= 17", `got ${r.payload && r.payload.docs_checked}`); + assert(r.payload && r.payload.corpus_entries >= 14, "real repo: corpus_entries >= 14", `got ${r.payload && r.payload.corpus_entries}`); + const discovered = new Set((r.payload && r.payload.docs) || []); + const missing = P0_SCHEMA_DOCS.filter((d) => !discovered.has(d)); + assert(missing.length === 0, "real repo: all 17 P0 contract docs are in the discovered set (Rule-14 cross-check)", `missing: ${missing.join(", ")}`); +} + +// --------------------------------------------------------------------------- +// Sandbox construction (verbatimSymlinks — see header). +// --------------------------------------------------------------------------- +const TMP = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "validate-schemas-"))); +let symlinksOk = true; + +function copyTree(src, dest) { + try { + fs.cpSync(src, dest, { recursive: true, verbatimSymlinks: true }); + } catch (e) { + if (e.code === "EPERM" || e.code === "EACCES") { + // win32 without symlink privilege: copy without symlinks; symlink axes loud-skip. + symlinksOk = false; + fs.cpSync(src, dest, { + recursive: true, + filter: (s) => !fs.lstatSync(s).isSymbolicLink(), + }); + } else { + throw e; + } + } +} + +function makeCase(name) { + const root = path.join(TMP, name); + for (const rel of SCAN_ROOTS) copyTree(path.join(REPO_ROOT, rel), path.join(root, rel)); + fs.mkdirSync(path.join(root, "tests", "fixtures"), { recursive: true }); + fs.copyFileSync(path.join(REPO_ROOT, CORPUS_REL), path.join(root, CORPUS_REL)); + return root; +} + +function readCorpus() { + return JSON.parse(fs.readFileSync(path.join(REPO_ROOT, CORPUS_REL), "utf8")); +} +function writeCorpus(root, corpus) { + fs.writeFileSync(path.join(root, CORPUS_REL), JSON.stringify(corpus)); +} + +// --------------------------------------------------------------------------- +// 2. Sandbox baseline — exit 0, and the copy is HERMETIC: no copied symlink +// resolves back into the real repo (the F-A axis), and at least one dangles +// (proving the no-follow walk tolerates dangling links — the F2b axis). +// --------------------------------------------------------------------------- +{ + const root = makeCase("baseline"); + if (symlinksOk) { + const links = []; + (function collect(dir) { + for (const ent of fs.readdirSync(dir, { withFileTypes: true })) { + const p = path.join(dir, ent.name); + if (ent.isSymbolicLink()) links.push(p); + else if (ent.isDirectory()) collect(p); + } + })(root); + let escapes = 0; + let dangling = 0; + for (const l of links) { + const target = path.resolve(path.dirname(l), fs.readlinkSync(l)); + let real = null; + try { real = fs.realpathSync(l); } catch { dangling++; } + if (real !== null && (real === REPO_ROOT || real.startsWith(REPO_ROOT + path.sep))) escapes++; + if (real === null && (target === REPO_ROOT || target.startsWith(REPO_ROOT + path.sep))) escapes++; + } + assert(escapes === 0, "sandbox hermetic: no copied symlink resolves into the real repo (verbatimSymlinks)", `${escapes} escape(s) of ${links.length} link(s)`); + assert(links.length === 0 || dangling >= 1, "sandbox carries >= 1 dangling symlink (the F2b axis is actually exercised)", `links=${links.length} dangling=${dangling}`); + } else { + skipped += 2; + } + const r = run(["--project", root, "--json"]); + assert(r.exit === 0, "sandbox baseline: exit 0 (dangling non-schema-named symlinks are inert)", `exit=${r.exit} stderr=${r.stderr.slice(0, 300)}`); +} + +// --------------------------------------------------------------------------- +// 3. NEGATIVE matrix (Class A — each must fail loudly, with attribution). +// --------------------------------------------------------------------------- + +// planted invalid schema doc -> doc-lint violation naming the file +{ + const root = makeCase("bad-doc"); + fs.writeFileSync(path.join(root, "schemas", "bad-test.schema.json"), JSON.stringify({ requiredd: [] })); + const r = run(["--project", root, "--json"]); + assert(r.exit === 1, "planted bad doc: exit 1", `exit=${r.exit}`); + assert(hasViolation(r.payload, "doc-lint", "bad-test.schema.json"), "planted bad doc: doc-lint violation names the file"); +} + +// unparseable schema doc -> doc-parse violation (content problem, exit 1 not 2) +{ + const root = makeCase("unparseable-doc"); + fs.writeFileSync(path.join(root, "schemas", "broken.schema.json"), "{ not json"); + const r = run(["--project", root, "--json"]); + assert(r.exit === 1, "unparseable doc: exit 1", `exit=${r.exit}`); + assert(hasViolation(r.payload, "doc-parse", "broken.schema.json"), "unparseable doc: doc-parse violation names the file"); +} + +// truncated corpus (13) -> non-vacuity guard +{ + const root = makeCase("short-corpus"); + writeCorpus(root, readCorpus().slice(0, 13)); + const r = run(["--project", root, "--json"]); + assert(r.exit === 1, "truncated corpus (13): exit 1", `exit=${r.exit}`); + assert(hasViolation(r.payload, "corpus-non-vacuity"), "truncated corpus: corpus-non-vacuity violation"); +} + +// vacuously-valid entry ({} is a VALID 2020-12 schema) -> divergence, explained +{ + const root = makeCase("vacuous-entry"); + writeCorpus(root, [...readCorpus(), { name: "vacuous", schema: {} }]); + const r = run(["--project", root, "--json"]); + assert(r.exit === 1, "vacuously-valid entry: exit 1", `exit=${r.exit}`); + assert(hasViolation(r.payload, "corpus-divergence", "vacuously valid"), "vacuously-valid entry: corpus-divergence violation explains {} is valid 2020-12"); +} + +// duplicate names -> uniqueness guard +{ + const root = makeCase("dup-names"); + const corpus = readCorpus(); + corpus[1] = { ...corpus[1], name: corpus[0].name }; + writeCorpus(root, corpus); + const r = run(["--project", root, "--json"]); + assert(r.exit === 1, "duplicate corpus names: exit 1", `exit=${r.exit}`); + assert(hasViolation(r.payload, "corpus-shape", "unique"), "duplicate corpus names: corpus-shape uniqueness violation"); +} + +// corpus missing -> exit 2 (IO fail-closed, NOT a vacuous pass, NOT exit 1) +{ + const root = makeCase("no-corpus"); + fs.rmSync(path.join(root, CORPUS_REL)); + const r = run(["--project", root, "--json"]); + assert(r.exit === 2, "missing corpus: exit 2 (absence != vacuous pass)", `exit=${r.exit}`); + assert(r.stderr.includes("corpus unreadable"), "missing corpus: stderr names the corpus", r.stderr.slice(0, 200)); +} + +// corpus malformed (object, not array) -> shape violation, exit 1 +{ + const root = makeCase("malformed-corpus"); + fs.writeFileSync(path.join(root, CORPUS_REL), JSON.stringify({ nope: true })); + const r = run(["--project", root, "--json"]); + assert(r.exit === 1, "malformed corpus (object): exit 1", `exit=${r.exit}`); + assert(hasViolation(r.payload, "corpus-shape", "array"), "malformed corpus: corpus-shape violation"); +} + +// out-of-root docs: BOTH spellings (planner F1 + reviewer F-B) -> sweep violations +{ + const root = makeCase("out-of-root"); + fs.mkdirSync(path.join(root, "scripts"), { recursive: true }); + fs.writeFileSync(path.join(root, "scripts", "stray.schema.json"), "{}"); + fs.writeFileSync(path.join(root, "scripts", "schema.json"), "{}"); // bare spelling (F-B) + const r = run(["--project", root, "--json"]); + assert(r.exit === 1, "out-of-root docs: exit 1", `exit=${r.exit}`); + assert(hasViolation(r.payload, "out-of-root-doc", "stray.schema.json"), "out-of-root: *.schema.json spelling swept"); + assert(hasViolation(r.payload, "out-of-root-doc", `scripts${path.sep}schema.json`), "out-of-root: bare schema.json spelling swept (F-B)"); +} + +// bare schema.json is ALSO discovered inside scan roots (F-B discovery side): +// plant an INVALID bare-named doc in a scan root -> doc-lint catches it. +{ + const root = makeCase("bare-name-discovery"); + fs.mkdirSync(path.join(root, "schemas", "sub"), { recursive: true }); + fs.writeFileSync(path.join(root, "schemas", "sub", "schema.json"), JSON.stringify({ type: "banana" })); + const r = run(["--project", root, "--json"]); + assert(r.exit === 1, "bare-named doc in scan root: exit 1", `exit=${r.exit}`); + assert(hasViolation(r.payload, "doc-lint", `sub${path.sep}schema.json`), "bare-named doc in scan root: discovered AND linted (F-B)"); +} + +// doc-named regular DIRECTORY in a scan root -> violation, never silently +// ignored (step-6 review F1: the symlink branch flagged doc-named entries but +// the directory branch stayed lenient — class fix judges doc-named entries by +// kind before any skip rule) +{ + const root = makeCase("doc-named-dir"); + fs.mkdirSync(path.join(root, "schemas", "dir.schema.json")); + const r = run(["--project", root, "--json"]); + assert(r.exit === 1, "doc-named directory: exit 1", `exit=${r.exit}`); + assert(hasViolation(r.payload, "doc-regular-file", "dir.schema.json"), "doc-named directory: doc-regular-file violation (not a silent skip)"); +} + +// dot-prefixed doc-named FILE -> discovered and linted (step-6 review F2: the +// dot-skip applies to directories only; a hidden .bad.schema.json must not +// lurk unlinted) +{ + const root = makeCase("dotfile-doc"); + fs.writeFileSync(path.join(root, "schemas", ".hidden.schema.json"), JSON.stringify({ requiredd: [] })); + const r = run(["--project", root, "--json"]); + assert(r.exit === 1, "hidden doc-named file: exit 1", `exit=${r.exit}`); + assert(hasViolation(r.payload, "doc-lint", ".hidden.schema.json"), "hidden doc-named file: discovered AND linted"); +} + +// discovery vacuity: empty scan roots -> min-docs violation +{ + const root = makeCase("min-docs"); + for (const rel of SCAN_ROOTS) fs.rmSync(path.join(root, rel), { recursive: true, force: true }); + const r = run(["--project", root, "--json"]); + assert(r.exit === 1, "empty scan roots: exit 1", `exit=${r.exit}`); + assert(hasViolation(r.payload, "min-docs"), "empty scan roots: min-docs vacuity violation"); +} + +// symlinked schema doc inside a scan root -> regular-file violation, never skipped +if (symlinksOk) { + const root = makeCase("symlinked-doc"); + try { + fs.symlinkSync(path.join("..", "tests", "fixtures", "schema-negative-corpus.json"), path.join(root, "schemas", "link.schema.json"), "file"); + const r = run(["--project", root, "--json"]); + assert(r.exit === 1, "symlinked schema doc: exit 1", `exit=${r.exit}`); + assert(hasViolation(r.payload, "doc-regular-file", "link.schema.json"), "symlinked schema doc: doc-regular-file violation (not a silent skip)"); + } catch (e) { + if (e.code === "EPERM" || e.code === "EACCES") skipped += 2; + else throw e; + } +} else { + skipped += 2; +} + +// --------------------------------------------------------------------------- +// 4. Usage errors — exit 2, never conflated with violations. +// --------------------------------------------------------------------------- +{ + const r = run(["--project", path.join(TMP, "does-not-exist")]); + assert(r.exit === 2, "nonexistent --project: exit 2", `exit=${r.exit}`); +} +{ + const r = run(["--bogus"]); + assert(r.exit === 2, "unknown argument: exit 2", `exit=${r.exit}`); + assert(r.stderr.includes("--bogus"), "unknown argument: named in stderr"); +} + +// direct-run guard survives URL-encoding-requiring paths (step-6 review F4): +// with the raw `file://${argv[1]}` compare, a SPACE in the script path made +// main() never run -> exit 0 + empty output = vacuous green for the CI gate. +{ + const spacedScripts = path.join(TMP, "spaced dir", "scripts"); + fs.mkdirSync(spacedScripts, { recursive: true }); + fs.copyFileSync(VALIDATOR, path.join(spacedScripts, "validate-schemas.mjs")); + // Whole lib/ (not a hand-list): a future import-graph addition must not turn + // into copy-list churn (round-2 FU-2; staleness was already fail-loud). + copyTree(path.join(REPO_ROOT, "scripts", "lib"), path.join(spacedScripts, "lib")); + const r = spawnSync( + process.execPath, + [path.join(spacedScripts, "validate-schemas.mjs"), "--project", path.join(TMP, "does-not-exist")], + { encoding: "utf8" }, + ); + assert(r.status === 2, "spaced script path: direct-run guard fires (exit 2, not a silent 0)", `exit=${r.status} stdout=${JSON.stringify(r.stdout.slice(0, 80))}`); + assert(r.stderr.includes("does not resolve"), "spaced script path: real usage error surfaced", r.stderr.slice(0, 200)); +} + +// --------------------------------------------------------------------------- +// Summary. +// --------------------------------------------------------------------------- +fs.rmSync(TMP, { recursive: true, force: true }); +console.log(`\ntest-validate-schemas: ${pass} passed, ${fail} failed, ${skipped} symlink-axis skipped`); +if (fail > 0) { + console.error("\nFAILURES:"); + for (const f of failures) console.error(` ✗ ${f}`); + process.exit(1); +} +if (pass === 0) { + console.error("✗ zero checks executed (vacuous run)"); + process.exit(1); +} +console.log("✓ all validate-schemas checks passed");