@namzu/sdk 15.0.0 → 15.1.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,240 @@
1
+ /**
2
+ * The one frontmatter reader.
3
+ *
4
+ * A `SKILL.md`, a command file, and anything else this kernel reads from a
5
+ * markdown file with a `---` fence all come through here. There were three
6
+ * readers before this one and two of them disagreed on the same input: this
7
+ * one **threw** on malformed frontmatter, and a second **silently returned no
8
+ * metadata**, so the same file was a hard error in one code path and a skill
9
+ * named after its own directory with "(no description)" in the other. Refuse
10
+ * versus degrade, on one file shape, is the divergence this module exists to
11
+ * end — see `docs/conventions/refuse-do-not-degrade.md`.
12
+ *
13
+ * **Deliberately not a YAML parser.** It is a flat key/value splitter with one
14
+ * level of nesting, and it refuses the constructs in {@link UNSUPPORTED_YAML}
15
+ * rather than mangling them. That refusal is the design: a reader that
16
+ * half-understands YAML produces a value that passes validation and means
17
+ * nothing.
18
+ *
19
+ * **Known gap, stated rather than implied.** The refusal is not yet total. A
20
+ * *block* sequence
21
+ *
22
+ * ```yaml
23
+ * allowed-tools:
24
+ * - Read
25
+ * ```
26
+ *
27
+ * is silently dropped — its lines carry no `:` and are skipped — while the
28
+ * flow form `[Read, Grep]` throws. Both readers this replaced behaved that
29
+ * way, so it is inherited rather than introduced, but it contradicts
30
+ * `docs/conventions/refuse-do-not-degrade.md` and is tracked as a follow-up.
31
+ * Do not read the paragraph above as a guarantee it does not make.
32
+ *
33
+ * **Vocabulary belongs to the caller.** This returns the parsed map; it does
34
+ * not know what a skill needs or what a command needs, and it validates no
35
+ * field names. Widening one caller's metadata type to cover another's is how a
36
+ * skill-shaped API comes to mean something it does not.
37
+ */
38
+
39
+ /**
40
+ * A closing fence is a line of its own, not `---` wherever it appears.
41
+ *
42
+ * An unanchored search found `---` inside a quoted value, inside a URL, inside
43
+ * prose — and cut the frontmatter there, which both truncated the metadata AND
44
+ * spilled the rest of it into the body, where it reaches the system prompt
45
+ * verbatim.
46
+ *
47
+ * `\r?` is explicit rather than incidental. `$` under `/m` already matches
48
+ * before a `\r` because JavaScript counts `\r` as a line terminator, so this
49
+ * pattern worked on CRLF by accident before it worked on purpose. Naming the
50
+ * carriage return keeps the next edit from removing a property nobody knew was
51
+ * being relied on — a file authored on Windows is the ordinary case, not the
52
+ * exotic one.
53
+ */
54
+ const FRONTMATTER_FENCE = /^---[ \t]*\r?$/m
55
+
56
+ const FRONTMATTER_DELIMITER = '---'
57
+
58
+ /**
59
+ * Splits on any of the three line endings — CRLF, LF, and a lone CR.
60
+ *
61
+ * The `\r?\n` half is defence in depth and was measured as such: reducing it to
62
+ * `/\n/` fails no test, because {@link normalizeScalar} trims the stray `\r`
63
+ * off every value anyway. The fence is the load-bearing half for CRLF, and that
64
+ * one has a mutation profile.
65
+ *
66
+ * The lone `\r` is not decoration. Without it a CR-only file is one single
67
+ * "line", and the whole frontmatter collapses into the first key: `name` came
68
+ * back as `"a-skill\rdescription: d"` — a *wrong value*, silently, which is the
69
+ * failure this module exists to end.
70
+ *
71
+ * `loadSkill` was accidentally protected, though not in the way first written
72
+ * here: the collapse leaves no `description` key at all, so the required-field
73
+ * check refused the file before any value could be used. A caller that
74
+ * validates nothing — which is every caller this is now exported for — would
75
+ * have taken the mangled name.
76
+ */
77
+ const LINE_SPLIT = /\r\n|\r|\n/
78
+
79
+ /**
80
+ * YAML this reader does not implement, refused rather than mangled.
81
+ *
82
+ * The documented contract says "YAML frontmatter" with no restriction — so an
83
+ * author has every reason to write a block scalar or a flow sequence, and no
84
+ * reason to expect what happened next. A `description: >-` followed by an
85
+ * indented paragraph produced the literal string `">-"`, which passed
86
+ * validation and registered with no warning; the skill then existed and was
87
+ * never selected, because its description said nothing. A `[Read, Grep]`
88
+ * became that literal text and was interpolated straight into the prompt.
89
+ *
90
+ * Refusing names the line and the file. That is worse for exactly one file —
91
+ * the one already silently broken — and better for everyone looking for it.
92
+ */
93
+ const UNSUPPORTED_YAML = [
94
+ { pattern: /^[>|][-+]?\s*$/, what: 'a block scalar (`>` or `|`)' },
95
+ { pattern: /^\[.*\]$/, what: 'a flow sequence (`[a, b]`)' },
96
+ { pattern: /^\{.*\}$/, what: 'a flow mapping (`{a: b}`)' },
97
+ ] as const
98
+
99
+ /**
100
+ * What one frontmatter key holds: a scalar, or a block of indented pairs.
101
+ *
102
+ * A discriminated union rather than two parallel maps, because the source
103
+ * format cannot express both at once. The first shape of this type had
104
+ * `data: Record<string, string>` beside `blocks: Record<string, Record<…>>`,
105
+ * which let one key sit in both — a state no YAML file can produce. Every
106
+ * caller would then have had to decide a precedence for a case that cannot
107
+ * arrive, and the ones who did not would be carrying a latent bug against a
108
+ * shape that told them the case existed. Removing the state beats documenting
109
+ * it.
110
+ */
111
+ export type FrontmatterValue =
112
+ | { readonly kind: 'scalar'; readonly value: string }
113
+ | { readonly kind: 'mapping'; readonly entries: Readonly<Record<string, string>> }
114
+
115
+ export interface ParsedFrontmatter {
116
+ /**
117
+ * Every top-level key, in the order the file declared it.
118
+ *
119
+ * A key whose value is empty and which has no indented lines under it is
120
+ * absent: it declared nothing. Narrow on `kind` to read it —
121
+ *
122
+ * ```ts
123
+ * const d = values.description
124
+ * if (d?.kind !== 'scalar') throw new Error('description must be a scalar')
125
+ * use(d.value)
126
+ * ```
127
+ */
128
+ readonly values: Readonly<Record<string, FrontmatterValue>>
129
+
130
+ /** Everything after the closing fence, trimmed. */
131
+ readonly body: string
132
+ }
133
+
134
+ /**
135
+ * Parse a markdown file's `---` frontmatter.
136
+ *
137
+ * @param raw The file's full contents. LF and CRLF both parse.
138
+ * @param source A label for error messages — a path, or a phrase naming the
139
+ * file. Used verbatim, so the caller controls how its own errors read.
140
+ * @throws If the frontmatter is absent, unclosed, or uses YAML this reader
141
+ * does not implement. It never returns a partial or empty result to stand in
142
+ * for a file it could not read.
143
+ */
144
+ export function parseFrontmatter(raw: string, source: string): ParsedFrontmatter {
145
+ const trimmed = raw.trimStart()
146
+
147
+ if (!trimmed.startsWith(FRONTMATTER_DELIMITER)) {
148
+ throw new Error(`${source} has no YAML frontmatter`)
149
+ }
150
+
151
+ const closing = FRONTMATTER_FENCE.exec(trimmed.slice(FRONTMATTER_DELIMITER.length))
152
+ if (!closing) {
153
+ throw new Error(`${source} has unclosed frontmatter`)
154
+ }
155
+
156
+ const endIdx = FRONTMATTER_DELIMITER.length + closing.index
157
+ const frontmatterRaw = trimmed.slice(FRONTMATTER_DELIMITER.length, endIdx).trim()
158
+ const body = trimmed.slice(endIdx + closing[0].length).trim()
159
+
160
+ // `Map`, not an object literal, because the keys come from an untrusted
161
+ // file. `blocks[key] = …` on a plain object with `key === '__proto__'`
162
+ // reaches `Object.prototype` through the inheritance chain and writes
163
+ // **there** — a frontmatter file could set `Object.prototype.metadata` and
164
+ // poison every object in the process. That is not theoretical: it was
165
+ // caught here by an adversarial pass, and the poisoned prototype then
166
+ // showed up in the metadata of an unrelated skill loaded afterwards.
167
+ // A `Map` has no prototype chain for string keys, and `Object.fromEntries`
168
+ // *defines* own properties rather than assigning through setters, so the
169
+ // round trip is safe at both ends.
170
+ const data = new Map<string, string>()
171
+ const blocks = new Map<string, Map<string, string>>()
172
+ let currentKey: string | undefined
173
+
174
+ for (const line of frontmatterRaw.split(LINE_SPLIT)) {
175
+ if (!line.trim() || line.trimStart().startsWith('#')) continue
176
+
177
+ if (/^\s/.test(line)) {
178
+ if (!currentKey) continue
179
+ const colonIdx = line.indexOf(':')
180
+ if (colonIdx === -1) continue
181
+ const key = line.slice(0, colonIdx).trim()
182
+ const value = normalizeScalar(line.slice(colonIdx + 1))
183
+ if (!key || !value) continue
184
+ let block = blocks.get(currentKey)
185
+ if (!block) {
186
+ block = new Map<string, string>()
187
+ blocks.set(currentKey, block)
188
+ }
189
+ block.set(key, value)
190
+ continue
191
+ }
192
+
193
+ const colonIdx = line.indexOf(':')
194
+ if (colonIdx === -1) continue
195
+ const key = line.slice(0, colonIdx).trim()
196
+ const value = normalizeScalar(line.slice(colonIdx + 1))
197
+
198
+ assertReadableScalar(key, value, source)
199
+
200
+ currentKey = key
201
+ if (value) data.set(key, value)
202
+ }
203
+
204
+ // A key cannot be a scalar and a mapping at once — no YAML file can say
205
+ // that — so refusing here is what makes the illegal state unrepresentable
206
+ // in the returned type rather than merely undocumented. The alternative,
207
+ // picking a precedence, would silently drop half of what the author wrote.
208
+ for (const key of blocks.keys()) {
209
+ if (!data.has(key)) continue
210
+ throw new Error(
211
+ `${source}: "${key}" has both a value and an indented block. A key is one or the other — remove the value, or un-indent the lines beneath it.`,
212
+ )
213
+ }
214
+
215
+ const values = new Map<string, FrontmatterValue>()
216
+ for (const [key, value] of data) {
217
+ values.set(key, { kind: 'scalar', value })
218
+ }
219
+ for (const [key, entries] of blocks) {
220
+ values.set(key, { kind: 'mapping', entries: Object.fromEntries(entries) })
221
+ }
222
+
223
+ return { values: Object.fromEntries(values), body }
224
+ }
225
+
226
+ function normalizeScalar(value: string): string {
227
+ return value
228
+ .trim()
229
+ .replace(/^["']|["']$/g, '')
230
+ .trim()
231
+ }
232
+
233
+ function assertReadableScalar(key: string, value: string, source: string): void {
234
+ for (const { pattern, what } of UNSUPPORTED_YAML) {
235
+ if (!pattern.test(value)) continue
236
+ throw new Error(
237
+ `${source}: "${key}" uses ${what}, which this reader does not support. Write it as a single-line value instead. Refusing rather than accepting a "${key}" that would read as ${JSON.stringify(value)}.`,
238
+ )
239
+ }
240
+ }