@ultimat3/schema 29.4.0 → 30.1.0

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.
@@ -0,0 +1,118 @@
1
+ /**
2
+ * The choices a sample walk delegates, and the minimal answer to each. One walker (`sample.ts`)
3
+ * takes a `Draw`, so the contract step's generated inputs and the policy assertion's single sample
4
+ * share every rule about what a node accepts; only the choices differ.
5
+ */
6
+
7
+ import type { SchemaFormat, SchemaNode } from './node';
8
+
9
+ /** Every choice a walk makes that its node leaves open. Answers stay inside the node's own bounds. */
10
+ export interface Draw {
11
+ /** An unformatted string within `minLength`/`maxLength` (formats are `BY_FORMAT`'s, never drawn). */
12
+ string(node: SchemaNode): string;
13
+ /** A number within `minimum`/`maximum`, whole when `integer`. */
14
+ number(node: SchemaNode): number;
15
+ boolean(): boolean;
16
+ /** Whether an optional or defaulted key is sent at all. */
17
+ include(key: string): boolean;
18
+ /** How many items an array sample holds, never below what `minItems` owes. */
19
+ items(node: SchemaNode): number;
20
+ /** One of an enum's values or a union's members; `undefined` only for an empty list. */
21
+ member<T>(choices: readonly T[]): T | undefined;
22
+ /** Whether a nullable node answers `null`. */
23
+ nullable(node: SchemaNode): boolean;
24
+ }
25
+
26
+ /** Version 4, variant 1 — the shape `t.uuid` insists on, and no byte of it means anything. */
27
+ const SAMPLE_UUID = '00000000-0000-4000-8000-000000000000';
28
+
29
+ /** Short, and valid under `t.slug`'s and `t.cursor`'s patterns as well as bare `t.string`. */
30
+ export const SAMPLE_STRING = 'sample';
31
+
32
+ /**
33
+ * One entry per `SchemaFormat`, exhaustively — a new format is a type error here rather than a
34
+ * silent `'sample'` that fails its own validator. The ONE table both draws read: a mutated uuid
35
+ * tests nothing but the validator, so a seeded draw never varies a format value.
36
+ */
37
+ export const BY_FORMAT: Readonly<Record<SchemaFormat, string>> = {
38
+ uuid: SAMPLE_UUID,
39
+ email: 'sample@example.test',
40
+ uri: 'https://example.test/sample',
41
+ 'date-time': '2020-01-01T00:00:00.000Z',
42
+ slug: SAMPLE_STRING,
43
+ timezone: 'UTC',
44
+ locale: 'en',
45
+ cursor: SAMPLE_STRING,
46
+ };
47
+
48
+ /**
49
+ * The value a formatted string node gets, or `undefined` for an unformatted one.
50
+ * `Object.hasOwn`, never the read alone: `node.format` comes from a provider's IR, and
51
+ * `BY_FORMAT['constructor']` is the `Object` function off the prototype chain.
52
+ */
53
+ export function formatValue(node: SchemaNode): string | undefined {
54
+ if (node.format === undefined) return undefined;
55
+ return Object.hasOwn(BY_FORMAT, node.format) ? BY_FORMAT[node.format] : SAMPLE_STRING;
56
+ }
57
+
58
+ /**
59
+ * The most items a sample will hold. `minItems` past this is an input the framework does not
60
+ * invent — `2 ** 32` made `Array.from` throw. Such an array is a GAP (`sampleGaps`), reported as
61
+ * `X_CONTRACT_DRIFT` with the instruction to pass the input, never silently under-sampled.
62
+ */
63
+ export const SAMPLE_ITEMS_MAX = 1000;
64
+
65
+ /**
66
+ * The `minItems` an array sample owes, or `undefined` when none can be built: past the ceiling,
67
+ * or not a count at all. The builtin `t.array` refuses a bad bound at declaration, but this IR
68
+ * may come from a swapped provider.
69
+ */
70
+ export function owedItems(node: SchemaNode): number | undefined {
71
+ const min = node.minItems;
72
+ if (min === undefined) return 0;
73
+ return Number.isSafeInteger(min) && min >= 0 && min <= SAMPLE_ITEMS_MAX ? min : undefined;
74
+ }
75
+
76
+ /** `'sample'`, padded to `minLength` and cut to `maxLength`. */
77
+ export function minimalString(node: SchemaNode): string {
78
+ const min = node.minLength ?? 0;
79
+ const padded = SAMPLE_STRING.length >= min ? SAMPLE_STRING : SAMPLE_STRING.padEnd(min, 'x');
80
+ return node.maxLength === undefined ? padded : padded.slice(0, node.maxLength);
81
+ }
82
+
83
+ /** The lower bound, rounded inward for an integer, or the upper one when the lower overshoots. */
84
+ export function minimalNumber(node: SchemaNode): number {
85
+ const low = node.minimum ?? 0;
86
+ const value = node.integer === true ? Math.ceil(low) : low;
87
+ if (node.maximum === undefined || value <= node.maximum) return value;
88
+ return node.integer === true ? Math.floor(node.maximum) : node.maximum;
89
+ }
90
+
91
+ /**
92
+ * Whether `value` satisfies the node's own `pattern`. An uncompilable pattern is a gap, not a
93
+ * throw: only a foreign provider's IR can produce one, and a generated contract test must not die
94
+ * on the way to reporting what it needs.
95
+ */
96
+ export function satisfiesPattern(node: SchemaNode, value: unknown): boolean {
97
+ if (node.pattern === undefined) return true;
98
+ if (typeof value !== 'string') return false;
99
+ try {
100
+ return new RegExp(node.pattern, node.patternFlags ?? '').test(value);
101
+ } catch {
102
+ return false;
103
+ }
104
+ }
105
+
106
+ /**
107
+ * The smallest value each choice allows: `null` where nullable, no optional key, the fewest
108
+ * items, the first member. The policy assertion's sample, byte for byte what it always was.
109
+ */
110
+ export const minimalDraw: Draw = Object.freeze({
111
+ string: minimalString,
112
+ number: minimalNumber,
113
+ boolean: () => false,
114
+ include: () => false,
115
+ items: (node: SchemaNode) => owedItems(node) ?? 0,
116
+ member: <T>(choices: readonly T[]): T | undefined => choices[0],
117
+ nullable: () => true,
118
+ });
@@ -0,0 +1,147 @@
1
+ /**
2
+ * `seededDraw(seed, run)`: the generated inputs the contract step's exercise assertion sends.
3
+ * A number in, the same values out — never `Math.random`, never the clock — so a red run replays
4
+ * from the test's name alone. Runs 0-3 sit on the IR's bounds; later runs draw inside them.
5
+ */
6
+
7
+ import type { SchemaNode } from './node';
8
+ import type { Draw } from './sample-draw';
9
+ import {
10
+ minimalNumber,
11
+ minimalString,
12
+ owedItems,
13
+ SAMPLE_ITEMS_MAX,
14
+ satisfiesPattern,
15
+ } from './sample-draw';
16
+
17
+ /** Runs below this one are boundaries, not draws. */
18
+ export const BOUNDARY_RUNS = 4;
19
+
20
+ /**
21
+ * Multibyte on purpose: the text a handler mishandles is the text a test never typed. Every entry
22
+ * is one code point, the unit `minLength`/`maxLength` count in (`char-count.ts`), and none is a
23
+ * control character or a lone surrogate, which a validator or a database refuses on its own.
24
+ */
25
+ const ALPHABET = [
26
+ 'a',
27
+ 'z',
28
+ 'Q',
29
+ '0',
30
+ '7',
31
+ ' ',
32
+ '-',
33
+ '_',
34
+ 'é',
35
+ 'ß',
36
+ 'ж',
37
+ 'ع',
38
+ '漢',
39
+ '字',
40
+ '🙂',
41
+ '👍',
42
+ ];
43
+
44
+ /** An unbounded string's length is this past its floor; a bound past the cap is clamped to it. */
45
+ const STRING_SPAN = 24;
46
+ const STRING_CAP = 4096;
47
+
48
+ /** An unbounded number's range, both directions. A whole number stays a 32-bit one. */
49
+ const NUMBER_SPAN = 1_000_000;
50
+ const INTEGER_HIGH = 2 ** 31 - 1;
51
+
52
+ /** An array draws at most this many items past what `minItems` owes. */
53
+ const ITEMS_SPAN = 4;
54
+
55
+ /** mulberry32: 32 bits of state, a uniform float in [0, 1) per call. */
56
+ function generator(state: number): () => number {
57
+ let s = state >>> 0;
58
+ return () => {
59
+ s = (s + 0x6d2b79f5) >>> 0;
60
+ let x = s;
61
+ x = Math.imul(x ^ (x >>> 15), x | 1);
62
+ x ^= x + Math.imul(x ^ (x >>> 7), x | 61);
63
+ return ((x ^ (x >>> 14)) >>> 0) / 4_294_967_296;
64
+ };
65
+ }
66
+
67
+ /** Which side of every bound a boundary run takes, and whether it reaches for the optional parts. */
68
+ interface Boundary {
69
+ readonly high: boolean;
70
+ readonly optional: boolean;
71
+ readonly nulls: boolean;
72
+ }
73
+
74
+ const BOUNDARIES: readonly Boundary[] = [
75
+ { high: false, optional: false, nulls: false },
76
+ { high: true, optional: true, nulls: false },
77
+ { high: false, optional: true, nulls: true },
78
+ { high: true, optional: false, nulls: false },
79
+ ];
80
+
81
+ const whole = (n: number, integer: boolean, up: boolean): number =>
82
+ integer ? (up ? Math.ceil(n) : Math.floor(n)) : n;
83
+
84
+ function numberRange(node: SchemaNode): readonly [number, number] {
85
+ const integer = node.integer === true;
86
+ const low = whole(node.minimum ?? (integer ? -INTEGER_HIGH : -NUMBER_SPAN), integer, true);
87
+ const high = whole(node.maximum ?? (integer ? INTEGER_HIGH : NUMBER_SPAN), integer, false);
88
+ return [low, high];
89
+ }
90
+
91
+ function lengthRange(node: SchemaNode): readonly [number, number] {
92
+ const low = Math.min(node.minLength ?? 0, STRING_CAP);
93
+ const high = Math.min(node.maxLength ?? low + STRING_SPAN, STRING_CAP);
94
+ return [low, Math.max(low, high)];
95
+ }
96
+
97
+ /**
98
+ * The draw for one run of one seed. `seed` is the caller's: `@ultimat3/action` and
99
+ * `@ultimat3/query` hand it `fnv1a(<name>)`, which this tier cannot import.
100
+ */
101
+ export function seededDraw(seed: number, run: number): Draw {
102
+ const next = generator((seed ^ Math.imul(run + 1, 0x9e3779b1)) >>> 0);
103
+ const between = (low: number, high: number): number =>
104
+ low + Math.floor(next() * (high - low + 1));
105
+ const edge = run >= 0 && run < BOUNDARY_RUNS ? BOUNDARIES[run] : undefined;
106
+
107
+ const text = (node: SchemaNode): string => {
108
+ const [low, high] = lengthRange(node);
109
+ const length = edge === undefined ? between(low, high) : edge.high ? high : low;
110
+ const chars = Array.from({ length }, () => ALPHABET[between(0, ALPHABET.length - 1)] ?? 'a');
111
+ const value = chars.join('');
112
+ // A regex cannot be inverted: a drawn value that misses it would test only the validator, so
113
+ // the one value the walk can build for it stands in (and `sampleGaps` reports when it cannot).
114
+ return satisfiesPattern(node, value) ? value : minimalString(node);
115
+ };
116
+
117
+ const number = (node: SchemaNode): number => {
118
+ const [low, high] = numberRange(node);
119
+ if (low > high) return minimalNumber(node);
120
+ if (edge !== undefined) return edge.high ? high : low;
121
+ if (node.integer === true) return between(low, high);
122
+ // Two decimals, clamped: a float draw must not step past the bound it was drawn under.
123
+ const drawn = Math.round((low + next() * (high - low)) * 100) / 100;
124
+ return Math.min(high, Math.max(low, drawn));
125
+ };
126
+
127
+ return {
128
+ string: text,
129
+ number,
130
+ boolean: () => (edge === undefined ? next() < 0.5 : edge.high),
131
+ include: () => (edge === undefined ? next() < 0.5 : edge.optional),
132
+ items: (node) => {
133
+ const owed = owedItems(node) ?? 0;
134
+ const cap = Math.max(
135
+ owed,
136
+ Math.min(node.maxItems ?? owed + ITEMS_SPAN, owed + ITEMS_SPAN * 2, SAMPLE_ITEMS_MAX),
137
+ );
138
+ if (edge !== undefined) return edge.high ? cap : owed;
139
+ return between(owed, Math.min(cap, owed + ITEMS_SPAN));
140
+ },
141
+ member: <T>(choices: readonly T[]): T | undefined =>
142
+ choices.length === 0
143
+ ? undefined
144
+ : choices[edge === undefined ? between(0, choices.length - 1) : run % choices.length],
145
+ nullable: () => (edge === undefined ? next() < 0.25 : edge.nulls),
146
+ };
147
+ }
package/src/sample.ts ADDED
@@ -0,0 +1,161 @@
1
+ /**
2
+ * `@ultimat3/schema/sample`, off the barrel: a value an input schema accepts, derived from that
3
+ * schema's own IR. One walker, parameterised by a `Draw` — `minimalDraw` is the policy assertion's
4
+ * single sample, `seededDraw` the contract step's generated inputs. Never in `t`'s bundle graph.
5
+ */
6
+
7
+ import type { SchemaNode } from './node';
8
+ import { requiredKeys } from './node';
9
+ import { tryIntrospect } from './provider';
10
+ import type { Draw } from './sample-draw';
11
+ import {
12
+ BY_FORMAT,
13
+ formatValue,
14
+ minimalDraw,
15
+ owedItems,
16
+ SAMPLE_STRING,
17
+ satisfiesPattern,
18
+ } from './sample-draw';
19
+ import { seededDraw } from './sample-seeded';
20
+ import type { StandardSchemaV1 } from './standard';
21
+
22
+ export type { Draw } from './sample-draw';
23
+ export { minimalDraw, SAMPLE_ITEMS_MAX } from './sample-draw';
24
+ export { BOUNDARY_RUNS, seededDraw } from './sample-seeded';
25
+
26
+ /**
27
+ * `Object.create(null)`, the shape the object parser already builds: on a `{}` literal
28
+ * `sample['__proto__'] = value` reaches `Object.prototype`'s SETTER, so a required field named
29
+ * `__proto__` replaced the sample's prototype and the payload failed the schema it came from.
30
+ * Required keys always; an optional or defaulted one only when the draw includes it.
31
+ */
32
+ function sampleObject(node: SchemaNode, draw: Draw): Record<string, unknown> {
33
+ const properties = node.properties ?? {};
34
+ const required = new Set(requiredKeys(node));
35
+ const sample: Record<string, unknown> = Object.create(null) as Record<string, unknown>;
36
+ for (const key of Object.keys(properties)) {
37
+ const child = Object.hasOwn(properties, key) ? properties[key] : undefined;
38
+ if (child === undefined) continue;
39
+ if (required.has(key) || draw.include(key)) sample[key] = sampleFor(child, draw);
40
+ }
41
+ return sample;
42
+ }
43
+
44
+ function sampleString(node: SchemaNode, draw: Draw): string {
45
+ // A format value is already the exact shape its validator wants; padding or drawing it would
46
+ // break the only thing that makes it valid.
47
+ return formatValue(node) ?? draw.string(node);
48
+ }
49
+
50
+ /** The walk itself. Every choice the node leaves open is the draw's. */
51
+ function sampleFor(node: SchemaNode, draw: Draw): unknown {
52
+ if (node.nullable === true && draw.nullable(node)) return null;
53
+ switch (node.kind) {
54
+ case 'string':
55
+ return sampleString(node, draw);
56
+ case 'number':
57
+ return draw.number(node);
58
+ case 'boolean':
59
+ return draw.boolean();
60
+ case 'date':
61
+ return BY_FORMAT['date-time'];
62
+ case 'enum':
63
+ return draw.member(node.values ?? []) ?? SAMPLE_STRING;
64
+ case 'literal':
65
+ return node.literal ?? null;
66
+ // Items are walked one by one, so a seeded draw varies them; the minimal one repeats.
67
+ case 'array':
68
+ return Array.from({ length: draw.items(node) }, () =>
69
+ node.items === undefined ? null : sampleFor(node.items, draw),
70
+ );
71
+ case 'union': {
72
+ const member = draw.member(node.anyOf ?? []);
73
+ return member === undefined ? null : sampleFor(member, draw);
74
+ }
75
+ case 'record':
76
+ return {};
77
+ case 'money':
78
+ return {
79
+ minor: draw.number({ kind: 'number', integer: true, minimum: 0, maximum: 1_000_000 }),
80
+ currency: 'USD',
81
+ };
82
+ case 'object':
83
+ return sampleObject(node, draw);
84
+ default:
85
+ // `unknown`, `json`, and any kind a third-party provider emits that this build has never
86
+ // heard of. Not `assertNever`: a swapped provider must not turn a contract test into a crash.
87
+ return null;
88
+ }
89
+ }
90
+
91
+ /**
92
+ * The minimal value `schema` accepts — best effort, and honest about it: a provider refinement the
93
+ * IR does not carry yields a value the schema rejects, which the caller reports as
94
+ * `X_CONTRACT_DRIFT`. The one constraint the IR DOES carry is `pattern`, and `sampleGaps` reads it.
95
+ */
96
+ export function sampleInput(schema: StandardSchemaV1): unknown {
97
+ const node = tryIntrospect(schema);
98
+ return node === undefined ? {} : sampleFor(node, minimalDraw);
99
+ }
100
+
101
+ /** Where in the replayable sequence a value comes from: the caller's seed and the run index. */
102
+ export interface ArbitraryOptions {
103
+ readonly seed: number;
104
+ readonly run: number;
105
+ }
106
+
107
+ /** A generated value under `schema`'s IR: the same `{ seed, run }`, the same value, every time. */
108
+ export function arbitrary(schema: StandardSchemaV1, options: ArbitraryOptions): unknown {
109
+ const node = tryIntrospect(schema);
110
+ return node === undefined ? {} : sampleFor(node, seededDraw(options.seed, options.run));
111
+ }
112
+
113
+ /** The root's name in a gap path — a bare `t.string` input has no field to point at. */
114
+ const ROOT_PATH = '(the input)';
115
+
116
+ /**
117
+ * Dotted paths of every sampled field whose own `pattern` the minimal value cannot satisfy — what
118
+ * this schema needs that the framework cannot invent. It walks the SAMPLED shape, so an optional
119
+ * key, which the minimal sample omits, owes nothing.
120
+ */
121
+ export function sampleGaps(schema: StandardSchemaV1): readonly string[] {
122
+ const node = tryIntrospect(schema);
123
+ return node === undefined ? [] : gapsIn(node, '');
124
+ }
125
+
126
+ function gapsIn(node: SchemaNode, path: string): string[] {
127
+ if (node.nullable === true) return [];
128
+ if (node.kind === 'object') {
129
+ const properties = node.properties ?? {};
130
+ const out: string[] = [];
131
+ for (const key of requiredKeys(node)) {
132
+ const child = Object.hasOwn(properties, key) ? properties[key] : undefined;
133
+ if (child !== undefined) out.push(...gapsIn(child, path === '' ? key : `${path}.${key}`));
134
+ }
135
+ return out;
136
+ }
137
+ const unbuildable = node.kind === 'array' && owedItems(node) === undefined;
138
+ if (!unbuildable && satisfiesPattern(node, sampleFor(node, minimalDraw))) return [];
139
+ return [path === '' ? ROOT_PATH : path];
140
+ }
141
+
142
+ /** `orderRef` -> `orderRef (must match ^ORD-\d{4}$)`, for a cause a reader can act on. */
143
+ export function describeSampleGap(schema: StandardSchemaV1, path: string): string {
144
+ const node = nodeAt(tryIntrospect(schema), path);
145
+ if (node?.kind === 'array' && node.minItems !== undefined && owedItems(node) === undefined) {
146
+ return `${path} (needs at least ${node.minItems} items)`;
147
+ }
148
+ const pattern = node?.pattern;
149
+ return pattern === undefined ? path : `${path} (must match ${pattern})`;
150
+ }
151
+
152
+ function nodeAt(node: SchemaNode | undefined, path: string): SchemaNode | undefined {
153
+ if (node === undefined) return undefined;
154
+ if (path === '' || path === ROOT_PATH) return node;
155
+ const [head, ...rest] = path.split('.');
156
+ // `path` is the caller's, so the own-property read the format table takes.
157
+ const properties = node.properties ?? {};
158
+ const child =
159
+ head === undefined || !Object.hasOwn(properties, head) ? undefined : properties[head];
160
+ return child === undefined ? undefined : nodeAt(child, rest.join('.'));
161
+ }
@@ -0,0 +1,65 @@
1
+ // Single responsibility: the closed vocabulary an issue's `rule` names, and the one reader that
2
+ // lifts `rule`/`params` off an issue nobody in this process built. A translator keys on these, so
3
+ // the values are contract like an `X_*` code: add one, never rename one.
4
+
5
+ /** Every rule a builtin validator refuses with, in declaration order. */
6
+ export const SCHEMA_RULES = /* @__PURE__ */ Object.freeze([
7
+ 'required',
8
+ 'type',
9
+ 'string.min',
10
+ 'string.max',
11
+ 'string.pattern',
12
+ 'string.format',
13
+ 'number.min',
14
+ 'number.max',
15
+ 'number.integer',
16
+ 'enum',
17
+ 'literal',
18
+ 'array.min',
19
+ 'array.max',
20
+ 'union',
21
+ 'record.key',
22
+ 'json',
23
+ 'money',
24
+ 'refine',
25
+ ] as const);
26
+
27
+ export type SchemaRule = (typeof SCHEMA_RULES)[number];
28
+
29
+ /**
30
+ * What a translator interpolates: `{ min: 3 }`, `{ format: 'uuid' }`. Scalars only, and only facts
31
+ * the schema author declared — never the rejected value, for `describe-value.ts`'s reason.
32
+ */
33
+ export type SchemaIssueParams = Readonly<Record<string, string | number>>;
34
+
35
+ /** The two members, as they ride beside `message` and `path`. Both absent for a foreign issue. */
36
+ export interface IssueRuleFacts {
37
+ readonly rule?: string;
38
+ readonly params?: SchemaIssueParams;
39
+ }
40
+
41
+ const isScalar = (value: unknown): boolean =>
42
+ typeof value === 'string' || (typeof value === 'number' && Number.isFinite(value));
43
+
44
+ /**
45
+ * `rule` and `params` off an issue, member by member, never spread: the issue may be a foreign
46
+ * library's or a wire document's. A non-string `rule` is dropped, and `params` travels only beside
47
+ * a `rule` and only when EVERY value is a scalar — a partly-kept `params` is a sentence rendered
48
+ * with a hole in it. Total: a getter that throws is an issue with neither. Small on purpose: it is
49
+ * in every browser bundle that decodes a refusal.
50
+ */
51
+ export function issueRuleFacts(issue: unknown): IssueRuleFacts {
52
+ try {
53
+ const { rule, params } = issue as { readonly rule?: unknown; readonly params?: unknown };
54
+ if (typeof rule !== 'string' || rule === '') return {};
55
+ // Object spread defines own properties, so a `__proto__` key stays a key.
56
+ return typeof params === 'object' &&
57
+ params !== null &&
58
+ !Array.isArray(params) &&
59
+ Object.values(params).every(isScalar)
60
+ ? { rule, params: { ...(params as SchemaIssueParams) } }
61
+ : { rule };
62
+ } catch {
63
+ return {};
64
+ }
65
+ }
@@ -0,0 +1,137 @@
1
+ // Single responsibility: the two readers of `.sensitive()` — the mask a walker that holds a schema
2
+ // applies before writing a value down, and the refusal of a flag reachable from an output. Its own
3
+ // file so `t`, which is in every bundle graph, carries neither.
4
+
5
+ import { SensitiveOutputError } from './errors';
6
+ import { isSchemaNode, type SchemaNode } from './node';
7
+ import { tryIntrospect } from './provider';
8
+
9
+ /** Whether anything under a node is flagged. Memoised per node: an unflagged action pays one read. */
10
+ const flagged = new WeakMap<SchemaNode, boolean>();
11
+
12
+ function children(node: SchemaNode): readonly SchemaNode[] {
13
+ return [
14
+ ...Object.values(node.properties ?? {}),
15
+ ...(node.items === undefined ? [] : [node.items]),
16
+ ...(node.valueNode === undefined ? [] : [node.valueNode]),
17
+ ...(node.anyOf ?? []),
18
+ ];
19
+ }
20
+
21
+ function holdsSensitive(node: SchemaNode): boolean {
22
+ const known = flagged.get(node);
23
+ if (known !== undefined) return known;
24
+ // Seeded false before the walk, so a node graph that reaches itself terminates.
25
+ flagged.set(node, false);
26
+ const answer = node.sensitive === true || children(node).some(holdsSensitive);
27
+ flagged.set(node, answer);
28
+ return answer;
29
+ }
30
+
31
+ function nodeFor(schema: unknown): SchemaNode | undefined {
32
+ if (isSchemaNode(schema)) return schema;
33
+ try {
34
+ return tryIntrospect(schema);
35
+ } catch {
36
+ return undefined;
37
+ }
38
+ }
39
+
40
+ function isRecord(value: unknown): value is Record<string, unknown> {
41
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
42
+ }
43
+
44
+ /** A shallow copy with the masked keys replaced — `defineProperty`, so `__proto__` stays a key. */
45
+ function withKeys(value: Record<string, unknown>, masked: ReadonlyMap<string, unknown>) {
46
+ const out: Record<string, unknown> = {};
47
+ for (const [name, held] of Object.entries(value)) {
48
+ Object.defineProperty(out, name, {
49
+ value: masked.has(name) ? masked.get(name) : held,
50
+ enumerable: true,
51
+ writable: true,
52
+ configurable: true,
53
+ });
54
+ }
55
+ return out;
56
+ }
57
+
58
+ function maskRecord(node: SchemaNode, value: Record<string, unknown>, replacement: string) {
59
+ const masked = new Map<string, unknown>();
60
+ for (const [key, child] of Object.entries(node.properties ?? {})) {
61
+ if (holdsSensitive(child) && Object.hasOwn(value, key)) {
62
+ masked.set(key, mask(child, value[key], replacement));
63
+ }
64
+ }
65
+ const entries = node.valueNode;
66
+ if (entries !== undefined && holdsSensitive(entries)) {
67
+ for (const [key, entry] of Object.entries(value)) {
68
+ masked.set(key, mask(entries, masked.has(key) ? masked.get(key) : entry, replacement));
69
+ }
70
+ }
71
+ return masked.size === 0 ? value : withKeys(value, masked);
72
+ }
73
+
74
+ function mask(node: SchemaNode, value: unknown, replacement: string): unknown {
75
+ if (!holdsSensitive(node) || value === undefined) return value;
76
+ if (node.sensitive === true) return replacement;
77
+ let out: unknown = value;
78
+ if (isRecord(out)) out = maskRecord(node, out, replacement);
79
+ const items = node.items;
80
+ if (items !== undefined && Array.isArray(out)) {
81
+ out = out.map((entry: unknown) => mask(items, entry, replacement));
82
+ }
83
+ // Every branch's mask, in turn, and no branch resolution: a value the walker misreads as the
84
+ // wrong branch must still lose the keys the right one flags. Fail closed.
85
+ for (const branch of node.anyOf ?? []) out = mask(branch, out, replacement);
86
+ return out;
87
+ }
88
+
89
+ /**
90
+ * `value` with every `.sensitive()` field `schema` reaches replaced by `replacement` — a copy,
91
+ * never an edit. The same reference back when nothing is flagged (or nothing can describe the
92
+ * schema), so the walk costs an unflagged action one map read.
93
+ *
94
+ * Reach: what the framework writes down from a value it holds WITH its schema — the audit record,
95
+ * `x jobs show`. Not an app's own log line: that carries no schema, and stays `redactKeys`.
96
+ */
97
+ export function maskSensitive(schema: unknown, value: unknown, replacement: string): unknown {
98
+ const node = nodeFor(schema);
99
+ return node === undefined ? value : mask(node, value, replacement);
100
+ }
101
+
102
+ function sensitivePath(node: SchemaNode, at: string): string | undefined {
103
+ if (!holdsSensitive(node)) return undefined;
104
+ if (node.sensitive === true) return at;
105
+ for (const [key, child] of Object.entries(node.properties ?? {})) {
106
+ const found = sensitivePath(child, at === '' ? key : `${at}.${key}`);
107
+ if (found !== undefined) return found;
108
+ }
109
+ const nested = [
110
+ ...(node.items === undefined ? [] : [[node.items, `${at}[]`] as const]),
111
+ ...(node.valueNode === undefined ? [] : [[node.valueNode, `${at}{}`] as const]),
112
+ ...(node.anyOf ?? []).map((branch) => [branch, at] as const),
113
+ ];
114
+ for (const [child, path] of nested) {
115
+ const found = sensitivePath(child, path);
116
+ if (found !== undefined) return found;
117
+ }
118
+ return undefined;
119
+ }
120
+
121
+ /**
122
+ * Refuse a `.sensitive()` field reachable from an output — an action's `output`, a query's
123
+ * `rows`. An output leaves the server, which `writeOnly` forbids. A schema nothing can describe is
124
+ * passed: the check reads the IR, and a provider without one has no flag to find.
125
+ */
126
+ export function assertNoSensitiveOutput(owner: string, schema: unknown): void {
127
+ const node = nodeFor(schema);
128
+ if (node === undefined) return;
129
+ const path = sensitivePath(node, '');
130
+ if (path === undefined) return;
131
+ const at = path === '' ? '(the root)' : path;
132
+ throw new SensitiveOutputError({
133
+ cause: `${owner} reaches the .sensitive() field ${at}, and an output is sent back to the caller`,
134
+ fix: `x verify # after deleting .sensitive() at ${at} in ${owner}, or dropping that field from the output`,
135
+ meta: { owner, path: at },
136
+ });
137
+ }
package/src/standard.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  // Zod and Valibot are all first-class without a line of adapter code.
4
4
 
5
5
  import { SchemaUnsupportedError, ValidationFailedError, type ValidationIssue } from './errors';
6
+ import { issueRuleFacts, type SchemaIssueParams } from './schema-rules';
6
7
 
7
8
  export interface StandardSchemaV1<Input = unknown, Output = Input> {
8
9
  readonly '~standard': StandardSchemaProps<Input, Output>;
@@ -39,6 +40,13 @@ export interface StandardPathSegment {
39
40
  export interface StandardIssue {
40
41
  readonly message: string;
41
42
  readonly path?: readonly (PropertyKey | StandardPathSegment)[] | undefined;
43
+ /**
44
+ * Ultimate's extension: which rule refused, from `SCHEMA_RULES` (`string.min`). Optional, since
45
+ * a conforming library has none — a translator keys on it, never on the English `message`.
46
+ */
47
+ readonly rule?: string | undefined;
48
+ /** The declared facts the rule names (`{ min: 3 }`), for the translator to interpolate. */
49
+ readonly params?: SchemaIssueParams | undefined;
42
50
  }
43
51
 
44
52
  export type InferInput<S extends StandardSchemaV1> = NonNullable<S['~standard']['types']>['input'];
@@ -93,6 +101,7 @@ export function toValidationIssues(issues: readonly StandardIssue[]): readonly V
93
101
  expected: issue.message,
94
102
  received: '',
95
103
  message: issue.message,
104
+ ...issueRuleFacts(issue),
96
105
  }));
97
106
  }
98
107