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.
@@ -0,0 +1,214 @@
1
+ /**
2
+ * The three maps `octwin platform-kb pull` writes beside the reference.
3
+ *
4
+ * The KB is ~1.6 MB across ~200 files. Reading it whole costs more context than
5
+ * the pack being authored, so the pull ships navigation instead:
6
+ *
7
+ * INDEX.md — the corpus: which docs exist, which FAMILY each belongs to,
8
+ * how big it is, and the order to read the first six.
9
+ * SYMBOLS.md — a NAME → the file that defines it (see `kb-symbols.ts`).
10
+ * OUTLINE.md — every doc's headings WITH line numbers, so a heavy doc is read
11
+ * from the right offset instead of end to end.
12
+ *
13
+ * ## Why INDEX.md stopped listing every catalog entry
14
+ *
15
+ * It used to print a row per primitive, per builtin, per render intent — ~900
16
+ * rows, ~30 KB, read in full at the start of every session to answer a question
17
+ * that is almost always about ONE name. That job is `SYMBOLS.md`'s, and it is a
18
+ * grep rather than a read. What is left here is the part that genuinely needs
19
+ * reading once: the shape of the corpus.
20
+ *
21
+ * ## Degrading against an older platform
22
+ *
23
+ * This CLI talks to any platform version. `group`, `bytes` and `sections` arrived
24
+ * with the navigation work; a platform that predates them sends an index without
25
+ * them. Every renderer here falls back to the flat shape rather than printing an
26
+ * empty table — and `OUTLINE.md` is simply not written when no doc carries an
27
+ * outline, because an empty map is worse than an absent one.
28
+ */
29
+ /** First sentence (or a hard clamp) of a possibly-long `describe` — a map needs
30
+ * one scannable line per entry, not the whole contract.
31
+ *
32
+ * Sentence detection ignores punctuation nested in brackets: primitive `describe`
33
+ * text routinely inlines an envelope shape (`… { rows, total, …, refs? } …`) whose
34
+ * `?` would otherwise cut the summary off mid-brace. */
35
+ export function kbOneLiner(text, max = 160) {
36
+ if (typeof text !== 'string' || !text.trim())
37
+ return '';
38
+ const flat = text.replace(/\s+/g, ' ').trim();
39
+ let depth = 0;
40
+ let end = -1;
41
+ for (let i = 0; i < flat.length; i++) {
42
+ const ch = flat[i];
43
+ if (ch === '{' || ch === '(' || ch === '[')
44
+ depth++;
45
+ else if (ch === '}' || ch === ')' || ch === ']')
46
+ depth = Math.max(0, depth - 1);
47
+ else if (depth === 0 && (ch === '.' || ch === '!' || ch === '?')) {
48
+ const next = flat[i + 1];
49
+ if (next === undefined || next === ' ') {
50
+ end = i + 1;
51
+ break;
52
+ }
53
+ }
54
+ }
55
+ const line = end >= 40 ? flat.slice(0, end) : flat;
56
+ return line.length > max ? line.slice(0, max - 1).trimEnd() + '…' : line;
57
+ }
58
+ /**
59
+ * What one doc costs to open.
60
+ *
61
+ * DERIVED from the published byte count, never declared. `craft-capabilities`
62
+ * used to carry this as a hand-typed table of four doc names, which is a fact
63
+ * stated twice — and the corpus is lopsided enough (3.7 KB to 71 KB) that
64
+ * getting it wrong costs an agent a five-figure token read on a hunch.
65
+ */
66
+ export function kbWeight(bytes) {
67
+ if (!bytes)
68
+ return { label: '', kb: '' };
69
+ const kb = `${Math.round(bytes / 1024)} KB`;
70
+ if (bytes >= 30 * 1024)
71
+ return { label: 'heavy — search it', kb };
72
+ if (bytes >= 12 * 1024)
73
+ return { label: 'a sitting', kb };
74
+ return { label: 'one read', kb };
75
+ }
76
+ /** Display order of the doc families: the authoring on-ramp first, grammar last. */
77
+ const GROUP_ORDER = ['craft', 'module-guide', 'reference'];
78
+ const GROUP_HEADING = {
79
+ craft: {
80
+ title: 'Craft guides — how to build WELL',
81
+ blurb: 'Author altitude, opinionated, and where the traps are. Each is small enough to read whole. '
82
+ + 'Start here for any "how should I…" question.',
83
+ },
84
+ 'module-guide': {
85
+ title: 'Module guides — one module end to end',
86
+ blurb: 'The deep companion to a declaration catalog. Reach for one when a craft guide has named the '
87
+ + 'module and you need everything it can do.',
88
+ },
89
+ reference: {
90
+ title: 'Reference — what EXISTS',
91
+ blurb: 'Grammar and contracts, pinned to platform source. Exhaustive rather than instructive: reach '
92
+ + 'for one when you need a rule no single catalog entry can give you.',
93
+ },
94
+ };
95
+ /**
96
+ * Build `INDEX.md` — the map an authoring agent reads FIRST, and the only one of
97
+ * the three meant to be read rather than searched.
98
+ */
99
+ export function buildKbIndexMarkdown(bundle, catalogEntryCounts) {
100
+ const index = bundle.index ?? [];
101
+ const docs = index.filter(e => e.kind === 'doc');
102
+ const catalogs = index.filter(e => e.kind === 'catalog');
103
+ const grouped = docs.some(d => d.group);
104
+ const L = [];
105
+ L.push('# Octwin platform capability reference — INDEX');
106
+ L.push('');
107
+ L.push(`Reference version ${bundle.version ?? '?'} · content_hash \`${bundle.content_hash ?? '?'}\` · pulled ${bundle.generated_at ?? '?'}`);
108
+ L.push('');
109
+ L.push('**Everything the platform supports is here.** If a step, function, field, or render intent is');
110
+ L.push('NOT in this reference, it does not exist for a pure-YAML pack. Do not fill a gap from memory.');
111
+ L.push('');
112
+ L.push('Three maps sit beside the reference, and they answer different questions:');
113
+ L.push('');
114
+ L.push('| You have | Open |');
115
+ L.push('|---|---|');
116
+ L.push('| a question (*"how do I take a deposit?"*) | **this file** — pick the guide, then read it |');
117
+ L.push('| a name (`record_list`, `active_only`, `$coalesce`, `collect:`) | **`SYMBOLS.md`** — grep it; every name maps to its exact file |');
118
+ L.push('| a heavy doc and one subject inside it | **`OUTLINE.md`** — every heading with its line number; read from there |');
119
+ L.push('');
120
+ // The reading route, straight from the index — no second hand-maintained copy.
121
+ const route = docs.filter(d => d.read_order != null).sort((a, b) => a.read_order - b.read_order);
122
+ if (route.length) {
123
+ L.push('## Read in this order');
124
+ L.push('');
125
+ L.push('Before authoring anything. Everything else is reached on demand.');
126
+ L.push('');
127
+ for (const d of route) {
128
+ const w = kbWeight(d.bytes);
129
+ L.push(`${d.read_order}. \`${d.key}.md\`${w.kb ? ` *(${w.kb})*` : ''} — ${kbOneLiner(d.summary, 150)}`);
130
+ }
131
+ L.push('');
132
+ }
133
+ L.push('## Guides & reference docs');
134
+ L.push('');
135
+ const renderDocTable = (rows) => {
136
+ L.push('| Doc | Read it for | File | Size |');
137
+ L.push('|---|---|---|---|');
138
+ for (const d of rows) {
139
+ const w = kbWeight(d.bytes);
140
+ const size = w.kb ? `${w.kb} · ${w.label}` : '';
141
+ L.push(`| ${d.title ?? d.key} | ${kbOneLiner(d.summary).replace(/\|/g, '\\|')} | \`${d.key}.md\` | ${size} |`);
142
+ }
143
+ L.push('');
144
+ };
145
+ if (grouped) {
146
+ for (const g of GROUP_ORDER) {
147
+ const rows = docs.filter(d => d.group === g);
148
+ if (!rows.length)
149
+ continue;
150
+ const h = GROUP_HEADING[g];
151
+ L.push(`### ${h.title}`);
152
+ L.push('');
153
+ L.push(h.blurb);
154
+ L.push('');
155
+ renderDocTable(rows);
156
+ }
157
+ // Anything the platform grouped in a way this CLI does not know about still
158
+ // gets printed — an unknown family must never silently drop a doc.
159
+ const known = new Set(GROUP_ORDER);
160
+ const rest = docs.filter(d => !d.group || !known.has(d.group));
161
+ if (rest.length) {
162
+ L.push('### Other');
163
+ L.push('');
164
+ renderDocTable(rest);
165
+ }
166
+ }
167
+ else {
168
+ renderDocTable(docs);
169
+ }
170
+ L.push('## Catalogs — exact machine-readable schemas');
171
+ L.push('');
172
+ L.push('One file per entry. **Never read a whole catalog** — grep `SYMBOLS.md` for the name and open');
173
+ L.push('the single file it points at (a primitive is a ~600-token read).');
174
+ L.push('');
175
+ L.push('| Catalog | What is in it | Entries | Path |');
176
+ L.push('|---|---|---|---|');
177
+ for (const c of catalogs) {
178
+ const n = catalogEntryCounts.get(c.key) ?? 0;
179
+ const path = n ? `\`${c.key}/<name>.json\`` : `\`${c.key}.json\``;
180
+ L.push(`| ${c.title ?? c.key} | ${kbOneLiner(c.summary, 200).replace(/\|/g, '\\|')} | ${n || '—'} | ${path} |`);
181
+ }
182
+ L.push('');
183
+ return L.join('\n');
184
+ }
185
+ /**
186
+ * Build `OUTLINE.md`, or `null` when no doc published an outline (an older
187
+ * platform) — an empty map would read as "these docs have no sections".
188
+ */
189
+ export function buildKbOutlineMarkdown(bundle) {
190
+ const docs = (bundle.index ?? []).filter(e => e.kind === 'doc' && e.sections?.length);
191
+ if (!docs.length)
192
+ return null;
193
+ const L = [];
194
+ L.push('# Octwin platform capability reference — OUTLINE');
195
+ L.push('');
196
+ L.push(`Every heading in every doc, with its line number. content_hash \`${bundle.content_hash ?? '?'}\`.`);
197
+ L.push('');
198
+ L.push('**For reading INTO a doc, not instead of one.** Find the section, then read that file from its');
199
+ L.push('line — `dsl.md` is ~18k tokens whole and ~900 for the one section you needed.');
200
+ L.push('');
201
+ // Heaviest first: the outline earns its keep on exactly those docs.
202
+ const ordered = [...docs].sort((a, b) => (b.bytes ?? 0) - (a.bytes ?? 0));
203
+ for (const d of ordered) {
204
+ const w = kbWeight(d.bytes);
205
+ L.push(`## \`${d.key}.md\`${w.kb ? ` — ${w.kb}` : ''}`);
206
+ L.push('');
207
+ for (const s of d.sections) {
208
+ const indent = s.level >= 3 ? ' ' : '';
209
+ L.push(`${indent}- **L${s.line}** ${s.title}`);
210
+ }
211
+ L.push('');
212
+ }
213
+ return L.join('\n');
214
+ }
@@ -68,6 +68,23 @@ export function lookupKbSubdir(packDir, subdir, isUsable) {
68
68
  }
69
69
  return { state: 'ok', dir };
70
70
  }
71
+ /**
72
+ * Is this filename one of the catalog's ENTRIES?
73
+ *
74
+ * An exploded catalog directory holds one `<entry>.json` per member — and, since
75
+ * 2026-08-09, may also hold `_catalog.json`: the envelope's catalog-LEVEL content,
76
+ * which the explode used to discard. `system-entities/_catalog.json` carries the
77
+ * reserved-key rules, which belong to the whole set and to no single entity.
78
+ *
79
+ * Every loader in this package iterates `*.json` over one of those directories, so
80
+ * without this guard `_catalog.json` is read as an entry: an entity named
81
+ * `_catalog` with no fields, a primitive with no `inputSchema`, a render intent
82
+ * with no `allowed_keys`. The `_` prefix is the convention; this is the one place
83
+ * that decides it.
84
+ */
85
+ export function isEntryFile(name) {
86
+ return name.endsWith('.json') && !name.startsWith('_');
87
+ }
71
88
  /** The line to print when a check could not run. Same wording for all three. */
72
89
  export function describeKbLookup(l, what) {
73
90
  return l.state === 'absent'
@@ -0,0 +1,271 @@
1
+ /**
2
+ * The symbol router — every addressable name in the platform, in one greppable
3
+ * file, next to the exact path that defines it.
4
+ *
5
+ * ## The gap this closes
6
+ *
7
+ * `INDEX.md` answers "what docs and catalogs exist". It does not answer the
8
+ * question an authoring agent actually has, which arrives as a bare NAME: a
9
+ * `do:` step in a flow it is editing, an argument in an error message, a YAML key
10
+ * a customer asked about. Today that name is resolved by opening `INDEX.md`
11
+ * (~8k tokens), guessing a catalog, and grepping — and names that are not entry
12
+ * names at all (`active_only`, `fits_minutes`, `title_field`, a port name, a node
13
+ * op-key) appear in NO index, only in prose spread across 38 docs.
14
+ *
15
+ * `SYMBOLS.md` is one line per name → the exact file. One grep, one answer, and
16
+ * a name that is absent from it does not exist — which is the closure rule the
17
+ * skill already states but had no procedure for.
18
+ *
19
+ * ## Why this lives in the CLI and not the platform
20
+ *
21
+ * Every row is DERIVED from catalogs the pull has already written to disk. There
22
+ * is nothing here the platform knows and the CLI does not, so publishing it as a
23
+ * ninth catalog would have grown the bundle (and moved `content_hash`) to ship a
24
+ * pure function of the other eight.
25
+ *
26
+ * The extractors DO encode a little shape knowledge — that a primitive's args
27
+ * live under `inputSchema.properties`, that an intent's fields are `allowed_keys`.
28
+ * That is the same coupling `args-check.ts` and `render-check.ts` already carry,
29
+ * and it is handled the same way: every extractor is individually defensive and
30
+ * returns NOTHING for a payload it does not recognise. A catalog whose shape
31
+ * moves loses its rows from the router; it never breaks the pull.
32
+ */
33
+ const obj = (v) => v && typeof v === 'object' && !Array.isArray(v) ? v : null;
34
+ const keysOf = (v) => Object.keys(obj(v) ?? {});
35
+ const str = (v) => (typeof v === 'string' && v.trim() ? v.trim() : undefined);
36
+ /** Clamp a summary to one scannable line — a table cell, not a paragraph. */
37
+ function line(text, max = 110) {
38
+ const s = str(text);
39
+ if (!s)
40
+ return undefined;
41
+ const flat = s.replace(/\s+/g, ' ');
42
+ return flat.length > max ? `${flat.slice(0, max - 1).trimEnd()}…` : flat;
43
+ }
44
+ const EXTRACTORS = {
45
+ primitives(entry, file) {
46
+ const v = obj(entry.value);
47
+ if (!v)
48
+ return [];
49
+ const out = [{ symbol: entry.name, kind: 'primitive', file, summary: line(entry.summary) }];
50
+ // Arguments — the class of name that used to appear in no index at all. An
51
+ // author reading `args: { order: … }` in their own flow has no other way to
52
+ // learn the argument is called `sort`.
53
+ for (const arg of keysOf(obj(v.inputSchema)?.properties)) {
54
+ out.push({ symbol: `${entry.name}.${arg}`, kind: 'primitive-arg', owner: entry.name, file });
55
+ }
56
+ // Output ports — what a `do:` node's `outputs:` switch may name.
57
+ for (const port of keysOf(v.portSchemas)) {
58
+ out.push({ symbol: `${entry.name}:${port}`, kind: 'primitive-port', owner: entry.name, file });
59
+ }
60
+ return out;
61
+ },
62
+ 'render-intents'(entry, file) {
63
+ const v = obj(entry.value);
64
+ if (!v)
65
+ return [];
66
+ const out = [{ symbol: entry.name, kind: 'render-intent', file, summary: line(entry.summary) }];
67
+ const allowed = Array.isArray(v.allowed_keys) ? v.allowed_keys : keysOf(v.fields);
68
+ for (const k of allowed) {
69
+ if (typeof k !== 'string')
70
+ continue;
71
+ const required = obj(obj(v.fields)?.[k])?.required === true;
72
+ out.push({
73
+ symbol: `${entry.name}.${k}`, kind: 'render-intent-field', owner: entry.name, file,
74
+ summary: required ? 'required' : undefined,
75
+ });
76
+ }
77
+ return out;
78
+ },
79
+ builtins(entry, file) {
80
+ const v = obj(entry.value);
81
+ // `$` is how an author writes it, and how they will grep for it.
82
+ return [{
83
+ symbol: `$${entry.name}`, kind: 'builtin', file,
84
+ summary: line(str(v?.signature) ?? entry.summary),
85
+ }];
86
+ },
87
+ templates(entry, file) {
88
+ const v = obj(entry.value);
89
+ const out = [{ symbol: entry.name, kind: 'template', file, summary: line(entry.summary) }];
90
+ const params = Array.isArray(v?.params) ? v.params : [];
91
+ for (const p of params) {
92
+ const name = str(obj(p)?.name);
93
+ if (!name)
94
+ continue;
95
+ out.push({
96
+ symbol: `${entry.name}.${name}`, kind: 'template-param', owner: entry.name, file,
97
+ summary: obj(p)?.required === true ? 'required' : undefined,
98
+ });
99
+ }
100
+ return out;
101
+ },
102
+ declarations(entry, file) {
103
+ const v = obj(entry.value);
104
+ if (!v)
105
+ return [];
106
+ // The symbol is the FILE the author writes (`xrm.yaml`), not the catalog key.
107
+ const declFile = str(v.file) ?? `${entry.name}.yaml`;
108
+ const out = [{ symbol: declFile, kind: 'declaration', file, summary: line(entry.summary) }];
109
+ const schema = obj(v.schema);
110
+ const defs = obj(schema?.$defs) ?? {};
111
+ /** Follow one `$ref` into `$defs` — deeper chains are not worth the router's weight. */
112
+ const deref = (node) => {
113
+ const n = obj(node);
114
+ if (!n)
115
+ return null;
116
+ const ref = str(n.$ref);
117
+ if (!ref)
118
+ return n;
119
+ const name = ref.split('/').pop();
120
+ return name ? obj(defs[name]) : null;
121
+ };
122
+ for (const top of keysOf(schema?.properties)) {
123
+ out.push({ symbol: `${declFile}:${top}`, kind: 'declaration-key', owner: declFile, file });
124
+ // One level deeper, through a `$ref` and through an array's `items` — that
125
+ // is where the keys authors actually mistype live (`entities.*.title_field`).
126
+ const node = deref(obj(schema?.properties)?.[top]);
127
+ const inner = deref(node?.items) ?? deref(obj(node?.additionalProperties)) ?? node;
128
+ for (const sub of keysOf(inner?.properties)) {
129
+ out.push({ symbol: `${declFile}:${top}.${sub}`, kind: 'declaration-key', owner: declFile, file });
130
+ }
131
+ }
132
+ return out;
133
+ },
134
+ 'system-entities'(entry, file) {
135
+ const v = obj(entry.value);
136
+ if (!v)
137
+ return [];
138
+ const out = [{
139
+ symbol: entry.name, kind: 'system-entity', file,
140
+ summary: line(str(v.description) ?? entry.summary) ?? 'reserved entity key',
141
+ }];
142
+ for (const f of keysOf(v.fields)) {
143
+ out.push({ symbol: `${entry.name}.${f}`, kind: 'system-entity-field', owner: entry.name, file });
144
+ }
145
+ return out;
146
+ },
147
+ channels(entry, file) {
148
+ const v = obj(entry.value);
149
+ const out = [{ symbol: entry.name, kind: 'channel', file, summary: line(entry.summary) }];
150
+ for (const cap of keysOf(v?.caps)) {
151
+ out.push({ symbol: `${entry.name}.$caps.${cap}`, kind: 'channel-cap', owner: entry.name, file });
152
+ }
153
+ return out;
154
+ },
155
+ };
156
+ /**
157
+ * Node op-keys from the flow JSON Schema — the one catalog that is a single
158
+ * document rather than a list, and the one holding names (`collect`, `dispatch`,
159
+ * `foreach`) that appear in no entry file anywhere.
160
+ *
161
+ * Each `FlowNode.anyOf` alternative is one node kind: its `required[0]` is the
162
+ * op-key, its sibling `properties` are that node's modifiers.
163
+ */
164
+ export function flowSchemaSymbols(schema, file) {
165
+ const alts = obj(obj(obj(schema)?.definitions)?.FlowNode)?.anyOf;
166
+ if (!Array.isArray(alts))
167
+ return [];
168
+ const out = [];
169
+ for (const alt of alts) {
170
+ const a = obj(alt);
171
+ const req = Array.isArray(a?.required) ? a.required : [];
172
+ const op = str(req[0]);
173
+ if (!op)
174
+ continue;
175
+ out.push({ symbol: `${op}:`, kind: 'node-op', file, summary: line(str(a?.description)) });
176
+ for (const mod of keysOf(a?.properties)) {
177
+ if (mod === op)
178
+ continue;
179
+ out.push({ symbol: `${op}:.${mod}`, kind: 'node-modifier', owner: `${op}:`, file });
180
+ }
181
+ }
182
+ return out;
183
+ }
184
+ /**
185
+ * Link a symbol to the prose section that explains it.
186
+ *
187
+ * Matching is deliberately narrow: a section counts only when the symbol appears
188
+ * **in its heading**, backticked. Scanning bodies would attach half the corpus to
189
+ * `record_list`, and a router that points everywhere points nowhere. A symbol
190
+ * with no heading-level home simply carries no link — the entry file is still
191
+ * exact, which is what the row is for.
192
+ */
193
+ export function linkExplainers(symbols, docSections) {
194
+ // symbol → `<docKey>#<anchor>`, first match wins in index order.
195
+ const byHeading = new Map();
196
+ for (const [docKey, sections] of docSections) {
197
+ for (const s of sections) {
198
+ for (const m of s.title.matchAll(/`([^`]+)`/g)) {
199
+ const name = m[1].trim().replace(/:$/, '').replace(/^\$/, '$');
200
+ if (!name || byHeading.has(name))
201
+ continue;
202
+ byHeading.set(name, `${docKey}#${s.anchor}`);
203
+ }
204
+ }
205
+ }
206
+ for (const sym of symbols) {
207
+ if (sym.owner)
208
+ continue; // members inherit their owner's file; a link would be noise
209
+ const hit = byHeading.get(sym.symbol) ?? byHeading.get(sym.symbol.replace(/:$/, ''));
210
+ if (hit)
211
+ sym.explains = hit;
212
+ }
213
+ }
214
+ /**
215
+ * Build the full symbol table from the catalogs the pull enumerated.
216
+ *
217
+ * `exploded` is catalogKey → entries; `files` maps an entry back to the path it
218
+ * was written to. `flat` carries the catalogs that were NOT exploded, so the flow
219
+ * schema still contributes.
220
+ */
221
+ export function buildSymbols(exploded, fileFor, flat) {
222
+ const out = [];
223
+ for (const [catalogKey, entries] of exploded) {
224
+ const extract = EXTRACTORS[catalogKey];
225
+ if (!extract)
226
+ continue; // a catalog this CLI has no extractor for: skipped, never guessed at
227
+ for (const entry of entries) {
228
+ try {
229
+ out.push(...extract(entry, fileFor(catalogKey, entry.name)));
230
+ }
231
+ catch { /* one malformed entry must not cost the whole router */ }
232
+ }
233
+ }
234
+ if (flat['flow-schema'] != null) {
235
+ out.push(...flowSchemaSymbols(flat['flow-schema'], 'flow-schema.json'));
236
+ }
237
+ // Stable, and sorted the way a human scans: by symbol, case-insensitively.
238
+ out.sort((a, b) => a.symbol.toLowerCase().localeCompare(b.symbol.toLowerCase()) || a.symbol.localeCompare(b.symbol));
239
+ return out;
240
+ }
241
+ /** Render `SYMBOLS.md` — a grep target first, a readable table second. */
242
+ export function renderSymbolsMarkdown(symbols, contentHash) {
243
+ const byKind = new Map();
244
+ for (const s of symbols)
245
+ byKind.set(s.kind, (byKind.get(s.kind) ?? 0) + 1);
246
+ const L = [];
247
+ L.push('# Octwin platform capability reference — SYMBOLS');
248
+ L.push('');
249
+ L.push(`Every addressable name in the platform, and the exact file that defines it. content_hash \`${contentHash ?? '?'}\`.`);
250
+ L.push('');
251
+ L.push('**Grep this file; do not read it.** Search the name you have — a `do:` step, an argument from an');
252
+ L.push('error, a YAML key, a `$function`, a node op-key — and open the file in its row.');
253
+ L.push('');
254
+ L.push('**A name that is not in this table does not exist for a pure-YAML pack.** Not "is undocumented":');
255
+ L.push('does not exist. Do not fill the gap from memory.');
256
+ L.push('');
257
+ L.push('Naming: `owner.member` is an argument or field of `owner`; `owner:port` is an output port;');
258
+ L.push('`file.yaml:key` is a key in a declaration file; `op:` is a flow node op-key.');
259
+ L.push('');
260
+ L.push(`${symbols.length} symbols — ${[...byKind].sort().map(([k, n]) => `${n} ${k}`).join(' · ')}`);
261
+ L.push('');
262
+ L.push('| Symbol | Kind | Defined in | Explained in |');
263
+ L.push('|---|---|---|---|');
264
+ for (const s of symbols) {
265
+ const cell = (v) => v.replace(/\|/g, '\\|');
266
+ const note = s.summary ? ` — ${cell(s.summary)}` : '';
267
+ L.push(`| \`${cell(s.symbol)}\` | ${s.kind}${note} | \`${s.file}\` | ${s.explains ? `\`${s.explains}\`` : ''} |`);
268
+ }
269
+ L.push('');
270
+ return L.join('\n');
271
+ }
@@ -22,7 +22,7 @@
22
22
  */
23
23
  import { readdirSync, readFileSync } from 'node:fs';
24
24
  import { join } from 'node:path';
25
- import { lookupKbSubdir } from './kb-path.js';
25
+ import { lookupKbSubdir, isEntryFile } from './kb-path.js';
26
26
  /**
27
27
  * Load `render_intent -> allowed_keys` from the pulled KB.
28
28
  *
@@ -30,14 +30,14 @@ import { lookupKbSubdir } from './kb-path.js';
30
30
  * "checked, nothing wrong" from "could not check". See `kb-path.ts`.
31
31
  */
32
32
  export function loadAllowedRenderKeys(packDir) {
33
- const lookup = lookupKbSubdir(packDir, 'render-intents', dir => readdirSync(dir).some(f => f.endsWith('.json')));
33
+ const lookup = lookupKbSubdir(packDir, 'render-intents', dir => readdirSync(dir).some(isEntryFile));
34
34
  if (lookup.state !== 'ok')
35
35
  return { lookup, keys: null };
36
36
  const dir = lookup.dir;
37
37
  const out = new Map();
38
38
  try {
39
39
  for (const file of readdirSync(dir)) {
40
- if (!file.endsWith('.json'))
40
+ if (!isEntryFile(file))
41
41
  continue;
42
42
  const entry = JSON.parse(readFileSync(join(dir, file), 'utf8'));
43
43
  if (entry.render_intent && Array.isArray(entry.allowed_keys)) {
@@ -61,9 +61,9 @@ export function loadAllowedRenderKeys(packDir) {
61
61
  */
62
62
  export function findRenderKeyViolations(doc, file, allowedByIntent) {
63
63
  const findings = [];
64
- const walk = (node) => {
64
+ const walk = (node, path) => {
65
65
  if (Array.isArray(node)) {
66
- node.forEach(walk);
66
+ node.forEach((v, i) => walk(v, [...path, i]));
67
67
  return;
68
68
  }
69
69
  if (!node || typeof node !== 'object')
@@ -72,26 +72,27 @@ export function findRenderKeyViolations(doc, file, allowedByIntent) {
72
72
  if (typeof obj.render_intent === 'string') {
73
73
  const allowed = allowedByIntent.get(obj.render_intent);
74
74
  if (!allowed) {
75
- findings.push({ file, intent: obj.render_intent, keys: [], allowed: [...allowedByIntent.keys()] });
75
+ findings.push({ file, path: [...path], intent: obj.render_intent, keys: [], allowed: [...allowedByIntent.keys()] });
76
76
  }
77
77
  else {
78
78
  const bad = Object.keys(obj).filter(k => !allowed.includes(k));
79
79
  if (bad.length)
80
- findings.push({ file, intent: obj.render_intent, keys: bad, allowed });
80
+ findings.push({ file, path: [...path], intent: obj.render_intent, keys: bad, allowed });
81
81
  }
82
82
  }
83
- for (const v of Object.values(obj))
84
- walk(v);
83
+ for (const [k, v] of Object.entries(obj))
84
+ walk(v, [...path, k]);
85
85
  };
86
- walk(doc);
86
+ walk(doc, []);
87
87
  return findings;
88
88
  }
89
89
  /** One-line human message per finding. */
90
90
  export function describeRenderFinding(f) {
91
+ const at = f.line != null ? `:${f.line}` : (f.path.length ? ` (${f.path.join('.')})` : '');
91
92
  if (f.keys.length === 0) {
92
- return `${f.file}: unknown render_intent '${f.intent}' — known intents: ${f.allowed.join(', ')}`;
93
+ return `${f.file}${at}: unknown render_intent '${f.intent}' — known intents: ${f.allowed.join(', ')}`;
93
94
  }
94
95
  const plural = f.keys.length === 1 ? 'field' : 'fields';
95
- return `${f.file}: render_intent '${f.intent}' has unknown ${plural} ${f.keys.map(k => `'${k}'`).join(', ')} ` +
96
+ return `${f.file}${at}: render_intent '${f.intent}' has unknown ${plural} ${f.keys.map(k => `'${k}'`).join(', ')} ` +
96
97
  `— silently dropped at render. Allowed: ${f.allowed.join(', ')}`;
97
98
  }