@crouton-kit/capture 1.3.8 → 1.3.10
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/bin/capture +29841 -8539
- package/package.json +6 -3
- package/src/capture.ts +86 -173
- package/src/cdp/a11y.ts +113 -14
- package/src/cdp/args.ts +300 -205
- package/src/cdp/bridge/protocol.ts +93 -6
- package/src/cdp/bridge/server.ts +118 -53
- package/src/cdp/bridge/spawn.ts +53 -6
- package/src/cdp/client.ts +13 -3
- package/src/cdp/commands/bridge-serve.ts +20 -14
- package/src/cdp/commands/cdp.ts +259 -90
- package/src/cdp/commands/gate-guard.ts +43 -0
- package/src/cdp/commands/lib.ts +339 -213
- package/src/cdp/commands/measure/census.ts +76 -20
- package/src/cdp/commands/measure/check.ts +100 -26
- package/src/cdp/commands/measure/diff.ts +109 -21
- package/src/cdp/commands/measure/explain.ts +121 -19
- package/src/cdp/commands/measure/index.ts +31 -39
- package/src/cdp/commands/measure/map-ax.ts +52 -0
- package/src/cdp/commands/measure/map-focus.ts +41 -13
- package/src/cdp/commands/measure/map-layers.ts +49 -15
- package/src/cdp/commands/measure/map-scroll.ts +85 -15
- package/src/cdp/commands/measure/snap.ts +405 -25
- package/src/cdp/commands/measure/sweep.ts +217 -20
- package/src/cdp/commands/motion/index.ts +18 -17
- package/src/cdp/commands/motion/jank.ts +119 -9
- package/src/cdp/commands/motion/mask.ts +81 -12
- package/src/cdp/commands/motion/rec.ts +520 -19
- package/src/cdp/commands/motion/response.ts +65 -15
- package/src/cdp/commands/motion/timeline.ts +139 -14
- package/src/cdp/commands/page/click.ts +194 -0
- package/src/cdp/commands/page/elements.ts +154 -0
- package/src/cdp/commands/page/exec.ts +317 -0
- package/src/cdp/commands/page/index.ts +72 -0
- package/src/cdp/commands/page/navigate.ts +330 -0
- package/src/cdp/commands/page/scroll.ts +107 -0
- package/src/cdp/commands/page/shot.ts +208 -0
- package/src/cdp/commands/page/type.ts +104 -0
- package/src/cdp/commands/tab/index.ts +59 -0
- package/src/cdp/commands/tab/list.ts +167 -0
- package/src/cdp/commands/tab/network.ts +162 -0
- package/src/cdp/commands/tab/open.ts +90 -0
- package/src/cdp/commands/tab/reset.ts +160 -0
- package/src/cdp/connection.ts +368 -37
- package/src/cdp/detect.ts +42 -13
- package/src/cdp/dispatch.ts +74 -28
- package/src/cdp/har-recorder.ts +648 -115
- package/src/cdp/leaf-grammar.ts +99 -0
- package/src/cdp/measure/census.ts +205 -0
- package/src/cdp/measure/check.ts +318 -0
- package/src/cdp/measure/collectors/animation.ts +385 -0
- package/src/cdp/measure/collectors/ax.ts +278 -0
- package/src/cdp/measure/collectors/focus.ts +1041 -0
- package/src/cdp/measure/collectors/forms.ts +905 -0
- package/src/cdp/measure/collectors/geometry.ts +1038 -0
- package/src/cdp/measure/collectors/hittest.ts +835 -0
- package/src/cdp/measure/collectors/layers.ts +697 -0
- package/src/cdp/measure/collectors/media.ts +522 -0
- package/src/cdp/measure/collectors/pixels.ts +1584 -0
- package/src/cdp/measure/collectors/queries.ts +464 -0
- package/src/cdp/measure/collectors/scroll.ts +599 -0
- package/src/cdp/measure/collectors/states.ts +992 -0
- package/src/cdp/measure/collectors/style-provenance.ts +643 -0
- package/src/cdp/measure/collectors/styles.ts +355 -0
- package/src/cdp/measure/collectors/text.ts +713 -0
- package/src/cdp/measure/diff.ts +470 -0
- package/src/cdp/measure/explain.ts +498 -0
- package/src/cdp/measure/map-ax.ts +633 -0
- package/src/cdp/measure/map-focus.ts +188 -0
- package/src/cdp/measure/map-layers.ts +177 -0
- package/src/cdp/measure/map-scroll.ts +317 -0
- package/src/cdp/measure/redaction.ts +51 -0
- package/src/cdp/measure/settle.ts +1051 -0
- package/src/cdp/measure/snapshot.ts +390 -0
- package/src/cdp/measure/sweep.ts +330 -0
- package/src/cdp/measure/types.ts +220 -0
- package/src/cdp/measure/v2/collectors.ts +19 -0
- package/src/cdp/measure/v2/orchestrator.ts +46 -0
- package/src/cdp/measure/v2/snap-selection.ts +33 -0
- package/src/cdp/measure/v2/target-lock.ts +15 -0
- package/src/cdp/measure/v2/target.ts +16 -0
- package/src/cdp/motion/jank.ts +433 -0
- package/src/cdp/motion/mask.ts +421 -0
- package/src/cdp/motion/recorder.ts +764 -0
- package/src/cdp/motion/response.ts +356 -0
- package/src/cdp/motion/timeline.ts +215 -0
- package/src/cdp/record.ts +5 -212
- package/src/cdp/recorder-bridge.ts +1478 -105
- package/src/cdp/recorder-client.ts +326 -0
- package/src/cdp/scope-lock.ts +73 -0
- package/src/cdp/screenshot.ts +199 -66
- package/src/cdp/targets.ts +21 -1
- package/src/cdp/timing.ts +15 -10
- package/src/cdp/types.ts +22 -7
- package/src/cdp/viewport.ts +21 -0
- package/src/cdp.ts +12 -58
- package/src/command-help.ts +29 -0
- package/src/command-resolution.ts +22 -0
- package/src/command-walker.ts +35 -0
- package/src/contracts/a11y.ts +33 -0
- package/src/contracts/cursor.ts +35 -0
- package/src/contracts/index.ts +15 -0
- package/src/contracts/motion.ts +19 -0
- package/src/contracts/neutral.ts +56 -0
- package/src/contracts/primitives.ts +205 -0
- package/src/contracts/release.ts +17 -0
- package/src/contracts/results.ts +208 -0
- package/src/contracts/routes.ts +140 -0
- package/src/contracts/snapshot.ts +92 -0
- package/src/cursor/index.ts +13 -0
- package/src/cursor/service.ts +310 -0
- package/src/errors.ts +71 -0
- package/src/har-manager.ts +992 -41
- package/src/interact.ts +410 -98
- package/src/output/artifact-lifecycle.ts +371 -0
- package/src/output/artifact.ts +6 -0
- package/src/output/exact-raw.ts +114 -0
- package/src/output/render.ts +396 -2
- package/src/output/structured.ts +211 -0
- package/src/registry.ts +208 -0
- package/src/session/artifacts.ts +452 -200
- package/src/session/commands.ts +1162 -397
- package/src/session/coordinator.ts +251 -0
- package/src/session/har-filter.ts +57 -0
- package/src/session/log-tailer.ts +653 -0
- package/src/session-context.ts +279 -51
- package/src/vault/bundle.ts +32 -17
- package/src/cdp/commands/exec.ts +0 -105
- package/src/cdp/commands/har.ts +0 -128
- package/src/cdp/commands/tabs.ts +0 -183
- package/src/cdp/commands/traffic.ts +0 -121
- package/src/cdp/commands/ui.ts +0 -172
- package/src/cdp/execute.ts +0 -113
- package/src/cdp/har-output.ts +0 -47
- package/src/cdp/usage.ts +0 -79
- package/src/tab-session.ts +0 -91
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** Frozen retained accessibility tree contract (U1), with pure validators only. */
|
|
2
|
+
import { Availability, OK, ValidationResult, combine, fail, isObject } from './primitives.js';
|
|
3
|
+
|
|
4
|
+
export interface AxRectCoverage { readonly eligible: number; readonly attempted: number; readonly succeeded: number; readonly failed: number; readonly skipped: number; }
|
|
5
|
+
/** Every retained AX node is source ordered, including ignored/unnamed nodes, and preserves evidence verbatim. */
|
|
6
|
+
export interface AxTreeNode { readonly axId: string; readonly sourceOrdinal: number; readonly role?: string; readonly name?: string; readonly description?: string; readonly value?: unknown; readonly ignored: boolean; readonly cdpNodeId?: number; readonly backendNodeId?: number; readonly parentAxId?: string; readonly childAxIds: readonly string[]; readonly rect: Availability<unknown>; readonly raw: Record<string, unknown>; }
|
|
7
|
+
/** Exhaustive retained store: first <=5000 source-array nodes, no semantic filtering. */
|
|
8
|
+
export interface AxTreeManifest { readonly schemaVersion: 1; readonly treeId: string; readonly source: 'live' | 'snapshot'; readonly snapshotId?: string; readonly nodes: readonly AxTreeNode[]; readonly sourceTotal?: number; readonly retained: number; readonly dropped?: number; readonly droppedUnknown?: boolean; readonly rectCoverage: AxRectCoverage; readonly iframeExclusion: Availability<null>; }
|
|
9
|
+
/** Sidecar meta at `<owner>/a11y/trees/<tree-id>/meta.json`; tree lifetime equals file lifetime. */
|
|
10
|
+
export interface AxTreeReference { readonly schemaVersion: 1; readonly treeId: string; readonly treePath: string; readonly metaPath: string; readonly owner: string; readonly retained: number; readonly coverage: Pick<AxTreeManifest, 'sourceTotal' | 'dropped' | 'droppedUnknown' | 'rectCoverage'>; }
|
|
11
|
+
export function validateAxTreeManifest(value: unknown): ValidationResult {
|
|
12
|
+
if (!isObject(value)) return fail('a11y tree must be an object');
|
|
13
|
+
const errs: ValidationResult[] = [];
|
|
14
|
+
if (value.schemaVersion !== 1) errs.push(fail('a11y tree schemaVersion must be 1'));
|
|
15
|
+
if (typeof value.treeId !== 'string' || !value.treeId) errs.push(fail('a11y tree missing treeId'));
|
|
16
|
+
if (value.source !== 'live' && value.source !== 'snapshot') errs.push(fail('a11y tree source must be live or snapshot'));
|
|
17
|
+
if (!isObject(value.rectCoverage)) errs.push(fail('a11y tree missing rectCoverage'));
|
|
18
|
+
if (!isObject(value.iframeExclusion)) errs.push(fail('a11y tree missing iframeExclusion'));
|
|
19
|
+
if (!Array.isArray(value.nodes)) errs.push(fail('a11y tree requires nodes array'));
|
|
20
|
+
else {
|
|
21
|
+
if (value.nodes.length > 5000) errs.push(fail('a11y tree retains more than 5000 nodes'));
|
|
22
|
+
const ids = new Set<string>();
|
|
23
|
+
for (const node of value.nodes) {
|
|
24
|
+
if (!isObject(node) || typeof node.axId !== 'string') { errs.push(fail('a11y tree has malformed node')); continue; }
|
|
25
|
+
if (ids.has(node.axId)) errs.push(fail(`a11y tree duplicate retained AX ID: ${node.axId}`));
|
|
26
|
+
ids.add(node.axId);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
if (typeof value.retained !== 'number') errs.push(fail('a11y tree missing retained count'));
|
|
30
|
+
else if (Array.isArray(value.nodes) && value.retained !== value.nodes.length) errs.push(fail('a11y tree retained count must equal node count'));
|
|
31
|
+
if (typeof value.sourceTotal === 'number' && typeof value.dropped === 'number' && typeof value.retained === 'number' && value.sourceTotal !== value.retained + value.dropped) errs.push(fail('a11y tree sourceTotal must equal retained + dropped'));
|
|
32
|
+
return combine(...errs);
|
|
33
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/** Frozen immutable index/cursor contract (U1), pure types/validators only. */
|
|
2
|
+
import { MAX_CURSOR_BYTES, OK, ValidationResult, combine, fail, isAscii, isObject, isSha256Hex } from './primitives.js';
|
|
3
|
+
|
|
4
|
+
/** Exactly the five public leaves permitted to materialize immutable cursor indexes. */
|
|
5
|
+
export type CursorLeaf = 'browser detect' | 'browser list' | 'library list' | 'library search' | 'library show';
|
|
6
|
+
export interface ImmutableIndex<Row = unknown> { readonly schemaVersion: 1; readonly leaf: CursorLeaf; readonly path: string; readonly digest: string; readonly createdAt: string; readonly expiresAt: string; readonly filter: Record<string, unknown>; readonly pageSize: number; readonly order: string; readonly coverage: unknown; readonly rows: readonly Row[]; }
|
|
7
|
+
/** Authenticated opaque cursor claims. The serialized token itself is <=2048 ASCII bytes. */
|
|
8
|
+
export interface CursorClaims { readonly schemaVersion: 1; readonly indexDigest: string; readonly leaf: CursorLeaf; readonly filter: Record<string, unknown>; readonly nextExclusiveOrdinal: number; readonly pageSize: number; readonly expiresAt: string; readonly mac: string; }
|
|
9
|
+
export function validateImmutableIndex(value: unknown): ValidationResult {
|
|
10
|
+
if (!isObject(value)) return fail('immutable index must be an object');
|
|
11
|
+
const errs: ValidationResult[] = [];
|
|
12
|
+
if (value.schemaVersion !== 1) errs.push(fail('index schemaVersion must be 1'));
|
|
13
|
+
if (typeof value.digest !== 'string' || !isSha256Hex(value.digest)) errs.push(fail('index digest must be lowercase SHA-256 hex'));
|
|
14
|
+
if (!Array.isArray(value.rows)) errs.push(fail('index requires rows array'));
|
|
15
|
+
if (!(typeof value.pageSize === 'number' && value.pageSize >= 1 && value.pageSize <= 20)) errs.push(fail('index pageSize must be 1..20'));
|
|
16
|
+
return combine(...errs);
|
|
17
|
+
}
|
|
18
|
+
export function validateCursorToken(token: string): ValidationResult {
|
|
19
|
+
if (!isAscii(token)) return fail('cursor must be ASCII');
|
|
20
|
+
if (Buffer.byteLength(token, 'ascii') > MAX_CURSOR_BYTES) return fail(`cursor exceeds ${MAX_CURSOR_BYTES} ASCII bytes`);
|
|
21
|
+
return OK;
|
|
22
|
+
}
|
|
23
|
+
export function validateCursorClaims(value: unknown): ValidationResult {
|
|
24
|
+
if (!isObject(value)) return fail('cursor claims must be an object');
|
|
25
|
+
const errs: ValidationResult[] = [];
|
|
26
|
+
if (value.schemaVersion !== 1) errs.push(fail('cursor schemaVersion must be 1'));
|
|
27
|
+
if (typeof value.indexDigest !== 'string' || !isSha256Hex(value.indexDigest)) errs.push(fail('cursor missing valid indexDigest'));
|
|
28
|
+
if (!['browser detect', 'browser list', 'library list', 'library search', 'library show'].includes(value.leaf as string)) errs.push(fail('cursor has invalid leaf'));
|
|
29
|
+
if (!isObject(value.filter)) errs.push(fail('cursor missing filter object'));
|
|
30
|
+
if (typeof value.expiresAt !== 'string' || Number.isNaN(Date.parse(value.expiresAt))) errs.push(fail('cursor missing valid expiresAt'));
|
|
31
|
+
if (!(typeof value.nextExclusiveOrdinal === 'number' && Number.isSafeInteger(value.nextExclusiveOrdinal) && value.nextExclusiveOrdinal >= 0)) errs.push(fail('cursor nextExclusiveOrdinal must be nonnegative safe integer'));
|
|
32
|
+
if (!(typeof value.pageSize === 'number' && value.pageSize >= 1 && value.pageSize <= 20)) errs.push(fail('cursor pageSize must be 1..20'));
|
|
33
|
+
if (typeof value.mac !== 'string' || !value.mac) errs.push(fail('cursor missing MAC'));
|
|
34
|
+
return combine(...errs);
|
|
35
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* U1 frozen Capture hard-cut contract surface. Import from this module for
|
|
3
|
+
* descriptor/result/schema types; it has no browser, CLI, output, or process
|
|
4
|
+
* side effects and remains intentionally unreachable from `src/capture.ts`
|
|
5
|
+
* until the atomic U15 public cutover.
|
|
6
|
+
*/
|
|
7
|
+
export * from './primitives.js';
|
|
8
|
+
export * from './results.js';
|
|
9
|
+
export * from './routes.js';
|
|
10
|
+
export * from './snapshot.js';
|
|
11
|
+
export * from './neutral.js';
|
|
12
|
+
export * from './a11y.js';
|
|
13
|
+
export * from './cursor.js';
|
|
14
|
+
export * from './motion.js';
|
|
15
|
+
export * from './release.js';
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Frozen motion observations schema-2 contract (U1), pure types/validators only. */
|
|
2
|
+
import { Availability, OK, ValidationResult, combine, fail, isObject } from './primitives.js';
|
|
3
|
+
|
|
4
|
+
export const MOTION_OBSERVATION_FAMILIES = ['source-record', 'trace-event', 'frame-interval', 'frame-pixel-delta', 'element-rect-delta', 'observer-rect-delta'] as const;
|
|
5
|
+
export type MotionObservationFamily = (typeof MOTION_OBSERVATION_FAMILIES)[number];
|
|
6
|
+
/** Source ordinals are contiguous across the entire recording before final write. */
|
|
7
|
+
export interface MotionObservation { readonly id: string; readonly family: MotionObservationFamily; readonly sourceOrdinal: number; readonly kindRank: number; readonly childRank: number; readonly values: Record<string, unknown>; readonly availability: Availability<null>; readonly provenance: { readonly recordingId: string; readonly artifactPath: string; readonly jsonPointer: string }; }
|
|
8
|
+
export interface MotionObservationsManifest { readonly schemaVersion: 2; readonly recordingId: string; readonly sourceSchemaVersion: 2; readonly observations: readonly MotionObservation[]; readonly completeWithinRetainedCoverage: true; readonly ordering: '(sourceOrdinal,kind-rank,child-rank)'; }
|
|
9
|
+
export function validateMotionObservationsManifest(value: unknown): ValidationResult {
|
|
10
|
+
if (!isObject(value)) return fail('motion observations must be an object');
|
|
11
|
+
const errs: ValidationResult[] = [];
|
|
12
|
+
if (value.schemaVersion !== 2 || value.sourceSchemaVersion !== 2) errs.push(fail('motion observations requires recording/source schemaVersion 2'));
|
|
13
|
+
if (typeof value.recordingId !== 'string' || !value.recordingId) errs.push(fail('motion observations missing recordingId'));
|
|
14
|
+
if (value.ordering !== '(sourceOrdinal,kind-rank,child-rank)') errs.push(fail('motion observations has wrong ordering declaration'));
|
|
15
|
+
if (value.completeWithinRetainedCoverage !== true) errs.push(fail('motion observations must attest retained coverage completeness'));
|
|
16
|
+
if (!Array.isArray(value.observations)) errs.push(fail('motion observations requires array'));
|
|
17
|
+
else for (const observation of value.observations) if (!isObject(observation) || typeof observation.id !== 'string' || !MOTION_OBSERVATION_FAMILIES.includes(observation.family as MotionObservationFamily) || !Number.isSafeInteger(observation.sourceOrdinal)) errs.push(fail('motion observations contains malformed observation'));
|
|
18
|
+
return combine(...errs);
|
|
19
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/** Frozen neutral measurement derived-read contracts (U1), pure types/validators only. */
|
|
2
|
+
import { Availability, OK, ValidationResult, combine, fail, isObject } from './primitives.js';
|
|
3
|
+
|
|
4
|
+
/** The eleven canonical family order is semantic and deterministic. */
|
|
5
|
+
export const NEUTRAL_FAMILIES = [
|
|
6
|
+
'viewport-box-position', 'element-scroll-extent', 'native-interactive-box-size',
|
|
7
|
+
'computed-color-pair-contrast', 'sampled-hit-reception', 'text-inline-extent',
|
|
8
|
+
'form-control-state', 'media-state', 'animation-state',
|
|
9
|
+
'opaque-background-sibling-box-intersection',
|
|
10
|
+
'css-overflow-visible-descendant-peer-box-intersection',
|
|
11
|
+
] as const;
|
|
12
|
+
export type NeutralFamily = (typeof NEUTRAL_FAMILIES)[number];
|
|
13
|
+
|
|
14
|
+
export interface NumericSummary { readonly count: number; readonly unavailableCount: number; readonly min: number | null; readonly p25: number | null; readonly p50: number | null; readonly p75: number | null; readonly max: number | null; }
|
|
15
|
+
export interface FactProvenance { readonly snapshotId: string; readonly sources: readonly string[]; readonly artifactPath: string; readonly jsonPointer: string; }
|
|
16
|
+
export interface NeutralFact { readonly id: string; readonly family: NeutralFamily; readonly schemaVersion: 1; readonly subjects: readonly string[]; readonly values: Record<string, unknown>; readonly units: Record<string, string>; readonly availability: Availability<null>; readonly provenance: FactProvenance; }
|
|
17
|
+
export interface FamilyCoverage { readonly populationDefinition: string; readonly schemaVersion: 1; readonly sources: readonly string[]; readonly availability: Availability<null>; readonly populationCount: number; readonly measuredCount: number; readonly unavailableCount: number; readonly sourceTotal?: number; readonly kept: number; readonly dropped?: number; readonly droppedUnknown?: boolean; readonly caps: readonly string[]; readonly exclusions: readonly string[]; readonly unavailableReasons: Readonly<Record<string, number>>; }
|
|
18
|
+
export interface NeutralFamilyStore { readonly family: NeutralFamily; readonly coverage: FamilyCoverage; readonly numericSummaries: Readonly<Record<string, NumericSummary>>; readonly categoricalMaps: Readonly<Record<string, Readonly<Record<string, number>>>>; readonly relationCandidateAccounting?: Record<string, number>; readonly facts: readonly NeutralFact[]; }
|
|
19
|
+
/** Exhaustive retained measurement evidence, never a bounded display projection. */
|
|
20
|
+
export interface FactsManifest { readonly schemaVersion: 1; readonly snapshotIds: readonly string[]; readonly familyOrder: readonly NeutralFamily[]; readonly families: readonly NeutralFamilyStore[]; readonly completeWithinRetainedCoverage: true; }
|
|
21
|
+
|
|
22
|
+
/** One merged snapshot-local subject; collisions never merge subjects. */
|
|
23
|
+
export interface SelectorSubject { readonly subjectId: string; readonly snapshotId: string; readonly geometryOrdinal?: number; readonly axSourceOrdinal?: number; readonly textSourceOrdinal?: number; readonly backendNodeId?: number; readonly css?: readonly string[]; readonly axIds?: readonly string[]; readonly axNames?: readonly string[]; readonly text?: readonly string[]; }
|
|
24
|
+
export interface SelectorsManifest { readonly schemaVersion: 1; readonly snapshotIds: readonly string[]; readonly order: 'geometry-ordinal-then-ax-document-order-then-text-capture-order'; readonly subjects: readonly SelectorSubject[]; }
|
|
25
|
+
|
|
26
|
+
/** Source occurrence attested by a derived read. SHA-256 is over source `meta.json` bytes. */
|
|
27
|
+
export interface ReadSourceOccurrence { readonly snapshotId: string; readonly canonicalPath: string; readonly metaSchemaVersion: 1 | 2; readonly metaSha256: string; }
|
|
28
|
+
export interface DerivedReadManifest { readonly schemaVersion: 1; readonly readId: string; readonly owner: string; readonly operation: string; readonly sources: readonly ReadSourceOccurrence[]; readonly factsPath: string; readonly selectorsPath: string; readonly familyCounts: Readonly<Record<string, number>>; readonly factOrderVersion: 1; readonly selectorOrderVersion: 1; readonly completeWithinRetainedCoverage: true; readonly cropsPath?: string; }
|
|
29
|
+
|
|
30
|
+
export function validateFactsManifest(value: unknown): ValidationResult {
|
|
31
|
+
if (!isObject(value)) return fail('facts manifest must be an object');
|
|
32
|
+
const errs: ValidationResult[] = [];
|
|
33
|
+
if (value.schemaVersion !== 1) errs.push(fail('facts schemaVersion must be 1'));
|
|
34
|
+
if (!Array.isArray(value.snapshotIds) || !value.snapshotIds.every((x) => typeof x === 'string')) errs.push(fail('facts requires snapshotIds string array'));
|
|
35
|
+
if (!Array.isArray(value.familyOrder) || !Array.isArray(value.families)) errs.push(fail('facts requires familyOrder and families arrays'));
|
|
36
|
+
else for (const family of value.families) {
|
|
37
|
+
if (!isObject(family) || typeof family.family !== 'string' || !isObject(family.coverage) || !Array.isArray(family.facts)) errs.push(fail('facts contains malformed family store'));
|
|
38
|
+
}
|
|
39
|
+
if (value.completeWithinRetainedCoverage !== true) errs.push(fail('facts must attest completeWithinRetainedCoverage=true'));
|
|
40
|
+
return combine(...errs);
|
|
41
|
+
}
|
|
42
|
+
export function validateSelectorsManifest(value: unknown): ValidationResult {
|
|
43
|
+
if (!isObject(value)) return fail('selectors manifest must be an object');
|
|
44
|
+
if (value.schemaVersion !== 1) return fail('selectors schemaVersion must be 1');
|
|
45
|
+
if (!Array.isArray(value.snapshotIds) || !Array.isArray(value.subjects) || typeof value.order !== 'string') return fail('selectors requires snapshotIds, order, and subjects');
|
|
46
|
+
return OK;
|
|
47
|
+
}
|
|
48
|
+
export function validateDerivedReadManifest(value: unknown): ValidationResult {
|
|
49
|
+
if (!isObject(value)) return fail('derived read manifest must be an object');
|
|
50
|
+
const errs: ValidationResult[] = [];
|
|
51
|
+
if (value.schemaVersion !== 1) errs.push(fail('derived read manifest schemaVersion must be 1'));
|
|
52
|
+
for (const f of ['readId', 'owner', 'operation', 'factsPath', 'selectorsPath']) if (typeof value[f] !== 'string' || !value[f]) errs.push(fail(`missing ${f}`));
|
|
53
|
+
if (!Array.isArray(value.sources)) errs.push(fail('derived read manifest requires sources array'));
|
|
54
|
+
if (value.completeWithinRetainedCoverage !== true) errs.push(fail('derived read must attest completeWithinRetainedCoverage=true'));
|
|
55
|
+
return combine(...errs);
|
|
56
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Frozen shared contract primitives for the Capture measurement/help hard cut
|
|
3
|
+
* (build unit U1). This module owns the small, purely-computational validators
|
|
4
|
+
* and shape vocabulary that every other `src/contracts/*` module builds on:
|
|
5
|
+
* argv grammar, ID grammars, integer/decimal grammars, byte bounds, coordinate
|
|
6
|
+
* spaces, and availability shapes.
|
|
7
|
+
*
|
|
8
|
+
* CONTRACT: this file is type/interface + pure functions ONLY. It imports no
|
|
9
|
+
* browser, CLI, filesystem-effect, or caller-facing print/exit API. It never
|
|
10
|
+
* emits to stdout/stderr and never exits the process. Downstream units freeze
|
|
11
|
+
* against these types; changing a shape here is a contract change.
|
|
12
|
+
*
|
|
13
|
+
* EVIDENCE POLICY: nothing here deletes, substitutes, hashes, or redacts a code
|
|
14
|
+
* point. Sanitation, where it exists downstream, is lossless/reversible and
|
|
15
|
+
* protects the result protocol, never secrecy (see `taste/no-redaction`).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** Structured, side-effect-free validation outcome. Validators never throw for invalid data. */
|
|
19
|
+
export interface ValidationResult {
|
|
20
|
+
readonly valid: boolean;
|
|
21
|
+
/** Human-readable, machine-stable reasons. Empty iff `valid`. */
|
|
22
|
+
readonly errors: readonly string[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A passing result. */
|
|
26
|
+
export const OK: ValidationResult = { valid: true, errors: [] };
|
|
27
|
+
|
|
28
|
+
/** Build a failing result from one or more reasons. */
|
|
29
|
+
export function fail(...errors: string[]): ValidationResult {
|
|
30
|
+
return { valid: false, errors };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Merge child results; valid iff every child is valid, accumulating all reasons in order. */
|
|
34
|
+
export function combine(...results: readonly ValidationResult[]): ValidationResult {
|
|
35
|
+
const errors: string[] = [];
|
|
36
|
+
for (const r of results) errors.push(...r.errors);
|
|
37
|
+
return errors.length === 0 ? OK : { valid: false, errors };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Prefix every reason of a result with `context: `. Useful for nested field paths. */
|
|
41
|
+
export function contextualize(context: string, result: ValidationResult): ValidationResult {
|
|
42
|
+
if (result.valid) return OK;
|
|
43
|
+
return { valid: false, errors: result.errors.map((e) => `${context}: ${e}`) };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// Byte bounds (all measured as UTF-8 unless a contract narrows to ASCII).
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
/** Maximum bytes of a single argv value (unless a tighter per-flag bound applies). */
|
|
51
|
+
export const MAX_ARGV_BYTES = 4096;
|
|
52
|
+
/** Maximum bytes of any single bounded-leaf success payload, independently in prose and JSON. */
|
|
53
|
+
export const MAX_BOUNDED_BYTES = 16_384;
|
|
54
|
+
/** Maximum ASCII bytes of an opaque pagination cursor token. */
|
|
55
|
+
export const MAX_CURSOR_BYTES = 2048;
|
|
56
|
+
/** Maximum bytes of one progress line on a declared-progress leaf. */
|
|
57
|
+
export const MAX_PROGRESS_LINE_BYTES = 256;
|
|
58
|
+
/** Maximum total progress bytes across a whole sweep run. */
|
|
59
|
+
export const MAX_PROGRESS_TOTAL_BYTES = 25_600;
|
|
60
|
+
/** Default and maximum page size for every immutable-cursor leaf. */
|
|
61
|
+
export const DEFAULT_PAGE_SIZE = 20;
|
|
62
|
+
export const MAX_PAGE_SIZE = 20;
|
|
63
|
+
|
|
64
|
+
/** UTF-8 byte length of a string without allocating a Buffer view of the caller's data. */
|
|
65
|
+
export function utf8ByteLength(value: string): number {
|
|
66
|
+
return Buffer.byteLength(value, 'utf8');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** True iff every code unit is ASCII (0x00–0x7F). */
|
|
70
|
+
export function isAscii(value: string): boolean {
|
|
71
|
+
for (let i = 0; i < value.length; i++) {
|
|
72
|
+
if (value.charCodeAt(i) > 0x7f) return false;
|
|
73
|
+
}
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** True iff the string contains no NUL. */
|
|
78
|
+
export function isNulFree(value: string): boolean {
|
|
79
|
+
return value.indexOf('\u0000') === -1;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Validate a raw argv value: NUL-free UTF-8 within the declared byte bound. */
|
|
83
|
+
export function validateArgvValue(value: string, maxBytes: number = MAX_ARGV_BYTES): ValidationResult {
|
|
84
|
+
if (!isNulFree(value)) return fail('argv value contains NUL');
|
|
85
|
+
const bytes = utf8ByteLength(value);
|
|
86
|
+
if (bytes > maxBytes) return fail(`argv value is ${bytes} bytes, exceeds ${maxBytes}`);
|
|
87
|
+
return OK;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
// ID and number grammars (case-sensitive; no coercion; safe integers only).
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
/** Public container IDs: session, HAR, snapshot, tree, recording. */
|
|
95
|
+
export const CONTAINER_ID_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
|
|
96
|
+
/** Evidence IDs: fact IDs, occurrence IDs, subject IDs, AX IDs. */
|
|
97
|
+
export const EVIDENCE_ID_RE = /^[A-Za-z0-9][A-Za-z0-9_.:@-]{0,127}$/;
|
|
98
|
+
/** `uint` grammar: 0 or a no-leading-zero positive. */
|
|
99
|
+
export const UINT_RE = /^(?:0|[1-9][0-9]*)$/;
|
|
100
|
+
/** `positive-int` grammar: a no-leading-zero positive. */
|
|
101
|
+
export const POSITIVE_INT_RE = /^[1-9][0-9]*$/;
|
|
102
|
+
/** `signed-int` grammar: 0 or a signed no-leading-zero integer (no `-0`). */
|
|
103
|
+
export const SIGNED_INT_RE = /^(?:0|-?[1-9][0-9]*)$/;
|
|
104
|
+
/** Non-exponent decimal grammar (no `+`, `-0`, leading zero, NaN, or infinity). Sweep endpoints override this. */
|
|
105
|
+
export const DECIMAL_RE = /^(?:0|[1-9][0-9]*)(?:\.[0-9]+)?$/;
|
|
106
|
+
|
|
107
|
+
export function isContainerId(value: string): boolean {
|
|
108
|
+
return CONTAINER_ID_RE.test(value);
|
|
109
|
+
}
|
|
110
|
+
export function isEvidenceId(value: string): boolean {
|
|
111
|
+
return EVIDENCE_ID_RE.test(value);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Parse a `uint` token to a safe integer; null on grammar or safe-integer failure. */
|
|
115
|
+
export function parseUint(token: string): number | null {
|
|
116
|
+
if (!UINT_RE.test(token)) return null;
|
|
117
|
+
const n = Number(token);
|
|
118
|
+
return Number.isSafeInteger(n) ? n : null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Parse a `positive-int` token to a safe integer; null on failure. */
|
|
122
|
+
export function parsePositiveInt(token: string): number | null {
|
|
123
|
+
if (!POSITIVE_INT_RE.test(token)) return null;
|
|
124
|
+
const n = Number(token);
|
|
125
|
+
return Number.isSafeInteger(n) && n >= 1 ? n : null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Parse a `signed-int` token to a safe integer; null on failure. */
|
|
129
|
+
export function parseSignedInt(token: string): number | null {
|
|
130
|
+
if (!SIGNED_INT_RE.test(token)) return null;
|
|
131
|
+
const n = Number(token);
|
|
132
|
+
return Number.isSafeInteger(n) ? n : null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** True iff `value` is an absolute WHATWG http:/https: URL. */
|
|
136
|
+
export function isAbsoluteHttpUrl(value: string): boolean {
|
|
137
|
+
let u: URL;
|
|
138
|
+
try {
|
|
139
|
+
u = new URL(value);
|
|
140
|
+
} catch {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
return u.protocol === 'http:' || u.protocol === 'https:';
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Lowercase 64-hex SHA-256 digest grammar. */
|
|
147
|
+
export const SHA256_HEX_RE = /^[0-9a-f]{64}$/;
|
|
148
|
+
export function isSha256Hex(value: string): boolean {
|
|
149
|
+
return SHA256_HEX_RE.test(value);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
// Coordinate spaces and availability.
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The three declared coordinate spaces. Layers have NO Capture geometric
|
|
158
|
+
* rectangle or coordinate space and therefore never carry one of these.
|
|
159
|
+
*/
|
|
160
|
+
export type CoordinateSpace =
|
|
161
|
+
| 'top-visual-viewport/css-px'
|
|
162
|
+
| 'top-page/css-px'
|
|
163
|
+
| 'screenshot/device-px';
|
|
164
|
+
|
|
165
|
+
export const COORDINATE_SPACES: readonly CoordinateSpace[] = [
|
|
166
|
+
'top-visual-viewport/css-px',
|
|
167
|
+
'top-page/css-px',
|
|
168
|
+
'screenshot/device-px',
|
|
169
|
+
];
|
|
170
|
+
|
|
171
|
+
export function isCoordinateSpace(value: string): value is CoordinateSpace {
|
|
172
|
+
return (COORDINATE_SPACES as readonly string[]).includes(value);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** A retained rectangle always names its coordinate space and unit. */
|
|
176
|
+
export interface GeometricRect {
|
|
177
|
+
readonly space: CoordinateSpace;
|
|
178
|
+
readonly x: number;
|
|
179
|
+
readonly y: number;
|
|
180
|
+
readonly width: number;
|
|
181
|
+
readonly height: number;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* An availability wrapper. A dependent fact is either present with a value or
|
|
186
|
+
* absent with a fixed machine-stable reason and optional detail. Zero is NEVER
|
|
187
|
+
* used to stand in for unavailable evidence.
|
|
188
|
+
*/
|
|
189
|
+
export type Availability<T> =
|
|
190
|
+
| { readonly available: true; readonly value: T }
|
|
191
|
+
| { readonly available: false; readonly reason: string; readonly details?: string };
|
|
192
|
+
|
|
193
|
+
export function isAvailable<T>(a: Availability<T>): a is { available: true; value: T } {
|
|
194
|
+
return a.available === true;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** True iff `x` is a finite number (never NaN/±Infinity). Availability, not zero, marks missing facts. */
|
|
198
|
+
export function isFiniteNumber(x: unknown): x is number {
|
|
199
|
+
return typeof x === 'number' && Number.isFinite(x);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** True iff `x` is a plain (non-null, non-array) object. */
|
|
203
|
+
export function isObject(x: unknown): x is Record<string, unknown> {
|
|
204
|
+
return typeof x === 'object' && x !== null && !Array.isArray(x);
|
|
205
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** Frozen hard-cut release metadata contract (U1), pure types/validators only. */
|
|
2
|
+
import { OK, ValidationResult, combine, fail, isObject, isSha256Hex } from './primitives.js';
|
|
3
|
+
/** Canonical JSON stored in the annotated release tag message. */
|
|
4
|
+
export interface ReleaseTagMetadata { readonly schemaVersion: 1; readonly package: '@crouton-kit/capture'; readonly version: string; readonly baseSha: string; readonly releaseSha: string; readonly gateSha: string; readonly tarball: { readonly filename: string; readonly bytes: number; readonly sha256: string; readonly integrity: string }; readonly runtime: { readonly node: string; readonly npm: string; readonly pnpm: string }; }
|
|
5
|
+
/** Persisted release controller state; conflicts are nonmutating. */
|
|
6
|
+
export type ReleaseDisposition = 'candidate' | 'release_already_published' | 'release_publish_retryable' | 'registry_integrity_conflict' | 'release_state_conflict';
|
|
7
|
+
export function validateReleaseTagMetadata(value: unknown): ValidationResult {
|
|
8
|
+
if (!isObject(value)) return fail('release tag metadata must be an object');
|
|
9
|
+
const errs: ValidationResult[] = [];
|
|
10
|
+
if (value.schemaVersion !== 1) errs.push(fail('release tag schemaVersion must be 1'));
|
|
11
|
+
if (value.package !== '@crouton-kit/capture') errs.push(fail('release tag package must be @crouton-kit/capture'));
|
|
12
|
+
if (typeof value.version !== 'string' || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(value.version)) errs.push(fail('invalid version'));
|
|
13
|
+
for (const key of ['baseSha', 'releaseSha', 'gateSha']) if (typeof value[key] !== 'string' || !/^[0-9a-f]{40,64}$/.test(value[key] as string)) errs.push(fail(`invalid ${key}`));
|
|
14
|
+
if (!isObject(value.tarball) || typeof value.tarball.filename !== 'string' || !(typeof value.tarball.bytes === 'number' && value.tarball.bytes >= 0) || typeof value.tarball.integrity !== 'string' || typeof value.tarball.sha256 !== 'string' || !isSha256Hex(value.tarball.sha256)) errs.push(fail('invalid tarball metadata'));
|
|
15
|
+
if (!isObject(value.runtime) || !['node', 'npm', 'pnpm'].every((key) => typeof value.runtime[key] === 'string' && value.runtime[key])) errs.push(fail('invalid runtime metadata'));
|
|
16
|
+
return combine(...errs);
|
|
17
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Frozen result-lane contracts (U1). Two lanes only:
|
|
3
|
+
*
|
|
4
|
+
* - `bounded:<domain>` — one typed projection through the bounded output
|
|
5
|
+
* kernel: at most {@link MAX_BOUNDED_BYTES} UTF-8 bytes independently in
|
|
6
|
+
* prose and JSON, with fixed identity/summary/scope/coverage/count/artifact
|
|
7
|
+
* metadata that is NEVER dropped, and optional records removed only at
|
|
8
|
+
* record boundaries (never byte-sliced) until both encodings fit.
|
|
9
|
+
* - `exact-raw:<payload>` — handler-produced bytes/text for one named payload,
|
|
10
|
+
* unbounded and preserved exactly (including final-newline presence); empty
|
|
11
|
+
* stderr on success. Global `--json` is rejected before effects.
|
|
12
|
+
*
|
|
13
|
+
* This module is type/interface + pure validators only. It imports no print or
|
|
14
|
+
* exit API. The "no direct stdout" rule is a contract on the handlers that
|
|
15
|
+
* produce these values, encoded in the descriptor layer (see `routes.ts`).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
MAX_BOUNDED_BYTES,
|
|
20
|
+
OK,
|
|
21
|
+
ValidationResult,
|
|
22
|
+
combine,
|
|
23
|
+
contextualize,
|
|
24
|
+
fail,
|
|
25
|
+
isObject,
|
|
26
|
+
utf8ByteLength,
|
|
27
|
+
} from './primitives.js';
|
|
28
|
+
|
|
29
|
+
export { MAX_BOUNDED_BYTES };
|
|
30
|
+
|
|
31
|
+
/** Discriminator for the two public result lanes plus the effect-only branch/launcher rows. */
|
|
32
|
+
export type ResultLaneKind = 'bounded' | 'exact-raw' | 'branch' | 'launcher-metadata';
|
|
33
|
+
|
|
34
|
+
/** A `bounded:<domain>` lane naming exactly one bounded projection domain. */
|
|
35
|
+
export interface BoundedLane {
|
|
36
|
+
readonly kind: 'bounded';
|
|
37
|
+
readonly domain: string;
|
|
38
|
+
/** The JSON/prose schema name the bounded kernel serializes. */
|
|
39
|
+
readonly schema: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** An exact raw lane naming its handler-owned unbounded payload. */
|
|
43
|
+
export interface ExactRawLane {
|
|
44
|
+
readonly kind: 'exact-raw';
|
|
45
|
+
/** The payload type, e.g. `recorded HAR bytes`. */
|
|
46
|
+
readonly payload: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A branch row: help + child assembly, no result. */
|
|
50
|
+
export interface BranchLane {
|
|
51
|
+
readonly kind: 'branch';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The launcher `--version` metadata row. */
|
|
55
|
+
export interface LauncherMetadataLane {
|
|
56
|
+
readonly kind: 'launcher-metadata';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export type ResultLane = BoundedLane | ExactRawLane | BranchLane | LauncherMetadataLane;
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// Byte/list bounds descriptor a bounded leaf declares.
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Every bounded leaf declares its collection and byte bounds up front so the
|
|
67
|
+
* registry can reject a growing collection that lacks an omission contract.
|
|
68
|
+
*/
|
|
69
|
+
export interface BoundedBounds {
|
|
70
|
+
/** Independent max UTF-8 bytes of prose and JSON. Always {@link MAX_BOUNDED_BYTES}. */
|
|
71
|
+
readonly maxBytes: number;
|
|
72
|
+
/** Max displayed records for the primary collection (e.g. 20). */
|
|
73
|
+
readonly maxRecords: number;
|
|
74
|
+
/**
|
|
75
|
+
* Whether the primary collection can grow with the underlying source. A
|
|
76
|
+
* growing collection MUST declare `paginated` so a cursor covers every row.
|
|
77
|
+
*/
|
|
78
|
+
readonly growing: boolean;
|
|
79
|
+
/** True iff this leaf adopts the immutable-cursor pagination service. */
|
|
80
|
+
readonly paginated: boolean;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// Bounded projection payload shape (the kernel's typed input, pre-encoding).
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
/** Fixed metadata every bounded projection carries and never drops when bounding. */
|
|
88
|
+
export interface BoundedFixedMeta {
|
|
89
|
+
readonly domain: string;
|
|
90
|
+
readonly schema: string;
|
|
91
|
+
/** Identity of the subject the projection describes (snapshot id, session id, tree id, …). */
|
|
92
|
+
readonly identity: Record<string, string>;
|
|
93
|
+
/** Scope and source coverage (populations/caps/availability), preserved verbatim. */
|
|
94
|
+
readonly coverage: unknown;
|
|
95
|
+
/** total/displayed/omitted/limit for the primary collection. */
|
|
96
|
+
readonly counts: BoundedCollectionCounts;
|
|
97
|
+
/** Absolute artifact paths a caller can re-read for the exhaustive evidence. */
|
|
98
|
+
readonly artifacts: readonly string[];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** total/displayed/omitted/limit accounting for one bounded collection. */
|
|
102
|
+
export interface BoundedCollectionCounts {
|
|
103
|
+
readonly total: number;
|
|
104
|
+
readonly displayed: number;
|
|
105
|
+
readonly omitted: number;
|
|
106
|
+
readonly limit: number;
|
|
107
|
+
/** Machine-stable reasons the omitted records were dropped (e.g. `byte-bound`, `record-limit`). */
|
|
108
|
+
readonly omissionCauses: readonly string[];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* A capped string reports byte accounting and an exact RFC-6901 pointer into
|
|
113
|
+
* the exhaustive artifact rather than silently truncating.
|
|
114
|
+
*/
|
|
115
|
+
export interface CappedStringMeta {
|
|
116
|
+
readonly sourceBytes: number;
|
|
117
|
+
readonly displayedBytes: number;
|
|
118
|
+
readonly omittedBytes: number;
|
|
119
|
+
/** Absolute artifact path + RFC-6901 JSON Pointer to the full value. */
|
|
120
|
+
readonly artifactPath: string;
|
|
121
|
+
readonly jsonPointer: string;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* A structured public error (bounded, factual). Written to stderr by the result
|
|
126
|
+
* owner, never by a handler. Exit 2 = invalid route/grammar/selection/cursor
|
|
127
|
+
* input; exit 3 = source/runtime/publication failure.
|
|
128
|
+
*/
|
|
129
|
+
export interface BoundedError {
|
|
130
|
+
readonly message: string;
|
|
131
|
+
readonly exit: 2 | 3;
|
|
132
|
+
readonly details?: Record<string, unknown>;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
// Validators.
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
export function validateResultLane(lane: ResultLane): ValidationResult {
|
|
140
|
+
switch (lane.kind) {
|
|
141
|
+
case 'bounded':
|
|
142
|
+
if (!lane.domain) return fail('bounded lane missing domain');
|
|
143
|
+
if (!lane.schema) return fail('bounded lane missing schema');
|
|
144
|
+
return OK;
|
|
145
|
+
case 'exact-raw':
|
|
146
|
+
if (!lane.payload) return fail('exact-raw lane missing payload type');
|
|
147
|
+
return OK;
|
|
148
|
+
case 'branch':
|
|
149
|
+
case 'launcher-metadata':
|
|
150
|
+
return OK;
|
|
151
|
+
default:
|
|
152
|
+
return fail(`unknown result lane kind: ${(lane as { kind: string }).kind}`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function validateBoundedBounds(bounds: BoundedBounds): ValidationResult {
|
|
157
|
+
const errs: ValidationResult[] = [];
|
|
158
|
+
if (bounds.maxBytes !== MAX_BOUNDED_BYTES) {
|
|
159
|
+
errs.push(fail(`bounded maxBytes must be ${MAX_BOUNDED_BYTES}, got ${bounds.maxBytes}`));
|
|
160
|
+
}
|
|
161
|
+
if (!(bounds.maxRecords >= 0)) errs.push(fail('bounded maxRecords must be >= 0'));
|
|
162
|
+
if (bounds.growing && !bounds.paginated) {
|
|
163
|
+
errs.push(fail('a growing bounded collection must declare pagination (cursor coverage)'));
|
|
164
|
+
}
|
|
165
|
+
return combine(...errs);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** True iff both encodings fit the independent byte bound. */
|
|
169
|
+
export function fitsBoundedBounds(prose: string, json: string): ValidationResult {
|
|
170
|
+
const errs: ValidationResult[] = [];
|
|
171
|
+
const p = utf8ByteLength(prose);
|
|
172
|
+
const j = utf8ByteLength(json);
|
|
173
|
+
if (p > MAX_BOUNDED_BYTES) errs.push(fail(`prose is ${p} bytes, exceeds ${MAX_BOUNDED_BYTES}`));
|
|
174
|
+
if (j > MAX_BOUNDED_BYTES) errs.push(fail(`json is ${j} bytes, exceeds ${MAX_BOUNDED_BYTES}`));
|
|
175
|
+
return combine(...errs);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Validate collection counts are internally consistent: displayed+omitted==total, displayed<=limit. */
|
|
179
|
+
export function validateCollectionCounts(counts: BoundedCollectionCounts): ValidationResult {
|
|
180
|
+
const errs: ValidationResult[] = [];
|
|
181
|
+
if (counts.displayed + counts.omitted !== counts.total) {
|
|
182
|
+
errs.push(fail(`displayed(${counts.displayed}) + omitted(${counts.omitted}) != total(${counts.total})`));
|
|
183
|
+
}
|
|
184
|
+
if (counts.displayed > counts.limit) {
|
|
185
|
+
errs.push(fail(`displayed(${counts.displayed}) exceeds limit(${counts.limit})`));
|
|
186
|
+
}
|
|
187
|
+
if (counts.omitted > 0 && counts.omissionCauses.length === 0) {
|
|
188
|
+
errs.push(fail('omitted records present but no omissionCauses declared'));
|
|
189
|
+
}
|
|
190
|
+
return combine(...errs);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Validate the fixed metadata block that must precede any bounded records. */
|
|
194
|
+
export function validateBoundedFixedMeta(meta: unknown): ValidationResult {
|
|
195
|
+
if (!isObject(meta)) return fail('bounded fixed meta must be an object');
|
|
196
|
+
const errs: ValidationResult[] = [];
|
|
197
|
+
if (typeof meta.domain !== 'string' || !meta.domain) errs.push(fail('missing domain'));
|
|
198
|
+
if (typeof meta.schema !== 'string' || !meta.schema) errs.push(fail('missing schema'));
|
|
199
|
+
if (!isObject(meta.identity)) errs.push(fail('missing identity object'));
|
|
200
|
+
if (!('coverage' in meta)) errs.push(fail('missing coverage'));
|
|
201
|
+
if (!Array.isArray(meta.artifacts)) errs.push(fail('artifacts must be an array of absolute paths'));
|
|
202
|
+
if (isObject(meta.counts)) {
|
|
203
|
+
errs.push(contextualize('counts', validateCollectionCounts(meta.counts as BoundedCollectionCounts)));
|
|
204
|
+
} else {
|
|
205
|
+
errs.push(fail('missing counts'));
|
|
206
|
+
}
|
|
207
|
+
return combine(...errs);
|
|
208
|
+
}
|