octwin-cli 0.8.5 → 0.8.6

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 CHANGED
@@ -5,6 +5,31 @@ Format: [Keep a Changelog](https://keepachangelog.com/) — newest first, bucket
5
5
  **Added · Changed · Deprecated · Removed · Fixed · Security**. The platform-wide view lives in the
6
6
  repo root [`CHANGELOG.md`](../../CHANGELOG.md); this file is the CLI-only cut that ships with the package.
7
7
 
8
+ ## [0.8.6] - 2026-09-04
9
+
10
+ ### Changed
11
+ - **`validate` can no longer print a `✓` for a check that did not run.** Each of the eight
12
+ checks now RETURNS an `Outcome` — `passed`, `findings`, or `not-run` — and a single printer
13
+ renders all of them, groups the skips by reason, and computes the exit code. Previously each
14
+ check printed its own `✓` inside the `if` that held its data, with a `skipped[]` array and a
15
+ `skipReasons` Map maintained alongside; it was correct because someone was careful, and the
16
+ next check added was one `if` away from lying again.
17
+
18
+ It had lied twice, in the same shape: the KB-dependent block once printed ONE `✓` above six
19
+ checks whose own `✓`s lived inside their `if`s (so a run with no pulled KB read as "one check,
20
+ passed", and an entire backlog batch reached production that way), and `yamlDocs()` once
21
+ dropped an unparseable file so `validate` printed all-green over a pack the platform's importer
22
+ then refused. **An unparseable file is not "no findings", it is "no idea", and those must never
23
+ print the same** — a check that returns nothing now cannot reach the `passed` branch at all.
24
+
25
+ Output is unchanged for a normal run. `passed` carries its own line, so the reserved-entity
26
+ check keeps the narrower wording it uses when the catalog rules are absent from an older pull.
27
+ `--require-kb` is now one line over the returned skip list.
28
+
29
+ Verified 2026-09-04: all 23 marketplace packs exit 0 with no findings; a pack with no pulled KB
30
+ prints zero `✓` for the six KB-dependent checks plus one grouped ⚠ naming all six, and
31
+ `--require-kb` exits 1; a duplicate YAML key still exits 1 naming the file.
32
+
8
33
  ## [0.8.5] - 2026-09-02
9
34
 
10
35
  ### Added
package/dist/index.js CHANGED
@@ -73,7 +73,8 @@ import { loadBuiltinNames, findBuiltinViolations, describeBuiltinFinding } from
73
73
  import { loadTemplateSpecs, findTemplateViolations, describeTemplateFinding } from './lib/template-check.js';
74
74
  import { loadSystemEntities, findEntityViolations, describeEntityFinding } from './lib/entity-check.js';
75
75
  import { loadDeclarationSpecs, findDeclarationViolations, describeDeclarationFinding } from './lib/declaration-check.js';
76
- import { describeKbLookup, findPlatformKbDir } from './lib/kb-path.js';
76
+ import { findPlatformKbDir } from './lib/kb-path.js';
77
+ import { reportChecks } from './lib/outcome.js';
77
78
  import { classifyPackPath, isSkippedDir } from './lib/pack-source.js';
78
79
  import { readPage, morePageHint } from './lib/page.js';
79
80
  import { kbOneLiner, buildKbIndexMarkdown, buildKbOutlineMarkdown, } from './lib/kb-index.js';
@@ -976,158 +977,161 @@ async function cmdValidate(flags) {
976
977
  const yamlDocs = () => parsed;
977
978
  // Checks that need the pulled KB. All of them DEGRADE when it is absent — the KB
978
979
  // is a gitignored cache wiped by every pull, so failing hard would break a fresh
979
- // clone before the author could act. But a skip is ANNOUNCED, and remembered:
980
- // the ✓ used to print above these blocks unconditionally while the per-check ✓s
981
- // lived inside the `if`s, so a KB-less run read as "one check, passed". An entire
982
- // backlog batch reached production that way. The defect is the silence, not the skip.
980
+ // clone before the author could act. But a skip is ANNOUNCED, and remembered.
983
981
  //
984
- // Reasons are COLLECTED rather than printed inline. When the KB is missing, every
985
- // check skips for the identical reason, and six copies of one sentence is how a
986
- // reader learns to scroll past the block which is the same failure as not
987
- // printing it. One line, naming all six.
988
- const skipped = [];
989
- const skipReasons = new Map();
990
- const noteSkip = (label, lookup) => {
991
- skipped.push(label);
992
- // Group by the lookup's IDENTITY, then let `describeKbLookup` phrase the one
993
- // line at print time — so the wording stays in the module that owns it and
994
- // cannot drift into a doubled "SKIPPED — SKIPPED —".
995
- const key = lookup.state === 'ok' ? 'ok'
996
- : `${lookup.state}|${'dir' in lookup ? lookup.dir : ''}|${'reason' in lookup ? lookup.reason : ''}`;
997
- const bucket = skipReasons.get(key) ?? { lookup, labels: [] };
998
- bucket.labels.push(label);
999
- skipReasons.set(key, bucket);
1000
- };
982
+ // Each check RETURNS an `Outcome` and prints nothing; `reportChecks` renders all of
983
+ // them and groups the skips by reason. That is the whole point: a check that returns
984
+ // nothing cannot reach the `passed` branch, so the false-`✓` this file printed twice
985
+ // before once for six checks at a time, once over an unparseable file — is no longer
986
+ // a mistake anyone can make. See [`lib/outcome.ts`](./lib/outcome.ts).
1001
987
  /** Stamp each finding with its source line — the walkers carry the node
1002
988
  * path; `files` holds the raw text the locator needs (E-08). */
1003
989
  const withLines = (fs) => fs.map(f => ({ ...f, line: files[f.file] ? yamlLineOf(files[f.file], f.path) : null }));
1004
- const render = loadAllowedRenderKeys(packDir);
1005
- if (render.keys) {
1006
- const findings = withLines(yamlDocs().flatMap(([p, doc]) => findRenderKeyViolations(doc, p, render.keys, render.nested)));
1007
- if (findings.length) {
1008
- console.error(`✗ ${findings.length} render-intent field error${findings.length === 1 ? '' : 's'}:`);
1009
- for (const f of findings)
1010
- console.error(` ✗ ${describeRenderFinding(f)}`);
1011
- die('fix these before deploying the platform rejects them at load, and before that they rendered as nothing');
1012
- }
1013
- console.log('✓ render intents use only fields the platform renders');
1014
- }
1015
- else {
1016
- noteSkip('render-intent fields', render.lookup);
1017
- }
1018
- // Primitive `args:` keys, same source and same contract. Cannot see inside a
1019
- // `use:` template body (expansion is the platform's job); `--remote` covers that.
1020
- const args = loadPrimitiveArgSpecs(packDir);
1021
- if (args.specs) {
1022
- const findings = withLines(yamlDocs().flatMap(([p, doc]) => findArgViolations(doc, p, args.specs)));
1023
- if (findings.length) {
1024
- console.error(`✗ ${findings.length} primitive-argument error${findings.length === 1 ? '' : 's'}:`);
1025
- for (const f of findings)
1026
- console.error(` ✗ ${describeArgFinding(f)}`);
1027
- die('fix these before deploying — an undeclared argument is dropped with no error at runtime');
1028
- }
1029
- console.log('✓ primitive arguments match their declared inputs');
1030
- }
1031
- else {
1032
- noteSkip('primitive arguments', args.lookup);
1033
- }
1034
- // Expression builtinsthe function set is CLOSED and generated from the
1035
- // runtime, so an invented `$fn(` is checkable here and nowhere else offline.
1036
- const builtins = loadBuiltinNames(packDir);
1037
- if (builtins.names) {
1038
- const findings = withLines(yamlDocs().flatMap(([p, doc]) => findBuiltinViolations(doc, p, builtins.names)));
1039
- if (findings.length) {
1040
- console.error(`✗ ${findings.length} unknown expression function${findings.length === 1 ? '' : 's'}:`);
1041
- for (const f of findings)
1042
- console.error(` ✗ ${describeBuiltinFinding(f)}`);
1043
- die('fix these before deploying — the evaluator cannot resolve them, and it fails mid-conversation');
1044
- }
1045
- console.log('✓ every $function() in an expression exists');
1046
- }
1047
- else {
1048
- noteSkip('expression functions', builtins.lookup);
1049
- }
1050
- // `use:` templates. A pack's OWN templates shadow the platform's, so they are
1051
- // named here and skipped — this check has no schema for them.
1052
- const templates = loadTemplateSpecs(packDir);
1053
- if (templates.specs) {
1054
- // A pack template is `templates/<name>.template.yaml` the `.template`
1055
- // segment is part of the convention the expander scans for, NOT part of the
1056
- // name a `use:` writes. Capturing it would leave every pack that shadows a
1057
- // platform template (kaiian shadows `field_prompt_render`) reported as using
1058
- // one that does not exist.
1059
- const packTemplates = new Set(Object.keys(files)
1060
- .map(p => /^templates\/(.+)\.template\.ya?ml$/i.exec(p.replace(/\\/g, '/'))?.[1])
1061
- .filter((n) => !!n));
1062
- const findings = yamlDocs().flatMap(([p, doc]) => findTemplateViolations(doc, p, templates.specs, packTemplates));
1063
- if (findings.length) {
1064
- console.error(`✗ ${findings.length} template error${findings.length === 1 ? '' : 's'}:`);
1065
- for (const f of findings)
1066
- console.error(` ✗ ${describeTemplateFinding(f)}`);
1067
- die('fix these before deploying — a template param that does not exist arrives as undefined, and renders as a blank');
1068
- }
1069
- console.log('✓ `use:` templates and their params exist');
1070
- }
1071
- else {
1072
- noteSkip('`use:` templates', templates.lookup);
1073
- }
1074
- // Reserved XRM entity keys — a boot error, which means the pack deploys clean
1075
- // and then fails to load on the first inbound message.
1076
- const system = loadSystemEntities(packDir);
1077
- if (system.entities) {
1078
- const findings = yamlDocs()
1079
- .filter(([p]) => /(^|[/\\])xrm\.ya?ml$/i.test(p))
1080
- .flatMap(([p, doc]) => findEntityViolations(doc, p, system.entities, system.rules));
1081
- if (findings.length) {
1082
- console.error(`✗ ${findings.length} reserved-entity error${findings.length === 1 ? '' : 's'}:`);
1083
- for (const f of findings)
1084
- console.error(` ✗ ${describeEntityFinding(f)}`);
1085
- die('fix these before deploying these fail at BOOT, after a deploy that reported success');
1086
- }
1087
- // Name the narrower promise when the catalog-level rules are absent (a KB
1088
- // pulled before they were published) — a ✓ that reads wider than what ran is
1089
- // the failure this file's skip contract exists to prevent.
1090
- console.log(system.rules
1091
- ? '✓ no entity collides with a reserved platform key, and no extension overrides a platform-owned one'
1092
- : '✓ no entity collides with a reserved platform key (re-pull for the `contact`/extension-override rules)');
1093
- }
1094
- else {
1095
- noteSkip('reserved entity keys', system.lookup);
1096
- }
1097
- // The declaration files themselves, against the published JSON Schemas.
1098
- // Deliberately narrow (see declaration-check.ts)it walks away from anything
1099
- // it cannot read rather than guessing.
1100
- const decls = loadDeclarationSpecs(packDir);
1101
- if (decls.specs) {
1102
- const findings = yamlDocs().flatMap(([p, doc]) => {
1103
- const base = p.replace(/\\/g, '/').split('/').pop() ?? p;
1104
- const spec = decls.specs.get(base);
1105
- // Only a file at the PACK ROOT is a declaration — `flows/tools/xrm.yaml`
1106
- // would be a flow that happens to share a name.
1107
- if (!spec || p.replace(/\\/g, '/').includes('/'))
1108
- return [];
1109
- return findDeclarationViolations(doc, spec).map(f => ({ ...f, file: p }));
1110
- });
1111
- if (findings.length) {
1112
- console.error(`✗ ${findings.length} declaration error${findings.length === 1 ? '' : 's'}:`);
1113
- for (const f of findings)
1114
- console.error(` ✗ ${describeDeclarationFinding(f)}`);
1115
- die('fix these before deploying a declaration file is parsed strictly, and an unknown key is rejected');
1116
- }
1117
- // Deliberately narrow wording. Most of `xrm.yaml`'s field shapes are a Zod
1118
- // union, which renders as `anyOf` and which this check walks away from by
1119
- // design — so "matches its schema" would be a promise it does not keep, and
1120
- // an over-claimed is how an author stops reading `--remote` output.
1121
- console.log('✓ declaration files carry no unknown or missing keys (unions are left to --remote)');
1122
- }
1123
- else {
1124
- noteSkip('declaration schemas', decls.lookup);
1125
- }
1126
- // One per distinct reason, naming every check it cost.
1127
- for (const { lookup, labels } of skipReasons.values()) {
1128
- const what = labels.length === 1 ? labels[0] : `${labels.length} checks (${labels.join(', ')})`;
1129
- console.log(`⚠ ${describeKbLookup(lookup, what)}`);
1130
- }
990
+ const checks = [
991
+ {
992
+ label: 'render-intent fields',
993
+ run: () => {
994
+ const render = loadAllowedRenderKeys(packDir);
995
+ if (!render.keys)
996
+ return { kind: 'not-run', lookup: render.lookup };
997
+ const findings = withLines(yamlDocs().flatMap(([p, doc]) => findRenderKeyViolations(doc, p, render.keys, render.nested)));
998
+ return findings.length
999
+ ? {
1000
+ kind: 'findings', noun: 'render-intent field error',
1001
+ lines: findings.map(describeRenderFinding),
1002
+ hint: 'fix these before deploying — the platform rejects them at load, and before that they rendered as nothing',
1003
+ }
1004
+ : { kind: 'passed', line: 'render intents use only fields the platform renders' };
1005
+ },
1006
+ },
1007
+ {
1008
+ // Primitive `args:` keys, same source and same contract. Cannot see inside a
1009
+ // `use:` template body (expansion is the platform's job); `--remote` covers that.
1010
+ label: 'primitive arguments',
1011
+ run: () => {
1012
+ const args = loadPrimitiveArgSpecs(packDir);
1013
+ if (!args.specs)
1014
+ return { kind: 'not-run', lookup: args.lookup };
1015
+ const findings = withLines(yamlDocs().flatMap(([p, doc]) => findArgViolations(doc, p, args.specs)));
1016
+ return findings.length
1017
+ ? {
1018
+ kind: 'findings', noun: 'primitive-argument error',
1019
+ lines: findings.map(describeArgFinding),
1020
+ hint: 'fix these before deploying an undeclared argument is dropped with no error at runtime',
1021
+ }
1022
+ : { kind: 'passed', line: 'primitive arguments match their declared inputs' };
1023
+ },
1024
+ },
1025
+ {
1026
+ // Expression builtins the function set is CLOSED and generated from the
1027
+ // runtime, so an invented `$fn(` is checkable here and nowhere else offline.
1028
+ label: 'expression functions',
1029
+ run: () => {
1030
+ const builtins = loadBuiltinNames(packDir);
1031
+ if (!builtins.names)
1032
+ return { kind: 'not-run', lookup: builtins.lookup };
1033
+ const findings = withLines(yamlDocs().flatMap(([p, doc]) => findBuiltinViolations(doc, p, builtins.names)));
1034
+ return findings.length
1035
+ ? {
1036
+ kind: 'findings', noun: 'unknown expression function',
1037
+ lines: findings.map(describeBuiltinFinding),
1038
+ hint: 'fix these before deploying — the evaluator cannot resolve them, and it fails mid-conversation',
1039
+ }
1040
+ : { kind: 'passed', line: 'every $function() in an expression exists' };
1041
+ },
1042
+ },
1043
+ {
1044
+ // `use:` templates. A pack's OWN templates shadow the platform's, so they are
1045
+ // named here and skipped — this check has no schema for them.
1046
+ label: '`use:` templates',
1047
+ run: () => {
1048
+ const templates = loadTemplateSpecs(packDir);
1049
+ if (!templates.specs)
1050
+ return { kind: 'not-run', lookup: templates.lookup };
1051
+ // A pack template is `templates/<name>.template.yaml` — the `.template`
1052
+ // segment is part of the convention the expander scans for, NOT part of the
1053
+ // name a `use:` writes. Capturing it would leave every pack that shadows a
1054
+ // platform template (kaiian shadows `field_prompt_render`) reported as using
1055
+ // one that does not exist.
1056
+ const packTemplates = new Set(Object.keys(files)
1057
+ .map(p => /^templates\/(.+)\.template\.ya?ml$/i.exec(p.replace(/\\/g, '/'))?.[1])
1058
+ .filter((n) => !!n));
1059
+ const findings = yamlDocs().flatMap(([p, doc]) => findTemplateViolations(doc, p, templates.specs, packTemplates));
1060
+ return findings.length
1061
+ ? {
1062
+ kind: 'findings', noun: 'template error',
1063
+ lines: findings.map(describeTemplateFinding),
1064
+ hint: 'fix these before deploying — a template param that does not exist arrives as undefined, and renders as a blank',
1065
+ }
1066
+ : { kind: 'passed', line: '`use:` templates and their params exist' };
1067
+ },
1068
+ },
1069
+ {
1070
+ // Reserved XRM entity keys — a boot error, which means the pack deploys clean
1071
+ // and then fails to load on the first inbound message.
1072
+ label: 'reserved entity keys',
1073
+ run: () => {
1074
+ const system = loadSystemEntities(packDir);
1075
+ if (!system.entities)
1076
+ return { kind: 'not-run', lookup: system.lookup };
1077
+ const findings = yamlDocs()
1078
+ .filter(([p]) => /(^|[/\\])xrm\.ya?ml$/i.test(p))
1079
+ .flatMap(([p, doc]) => findEntityViolations(doc, p, system.entities, system.rules));
1080
+ return findings.length
1081
+ ? {
1082
+ kind: 'findings', noun: 'reserved-entity error',
1083
+ lines: findings.map(describeEntityFinding),
1084
+ hint: 'fix these before deployingthese fail at BOOT, after a deploy that reported success',
1085
+ }
1086
+ // Name the narrower promise when the catalog-level rules are absent (a KB
1087
+ // pulled before they were published) — a ✓ that reads wider than what ran is
1088
+ // the failure this file's skip contract exists to prevent, and it is why
1089
+ // `passed` carries its own line instead of the printer inventing one.
1090
+ : {
1091
+ kind: 'passed',
1092
+ line: system.rules
1093
+ ? 'no entity collides with a reserved platform key, and no extension overrides a platform-owned one'
1094
+ : 'no entity collides with a reserved platform key (re-pull for the `contact`/extension-override rules)',
1095
+ };
1096
+ },
1097
+ },
1098
+ {
1099
+ // The declaration files themselves, against the published JSON Schemas.
1100
+ // Deliberately narrow (see declaration-check.ts) — it walks away from anything
1101
+ // it cannot read rather than guessing.
1102
+ label: 'declaration schemas',
1103
+ run: () => {
1104
+ const decls = loadDeclarationSpecs(packDir);
1105
+ if (!decls.specs)
1106
+ return { kind: 'not-run', lookup: decls.lookup };
1107
+ const findings = yamlDocs().flatMap(([p, doc]) => {
1108
+ const base = p.replace(/\\/g, '/').split('/').pop() ?? p;
1109
+ const spec = decls.specs.get(base);
1110
+ // Only a file at the PACK ROOT is a declaration `flows/tools/xrm.yaml`
1111
+ // would be a flow that happens to share a name.
1112
+ if (!spec || p.replace(/\\/g, '/').includes('/'))
1113
+ return [];
1114
+ return findDeclarationViolations(doc, spec).map(f => ({ ...f, file: p }));
1115
+ });
1116
+ return findings.length
1117
+ ? {
1118
+ kind: 'findings', noun: 'declaration error',
1119
+ lines: findings.map(describeDeclarationFinding),
1120
+ hint: 'fix these before deploying — a declaration file is parsed strictly, and an unknown key is rejected',
1121
+ }
1122
+ // Deliberately narrow wording. Most of `xrm.yaml`'s field shapes are a Zod
1123
+ // union, which renders as `anyOf` and which this check walks away from by
1124
+ // design — so "matches its schema" would be a promise it does not keep, and
1125
+ // an over-claimed ✓ is how an author stops reading `--remote` output.
1126
+ : { kind: 'passed', line: 'declaration files carry no unknown or missing keys (unions are left to --remote)' };
1127
+ },
1128
+ },
1129
+ ];
1130
+ const skipped = reportChecks(checks, {
1131
+ log: (l) => console.log(l),
1132
+ err: (l) => console.error(l),
1133
+ die,
1134
+ });
1131
1135
  // `--require-kb` is for CI, where a skip nobody reads is worse than a red build.
1132
1136
  if (skipped.length && flags['require-kb'] === true) {
1133
1137
  die(`--require-kb: ${skipped.length} check${skipped.length === 1 ? '' : 's'} could not run (${skipped.join(', ')})`);
@@ -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.6",
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": {