repo-contract 0.2.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "repo-contract",
3
- "version": "0.2.0",
3
+ "version": "0.3.2",
4
4
  "description": "Enforce your repository's own quality bar before CI even spins up. Configure arbitrary checks, capture what happened as evidence, and let repository-owned policies determine what passes. repo-contract provides the execution and evidence layer without becoming another CI system or imposing its own definition of code quality.",
5
5
  "keywords": [
6
6
  "contract",
@@ -111,7 +111,7 @@
111
111
  },
112
112
  "output": {
113
113
  "$ref": "#/definitions/ParsedOutput%3Cunknown%3E",
114
- "description": "The parsed interpretation of `stdout`, present only if this check's config requested a `format`."
114
+ "description": "The parsed interpretation of `stdout`, present only if this check's config requested a `format`. If that config also supplied `output.schema`, a successful parse is additionally validated (and possibly transformed) by that schema before landing here -- see this interface's own doc comment above and `CheckDefinitionConfig.output.schema`."
115
115
  }
116
116
  },
117
117
  "required": [
@@ -126,7 +126,7 @@
126
126
  "stderr",
127
127
  "status"
128
128
  ],
129
- "description": "What actually happened when one configured check ran. `output` is present only if that check's config requested a format, and is otherwise `undefined` -- a policy narrows with `ctx.result.output?.success` (or an `if (ctx.result.output) { ... }` guard) before reading `.value`/`.error`.\n\n`output.value` is typed `unknown` for every format, including `\"text\"` (even though `parseText` always produces a `string` at runtime) -- neither repo-contract nor TypeScript's generic inference can reliably carry a specific check's own literal `output.format` through to that same check's `policy` parameter once several checks with heterogeneous formats live together in one `checks` record (a real TypeScript inference limitation hit and confirmed during implementation, not a hypothetical -- see specs/decisions/ for the isolated repro). A policy author narrows or casts `.value` themselves, exactly as they already must for `\"json\"`/`\"yaml\"` where no schema knowledge exists either way."
129
+ "description": "What actually happened when one configured check ran. `output` is present only if that check's config requested a format, and is otherwise `undefined` -- a policy narrows with `ctx.result.output?.success` (or an `if (ctx.result.output) { ... }` guard) before reading `.value`/`.error`.\n\n`output.value` is the value produced by the configured `format` parser, optionally validated and (if a schema transforms/coerces it) replaced by `output.schema` (see `CheckDefinitionConfig.output.schema`) -- so it is not necessarily the raw parser result once a check's config supplies a schema. It is typed `unknown` for every format regardless, including `\"text\"` (even though `parseText` always produces a `string` at runtime) and regardless of whether a schema was supplied -- neither repo-contract nor TypeScript's generic inference can reliably carry a specific check's own literal `output.format` (or a specific `schema`'s own inferred output type) through to that same check's `policy` parameter once several checks with heterogeneous formats live together in one `checks` record (a real TypeScript inference limitation hit and confirmed during implementation, not a hypothetical -- see specs/decisions/ for the isolated repro). This is a deliberate, acknowledged tradeoff, not a gap this package is attempting to close: a schema still gives its own author real compile-time input/output typing via `StandardSchemaV1<Input, Output>` for their own code, just not threaded through this shared `CheckEvidence` shape. A policy author narrows or casts `.value` themselves, exactly as they already must for `\"json\"`/`\"yaml\"` with no schema supplied."
130
130
  },
131
131
  "global.NodeJS.Signals": {
132
132
  "type": "string",
@@ -321,7 +321,7 @@ function validateOutput(checkId: string, output: unknown): void {
321
321
  if (output === null || typeof output !== "object") {
322
322
  throw new InvalidCheckConfigError(checkId, "output must be an object when provided.")
323
323
  }
324
- const { format } = output as Record<string, unknown>
324
+ const { format, schema } = output as Record<string, unknown>
325
325
  // `typeof format !== "string"` is behaviorally redundant with the clause
326
326
  // after it: `Array#includes` uses strict equality, so a non-string
327
327
  // `format` can never match an entry of `OUTPUT_FORMATS` (all strings),
@@ -334,6 +334,60 @@ function validateOutput(checkId: string, output: unknown): void {
334
334
  `output.format must be one of ${OUTPUT_FORMATS.map((f) => `"${f}"`).join(", ")}.`,
335
335
  )
336
336
  }
337
+ validateOutputSchema(checkId, schema)
338
+ }
339
+
340
+ /**
341
+ * Validates that `schema`, when provided, is at least *plausibly* a `StandardSchemaV1`-compliant
342
+ * object (https://standardschema.dev) -- checks only the three fields every
343
+ * `StandardSchemaV1.Props` object must have (`version`, `vendor`, `validate`), since `schema` is
344
+ * an opaque, consumer-supplied object and anything else in its shape (e.g. the optional `types`
345
+ * field) is not part of the contract this function can or should enforce. Never calls `validate`
346
+ * itself -- that only happens once a check's stdout actually parses (see `parseOutput`); this
347
+ * function's whole job is structural, not behavioral.
348
+ *
349
+ * Accepts a callable `schema` (`typeof schema === "function"`), not just a plain object -- ArkType's
350
+ * `Type` values are themselves callable functions with `"~standard"` attached as a property, exactly
351
+ * as valid a Standard Schema-compliant value as a plain object one (e.g. Zod's), since `["~standard"]`
352
+ * property access works identically on either. Rejecting callable schemas outright would incorrectly
353
+ * reject a real, popular Standard Schema implementation.
354
+ * @param checkId - identifies which check is being validated, used in thrown error messages.
355
+ * @param schema - the check's raw `output.schema` field to validate.
356
+ */
357
+ function validateOutputSchema(checkId: string, schema: unknown): void {
358
+ if (schema === undefined) return
359
+ // eslint-disable-next-line secure-coding/no-improper-type-validation -- `null` is already excluded by the preceding `schema === null` short-circuit, and an array slipping past this check (typeof [] === "object") is still correctly rejected by the "~standard" property check just below, since no array has one -- the class of bug this rule guards against (silently treating null/an array as a valid object) can't actually happen here.
360
+ if (schema === null || (typeof schema !== "object" && typeof schema !== "function")) {
361
+ throw new InvalidCheckConfigError(
362
+ checkId,
363
+ "output.schema must be a Standard Schema-compliant object or function when provided (see https://standardschema.dev).",
364
+ )
365
+ }
366
+
367
+ const standard = (schema as Record<PropertyKey, unknown>)["~standard"]
368
+ if (standard === null || typeof standard !== "object") {
369
+ throw new InvalidCheckConfigError(
370
+ checkId,
371
+ 'output.schema must have a "~standard" property (see https://standardschema.dev).',
372
+ )
373
+ }
374
+
375
+ const { version, vendor, validate } = standard as Record<string, unknown>
376
+ if (version !== 1) {
377
+ throw new InvalidCheckConfigError(checkId, 'output.schema["~standard"].version must be 1.')
378
+ }
379
+ if (typeof vendor !== "string") {
380
+ throw new InvalidCheckConfigError(
381
+ checkId,
382
+ 'output.schema["~standard"].vendor must be a string.',
383
+ )
384
+ }
385
+ if (typeof validate !== "function") {
386
+ throw new InvalidCheckConfigError(
387
+ checkId,
388
+ 'output.schema["~standard"].validate must be a function.',
389
+ )
390
+ }
337
391
  }
338
392
 
339
393
  /**
package/src/errors.ts CHANGED
@@ -121,6 +121,32 @@ export class ParserDependencyMissingError extends RepoContractError {
121
121
  // case a future optional format needs the same treatment.
122
122
  type OutputFormatForError = "yaml"
123
123
 
124
+ /**
125
+ * A check's `output.schema["~standard"].validate()` threw synchronously, or returned a `Promise`
126
+ * that rejected, instead of returning a `Result`. This is a bug in the consumer-supplied schema
127
+ * object, not malformed check output -- the same distinction `PolicyThrewError` below draws for a
128
+ * throwing policy: a schema *returning* failure `issues` becomes an ordinary
129
+ * `ParsedOutputFailure` (reported as data, exactly like a malformed-JSON/YAML parse failure), but
130
+ * a schema *throwing* means the validator itself is broken, so it propagates as a rejected
131
+ * `runRepoContract()` promise instead. The original thrown/rejected value is preserved verbatim
132
+ * via the native `Error` `cause` chain.
133
+ */
134
+ export class StandardSchemaValidateThrewError extends RepoContractError {
135
+ /** Always `"REPO_CONTRACT_STANDARD_SCHEMA_VALIDATE_THREW"`. */
136
+ readonly code = "REPO_CONTRACT_STANDARD_SCHEMA_VALIDATE_THREW"
137
+ /** The id of the check whose `output.schema` threw during validation. */
138
+ readonly checkId: string
139
+
140
+ constructor(checkId: string, cause: unknown) {
141
+ super(
142
+ `Check "${checkId}"'s output.schema["~standard"].validate() threw instead of returning a Result`,
143
+ { cause },
144
+ )
145
+ this.name = "StandardSchemaValidateThrewError"
146
+ this.checkId = checkId
147
+ }
148
+ }
149
+
124
150
  /**
125
151
  * A check's `policy` function threw synchronously, or returned a `Promise`
126
152
  * that later rejected. Both failure modes are wrapped identically. The
@@ -45,7 +45,12 @@ export async function buildEvidence(
45
45
  results.map(async ([checkId, check, raw]): Promise<ParsedCheckEntry> => {
46
46
  if (check.output === undefined) return [checkId, check, raw]
47
47
  try {
48
- const output = await parseOutput(check.output.format, raw.stdout, checkId)
48
+ const output = await parseOutput(
49
+ check.output.format,
50
+ raw.stdout,
51
+ checkId,
52
+ check.output.schema,
53
+ )
49
54
  return [checkId, check, { ...raw, output }]
50
55
  } catch (error) {
51
56
  thrown.push(error)
package/src/index.ts CHANGED
@@ -19,9 +19,12 @@ export {
19
19
  PolicyReadUnrequestedOutputError,
20
20
  PolicyThrewError,
21
21
  RepoContractError,
22
+ StandardSchemaValidateThrewError,
22
23
  UnknownCheckIdError,
23
24
  } from "./errors.js"
24
25
 
26
+ export type { StandardSchemaV1 } from "./standard-schema/types.js"
27
+
25
28
  export type {
26
29
  CheckDefinition,
27
30
  CheckDefinitionConfig,
@@ -0,0 +1,56 @@
1
+ import type { StandardSchemaV1 } from "../standard-schema/types.js"
2
+
3
+ /**
4
+ * Renders a failed `output.schema` validation's issues as a single, human-readable string for
5
+ * `ParsedOutputFailure.error` -- e.g. `Schema validation failed: foo.bar: Expected string;
6
+ * items[2].name: Required`. Kept as its own small module, independent of `parseOutput`, so its
7
+ * path-rendering logic (the only non-trivial part) can be unit-tested directly.
8
+ * @param issues - the failed `StandardSchemaV1.Result`'s `issues`.
9
+ * @returns the formatted error string.
10
+ */
11
+ export function formatSchemaIssues(issues: readonly StandardSchemaV1.Issue[]): string {
12
+ return `Schema validation failed: ${issues.map(formatIssue).join("; ")}`
13
+ }
14
+
15
+ /**
16
+ * Renders one issue as `path: message`, or just `message` when it has no path.
17
+ * @param issue - the issue to render.
18
+ * @returns the rendered issue.
19
+ */
20
+ function formatIssue(issue: StandardSchemaV1.Issue): string {
21
+ const path = formatPath(issue.path)
22
+ return path === "" ? issue.message : `${path}: ${issue.message}`
23
+ }
24
+
25
+ /**
26
+ * Renders an issue's `path` as a dotted/bracketed accessor string, e.g. `foo.bar` or
27
+ * `items[2].name`. Each segment is either a bare `PropertyKey` (`string | number | symbol` -- a
28
+ * `symbol` must never be interpolated directly into a template literal, which throws `TypeError:
29
+ * Cannot convert a Symbol value to a string`; always going through `String(key)` avoids that for
30
+ * every segment type uniformly) or a `{ key: PropertyKey }` object per the spec.
31
+ * @param path - the issue's raw `path`, if any.
32
+ * @returns the rendered path, or `""` for a pathless issue.
33
+ */
34
+ function formatPath(
35
+ path: readonly (PropertyKey | StandardSchemaV1.PathSegment)[] | undefined,
36
+ ): string {
37
+ // Equivalent mutant: mutating `path.length === 0` to `false` makes an empty (but defined) `path`
38
+ // fall through to `path.map(...).join("")` below instead of returning `""` here directly -- but
39
+ // mapping and joining an empty array always produces `""` regardless of the mapping function, so
40
+ // both branches are observably identical for every possible input; no test could ever distinguish
41
+ // them by `formatPath`'s return value.
42
+ // Stryker disable next-line ConditionalExpression -- equivalent mutant: `path.length === 0` falling through to `path.map(...).join("")` on an empty array also produces `""`, identical to this early return, for every possible input -- no test could ever distinguish them.
43
+ if (path === undefined || path.length === 0) return ""
44
+
45
+ return path
46
+ .map((segment) =>
47
+ // eslint-disable-next-line secure-coding/no-improper-type-validation -- `segment` is declared `PropertyKey | StandardSchemaV1.PathSegment` (never `null` or an array), so `typeof segment === "object"` can only match the `PathSegment` object form here; the null/array-safe rewrite this rule suggests is flagged as an unreachable condition by @typescript-eslint/no-unnecessary-condition given that exact type, so satisfying both rules at once is impossible -- the type itself is the guarantee this check would otherwise add at runtime.
48
+ typeof segment === "object" ? segment.key : segment,
49
+ )
50
+ .map((key, index) => {
51
+ if (typeof key === "number") return `[${String(key)}]`
52
+ const rendered = String(key)
53
+ return index === 0 ? rendered : `.${rendered}`
54
+ })
55
+ .join("")
56
+ }
@@ -1,19 +1,66 @@
1
+ import { StandardSchemaValidateThrewError } from "../errors.js"
2
+ import type { StandardSchemaV1 } from "../standard-schema/types.js"
1
3
  import type { OutputFormat, ParsedOutput } from "../types.js"
4
+ import { formatSchemaIssues } from "./format-schema-issues.js"
2
5
  import { parseJson } from "./parse-json.js"
3
6
  import { parseText } from "./parse-text.js"
4
7
  import { parseYaml } from "./parse-yaml.js"
5
8
 
6
9
  /**
7
- * Dispatches to the parser for `format`. Never throws for a malformed-output parse failure -- see each parser's own documentation. May throw `ParserDependencyMissingError` for `format: "yaml"` if the optional `yaml` peer dependency is unavailable.
10
+ * Dispatches to the parser for `format`, then -- only on a successful parse, and only if `schema`
11
+ * is supplied -- runs `schema["~standard"].validate()` against the parsed value
12
+ * (https://standardschema.dev). `parse-json.ts`/`parse-yaml.ts`/`parse-text.ts` stay entirely
13
+ * unaware of `schema`; this is the sole orchestration point. `validate()` may return its `Result`
14
+ * synchronously or as a `Promise` -- `await`ing either uniformly is a no-op for the synchronous
15
+ * case, so this doesn't need its own reason to be async on top of the one this function already
16
+ * has (the dynamic `import("yaml")` for `format: "yaml"`). A successful `Result` (`issues ===
17
+ * undefined`) *replaces* `value` with the schema's own (possibly transformed) output; a failing
18
+ * `Result` becomes a `ParsedOutputFailure` whose `error` is built by `formatSchemaIssues`.
19
+ * `schema.validate()` itself throwing or rejecting is a bug in the consumer-supplied schema, not
20
+ * malformed output -- it throws `StandardSchemaValidateThrewError` rather than becoming a
21
+ * `ParsedOutputFailure`. Never throws for a malformed-output parse failure -- see each parser's
22
+ * own documentation. May throw `ParserDependencyMissingError` for `format: "yaml"` if the
23
+ * optional `yaml` peer dependency is unavailable.
8
24
  * @param format - which parser to dispatch to.
9
25
  * @param stdout - the check's raw stdout to parse.
10
- * @param checkId - identifies which check's output is being parsed, used in the `ParserDependencyMissingError` thrown for a missing `yaml` dependency.
11
- * @returns the parsed output produced by the selected parser.
26
+ * @param checkId - identifies which check's output is being parsed, used in a thrown `ParserDependencyMissingError`/`StandardSchemaValidateThrewError`.
27
+ * @param schema - an optional Standard Schema-compliant validator to run against a successful parse's value.
28
+ * @returns the parsed (and, if `schema` was supplied and validation succeeded, schema-transformed) output.
12
29
  */
13
30
  export async function parseOutput(
14
31
  format: OutputFormat,
15
32
  stdout: string,
16
33
  checkId: string,
34
+ schema?: StandardSchemaV1,
35
+ ): Promise<ParsedOutput<unknown>> {
36
+ const parsed = await dispatch(format, stdout, checkId)
37
+ if (!parsed.success || schema === undefined) return parsed
38
+
39
+ let result: StandardSchemaV1.Result<unknown>
40
+ try {
41
+ result = await schema["~standard"].validate(parsed.value)
42
+ } catch (error) {
43
+ throw new StandardSchemaValidateThrewError(checkId, error)
44
+ }
45
+
46
+ if (result.issues === undefined) {
47
+ return { format, success: true, value: result.value }
48
+ }
49
+ return { format, success: false, error: formatSchemaIssues(result.issues) }
50
+ }
51
+
52
+ /**
53
+ * Dispatches to the parser for `format` -- the pre-existing behavior of `parseOutput`, extracted
54
+ * so `parseOutput` itself reads as: parse, then optionally validate.
55
+ * @param format - which parser to dispatch to.
56
+ * @param stdout - the check's raw stdout to parse.
57
+ * @param checkId - identifies which check's output is being parsed, used in a thrown `ParserDependencyMissingError`.
58
+ * @returns the parsed output produced by the selected parser.
59
+ */
60
+ async function dispatch(
61
+ format: OutputFormat,
62
+ stdout: string,
63
+ checkId: string,
17
64
  ): Promise<ParsedOutput<unknown>> {
18
65
  switch (format) {
19
66
  case "json":
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Hand-vendored from `@standard-schema/spec@1.1.0` (https://standardschema.dev), pinned
3
+ * 2026-09-04 -- see specs/decisions/0012-hand-vendored-standard-schema-support-for-optional-output-validation.md for why this is
4
+ * vendored rather than an installed dependency, and for the version-pin/re-diff process. Pure type
5
+ * declarations, zero runtime code -- assigning any real Zod/Valibot/ArkType (etc.) schema to this
6
+ * type costs nothing at runtime. Only `StandardSchemaV1` (validation) is vendored here -- the
7
+ * separate, optional `StandardJSONSchemaV1` (JSON Schema conversion) extension
8
+ * (https://standardschema.dev/json-schema) is out of scope; see the ADR.
9
+ *
10
+ * Upstream's `StandardSchemaV1.Props` actually extends a shared `StandardTypedV1.Props` base
11
+ * (`version`/`vendor`/`types`); this vendored copy inlines those fields directly into one flat
12
+ * interface, since repo-contract has no use for the shared base on its own -- structurally
13
+ * identical for any real schema object assigned to it. Re-diff against
14
+ * `@standard-schema/spec`'s published `dist/index.d.ts` if this file is ever touched.
15
+ */
16
+ export interface StandardSchemaV1<Input = unknown, Output = Input> {
17
+ /** The Standard Schema properties. */
18
+ readonly "~standard": StandardSchemaV1.Props<Input, Output>
19
+ }
20
+
21
+ /** Namespaced members of {@link StandardSchemaV1}: `Props`, `Result`, `SuccessResult`, `FailureResult`, `Issue`, `PathSegment`, `Types`, `InferInput`, `InferOutput`. */
22
+ // eslint-disable-next-line @typescript-eslint/no-namespace -- the interface+namespace declaration-merging pattern is required here to mirror @standard-schema/spec's own published shape (StandardSchemaV1.Props/.Result/.Issue/... nested under the interface's own name) -- an ES2015 module can't merge with an interface of the same name the way a namespace does, and diverging from upstream's exact shape would defeat this file's whole purpose of being a faithful, re-diffable vendor copy (see this file's own top comment).
23
+ export declare namespace StandardSchemaV1 {
24
+ /** The Standard Schema properties interface. */
25
+ export interface Props<Input = unknown, Output = Input> {
26
+ /** The version number of the standard. */
27
+ readonly version: 1
28
+ /** The vendor name of the schema library. */
29
+ readonly vendor: string
30
+ /** Validates unknown input values. */
31
+ readonly validate: (
32
+ value: unknown,
33
+ options?: Options,
34
+ ) => Result<Output> | Promise<Result<Output>>
35
+ /** Inferred types associated with the schema. */
36
+ readonly types?: Types<Input, Output> | undefined
37
+ }
38
+
39
+ /** Options passable to `validate`. */
40
+ export interface Options {
41
+ /** Explicit support for additional vendor-specific parameters, if needed. */
42
+ readonly libraryOptions?: Record<string, unknown> | undefined
43
+ }
44
+
45
+ /** The result interface of the validate function. */
46
+ export type Result<Output> = SuccessResult<Output> | FailureResult
47
+
48
+ /** The result interface if validation succeeds. */
49
+ export interface SuccessResult<Output> {
50
+ /** The typed output value. */
51
+ readonly value: Output
52
+ /** A falsy value for `issues` indicates success. */
53
+ readonly issues?: undefined
54
+ }
55
+
56
+ /** The result interface if validation fails. */
57
+ export interface FailureResult {
58
+ /** The issues of failed validation. */
59
+ readonly issues: readonly Issue[]
60
+ }
61
+
62
+ /** The issue interface of the failure output. */
63
+ export interface Issue {
64
+ /** The error message of the issue. */
65
+ readonly message: string
66
+ /** The path of the issue, if any. */
67
+ readonly path?: readonly (PropertyKey | PathSegment)[] | undefined
68
+ }
69
+
70
+ /** The path segment interface of the issue. */
71
+ export interface PathSegment {
72
+ /** The key representing a path segment. */
73
+ readonly key: PropertyKey
74
+ }
75
+
76
+ /** The Standard Schema types interface. */
77
+ export interface Types<Input = unknown, Output = Input> {
78
+ /** The input type of the schema. */
79
+ readonly input: Input
80
+ /** The output type of the schema. */
81
+ readonly output: Output
82
+ }
83
+
84
+ /** Infers the input type of a Standard Schema. */
85
+ export type InferInput<Schema extends StandardSchemaV1> = NonNullable<
86
+ Schema["~standard"]["types"]
87
+ >["input"]
88
+
89
+ /** Infers the output type of a Standard Schema. */
90
+ export type InferOutput<Schema extends StandardSchemaV1> = NonNullable<
91
+ Schema["~standard"]["types"]
92
+ >["output"]
93
+ }
package/src/types.ts CHANGED
@@ -12,6 +12,8 @@ import type {
12
12
  SpawnSyncReturns,
13
13
  } from "node:child_process"
14
14
 
15
+ import type { StandardSchemaV1 } from "./standard-schema/types.js"
16
+
15
17
  /** Output interpretation a check can explicitly request. No format requested means no parsing -- the consumer gets raw stdout/stderr only. */
16
18
  export type OutputFormat = "json" | "yaml" | "text"
17
19
 
@@ -66,16 +68,21 @@ export type ParsedOutput<T> = ParsedOutputSuccess<T> | ParsedOutputFailure
66
68
  * `undefined` -- a policy narrows with `ctx.result.output?.success` (or an
67
69
  * `if (ctx.result.output) { ... }` guard) before reading `.value`/`.error`.
68
70
  *
69
- * `output.value` is typed `unknown` for every format, including `"text"`
70
- * (even though `parseText` always produces a `string` at runtime) --
71
- * neither repo-contract nor TypeScript's generic inference can reliably
72
- * carry a specific check's own literal `output.format` through to that
73
- * same check's `policy` parameter once several checks with heterogeneous
74
- * formats live together in one `checks` record (a real TypeScript
75
- * inference limitation hit and confirmed during implementation, not a
76
- * hypothetical -- see specs/decisions/ for the isolated repro). A policy
77
- * author narrows or casts `.value` themselves, exactly as they already must
78
- * for `"json"`/`"yaml"` where no schema knowledge exists either way.
71
+ * `output.value` is the value produced by the configured `format` parser, optionally validated
72
+ * and (if a schema transforms/coerces it) replaced by `output.schema` (see
73
+ * `CheckDefinitionConfig.output.schema`) -- so it is not necessarily the raw parser result once a
74
+ * check's config supplies a schema. It is typed `unknown` for every format regardless, including
75
+ * `"text"` (even though `parseText` always produces a `string` at runtime) and regardless of
76
+ * whether a schema was supplied -- neither repo-contract nor TypeScript's generic inference can
77
+ * reliably carry a specific check's own literal `output.format` (or a specific `schema`'s own
78
+ * inferred output type) through to that same check's `policy` parameter once several checks with
79
+ * heterogeneous formats live together in one `checks` record (a real TypeScript inference
80
+ * limitation hit and confirmed during implementation, not a hypothetical -- see specs/decisions/
81
+ * for the isolated repro). This is a deliberate, acknowledged tradeoff, not a gap this package
82
+ * is attempting to close: a schema still gives its own author real compile-time input/output
83
+ * typing via `StandardSchemaV1<Input, Output>` for their own code, just not threaded through this
84
+ * shared `CheckEvidence` shape. A policy author narrows or casts `.value` themselves, exactly as
85
+ * they already must for `"json"`/`"yaml"` with no schema supplied.
79
86
  */
80
87
  export interface CheckEvidence {
81
88
  /** The executable that was actually spawned (after tokenization, if `run` was a string). */
@@ -102,7 +109,12 @@ export interface CheckEvidence {
102
109
  readonly spawnError?: string
103
110
  /** Populated only for `status === "spawn_error"` -- the underlying Node `ErrnoException`'s structured `.code` (e.g. `"ENOENT"`, `"EACCES"`), when Node provides one. Never populated for any other status. Distinguishes "the executable does not exist" from other spawn failures (permission denied, invalid executable format, etc.) without parsing `spawnError`'s free-text message. */
104
111
  readonly spawnErrorCode?: string
105
- /** The parsed interpretation of `stdout`, present only if this check's config requested a `format`. */
112
+ /**
113
+ * The parsed interpretation of `stdout`, present only if this check's config requested a
114
+ * `format`. If that config also supplied `output.schema`, a successful parse is additionally
115
+ * validated (and possibly transformed) by that schema before landing here -- see this
116
+ * interface's own doc comment above and `CheckDefinitionConfig.output.schema`.
117
+ */
106
118
  readonly output?: ParsedOutput<unknown>
107
119
  }
108
120
 
@@ -238,7 +250,31 @@ export interface CheckDefinitionConfig {
238
250
  /** Maximum time to let this check's process run before it is terminated and recorded with `status: "timed_out"`. No timeout by default. */
239
251
  readonly timeoutMs?: number
240
252
  /** Request that stdout be parsed as this format. Omit for no parsing -- the consumer gets raw stdout/stderr only. */
241
- readonly output?: { readonly format: OutputFormat }
253
+ readonly output?: {
254
+ readonly format: OutputFormat
255
+ /**
256
+ * An optional Standard Schema-compliant validator (Zod, Valibot, ArkType, or any other
257
+ * implementation of https://standardschema.dev), run once the requested `format` parse
258
+ * itself succeeds. repo-contract never imports a schema library itself --
259
+ * `StandardSchemaV1` is hand-vendored (see `src/standard-schema/types.ts`) purely as a
260
+ * type-level contract, so accepting any consumer's own schema object costs zero new
261
+ * runtime or dev dependencies (see
262
+ * specs/decisions/0012-hand-vendored-standard-schema-support-for-optional-output-validation.md). A successful
263
+ * `~standard.validate()` result *replaces* the parsed value on `CheckEvidence.output.value`
264
+ * (letting a schema transform, not just check, its input); a failing result becomes an
265
+ * ordinary `ParsedOutputFailure`, indistinguishable in shape from a malformed-JSON/YAML
266
+ * parse failure -- both are "this check's output isn't what was expected," reported as
267
+ * data, never a throw (see `parseOutput`). `validate()` itself throwing or rejecting is a
268
+ * different case -- a bug in the supplied schema, not malformed output -- and surfaces as
269
+ * `StandardSchemaValidateThrewError` instead (see that error's own doc comment). Like every
270
+ * other format's `value`, `schema`'s inferred output type is *not* threaded to
271
+ * `CheckEvidence.output.value`'s declared type -- it stays `unknown`, for the same
272
+ * heterogeneous-checks-in-one-record TypeScript inference limitation already documented on
273
+ * `CheckEvidence` above; a policy still narrows or casts `.value` itself, now
274
+ * shaped/transformed by `schema` at runtime even though the type alone doesn't say so.
275
+ */
276
+ readonly schema?: StandardSchemaV1
277
+ }
242
278
  /**
243
279
  * A full scheduling barrier at this check's own position in the `checks` object: it does not
244
280
  * spawn until every check declared *earlier* has reached a terminal status (nothing "currently in