octwin-cli 0.3.0 → 0.6.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/CHANGELOG.md +67 -1
- package/README.md +214 -210
- package/dist/index.js +731 -255
- package/dist/lib/args-check.js +11 -10
- package/dist/lib/builtin-check.js +130 -0
- package/dist/lib/declaration-check.js +192 -0
- package/dist/lib/entity-check.js +150 -0
- package/dist/lib/kb-index.js +214 -0
- package/dist/lib/kb-path.js +17 -0
- package/dist/lib/kb-symbols.js +271 -0
- package/dist/lib/render-check.js +13 -12
- package/dist/lib/template-check.js +107 -0
- package/dist/lib/validate.js +33 -4
- package/dist/lib/yaml-pos.js +29 -0
- package/package.json +1 -1
package/dist/lib/args-check.js
CHANGED
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
*/
|
|
30
30
|
import { readdirSync, readFileSync } from 'node:fs';
|
|
31
31
|
import { join } from 'node:path';
|
|
32
|
-
import { lookupKbSubdir } from './kb-path.js';
|
|
32
|
+
import { lookupKbSubdir, isEntryFile } from './kb-path.js';
|
|
33
33
|
/**
|
|
34
34
|
* Load `primitive -> ArgSpec` from the pulled KB.
|
|
35
35
|
*
|
|
@@ -37,14 +37,14 @@ import { lookupKbSubdir } from './kb-path.js';
|
|
|
37
37
|
* distinguish "checked and clean" from "could not check". See `kb-path.ts`.
|
|
38
38
|
*/
|
|
39
39
|
export function loadPrimitiveArgSpecs(packDir) {
|
|
40
|
-
const lookup = lookupKbSubdir(packDir, 'primitives', dir => readdirSync(dir).some(
|
|
40
|
+
const lookup = lookupKbSubdir(packDir, 'primitives', dir => readdirSync(dir).some(isEntryFile));
|
|
41
41
|
if (lookup.state !== 'ok')
|
|
42
42
|
return { lookup, specs: null };
|
|
43
43
|
const dir = lookup.dir;
|
|
44
44
|
const out = new Map();
|
|
45
45
|
try {
|
|
46
46
|
for (const file of readdirSync(dir)) {
|
|
47
|
-
if (!file
|
|
47
|
+
if (!isEntryFile(file))
|
|
48
48
|
continue;
|
|
49
49
|
const entry = JSON.parse(readFileSync(join(dir, file), 'utf8'));
|
|
50
50
|
const schema = entry.inputSchema;
|
|
@@ -77,9 +77,9 @@ export function loadPrimitiveArgSpecs(packDir) {
|
|
|
77
77
|
*/
|
|
78
78
|
export function findArgViolations(doc, file, specs) {
|
|
79
79
|
const findings = [];
|
|
80
|
-
const walk = (node) => {
|
|
80
|
+
const walk = (node, path) => {
|
|
81
81
|
if (Array.isArray(node)) {
|
|
82
|
-
node.forEach(walk);
|
|
82
|
+
node.forEach((v, i) => walk(v, [...path, i]));
|
|
83
83
|
return;
|
|
84
84
|
}
|
|
85
85
|
if (!node || typeof node !== 'object')
|
|
@@ -97,14 +97,14 @@ export function findArgViolations(doc, file, specs) {
|
|
|
97
97
|
const missing = spec.required.filter(k => !(k in args));
|
|
98
98
|
const unscoped = spec.requiresOneOf.filter(g => !g.some(k => k in args));
|
|
99
99
|
if (unknown.length || missing.length || unscoped.length) {
|
|
100
|
-
findings.push({ file, primitive: obj.do, unknown, missing, unscoped, declared: [...spec.keys].sort() });
|
|
100
|
+
findings.push({ file, primitive: obj.do, path: [...path], unknown, missing, unscoped, declared: [...spec.keys].sort() });
|
|
101
101
|
}
|
|
102
102
|
}
|
|
103
103
|
}
|
|
104
|
-
for (const v of Object.
|
|
105
|
-
walk(v);
|
|
104
|
+
for (const [k, v] of Object.entries(obj))
|
|
105
|
+
walk(v, [...path, k]);
|
|
106
106
|
};
|
|
107
|
-
walk(doc);
|
|
107
|
+
walk(doc, []);
|
|
108
108
|
return findings;
|
|
109
109
|
}
|
|
110
110
|
/** One-line human message per finding. */
|
|
@@ -123,5 +123,6 @@ export function describeArgFinding(f) {
|
|
|
123
123
|
// "so what?", and for the scope groups the answer is a data leak.
|
|
124
124
|
parts.push(`no ${g.map(k => `'${k}'`).join(' or ')} — the read is project-wide and returns other contacts' records`);
|
|
125
125
|
}
|
|
126
|
-
|
|
126
|
+
const at = f.line != null ? `:${f.line}` : (f.path.length ? ` (${f.path.join('.')})` : '');
|
|
127
|
+
return `${f.file}${at}: \`${f.primitive}\` has ${parts.join('; ')}. It takes: ${f.declared.join(', ') || 'no arguments'}`;
|
|
127
128
|
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Offline expression-builtin check, driven by the pulled platform KB.
|
|
3
|
+
*
|
|
4
|
+
* WHY it matters: the expression language has a CLOSED function set, generated
|
|
5
|
+
* from the runtime into `builtins/*.json`. A call to something outside it —
|
|
6
|
+
* `$sum_by(…)`, `$format_date(…)`, `$first(…)` — is not a syntax error and not a
|
|
7
|
+
* missing import; it is a name the evaluator cannot resolve, and the failure
|
|
8
|
+
* surfaces mid-conversation to a customer rather than at authoring time. An
|
|
9
|
+
* invented builtin also reads EXACTLY like a real one, which is why it survives
|
|
10
|
+
* review: the repo's own KB gate exists for the same reason on the docs side
|
|
11
|
+
* (`scripts/check-kb-examples.mjs`), and it found eight fictional functions in
|
|
12
|
+
* prose that every other gate was blind to.
|
|
13
|
+
*
|
|
14
|
+
* WHAT IT CHECKS: every `$name(` occurrence in every string in the pack's YAML.
|
|
15
|
+
* Not just `assign:` — expressions appear in `args:`, in render fields, inside
|
|
16
|
+
* `{$…}` interpolation, in `when:`, in `item_template`, and in locale values.
|
|
17
|
+
*
|
|
18
|
+
* WHAT IT DOES NOT CHECK — stated because a check that hides its edges gets
|
|
19
|
+
* trusted past them:
|
|
20
|
+
* • ARITY. The catalog publishes a TypeScript signature string, not a machine
|
|
21
|
+
* schema; parsing "(arr: unknown, mapping: unknown): unknown[]" into an
|
|
22
|
+
* arity is guesswork the moment a signature carries a default or a rest
|
|
23
|
+
* parameter, and a wrong arity error is worse than no arity error.
|
|
24
|
+
* • Whether the argument VALUES make sense — that is the platform's job.
|
|
25
|
+
* • Anything inside a `use:` template body (the platform expands those).
|
|
26
|
+
*
|
|
27
|
+
* NO WHITESPACE before the paren, deliberately: `$name(` is how the grammar is
|
|
28
|
+
* written, and allowing `$name (` starts matching customer-facing copy.
|
|
29
|
+
*
|
|
30
|
+
* Degrades to a no-op when the KB has not been pulled — see `kb-path.ts` and the
|
|
31
|
+
* sibling checks; a skip is reported, never silently passed.
|
|
32
|
+
*/
|
|
33
|
+
import { readdirSync, readFileSync } from 'node:fs';
|
|
34
|
+
import { join } from 'node:path';
|
|
35
|
+
import { lookupKbSubdir, isEntryFile } from './kb-path.js';
|
|
36
|
+
/**
|
|
37
|
+
* Names that look like builtins and are not — they belong to a DIFFERENT engine.
|
|
38
|
+
*
|
|
39
|
+
* `$opt(` is the `taps.yaml` agent-token vocabulary, substituted at tap-match
|
|
40
|
+
* time, never evaluated by the expression runtime. The repo's KB gate carries
|
|
41
|
+
* the same exclusion for the same reason.
|
|
42
|
+
*/
|
|
43
|
+
const OTHER_ENGINE_VOCAB = new Set(['opt']);
|
|
44
|
+
/** `$name(` — the only shape the expression grammar accepts for a call. */
|
|
45
|
+
const CALL = /\$([a-z_][a-z0-9_]*)\(/g;
|
|
46
|
+
/** Load the closed builtin set from the pulled KB. */
|
|
47
|
+
export function loadBuiltinNames(packDir) {
|
|
48
|
+
const lookup = lookupKbSubdir(packDir, 'builtins', dir => readdirSync(dir).some(isEntryFile));
|
|
49
|
+
if (lookup.state !== 'ok')
|
|
50
|
+
return { lookup, names: null };
|
|
51
|
+
const dir = lookup.dir;
|
|
52
|
+
const names = new Set();
|
|
53
|
+
try {
|
|
54
|
+
for (const file of readdirSync(dir)) {
|
|
55
|
+
if (!isEntryFile(file))
|
|
56
|
+
continue;
|
|
57
|
+
const entry = JSON.parse(readFileSync(join(dir, file), 'utf8'));
|
|
58
|
+
if (entry.name)
|
|
59
|
+
names.add(entry.name);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
return { lookup: { state: 'malformed', dir, reason: String(err?.message ?? err) }, names: null };
|
|
64
|
+
}
|
|
65
|
+
if (!names.size)
|
|
66
|
+
return { lookup: { state: 'malformed', dir, reason: 'no builtin entries parsed' }, names: null };
|
|
67
|
+
return { lookup, names };
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Builtins whose name is close to the one written — edit distance is overkill
|
|
71
|
+
* here; a shared 4-character prefix or a containment catches the real typos.
|
|
72
|
+
*
|
|
73
|
+
* The 4-character floor on containment is load-bearing, not tidiness. Without
|
|
74
|
+
* it, the short names in the set (`t`, `in`, `get`, `min`, `sum`, `map`) are a
|
|
75
|
+
* substring of almost anything: `$first_of()` suggested `$t()`, because
|
|
76
|
+
* `'first_of'.includes('t')`. A suggestion list with an absurd entry in it is
|
|
77
|
+
* read as noise, and then the good entry beside it is ignored too.
|
|
78
|
+
*/
|
|
79
|
+
function suggest(name, names) {
|
|
80
|
+
const near = (a, b) => a.length >= 4 && b.includes(a);
|
|
81
|
+
const hits = [];
|
|
82
|
+
for (const n of names) {
|
|
83
|
+
if (n === name)
|
|
84
|
+
continue;
|
|
85
|
+
if (n.slice(0, 4) === name.slice(0, 4) || near(n, name) || near(name, n))
|
|
86
|
+
hits.push(n);
|
|
87
|
+
}
|
|
88
|
+
return hits.sort().slice(0, 4);
|
|
89
|
+
}
|
|
90
|
+
/** Walk parsed YAML and report every `$name(` naming a function that does not exist. */
|
|
91
|
+
export function findBuiltinViolations(doc, file, names) {
|
|
92
|
+
const findings = [];
|
|
93
|
+
const seen = new Set(); // one finding per name per file — a helper used ten times is one mistake
|
|
94
|
+
const scan = (text, path) => {
|
|
95
|
+
for (const m of text.matchAll(CALL)) {
|
|
96
|
+
const name = m[1];
|
|
97
|
+
if (names.has(name) || OTHER_ENGINE_VOCAB.has(name) || seen.has(name))
|
|
98
|
+
continue;
|
|
99
|
+
seen.add(name);
|
|
100
|
+
findings.push({ file, path: [...path], name, didYouMean: suggest(name, names) });
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
const walk = (node, path) => {
|
|
104
|
+
if (typeof node === 'string') {
|
|
105
|
+
scan(node, path);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (Array.isArray(node)) {
|
|
109
|
+
node.forEach((v, i) => walk(v, [...path, i]));
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (!node || typeof node !== 'object')
|
|
113
|
+
return;
|
|
114
|
+
// KEYS can carry expressions too — `outputs:` port names cannot, but a
|
|
115
|
+
// `{$…}`-interpolated map key can, and walking values only would miss it.
|
|
116
|
+
for (const [k, v] of Object.entries(node)) {
|
|
117
|
+
scan(k, [...path, k]);
|
|
118
|
+
walk(v, [...path, k]);
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
walk(doc, []);
|
|
122
|
+
return findings;
|
|
123
|
+
}
|
|
124
|
+
/** One-line human message per finding. */
|
|
125
|
+
export function describeBuiltinFinding(f) {
|
|
126
|
+
const at = f.line != null ? `:${f.line}` : (f.path.length ? ` (${f.path.join('.')})` : '');
|
|
127
|
+
return `${f.file}${at}: \`$${f.name}()\` is not an expression builtin`
|
|
128
|
+
+ (f.didYouMean.length ? ` — did you mean ${f.didYouMean.map(n => `$${n}()`).join(', ')}?` : '')
|
|
129
|
+
+ ' The set is closed and generated from the runtime; grep SYMBOLS.md for the name you want.';
|
|
130
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Offline declaration-file check — validates `manifest.yaml`, `xrm.yaml`,
|
|
3
|
+
* `scheduling.yaml` and their siblings against the JSON Schemas the platform
|
|
4
|
+
* publishes in `declarations/*.json`.
|
|
5
|
+
*
|
|
6
|
+
* WHY it matters: the manifest is `.strict()`, so an unknown key is a DEPLOY
|
|
7
|
+
* rejection, and every sibling declaration is parsed the same way at load. Today
|
|
8
|
+
* an author learns about a misspelt key from a round-trip (`validate --remote`),
|
|
9
|
+
* or from a boot failure. The schema that decides it is already on disk after a
|
|
10
|
+
* pull; there is no reason to ask the platform.
|
|
11
|
+
*
|
|
12
|
+
* ## A DELIBERATELY NARROW subset of JSON Schema
|
|
13
|
+
*
|
|
14
|
+
* This is the check where a false positive costs the most: it fires on the
|
|
15
|
+
* author's own declaration files, and an author who is told a valid `xrm.yaml`
|
|
16
|
+
* is wrong will stop trusting `validate` entirely — which also silences the two
|
|
17
|
+
* checks that were catching real bugs. So the rule is: **report only what is
|
|
18
|
+
* unambiguous, and walk away from anything else.**
|
|
19
|
+
*
|
|
20
|
+
* It reports exactly four things:
|
|
21
|
+
* 1. a key not in `properties` where `additionalProperties: false`
|
|
22
|
+
* 2. a missing `required` key
|
|
23
|
+
* 3. a scalar whose `type` is plainly wrong
|
|
24
|
+
* 4. a value outside a closed `enum`
|
|
25
|
+
*
|
|
26
|
+
* It STOPS DESCENDING (reports nothing at all for that subtree) at any node
|
|
27
|
+
* carrying `anyOf` / `oneOf` / `allOf` / `not`, at an unresolvable `$ref`, and at
|
|
28
|
+
* an empty schema `{}` — which is what `z.toJSONSchema(…, { unrepresentable:
|
|
29
|
+
* 'any' })` emits for everything Zod cannot express, including every custom
|
|
30
|
+
* refinement and transform in these schemas. A union is not "invalid", it is
|
|
31
|
+
* "more than this validator can read", and those two must never be confused.
|
|
32
|
+
*
|
|
33
|
+
* No dependency: adding `ajv` to ship four rules would put a full JSON Schema
|
|
34
|
+
* engine (and its own draft quirks) into a CLI that authors install globally.
|
|
35
|
+
*/
|
|
36
|
+
import { readdirSync, readFileSync } from 'node:fs';
|
|
37
|
+
import { join } from 'node:path';
|
|
38
|
+
import { lookupKbSubdir, isEntryFile } from './kb-path.js';
|
|
39
|
+
const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
40
|
+
/** Load `declarations/*.json` from the pulled KB, keyed by the FILE an author writes. */
|
|
41
|
+
export function loadDeclarationSpecs(packDir) {
|
|
42
|
+
const lookup = lookupKbSubdir(packDir, 'declarations', dir => readdirSync(dir).some(isEntryFile));
|
|
43
|
+
if (lookup.state !== 'ok')
|
|
44
|
+
return { lookup, specs: null };
|
|
45
|
+
const dir = lookup.dir;
|
|
46
|
+
const out = new Map();
|
|
47
|
+
try {
|
|
48
|
+
for (const f of readdirSync(dir)) {
|
|
49
|
+
if (!isEntryFile(f))
|
|
50
|
+
continue;
|
|
51
|
+
const entry = JSON.parse(readFileSync(join(dir, f), 'utf8'));
|
|
52
|
+
if (entry.file && isObj(entry.schema))
|
|
53
|
+
out.set(entry.file, { file: entry.file, schema: entry.schema });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
return { lookup: { state: 'malformed', dir, reason: String(err?.message ?? err) }, specs: null };
|
|
58
|
+
}
|
|
59
|
+
if (!out.size)
|
|
60
|
+
return { lookup: { state: 'malformed', dir, reason: 'no declaration entries parsed' }, specs: null };
|
|
61
|
+
return { lookup, specs: out };
|
|
62
|
+
}
|
|
63
|
+
/** The JSON-Schema type name for a YAML-parsed value, or null for null/undefined. */
|
|
64
|
+
function jsonTypeOf(v) {
|
|
65
|
+
if (v === null || v === undefined)
|
|
66
|
+
return null;
|
|
67
|
+
if (Array.isArray(v))
|
|
68
|
+
return 'array';
|
|
69
|
+
if (typeof v === 'number')
|
|
70
|
+
return Number.isInteger(v) ? 'integer' : 'number';
|
|
71
|
+
if (typeof v === 'boolean')
|
|
72
|
+
return 'boolean';
|
|
73
|
+
if (typeof v === 'string')
|
|
74
|
+
return 'string';
|
|
75
|
+
if (v instanceof Date)
|
|
76
|
+
return 'string'; // YAML parses bare dates; the schema calls them strings
|
|
77
|
+
if (typeof v === 'object')
|
|
78
|
+
return 'object';
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
/** True when the declared type accepts the observed one. */
|
|
82
|
+
function typeAccepts(declared, actual) {
|
|
83
|
+
if (declared === actual)
|
|
84
|
+
return true;
|
|
85
|
+
if (declared === 'number' && actual === 'integer')
|
|
86
|
+
return true; // every integer is a number
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Validate `value` against `schema`, appending findings.
|
|
91
|
+
*
|
|
92
|
+
* Returns silently the moment the schema uses anything outside the subset — see
|
|
93
|
+
* the header. `depth` is a hard stop against a `$ref` cycle the deref cannot see.
|
|
94
|
+
*/
|
|
95
|
+
function walk(value, schema, path, defs, out, file, depth) {
|
|
96
|
+
if (depth > 24)
|
|
97
|
+
return;
|
|
98
|
+
// `$ref` — follow one hop into `$defs`; anything else is out of subset.
|
|
99
|
+
const ref = schema.$ref;
|
|
100
|
+
if (typeof ref === 'string') {
|
|
101
|
+
const name = ref.startsWith('#/$defs/') ? ref.slice('#/$defs/'.length) : null;
|
|
102
|
+
const target = name ? defs[name] : undefined;
|
|
103
|
+
if (!isObj(target))
|
|
104
|
+
return;
|
|
105
|
+
return walk(value, target, path, defs, out, file, depth + 1);
|
|
106
|
+
}
|
|
107
|
+
// Combinators and the empty schema: more than this validator can read.
|
|
108
|
+
if (schema.anyOf || schema.oneOf || schema.allOf || schema.not)
|
|
109
|
+
return;
|
|
110
|
+
if (Object.keys(schema).length === 0)
|
|
111
|
+
return;
|
|
112
|
+
if (value === null || value === undefined)
|
|
113
|
+
return; // an explicit null is the platform's to judge
|
|
114
|
+
const actual = jsonTypeOf(value);
|
|
115
|
+
if (!actual)
|
|
116
|
+
return;
|
|
117
|
+
// 4. Closed enum.
|
|
118
|
+
if (Array.isArray(schema.enum)) {
|
|
119
|
+
if (!schema.enum.includes(value)) {
|
|
120
|
+
out.push({
|
|
121
|
+
file, path,
|
|
122
|
+
message: `${JSON.stringify(value)} is not one of ${schema.enum.map(e => JSON.stringify(e)).join(', ')}`,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
// 3. Scalar type.
|
|
128
|
+
const declared = schema.type;
|
|
129
|
+
if (typeof declared === 'string' && !typeAccepts(declared, actual)) {
|
|
130
|
+
out.push({ file, path, message: `expected ${declared}, got ${actual}` });
|
|
131
|
+
return; // shape is wrong; descending would cascade noise
|
|
132
|
+
}
|
|
133
|
+
if (actual === 'array') {
|
|
134
|
+
const items = schema.items;
|
|
135
|
+
if (isObj(items)) {
|
|
136
|
+
value.forEach((v, i) => walk(v, items, `${path}[${i}]`, defs, out, file, depth + 1));
|
|
137
|
+
}
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (actual !== 'object')
|
|
141
|
+
return;
|
|
142
|
+
const obj = value;
|
|
143
|
+
const props = isObj(schema.properties) ? schema.properties : undefined;
|
|
144
|
+
const addl = schema.additionalProperties;
|
|
145
|
+
// A map schema (`additionalProperties: <schema>`, no `properties`) — every
|
|
146
|
+
// value shares one shape. This is how `entities:` and `agents:` are declared.
|
|
147
|
+
if (!props && isObj(addl)) {
|
|
148
|
+
for (const [k, v] of Object.entries(obj))
|
|
149
|
+
walk(v, addl, path ? `${path}.${k}` : k, defs, out, file, depth + 1);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if (!props)
|
|
153
|
+
return;
|
|
154
|
+
// 2. Required.
|
|
155
|
+
if (Array.isArray(schema.required)) {
|
|
156
|
+
for (const key of schema.required) {
|
|
157
|
+
if (typeof key === 'string' && !(key in obj)) {
|
|
158
|
+
out.push({ file, path: path ? `${path}.${key}` : key, message: 'required key is missing' });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
// 1. Unknown keys — ONLY when the schema closes itself.
|
|
163
|
+
if (addl === false) {
|
|
164
|
+
const known = Object.keys(props);
|
|
165
|
+
for (const k of Object.keys(obj)) {
|
|
166
|
+
if (!known.includes(k)) {
|
|
167
|
+
out.push({
|
|
168
|
+
file, path: path ? `${path}.${k}` : k,
|
|
169
|
+
message: `unknown key — this file is strict, so it is rejected on deploy. Declared keys here: ${known.join(', ')}`,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
175
|
+
const sub = props[k];
|
|
176
|
+
if (isObj(sub))
|
|
177
|
+
walk(v, sub, path ? `${path}.${k}` : k, defs, out, file, depth + 1);
|
|
178
|
+
else if (isObj(addl))
|
|
179
|
+
walk(v, addl, path ? `${path}.${k}` : k, defs, out, file, depth + 1);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
/** Validate one parsed declaration document against its published schema. */
|
|
183
|
+
export function findDeclarationViolations(doc, spec) {
|
|
184
|
+
const out = [];
|
|
185
|
+
const defs = isObj(spec.schema.$defs) ? spec.schema.$defs : {};
|
|
186
|
+
walk(doc, spec.schema, '', defs, out, spec.file, 0);
|
|
187
|
+
return out;
|
|
188
|
+
}
|
|
189
|
+
/** One-line human message per finding. */
|
|
190
|
+
export function describeDeclarationFinding(f) {
|
|
191
|
+
return `${f.file}${f.path ? ` → ${f.path}` : ''}: ${f.message}`;
|
|
192
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Offline reserved-entity check for `xrm.yaml`, driven by the pulled platform KB.
|
|
3
|
+
*
|
|
4
|
+
* WHY it matters: the seven system entities (`order`, `cart`, `product`, `case`,
|
|
5
|
+
* `booking`, `survey_response`, `campaign`) are RESERVED keys. Declaring one as
|
|
6
|
+
* your own entity is a BOOT error — not a validate error and not a deploy error,
|
|
7
|
+
* a boot error — so the pack deploys clean and then fails to load on the first
|
|
8
|
+
* inbound message, which reads to an author as "the platform is broken". The
|
|
9
|
+
* same is true of the `journey_` prefix and of `extends: system` on a key that
|
|
10
|
+
* is not a system entity.
|
|
11
|
+
*
|
|
12
|
+
* Every rule here is replayed from `system-entities/`, which publishes each
|
|
13
|
+
* template's shipped field set per entry AND the catalog-level rules in
|
|
14
|
+
* `_catalog.json`. Nothing is hand-listed.
|
|
15
|
+
*
|
|
16
|
+
* The two rules this file used to name as gaps — `contact` as a reserved key, and
|
|
17
|
+
* the `EXTENSION_FORBIDDEN` set — are covered as of 2026-08-09: the platform now
|
|
18
|
+
* exports those constants and the catalog publishes them, so the check reads the
|
|
19
|
+
* same values the boot-time validator does instead of guessing at them.
|
|
20
|
+
*
|
|
21
|
+
* WHAT IT STILL DOES NOT CHECK: anything requiring the merged entity shape (an
|
|
22
|
+
* extension's interaction with a pipeline it inherits, say). `--remote` owns that.
|
|
23
|
+
*/
|
|
24
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
25
|
+
import { join } from 'node:path';
|
|
26
|
+
import { lookupKbSubdir, isEntryFile } from './kb-path.js';
|
|
27
|
+
/**
|
|
28
|
+
* Fallback for the journey prefix when the pulled KB predates `_catalog.json`.
|
|
29
|
+
*
|
|
30
|
+
* Unlike the other two rules, this one keeps a hardcoded default: it was already
|
|
31
|
+
* enforced here before the catalog published anything, so dropping it for an old
|
|
32
|
+
* KB would REMOVE a working check. The catalog's value wins whenever it is there.
|
|
33
|
+
*/
|
|
34
|
+
const JOURNEY_PREFIX = 'journey_';
|
|
35
|
+
/** Load the system-entity templates + the catalog-level rules from the pulled KB. */
|
|
36
|
+
export function loadSystemEntities(packDir) {
|
|
37
|
+
const lookup = lookupKbSubdir(packDir, 'system-entities', dir => readdirSync(dir).some(isEntryFile));
|
|
38
|
+
if (lookup.state !== 'ok')
|
|
39
|
+
return { lookup, entities: null, rules: null };
|
|
40
|
+
const dir = lookup.dir;
|
|
41
|
+
const out = new Map();
|
|
42
|
+
let rules = null;
|
|
43
|
+
try {
|
|
44
|
+
for (const file of readdirSync(dir)) {
|
|
45
|
+
if (!isEntryFile(file))
|
|
46
|
+
continue;
|
|
47
|
+
const key = file.slice(0, -'.json'.length);
|
|
48
|
+
const entry = JSON.parse(readFileSync(join(dir, file), 'utf8'));
|
|
49
|
+
out.set(key, { key, fields: Object.keys(entry.fields ?? {}) });
|
|
50
|
+
}
|
|
51
|
+
// The catalog-level block, when the platform published one. Absent on a KB
|
|
52
|
+
// pulled before 2026-08-09 — the two rules it feeds then simply do not run.
|
|
53
|
+
const catalogFile = join(dir, '_catalog.json');
|
|
54
|
+
if (existsSync(catalogFile)) {
|
|
55
|
+
const raw = JSON.parse(readFileSync(catalogFile, 'utf8'));
|
|
56
|
+
const r = raw.rules;
|
|
57
|
+
if (r) {
|
|
58
|
+
rules = {
|
|
59
|
+
reservedKeys: Array.isArray(r.reserved_keys) ? r.reserved_keys.filter((x) => typeof x === 'string') : [],
|
|
60
|
+
reservedPrefix: typeof r.reserved_prefix === 'string' ? r.reserved_prefix : null,
|
|
61
|
+
extensionForbidden: Array.isArray(r.extension_forbidden) ? r.extension_forbidden.filter((x) => typeof x === 'string') : [],
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
return { lookup: { state: 'malformed', dir, reason: String(err?.message ?? err) }, entities: null, rules: null };
|
|
68
|
+
}
|
|
69
|
+
if (!out.size)
|
|
70
|
+
return { lookup: { state: 'malformed', dir, reason: 'no system-entity entries parsed' }, entities: null, rules: null };
|
|
71
|
+
return { lookup, entities: out, rules };
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Check one parsed `xrm.yaml`. Takes the whole document and reads `entities:` —
|
|
75
|
+
* anything else in the file is another check's business.
|
|
76
|
+
*/
|
|
77
|
+
export function findEntityViolations(doc, file, system, rules) {
|
|
78
|
+
const entities = doc?.entities;
|
|
79
|
+
if (!entities || typeof entities !== 'object' || Array.isArray(entities))
|
|
80
|
+
return [];
|
|
81
|
+
// Only from the catalog. A KB pulled before these were published leaves them
|
|
82
|
+
// empty, and the rules that read them do not fire — deliberately, rather than
|
|
83
|
+
// falling back to a hardcoded copy that could drift from the platform.
|
|
84
|
+
const reservedKeys = new Set(rules?.reservedKeys ?? []);
|
|
85
|
+
const forbidden = rules?.extensionForbidden ?? [];
|
|
86
|
+
const journeyPrefix = rules?.reservedPrefix ?? JOURNEY_PREFIX;
|
|
87
|
+
const findings = [];
|
|
88
|
+
for (const [key, raw] of Object.entries(entities)) {
|
|
89
|
+
const spec = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
|
|
90
|
+
const extendsSystem = spec.extends === 'system';
|
|
91
|
+
const sys = system.get(key);
|
|
92
|
+
// Not declarable under any form — it names the platform contacts table in
|
|
93
|
+
// `relations.to`, so an entity of this name makes every relation ambiguous.
|
|
94
|
+
if (reservedKeys.has(key)) {
|
|
95
|
+
findings.push({ kind: 'reserved-key', file, entity: key });
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (key.startsWith(journeyPrefix)) {
|
|
99
|
+
findings.push({ kind: 'journey-prefix', file, entity: key });
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (extendsSystem && sys) {
|
|
103
|
+
const overridden = forbidden.filter(k => spec[k] != null);
|
|
104
|
+
if (overridden.length)
|
|
105
|
+
findings.push({ kind: 'extension-override', file, entity: key, keys: overridden });
|
|
106
|
+
}
|
|
107
|
+
if (extendsSystem && !sys) {
|
|
108
|
+
findings.push({ kind: 'not-system', file, entity: key, known: [...system.keys()].sort() });
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (!extendsSystem && sys) {
|
|
112
|
+
findings.push({ kind: 'reserved', file, entity: key });
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (extendsSystem && sys) {
|
|
116
|
+
const fields = spec.fields;
|
|
117
|
+
const declared = fields && typeof fields === 'object' && !Array.isArray(fields)
|
|
118
|
+
? Object.keys(fields)
|
|
119
|
+
: [];
|
|
120
|
+
const clash = declared.filter(f => sys.fields.includes(f));
|
|
121
|
+
if (clash.length)
|
|
122
|
+
findings.push({ kind: 'field-clash', file, entity: key, fields: clash });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return findings;
|
|
126
|
+
}
|
|
127
|
+
/** One-line human message per finding. */
|
|
128
|
+
export function describeEntityFinding(f) {
|
|
129
|
+
switch (f.kind) {
|
|
130
|
+
case 'reserved':
|
|
131
|
+
return `${f.file}: entity '${f.entity}' is a platform system entity — add 'extends: system' to extend it (add-only), or pick another key. `
|
|
132
|
+
+ 'Left as is, the pack DEPLOYS and then fails to load on the first inbound message.';
|
|
133
|
+
case 'not-system':
|
|
134
|
+
return `${f.file}: entity '${f.entity}' declares 'extends: system', but the platform ships no such entity. `
|
|
135
|
+
+ `System entities: ${f.known.join(', ')}`;
|
|
136
|
+
case 'field-clash':
|
|
137
|
+
return `${f.file}: entity '${f.entity}' redeclares field${f.fields.length === 1 ? '' : 's'} ${f.fields.map(x => `'${x}'`).join(', ')} `
|
|
138
|
+
+ 'that the system template already ships — an extension may only ADD fields';
|
|
139
|
+
case 'journey-prefix':
|
|
140
|
+
return `${f.file}: entity '${f.entity}' uses the reserved '${JOURNEY_PREFIX}' prefix (entities synthesized from journeys live there) `
|
|
141
|
+
+ `— rename it, or declare it as journeys/${f.entity.slice(JOURNEY_PREFIX.length)}.journey.yaml`;
|
|
142
|
+
case 'reserved-key':
|
|
143
|
+
return `${f.file}: '${f.entity}' is a reserved entity key — it names the platform contacts table in \`relations.to\`, `
|
|
144
|
+
+ 'so an entity of that name makes every relation ambiguous. Pick another key; there is no `extends:` form for this one.';
|
|
145
|
+
case 'extension-override':
|
|
146
|
+
return `${f.file}: entity '${f.entity}' is an 'extends: system' extension and may not override `
|
|
147
|
+
+ `${f.keys.map(k => `'${k}'`).join(', ')} — the template's shape is platform-owned. `
|
|
148
|
+
+ 'Declare your own entity if you need a different shape.';
|
|
149
|
+
}
|
|
150
|
+
}
|