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.
@@ -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 four things:
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "octwin-cli",
3
- "version": "0.8.5",
3
+ "version": "0.8.7",
4
4
  "description": "Octwin external-pack developer CLI (by CEQUENS) — scaffold, validate, deploy, and check pure-YAML packs on your tenant.",
5
5
  "type": "module",
6
6
  "bin": {