@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,669 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Product-YAML VIEW lint — the static, fail-closed validation of view declarations.
|
|
3
|
+
*
|
|
4
|
+
* Called by `lintProductSpec` (product-lint.ts) AND re-run by `@rayspec/views-runtime` at MOUNT time
|
|
5
|
+
* (one source of truth — the runtime never re-implements these rules, so parse-time and mount-time
|
|
6
|
+
* validation cannot drift; the runtime only ADDS mount-only checks such as store existence against
|
|
7
|
+
* the injected read surface and column existence against the declared store columns).
|
|
8
|
+
*
|
|
9
|
+
* THE TWO SEPARATE VALIDATIONS:
|
|
10
|
+
* 1. SOURCE resolution (backing data) — KIND-AWARE:
|
|
11
|
+
* - `store` → a Tier-A/B store NAME (safe identifier). NEVER resolved against contracts;
|
|
12
|
+
* a contract-id-shaped ref (or the view's own response contract) is rejected.
|
|
13
|
+
* - `artifact_query` → a DECLARED artifact KIND or COLLECTION (`artifacts[]`). A ref that merely
|
|
14
|
+
* names a contract is rejected with an explaining error.
|
|
15
|
+
* - `capability` → a contract DECLARED ON a capability (`capabilities[].contracts[]`). A
|
|
16
|
+
* top-level product contract does not satisfy it.
|
|
17
|
+
* 2. RESPONSE-CONTRACT conformance (DTO shape) — the projected `read.shape` (and `read.absent`)
|
|
18
|
+
* must FIT the named contract: every projected field must be a declared contract property (when
|
|
19
|
+
* the contract closes its properties), every `required` property must be projected, and leaf
|
|
20
|
+
* types/defaults must be admitted by the property's declared types.
|
|
21
|
+
*
|
|
22
|
+
* Everything else is the CONTEXT-RULE table (the mode-dependent field vocabulary), param coverage,
|
|
23
|
+
* pagination laws, absent-state laws, and the reserved-name (`__proto__`-class) denylist. EVERY
|
|
24
|
+
* violation is an `invalid_view` (or `dangling_ref` where a plain cross-reference dangles) — never a
|
|
25
|
+
* silent skip (the FAIL-OPEN lesson: reject loudly, never silently skip).
|
|
26
|
+
*/
|
|
27
|
+
import { braceParamNames } from './brace-params.js';
|
|
28
|
+
import { specError } from './errors.js';
|
|
29
|
+
import { SafeIdentifier } from './grammar.js';
|
|
30
|
+
import { VIEW_RESERVED_NAMES, } from './product-views.js';
|
|
31
|
+
/** Extract `{param}` names from a declared route path (shared linear, no-regex scan). */
|
|
32
|
+
export function viewPathParams(path) {
|
|
33
|
+
return braceParamNames(path);
|
|
34
|
+
}
|
|
35
|
+
/** The CLOSED allowed-kind table per context (an out-of-context kind is rejected, never skipped). */
|
|
36
|
+
const ALLOWED_KINDS = {
|
|
37
|
+
// list mode top level: the pagination ENVELOPE (no row context exists here).
|
|
38
|
+
list_envelope: new Set(['page_items', 'page_total', 'page_next_offset', 'param', 'const']),
|
|
39
|
+
// single mode top level: THE row's projection.
|
|
40
|
+
single_row: new Set(['column', 'json', 'param', 'const', 'items', 'list', 'lookup', 'counts']),
|
|
41
|
+
// collect mode top level: aggregation over ALL collected rows (no single-row context).
|
|
42
|
+
collect_top: new Set(['param', 'const', 'group', 'counts']),
|
|
43
|
+
// any NESTED row shape (a page item, a list child row, a group row projection).
|
|
44
|
+
row: new Set(['column', 'json', 'param', 'const', 'items', 'list', 'lookup', 'counts']),
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* Resolve a contract node's ADMITTED TYPE SET (e.g. `{integer, null}`), following top-level `ref`s
|
|
48
|
+
* with a cycle guard. `undefined` = OPEN (the node declares no type — anything conforms).
|
|
49
|
+
*/
|
|
50
|
+
function admittedTypes(node, contracts, seen) {
|
|
51
|
+
if (!node)
|
|
52
|
+
return undefined;
|
|
53
|
+
const out = new Set();
|
|
54
|
+
const t = node.type;
|
|
55
|
+
if (typeof t === 'string')
|
|
56
|
+
out.add(t);
|
|
57
|
+
else if (Array.isArray(t))
|
|
58
|
+
for (const one of t)
|
|
59
|
+
if (typeof one === 'string')
|
|
60
|
+
out.add(one);
|
|
61
|
+
if (node.nullable === true)
|
|
62
|
+
out.add('null');
|
|
63
|
+
if (typeof node.ref === 'string' && !seen.has(node.ref)) {
|
|
64
|
+
seen.add(node.ref);
|
|
65
|
+
const target = contracts[node.ref];
|
|
66
|
+
const refTypes = admittedTypes(target, contracts, seen);
|
|
67
|
+
if (refTypes)
|
|
68
|
+
for (const one of refTypes)
|
|
69
|
+
out.add(one);
|
|
70
|
+
else if (out.size === 0)
|
|
71
|
+
return undefined; // an unresolvable/typeless ref keeps the node OPEN
|
|
72
|
+
}
|
|
73
|
+
return out.size > 0 ? out : undefined;
|
|
74
|
+
}
|
|
75
|
+
/** Does the admitted-type set accept a projected LEAF of declared type `t`? (integer ⊆ number.) */
|
|
76
|
+
function typesAdmit(admitted, t) {
|
|
77
|
+
if (!admitted)
|
|
78
|
+
return true; // open node — anything conforms
|
|
79
|
+
if (admitted.has(t))
|
|
80
|
+
return true;
|
|
81
|
+
if (t === 'integer' && admitted.has('number'))
|
|
82
|
+
return true;
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
/** The contract-type name of a literal default/const value. */
|
|
86
|
+
function constTypeNames(value) {
|
|
87
|
+
if (value === null)
|
|
88
|
+
return ['null'];
|
|
89
|
+
if (Array.isArray(value))
|
|
90
|
+
return ['array'];
|
|
91
|
+
if (typeof value === 'object')
|
|
92
|
+
return ['object'];
|
|
93
|
+
if (typeof value === 'boolean')
|
|
94
|
+
return ['boolean'];
|
|
95
|
+
if (typeof value === 'string')
|
|
96
|
+
return ['string'];
|
|
97
|
+
// a number: an integer literal satisfies BOTH integer and number properties
|
|
98
|
+
return Number.isInteger(value) ? ['integer', 'number'] : ['number'];
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Resolve a node's `properties` map, following a `ref` to a top-level contract (cycle-guarded).
|
|
102
|
+
*
|
|
103
|
+
* OPEN vs CLOSED (SEP-2): a node is OPEN (anything conforms) only when it declares NEITHER
|
|
104
|
+
* `properties` NOR `additional_properties: false`. A `{type: object, additional_properties: false}`
|
|
105
|
+
* node with NO properties is CLOSED-EMPTY — it declares "no keys at all", so EVERY projected field
|
|
106
|
+
* is rejected (previously such a node was mis-read as open, making conformance a no-op on it).
|
|
107
|
+
*/
|
|
108
|
+
function nodeProperties(node, contracts, seen) {
|
|
109
|
+
if (!node)
|
|
110
|
+
return { required: [], open: true };
|
|
111
|
+
// SEP-2: an explicit additional_properties:false CLOSES the node even with no declared properties.
|
|
112
|
+
const declaresClosed = node.additional_properties === false;
|
|
113
|
+
if (typeof node.ref === 'string') {
|
|
114
|
+
const target = contracts[node.ref];
|
|
115
|
+
if (target && !seen.has(node.ref)) {
|
|
116
|
+
seen.add(node.ref);
|
|
117
|
+
const inner = nodeProperties(target, contracts, seen);
|
|
118
|
+
// The referring node may ALSO declare properties/required — merge (outer wins per key).
|
|
119
|
+
const props = { ...(inner.properties ?? {}), ...(asProps(node.properties) ?? {}) };
|
|
120
|
+
const required = [...inner.required, ...asRequired(node.required)];
|
|
121
|
+
return {
|
|
122
|
+
...(Object.keys(props).length > 0 ? { properties: props } : {}),
|
|
123
|
+
required,
|
|
124
|
+
open: inner.open && node.properties === undefined && !declaresClosed,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
// A capability-contract ref (no top-level body) → OPEN unless this node itself closes.
|
|
128
|
+
return {
|
|
129
|
+
...(asProps(node.properties) ? { properties: asProps(node.properties) } : {}),
|
|
130
|
+
required: asRequired(node.required),
|
|
131
|
+
open: node.properties === undefined && !declaresClosed,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
const props = asProps(node.properties);
|
|
135
|
+
return {
|
|
136
|
+
...(props ? { properties: props } : {}),
|
|
137
|
+
required: asRequired(node.required),
|
|
138
|
+
open: props === undefined && !declaresClosed,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
function asProps(v) {
|
|
142
|
+
if (v === null || typeof v !== 'object' || Array.isArray(v))
|
|
143
|
+
return undefined;
|
|
144
|
+
return v;
|
|
145
|
+
}
|
|
146
|
+
function asRequired(v) {
|
|
147
|
+
return Array.isArray(v) ? v.filter((r) => typeof r === 'string') : [];
|
|
148
|
+
}
|
|
149
|
+
// ---------------------------------------------------------------------------------------
|
|
150
|
+
// the lint pass
|
|
151
|
+
// ---------------------------------------------------------------------------------------
|
|
152
|
+
/** Validate every view declaration. Returns the FULL violation list (never the first). */
|
|
153
|
+
export function lintProductViews(input) {
|
|
154
|
+
const errors = [];
|
|
155
|
+
const contractIds = new Set(Object.keys(input.contracts));
|
|
156
|
+
const artifactKinds = new Set(input.artifacts.map((a) => a.kind));
|
|
157
|
+
const artifactCollections = new Set(input.artifacts.map((a) => a.collection).filter((c) => typeof c === 'string'));
|
|
158
|
+
const capabilityContractRefs = new Set();
|
|
159
|
+
for (const cap of input.capabilities)
|
|
160
|
+
for (const c of cap.contracts)
|
|
161
|
+
capabilityContractRefs.add(c);
|
|
162
|
+
const invalid = (message, path) => {
|
|
163
|
+
errors.push(specError('invalid_view', message, path));
|
|
164
|
+
};
|
|
165
|
+
input.views.forEach((view, vi) => {
|
|
166
|
+
const base = `views[${vi}]`;
|
|
167
|
+
const params = view.params ?? {};
|
|
168
|
+
const paramNames = new Set(Object.keys(params));
|
|
169
|
+
// ---- reserved names in the params map -------------------------------------------------
|
|
170
|
+
for (const name of Object.keys(params)) {
|
|
171
|
+
if (VIEW_RESERVED_NAMES.has(name)) {
|
|
172
|
+
invalid(`view '${view.id}' declares reserved param name '${name}' (prototype-pollution guard)`, `${base}.params.${name}`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
// ---- source: KIND-AWARE resolution --------------------------------
|
|
176
|
+
if (view.source) {
|
|
177
|
+
const ref = view.source.ref;
|
|
178
|
+
const srcPath = `${base}.source.ref`;
|
|
179
|
+
switch (view.source.kind) {
|
|
180
|
+
case 'store': {
|
|
181
|
+
if (ref === view.response_contract) {
|
|
182
|
+
invalid(`view '${view.id}' store source ref '${ref}' names the view's own response contract — ` +
|
|
183
|
+
'a source declares BACKING DATA (a store name), not the DTO shape', srcPath);
|
|
184
|
+
}
|
|
185
|
+
else if (!SafeIdentifier.safeParse(ref).success) {
|
|
186
|
+
invalid(`view '${view.id}' store source ref '${ref}' is not a store name (a safe identifier); ` +
|
|
187
|
+
'a store source is NEVER a contract id — declare the backing store, not the DTO contract', srcPath);
|
|
188
|
+
}
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
case 'artifact_query': {
|
|
192
|
+
if (!artifactKinds.has(ref) && !artifactCollections.has(ref)) {
|
|
193
|
+
const conflated = contractIds.has(ref) || ref === view.response_contract;
|
|
194
|
+
if (view.read) {
|
|
195
|
+
// An INTERPRETED view is strictly kind-aware: its source names the backing artifacts.
|
|
196
|
+
invalid(`view '${view.id}' artifact_query source ref '${ref}' does not resolve to a declared ` +
|
|
197
|
+
`artifact kind or collection${conflated
|
|
198
|
+
? ' — it names a CONTRACT; a source declares BACKING DATA (what the view reads), not the DTO shape it returns'
|
|
199
|
+
: ''}`, srcPath);
|
|
200
|
+
}
|
|
201
|
+
else if (contractIds.has(ref)) {
|
|
202
|
+
// LEGACY carve-out (documented judgment call, TIGHTENED): a
|
|
203
|
+
// DECLARATION-ONLY view (no `read`) may still ref a TOP-LEVEL CONTRACT ID — EXACTLY
|
|
204
|
+
// the class a frozen legacy fixture declares (byte-synced to the committed
|
|
205
|
+
// fixture: its read-less artifact_query views ref top-level response contracts,
|
|
206
|
+
// never capability contracts). Frozen artifacts are not rewritten. Such a view is
|
|
207
|
+
// INERT: it can never mount (@rayspec/views-runtime requires `read`, where the
|
|
208
|
+
// separation is STRICT), so the conflation cannot reach the runtime. NOTHING ELSE
|
|
209
|
+
// passes silently.
|
|
210
|
+
}
|
|
211
|
+
else if (capabilityContractRefs.has(ref)) {
|
|
212
|
+
// OUTSIDE the carve-out: a capability-contract ref on an artifact_query source has no
|
|
213
|
+
// legacy precedent (the legacy fixture never declares it) and no execution path — the view is
|
|
214
|
+
// dead-on-arrival. Rejected loudly, never silently accepted.
|
|
215
|
+
invalid(`view '${view.id}' artifact_query source ref '${ref}' names a CAPABILITY contract — ` +
|
|
216
|
+
'outside the legacy carve-out (which admits top-level contract ids only, the ' +
|
|
217
|
+
'frozen legacy class); this view is dead-on-arrival: declare an artifact ' +
|
|
218
|
+
'kind/collection, or use a capability source for a delegated view', srcPath);
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
errors.push(specError('dangling_ref', `unresolved view source reference '${ref}' at ${srcPath}; declare it as an artifact ` +
|
|
222
|
+
'kind/collection (preferred) or a top-level contract (legacy, declaration-only)', srcPath));
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
break;
|
|
226
|
+
}
|
|
227
|
+
case 'capability': {
|
|
228
|
+
if (!capabilityContractRefs.has(ref)) {
|
|
229
|
+
invalid(`view '${view.id}' capability source ref '${ref}' does not resolve to a contract ` +
|
|
230
|
+
'declared on a capability (capabilities[].contracts[]); a top-level product contract ' +
|
|
231
|
+
'does not satisfy a capability source', srcPath);
|
|
232
|
+
}
|
|
233
|
+
break;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
// ---- response_contract resolution ------------------------------------------------------
|
|
238
|
+
const rcPath = `${base}.response_contract`;
|
|
239
|
+
if (view.read) {
|
|
240
|
+
// An INTERPRETED view's DTO contract must be a TOP-LEVEL product contract (conformance below
|
|
241
|
+
// needs its body); a capability contract has no in-document schema to conform against.
|
|
242
|
+
if (!contractIds.has(view.response_contract)) {
|
|
243
|
+
invalid(`view '${view.id}' response_contract '${view.response_contract}' must be a top-level ` +
|
|
244
|
+
'product contract (contracts[]) for an interpreted view (its shape is conformance-checked)', rcPath);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
else if (!contractIds.has(view.response_contract) &&
|
|
248
|
+
!capabilityContractRefs.has(view.response_contract)) {
|
|
249
|
+
errors.push(specError('dangling_ref', `unresolved contract/capability reference '${view.response_contract}' at ${rcPath}; declare ` +
|
|
250
|
+
'it in contracts[] or reference a declared capability contract', rcPath));
|
|
251
|
+
}
|
|
252
|
+
// ---- conditional_read: GET only ---------------------------------------------------------
|
|
253
|
+
if (view.conditional_read && view.route.method !== 'GET') {
|
|
254
|
+
invalid(`view '${view.id}' declares conditional_read '${view.conditional_read}' on a ` +
|
|
255
|
+
`${view.route.method} route — conditional reads apply to GET views only`, `${base}.conditional_read`);
|
|
256
|
+
}
|
|
257
|
+
// ---- params ↔ path coverage -------------------------------------------------------------
|
|
258
|
+
const pathParams = viewPathParams(view.route.path);
|
|
259
|
+
for (const [name, p] of Object.entries(params)) {
|
|
260
|
+
if (p.in === 'path') {
|
|
261
|
+
if (!pathParams.includes(name)) {
|
|
262
|
+
invalid(`view '${view.id}' declares path param '${name}' which does not appear in route path ` +
|
|
263
|
+
`'${view.route.path}'`, `${base}.params.${name}`);
|
|
264
|
+
}
|
|
265
|
+
if (p.required === false) {
|
|
266
|
+
invalid(`view '${view.id}' path param '${name}' cannot be optional (a path segment is always present)`, `${base}.params.${name}.required`);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
// A param must not collide with the pagination params (single owner: the pagination engine).
|
|
270
|
+
if (view.pagination &&
|
|
271
|
+
(name === view.pagination.limit_param || name === view.pagination.offset_param)) {
|
|
272
|
+
invalid(`view '${view.id}' param '${name}' collides with a pagination param — pagination params ` +
|
|
273
|
+
'are owned by the pagination declaration, not params', `${base}.params.${name}`);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
// ---- the read declaration ----------------------------------------------------------------
|
|
277
|
+
const read = view.read;
|
|
278
|
+
if (!read) {
|
|
279
|
+
// Declaration-only view (legacy-compatible). Pagination/absent_state stay as declared.
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
const readPath = `${base}.read`;
|
|
283
|
+
// An interpreted view must declare WHAT it reads — and a capability source is DELEGATED to the
|
|
284
|
+
// capability's own mount surface, never interpreted (its handler is capability code, not data).
|
|
285
|
+
if (!view.source) {
|
|
286
|
+
invalid(`view '${view.id}' declares read but no source (what does it read?)`, readPath);
|
|
287
|
+
}
|
|
288
|
+
else if (view.source.kind === 'capability') {
|
|
289
|
+
invalid(`view '${view.id}' declares read on a capability source — capability views are delegated to ` +
|
|
290
|
+
'the capability mount (their behavior is capability code), never interpreted from YAML', readPath);
|
|
291
|
+
}
|
|
292
|
+
// Every {param} in the route path must be a declared path param (full coverage — an undeclared
|
|
293
|
+
// path param would reach the read unvalidated).
|
|
294
|
+
for (const name of pathParams) {
|
|
295
|
+
if (!paramNames.has(name)) {
|
|
296
|
+
invalid(`view '${view.id}' route path param '{${name}}' is not declared in params (an interpreted ` +
|
|
297
|
+
'view validates EVERY input; declare it with a shape preset)', `${base}.route.path`);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
// ---- pagination laws ---------------------------------------------------------------------
|
|
301
|
+
if (read.mode === 'list') {
|
|
302
|
+
const pg = view.pagination;
|
|
303
|
+
if (!pg?.limit_param || !pg.offset_param || pg.max_limit === undefined) {
|
|
304
|
+
invalid(`view '${view.id}' is a list view and must declare bounded pagination ` +
|
|
305
|
+
'(limit_param + offset_param + max_limit) — an unbounded list response is fail-open', `${base}.pagination`);
|
|
306
|
+
}
|
|
307
|
+
else if (pg.default_limit !== undefined && pg.default_limit > pg.max_limit) {
|
|
308
|
+
invalid(`view '${view.id}' pagination default_limit ${pg.default_limit} exceeds max_limit ${pg.max_limit}`, `${base}.pagination.default_limit`);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
else if (view.pagination) {
|
|
312
|
+
invalid(`view '${view.id}' declares pagination on a '${read.mode}' read — pagination applies to list views only`, `${base}.pagination`);
|
|
313
|
+
}
|
|
314
|
+
// ---- absent-state laws -------------------------------------------------------------------
|
|
315
|
+
if (read.mode === 'single') {
|
|
316
|
+
if (view.absent_state === undefined) {
|
|
317
|
+
invalid(`view '${view.id}' is a single-row view and must declare absent_state ` +
|
|
318
|
+
"('empty_200' with read.absent, or 'not_ready_409')", `${base}.absent_state`);
|
|
319
|
+
}
|
|
320
|
+
else if (view.absent_state === 'empty_200' && !read.absent) {
|
|
321
|
+
invalid(`view '${view.id}' declares absent_state 'empty_200' but no read.absent shape — the ` +
|
|
322
|
+
'absent-row 200 DTO must be DECLARED, never improvised', `${readPath}.absent`);
|
|
323
|
+
}
|
|
324
|
+
else if (view.absent_state === 'not_ready_409' && read.absent) {
|
|
325
|
+
invalid(`view '${view.id}' declares BOTH absent_state 'not_ready_409' and a read.absent shape — ` +
|
|
326
|
+
'a 409 has a fixed error body; declare exactly one absent behavior', `${readPath}.absent`);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
else {
|
|
330
|
+
if (view.absent_state === 'not_ready_409') {
|
|
331
|
+
invalid(`view '${view.id}' declares absent_state 'not_ready_409' on a '${read.mode}' read — ` +
|
|
332
|
+
'readiness gating applies to single-row views only (a list/collect read is empty, not un-ready)', `${base}.absent_state`);
|
|
333
|
+
}
|
|
334
|
+
if (read.absent) {
|
|
335
|
+
invalid(`view '${view.id}' declares read.absent on a '${read.mode}' read — an absent shape applies ` +
|
|
336
|
+
'to single-row views only', `${readPath}.absent`);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
// ---- filter / order_by / exclude param refs + reserved names ------------------------------
|
|
340
|
+
const checkParamRef = (param, path) => {
|
|
341
|
+
if (!paramNames.has(param)) {
|
|
342
|
+
invalid(`view '${view.id}' references undeclared param '${param}' (declare it in params)`, path);
|
|
343
|
+
}
|
|
344
|
+
};
|
|
345
|
+
for (const [col, arg] of Object.entries(read.filter ?? {})) {
|
|
346
|
+
if (VIEW_RESERVED_NAMES.has(col)) {
|
|
347
|
+
invalid(`view '${view.id}' filter uses reserved column name '${col}'`, `${readPath}.filter.${col}`);
|
|
348
|
+
}
|
|
349
|
+
checkFilterArg(arg, `${readPath}.filter.${col}`, checkParamRef);
|
|
350
|
+
// A view-level filter param must be REQUIRED (a path param, or required:true) — an absent
|
|
351
|
+
// optional param would make the read's meaning ambiguous (unfiltered vs empty); fail-closed
|
|
352
|
+
// at declaration time instead of picking a runtime interpretation.
|
|
353
|
+
if ('param' in arg) {
|
|
354
|
+
const p = params[arg.param];
|
|
355
|
+
if (p && p.in !== 'path' && p.required !== true) {
|
|
356
|
+
invalid(`view '${view.id}' read.filter.${col} references OPTIONAL param '${arg.param}' — a ` +
|
|
357
|
+
'filter param must be required (a path param or required:true), so the read is never ambiguous', `${readPath}.filter.${col}`);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
// ---- the shape context walk ----------------------------------------------------------------
|
|
362
|
+
const topContext = read.mode === 'list'
|
|
363
|
+
? 'list_envelope'
|
|
364
|
+
: read.mode === 'single'
|
|
365
|
+
? 'single_row'
|
|
366
|
+
: 'collect_top';
|
|
367
|
+
const pageItemsCount = walkShape(read.shape, topContext, `${readPath}.shape`, {
|
|
368
|
+
viewId: view.id,
|
|
369
|
+
invalid,
|
|
370
|
+
checkParamRef,
|
|
371
|
+
collectTop: read.mode === 'collect',
|
|
372
|
+
});
|
|
373
|
+
if (read.mode === 'list' && pageItemsCount !== 1) {
|
|
374
|
+
invalid(`view '${view.id}' list envelope must contain exactly one page_items field (found ${pageItemsCount})`, `${readPath}.shape`);
|
|
375
|
+
}
|
|
376
|
+
// absent shape (param/const only — zod-enforced): reserved names + param resolution.
|
|
377
|
+
if (read.absent) {
|
|
378
|
+
for (const [name, field] of Object.entries(read.absent.fields)) {
|
|
379
|
+
if (VIEW_RESERVED_NAMES.has(name)) {
|
|
380
|
+
invalid(`view '${view.id}' absent shape uses reserved field name '${name}'`, `${readPath}.absent.fields.${name}`);
|
|
381
|
+
}
|
|
382
|
+
if (field.kind === 'param')
|
|
383
|
+
checkParamRef(field.param, `${readPath}.absent.fields.${name}`);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
// ---- response-contract CONFORMANCE (the DTO half) ---------------------
|
|
387
|
+
const contract = input.contracts[view.response_contract];
|
|
388
|
+
if (contract) {
|
|
389
|
+
conformShape(read.shape, contract, `${readPath}.shape`, {
|
|
390
|
+
viewId: view.id,
|
|
391
|
+
contracts: input.contracts,
|
|
392
|
+
invalid,
|
|
393
|
+
});
|
|
394
|
+
if (read.absent) {
|
|
395
|
+
conformAbsent(read.absent, contract, `${readPath}.absent`, {
|
|
396
|
+
viewId: view.id,
|
|
397
|
+
contracts: input.contracts,
|
|
398
|
+
invalid,
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
});
|
|
403
|
+
return errors;
|
|
404
|
+
}
|
|
405
|
+
/** Validate a sub-read's match refs + reserved names. */
|
|
406
|
+
function checkSubRead(sub, path, ctx) {
|
|
407
|
+
const entries = Object.entries(sub.match);
|
|
408
|
+
if (entries.length === 0) {
|
|
409
|
+
ctx.invalid(`view '${ctx.viewId}' sub-read on store '${sub.store}' declares an EMPTY match — a sub-read ` +
|
|
410
|
+
'must be keyed (an unmatched sub-read would read the whole tenant table per parent row)', `${path}.match`);
|
|
411
|
+
}
|
|
412
|
+
for (const [col, arg] of entries) {
|
|
413
|
+
if (VIEW_RESERVED_NAMES.has(col)) {
|
|
414
|
+
ctx.invalid(`view '${ctx.viewId}' match uses reserved column name '${col}'`, `${path}.match.${col}`);
|
|
415
|
+
}
|
|
416
|
+
checkMatchArg(arg, `${path}.match.${col}`, ctx.checkParamRef);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
function checkFilterArg(arg, path, checkParamRef) {
|
|
420
|
+
if ('param' in arg)
|
|
421
|
+
checkParamRef(arg.param, path);
|
|
422
|
+
}
|
|
423
|
+
function checkMatchArg(arg, path, checkParamRef) {
|
|
424
|
+
if ('param' in arg)
|
|
425
|
+
checkParamRef(arg.param, path);
|
|
426
|
+
}
|
|
427
|
+
/** Walk one object shape in a context; returns the count of page_items fields seen at THIS level. */
|
|
428
|
+
function walkShape(shape, context, path, ctx) {
|
|
429
|
+
let pageItems = 0;
|
|
430
|
+
const allowed = ALLOWED_KINDS[context];
|
|
431
|
+
for (const [name, field] of Object.entries(shape.fields)) {
|
|
432
|
+
const fpath = `${path}.fields.${name}`;
|
|
433
|
+
if (VIEW_RESERVED_NAMES.has(name)) {
|
|
434
|
+
ctx.invalid(`view '${ctx.viewId}' shape uses reserved field name '${name}'`, fpath);
|
|
435
|
+
}
|
|
436
|
+
if (!allowed.has(field.kind)) {
|
|
437
|
+
ctx.invalid(`view '${ctx.viewId}' field '${name}' of kind '${field.kind}' is not allowed in this ` +
|
|
438
|
+
`context ('${context}') — the mode-dependent field vocabulary is closed`, fpath);
|
|
439
|
+
continue; // the kind is out of context; deeper checks would mislead
|
|
440
|
+
}
|
|
441
|
+
switch (field.kind) {
|
|
442
|
+
case 'param':
|
|
443
|
+
ctx.checkParamRef(field.param, fpath);
|
|
444
|
+
break;
|
|
445
|
+
case 'items':
|
|
446
|
+
checkItemShape(field.shape, `${fpath}.shape`, ctx);
|
|
447
|
+
break;
|
|
448
|
+
case 'list':
|
|
449
|
+
checkSubRead(field.source, `${fpath}.source`, ctx);
|
|
450
|
+
walkShape(field.shape, 'row', `${fpath}.shape`, ctx);
|
|
451
|
+
break;
|
|
452
|
+
case 'lookup':
|
|
453
|
+
checkSubRead(field.source, `${fpath}.source`, ctx);
|
|
454
|
+
break;
|
|
455
|
+
case 'counts':
|
|
456
|
+
if (field.of) {
|
|
457
|
+
checkSubRead(field.of, `${fpath}.of`, ctx);
|
|
458
|
+
}
|
|
459
|
+
else if (context !== 'collect_top') {
|
|
460
|
+
ctx.invalid(`view '${ctx.viewId}' counts field '${name}' has no 'of' sub-read — self-counts (over ` +
|
|
461
|
+
"the view's own collected rows) are only legal at the top level of a collect view", fpath);
|
|
462
|
+
}
|
|
463
|
+
for (const bucket of field.buckets) {
|
|
464
|
+
if (VIEW_RESERVED_NAMES.has(bucket)) {
|
|
465
|
+
ctx.invalid(`view '${ctx.viewId}' counts bucket '${bucket}' is a reserved name`, fpath);
|
|
466
|
+
}
|
|
467
|
+
// FCY-3: the tally emits the grand total under the hardcoded `total` key — a bucket of the
|
|
468
|
+
// same name would silently double-count into it. Reserved at declaration time, fail-closed.
|
|
469
|
+
if (bucket === 'total') {
|
|
470
|
+
ctx.invalid(`view '${ctx.viewId}' counts bucket 'total' is reserved — the tally emits the grand ` +
|
|
471
|
+
"total under 'total', so a bucket of that name would double-count into it", fpath);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
break;
|
|
475
|
+
case 'group': {
|
|
476
|
+
const hasValue = field.value !== undefined;
|
|
477
|
+
const hasShape = field.shape !== undefined;
|
|
478
|
+
if (hasValue === hasShape) {
|
|
479
|
+
ctx.invalid(`view '${ctx.viewId}' group field '${name}' must declare exactly ONE of value|shape ` +
|
|
480
|
+
`(got ${hasValue ? 'both' : 'neither'})`, fpath);
|
|
481
|
+
}
|
|
482
|
+
if (field.mode === 'list' && field.absent !== undefined) {
|
|
483
|
+
ctx.invalid(`view '${ctx.viewId}' group field '${name}' declares 'absent' with mode 'list' — a list ` +
|
|
484
|
+
'bucket is empty when nothing matches, never absent', fpath);
|
|
485
|
+
}
|
|
486
|
+
if (field.shape)
|
|
487
|
+
walkShape(field.shape, 'row', `${fpath}.shape`, ctx);
|
|
488
|
+
break;
|
|
489
|
+
}
|
|
490
|
+
case 'page_items':
|
|
491
|
+
pageItems += 1;
|
|
492
|
+
walkShape(field.shape, 'row', `${fpath}.shape`, ctx);
|
|
493
|
+
break;
|
|
494
|
+
default:
|
|
495
|
+
break; // column/json/const/page_total/page_next_offset: no deeper structure to walk
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
return pageItems;
|
|
499
|
+
}
|
|
500
|
+
function checkItemShape(shape, path, ctx) {
|
|
501
|
+
for (const name of Object.keys(shape.fields)) {
|
|
502
|
+
if (VIEW_RESERVED_NAMES.has(name)) {
|
|
503
|
+
ctx.invalid(`view '${ctx.viewId}' item shape uses reserved field name '${name}'`, `${path}.fields.${name}`);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
/** The leaf type(s) a shape field PRODUCES (undefined = not a leaf / not statically known). */
|
|
508
|
+
function fieldProducedTypes(field) {
|
|
509
|
+
switch (field.kind) {
|
|
510
|
+
case 'column':
|
|
511
|
+
case 'json':
|
|
512
|
+
return leafTypes(field.type, field.default);
|
|
513
|
+
case 'lookup':
|
|
514
|
+
return leafTypes(field.type, field.default);
|
|
515
|
+
case 'param':
|
|
516
|
+
return ['string'];
|
|
517
|
+
case 'const':
|
|
518
|
+
return constTypeNames(field.value);
|
|
519
|
+
case 'items':
|
|
520
|
+
case 'list':
|
|
521
|
+
return ['array'];
|
|
522
|
+
case 'counts':
|
|
523
|
+
return ['object'];
|
|
524
|
+
case 'group':
|
|
525
|
+
if (field.mode === 'list')
|
|
526
|
+
return ['array'];
|
|
527
|
+
if (field.value) {
|
|
528
|
+
const base = leafTypes(field.value.type, field.value.default);
|
|
529
|
+
return withAbsent(base, field.absent);
|
|
530
|
+
}
|
|
531
|
+
return withAbsent(['object'], field.absent);
|
|
532
|
+
case 'page_items':
|
|
533
|
+
return ['array'];
|
|
534
|
+
case 'page_total':
|
|
535
|
+
return ['integer', 'number'];
|
|
536
|
+
case 'page_next_offset':
|
|
537
|
+
return ['integer', 'number', 'null'];
|
|
538
|
+
default:
|
|
539
|
+
return undefined;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
/** A typed leaf produces its declared type OR its default's type (default null when omitted). */
|
|
543
|
+
function leafTypes(type, deflt) {
|
|
544
|
+
const defaultTypes = deflt === undefined ? ['null'] : constTypeNames(deflt);
|
|
545
|
+
const out = new Set([type, ...defaultTypes]);
|
|
546
|
+
return [...out];
|
|
547
|
+
}
|
|
548
|
+
function withAbsent(base, absent) {
|
|
549
|
+
const absentTypes = absent === undefined ? ['null'] : constTypeNames(absent);
|
|
550
|
+
return [...new Set([...base, ...absentTypes])];
|
|
551
|
+
}
|
|
552
|
+
/**
|
|
553
|
+
* Conformance: every PRODUCIBLE type of the field must be admitted by the property's declared types.
|
|
554
|
+
* For an integer/number pair produced by an int literal, ONE admitted member suffices (an integer
|
|
555
|
+
* literal conforms to a `number` property).
|
|
556
|
+
*/
|
|
557
|
+
function checkProducedTypes(produced, admitted, fieldName, path, ctx) {
|
|
558
|
+
if (!admitted)
|
|
559
|
+
return;
|
|
560
|
+
// Partition: the null member is checked separately; an integer/number literal-pair counts as one.
|
|
561
|
+
const nonNull = produced.filter((t) => t !== 'null');
|
|
562
|
+
const hasNull = produced.includes('null');
|
|
563
|
+
if (hasNull && !admitted.has('null')) {
|
|
564
|
+
ctx.invalid(`view '${ctx.viewId}' field '${fieldName}' can produce null but the response contract property ` +
|
|
565
|
+
'does not admit \'null\' (declare nullable/[…, "null"] or give the field a non-null default)', path);
|
|
566
|
+
}
|
|
567
|
+
// An int-literal default reports ['integer','number'] — one admitted member suffices for THAT value.
|
|
568
|
+
if (nonNull.length > 0) {
|
|
569
|
+
const intPair = nonNull.length === 2 && nonNull.includes('integer') && nonNull.includes('number');
|
|
570
|
+
const ok = intPair
|
|
571
|
+
? typesAdmit(admitted, 'integer') || typesAdmit(admitted, 'number')
|
|
572
|
+
: nonNull.every((t) => typesAdmit(admitted, t));
|
|
573
|
+
if (!ok) {
|
|
574
|
+
ctx.invalid(`view '${ctx.viewId}' field '${fieldName}' produces type(s) [${nonNull.join(', ')}] not ` +
|
|
575
|
+
`admitted by the response contract property (admitted: [${[...admitted].join(', ')}])`, path);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
/** Conform one object shape against one contract node (recursing where both sides declare). */
|
|
580
|
+
function conformShape(shape, node, path, ctx) {
|
|
581
|
+
const { properties, required, open } = nodeProperties(node, ctx.contracts, new Set());
|
|
582
|
+
const additional = node.additional_properties === true;
|
|
583
|
+
for (const [name, field] of Object.entries(shape.fields)) {
|
|
584
|
+
const fpath = `${path}.fields.${name}`;
|
|
585
|
+
const prop = properties?.[name];
|
|
586
|
+
if (!prop && !open && !additional) {
|
|
587
|
+
ctx.invalid(`view '${ctx.viewId}' projects field '${name}' which the response contract does not declare ` +
|
|
588
|
+
'(the DTO contract is the closed client-facing shape — declare the property or drop the field)', fpath);
|
|
589
|
+
continue;
|
|
590
|
+
}
|
|
591
|
+
const produced = fieldProducedTypes(field);
|
|
592
|
+
if (produced && prop) {
|
|
593
|
+
checkProducedTypes(produced, admittedTypes(prop, ctx.contracts, new Set()), name, fpath, ctx);
|
|
594
|
+
}
|
|
595
|
+
// Recurse into array-producing composites where the contract declares items with properties.
|
|
596
|
+
if (prop) {
|
|
597
|
+
const propItems = prop.items !== null && typeof prop.items === 'object' && !Array.isArray(prop.items)
|
|
598
|
+
? prop.items
|
|
599
|
+
: undefined;
|
|
600
|
+
if (field.kind === 'list' && propItems) {
|
|
601
|
+
conformShape(field.shape, propItems, `${fpath}.shape`, ctx);
|
|
602
|
+
}
|
|
603
|
+
if (field.kind === 'page_items' && propItems) {
|
|
604
|
+
conformShape(field.shape, propItems, `${fpath}.shape`, ctx);
|
|
605
|
+
}
|
|
606
|
+
if (field.kind === 'group' && field.mode === 'list' && field.shape && propItems) {
|
|
607
|
+
conformShape(field.shape, propItems, `${fpath}.shape`, ctx);
|
|
608
|
+
}
|
|
609
|
+
if (field.kind === 'group' && field.mode !== 'list' && field.shape) {
|
|
610
|
+
conformShape(field.shape, prop, `${fpath}.shape`, ctx);
|
|
611
|
+
}
|
|
612
|
+
if (field.kind === 'items' && propItems) {
|
|
613
|
+
conformItemShape(field.shape, propItems, `${fpath}.shape`, ctx);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
for (const r of required) {
|
|
618
|
+
if (!(r in shape.fields)) {
|
|
619
|
+
ctx.invalid(`view '${ctx.viewId}' response contract requires property '${r}' but the shape does not ` +
|
|
620
|
+
'project it (every required DTO property must be produced)', path);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
/** Conform an ITEM shape (item/const leaves) against a contract items node. */
|
|
625
|
+
function conformItemShape(shape, node, path, ctx) {
|
|
626
|
+
const { properties, required, open } = nodeProperties(node, ctx.contracts, new Set());
|
|
627
|
+
const additional = node.additional_properties === true;
|
|
628
|
+
for (const [name, field] of Object.entries(shape.fields)) {
|
|
629
|
+
const fpath = `${path}.fields.${name}`;
|
|
630
|
+
const prop = properties?.[name];
|
|
631
|
+
if (!prop && !open && !additional) {
|
|
632
|
+
ctx.invalid(`view '${ctx.viewId}' item shape projects field '${name}' which the contract's items node does not declare`, fpath);
|
|
633
|
+
continue;
|
|
634
|
+
}
|
|
635
|
+
const produced = field.kind === 'item' ? leafTypes(field.type, field.default) : constTypeNames(field.value);
|
|
636
|
+
if (prop) {
|
|
637
|
+
checkProducedTypes(produced, admittedTypes(prop, ctx.contracts, new Set()), name, fpath, ctx);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
for (const r of required) {
|
|
641
|
+
if (!(r in shape.fields)) {
|
|
642
|
+
ctx.invalid(`view '${ctx.viewId}' contract items node requires property '${r}' but the item shape does not project it`, path);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
/** Conform the absent shape: required coverage + const/param types vs contract properties. */
|
|
647
|
+
function conformAbsent(absent, node, path, ctx) {
|
|
648
|
+
const { properties, required, open } = nodeProperties(node, ctx.contracts, new Set());
|
|
649
|
+
const additional = node.additional_properties === true;
|
|
650
|
+
for (const [name, field] of Object.entries(absent.fields)) {
|
|
651
|
+
const fpath = `${path}.fields.${name}`;
|
|
652
|
+
const prop = properties?.[name];
|
|
653
|
+
if (!prop && !open && !additional) {
|
|
654
|
+
ctx.invalid(`view '${ctx.viewId}' absent shape projects field '${name}' which the response contract does not declare`, fpath);
|
|
655
|
+
continue;
|
|
656
|
+
}
|
|
657
|
+
const produced = field.kind === 'param' ? ['string'] : constTypeNames(field.value);
|
|
658
|
+
if (prop) {
|
|
659
|
+
checkProducedTypes([...produced], admittedTypes(prop, ctx.contracts, new Set()), name, fpath, ctx);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
for (const r of required) {
|
|
663
|
+
if (!(r in absent.fields)) {
|
|
664
|
+
ctx.invalid(`view '${ctx.viewId}' response contract requires property '${r}' but the absent shape does ` +
|
|
665
|
+
'not produce it (the absent-row 200 must satisfy the same contract)', path);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
//# sourceMappingURL=product-views-lint.js.map
|