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
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Offline `use:` template check, driven by the pulled platform KB.
|
|
3
|
+
*
|
|
4
|
+
* WHY it matters: `use:` splices a template's NODES into the flow at load time,
|
|
5
|
+
* before any expression evaluates, and its `with:` block is the only thing the
|
|
6
|
+
* spliced subtree can see. So a mistyped param name does not error — it arrives
|
|
7
|
+
* as `undefined` inside the template body, and the card renders with a blank
|
|
8
|
+
* where the value should be, or a confirm step approves with no preview. The
|
|
9
|
+
* platform's own `approve_apply` is the common case: `confirm_title` misspelled
|
|
10
|
+
* silently falls back to the hardcoded Arabic default.
|
|
11
|
+
*
|
|
12
|
+
* PACK TEMPLATES SHADOW PLATFORM ONES. A pack may ship `templates/<name>.yaml`
|
|
13
|
+
* of its own, and a pack template of the same name wins. So the caller passes
|
|
14
|
+
* the pack's own template names and this check stays silent about them entirely
|
|
15
|
+
* — it has no schema for a template it did not publish, and inventing one would
|
|
16
|
+
* flag every pack that uses its own.
|
|
17
|
+
*
|
|
18
|
+
* WHAT IT DOES NOT CHECK: a pack template's params (no published schema, see
|
|
19
|
+
* above), and the VALUES passed to a platform template's params — a `with:`
|
|
20
|
+
* value is almost always an expression resolved at runtime, exactly as in
|
|
21
|
+
* `args-check.ts`.
|
|
22
|
+
*/
|
|
23
|
+
import { readdirSync, readFileSync } from 'node:fs';
|
|
24
|
+
import { join } from 'node:path';
|
|
25
|
+
import { lookupKbSubdir, isEntryFile } from './kb-path.js';
|
|
26
|
+
/** Load platform template param specs from the pulled KB. */
|
|
27
|
+
export function loadTemplateSpecs(packDir) {
|
|
28
|
+
const lookup = lookupKbSubdir(packDir, 'templates', dir => readdirSync(dir).some(isEntryFile));
|
|
29
|
+
if (lookup.state !== 'ok')
|
|
30
|
+
return { lookup, specs: null };
|
|
31
|
+
const dir = lookup.dir;
|
|
32
|
+
const out = new Map();
|
|
33
|
+
try {
|
|
34
|
+
for (const file of readdirSync(dir)) {
|
|
35
|
+
if (!isEntryFile(file))
|
|
36
|
+
continue;
|
|
37
|
+
const entry = JSON.parse(readFileSync(join(dir, file), 'utf8'));
|
|
38
|
+
if (!entry.name)
|
|
39
|
+
continue;
|
|
40
|
+
const params = Array.isArray(entry.params) ? entry.params : [];
|
|
41
|
+
out.set(entry.name, {
|
|
42
|
+
name: entry.name,
|
|
43
|
+
params: params.map(p => p.name).filter((n) => !!n),
|
|
44
|
+
required: params.filter(p => p.required === true).map(p => p.name).filter((n) => !!n),
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
return { lookup: { state: 'malformed', dir, reason: String(err?.message ?? err) }, specs: null };
|
|
50
|
+
}
|
|
51
|
+
if (!out.size)
|
|
52
|
+
return { lookup: { state: 'malformed', dir, reason: 'no template entries parsed' }, specs: null };
|
|
53
|
+
return { lookup, specs: out };
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Walk parsed YAML for `use:` nodes and check them against the platform specs.
|
|
57
|
+
* `packTemplates` names the templates this pack ships itself — those are skipped.
|
|
58
|
+
*/
|
|
59
|
+
export function findTemplateViolations(doc, file, specs, packTemplates) {
|
|
60
|
+
const findings = [];
|
|
61
|
+
const walk = (node) => {
|
|
62
|
+
if (Array.isArray(node)) {
|
|
63
|
+
node.forEach(walk);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (!node || typeof node !== 'object')
|
|
67
|
+
return;
|
|
68
|
+
const obj = node;
|
|
69
|
+
const name = obj.use;
|
|
70
|
+
if (typeof name === 'string' && !packTemplates.has(name)) {
|
|
71
|
+
const spec = specs.get(name);
|
|
72
|
+
if (!spec) {
|
|
73
|
+
findings.push({ kind: 'unknown', file, template: name, known: [...specs.keys()].sort() });
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
const withBlock = obj.with;
|
|
77
|
+
const passed = withBlock && typeof withBlock === 'object' && !Array.isArray(withBlock)
|
|
78
|
+
? Object.keys(withBlock)
|
|
79
|
+
: [];
|
|
80
|
+
const unknown = passed.filter(k => !spec.params.includes(k));
|
|
81
|
+
if (unknown.length)
|
|
82
|
+
findings.push({ kind: 'unknown-param', file, template: name, keys: unknown, params: [...spec.params].sort() });
|
|
83
|
+
const missing = spec.required.filter(k => !passed.includes(k));
|
|
84
|
+
if (missing.length)
|
|
85
|
+
findings.push({ kind: 'missing', file, template: name, keys: missing });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
for (const v of Object.values(obj))
|
|
89
|
+
walk(v);
|
|
90
|
+
};
|
|
91
|
+
walk(doc);
|
|
92
|
+
return findings;
|
|
93
|
+
}
|
|
94
|
+
/** One-line human message per finding. */
|
|
95
|
+
export function describeTemplateFinding(f) {
|
|
96
|
+
switch (f.kind) {
|
|
97
|
+
case 'unknown':
|
|
98
|
+
return `${f.file}: \`use: ${f.template}\` — no such template. Platform templates: ${f.known.join(', ')}`
|
|
99
|
+
+ `. (A template of your own must live at templates/${f.template}.yaml in the pack.)`;
|
|
100
|
+
case 'unknown-param':
|
|
101
|
+
return `${f.file}: \`use: ${f.template}\` passes ${f.keys.map(k => `'${k}'`).join(', ')}, which the template declares no param for `
|
|
102
|
+
+ `— dropped in silence, and the body sees undefined. Params: ${f.params.join(', ')}`;
|
|
103
|
+
case 'missing':
|
|
104
|
+
return `${f.file}: \`use: ${f.template}\` is missing required param${f.keys.length === 1 ? '' : 's'} `
|
|
105
|
+
+ `${f.keys.map(k => `'${k}'`).join(', ')} — the body reads undefined where the value should be`;
|
|
106
|
+
}
|
|
107
|
+
}
|
package/dist/lib/validate.js
CHANGED
|
@@ -14,6 +14,33 @@
|
|
|
14
14
|
* every rule, so a divergence fails the build rather than surfacing as "it passed
|
|
15
15
|
* locally but the deploy rejected it".
|
|
16
16
|
*/
|
|
17
|
+
/**
|
|
18
|
+
* The pack-name grammar, vendored from `src/platform/core/kernel/pack-id.ts`.
|
|
19
|
+
*
|
|
20
|
+
* Your `manifest.yaml` declares a BARE name (`clinic`). The platform prefixes your
|
|
21
|
+
* workspace slug when it publishes, so the pack becomes `acme.clinic` in the catalog
|
|
22
|
+
* — you never write the owner half, and therefore cannot claim someone else's.
|
|
23
|
+
*/
|
|
24
|
+
const SLUG_RE = /^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/;
|
|
25
|
+
/** Validate a raw string as a pack name, or `null`. The only way to mint a `PackName`. */
|
|
26
|
+
export function asPackName(raw) {
|
|
27
|
+
return describePackNameProblem(raw) === null ? raw : null;
|
|
28
|
+
}
|
|
29
|
+
/** Why the manifest's `id` is unusable, or null. Prose, because an author reads it. */
|
|
30
|
+
export function describePackNameProblem(name) {
|
|
31
|
+
if (!name)
|
|
32
|
+
return 'manifest.yaml must declare a non-empty `id`';
|
|
33
|
+
if (name.includes('.')) {
|
|
34
|
+
return `pack id '${name}' must not contain '.' — declare the bare name ` +
|
|
35
|
+
`('${name.slice(name.indexOf('.') + 1)}'); the platform prefixes your workspace ` +
|
|
36
|
+
`when it publishes`;
|
|
37
|
+
}
|
|
38
|
+
if (!SLUG_RE.test(name)) {
|
|
39
|
+
return `pack id '${name}' must be lowercase letters, digits and inner hyphens, ` +
|
|
40
|
+
`1–40 chars (e.g. 'clinic')`;
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
17
44
|
/** Declarative TEXT extensions a pure-YAML pack may contain. */
|
|
18
45
|
const ALLOWED_EXT = new Set(['yaml', 'yml', 'md', 'json']);
|
|
19
46
|
/** Binary extensions that travel as artifact BLOBS (base64 on the wire, bytea in
|
|
@@ -39,13 +66,15 @@ function ext(p) {
|
|
|
39
66
|
* no path traversal or absolute paths, declarative extensions only. Returns all
|
|
40
67
|
* violations at once.
|
|
41
68
|
*/
|
|
42
|
-
export function validatePackBundle(
|
|
69
|
+
export function validatePackBundle(
|
|
70
|
+
/** The manifest's BARE name — the platform prefixes your workspace at publish. */
|
|
71
|
+
packName, files,
|
|
43
72
|
/** `{ relPath: base64 }` — the binary half, as the CLI collects it. */
|
|
44
73
|
blobs = {}) {
|
|
45
74
|
const errors = [];
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
75
|
+
const idProblem = describePackNameProblem(packName);
|
|
76
|
+
if (idProblem)
|
|
77
|
+
errors.push(idProblem);
|
|
49
78
|
const paths = Object.keys(files);
|
|
50
79
|
if (paths.length === 0 && Object.keys(blobs).length === 0)
|
|
51
80
|
errors.push('bundle is empty');
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* YAML source line for a node path — the CLI-side twin of the platform's
|
|
3
|
+
* `core/utils/yaml-position.ts` (E-08). Standalone because the CLI is
|
|
4
|
+
* platform-free by construction; both are ~40 lines over the same `yaml` API.
|
|
5
|
+
*
|
|
6
|
+
* `yamlLineOf(raw, path)` parses once per call — fine here, because findings
|
|
7
|
+
* are rare (the happy path calls this zero times). When the exact node doesn't
|
|
8
|
+
* resolve, walks up the path so the enclosing node's line is reported.
|
|
9
|
+
*/
|
|
10
|
+
import { LineCounter, parseDocument } from 'yaml';
|
|
11
|
+
export function yamlLineOf(raw, path) {
|
|
12
|
+
const lineCounter = new LineCounter();
|
|
13
|
+
let doc;
|
|
14
|
+
try {
|
|
15
|
+
doc = parseDocument(raw, { lineCounter, keepSourceTokens: true });
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
for (let end = path.length; end >= 0; end--) {
|
|
21
|
+
const node = end === 0
|
|
22
|
+
? doc.contents
|
|
23
|
+
: doc.getIn(path.slice(0, end), true);
|
|
24
|
+
const start = node?.range?.[0];
|
|
25
|
+
if (typeof start === 'number')
|
|
26
|
+
return lineCounter.linePos(start).line;
|
|
27
|
+
}
|
|
28
|
+
return null;
|
|
29
|
+
}
|
package/package.json
CHANGED