@xemahq/dsl 0.6.1 → 0.8.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/dist/deliverable-spec/lib/schema.d.ts +2 -0
- package/dist/deliverable-spec/lib/schema.d.ts.map +1 -1
- package/dist/deliverable-spec/lib/schema.js +10 -1
- package/dist/deliverable-spec/lib/schema.js.map +1 -1
- package/dist/schema/reusable-workflow.schema.json +9 -1
- package/dist/schema/workflow.schema.json +39 -13
- package/dist/workflow/index.d.ts +3 -0
- package/dist/workflow/index.d.ts.map +1 -1
- package/dist/workflow/index.js +7 -1
- package/dist/workflow/index.js.map +1 -1
- package/dist/workflow/lib/compiler/compile.d.ts.map +1 -1
- package/dist/workflow/lib/compiler/compile.js +105 -20
- package/dist/workflow/lib/compiler/compile.js.map +1 -1
- package/dist/workflow/lib/compiler/concurrency.js +1 -1
- package/dist/workflow/lib/compiler/concurrency.js.map +1 -1
- package/dist/workflow/lib/compiler/manifest-source.d.ts +2 -1
- package/dist/workflow/lib/compiler/manifest-source.d.ts.map +1 -1
- package/dist/workflow/lib/compiler/manifest-source.js +13 -4
- package/dist/workflow/lib/compiler/manifest-source.js.map +1 -1
- package/dist/workflow/lib/compiler/review-step.d.ts +2 -1
- package/dist/workflow/lib/compiler/review-step.d.ts.map +1 -1
- package/dist/workflow/lib/compiler/review-step.js +13 -1
- package/dist/workflow/lib/compiler/review-step.js.map +1 -1
- package/dist/workflow/lib/compiler/types.d.ts +3 -2
- package/dist/workflow/lib/compiler/types.d.ts.map +1 -1
- package/dist/workflow/lib/deliverable-spec-introspection-error.d.ts +12 -0
- package/dist/workflow/lib/deliverable-spec-introspection-error.d.ts.map +1 -0
- package/dist/workflow/lib/deliverable-spec-introspection-error.js +33 -0
- package/dist/workflow/lib/deliverable-spec-introspection-error.js.map +1 -0
- package/dist/workflow/lib/deliverable-spec-keys.d.ts +1 -1
- package/dist/workflow/lib/deliverable-spec-keys.d.ts.map +1 -1
- package/dist/workflow/lib/deliverable-spec-keys.js +27 -52
- package/dist/workflow/lib/deliverable-spec-keys.js.map +1 -1
- package/dist/workflow/lib/deliverable-spec-source-scan.d.ts +6 -0
- package/dist/workflow/lib/deliverable-spec-source-scan.d.ts.map +1 -0
- package/dist/workflow/lib/deliverable-spec-source-scan.js +189 -0
- package/dist/workflow/lib/deliverable-spec-source-scan.js.map +1 -0
- package/dist/workflow/lib/expression/context.d.ts +1 -1
- package/dist/workflow/lib/expression/context.d.ts.map +1 -1
- package/dist/workflow/lib/expression/context.js +1 -1
- package/dist/workflow/lib/expression/context.js.map +1 -1
- package/dist/workflow/lib/types.d.ts +1 -1
- package/dist/workflow/lib/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/schema/reusable-workflow.schema.json +9 -1
- package/schema/workflow.schema.json +39 -13
- package/src/deliverable-spec/lib/schema.ts +23 -0
- package/src/workflow/index.ts +7 -0
- package/src/workflow/lib/compiler/compile.ts +302 -149
- package/src/workflow/lib/compiler/concurrency.ts +1 -1
- package/src/workflow/lib/compiler/manifest-source.ts +23 -8
- package/src/workflow/lib/compiler/review-step.ts +33 -2
- package/src/workflow/lib/compiler/types.ts +9 -9
- package/src/workflow/lib/deliverable-spec-introspection-error.ts +63 -0
- package/src/workflow/lib/deliverable-spec-keys.ts +88 -50
- package/src/workflow/lib/deliverable-spec-source-scan.ts +280 -0
- package/src/workflow/lib/expression/context.ts +2 -2
- package/src/workflow/lib/types.ts +38 -8
|
@@ -40,7 +40,10 @@
|
|
|
40
40
|
* worker's runtime contract — they intentionally diverge.
|
|
41
41
|
*/
|
|
42
42
|
|
|
43
|
-
import {
|
|
43
|
+
import {
|
|
44
|
+
WorkflowErrorCode,
|
|
45
|
+
type CompiledManifestSource,
|
|
46
|
+
} from '@xemahq/kernel-contracts/workflow';
|
|
44
47
|
|
|
45
48
|
import { WorkflowDslError } from '../errors';
|
|
46
49
|
import {
|
|
@@ -379,6 +382,7 @@ export function rewriteReviewStepWith(
|
|
|
379
382
|
jobKey: string,
|
|
380
383
|
payload: WorkflowJobDeclaration,
|
|
381
384
|
workflow: WorkflowDocument,
|
|
385
|
+
manifestSourcesByJob: Readonly<Record<string, CompiledManifestSource | null>>,
|
|
382
386
|
): Readonly<Record<string, unknown>> {
|
|
383
387
|
if (!isReviewActionRef(payload.uses)) {
|
|
384
388
|
return payload.with ?? {};
|
|
@@ -423,6 +427,11 @@ export function rewriteReviewStepWith(
|
|
|
423
427
|
step: shape.redraft.step,
|
|
424
428
|
uses: producer.uses,
|
|
425
429
|
with: producer.with ?? {},
|
|
430
|
+
manifestSource: requireRedraftManifestSource(
|
|
431
|
+
jobKey,
|
|
432
|
+
shape.redraft.step,
|
|
433
|
+
manifestSourcesByJob,
|
|
434
|
+
),
|
|
426
435
|
};
|
|
427
436
|
const strategy = compileRedraftStrategyIR(producer.strategy);
|
|
428
437
|
if (strategy !== null) {
|
|
@@ -437,6 +446,26 @@ export function rewriteReviewStepWith(
|
|
|
437
446
|
return out;
|
|
438
447
|
}
|
|
439
448
|
|
|
449
|
+
function requireRedraftManifestSource(
|
|
450
|
+
reviewJobKey: string,
|
|
451
|
+
producerJobKey: string,
|
|
452
|
+
manifestSourcesByJob: Readonly<Record<string, CompiledManifestSource | null>>,
|
|
453
|
+
): CompiledManifestSource {
|
|
454
|
+
const source = manifestSourcesByJob[producerJobKey];
|
|
455
|
+
if (source === undefined || source === null) {
|
|
456
|
+
throw new WorkflowDslError(
|
|
457
|
+
WorkflowErrorCode.DSL_SEMANTIC_INVALID,
|
|
458
|
+
`Job '${reviewJobKey}' redraft producer '${producerJobKey}' has no compiled Agent revision source.`,
|
|
459
|
+
{
|
|
460
|
+
jobKey: reviewJobKey,
|
|
461
|
+
fieldPath: 'with.redraft.step',
|
|
462
|
+
referencedJob: producerJobKey,
|
|
463
|
+
},
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
return source;
|
|
467
|
+
}
|
|
468
|
+
|
|
440
469
|
/**
|
|
441
470
|
* Pre-resolve a producer step's authored matrix strategy into a
|
|
442
471
|
* JSON-serializable IR descriptor the worker carries through the
|
|
@@ -513,5 +542,7 @@ function expandStaticMatrixAxes(
|
|
|
513
542
|
}
|
|
514
543
|
entries = next;
|
|
515
544
|
}
|
|
516
|
-
return entries.map(Object.freeze) as readonly Readonly<
|
|
545
|
+
return entries.map(Object.freeze) as readonly Readonly<
|
|
546
|
+
Record<string, unknown>
|
|
547
|
+
>[];
|
|
517
548
|
}
|
|
@@ -14,13 +14,13 @@ import type { ActionManifest, WorkflowDocument } from '../types';
|
|
|
14
14
|
*
|
|
15
15
|
* - `workflow` — validated WorkflowDocument (pass through
|
|
16
16
|
* `validateWorkflowDocument` first).
|
|
17
|
-
* - `workflowRef` — slug/
|
|
17
|
+
* - `workflowRef` — slug/revision/content-contract identity, resolved from the DB.
|
|
18
18
|
* - `trigger` — the payload that fired this run.
|
|
19
19
|
* - `resolvedRefs` — map of `uses:` strings to resolved refs, populated
|
|
20
20
|
* by looking up each job's `uses:` in the action-manifest registry
|
|
21
21
|
* (for `xema/...`) or the reusable-workflow registry (for
|
|
22
22
|
* `xema://workflow/...`).
|
|
23
|
-
* - `
|
|
23
|
+
* - `workflowDefinitionContentHash` — content hash of the immutable source revision the
|
|
24
24
|
* compiler is consuming. Emitted into CompiledRun for audit.
|
|
25
25
|
*/
|
|
26
26
|
export interface CompileInput {
|
|
@@ -29,11 +29,10 @@ export interface CompileInput {
|
|
|
29
29
|
readonly trigger: TriggerPayload;
|
|
30
30
|
readonly resolvedRefs: Readonly<Record<string, ResolvedRef>>;
|
|
31
31
|
/**
|
|
32
|
-
* Map of agent
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
* parsing the bare slug off the `<slug>@<version>` ref before lookup.
|
|
32
|
+
* Map of authored literal agent ref → immutable Agent revision metadata.
|
|
33
|
+
* The engine resolves each exact ref through llm-registry-api before
|
|
34
|
+
* compilation. Literal refs are therefore compile-time selectors only:
|
|
35
|
+
* CompiledRun stores the immutable revision id + content hash.
|
|
37
36
|
* Expression-shaped refs (e.g. `${{ inputs.agentSlug }}`) are skipped
|
|
38
37
|
* — they resolve at dispatch and the activity's `AGENT_NOT_REGISTERED`
|
|
39
38
|
* preflight catches them at runtime.
|
|
@@ -53,7 +52,7 @@ export interface CompileInput {
|
|
|
53
52
|
readonly resolvedDeliverableSpecs?: Readonly<
|
|
54
53
|
Record<string, ResolvedDeliverableSpec>
|
|
55
54
|
>;
|
|
56
|
-
readonly
|
|
55
|
+
readonly workflowDefinitionContentHash: string;
|
|
57
56
|
/**
|
|
58
57
|
* Contents of every wallet referenced under `requires.wallets`, plus
|
|
59
58
|
* any additional wallets a dispatch caller asked for. The compiler
|
|
@@ -129,7 +128,8 @@ export interface InstallationCompileScope {
|
|
|
129
128
|
*/
|
|
130
129
|
export interface ResolvedAgentMeta {
|
|
131
130
|
readonly slug: string;
|
|
132
|
-
readonly
|
|
131
|
+
readonly agentRevisionId: string;
|
|
132
|
+
readonly agentContentHash: string;
|
|
133
133
|
}
|
|
134
134
|
|
|
135
135
|
/**
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed failure raised when a deliverable spec's Zod source cannot be
|
|
3
|
+
* introspected UNAMBIGUOUSLY. Distinct from the `null` return of the
|
|
4
|
+
* extractors, which means "this source has no introspectable shape at
|
|
5
|
+
* all" — a spec kind or body we simply cannot read. This error means the
|
|
6
|
+
* opposite: the source DOES declare a shape, but the spec has not said
|
|
7
|
+
* WHICH one is the contract, so any answer would be a guess.
|
|
8
|
+
*
|
|
9
|
+
* Callers MUST branch on `.reason` — free-form message matching is
|
|
10
|
+
* forbidden so handling stays exhaustive (same convention as
|
|
11
|
+
* `WorkflowDslError.code`).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Closed set of introspection failure reasons. */
|
|
15
|
+
export enum DeliverableSpecIntrospectionFailure {
|
|
16
|
+
/**
|
|
17
|
+
* The source declares two or more top-level `z.object({ … })` schemas
|
|
18
|
+
* and the spec did not name which one is the contract. Resolving this
|
|
19
|
+
* by position ("the first one") is guesswork: a schema hoisted above
|
|
20
|
+
* the contract purely so the contract can reference it would silently
|
|
21
|
+
* become the contract.
|
|
22
|
+
*/
|
|
23
|
+
AmbiguousSchema = 'AMBIGUOUS_SCHEMA',
|
|
24
|
+
/**
|
|
25
|
+
* The spec named a contract export (`zodSchemaExport`) that the source
|
|
26
|
+
* does not declare as a top-level `z.object({ … })` — a typo, a rename,
|
|
27
|
+
* or an unbalanced literal the scanner could not close.
|
|
28
|
+
*/
|
|
29
|
+
ExportNotFound = 'EXPORT_NOT_FOUND',
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class DeliverableSpecIntrospectionError extends Error {
|
|
33
|
+
readonly reason: DeliverableSpecIntrospectionFailure;
|
|
34
|
+
readonly details: Readonly<Record<string, unknown>>;
|
|
35
|
+
|
|
36
|
+
constructor(
|
|
37
|
+
reason: DeliverableSpecIntrospectionFailure,
|
|
38
|
+
message: string,
|
|
39
|
+
details: Readonly<Record<string, unknown>> = {},
|
|
40
|
+
) {
|
|
41
|
+
super(message);
|
|
42
|
+
this.name = 'DeliverableSpecIntrospectionError';
|
|
43
|
+
this.reason = reason;
|
|
44
|
+
this.details = details;
|
|
45
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
toJSON(): Readonly<Record<string, unknown>> {
|
|
49
|
+
return {
|
|
50
|
+
name: this.name,
|
|
51
|
+
reason: this.reason,
|
|
52
|
+
message: this.message,
|
|
53
|
+
details: this.details,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Narrowing helper. */
|
|
59
|
+
export function isDeliverableSpecIntrospectionError(
|
|
60
|
+
err: unknown,
|
|
61
|
+
): err is DeliverableSpecIntrospectionError {
|
|
62
|
+
return err instanceof DeliverableSpecIntrospectionError;
|
|
63
|
+
}
|
|
@@ -6,34 +6,91 @@
|
|
|
6
6
|
* spec the producing job declared via `with.deliverableSpecRef` —
|
|
7
7
|
* catching "the YAML reads `.changeUnits` but the spec has
|
|
8
8
|
* `.handoffPackage`" at compile time, not at runtime when projection
|
|
9
|
-
* blows up.
|
|
9
|
+
* blows up. The same keys are the ONLY content gate the harvester has
|
|
10
|
+
* for a `ZOD_SCHEMA` spec (nothing anywhere evaluates the Zod schema),
|
|
11
|
+
* so a wrong key set silently rejects correct agent output.
|
|
10
12
|
*
|
|
11
13
|
* Two extractors:
|
|
12
|
-
* - `extractZodTopLevelObjectKeys`:
|
|
13
|
-
* literal
|
|
14
|
-
* the deliverable-specs-api
|
|
15
|
-
* validation; single source of
|
|
16
|
-
* and the harvester agree.
|
|
14
|
+
* - `extractZodTopLevelObjectKeys`: reads the field names of the
|
|
15
|
+
* `z.object({ … })` literal a spec designates as its contract, out of
|
|
16
|
+
* TypeScript Zod source. Same scanner the deliverable-specs-api
|
|
17
|
+
* ZodSchemaHandler uses for harvest-time validation; single source of
|
|
18
|
+
* truth lives here so the DSL compiler and the harvester agree.
|
|
17
19
|
* - `extractJsonSchemaTopLevelKeys`: pulls keys from the `properties`
|
|
18
20
|
* object of a JSON Schema document.
|
|
19
21
|
*
|
|
20
|
-
* Both return `null` when
|
|
21
|
-
* field check (preview / specs without introspectable
|
|
22
|
+
* Both return `null` when a source has NO introspectable shape, so
|
|
23
|
+
* callers can skip the field check (preview / specs without introspectable
|
|
24
|
+
* shape). `null` never means "we guessed" — an unresolvable-but-present
|
|
25
|
+
* shape raises `DeliverableSpecIntrospectionError` instead.
|
|
22
26
|
*/
|
|
23
27
|
|
|
28
|
+
import {
|
|
29
|
+
DeliverableSpecIntrospectionError,
|
|
30
|
+
DeliverableSpecIntrospectionFailure,
|
|
31
|
+
} from './deliverable-spec-introspection-error';
|
|
32
|
+
import { scanTopLevelZodObjectDeclarations } from './deliverable-spec-source-scan';
|
|
33
|
+
|
|
34
|
+
import type { TopLevelZodObjectDeclaration } from './deliverable-spec-source-scan';
|
|
35
|
+
|
|
24
36
|
/**
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
37
|
+
* Return the top-level field identifiers of the `z.object({ … })` literal
|
|
38
|
+
* that IS this spec's contract.
|
|
39
|
+
*
|
|
40
|
+
* Which literal that is comes from the spec, not from source order:
|
|
41
|
+
*
|
|
42
|
+
* - `exportName` given → the literal bound to that name. Absent from
|
|
43
|
+
* the source ⇒ `EXPORT_NOT_FOUND`, never a fallback to another
|
|
44
|
+
* schema. A spec that names a contract it does not have is broken,
|
|
45
|
+
* and guessing would reintroduce exactly the bug this parameter
|
|
46
|
+
* exists to remove.
|
|
47
|
+
* - `exportName` omitted, source declares exactly ONE top-level
|
|
48
|
+
* literal → that one. There is nothing to choose between, so the
|
|
49
|
+
* spec is not asked to restate it.
|
|
50
|
+
* - `exportName` omitted, source declares TWO OR MORE →
|
|
51
|
+
* `AMBIGUOUS_SCHEMA`. This is the case the predecessor resolved by
|
|
52
|
+
* taking the first literal in the file, which silently picked an
|
|
53
|
+
* element/helper schema hoisted above the contract that references
|
|
54
|
+
* it. There is no correct positional answer; the spec must say.
|
|
55
|
+
* - source declares NONE → `null` (no introspectable shape).
|
|
56
|
+
*
|
|
57
|
+
* Deliberately a scanner, not a Zod evaluation: this runs in the DSL
|
|
58
|
+
* compiler, a CI gate, and a request-scoped API handler, none of which
|
|
59
|
+
* may transpile or execute spec-authored TypeScript.
|
|
60
|
+
*
|
|
61
|
+
* @throws {DeliverableSpecIntrospectionError} on a named export that is
|
|
62
|
+
* absent, or on an unnamed contract in a multi-schema source.
|
|
31
63
|
*/
|
|
32
|
-
export function extractZodTopLevelObjectKeys(
|
|
64
|
+
export function extractZodTopLevelObjectKeys(
|
|
65
|
+
source: string,
|
|
66
|
+
exportName?: string | null,
|
|
67
|
+
): readonly string[] | null {
|
|
33
68
|
if (!source || source.trim().length === 0) return null;
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
69
|
+
const declarations = scanTopLevelZodObjectDeclarations(source);
|
|
70
|
+
const named = exportName?.trim() ?? '';
|
|
71
|
+
|
|
72
|
+
if (named.length > 0) {
|
|
73
|
+
const match = declarations.find((d) => d.exportName === named);
|
|
74
|
+
if (!match) {
|
|
75
|
+
throw new DeliverableSpecIntrospectionError(
|
|
76
|
+
DeliverableSpecIntrospectionFailure.ExportNotFound,
|
|
77
|
+
`deliverable spec names '${named}' as its Zod contract, but the source declares no top-level \`z.object({ … })\` bound to that name. ` +
|
|
78
|
+
`${describeDeclarations(declarations)} Fix the spec's \`zodSchemaExport\`, or check the literal's braces are balanced.`,
|
|
79
|
+
{ exportName: named, available: availableNames(declarations) },
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
return match.keys;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (declarations.length === 0) return null;
|
|
86
|
+
if (declarations.length === 1) return declarations[0]!.keys;
|
|
87
|
+
|
|
88
|
+
throw new DeliverableSpecIntrospectionError(
|
|
89
|
+
DeliverableSpecIntrospectionFailure.AmbiguousSchema,
|
|
90
|
+
`deliverable spec source declares ${declarations.length} top-level \`z.object({ … })\` schemas and the spec does not say which one is the contract. ` +
|
|
91
|
+
`${describeDeclarations(declarations)} Set \`zodSchemaExport\` on the spec to the contract's export name.`,
|
|
92
|
+
{ available: availableNames(declarations) },
|
|
93
|
+
);
|
|
37
94
|
}
|
|
38
95
|
|
|
39
96
|
/**
|
|
@@ -71,39 +128,20 @@ function isPlainObject(v: unknown): v is Record<string, unknown> {
|
|
|
71
128
|
return v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
72
129
|
}
|
|
73
130
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
for (let i = start; i < source.length; i += 1) {
|
|
81
|
-
const ch = source[i];
|
|
82
|
-
if (ch === '{') depth += 1;
|
|
83
|
-
else if (ch === '}' && --depth === 0) return source.slice(start, i);
|
|
84
|
-
}
|
|
85
|
-
return null;
|
|
131
|
+
function availableNames(
|
|
132
|
+
declarations: readonly TopLevelZodObjectDeclaration[],
|
|
133
|
+
): readonly string[] {
|
|
134
|
+
return declarations
|
|
135
|
+
.map((d) => d.exportName)
|
|
136
|
+
.filter((n): n is string => n !== null);
|
|
86
137
|
}
|
|
87
138
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
if (depth.brace !== 0 || depth.paren !== 0) continue;
|
|
95
|
-
if (i !== 0 && body[i] !== '\n' && body[i] !== ',') continue;
|
|
96
|
-
const rest = body.slice(i === 0 ? 0 : i + 1);
|
|
97
|
-
const keyMatch = /^\s*([A-Za-z_$][A-Za-z0-9_$]*)\s*:/.exec(rest);
|
|
98
|
-
if (keyMatch?.[1]) keys.push(keyMatch[1]);
|
|
139
|
+
function describeDeclarations(
|
|
140
|
+
declarations: readonly TopLevelZodObjectDeclaration[],
|
|
141
|
+
): string {
|
|
142
|
+
const names = availableNames(declarations);
|
|
143
|
+
if (names.length === 0) {
|
|
144
|
+
return 'The source declares no NAMED top-level schema.';
|
|
99
145
|
}
|
|
100
|
-
return
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
function updateDepth(depth: { brace: number; paren: number }, ch: string | undefined): boolean {
|
|
104
|
-
if (ch === '{') { depth.brace += 1; return true; }
|
|
105
|
-
if (ch === '}') { depth.brace = Math.max(0, depth.brace - 1); return true; }
|
|
106
|
-
if (ch === '(') { depth.paren += 1; return true; }
|
|
107
|
-
if (ch === ')') { depth.paren = Math.max(0, depth.paren - 1); return true; }
|
|
108
|
-
return false;
|
|
146
|
+
return `Top-level schemas in this source: ${names.join(', ')}.`;
|
|
109
147
|
}
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Character-level scanner over TypeScript Zod source, used to locate the
|
|
3
|
+
* `z.object({ … })` literals a deliverable spec declares at the TOP level
|
|
4
|
+
* and to read their field names.
|
|
5
|
+
*
|
|
6
|
+
* Deliberately NOT a TypeScript parse or a Zod evaluation: introspection
|
|
7
|
+
* runs inside the DSL compiler, the CI gate, and a request-scoped API
|
|
8
|
+
* handler, none of which may transpile or execute spec-authored code.
|
|
9
|
+
* A scanner is enough because the only question asked is "which
|
|
10
|
+
* identifiers sit at depth 0 of this object literal".
|
|
11
|
+
*
|
|
12
|
+
* What it tracks, and why the naive regex it replaced could not:
|
|
13
|
+
* - comments, strings, template literals and regex literals, so a
|
|
14
|
+
* `z.object(`, a brace, or a comma appearing INSIDE one of those is
|
|
15
|
+
* never read as code;
|
|
16
|
+
* - brace + paren depth, so a NESTED `z.object(` (a field's own shape)
|
|
17
|
+
* is never confused with a top-level schema declaration;
|
|
18
|
+
* - the `const <Name> =` binding preceding each top-level literal, so a
|
|
19
|
+
* spec can name its contract instead of relying on source order.
|
|
20
|
+
*
|
|
21
|
+
* All three walkers below share ONE trivia primitive (`skipTriviaAt`) so
|
|
22
|
+
* they cannot drift into disagreeing about what counts as code.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** A `z.object({ … })` literal declared at the top level of the source. */
|
|
26
|
+
export interface TopLevelZodObjectDeclaration {
|
|
27
|
+
/** Binding name (`export const Foo = z.object({…})` → `Foo`), or null when anonymous. */
|
|
28
|
+
readonly exportName: string | null;
|
|
29
|
+
/** Field identifiers at depth 0 of the literal, in source order, de-duplicated. */
|
|
30
|
+
readonly keys: readonly string[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* If a comment / string / template / regex literal STARTS at `index`,
|
|
35
|
+
* return the index just past its end; otherwise return `index` unchanged.
|
|
36
|
+
* An unterminated token consumes the rest of the source, which makes the
|
|
37
|
+
* callers treat it as "no readable shape from here" rather than
|
|
38
|
+
* re-entering code state mid-literal.
|
|
39
|
+
*
|
|
40
|
+
* Regex detection is bounded on purpose: a `/` is treated as a regex only
|
|
41
|
+
* when the previous non-whitespace code character is `(` or `,` — the only
|
|
42
|
+
* positions a regex occupies in a schema-declaration module
|
|
43
|
+
* (`.regex(/…/)`, `.refine(x, /…/)`). Division never appears in that
|
|
44
|
+
* position, so there is no ambiguous case to guess at.
|
|
45
|
+
*/
|
|
46
|
+
function skipTriviaAt(source: string, index: number, prevCode: string): number {
|
|
47
|
+
const ch = source[index];
|
|
48
|
+
const next = source[index + 1];
|
|
49
|
+
|
|
50
|
+
if (ch === '/' && next === '/') return endOfLineComment(source, index + 2);
|
|
51
|
+
if (ch === '/' && next === '*') return endOfBlockComment(source, index + 2);
|
|
52
|
+
if (ch === "'" || ch === '"' || ch === '`') {
|
|
53
|
+
return endOfQuoted(source, index + 1, ch);
|
|
54
|
+
}
|
|
55
|
+
if (ch === '/' && (prevCode === '(' || prevCode === ',')) {
|
|
56
|
+
return endOfQuoted(source, index + 1, '/');
|
|
57
|
+
}
|
|
58
|
+
return index;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function endOfLineComment(source: string, from: number): number {
|
|
62
|
+
const nl = source.indexOf('\n', from);
|
|
63
|
+
return nl === -1 ? source.length : nl + 1;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function endOfBlockComment(source: string, from: number): number {
|
|
67
|
+
const end = source.indexOf('*/', from);
|
|
68
|
+
return end === -1 ? source.length : end + 2;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** End of a `'…'` / `"…"` / `` `…` `` / `/…/` token, honoring backslash escapes. */
|
|
72
|
+
function endOfQuoted(source: string, from: number, terminator: string): number {
|
|
73
|
+
for (let i = from; i < source.length; i += 1) {
|
|
74
|
+
const ch = source[i];
|
|
75
|
+
if (ch === '\\') {
|
|
76
|
+
i += 1;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (ch === terminator) return i + 1;
|
|
80
|
+
}
|
|
81
|
+
return source.length;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Nesting counters shared by the depth-tracking walkers. */
|
|
85
|
+
interface Depths {
|
|
86
|
+
brace: number;
|
|
87
|
+
paren: number;
|
|
88
|
+
bracket: number;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function newDepths(): Depths {
|
|
92
|
+
return { brace: 0, paren: 0, bracket: 0 };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function applyDepth(depths: Depths, ch: string | undefined): void {
|
|
96
|
+
if (ch === '{') depths.brace += 1;
|
|
97
|
+
else if (ch === '}') depths.brace = Math.max(0, depths.brace - 1);
|
|
98
|
+
else if (ch === '(') depths.paren += 1;
|
|
99
|
+
else if (ch === ')') depths.paren = Math.max(0, depths.paren - 1);
|
|
100
|
+
else if (ch === '[') depths.bracket += 1;
|
|
101
|
+
else if (ch === ']') depths.bracket = Math.max(0, depths.bracket - 1);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function isAtTopLevel(depths: Depths): boolean {
|
|
105
|
+
return (
|
|
106
|
+
depths.brace === 0 && depths.paren === 0 && depths.bracket === 0
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Find every `z.object({ … })` literal that sits at depth 0 — i.e. every
|
|
112
|
+
* schema the module declares in its own right, as opposed to a shape
|
|
113
|
+
* nested inside another schema.
|
|
114
|
+
*
|
|
115
|
+
* Literals whose braces never close are skipped: an unbalanced source has
|
|
116
|
+
* no readable shape, and reporting a truncated key list would be worse
|
|
117
|
+
* than reporting none.
|
|
118
|
+
*/
|
|
119
|
+
export function scanTopLevelZodObjectDeclarations(
|
|
120
|
+
source: string,
|
|
121
|
+
): readonly TopLevelZodObjectDeclaration[] {
|
|
122
|
+
const out: TopLevelZodObjectDeclaration[] = [];
|
|
123
|
+
const depths = newDepths();
|
|
124
|
+
let prevCode = '';
|
|
125
|
+
let i = 0;
|
|
126
|
+
|
|
127
|
+
while (i < source.length) {
|
|
128
|
+
const afterTrivia = skipTriviaAt(source, i, prevCode);
|
|
129
|
+
if (afterTrivia !== i) {
|
|
130
|
+
i = afterTrivia;
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (isAtTopLevel(depths) && matchesZodObjectAt(source, i)) {
|
|
135
|
+
const bodyStart = openingBraceIndexAfterZodObject(source, i);
|
|
136
|
+
const bodyEnd =
|
|
137
|
+
bodyStart === null ? null : matchingBraceIndex(source, bodyStart);
|
|
138
|
+
if (bodyStart !== null && bodyEnd !== null) {
|
|
139
|
+
out.push({
|
|
140
|
+
exportName: bindingNameBefore(source, i),
|
|
141
|
+
keys: parseObjectKeysAtTopLevel(source.slice(bodyStart, bodyEnd)),
|
|
142
|
+
});
|
|
143
|
+
// Resume AFTER the literal: its interior is already accounted
|
|
144
|
+
// for, and re-walking it could only produce nested matches we
|
|
145
|
+
// deliberately exclude.
|
|
146
|
+
i = bodyEnd + 1;
|
|
147
|
+
prevCode = '}';
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const ch = source[i];
|
|
153
|
+
applyDepth(depths, ch);
|
|
154
|
+
if (ch !== undefined && !/\s/.test(ch)) prevCode = ch;
|
|
155
|
+
i += 1;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** True when `z.object` starts exactly at `index` and is not part of a longer identifier. */
|
|
162
|
+
function matchesZodObjectAt(source: string, index: number): boolean {
|
|
163
|
+
if (!source.startsWith('z.object', index)) return false;
|
|
164
|
+
const prev = index > 0 ? (source[index - 1] ?? '') : '';
|
|
165
|
+
return !/[A-Za-z0-9_$.]/.test(prev);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Index just past the `{` of `z.object ( {`, or null when the call is not an object literal. */
|
|
169
|
+
function openingBraceIndexAfterZodObject(
|
|
170
|
+
source: string,
|
|
171
|
+
index: number,
|
|
172
|
+
): number | null {
|
|
173
|
+
let i = skipWhitespace(source, index + 'z.object'.length);
|
|
174
|
+
if (source[i] !== '(') return null;
|
|
175
|
+
i = skipWhitespace(source, i + 1);
|
|
176
|
+
if (source[i] !== '{') return null;
|
|
177
|
+
return i + 1;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Index of the `}` closing the literal that starts at `bodyStart`, or null
|
|
182
|
+
* when it never closes.
|
|
183
|
+
*/
|
|
184
|
+
function matchingBraceIndex(source: string, bodyStart: number): number | null {
|
|
185
|
+
let depth = 1;
|
|
186
|
+
let prevCode = '{';
|
|
187
|
+
let i = bodyStart;
|
|
188
|
+
|
|
189
|
+
while (i < source.length) {
|
|
190
|
+
const afterTrivia = skipTriviaAt(source, i, prevCode);
|
|
191
|
+
if (afterTrivia !== i) {
|
|
192
|
+
i = afterTrivia;
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
const ch = source[i];
|
|
196
|
+
if (ch === '{') depth += 1;
|
|
197
|
+
else if (ch === '}') {
|
|
198
|
+
depth -= 1;
|
|
199
|
+
if (depth === 0) return i;
|
|
200
|
+
}
|
|
201
|
+
if (ch !== undefined && !/\s/.test(ch)) prevCode = ch;
|
|
202
|
+
i += 1;
|
|
203
|
+
}
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const BINDING_LOOKBEHIND = 400;
|
|
208
|
+
const BINDING_TAIL =
|
|
209
|
+
/(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*(?::[^=]*)?=\s*$/;
|
|
210
|
+
|
|
211
|
+
/** Name bound to the literal starting at `index` (`export const Foo = z.object(`), or null. */
|
|
212
|
+
function bindingNameBefore(source: string, index: number): string | null {
|
|
213
|
+
const from = Math.max(0, index - BINDING_LOOKBEHIND);
|
|
214
|
+
return BINDING_TAIL.exec(source.slice(from, index))?.[1] ?? null;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function skipWhitespace(source: string, index: number): number {
|
|
218
|
+
let i = index;
|
|
219
|
+
while (i < source.length && /\s/.test(source[i]!)) i += 1;
|
|
220
|
+
return i;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Field identifiers at depth 0 of an object-literal body.
|
|
225
|
+
*
|
|
226
|
+
* Splits on depth-0 commas and reads the leading key of each segment, so
|
|
227
|
+
* a key is recorded exactly once. (The predecessor scanned for a key after
|
|
228
|
+
* BOTH the comma and the newline that followed it, emitting every
|
|
229
|
+
* multi-line key twice — harmless for a set-membership test, but the
|
|
230
|
+
* duplicates leaked into compiler error messages and the "known fields"
|
|
231
|
+
* preview shown to authors.)
|
|
232
|
+
*/
|
|
233
|
+
function parseObjectKeysAtTopLevel(body: string): readonly string[] {
|
|
234
|
+
const keys: string[] = [];
|
|
235
|
+
const seen = new Set<string>();
|
|
236
|
+
for (const segment of splitTopLevelSegments(body)) {
|
|
237
|
+
const key = leadingKeyOf(segment);
|
|
238
|
+
if (key !== null && !seen.has(key)) {
|
|
239
|
+
seen.add(key);
|
|
240
|
+
keys.push(key);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return Object.freeze(keys);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function splitTopLevelSegments(body: string): readonly string[] {
|
|
247
|
+
const segments: string[] = [];
|
|
248
|
+
const depths = newDepths();
|
|
249
|
+
let prevCode = '';
|
|
250
|
+
let start = 0;
|
|
251
|
+
let i = 0;
|
|
252
|
+
|
|
253
|
+
while (i < body.length) {
|
|
254
|
+
const afterTrivia = skipTriviaAt(body, i, prevCode);
|
|
255
|
+
if (afterTrivia !== i) {
|
|
256
|
+
i = afterTrivia;
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
const ch = body[i];
|
|
260
|
+
if (ch === ',' && isAtTopLevel(depths)) {
|
|
261
|
+
segments.push(body.slice(start, i));
|
|
262
|
+
start = i + 1;
|
|
263
|
+
} else {
|
|
264
|
+
applyDepth(depths, ch);
|
|
265
|
+
}
|
|
266
|
+
if (ch !== undefined && !/\s/.test(ch)) prevCode = ch;
|
|
267
|
+
i += 1;
|
|
268
|
+
}
|
|
269
|
+
segments.push(body.slice(start));
|
|
270
|
+
return segments;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const LEADING_TRIVIA = /^(?:\s|\/\/[^\n]*(?:\n|$)|\/\*[\s\S]*?\*\/)+/;
|
|
274
|
+
const LEADING_KEY = /^(?:([A-Za-z_$][A-Za-z0-9_$]*)|'([^']+)'|"([^"]+)")\s*:/;
|
|
275
|
+
|
|
276
|
+
function leadingKeyOf(segment: string): string | null {
|
|
277
|
+
const match = LEADING_KEY.exec(segment.replace(LEADING_TRIVIA, ''));
|
|
278
|
+
if (!match) return null;
|
|
279
|
+
return match[1] ?? match[2] ?? match[3] ?? null;
|
|
280
|
+
}
|
|
@@ -85,7 +85,7 @@ export interface XemaContext {
|
|
|
85
85
|
};
|
|
86
86
|
readonly workflow: {
|
|
87
87
|
readonly key: string;
|
|
88
|
-
readonly
|
|
88
|
+
readonly revisionNumber: number;
|
|
89
89
|
};
|
|
90
90
|
readonly job: {
|
|
91
91
|
readonly key: string;
|
|
@@ -142,7 +142,7 @@ export const EMPTY_CONTEXT_SEEDS = {
|
|
|
142
142
|
correlationId: '',
|
|
143
143
|
actor: Object.freeze({ subject: null, kind: XemaActorKind.System }),
|
|
144
144
|
}),
|
|
145
|
-
workflow: Object.freeze({ key: '',
|
|
145
|
+
workflow: Object.freeze({ key: '', revisionNumber: 0 }),
|
|
146
146
|
job: Object.freeze({ key: '', attempt: 0 }),
|
|
147
147
|
}) as XemaContext,
|
|
148
148
|
} as const;
|