octwin-cli 0.8.5 → 0.8.7
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 +62 -0
- package/dist/index.js +493 -503
- package/dist/lib/declaration-check.js +50 -1
- package/dist/lib/outcome.js +79 -0
- package/package.json +1 -1
|
@@ -17,11 +17,18 @@
|
|
|
17
17
|
* checks that were catching real bugs. So the rule is: **report only what is
|
|
18
18
|
* unambiguous, and walk away from anything else.**
|
|
19
19
|
*
|
|
20
|
-
* It reports exactly
|
|
20
|
+
* It reports exactly five things:
|
|
21
21
|
* 1. a key not in `properties` where `additionalProperties: false`
|
|
22
22
|
* 2. a missing `required` key
|
|
23
23
|
* 3. a scalar whose `type` is plainly wrong
|
|
24
24
|
* 4. a value outside a closed `enum`
|
|
25
|
+
* 5. a map KEY outside `propertyNames` (`enum` or `pattern`)
|
|
26
|
+
*
|
|
27
|
+
* Rule 5 was added 2026-09-06 and is the reason the RBAC vocabulary is worth publishing at all: a
|
|
28
|
+
* map's keys carry meaning in `roles.yaml` (grant resource keys, verb keys), and reading only
|
|
29
|
+
* VALUES meant an author could not learn about a bad key until `--remote`. It is safe under the
|
|
30
|
+
* false-positive rule above because it is a faithful replay — the same enum membership and the same
|
|
31
|
+
* regex engine the platform's own Zod runs.
|
|
25
32
|
*
|
|
26
33
|
* It STOPS DESCENDING (reports nothing at all for that subtree) at any node
|
|
27
34
|
* carrying `anyOf` / `oneOf` / `allOf` / `not`, at an unresolvable `$ref`, and at
|
|
@@ -142,6 +149,48 @@ function walk(value, schema, path, defs, out, file, depth) {
|
|
|
142
149
|
const obj = value;
|
|
143
150
|
const props = isObj(schema.properties) ? schema.properties : undefined;
|
|
144
151
|
const addl = schema.additionalProperties;
|
|
152
|
+
// 5. KEY vocabulary — `propertyNames` on a map schema.
|
|
153
|
+
//
|
|
154
|
+
// The fifth rule, and the one this file existed without for its whole life. A map's KEYS carry
|
|
155
|
+
// meaning in three declarations (`roles.yaml` grants and verbs, `xrm.yaml` entity names), and the
|
|
156
|
+
// walker read only values — so a pack author following `craft/manifest.md` wrote `case:` where
|
|
157
|
+
// the key is `record.case`, passed offline `validate`, and learned the truth from a `--remote`
|
|
158
|
+
// round-trip that reported one bad guess at a time. They abandoned custom RBAC over it.
|
|
159
|
+
//
|
|
160
|
+
// Safe to report because it is a FAITHFUL REPLAY: the same `enum` membership and the same JS
|
|
161
|
+
// regex engine Zod itself runs, over a pattern the platform generated. A false positive would
|
|
162
|
+
// require the published schema to disagree with the platform that published it.
|
|
163
|
+
const names = isObj(schema.propertyNames) ? schema.propertyNames : undefined;
|
|
164
|
+
if (names) {
|
|
165
|
+
const allowed = Array.isArray(names.enum) ? names.enum : undefined;
|
|
166
|
+
const pattern = typeof names.pattern === 'string' ? names.pattern : undefined;
|
|
167
|
+
let re;
|
|
168
|
+
// A pattern the local engine cannot compile is out of subset, exactly like a combinator —
|
|
169
|
+
// walk away rather than guess. (JSON Schema permits ECMA-262; this IS that engine, so in
|
|
170
|
+
// practice only a future dialect change lands here.)
|
|
171
|
+
if (pattern) {
|
|
172
|
+
try {
|
|
173
|
+
re = new RegExp(pattern);
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
re = undefined;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
for (const k of Object.keys(obj)) {
|
|
180
|
+
if (allowed && !allowed.includes(k)) {
|
|
181
|
+
out.push({
|
|
182
|
+
file, path: path ? `${path}.${k}` : k,
|
|
183
|
+
message: `not a valid key here — must be one of ${allowed.map(e => JSON.stringify(e)).join(', ')}`,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
else if (re && !re.test(k)) {
|
|
187
|
+
out.push({
|
|
188
|
+
file, path: path ? `${path}.${k}` : k,
|
|
189
|
+
message: `not a valid key here — it must match ${pattern}`,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
145
194
|
// A map schema (`additionalProperties: <schema>`, no `properties`) — every
|
|
146
195
|
// value shares one shape. This is how `entities:` and `agents:` are declared.
|
|
147
196
|
if (!props && isObj(addl)) {
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Outcome` — what one `octwin validate` check ACTUALLY did, as a value.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this exists
|
|
5
|
+
*
|
|
6
|
+
* `cmdValidate` used to hand-write its own `✓` per check, inside the `if` that had the
|
|
7
|
+
* check's data, with a `skipped[]` array and a `skipReasons` Map maintained alongside. It
|
|
8
|
+
* was correct because someone was careful, and the next check added was one `if` away from
|
|
9
|
+
* lying again. It had lied before, twice, in the same shape:
|
|
10
|
+
*
|
|
11
|
+
* - the KB-dependent block printed ONE `✓` above six checks whose own `✓`s lived inside
|
|
12
|
+
* their `if`s, so a run with no pulled KB read as "one check, passed". An entire backlog
|
|
13
|
+
* batch reached production that way;
|
|
14
|
+
* - `yamlDocs()` parsed inside `try { … } catch { return [] }`, so a file that failed to
|
|
15
|
+
* parse was silently DROPPED and every later check skipped it — `validate` printed
|
|
16
|
+
* all-green over a pack the platform's importer then refused with the same message, from
|
|
17
|
+
* the same `yaml` version this CLI already had.
|
|
18
|
+
*
|
|
19
|
+
* **An unparseable file is not "no findings", it is "no idea", and those must never print the
|
|
20
|
+
* same.** So a check no longer prints; it RETURNS one of three things, and a single printer
|
|
21
|
+
* renders all of them. A check that returns nothing cannot reach the `passed` branch, which
|
|
22
|
+
* makes the false-`✓` unrepresentable rather than merely discouraged.
|
|
23
|
+
*
|
|
24
|
+
* ## Why `not-run` carries the lookup rather than a message
|
|
25
|
+
*
|
|
26
|
+
* `describeKbLookup` phrases it at print time, so the wording lives in the module that owns
|
|
27
|
+
* the three KB states and cannot drift into a doubled "SKIPPED — SKIPPED —". It also lets the
|
|
28
|
+
* printer GROUP by reason: when the KB is absent every check skips for the identical reason,
|
|
29
|
+
* and six copies of one sentence is how a reader learns to scroll past the ⚠ block — the same
|
|
30
|
+
* failure as not printing it at all.
|
|
31
|
+
*/
|
|
32
|
+
import { describeKbLookup } from './kb-path.js';
|
|
33
|
+
/**
|
|
34
|
+
* Run every check in order and print exactly what each returned.
|
|
35
|
+
*
|
|
36
|
+
* Returns the labels that could not run, so the caller can decide what a partial pass means
|
|
37
|
+
* (`--require-kb` fails on any; an interactive run says which, last, where it is read).
|
|
38
|
+
*
|
|
39
|
+
* The first `findings` outcome terminates via `die`, matching the previous behaviour: an
|
|
40
|
+
* author fixes one class at a time, and printing six screens of unrelated findings buries the
|
|
41
|
+
* first. Order therefore matters and is the caller's to choose.
|
|
42
|
+
*/
|
|
43
|
+
export function reportChecks(checks, io) {
|
|
44
|
+
const notRun = [];
|
|
45
|
+
for (const check of checks) {
|
|
46
|
+
const outcome = check.run();
|
|
47
|
+
switch (outcome.kind) {
|
|
48
|
+
case 'passed':
|
|
49
|
+
io.log(`✓ ${outcome.line}`);
|
|
50
|
+
break;
|
|
51
|
+
case 'findings': {
|
|
52
|
+
const n = outcome.lines.length;
|
|
53
|
+
io.err(`✗ ${n} ${outcome.noun}${n === 1 ? '' : 's'}:`);
|
|
54
|
+
for (const line of outcome.lines)
|
|
55
|
+
io.err(` ✗ ${line}`);
|
|
56
|
+
io.die(outcome.hint);
|
|
57
|
+
}
|
|
58
|
+
// eslint-disable-next-line no-fallthrough -- `die` returns never; the case above cannot exit.
|
|
59
|
+
case 'not-run':
|
|
60
|
+
notRun.push({ label: check.label, lookup: outcome.lookup });
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
// One ⚠ per distinct reason, naming every check it cost. Grouped by the lookup's IDENTITY.
|
|
65
|
+
const byReason = new Map();
|
|
66
|
+
for (const { label, lookup } of notRun) {
|
|
67
|
+
const key = lookup.state === 'ok'
|
|
68
|
+
? 'ok'
|
|
69
|
+
: `${lookup.state}|${'dir' in lookup ? lookup.dir : ''}|${'reason' in lookup ? lookup.reason : ''}`;
|
|
70
|
+
const bucket = byReason.get(key) ?? { lookup, labels: [] };
|
|
71
|
+
bucket.labels.push(label);
|
|
72
|
+
byReason.set(key, bucket);
|
|
73
|
+
}
|
|
74
|
+
for (const { lookup, labels } of byReason.values()) {
|
|
75
|
+
const what = labels.length === 1 ? labels[0] : `${labels.length} checks (${labels.join(', ')})`;
|
|
76
|
+
io.log(`⚠ ${describeKbLookup(lookup, what)}`);
|
|
77
|
+
}
|
|
78
|
+
return notRun.map(n => n.label);
|
|
79
|
+
}
|
package/package.json
CHANGED