@loomcli/validators 0.0.0 → 0.5.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Drew Butler
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,27 @@
1
+ /** Inclusive bounds on a numeric factory, either or both absent. */
2
+ interface Bounds {
3
+ min: number | undefined;
4
+ max: number | undefined;
5
+ }
6
+ /** What a numeric factory requires of each bound, stated for its fault messages. */
7
+ interface BoundRule {
8
+ factory: string;
9
+ accepts: (value: number) => boolean;
10
+ requirement: string;
11
+ correction: string;
12
+ }
13
+ /** Reads a numeric factory's `min` and `max`, and faults on a bad bound or `min` above `max`. */
14
+ declare function readBounds(options: unknown, rule: BoundRule): Bounds;
15
+ declare function withinBounds(value: number, { max, min }: Bounds): boolean;
16
+ /** The number a numeric token outputs, with `-0` read as `0`. */
17
+ declare function withoutNegativeZero(value: number): number;
18
+ /** The one sentence for a numeric configuration, each bound printed as JavaScript prints it. */
19
+ declare function boundsSentence(noun: string, { max, min }: Bounds): string;
20
+ /** The published schema for a numeric type, with each declared bound. */
21
+ declare function boundsSchema(type: 'integer' | 'number', { max, min }: Bounds): {
22
+ type: "integer" | "number";
23
+ minimum?: number;
24
+ maximum?: number;
25
+ };
26
+ export { boundsSchema, boundsSentence, readBounds, withinBounds, withoutNegativeZero };
27
+ export type { Bounds };
package/dist/bounds.js ADDED
@@ -0,0 +1,50 @@
1
+ import { fault, readOptions } from './faults.js';
2
+ const zero = 0;
3
+ function boundOf(rule, name, value) {
4
+ if (value === undefined) {
5
+ return undefined;
6
+ }
7
+ if (typeof value !== 'number' || !rule.accepts(value)) {
8
+ throw fault(`${rule.factory}() ${name} is not ${rule.requirement}. ${rule.correction}`);
9
+ }
10
+ return value;
11
+ }
12
+ /** Reads a numeric factory's `min` and `max`, and faults on a bad bound or `min` above `max`. */
13
+ function readBounds(options, rule) {
14
+ const declared = readOptions(rule.factory, options);
15
+ const min = boundOf(rule, 'min', declared.min);
16
+ const max = boundOf(rule, 'max', declared.max);
17
+ if (min !== undefined && max !== undefined && min > max) {
18
+ throw fault(`${rule.factory}() min ${String(min)} is above max ${String(max)}. Supply a min at or below max.`);
19
+ }
20
+ return { max, min };
21
+ }
22
+ function withinBounds(value, { max, min }) {
23
+ return (min === undefined || value >= min) && (max === undefined || value <= max);
24
+ }
25
+ /** The number a numeric token outputs, with `-0` read as `0`. */
26
+ function withoutNegativeZero(value) {
27
+ return value === zero ? zero : value;
28
+ }
29
+ /** The one sentence for a numeric configuration, each bound printed as JavaScript prints it. */
30
+ function boundsSentence(noun, { max, min }) {
31
+ if (min !== undefined && max !== undefined) {
32
+ return `Expected ${noun} from ${String(min)} through ${String(max)}.`;
33
+ }
34
+ if (min !== undefined) {
35
+ return `Expected ${noun} of at least ${String(min)}.`;
36
+ }
37
+ if (max !== undefined) {
38
+ return `Expected ${noun} of at most ${String(max)}.`;
39
+ }
40
+ return `Expected ${noun}.`;
41
+ }
42
+ /** The published schema for a numeric type, with each declared bound. */
43
+ function boundsSchema(type, { max, min }) {
44
+ return {
45
+ type,
46
+ ...(min === undefined ? {} : { minimum: min }),
47
+ ...(max === undefined ? {} : { maximum: max }),
48
+ };
49
+ }
50
+ export { boundsSchema, boundsSentence, readBounds, withinBounds, withoutNegativeZero };
@@ -0,0 +1,20 @@
1
+ import type { StandardJSONSchemaV1, StandardSchemaV1, ValidationContext } from '@loomcli/core';
2
+ /** What `parse` returns: `{ value }` for an accepted token or `{ issues }` for a rejected one. */
3
+ type ParseResult<Output> = StandardSchemaV1.Result<Output>;
4
+ /** A validator that publishes its input schema, as every catalog factory returns. */
5
+ type Validator<Output> = StandardSchemaV1<string, Output> & StandardJSONSchemaV1<string, Output>;
6
+ /** The author's definition of one validator. */
7
+ interface ValidatorDefinition<Output> {
8
+ parse: (raw: string, context: ValidationContext) => ParseResult<Output> | Promise<ParseResult<Output>>;
9
+ inputSchema?: Readonly<Record<string, unknown>>;
10
+ }
11
+ /**
12
+ * Builds a frozen Standard Schema value from the author's parse function.
13
+ * With an input schema it also publishes that schema through Standard JSON Schema.
14
+ */
15
+ declare function createValidator<Output>(definition: ValidatorDefinition<Output> & {
16
+ inputSchema: Readonly<Record<string, unknown>>;
17
+ }): Validator<Output>;
18
+ declare function createValidator<Output>(definition: ValidatorDefinition<Output>): StandardSchemaV1<string, Output>;
19
+ export { createValidator };
20
+ export type { ParseResult, Validator, ValidatorDefinition };
package/dist/create.js ADDED
@@ -0,0 +1,92 @@
1
+ import { DeclarationError, validationContext } from '@loomcli/core';
2
+ import { copyRecord, isPlainObject } from './data.js';
3
+ import { fault } from './faults.js';
4
+ const vendor = '@loomcli/validators';
5
+ const target = 'draft-2020-12';
6
+ const dialect = 'https://json-schema.org/draft/2020-12/schema';
7
+ function unavailable() {
8
+ throw new DeclarationError('This validator reads the validation context, which only exists during a Loom run.');
9
+ }
10
+ /**
11
+ * The context `parse` receives when core did not make the call.
12
+ * Every field throws at the read, so only a validator that reads the context fails.
13
+ */
14
+ const outsideRun = Object.freeze({
15
+ get command() {
16
+ return unavailable();
17
+ },
18
+ get host() {
19
+ return unavailable();
20
+ },
21
+ get input() {
22
+ return unavailable();
23
+ },
24
+ get passthrough() {
25
+ return unavailable();
26
+ },
27
+ get phase() {
28
+ return unavailable();
29
+ },
30
+ get supplied() {
31
+ return unavailable();
32
+ },
33
+ });
34
+ /** The converter that publishes a frozen input schema for draft 2020-12 and nothing else. */
35
+ function converter(schema) {
36
+ return Object.freeze({
37
+ input: (options) => {
38
+ if (options.target !== target) {
39
+ throw new Error(`This validator publishes JSON Schema for ${target} only, not ${options.target}.`);
40
+ }
41
+ return { $schema: dialect, ...copyRecord(schema, false) };
42
+ },
43
+ output: () => {
44
+ throw new Error('This validator publishes no output schema.');
45
+ },
46
+ });
47
+ }
48
+ /**
49
+ * Checks a definition that bypassed the types, and returns its input schema copied and frozen, or
50
+ * undefined when it declares none.
51
+ */
52
+ function checkDefinition(definition) {
53
+ if (!isPlainObject(definition)) {
54
+ throw fault('createValidator() definition is not a plain object. Supply an object with a parse function.');
55
+ }
56
+ if (typeof definition.parse !== 'function') {
57
+ throw fault('createValidator() parse is not a function. Supply a function that validates one raw string.');
58
+ }
59
+ return declaredSchema(definition.inputSchema);
60
+ }
61
+ /** The declared input schema, checked, copied, and frozen, or undefined when none is declared. */
62
+ function declaredSchema(inputSchema) {
63
+ if (inputSchema === undefined) {
64
+ return undefined;
65
+ }
66
+ if (!isPlainObject(inputSchema)) {
67
+ throw fault('createValidator() inputSchema is not a plain object. Supply the JSON Schema as a plain object.');
68
+ }
69
+ if (Object.hasOwn(inputSchema, '$schema')) {
70
+ throw fault('createValidator() inputSchema declares its own $schema. Leave out $schema, which the validator publishes itself.');
71
+ }
72
+ return copyRecord(inputSchema, true);
73
+ }
74
+ function createValidator(definition) {
75
+ const schema = checkDefinition(definition);
76
+ const { parse } = definition;
77
+ const props = {
78
+ validate: (value, options) => {
79
+ if (typeof value !== 'string') {
80
+ throw new TypeError(`A validator reads one raw string, and received a value of type ${typeof value}.`);
81
+ }
82
+ return parse(value, validationContext(options) ?? outsideRun);
83
+ },
84
+ vendor,
85
+ version: 1,
86
+ };
87
+ if (schema === undefined) {
88
+ return Object.freeze({ '~standard': Object.freeze(props) });
89
+ }
90
+ return Object.freeze({ '~standard': Object.freeze({ ...props, jsonSchema: converter(schema) }) });
91
+ }
92
+ export { createValidator };
package/dist/data.d.ts ADDED
@@ -0,0 +1,8 @@
1
+ /** Whether a value is a plain object: an object literal or one made with a null prototype. */
2
+ declare function isPlainObject(value: unknown): value is Record<string, unknown>;
3
+ /**
4
+ * A deep copy of plain JSON-like data, so neither the author nor a reader can change the original.
5
+ * Arrays and plain objects are copied, frozen when asked, and every other value is kept as it is.
6
+ */
7
+ declare function copyRecord(record: Readonly<Record<string, unknown>>, freeze: boolean): Record<string, unknown>;
8
+ export { copyRecord, isPlainObject };
package/dist/data.js ADDED
@@ -0,0 +1,25 @@
1
+ /** Whether a value is a plain object: an object literal or one made with a null prototype. */
2
+ function isPlainObject(value) {
3
+ if (typeof value !== 'object' || value === null) {
4
+ return false;
5
+ }
6
+ const prototype = Object.getPrototypeOf(value);
7
+ return prototype === Object.prototype || prototype === null;
8
+ }
9
+ function copyValue(value, freeze) {
10
+ if (Array.isArray(value)) {
11
+ const items = value;
12
+ const copy = items.map((item) => copyValue(item, freeze));
13
+ return freeze ? Object.freeze(copy) : copy;
14
+ }
15
+ return isPlainObject(value) ? copyRecord(value, freeze) : value;
16
+ }
17
+ /**
18
+ * A deep copy of plain JSON-like data, so neither the author nor a reader can change the original.
19
+ * Arrays and plain objects are copied, frozen when asked, and every other value is kept as it is.
20
+ */
21
+ function copyRecord(record, freeze) {
22
+ const copy = Object.fromEntries(Object.entries(record).map(([key, value]) => [key, copyValue(value, freeze)]));
23
+ return freeze ? Object.freeze(copy) : copy;
24
+ }
25
+ export { copyRecord, isPlainObject };
package/dist/date.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ import type { Validator } from './create.js';
2
+ /** A calendar date as `YYYY-MM-DD`, kept as the token because a date has no time or zone. */
3
+ declare function date(): Validator<string>;
4
+ export { date };
package/dist/date.js ADDED
@@ -0,0 +1,30 @@
1
+ import { createValidator } from './create.js';
2
+ import { reject } from './issues.js';
3
+ const layout = /^(?<year>[0-9]{4})-(?<month>[0-9]{2})-(?<day>[0-9]{2})$/u;
4
+ /**
5
+ * Five 400-year Gregorian cycles.
6
+ * The calendar repeats every cycle, so a shifted year keeps its leap days, and the shift moves
7
+ * years below 100 out of the range `Date.UTC` maps to the 1900s.
8
+ */
9
+ const cycleShift = 2000;
10
+ /** The number of the first month, which `Date.UTC` counts from zero. */
11
+ const january = 1;
12
+ /** Whether the parts name a real day of the proleptic Gregorian calendar. */
13
+ function isRealDay(year, month, day) {
14
+ const monthIndex = month - january;
15
+ const probe = new Date(Date.UTC(year + cycleShift, monthIndex, day));
16
+ return probe.getUTCMonth() === monthIndex && probe.getUTCDate() === day;
17
+ }
18
+ /** A calendar date as `YYYY-MM-DD`, kept as the token because a date has no time or zone. */
19
+ function date() {
20
+ return createValidator({
21
+ inputSchema: { type: 'string', format: 'date' },
22
+ parse: (raw) => {
23
+ const parts = layout.exec(raw)?.groups;
24
+ const real = parts !== undefined &&
25
+ isRealDay(Number(parts.year), Number(parts.month), Number(parts.day));
26
+ return real ? { value: raw } : reject('Expected a date as YYYY-MM-DD, such as 2026-09-25.');
27
+ },
28
+ });
29
+ }
30
+ export { date };
@@ -0,0 +1,14 @@
1
+ import { DeclarationError } from '@loomcli/core';
2
+ /**
3
+ * A factory argument that can never work.
4
+ * The message names the factory and the argument, states the problem, then the correction.
5
+ */
6
+ declare function fault(message: string): DeclarationError;
7
+ /** A declared value as a fault message quotes it: a string in quotes, a primitive as printed. */
8
+ declare function quote(value: unknown): string;
9
+ /**
10
+ * A factory's options read as unknown values, because a JavaScript caller can pass anything.
11
+ * Omitted options read as an empty record.
12
+ */
13
+ declare function readOptions(factory: string, options: unknown): Readonly<Record<string, unknown>>;
14
+ export { fault, quote, readOptions };
package/dist/faults.js ADDED
@@ -0,0 +1,37 @@
1
+ import { DeclarationError } from '@loomcli/core';
2
+ import { isPlainObject } from './data.js';
3
+ /**
4
+ * A factory argument that can never work.
5
+ * The message names the factory and the argument, states the problem, then the correction.
6
+ */
7
+ function fault(message) {
8
+ return new DeclarationError(message);
9
+ }
10
+ /** A declared value as a fault message quotes it: a string in quotes, a primitive as printed. */
11
+ function quote(value) {
12
+ if (typeof value === 'string') {
13
+ return JSON.stringify(value);
14
+ }
15
+ if (typeof value === 'number' ||
16
+ typeof value === 'boolean' ||
17
+ typeof value === 'bigint' ||
18
+ value === null ||
19
+ value === undefined) {
20
+ return String(value);
21
+ }
22
+ return `a value of type ${typeof value}`;
23
+ }
24
+ /**
25
+ * A factory's options read as unknown values, because a JavaScript caller can pass anything.
26
+ * Omitted options read as an empty record.
27
+ */
28
+ function readOptions(factory, options) {
29
+ if (options === undefined) {
30
+ return {};
31
+ }
32
+ if (!isPlainObject(options)) {
33
+ throw fault(`${factory}() options is not a plain object. Supply an object or leave it out.`);
34
+ }
35
+ return options;
36
+ }
37
+ export { fault, quote, readOptions };
@@ -0,0 +1,11 @@
1
+ export { createValidator } from './create.js';
2
+ export { date } from './date.js';
3
+ export { integer } from './integer.js';
4
+ export { number } from './number.js';
5
+ export { oneOf } from './one-of.js';
6
+ export { path } from './path.js';
7
+ export { port } from './port.js';
8
+ export { text } from './text.js';
9
+ export { url } from './url.js';
10
+ export { uuid } from './uuid.js';
11
+ export type { ParseResult, Validator } from './create.js';
package/dist/index.js ADDED
@@ -0,0 +1,10 @@
1
+ export { createValidator } from './create.js';
2
+ export { date } from './date.js';
3
+ export { integer } from './integer.js';
4
+ export { number } from './number.js';
5
+ export { oneOf } from './one-of.js';
6
+ export { path } from './path.js';
7
+ export { port } from './port.js';
8
+ export { text } from './text.js';
9
+ export { url } from './url.js';
10
+ export { uuid } from './uuid.js';
@@ -0,0 +1,11 @@
1
+ import type { Validator } from './create.js';
2
+ interface IntegerOptions {
3
+ min?: number;
4
+ max?: number;
5
+ }
6
+ /** The safe integer a token spells in decimal digits, `-0` as `0`, or undefined when none. */
7
+ declare function readInteger(raw: string): number | undefined;
8
+ /** A decimal whole number that is a safe integer within `min` and `max`, both inclusive. */
9
+ declare function integer(options?: IntegerOptions): Validator<number>;
10
+ export { integer, readInteger };
11
+ export type { IntegerOptions };
@@ -0,0 +1,30 @@
1
+ import { boundsSchema, boundsSentence, readBounds, withinBounds, withoutNegativeZero, } from './bounds.js';
2
+ import { createValidator } from './create.js';
3
+ import { reject } from './issues.js';
4
+ const digits = /^-?[0-9]+$/u;
5
+ /** The safe integer a token spells in decimal digits, `-0` as `0`, or undefined when none. */
6
+ function readInteger(raw) {
7
+ if (!digits.test(raw)) {
8
+ return undefined;
9
+ }
10
+ const value = Number(raw);
11
+ return Number.isSafeInteger(value) ? withoutNegativeZero(value) : undefined;
12
+ }
13
+ /** A decimal whole number that is a safe integer within `min` and `max`, both inclusive. */
14
+ function integer(options) {
15
+ const bounds = readBounds(options, {
16
+ accepts: Number.isSafeInteger,
17
+ correction: 'Supply a whole number from -9007199254740991 through 9007199254740991.',
18
+ factory: 'integer',
19
+ requirement: 'a safe integer',
20
+ });
21
+ const sentence = boundsSentence('a whole number', bounds);
22
+ return createValidator({
23
+ inputSchema: boundsSchema('integer', bounds),
24
+ parse: (raw) => {
25
+ const value = readInteger(raw);
26
+ return value !== undefined && withinBounds(value, bounds) ? { value } : reject(sentence);
27
+ },
28
+ });
29
+ }
30
+ export { integer, readInteger };
@@ -0,0 +1,6 @@
1
+ import type { StandardSchemaV1 } from '@loomcli/core';
2
+ /** A rejection: one issue with no path and no code, carrying the configuration's one sentence. */
3
+ declare function reject(message: string): StandardSchemaV1.FailureResult;
4
+ /** Joins items as a sentence lists them: `a`, `a or b`, or `a, b, or c`. */
5
+ declare function listing(items: readonly string[]): string;
6
+ export { listing, reject };
package/dist/issues.js ADDED
@@ -0,0 +1,17 @@
1
+ /** The longest list that joins with `or` alone. */
2
+ const pair = 2;
3
+ const first = 0;
4
+ /** The index of the last item, counted from the end. */
5
+ const last = -1;
6
+ /** A rejection: one issue with no path and no code, carrying the configuration's one sentence. */
7
+ function reject(message) {
8
+ return { issues: [{ message }] };
9
+ }
10
+ /** Joins items as a sentence lists them: `a`, `a or b`, or `a, b, or c`. */
11
+ function listing(items) {
12
+ if (items.length <= pair) {
13
+ return items.join(' or ');
14
+ }
15
+ return `${items.slice(first, last).join(', ')}, or ${items.at(last) ?? ''}`;
16
+ }
17
+ export { listing, reject };
@@ -0,0 +1,9 @@
1
+ import type { Validator } from './create.js';
2
+ interface NumberOptions {
3
+ min?: number;
4
+ max?: number;
5
+ }
6
+ /** A finite decimal number, with an optional fraction and exponent, within `min` and `max`. */
7
+ declare function number(options?: NumberOptions): Validator<number>;
8
+ export { number };
9
+ export type { NumberOptions };
package/dist/number.js ADDED
@@ -0,0 +1,25 @@
1
+ import { boundsSchema, boundsSentence, readBounds, withinBounds, withoutNegativeZero, } from './bounds.js';
2
+ import { createValidator } from './create.js';
3
+ import { reject } from './issues.js';
4
+ const decimal = /^-?[0-9]+(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?$/u;
5
+ /** A finite decimal number, with an optional fraction and exponent, within `min` and `max`. */
6
+ function number(options) {
7
+ const bounds = readBounds(options, {
8
+ accepts: Number.isFinite,
9
+ correction: 'Supply a finite number.',
10
+ factory: 'number',
11
+ requirement: 'a finite number',
12
+ });
13
+ const sentence = boundsSentence('a number', bounds);
14
+ return createValidator({
15
+ inputSchema: boundsSchema('number', bounds),
16
+ parse: (raw) => {
17
+ if (!decimal.test(raw)) {
18
+ return reject(sentence);
19
+ }
20
+ const value = withoutNegativeZero(Number(raw));
21
+ return Number.isFinite(value) && withinBounds(value, bounds) ? { value } : reject(sentence);
22
+ },
23
+ });
24
+ }
25
+ export { number };
@@ -0,0 +1,4 @@
1
+ import type { Validator } from './create.js';
2
+ /** A token exactly equal to one of the declared values, typed as their union. */
3
+ declare function oneOf<const Values extends readonly [string, ...string[]]>(values: Values): Validator<Values[number]>;
4
+ export { oneOf };
package/dist/one-of.js ADDED
@@ -0,0 +1,47 @@
1
+ import { createValidator } from './create.js';
2
+ import { fault } from './faults.js';
3
+ import { reject } from './issues.js';
4
+ const empty = 0;
5
+ /** The declared list as unknown items, after faulting on one that is not an array or is empty. */
6
+ function checkList(values) {
7
+ if (!Array.isArray(values)) {
8
+ throw fault('oneOf() values is not an array. Supply an array of strings.');
9
+ }
10
+ const items = values;
11
+ if (items.length === empty) {
12
+ throw fault('oneOf() values is empty. List at least one value.');
13
+ }
14
+ return items;
15
+ }
16
+ /** Faults on a value that is not a string, is empty, or was listed earlier. */
17
+ function checkValue(item, seen) {
18
+ if (typeof item !== 'string') {
19
+ throw fault('oneOf() lists a value that is not a string. List strings only.');
20
+ }
21
+ if (item === '') {
22
+ throw fault('oneOf() lists an empty string. List nonempty values only.');
23
+ }
24
+ if (seen.has(item)) {
25
+ throw fault(`oneOf() lists ${JSON.stringify(item)} twice. List each value once.`);
26
+ }
27
+ seen.add(item);
28
+ }
29
+ /** A token exactly equal to one of the declared values, typed as their union. */
30
+ function oneOf(values) {
31
+ checkList(values);
32
+ // The copy is what the validator keeps, so the checks read the copy and not the caller's list.
33
+ const listed = [...values];
34
+ const seen = new Set();
35
+ for (const item of listed) {
36
+ checkValue(item, seen);
37
+ }
38
+ const sentence = `Expected one of: ${listed.join(', ')}.`;
39
+ return createValidator({
40
+ inputSchema: { type: 'string', enum: [...listed] },
41
+ parse: (raw) => {
42
+ const match = listed.find((value) => value === raw);
43
+ return match === undefined ? reject(sentence) : { value: match };
44
+ },
45
+ });
46
+ }
47
+ export { oneOf };
package/dist/path.d.ts ADDED
@@ -0,0 +1,13 @@
1
+ import type { Validator } from './create.js';
2
+ import type { PathKind } from './probe.js';
3
+ interface PathOptions {
4
+ access?: 'read' | 'write';
5
+ kind?: PathKind;
6
+ }
7
+ /**
8
+ * A path resolved against the host's cwd under the host platform's rules, and normalized.
9
+ * With `access`, the filesystem is probed; the probe is advisory, since the filesystem can change.
10
+ */
11
+ declare function path(options?: PathOptions): Validator<string>;
12
+ export { path };
13
+ export type { PathOptions };
package/dist/path.js ADDED
@@ -0,0 +1,61 @@
1
+ import { posix, win32 } from 'node:path';
2
+ import { createValidator } from './create.js';
3
+ import { fault, readOptions } from './faults.js';
4
+ import { reject } from './issues.js';
5
+ import { readable, writable } from './probe.js';
6
+ const sentences = {
7
+ read: {
8
+ any: 'Expected a readable file or directory that exists.',
9
+ directory: 'Expected a readable directory that exists.',
10
+ file: 'Expected a readable file that exists.',
11
+ },
12
+ write: {
13
+ any: 'Expected a writable path, or a new path in a writable directory.',
14
+ directory: 'Expected a writable directory, or a new directory in a writable directory.',
15
+ file: 'Expected a writable file, or a new file in a writable directory.',
16
+ },
17
+ };
18
+ function accessOf(value) {
19
+ if (value === undefined || value === 'read' || value === 'write') {
20
+ return value;
21
+ }
22
+ throw fault('path() access is not "read" or "write". Supply "read" or "write".');
23
+ }
24
+ function kindOf(value, access) {
25
+ if (value !== undefined && access === undefined) {
26
+ throw fault('path() kind has no access to check. Supply an access or leave out kind.');
27
+ }
28
+ if (value === undefined || value === 'file' || value === 'directory' || value === 'any') {
29
+ return value ?? 'file';
30
+ }
31
+ throw fault('path() kind is not "file", "directory", or "any". Supply one of them.');
32
+ }
33
+ /**
34
+ * A path resolved against the host's cwd under the host platform's rules, and normalized.
35
+ * With `access`, the filesystem is probed; the probe is advisory, since the filesystem can change.
36
+ */
37
+ function path(options) {
38
+ const declared = readOptions('path', options);
39
+ const access = accessOf(declared.access);
40
+ const kind = kindOf(declared.kind, access);
41
+ const sentence = access === undefined ? 'Expected a path.' : sentences[access][kind];
42
+ return createValidator({
43
+ inputSchema: { type: 'string', minLength: 1 },
44
+ parse: (raw, context) => {
45
+ if (raw === '' || raw.includes('\0')) {
46
+ return reject(sentence);
47
+ }
48
+ const { cwd, platform } = context.host;
49
+ const rules = platform === 'win32' ? win32 : posix;
50
+ const resolved = rules.resolve(cwd, raw);
51
+ if (access === undefined) {
52
+ return { value: resolved };
53
+ }
54
+ const probe = access === 'read'
55
+ ? readable(resolved, kind)
56
+ : writable(resolved, kind, rules.dirname(resolved));
57
+ return probe.then((passes) => (passes ? { value: resolved } : reject(sentence)));
58
+ },
59
+ });
60
+ }
61
+ export { path };
package/dist/port.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ import type { Validator } from './create.js';
2
+ /** A TCP or UDP port the operator names, 1 through 65535; port 0 is left to `integer()`. */
3
+ declare function port(): Validator<number>;
4
+ export { port };
package/dist/port.js ADDED
@@ -0,0 +1,18 @@
1
+ import { boundsSchema, withinBounds } from './bounds.js';
2
+ import { createValidator } from './create.js';
3
+ import { readInteger } from './integer.js';
4
+ import { reject } from './issues.js';
5
+ const range = { max: 65_535, min: 1 };
6
+ /** A TCP or UDP port the operator names, 1 through 65535; port 0 is left to `integer()`. */
7
+ function port() {
8
+ return createValidator({
9
+ inputSchema: boundsSchema('integer', range),
10
+ parse: (raw) => {
11
+ const value = readInteger(raw);
12
+ return value !== undefined && withinBounds(value, range)
13
+ ? { value }
14
+ : reject('Expected a port number from 1 through 65535.');
15
+ },
16
+ });
17
+ }
18
+ export { port };
@@ -0,0 +1,11 @@
1
+ type PathKind = 'file' | 'directory' | 'any';
2
+ /** Runs one probe, reading a verdict failure as `false` and rethrowing any other failure. */
3
+ declare function holds(probe: () => Promise<boolean>): Promise<boolean>;
4
+ declare function readable(target: string, kind: PathKind): Promise<boolean>;
5
+ /**
6
+ * Whether the process can write the entry, or create it when no entry exists: then the parent
7
+ * must be an existing directory the process can write. Nothing is created.
8
+ */
9
+ declare function writable(target: string, kind: PathKind, parent: string): Promise<boolean>;
10
+ export { holds, readable, writable };
11
+ export type { PathKind };
package/dist/probe.js ADDED
@@ -0,0 +1,66 @@
1
+ import { constants } from 'node:fs';
2
+ import { access, stat } from 'node:fs/promises';
3
+ /** The probe failures that are a verdict about the operator's path rather than a broken host. */
4
+ const verdicts = new Set([
5
+ 'ENOENT',
6
+ 'ENOTDIR',
7
+ 'EACCES',
8
+ 'EPERM',
9
+ 'ELOOP',
10
+ 'ENAMETOOLONG',
11
+ 'EINVAL',
12
+ ]);
13
+ function codeOf(error) {
14
+ return error instanceof Error && 'code' in error && typeof error.code === 'string'
15
+ ? error.code
16
+ : undefined;
17
+ }
18
+ /** Runs one probe, reading a verdict failure as `false` and rethrowing any other failure. */
19
+ async function holds(probe) {
20
+ try {
21
+ return await probe();
22
+ }
23
+ catch (error) {
24
+ const code = codeOf(error);
25
+ if (code !== undefined && verdicts.has(code)) {
26
+ return false;
27
+ }
28
+ throw error;
29
+ }
30
+ }
31
+ function isKind(stats, kind) {
32
+ if (kind === 'file') {
33
+ return stats.isFile();
34
+ }
35
+ return kind === 'directory' ? stats.isDirectory() : true;
36
+ }
37
+ /** Whether an entry exists, following symbolic links, is of `kind`, and grants `mode`. */
38
+ async function grants(target, kind, mode) {
39
+ const stats = await stat(target);
40
+ if (!isKind(stats, kind)) {
41
+ return false;
42
+ }
43
+ await access(target, mode);
44
+ return true;
45
+ }
46
+ async function readable(target, kind) {
47
+ return await holds(async () => await grants(target, kind, constants.R_OK));
48
+ }
49
+ /**
50
+ * Whether the process can write the entry, or create it when no entry exists: then the parent
51
+ * must be an existing directory the process can write. Nothing is created.
52
+ */
53
+ async function writable(target, kind, parent) {
54
+ return await holds(async () => {
55
+ try {
56
+ return await grants(target, kind, constants.W_OK);
57
+ }
58
+ catch (error) {
59
+ if (codeOf(error) !== 'ENOENT') {
60
+ throw error;
61
+ }
62
+ return await grants(parent, 'directory', constants.W_OK);
63
+ }
64
+ });
65
+ }
66
+ export { holds, readable, writable };
package/dist/text.d.ts ADDED
@@ -0,0 +1,11 @@
1
+ import type { Validator } from './create.js';
2
+ interface TextOptions {
3
+ minLength?: number;
4
+ maxLength?: number;
5
+ pattern?: RegExp;
6
+ message?: string;
7
+ }
8
+ /** A string whose code-point length is within bounds and which the pattern matches, if any. */
9
+ declare function text(options?: TextOptions): Validator<string>;
10
+ export { text };
11
+ export type { TextOptions };
package/dist/text.js ADDED
@@ -0,0 +1,113 @@
1
+ import { createValidator } from './create.js';
2
+ import { fault, readOptions } from './faults.js';
3
+ import { reject } from './issues.js';
4
+ const noLength = 0;
5
+ const oneCharacter = 1;
6
+ const defaultMinLength = 1;
7
+ function lengthOf(name, value) {
8
+ if (value === undefined) {
9
+ return undefined;
10
+ }
11
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < noLength) {
12
+ throw fault(`text() ${name} is not a non-negative safe integer. Supply a whole number of 0 or more.`);
13
+ }
14
+ return value;
15
+ }
16
+ /** Faults on a pattern flag other than `u`, which JSON Schema has no way to state. */
17
+ function checkFlags(pattern) {
18
+ const others = pattern.flags.replace('u', '');
19
+ if (others !== '') {
20
+ const noun = others.length === oneCharacter ? 'flag' : 'flags';
21
+ throw fault(`text() pattern carries the ${noun} ${others}. Supply a pattern with no flag other than u.`);
22
+ }
23
+ }
24
+ /** The author's pattern recompiled under the `u` flag, as JSON Schema reads a `pattern`. */
25
+ function patternOf(value) {
26
+ if (value === undefined) {
27
+ return undefined;
28
+ }
29
+ if (!(value instanceof RegExp)) {
30
+ throw fault('text() pattern is not a RegExp. Supply a regular expression literal.');
31
+ }
32
+ checkFlags(value);
33
+ try {
34
+ return new RegExp(value.source, 'u');
35
+ }
36
+ catch {
37
+ throw fault('text() pattern does not compile under the u flag. Supply a pattern that is valid with the u flag.');
38
+ }
39
+ }
40
+ function messageOf(value, pattern) {
41
+ if (value === undefined) {
42
+ return undefined;
43
+ }
44
+ if (typeof value !== 'string' || value === '') {
45
+ throw fault('text() message is not a nonempty string. Supply one sentence that states the expectation.');
46
+ }
47
+ if (pattern === undefined) {
48
+ throw fault('text() message has no pattern to describe. Supply a pattern or leave out message.');
49
+ }
50
+ return value;
51
+ }
52
+ function characters(count) {
53
+ return count === oneCharacter ? '1 character' : `${count} characters`;
54
+ }
55
+ /** The sentence for bounds with a `maxLength`, where `min` is `minLength` after its default. */
56
+ function boundedSentence(min, max) {
57
+ if (min === max) {
58
+ return `Expected exactly ${characters(min)}.`;
59
+ }
60
+ if (min === noLength) {
61
+ return `Expected at most ${characters(max)}.`;
62
+ }
63
+ return `Expected from ${min} through ${max} characters.`;
64
+ }
65
+ /** The length rule for the effective bounds, or undefined when every length passes. */
66
+ function lengthRule(min, max) {
67
+ if (max !== undefined) {
68
+ return { fits: (count) => count >= min && count <= max, sentence: boundedSentence(min, max) };
69
+ }
70
+ if (min === noLength) {
71
+ return undefined;
72
+ }
73
+ const sentence = min === oneCharacter ? 'Expected a nonempty value.' : `Expected at least ${characters(min)}.`;
74
+ return { fits: (count) => count >= min, sentence };
75
+ }
76
+ /**
77
+ * The token's length in Unicode code points, as JSON Schema counts it.
78
+ * Under the `u` flag a dot matches one code point, a lone surrogate included, so replacing each
79
+ * match with one code unit leaves a string as long as the count.
80
+ */
81
+ function codePoints(raw) {
82
+ return raw.replaceAll(/./gsu, '.').length;
83
+ }
84
+ /** A string whose code-point length is within bounds and which the pattern matches, if any. */
85
+ function text(options) {
86
+ const declared = readOptions('text', options);
87
+ const minLength = lengthOf('minLength', declared.minLength) ?? defaultMinLength;
88
+ const maxLength = lengthOf('maxLength', declared.maxLength);
89
+ if (maxLength !== undefined && minLength > maxLength) {
90
+ throw fault(`text() minLength ${minLength} is above maxLength ${maxLength}. Supply a minLength at or below maxLength.`);
91
+ }
92
+ const pattern = patternOf(declared.pattern);
93
+ const patternSentence = messageOf(declared.message, pattern) ?? 'Expected a value that matches the required pattern.';
94
+ const length = lengthRule(minLength, maxLength);
95
+ return createValidator({
96
+ inputSchema: {
97
+ type: 'string',
98
+ minLength,
99
+ ...(maxLength === undefined ? {} : { maxLength }),
100
+ ...(pattern === undefined ? {} : { pattern: pattern.source }),
101
+ },
102
+ parse: (raw) => {
103
+ if (length !== undefined && !length.fits(codePoints(raw))) {
104
+ return reject(length.sentence);
105
+ }
106
+ if (pattern !== undefined && !pattern.test(raw)) {
107
+ return reject(patternSentence);
108
+ }
109
+ return { value: raw };
110
+ },
111
+ });
112
+ }
113
+ export { text };
@@ -0,0 +1,9 @@
1
+ /**
2
+ * The RFC 3986 `URI` production, built from the RFC's own rule names:
3
+ * `scheme ":" hier-part [ "?" query ] [ "#" fragment ]`.
4
+ * Letters are spelled in both cases, so the expression needs no `i` flag.
5
+ */
6
+ declare const uriPattern: RegExp;
7
+ /** The RFC 3986 `scheme` production on its own, for checking a declared protocol. */
8
+ declare const schemePattern: RegExp;
9
+ export { schemePattern, uriPattern };
@@ -0,0 +1,42 @@
1
+ /**
2
+ * The RFC 3986 `URI` production, built from the RFC's own rule names:
3
+ * `scheme ":" hier-part [ "?" query ] [ "#" fragment ]`.
4
+ * Letters are spelled in both cases, so the expression needs no `i` flag.
5
+ */
6
+ const hex = '[0-9A-Fa-f]';
7
+ const pctEncoded = `%${hex}{2}`;
8
+ const unreserved = String.raw `A-Za-z0-9\-._~`;
9
+ const subDelims = "!$&'()*+,;=";
10
+ const pchar = `(?:[${unreserved}${subDelims}:@]|${pctEncoded})`;
11
+ const scheme = String.raw `[A-Za-z][A-Za-z0-9+\-.]*`;
12
+ const userinfo = `(?:[${unreserved}${subDelims}:]|${pctEncoded})*`;
13
+ const decOctet = '(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])';
14
+ const ipv4 = String.raw `${decOctet}(?:\.${decOctet}){3}`;
15
+ const h16 = `${hex}{1,4}`;
16
+ const ls32 = `(?:${h16}:${h16}|${ipv4})`;
17
+ const ipv6 = `(?:${[
18
+ `(?:${h16}:){6}${ls32}`,
19
+ `::(?:${h16}:){5}${ls32}`,
20
+ `(?:${h16})?::(?:${h16}:){4}${ls32}`,
21
+ `(?:(?:${h16}:){0,1}${h16})?::(?:${h16}:){3}${ls32}`,
22
+ `(?:(?:${h16}:){0,2}${h16})?::(?:${h16}:){2}${ls32}`,
23
+ `(?:(?:${h16}:){0,3}${h16})?::${h16}:${ls32}`,
24
+ `(?:(?:${h16}:){0,4}${h16})?::${ls32}`,
25
+ `(?:(?:${h16}:){0,5}${h16})?::${h16}`,
26
+ `(?:(?:${h16}:){0,6}${h16})?::`,
27
+ ].join('|')})`;
28
+ const ipvFuture = String.raw `[vV]${hex}+\.[${unreserved}${subDelims}:]+`;
29
+ const ipLiteral = String.raw `\[(?:${ipv6}|${ipvFuture})\]`;
30
+ const regName = `(?:[${unreserved}${subDelims}]|${pctEncoded})*`;
31
+ const host = `(?:${ipLiteral}|${ipv4}|${regName})`;
32
+ const authority = `(?:${userinfo}@)?${host}(?::[0-9]*)?`;
33
+ const pathAbempty = `(?:/${pchar}*)*`;
34
+ const pathAbsolute = `/(?:${pchar}+(?:/${pchar}*)*)?`;
35
+ const pathRootless = `${pchar}+(?:/${pchar}*)*`;
36
+ // RFC 3986 also allows an empty path, which the published `uri` format refuses, so soundness drops it.
37
+ const hierPart = `(?://${authority}${pathAbempty}|${pathAbsolute}|${pathRootless})`;
38
+ const queryOrFragment = `(?:${pchar}|[/?])*`;
39
+ const uriPattern = new RegExp(String.raw `^${scheme}:${hierPart}(?:\?${queryOrFragment})?(?:#${queryOrFragment})?$`, 'u');
40
+ /** The RFC 3986 `scheme` production on its own, for checking a declared protocol. */
41
+ const schemePattern = new RegExp(`^${scheme}$`, 'u');
42
+ export { schemePattern, uriPattern };
package/dist/url.d.ts ADDED
@@ -0,0 +1,8 @@
1
+ import type { Validator } from './create.js';
2
+ interface UrlOptions {
3
+ protocols?: readonly [string, ...string[]];
4
+ }
5
+ /** An absolute RFC 3986 URI that the WHATWG parser also reads, optionally with a listed scheme. */
6
+ declare function url(options?: UrlOptions): Validator<URL>;
7
+ export { url };
8
+ export type { UrlOptions };
package/dist/url.js ADDED
@@ -0,0 +1,63 @@
1
+ import { createValidator } from './create.js';
2
+ import { fault, quote, readOptions } from './faults.js';
3
+ import { listing, reject } from './issues.js';
4
+ import { schemePattern, uriPattern } from './uri-grammar.js';
5
+ const empty = 0;
6
+ function protocolsOf(value) {
7
+ if (value === undefined) {
8
+ return undefined;
9
+ }
10
+ if (!Array.isArray(value)) {
11
+ throw fault('url() protocols is not an array. Supply an array of scheme names.');
12
+ }
13
+ const items = value;
14
+ if (items.length === empty) {
15
+ throw fault('url() protocols is empty. List at least one scheme.');
16
+ }
17
+ // `Array.from` reads a hole as `undefined`, so a sparse list faults instead of skipping it.
18
+ return Array.from(items, (item) => {
19
+ if (typeof item !== 'string' || !schemePattern.test(item)) {
20
+ throw fault(`url() protocols lists ${quote(item)}, which is not a scheme name. List a letter followed by letters, digits, +, -, or ., with no trailing colon.`);
21
+ }
22
+ return item;
23
+ });
24
+ }
25
+ /**
26
+ * One scheme as a case-free pattern: each letter as a two-case class, `+` and `.` escaped.
27
+ * A hyphen stays bare, because the `u` flag JSON Schema patterns use refuses `\-` outside a class.
28
+ */
29
+ function spellScheme(scheme) {
30
+ return scheme
31
+ .replaceAll(/[+.]/gu, String.raw `\$&`)
32
+ .replaceAll(/[A-Za-z]/gu, (letter) => `[${letter.toLowerCase()}${letter.toUpperCase()}]`);
33
+ }
34
+ /** The pattern that publishes a scheme list, anchored at the start and ending at the colon. */
35
+ function schemesPattern(protocols) {
36
+ return `^(?:${protocols.map((protocol) => spellScheme(protocol)).join('|')}):`;
37
+ }
38
+ /** An absolute RFC 3986 URI that the WHATWG parser also reads, optionally with a listed scheme. */
39
+ function url(options) {
40
+ const protocols = protocolsOf(readOptions('url', options).protocols);
41
+ // The WHATWG parser lowercases the scheme and ends `protocol` with a colon.
42
+ const listed = new Set(protocols?.map((protocol) => `${protocol.toLowerCase()}:`));
43
+ const sentence = protocols === undefined
44
+ ? 'Expected an absolute URL, such as https://example.com.'
45
+ : `Expected an absolute URL with the scheme ${listing(protocols)}.`;
46
+ return createValidator({
47
+ inputSchema: {
48
+ type: 'string',
49
+ format: 'uri',
50
+ ...(protocols === undefined ? {} : { pattern: schemesPattern(protocols) }),
51
+ },
52
+ parse: (raw) => {
53
+ if (!uriPattern.test(raw) || !URL.canParse(raw)) {
54
+ return reject(sentence);
55
+ }
56
+ const parsed = new URL(raw);
57
+ return protocols === undefined || listed.has(parsed.protocol)
58
+ ? { value: parsed }
59
+ : reject(sentence);
60
+ },
61
+ });
62
+ }
63
+ export { url };
package/dist/uuid.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ import type { Validator } from './create.js';
2
+ /** A UUID of any version in any letter case, read as lowercase so two spellings compare equal. */
3
+ declare function uuid(): Validator<string>;
4
+ export { uuid };
package/dist/uuid.js ADDED
@@ -0,0 +1,13 @@
1
+ import { createValidator } from './create.js';
2
+ import { reject } from './issues.js';
3
+ const grouped = /^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$/u;
4
+ /** A UUID of any version in any letter case, read as lowercase so two spellings compare equal. */
5
+ function uuid() {
6
+ return createValidator({
7
+ inputSchema: { type: 'string', format: 'uuid' },
8
+ parse: (raw) => grouped.test(raw)
9
+ ? { value: raw.toLowerCase() }
10
+ : reject('Expected a UUID, such as 123e4567-e89b-12d3-a456-426614174000.'),
11
+ });
12
+ }
13
+ export { uuid };
package/package.json CHANGED
@@ -1,10 +1,32 @@
1
1
  {
2
2
  "name": "@loomcli/validators",
3
- "version": "0.0.0",
4
- "description": "Placeholder for the Loom CLI validator catalog. The first real release follows.",
3
+ "version": "0.5.0",
4
+ "description": "The Loom CLI validator catalog. Ships Standard Schema values for common command-line input shapes.",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
8
8
  "url": "git+https://github.com/dbtlr/loomcli.git"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "LICENSE"
13
+ ],
14
+ "type": "module",
15
+ "exports": {
16
+ ".": {
17
+ "types": "./dist/index.d.ts",
18
+ "import": "./dist/index.js"
19
+ }
20
+ },
21
+ "devDependencies": {
22
+ "ajv": "^8.20.0",
23
+ "ajv-formats": "^3.0.1"
24
+ },
25
+ "peerDependencies": {
26
+ "@loomcli/core": "0.5.0"
27
+ },
28
+ "engines": {
29
+ "bun": ">=1.4.0",
30
+ "node": ">=22.23.2"
9
31
  }
10
32
  }