@rayspec/spec 1.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 +105 -0
- package/dist/brace-params.d.ts +32 -0
- package/dist/brace-params.d.ts.map +1 -0
- package/dist/brace-params.js +89 -0
- package/dist/brace-params.js.map +1 -0
- package/dist/detect.d.ts +44 -0
- package/dist/detect.d.ts.map +1 -0
- package/dist/detect.js +72 -0
- package/dist/detect.js.map +1 -0
- package/dist/errors.d.ts +204 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +165 -0
- package/dist/errors.js.map +1 -0
- package/dist/export.d.ts +54 -0
- package/dist/export.d.ts.map +1 -0
- package/dist/export.js +121 -0
- package/dist/export.js.map +1 -0
- package/dist/grammar.d.ts +569 -0
- package/dist/grammar.d.ts.map +1 -0
- package/dist/grammar.js +503 -0
- package/dist/grammar.js.map +1 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +26 -0
- package/dist/index.js.map +1 -0
- package/dist/lint.d.ts +79 -0
- package/dist/lint.d.ts.map +1 -0
- package/dist/lint.js +823 -0
- package/dist/lint.js.map +1 -0
- package/dist/parse.d.ts +8 -0
- package/dist/parse.d.ts.map +1 -0
- package/dist/parse.js +118 -0
- package/dist/parse.js.map +1 -0
- package/dist/product-events.d.ts +73 -0
- package/dist/product-events.d.ts.map +1 -0
- package/dist/product-events.js +42 -0
- package/dist/product-events.js.map +1 -0
- package/dist/product-grammar.d.ts +948 -0
- package/dist/product-grammar.d.ts.map +1 -0
- package/dist/product-grammar.js +581 -0
- package/dist/product-grammar.js.map +1 -0
- package/dist/product-lint.d.ts +74 -0
- package/dist/product-lint.d.ts.map +1 -0
- package/dist/product-lint.js +821 -0
- package/dist/product-lint.js.map +1 -0
- package/dist/product-parse.d.ts +8 -0
- package/dist/product-parse.d.ts.map +1 -0
- package/dist/product-parse.js +119 -0
- package/dist/product-parse.js.map +1 -0
- package/dist/product-scope.d.ts +53 -0
- package/dist/product-scope.d.ts.map +1 -0
- package/dist/product-scope.js +55 -0
- package/dist/product-scope.js.map +1 -0
- package/dist/product-views-lint.d.ts +14 -0
- package/dist/product-views-lint.d.ts.map +1 -0
- package/dist/product-views-lint.js +669 -0
- package/dist/product-views-lint.js.map +1 -0
- package/dist/product-views.d.ts +426 -0
- package/dist/product-views.d.ts.map +1 -0
- package/dist/product-views.js +317 -0
- package/dist/product-views.js.map +1 -0
- package/package.json +34 -0
|
@@ -0,0 +1,821 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Product-YAML semantic validation + the fail-closed no-code guardrails.
|
|
3
|
+
*
|
|
4
|
+
* Two exported passes, both returning the FULL list of violations (never the first):
|
|
5
|
+
*
|
|
6
|
+
* 1. `scanProductGuardrails(raw)` — the NO-CODE-IN-YAML enforcement,
|
|
7
|
+
* run on the RAW parsed object BEFORE the strict Zod parse so each ban produces a SPECIFIC,
|
|
8
|
+
* EXPLAINING error (the draft's validation-error table) instead of a generic strict unknown-key.
|
|
9
|
+
* It is SECTION-AWARE:
|
|
10
|
+
* • GLOBAL (whole doc EXCEPT `contracts`): hard code/handler/SQL/shell keys + inline-code string
|
|
11
|
+
* VALUES + provider-native WIRE-BLOB keys are banned everywhere (`no_code_in_yaml` /
|
|
12
|
+
* `provider_native_leak`). `contracts` is EXCLUDED because there a `code`/`function` KEY is a
|
|
13
|
+
* legitimate DATA property name, not a handler reference — contracts are vetted by the
|
|
14
|
+
* vocabulary check in `lintProductSpec` instead.
|
|
15
|
+
* • WORKFLOWS + EXTRACTORS subtrees ONLY: provider/model POLICY keys, provider NAMES, and prompt keys
|
|
16
|
+
* are ALSO banned — this is the EXECUTABLE graph, which must stay provider-neutral so it compiles
|
|
17
|
+
* through `@rayspec/product-yaml-workflow-bridge`. The key sets/regexes MIRROR that bridge's
|
|
18
|
+
* neutrality walk exactly, so a spec that validates here feeds the bridge unchanged, AND provider
|
|
19
|
+
* POLICY stays legal where the draft allows it (`capabilities[].provider_policy`,
|
|
20
|
+
* `deployment_overrides`).
|
|
21
|
+
*
|
|
22
|
+
* 2. `lintProductSpec(spec)` — cross-reference resolution, duplicate-id rejection, capability-status
|
|
23
|
+
* discipline, and the CLOSED contract vocabulary — over an already-shape-valid `ProductSpec`.
|
|
24
|
+
*
|
|
25
|
+
* NOTHING is ever silently dropped or decoratively validated (the fail-open lesson: reject loudly,
|
|
26
|
+
* never silently drop). Every mis-specified element is rejected with a closed `SpecError` code.
|
|
27
|
+
*/
|
|
28
|
+
import { specError } from './errors.js';
|
|
29
|
+
// The RESERVED (injected tenancy/GDPR) column names — the SAME set the backend store lint enforces
|
|
30
|
+
// (lint.ts). A declared product store column may not shadow one: the SQL generator INJECTS them.
|
|
31
|
+
// `toJsIdentifier` is the spec-side snake→camel copy (KEEP-IN-SYNC docstring at its definition)
|
|
32
|
+
// used by the column-collision check below.
|
|
33
|
+
import { RESERVED_COLUMN_NAMES, RESERVED_QUERY_KEYWORDS, toJsIdentifier } from './lint.js';
|
|
34
|
+
import { normalizeProductTriggerEvent } from './product-events.js';
|
|
35
|
+
import { lintProductViews } from './product-views-lint.js';
|
|
36
|
+
// ---------------------------------------------------------------------------------------
|
|
37
|
+
// no-code guardrails — key sets + patterns
|
|
38
|
+
// (the banned-key SETS below are `export`ed for the cross-package parser↔bridge KEY-SET parity test in
|
|
39
|
+
// `@rayspec/product-yaml-workflow-bridge`, which asserts the parser bans a SUPERSET of the bridge's
|
|
40
|
+
// keys. Export-only — no runtime behavior change.)
|
|
41
|
+
// ---------------------------------------------------------------------------------------
|
|
42
|
+
/**
|
|
43
|
+
* HARD code/handler keys — banned EVERYWHERE (except inside `contracts`, where a property may
|
|
44
|
+
* legitimately be named this). A key here means product-owned code/handlers/SQL/shell are being
|
|
45
|
+
* smuggled into product meaning; implementation belongs in Tier A/B.
|
|
46
|
+
*/
|
|
47
|
+
export const GLOBAL_CODE_KEYS = new Set([
|
|
48
|
+
'code',
|
|
49
|
+
'fn',
|
|
50
|
+
'function',
|
|
51
|
+
'handler',
|
|
52
|
+
'handler_path',
|
|
53
|
+
'handlers',
|
|
54
|
+
'implementation',
|
|
55
|
+
'inline_js',
|
|
56
|
+
'inline_ts',
|
|
57
|
+
'javascript',
|
|
58
|
+
'module',
|
|
59
|
+
'module_path',
|
|
60
|
+
'resolver',
|
|
61
|
+
'route_handler',
|
|
62
|
+
'shell',
|
|
63
|
+
'sql',
|
|
64
|
+
'typescript',
|
|
65
|
+
]);
|
|
66
|
+
/**
|
|
67
|
+
* Provider-native WIRE-BLOB keys — banned EVERYWHERE (a raw provider request/response payload is never
|
|
68
|
+
* a Product-YAML contract). DISTINCT from provider POLICY keys (`default_provider`/`default_model`/…),
|
|
69
|
+
* which are ALLOWED on a capability / in `deployment_overrides` and only banned inside workflows/extractors.
|
|
70
|
+
*/
|
|
71
|
+
export const GLOBAL_PROVIDER_BLOB_KEYS = new Set([
|
|
72
|
+
'api_key',
|
|
73
|
+
'api_key_env',
|
|
74
|
+
'body',
|
|
75
|
+
'deepgram_request',
|
|
76
|
+
'headers',
|
|
77
|
+
'native_payload',
|
|
78
|
+
'provider_payload',
|
|
79
|
+
'raw_provider_payload',
|
|
80
|
+
]);
|
|
81
|
+
/**
|
|
82
|
+
* Inline-code string VALUES (mirrors the product-YAML check script's `codeLikePattern`) — an import/require/
|
|
83
|
+
* arrow-fn/class/process.env/child_process/eval/file-path/SQL fragment appearing as a value is code.
|
|
84
|
+
*/
|
|
85
|
+
const CODE_LIKE_VALUE = /\b(import\s+|export\s+|require\s*\(|=>|function\s+\w*|class\s+\w+|async\s+function|process\.env|Deno\.|Bun\.|child_process|exec\s*\(|eval\s*\(|new\s+Function|\.ts\b|\.tsx\b|\.js\b|\.mjs\b|\.cjs\b|\/handlers\/|handlers\/|SELECT\s+.+\s+FROM|INSERT\s+INTO|UPDATE\s+.+\s+SET|DELETE\s+FROM)\b/i;
|
|
86
|
+
/**
|
|
87
|
+
* Provider/model POLICY keys — banned INSIDE `workflows`/`extractors` (the executable graph). These mirror
|
|
88
|
+
* `@rayspec/product-yaml-workflow-bridge`'s `providerNativeKeys` so the two neutrality boundaries are
|
|
89
|
+
* identical. Legal OUTSIDE the graph (capability provider_policy / deployment_overrides).
|
|
90
|
+
*/
|
|
91
|
+
export const GRAPH_PROVIDER_POLICY_KEYS = new Set([
|
|
92
|
+
'adapter_visibility',
|
|
93
|
+
'backend',
|
|
94
|
+
'credential_env',
|
|
95
|
+
'default_backend',
|
|
96
|
+
'default_model',
|
|
97
|
+
'default_provider',
|
|
98
|
+
'model',
|
|
99
|
+
'model_policy',
|
|
100
|
+
'provider',
|
|
101
|
+
'provider_payload',
|
|
102
|
+
'provider_policy',
|
|
103
|
+
]);
|
|
104
|
+
/** Prompt keys — banned inside the graph (prompt text is a Tier-B agent-execution concern, not YAML). */
|
|
105
|
+
export const GRAPH_PROMPT_KEYS = new Set([
|
|
106
|
+
'prompt',
|
|
107
|
+
'prompt_template',
|
|
108
|
+
'system_prompt',
|
|
109
|
+
'user_prompt',
|
|
110
|
+
]);
|
|
111
|
+
/** A product-owned handler/module PATH as a string value (banned in the graph). */
|
|
112
|
+
const PRODUCT_OWNED_PATH = /(?:\/handlers\/|handlers\/|\.tsx?\b|\.mjs\b|\.cjs\b|\.js\b)/i;
|
|
113
|
+
/** A provider NAME as a string value (banned in the graph — mirrors the bridge). */
|
|
114
|
+
const PROVIDER_NAME_VALUE = /\b(?:deepgram|openai|anthropic|gemini|pi)\b|provider_native|native_payload/i;
|
|
115
|
+
/**
|
|
116
|
+
* A PRODUCTION-EXECUTION claim as a string value (banned in the graph). MIRRORS the workflow-bridge's
|
|
117
|
+
* `productionClaimPattern` BYTE-FOR-BYTE (compiler.ts) — a Product-YAML doc declares meaning, it does not
|
|
118
|
+
* claim it EXECUTES in production. The `product-bridge-parity.test.ts` cross-check keeps the two in sync.
|
|
119
|
+
*/
|
|
120
|
+
const GRAPH_PRODUCTION_CLAIM = /\b(production_ready|prod(?:uction)?\s+execution|prod\s+runtime)\b/i;
|
|
121
|
+
/**
|
|
122
|
+
* A PROMPT/LLM-EXECUTION claim as a string value (banned in the graph). MIRRORS the workflow-bridge's
|
|
123
|
+
* `promptExecutionPattern` BYTE-FOR-BYTE — prompt/agent execution is a Tier-B runtime concern, not YAML
|
|
124
|
+
* meaning. Closing this: a graph string like `llm call` passed the parser but the bridge
|
|
125
|
+
* THREW on it (a validate/compile drift). Kept in sync by the parity cross-check test.
|
|
126
|
+
*/
|
|
127
|
+
const GRAPH_PROMPT_EXECUTION = /\b(prompt\s+execution|execute\s+prompt|llm\s+call|agent\s+call)\b/i;
|
|
128
|
+
/**
|
|
129
|
+
* A media STREAMING/PLAYBACK route marker. Streaming/range media serving is a Tier-B media
|
|
130
|
+
* capability, NEVER a Product-YAML view handler. Word-boundary-anchored so a playback-TOKEN read
|
|
131
|
+
* view (e.g. `/play-token`) and a benign name like `/downstream-processor` are NOT caught, while real
|
|
132
|
+
* streaming routes ARE — including COMPOUND names (`/livestream`, `/live-streaming`) and the plural
|
|
133
|
+
* (`/streams`), which the prior `\bstream\b` marker missed because `live`+`stream` carries no internal
|
|
134
|
+
* word boundary. The closed `source.kind` enum stays the STRUCTURAL guard (a `stream` source kind → a
|
|
135
|
+
* bad-enum `schema_violation`); this route-path marker is defense-in-depth. Tested fail-the-fix with BOTH
|
|
136
|
+
* positive and negative cases (no untested substring theater).
|
|
137
|
+
*/
|
|
138
|
+
const STREAMING_ROUTE_MARKER = /\b(?:playback|(?:live)?stream(?:ing|s)?)\b/i;
|
|
139
|
+
// The trigger normalization (a workflow trigger's `capability` + `event` → the canonical event id)
|
|
140
|
+
// is the SHARED `normalizeProductTriggerEvent` from `./product-events.js` — the ONE source the
|
|
141
|
+
// bridge compiler also imports (the old KEEP-IN-SYNC local copy is gone; the cross-package parity
|
|
142
|
+
// test pins the single source). The parser requires the normalized event to be a declared contract of
|
|
143
|
+
// the trigger's capability (its doc-level proxy for the Tier-B event vocabulary the bridge validates
|
|
144
|
+
// against its stage manifests), so a typo'd `trigger.event` fails closed here instead of only at
|
|
145
|
+
// bridge-compile time.
|
|
146
|
+
/**
|
|
147
|
+
* Walk an arbitrary value, applying `onKey` to every object key and `onString` to every string leaf,
|
|
148
|
+
* building a JSON path for the message. Pure structural recursion (arrays index, objects descend).
|
|
149
|
+
*/
|
|
150
|
+
function walk(value, path, onKey, onString) {
|
|
151
|
+
if (Array.isArray(value)) {
|
|
152
|
+
value.forEach((item, i) => {
|
|
153
|
+
walk(item, `${path}[${i}]`, onKey, onString);
|
|
154
|
+
});
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (value !== null && typeof value === 'object') {
|
|
158
|
+
for (const [key, child] of Object.entries(value)) {
|
|
159
|
+
onKey(key, `${path}.${key}`);
|
|
160
|
+
walk(child, `${path}.${key}`, onKey, onString);
|
|
161
|
+
}
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (typeof value === 'string')
|
|
165
|
+
onString(value, path);
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* The no-code-in-YAML guardrail scan over the RAW parsed doc. Returns SPECIFIC, explaining errors
|
|
169
|
+
* (`no_code_in_yaml` / `provider_native_leak`). Section-aware (see the module docstring).
|
|
170
|
+
*/
|
|
171
|
+
export function scanProductGuardrails(raw) {
|
|
172
|
+
const errors = [];
|
|
173
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
|
|
174
|
+
return errors;
|
|
175
|
+
const doc = raw;
|
|
176
|
+
// ---- GLOBAL scan (whole doc EXCEPT `contracts`) --------------------------------------
|
|
177
|
+
for (const [topKey, topVal] of Object.entries(doc)) {
|
|
178
|
+
if (topKey === 'contracts')
|
|
179
|
+
continue; // contracts are vetted by the vocabulary check below
|
|
180
|
+
walk(topVal, topKey, (key, keyPath) => {
|
|
181
|
+
if (GLOBAL_CODE_KEYS.has(key)) {
|
|
182
|
+
errors.push(specError('no_code_in_yaml', `banned code-like key '${key}' at ${keyPath}; Product YAML declares meaning and ` +
|
|
183
|
+
'contracts — code/handlers/SQL belong in Tier A/B implementation, not in YAML', keyPath));
|
|
184
|
+
}
|
|
185
|
+
else if (GLOBAL_PROVIDER_BLOB_KEYS.has(key)) {
|
|
186
|
+
errors.push(specError('provider_native_leak', `provider-native wire-blob key '${key}' at ${keyPath}; expose a provider-neutral ` +
|
|
187
|
+
'contract instead of a raw provider request/response payload', keyPath));
|
|
188
|
+
}
|
|
189
|
+
}, (str, strPath) => {
|
|
190
|
+
if (CODE_LIKE_VALUE.test(str)) {
|
|
191
|
+
errors.push(specError('no_code_in_yaml', `inline-code string value at ${strPath}; Product YAML must not contain JS/TS, SQL, ` +
|
|
192
|
+
'shell, or handler module paths', strPath));
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
// ---- GRAPH scan (workflows + extractors ONLY) ----------------------------------------
|
|
197
|
+
for (const section of ['workflows', 'extractors']) {
|
|
198
|
+
if (!(section in doc))
|
|
199
|
+
continue;
|
|
200
|
+
walk(doc[section], section, (key, keyPath) => {
|
|
201
|
+
if (GRAPH_PROVIDER_POLICY_KEYS.has(key)) {
|
|
202
|
+
errors.push(specError('provider_native_leak', `provider/model policy key '${key}' at ${keyPath}; the executable workflow/agent graph ` +
|
|
203
|
+
'must stay provider-neutral — put provider policy on the capability or in ' +
|
|
204
|
+
'deployment_overrides, not in the graph', keyPath));
|
|
205
|
+
}
|
|
206
|
+
else if (GRAPH_PROMPT_KEYS.has(key)) {
|
|
207
|
+
errors.push(specError('no_code_in_yaml', `prompt key '${key}' at ${keyPath}; prompt text is a Tier-B agent-execution concern, ` +
|
|
208
|
+
'not Product YAML meaning', keyPath));
|
|
209
|
+
}
|
|
210
|
+
}, (str, strPath) => {
|
|
211
|
+
// MIRRORS the bridge's `walkWorkflowDeclarations` string checks in ORDER (compiler.ts): product-
|
|
212
|
+
// owned path → provider name → production-execution claim → prompt/LLM-execution claim. Keeping
|
|
213
|
+
// both this order and the regexes identical is the anti-drift contract (parity-tested).
|
|
214
|
+
if (PRODUCT_OWNED_PATH.test(str)) {
|
|
215
|
+
errors.push(specError('no_code_in_yaml', `product-owned handler/module path '${str}' at ${strPath}; the workflow/agent graph ` +
|
|
216
|
+
'references Tier A/B primitives, not TypeScript modules', strPath));
|
|
217
|
+
}
|
|
218
|
+
else if (PROVIDER_NAME_VALUE.test(str)) {
|
|
219
|
+
errors.push(specError('provider_native_leak', `provider-native value '${str}' at ${strPath}; the workflow/agent graph must name ` +
|
|
220
|
+
'neutral capability operations, not providers', strPath));
|
|
221
|
+
}
|
|
222
|
+
else if (GRAPH_PRODUCTION_CLAIM.test(str)) {
|
|
223
|
+
errors.push(specError('production_execution_claim', `production-execution claim '${str}' at ${strPath}; Product YAML declares meaning, it ` +
|
|
224
|
+
'does not claim it EXECUTES in production (that is a Tier A/B runtime concern)', strPath));
|
|
225
|
+
}
|
|
226
|
+
else if (GRAPH_PROMPT_EXECUTION.test(str)) {
|
|
227
|
+
errors.push(specError('prompt_execution_claim', `prompt/LLM-execution claim '${str}' at ${strPath}; prompt/agent execution is a Tier-B ` +
|
|
228
|
+
'runtime concern, not Product YAML meaning', strPath));
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
return errors;
|
|
233
|
+
}
|
|
234
|
+
// ---------------------------------------------------------------------------------------
|
|
235
|
+
// contract vocabulary — the CLOSED declarative JSON-Schema-like vocabulary
|
|
236
|
+
// ---------------------------------------------------------------------------------------
|
|
237
|
+
/** Allowed keys at a contract SCHEMA level (the draft's "Allowed schema vocabulary"). */
|
|
238
|
+
const CONTRACT_SCHEMA_KEYS = new Set([
|
|
239
|
+
'type',
|
|
240
|
+
'description',
|
|
241
|
+
'properties',
|
|
242
|
+
'items',
|
|
243
|
+
'required',
|
|
244
|
+
'enum',
|
|
245
|
+
'additional_properties',
|
|
246
|
+
'nullable',
|
|
247
|
+
'ref',
|
|
248
|
+
]);
|
|
249
|
+
/** Allowed `type` values (the draft's "Allowed contract types"). */
|
|
250
|
+
const CONTRACT_TYPES = new Set([
|
|
251
|
+
'object',
|
|
252
|
+
'array',
|
|
253
|
+
'string',
|
|
254
|
+
'number',
|
|
255
|
+
'integer',
|
|
256
|
+
'boolean',
|
|
257
|
+
'null',
|
|
258
|
+
]);
|
|
259
|
+
/** Validate ONE contract schema node recursively against the closed vocabulary. */
|
|
260
|
+
function lintContractSchema(node, path, errors) {
|
|
261
|
+
if (node === null || typeof node !== 'object' || Array.isArray(node)) {
|
|
262
|
+
errors.push(specError('invalid_contract', `contract schema at ${path} must be an object (a declarative JSON-Schema-like node)`, path));
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
const schema = node;
|
|
266
|
+
for (const key of Object.keys(schema)) {
|
|
267
|
+
if (!CONTRACT_SCHEMA_KEYS.has(key)) {
|
|
268
|
+
errors.push(specError('invalid_contract', `unknown contract schema key '${key}' at ${path}.${key}; the contract vocabulary is closed ` +
|
|
269
|
+
'(type/description/properties/items/required/enum/additional_properties/nullable/ref) — ' +
|
|
270
|
+
'functions, transforms, computed expressions, and provider-native shapes are forbidden', `${path}.${key}`));
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
// SCALAR-valued keys must carry their expected scalar shape. Without this, a NON-schema position
|
|
274
|
+
// that admits an object (e.g. `description: { handler: '/handlers/evil.ts', code: "import x from 'y'" }`)
|
|
275
|
+
// escaped BOTH the contracts-exempt global guardrail scan AND this vocabulary check — smuggling
|
|
276
|
+
// handler/code keys past the parser. `type`/`properties`/`items`/`required`/`enum` are shape-checked
|
|
277
|
+
// below; these four are the remaining allowed keys, each of which must be a scalar of the right type.
|
|
278
|
+
if ('description' in schema && typeof schema.description !== 'string') {
|
|
279
|
+
errors.push(specError('invalid_contract', `contract 'description' at ${path}.description must be a string (not an object/array)`, `${path}.description`));
|
|
280
|
+
}
|
|
281
|
+
if ('additional_properties' in schema && typeof schema.additional_properties !== 'boolean') {
|
|
282
|
+
errors.push(specError('invalid_contract', `contract 'additional_properties' at ${path}.additional_properties must be a boolean`, `${path}.additional_properties`));
|
|
283
|
+
}
|
|
284
|
+
if ('nullable' in schema && typeof schema.nullable !== 'boolean') {
|
|
285
|
+
errors.push(specError('invalid_contract', `contract 'nullable' at ${path}.nullable must be a boolean`, `${path}.nullable`));
|
|
286
|
+
}
|
|
287
|
+
if ('ref' in schema && typeof schema.ref !== 'string') {
|
|
288
|
+
errors.push(specError('invalid_contract', `contract 'ref' at ${path}.ref must be a string (a contract id)`, `${path}.ref`));
|
|
289
|
+
}
|
|
290
|
+
// `type` — a single allowed type OR an array of allowed types (nullable unions like [object, "null"]).
|
|
291
|
+
if ('type' in schema) {
|
|
292
|
+
const t = schema.type;
|
|
293
|
+
const types = Array.isArray(t) ? t : [t];
|
|
294
|
+
for (const one of types) {
|
|
295
|
+
if (typeof one !== 'string' || !CONTRACT_TYPES.has(one)) {
|
|
296
|
+
errors.push(specError('invalid_contract', `contract type ${JSON.stringify(one)} at ${path}.type is not an allowed type ` +
|
|
297
|
+
'(object/array/string/number/integer/boolean/null)', `${path}.type`));
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
// `properties` — a map of arbitrary PROPERTY NAMES to sub-schemas (recurse into the sub-schemas).
|
|
302
|
+
if ('properties' in schema) {
|
|
303
|
+
const props = schema.properties;
|
|
304
|
+
if (props === null || typeof props !== 'object' || Array.isArray(props)) {
|
|
305
|
+
errors.push(specError('invalid_contract', `contract 'properties' at ${path}.properties must be an object`, `${path}.properties`));
|
|
306
|
+
}
|
|
307
|
+
else {
|
|
308
|
+
for (const [pname, pschema] of Object.entries(props)) {
|
|
309
|
+
lintContractSchema(pschema, `${path}.properties.${pname}`, errors);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
// `items` — a single sub-schema (recurse).
|
|
314
|
+
if ('items' in schema)
|
|
315
|
+
lintContractSchema(schema.items, `${path}.items`, errors);
|
|
316
|
+
// `required` — an array of strings.
|
|
317
|
+
if ('required' in schema) {
|
|
318
|
+
const req = schema.required;
|
|
319
|
+
if (!Array.isArray(req) || req.some((r) => typeof r !== 'string')) {
|
|
320
|
+
errors.push(specError('invalid_contract', `contract 'required' at ${path}.required must be an array of strings`, `${path}.required`));
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
// `enum` — a non-empty array of SCALARS (string/number/boolean/null). An object/array element is not a
|
|
324
|
+
// valid enum member and would smuggle a nested shape (e.g. a `{ handler, code }` map) past BOTH the
|
|
325
|
+
// contracts-exempt global guardrail scan and this vocabulary check — the same class of hole that
|
|
326
|
+
// `description` had. Each non-scalar element is reported at its index.
|
|
327
|
+
if ('enum' in schema) {
|
|
328
|
+
const en = schema.enum;
|
|
329
|
+
if (!Array.isArray(en) || en.length === 0) {
|
|
330
|
+
errors.push(specError('invalid_contract', `contract 'enum' at ${path}.enum must be a non-empty array`, `${path}.enum`));
|
|
331
|
+
}
|
|
332
|
+
else {
|
|
333
|
+
en.forEach((element, i) => {
|
|
334
|
+
if (element !== null && typeof element === 'object') {
|
|
335
|
+
errors.push(specError('invalid_contract', `contract 'enum' element at ${path}.enum[${i}] must be a scalar ` +
|
|
336
|
+
'(string/number/boolean/null), not an object/array', `${path}.enum[${i}]`));
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
// ---------------------------------------------------------------------------------------
|
|
343
|
+
// declared product stores + store steps
|
|
344
|
+
// ---------------------------------------------------------------------------------------
|
|
345
|
+
/**
|
|
346
|
+
* The GRAPH KEY DENYLIST a declared store column name must avoid: the whole point of a declared
|
|
347
|
+
* column is to be referenced as an OBJECT KEY from workflow-step `filter`/`values` maps — which live
|
|
348
|
+
* in the graph subtree the guardrail scan bans these keys in. A column named `model`/`body`/`sql`/…
|
|
349
|
+
* would be UNDECLARABLE in any step (a confusing late `no_code_in_yaml`/`provider_native_leak` at the
|
|
350
|
+
* use site), so it is rejected AT THE DECLARATION with an explaining message instead.
|
|
351
|
+
*/
|
|
352
|
+
const STORE_COLUMN_DENYLIST = new Set([
|
|
353
|
+
...GLOBAL_CODE_KEYS,
|
|
354
|
+
...GLOBAL_PROVIDER_BLOB_KEYS,
|
|
355
|
+
...GRAPH_PROVIDER_POLICY_KEYS,
|
|
356
|
+
...GRAPH_PROMPT_KEYS,
|
|
357
|
+
]);
|
|
358
|
+
/**
|
|
359
|
+
* The store checks — section-level (names, columns, keys) + step-level (target resolution, column
|
|
360
|
+
* contracts, the conflict-key law, per-type field discipline). ONE shared implementation consumed by
|
|
361
|
+
* BOTH `lintProductSpec` (doc/parse time) and `composeProductDeploy` (mount time, defense-in-depth for
|
|
362
|
+
* a code-built spec that bypassed the parser) — never two drifting copies. Returns the FULL list.
|
|
363
|
+
*
|
|
364
|
+
* `capabilityStoreNames` (OPTIONAL — CW-1): the CAPABILITY-OWNED store names of the composing
|
|
365
|
+
* runtime (e.g. the audio mount's stores). A declared store shadowing one is rejected fail-closed
|
|
366
|
+
* (capability stores are owned by their Tier-B runtime). PARSE-TIME callers pass NOTHING —
|
|
367
|
+
* @rayspec/spec cannot import runtime store names, so the doc-level lint covers COLLECTION
|
|
368
|
+
* collisions only (the documented cut); COMPOSE passes the real wired set and covers BOTH (so a
|
|
369
|
+
* code-built spec that bypassed the parser is caught at compose, BEFORE derive/rollout).
|
|
370
|
+
*/
|
|
371
|
+
export function checkProductStores(spec, capabilityStoreNames) {
|
|
372
|
+
const errors = [];
|
|
373
|
+
const inv = (message, path) => {
|
|
374
|
+
errors.push(specError('invalid_store', message, path));
|
|
375
|
+
};
|
|
376
|
+
// ---- section level ------------------------------------------------------------------
|
|
377
|
+
errors.push(...findDuplicates(spec.stores, (s) => s.name, 'store name', (i) => `stores[${i}].name`));
|
|
378
|
+
const collectionNames = new Set(spec.artifacts.map((a) => a.collection).filter((c) => typeof c === 'string'));
|
|
379
|
+
const storeByName = new Map(spec.stores.map((s) => [s.name, s]));
|
|
380
|
+
spec.stores.forEach((store, si) => {
|
|
381
|
+
const base = `stores[${si}]`;
|
|
382
|
+
if (collectionNames.has(store.name)) {
|
|
383
|
+
inv(`store '${store.name}' collides with a derived artifact COLLECTION store of the same name — ` +
|
|
384
|
+
'collection stores are derived from artifacts[].collection and owned by the artifact.persist ' +
|
|
385
|
+
'lifecycle; declare a distinct name', `${base}.name`);
|
|
386
|
+
}
|
|
387
|
+
// CW-1: only when the caller supplies the runtime's capability-owned store names (compose does;
|
|
388
|
+
// parse time cannot — see the docstring). Mirrors deriveProductStores' own fail-closed check so
|
|
389
|
+
// an audio-name shadow is rejected at compose even when derive never runs (hand-built rollout).
|
|
390
|
+
if (capabilityStoreNames?.has(store.name)) {
|
|
391
|
+
inv(`store '${store.name}' collides with a capability-owned store of the same name — ` +
|
|
392
|
+
'capability stores are owned by their Tier-B runtime; declare a distinct name', `${base}.name`);
|
|
393
|
+
}
|
|
394
|
+
errors.push(...findDuplicates(store.columns, (c) => c.name, `column name in store '${store.name}'`, (i) => `${base}.columns[${i}].name`));
|
|
395
|
+
const columnByName = new Map(store.columns.map((c) => [c.name, c]));
|
|
396
|
+
store.columns.forEach((col, ci) => {
|
|
397
|
+
if (RESERVED_COLUMN_NAMES.has(col.name)) {
|
|
398
|
+
errors.push(specError('reserved_column_name', `store '${store.name}' declares reserved column '${col.name}' — that column is injected ` +
|
|
399
|
+
'by the platform (tenancy/GDPR) and may not be declared as a business column', `${base}.columns[${ci}].name`));
|
|
400
|
+
}
|
|
401
|
+
else if (RESERVED_QUERY_KEYWORDS.has(col.name)) {
|
|
402
|
+
// A column named after a list-query control keyword (order/after/limit/search/__search) would be
|
|
403
|
+
// un-filterable AND would emit a duplicate OpenAPI query parameter on a list route — reject at
|
|
404
|
+
// config with a rename hint (symmetric with the backend store lint).
|
|
405
|
+
errors.push(specError('reserved_query_keyword', `store '${store.name}' declares column '${col.name}', which collides with a reserved ` +
|
|
406
|
+
'list-query control keyword (order/after/limit/search/__search) used for sorting/keyset ' +
|
|
407
|
+
'pagination/substring/full-text search — the column would be un-filterable and would emit ' +
|
|
408
|
+
'a duplicate OpenAPI query parameter; rename the business column', `${base}.columns[${ci}].name`));
|
|
409
|
+
}
|
|
410
|
+
else if (STORE_COLUMN_DENYLIST.has(col.name)) {
|
|
411
|
+
inv(`store '${store.name}' column '${col.name}' collides with a banned graph key — it could ` +
|
|
412
|
+
"never be referenced from a workflow step's filter/values (the no-code/provider-neutrality " +
|
|
413
|
+
'guardrails ban that key in the graph); pick a different column name', `${base}.columns[${ci}].name`);
|
|
414
|
+
}
|
|
415
|
+
});
|
|
416
|
+
// Two DECLARED column names mapping to the SAME camelCase
|
|
417
|
+
// runtime key would silently resolve to ONE runtime column — the table builder keys the PgTable
|
|
418
|
+
// by camelCase (build-product-tables.ts) and the facade maps names with the same rule
|
|
419
|
+
// (store-facade.ts snakeToCamel), while the store NODE classifies its DO-UPDATE/DO-NOTHING
|
|
420
|
+
// upsert arm on the SNAKE names (store-nodes.ts) — a camel collision is exactly where those two
|
|
421
|
+
// independent classifications could diverge. SafeIdentifier does NOT prevent it: the rule's
|
|
422
|
+
// `_([a-z0-9])` uppercases DIGITS as a NO-OP, so `col_1` and `col1` BOTH map to `col1` (letters
|
|
423
|
+
// are safe: `a_bc`→`aBc` ≠ `ab_c`→`abC` — the underscore survives as case). Mirrors the backend
|
|
424
|
+
// lint's column-collision check (lint.ts) with an explaining both-columns message.
|
|
425
|
+
//
|
|
426
|
+
// The map is SEEDED with the INJECTED tenancy/GDPR camels — HONESTY NOTE: with the CURRENT
|
|
427
|
+
// injected set this seed arm is provably unreachable (no injected name contains a digit, and an
|
|
428
|
+
// underscore-before-a-digit is the ONLY collision vector within SafeIdentifier space, so any
|
|
429
|
+
// name camel-colliding with an injected name IS that name — which `reserved_column_name` above
|
|
430
|
+
// already rejected). Kept as a structural guard should the injected set ever gain a
|
|
431
|
+
// digit-bearing name; the DECLARED-vs-DECLARED arm below is the live, RED-first-tested one.
|
|
432
|
+
const camelOwner = new Map();
|
|
433
|
+
for (const reserved of RESERVED_COLUMN_NAMES)
|
|
434
|
+
camelOwner.set(toJsIdentifier(reserved), reserved);
|
|
435
|
+
store.columns.forEach((col, ci) => {
|
|
436
|
+
const camel = toJsIdentifier(col.name);
|
|
437
|
+
const owner = camelOwner.get(camel);
|
|
438
|
+
if (owner === undefined) {
|
|
439
|
+
camelOwner.set(camel, col.name);
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
// The exact-duplicate / exact-reserved case is already rejected above (duplicate_name /
|
|
443
|
+
// reserved_column_name) — do not double-report it here.
|
|
444
|
+
if (owner === col.name)
|
|
445
|
+
return;
|
|
446
|
+
const ownerIsInjected = RESERVED_COLUMN_NAMES.has(owner);
|
|
447
|
+
inv(ownerIsInjected
|
|
448
|
+
? `store '${store.name}' column '${col.name}' collides with the INJECTED column '${owner}' ` +
|
|
449
|
+
`under the snake_case→camelCase column mapping (both become runtime key '${camel}') — ` +
|
|
450
|
+
'the injected tenancy/GDPR columns own that runtime key; rename the business column'
|
|
451
|
+
: `store '${store.name}' columns '${owner}' and '${col.name}' collide under the ` +
|
|
452
|
+
`snake_case→camelCase column mapping (both become runtime column key '${camel}') — the ` +
|
|
453
|
+
'runtime table and the store facade key columns by camelCase, so the two declared names ' +
|
|
454
|
+
'would silently resolve to ONE column; rename one', `${base}.columns[${ci}].name`);
|
|
455
|
+
});
|
|
456
|
+
store.key.forEach((keyColumn, ki) => {
|
|
457
|
+
const col = columnByName.get(keyColumn);
|
|
458
|
+
if (!col) {
|
|
459
|
+
inv(`store '${store.name}' key names undeclared column '${keyColumn}' — the conflict key must ` +
|
|
460
|
+
'be a declared column', `${base}.key[${ki}]`);
|
|
461
|
+
}
|
|
462
|
+
else if (col.nullable) {
|
|
463
|
+
inv(`store '${store.name}' key column '${keyColumn}' is nullable — a NULLABLE conflict key ` +
|
|
464
|
+
'breaks the upsert identity (a unique index admits multiple NULLs, so re-executed writes ' +
|
|
465
|
+
'would duplicate instead of dedupe); declare it non-nullable', `${base}.key[${ki}]`);
|
|
466
|
+
}
|
|
467
|
+
});
|
|
468
|
+
});
|
|
469
|
+
// ---- step level ---------------------------------------------------------------------
|
|
470
|
+
const contractIds = new Set(Object.keys(spec.contracts));
|
|
471
|
+
const capabilityContractRefs = new Set();
|
|
472
|
+
for (const cap of spec.capabilities)
|
|
473
|
+
for (const cref of cap.contracts)
|
|
474
|
+
capabilityContractRefs.add(cref);
|
|
475
|
+
spec.workflows.forEach((wf, wi) => {
|
|
476
|
+
wf.steps.forEach((step, si) => {
|
|
477
|
+
const base = `workflows[${wi}].steps[${si}]`;
|
|
478
|
+
const isRead = step.type === 'store_read';
|
|
479
|
+
const isWrite = step.type === 'store_write';
|
|
480
|
+
if (!isRead && !isWrite) {
|
|
481
|
+
for (const field of ['store', 'filter', 'limit', 'values']) {
|
|
482
|
+
if (step[field] !== undefined) {
|
|
483
|
+
inv(`workflow '${wf.id}' step '${step.id}' of type '${step.type}' declares '${field}', ` +
|
|
484
|
+
'which is store-step vocabulary (store_read/store_write only)', `${base}.${field}`);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
// The target store must be DECLARED (stores[]) — never a derived collection / capability store
|
|
490
|
+
// (those are owned by their own lifecycles; a step write would bypass them).
|
|
491
|
+
const target = step.store !== undefined ? storeByName.get(step.store) : undefined;
|
|
492
|
+
if (step.store === undefined) {
|
|
493
|
+
inv(`workflow '${wf.id}' ${step.type} step '${step.id}' must declare a target 'store' ` +
|
|
494
|
+
'(a declared stores[].name)', `${base}.store`);
|
|
495
|
+
}
|
|
496
|
+
else if (!target) {
|
|
497
|
+
inv(`workflow '${wf.id}' step '${step.id}' targets store '${step.store}', which is not a ` +
|
|
498
|
+
'declared product store (stores[]) — derived collection/capability-owned stores are ' +
|
|
499
|
+
'never step-addressable', `${base}.store`);
|
|
500
|
+
}
|
|
501
|
+
const declaredColumns = target ? new Set(target.columns.map((c) => c.name)) : undefined;
|
|
502
|
+
const columnList = target ? target.columns.map((c) => c.name).join(', ') : '';
|
|
503
|
+
if (isRead) {
|
|
504
|
+
if (step.values !== undefined) {
|
|
505
|
+
inv(`store_read step '${step.id}' declares 'values' (store_write vocabulary) — a read has ` +
|
|
506
|
+
'filters, not written values', `${base}.values`);
|
|
507
|
+
}
|
|
508
|
+
for (const column of Object.keys(step.filter ?? {})) {
|
|
509
|
+
if (declaredColumns && !declaredColumns.has(column)) {
|
|
510
|
+
inv(`store_read step '${step.id}' filters on '${column}', which is not a declared column ` +
|
|
511
|
+
`of store '${step.store}' (declared: ${columnList})`, `${base}.filter.${column}`);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
const outputCount = Object.keys(step.outputs ?? {}).length;
|
|
515
|
+
if (outputCount !== 1) {
|
|
516
|
+
inv(`store_read step '${step.id}' must declare exactly one output (the rows artifact ` +
|
|
517
|
+
`contract ref a downstream step consumes); got ${outputCount}`, `${base}.outputs`);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
else {
|
|
521
|
+
for (const field of ['filter', 'limit']) {
|
|
522
|
+
if (step[field] !== undefined) {
|
|
523
|
+
inv(`store_write step '${step.id}' declares '${field}' (store_read vocabulary) — a write ` +
|
|
524
|
+
'is an upsert of one declared row, never a query', `${base}.${field}`);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
const entries = Object.entries(step.values ?? {});
|
|
528
|
+
if (step.values === undefined || entries.length === 0) {
|
|
529
|
+
inv(`store_write step '${step.id}' must declare non-empty 'values' (the written row: ` +
|
|
530
|
+
'column → event key | literal | upstream artifact)', `${base}.values`);
|
|
531
|
+
}
|
|
532
|
+
for (const [column, source] of entries) {
|
|
533
|
+
if (declaredColumns && !declaredColumns.has(column)) {
|
|
534
|
+
inv(`store_write step '${step.id}' writes column '${column}', which is not a declared ` +
|
|
535
|
+
`column of store '${step.store}' (declared: ${columnList})`, `${base}.values.${column}`);
|
|
536
|
+
}
|
|
537
|
+
if (source !== null && typeof source === 'object' && 'artifact' in source) {
|
|
538
|
+
const ref = source.artifact;
|
|
539
|
+
if (!contractIds.has(ref) && !capabilityContractRefs.has(ref)) {
|
|
540
|
+
errors.push(specError('dangling_ref', `store_write step '${step.id}' values.${column} references artifact contract ` +
|
|
541
|
+
`'${ref}', which is not declared in contracts[] or on a capability`, `${base}.values.${column}`));
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
if (target && entries.length > 0) {
|
|
546
|
+
for (const keyColumn of target.key) {
|
|
547
|
+
if (!entries.some(([column]) => column === keyColumn)) {
|
|
548
|
+
inv(`store_write step '${step.id}' values must include the conflict-key column ` +
|
|
549
|
+
`'${keyColumn}' of store '${step.store}' — the upsert identity every re-executed ` +
|
|
550
|
+
'write dedupes on (the at-least-once law)', `${base}.values`);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
const outputCount = Object.keys(step.outputs ?? {}).length;
|
|
555
|
+
if (outputCount > 1) {
|
|
556
|
+
inv(`store_write step '${step.id}' may declare at most one output (the written-row ` +
|
|
557
|
+
`artifact); got ${outputCount}`, `${base}.outputs`);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
});
|
|
561
|
+
});
|
|
562
|
+
return errors;
|
|
563
|
+
}
|
|
564
|
+
// ---------------------------------------------------------------------------------------
|
|
565
|
+
// cross-references + duplicates + capability status
|
|
566
|
+
// ---------------------------------------------------------------------------------------
|
|
567
|
+
/** Report each duplicate occurrence (by index) as a `duplicate_name` SpecError. */
|
|
568
|
+
function findDuplicates(items, keyOf, label, pathOf) {
|
|
569
|
+
const errors = [];
|
|
570
|
+
const seen = new Set();
|
|
571
|
+
items.forEach((item, index) => {
|
|
572
|
+
const key = keyOf(item);
|
|
573
|
+
if (seen.has(key)) {
|
|
574
|
+
errors.push(specError('duplicate_name', `duplicate ${label} '${key}' (each ${label} must be unique)`, pathOf(index)));
|
|
575
|
+
}
|
|
576
|
+
else {
|
|
577
|
+
seen.add(key);
|
|
578
|
+
}
|
|
579
|
+
});
|
|
580
|
+
return errors;
|
|
581
|
+
}
|
|
582
|
+
/**
|
|
583
|
+
* The full semantic pass over an already-shape-valid `ProductSpec`. Resolves every cross-reference,
|
|
584
|
+
* rejects duplicates, enforces the capability-status discipline, and vets the contract vocabulary.
|
|
585
|
+
*/
|
|
586
|
+
export function lintProductSpec(spec) {
|
|
587
|
+
const errors = [];
|
|
588
|
+
const capabilityIds = new Set(spec.capabilities.map((c) => c.id));
|
|
589
|
+
// The declared extractor ids — each registers a runtime `agent.<id>` operation (the byte-identity
|
|
590
|
+
// namespace), so a workflow `agent` step's `use: agent.<id>` resolves against THIS set.
|
|
591
|
+
const extractorIds = new Set(spec.extractors.map((a) => a.id));
|
|
592
|
+
const contractIds = new Set(Object.keys(spec.contracts));
|
|
593
|
+
// Every contract EXPLICITLY declared on a capability (its `contracts[]` list). A capability-namespaced
|
|
594
|
+
// ref must resolve to one of THESE, not to the namespace alone: `cap.typoed_contract` used to
|
|
595
|
+
// resolve merely because `cap` was a declared capability, letting a typo'd Tier-B contract ref through.
|
|
596
|
+
const capabilityContractRefs = new Set();
|
|
597
|
+
for (const cap of spec.capabilities)
|
|
598
|
+
for (const cref of cap.contracts)
|
|
599
|
+
capabilityContractRefs.add(cref);
|
|
600
|
+
/**
|
|
601
|
+
* A contract ref resolves iff it is a DECLARED top-level contract id OR an EXACT contract declared on
|
|
602
|
+
* some capability (`capabilities[].contracts[]`). Fail-closed: a capability-namespaced ref whose
|
|
603
|
+
* contract is NOT declared on that capability is a `dangling_ref`, not silently accepted on the
|
|
604
|
+
* namespace alone.
|
|
605
|
+
*/
|
|
606
|
+
const refResolves = (ref) => contractIds.has(ref) || capabilityContractRefs.has(ref);
|
|
607
|
+
const checkRef = (ref, path) => {
|
|
608
|
+
if (!refResolves(ref)) {
|
|
609
|
+
errors.push(specError('dangling_ref', `unresolved contract/capability reference '${ref}' at ${path}; declare it in contracts[] ` +
|
|
610
|
+
'or reference a declared capability contract', path));
|
|
611
|
+
}
|
|
612
|
+
};
|
|
613
|
+
// ---- DUPLICATES --------------------------------------------------------------------
|
|
614
|
+
errors.push(...findDuplicates(spec.capabilities, (c) => c.id, 'capability id', (i) => `capabilities[${i}].id`), ...findDuplicates(spec.artifacts, (a) => a.kind, 'artifact kind', (i) => `artifacts[${i}].kind`), ...findDuplicates(spec.extractors, (a) => a.id, 'extractor id', (i) => `extractors[${i}].id`), ...findDuplicates(spec.workflows, (w) => w.id, 'workflow id', (i) => `workflows[${i}].id`), ...findDuplicates(spec.views, (v) => v.id, 'view id', (i) => `views[${i}].id`), ...findDuplicates(spec.views, (v) => `${v.route.method} ${v.route.path}`, 'view route', (i) => `views[${i}].route`));
|
|
615
|
+
// ---- requires.capabilities → declared capability ids -------------------------------
|
|
616
|
+
spec.requires.capabilities.forEach((id, i) => {
|
|
617
|
+
if (!capabilityIds.has(id)) {
|
|
618
|
+
errors.push(specError('dangling_ref', `requires.capabilities references undeclared capability '${id}' — declare it in capabilities[]`, `requires.capabilities[${i}]`));
|
|
619
|
+
}
|
|
620
|
+
});
|
|
621
|
+
// ---- capability status vocabulary -------------------------------------------
|
|
622
|
+
// The earlier blanket rejection of `status:'available'` is deliberately GONE: its premise
|
|
623
|
+
// ("no Tier B runtime is wired yet") expired once the Tier B runtime landed and
|
|
624
|
+
// unlocked the deploy mount. The lint is a DOC-level pass and cannot know what a given deployment
|
|
625
|
+
// wires, so WIREDNESS is now enforced at the real enforcement point — the deploy composition
|
|
626
|
+
// (`@rayspec/product-yaml` composeProductDeploy, called from `deploy()`): a MOUNT rejects any
|
|
627
|
+
// capability that is not `available` or not runtime-backed, fail-closed with the section named.
|
|
628
|
+
// `doctor`/`plan` (validate-only) accept all three statuses; the closed enum in product-grammar.ts
|
|
629
|
+
// still rejects unknown statuses at the shape level. (`invalid_capability_status` stays a reserved
|
|
630
|
+
// SpecError code for the closed-code discipline; nothing emits it at doc level today.)
|
|
631
|
+
// ---- artifacts[].contract → resolve ------------------------------------------------
|
|
632
|
+
spec.artifacts.forEach((art, i) => {
|
|
633
|
+
checkRef(art.contract, `artifacts[${i}].contract`);
|
|
634
|
+
if (art.provenance?.source)
|
|
635
|
+
checkRef(art.provenance.source, `artifacts[${i}].provenance.source`);
|
|
636
|
+
// A declared `quote_field` (opt-in grounding quote-text verification) must name a STRING property
|
|
637
|
+
// of this artifact's contract — the runtime reads the quote off the member payload and verifies it
|
|
638
|
+
// against the cited spans' text, so a quote_field with no declared string field is fail-closed.
|
|
639
|
+
// (The sibling `evidence_field` is intentionally NOT property-resolved: it may name a runtime-only
|
|
640
|
+
// field the contract does not declare; a quote to VERIFY must be a real declared string.)
|
|
641
|
+
const quoteField = art.provenance?.quote_field;
|
|
642
|
+
if (quoteField) {
|
|
643
|
+
const contract = spec.contracts[art.contract];
|
|
644
|
+
if (contract && typeof contract === 'object') {
|
|
645
|
+
const props = contract.properties;
|
|
646
|
+
const prop = props && typeof props === 'object'
|
|
647
|
+
? props[quoteField]
|
|
648
|
+
: undefined;
|
|
649
|
+
const isStringProp = prop !== null &&
|
|
650
|
+
typeof prop === 'object' &&
|
|
651
|
+
prop.type === 'string';
|
|
652
|
+
if (!isStringProp) {
|
|
653
|
+
errors.push(specError('dangling_ref', `artifacts[${i}].provenance.quote_field '${quoteField}' does not name a string property ` +
|
|
654
|
+
`of contract '${art.contract}' — grounding quote-text verification needs a declared string field.`, `artifacts[${i}].provenance.quote_field`));
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
});
|
|
659
|
+
// ---- capabilities[].input_normalize.output_contract → resolve ----------------------
|
|
660
|
+
// A declared input-normalize step names the contract the NORMALIZED record must conform to; it must
|
|
661
|
+
// resolve to a declared/capability contract (the same discipline as an artifacts[].contract ref). The
|
|
662
|
+
// `agent` id is a config-side label (the deployment binds a normalizer to it — not a declared
|
|
663
|
+
// extractor), so it is grammar-shape-checked (SafeIdentifier) but not resolved here.
|
|
664
|
+
spec.capabilities.forEach((cap, i) => {
|
|
665
|
+
if (cap.input_normalize) {
|
|
666
|
+
checkRef(cap.input_normalize.output_contract, `capabilities[${i}].input_normalize.output_contract`);
|
|
667
|
+
}
|
|
668
|
+
});
|
|
669
|
+
// ---- contracts vocabulary ----------------------------------------------------------
|
|
670
|
+
for (const [id, schema] of Object.entries(spec.contracts)) {
|
|
671
|
+
lintContractSchema(schema, `contracts.${id}`, errors);
|
|
672
|
+
}
|
|
673
|
+
// ---- extractors[].extraction refs → resolve ----------------------------------------
|
|
674
|
+
spec.extractors.forEach((extractor, ai) => {
|
|
675
|
+
const ex = extractor.extraction;
|
|
676
|
+
ex.input_artifacts.forEach((art, i) => {
|
|
677
|
+
checkRef(art.ref, `extractors[${ai}].extraction.input_artifacts[${i}].ref`);
|
|
678
|
+
});
|
|
679
|
+
ex.output_artifacts.forEach((art, i) => {
|
|
680
|
+
checkRef(art.ref, `extractors[${ai}].extraction.output_artifacts[${i}].ref`);
|
|
681
|
+
if (art.schema_ref)
|
|
682
|
+
checkRef(art.schema_ref, `extractors[${ai}].extraction.output_artifacts[${i}].schema_ref`);
|
|
683
|
+
});
|
|
684
|
+
checkRef(ex.required_output_shape.schema_ref, `extractors[${ai}].extraction.required_output_shape.schema_ref`);
|
|
685
|
+
});
|
|
686
|
+
// ---- workflows[] --------------------------------------------------------------------
|
|
687
|
+
spec.workflows.forEach((wf, wi) => {
|
|
688
|
+
const triggerCap = spec.capabilities.find((c) => c.id === wf.trigger.capability);
|
|
689
|
+
if (!triggerCap) {
|
|
690
|
+
errors.push(specError('dangling_ref', `workflow '${wf.id}' trigger references undeclared capability '${wf.trigger.capability}'`, `workflows[${wi}].trigger.capability`));
|
|
691
|
+
}
|
|
692
|
+
else {
|
|
693
|
+
// trigger.event must resolve to a declared contract of that capability. We normalize via
|
|
694
|
+
// the SHARED `normalizeProductTriggerEvent` (product-events.ts — the same single source the
|
|
695
|
+
// bridge compiles with), then require the normalized event ∈ the capability's declared
|
|
696
|
+
// `contracts[]` — the doc-level proxy for the Tier-B event vocabulary the bridge checks against
|
|
697
|
+
// its stage manifests. A typo'd event fails closed HERE, not only at bridge-compile time.
|
|
698
|
+
const triggerEvent = normalizeProductTriggerEvent(wf.trigger.capability, wf.trigger.event);
|
|
699
|
+
if (!triggerCap.contracts.includes(triggerEvent)) {
|
|
700
|
+
errors.push(specError('dangling_ref', `workflow '${wf.id}' trigger event '${wf.trigger.event}' does not resolve to a declared ` +
|
|
701
|
+
`contract of capability '${wf.trigger.capability}' (normalized '${triggerEvent}'); declare ` +
|
|
702
|
+
"it in that capability's contracts[]", `workflows[${wi}].trigger.event`));
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
errors.push(...findDuplicates(wf.steps, (s) => s.id, `step id in workflow '${wf.id}'`, (i) => `workflows[${wi}].steps[${i}].id`));
|
|
706
|
+
const stepIds = new Set(wf.steps.map((s) => s.id));
|
|
707
|
+
// Steps declared BEFORE the current one (accumulated in declaration order). `depends_on` may only
|
|
708
|
+
// reference an EARLIER step: an unknown step is a `dangling_ref`; a KNOWN step that is not yet
|
|
709
|
+
// declared (a forward or self reference) is an `invalid_dependency_order`. Requiring declaration-order
|
|
710
|
+
// structurally forbids dependency CYCLES (a cycle needs at least one forward edge) and mirrors the
|
|
711
|
+
// bridge's declaration-ordered step graph.
|
|
712
|
+
const seenStepIds = new Set();
|
|
713
|
+
wf.steps.forEach((step, si) => {
|
|
714
|
+
const base = `workflows[${wi}].steps[${si}]`;
|
|
715
|
+
for (const dep of step.depends_on ?? []) {
|
|
716
|
+
if (!stepIds.has(dep)) {
|
|
717
|
+
errors.push(specError('dangling_ref', `workflow '${wf.id}' step '${step.id}' depends on unknown step '${dep}'`, `${base}.depends_on`));
|
|
718
|
+
}
|
|
719
|
+
else if (!seenStepIds.has(dep)) {
|
|
720
|
+
errors.push(specError('invalid_dependency_order', `workflow '${wf.id}' step '${step.id}' depends on '${dep}', which is not declared before ` +
|
|
721
|
+
'it (forward/self/cyclic dependencies are rejected — declare dependencies earlier in steps[])', `${base}.depends_on`));
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
// `use` = namespace.operation; the operation half must be present.
|
|
725
|
+
const dot = step.use.indexOf('.');
|
|
726
|
+
const namespace = dot === -1 ? step.use : step.use.slice(0, dot);
|
|
727
|
+
const operation = dot === -1 ? '' : step.use.slice(dot + 1);
|
|
728
|
+
if (operation.length === 0) {
|
|
729
|
+
errors.push(specError('schema_violation', `workflow '${wf.id}' step '${step.id}' use '${step.use}' must be namespace.operation`, `${base}.use`));
|
|
730
|
+
}
|
|
731
|
+
else {
|
|
732
|
+
// per-type `use` discipline (mirrors the reference fixture + the bridge)
|
|
733
|
+
switch (step.type) {
|
|
734
|
+
case 'capability':
|
|
735
|
+
if (!capabilityIds.has(namespace)) {
|
|
736
|
+
errors.push(specError('dangling_ref', `workflow '${wf.id}' step '${step.id}' references undeclared capability '${namespace}'`, `${base}.use`));
|
|
737
|
+
}
|
|
738
|
+
break;
|
|
739
|
+
case 'agent':
|
|
740
|
+
if (namespace !== 'agent' || !extractorIds.has(operation)) {
|
|
741
|
+
errors.push(specError('dangling_ref', `workflow '${wf.id}' agent step '${step.id}' must use agent.<declared-extractor-id> (got '${step.use}')`, `${base}.use`));
|
|
742
|
+
}
|
|
743
|
+
break;
|
|
744
|
+
case 'validation':
|
|
745
|
+
if (namespace !== 'grounding' && namespace !== 'validation') {
|
|
746
|
+
errors.push(specError('schema_violation', `workflow '${wf.id}' validation step '${step.id}' must use grounding.* or validation.* (got '${step.use}')`, `${base}.use`));
|
|
747
|
+
}
|
|
748
|
+
break;
|
|
749
|
+
case 'artifact_persist':
|
|
750
|
+
if (step.use !== 'artifact.persist') {
|
|
751
|
+
errors.push(specError('schema_violation', `workflow '${wf.id}' artifact_persist step '${step.id}' must use artifact.persist (got '${step.use}')`, `${base}.use`));
|
|
752
|
+
}
|
|
753
|
+
break;
|
|
754
|
+
case 'artifact_read':
|
|
755
|
+
if (step.use !== 'artifact.read') {
|
|
756
|
+
errors.push(specError('schema_violation', `workflow '${wf.id}' artifact_read step '${step.id}' must use artifact.read (got '${step.use}')`, `${base}.use`));
|
|
757
|
+
}
|
|
758
|
+
break;
|
|
759
|
+
// the use discipline is EXACT (mirrors artifact_persist/artifact_read + the bridge) —
|
|
760
|
+
// the two wired operations are store.read / store.write, nothing else.
|
|
761
|
+
case 'store_read':
|
|
762
|
+
if (step.use !== 'store.read') {
|
|
763
|
+
errors.push(specError('schema_violation', `workflow '${wf.id}' store_read step '${step.id}' must use store.read (got '${step.use}')`, `${base}.use`));
|
|
764
|
+
}
|
|
765
|
+
break;
|
|
766
|
+
case 'store_write':
|
|
767
|
+
if (step.use !== 'store.write') {
|
|
768
|
+
errors.push(specError('schema_violation', `workflow '${wf.id}' store_write step '${step.id}' must use store.write (got '${step.use}')`, `${base}.use`));
|
|
769
|
+
}
|
|
770
|
+
break;
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
// inputs/outputs refs → resolve
|
|
774
|
+
for (const [k, ref] of Object.entries(step.inputs ?? {}))
|
|
775
|
+
checkRef(ref, `${base}.inputs.${k}`);
|
|
776
|
+
for (const [k, ref] of Object.entries(step.outputs ?? {}))
|
|
777
|
+
checkRef(ref, `${base}.outputs.${k}`);
|
|
778
|
+
// Record this step as declared — a LATER step may depend on it, an earlier one may not.
|
|
779
|
+
seenStepIds.add(step.id);
|
|
780
|
+
});
|
|
781
|
+
});
|
|
782
|
+
// ---- declared product stores + store steps ------------------------------------------
|
|
783
|
+
errors.push(...checkProductStores(spec));
|
|
784
|
+
// ---- grounding policy refs ---------------------------------------------------------
|
|
785
|
+
if (spec.grounding) {
|
|
786
|
+
if (spec.grounding.source_span_contract)
|
|
787
|
+
checkRef(spec.grounding.source_span_contract, 'grounding.source_span_contract');
|
|
788
|
+
if (spec.grounding.validation_capability)
|
|
789
|
+
checkRef(spec.grounding.validation_capability, 'grounding.validation_capability');
|
|
790
|
+
}
|
|
791
|
+
// ---- views[] -----------------------------------------------------------------------
|
|
792
|
+
// Source resolution is now KIND-AWARE and response-contract
|
|
793
|
+
// resolution + shape⊆contract conformance are SEPARATE validations — both live in
|
|
794
|
+
// `lintProductViews` (product-views-lint.ts), which the views runtime re-runs at mount time.
|
|
795
|
+
// The old flat `checkRef(view.source.ref)` (which let a source ref resolve against ANY contract
|
|
796
|
+
// — the conflation) is deliberately GONE.
|
|
797
|
+
errors.push(...lintProductViews({
|
|
798
|
+
views: spec.views,
|
|
799
|
+
contracts: spec.contracts,
|
|
800
|
+
artifacts: spec.artifacts,
|
|
801
|
+
capabilities: spec.capabilities,
|
|
802
|
+
}));
|
|
803
|
+
spec.views.forEach((view, vi) => {
|
|
804
|
+
const base = `views[${vi}]`;
|
|
805
|
+
if (!view.route.path.startsWith('/')) {
|
|
806
|
+
errors.push(specError('schema_violation', `view '${view.id}' route path must start with '/'`, `${base}.route.path`));
|
|
807
|
+
}
|
|
808
|
+
// Media STREAMING/PLAYBACK serving is a Tier-B media capability, NEVER a Product-YAML view
|
|
809
|
+
// handler. The view grammar already forbids a streaming SOURCE KIND (source.kind is a
|
|
810
|
+
// closed enum → a bad-enum `schema_violation`); this additionally rejects a route PATH that names
|
|
811
|
+
// binary/range media streaming (`/playback`, `/stream`, `/streaming`). Word-boundary-anchored so a
|
|
812
|
+
// playback-TOKEN read view (e.g. `/…/play-token`) is allowed — the token view returns a read model,
|
|
813
|
+
// not a stream. Tested per marker — no decorative/untested substring check.
|
|
814
|
+
if (STREAMING_ROUTE_MARKER.test(view.route.path)) {
|
|
815
|
+
errors.push(specError('schema_violation', `view '${view.id}' route '${view.route.path}' names media streaming/playback, which belongs ` +
|
|
816
|
+
'to Tier-B media capability serving, not a Product-YAML view route', `${base}.route.path`));
|
|
817
|
+
}
|
|
818
|
+
});
|
|
819
|
+
return errors;
|
|
820
|
+
}
|
|
821
|
+
//# sourceMappingURL=product-lint.js.map
|