flecto 3.1.0 → 4.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.
package/src/mcp.js ADDED
@@ -0,0 +1,487 @@
1
+ import { spawnSync } from 'child_process';
2
+ import { existsSync, realpathSync } from 'fs';
3
+ import { isAbsolute, resolve, sep } from 'path';
4
+
5
+ /**
6
+ * `flecto mcp` — a read-only Model Context Protocol server over stdio (#140).
7
+ *
8
+ * The argument for it is the one the CLI cannot make on its own: an agent asked
9
+ * to debug a config incident reads the whole file into context to learn that
10
+ * `pool_size` doubled. Flecto already computes that small answer; this hands it
11
+ * to the agent as a structured tool result instead of a screen-scrape.
12
+ *
13
+ * ## Why it is built as a translator over the CLI
14
+ *
15
+ * Every tool here runs the *same* read-only `flecto ci --format json` path a
16
+ * pull request triggers, as a subprocess, and returns its JSON envelope. That is
17
+ * deliberate, and it is the whole security posture:
18
+ *
19
+ * - **Read-only by construction.** A tool can only reach what `ci` reaches. It
20
+ * never passes `--command`, `--pr-comment-post`, `--baseline`,
21
+ * `--update-baseline`, `--output`, or `--plugins`, so there is no argument by
22
+ * which an agent-supplied value becomes a write or a shell command. That is
23
+ * GHSA-wq8m-fc3q-8m5x's lesson generalized: a tool an agent can invoke must
24
+ * not be able to execute a shell command. Nor can a value smuggle one of those
25
+ * options in: files follow a `--`, values ride as `--name=value`, and a file
26
+ * argument starting with `-` is refused before anything spawns.
27
+ * - **Plugins stay off**, regardless of `FLECTO_ALLOW_RC_PLUGINS` in the
28
+ * environment — the runner strips it from the child, because model-supplied
29
+ * arguments are untrusted input by definition and an rc-declared plugin is
30
+ * code.
31
+ * - **Path containment** is enforced twice: `assertSafeTargetArg` refuses a
32
+ * traversal in a tool argument before anything spawns, and the CLI then
33
+ * applies its own symlink-escape check on every resolved target
34
+ * (`FLECTO_ALLOW_SYMLINK_TARGETS` is stripped from the child, so it cannot be
35
+ * switched off).
36
+ * - **Masking is inverted from the CLI**: on by default here, because the
37
+ * consumer is a model context that is transmitted to a provider and very often
38
+ * logged on the way. The opt-out is explicit (`mask: false`) and documented as
39
+ * a disclosure.
40
+ *
41
+ * The seam is the JSON envelope (`schema_version`), which is already Flecto's
42
+ * versioned machine-facing contract. When this moves to its own `flecto-mcp`
43
+ * package, only {@link makeCliRunner} changes — it locates the `flecto` binary
44
+ * from `node_modules` instead of being handed this repo's `index.js`. The
45
+ * protocol layer, the tool schemas, the validation, and the bounding all move
46
+ * verbatim. Nothing here imports Flecto's internals, so there is no private API
47
+ * to freeze first.
48
+ */
49
+
50
+ /** The MCP revision advertised when the client names none. */
51
+ export const DEFAULT_PROTOCOL_VERSION = '2025-06-18';
52
+
53
+ /** A result never returns more than this many changes or findings per file. */
54
+ export const MAX_ITEMS = 500;
55
+
56
+ /**
57
+ * The three read-only tools, exactly the sketch in #140. `inputSchema` is JSON
58
+ * Schema, which is what an MCP client renders and validates against.
59
+ */
60
+ export const TOOLS = [
61
+ {
62
+ name: 'flecto_diff',
63
+ description:
64
+ 'Semantic changes to one config file against a baseline (a git ref, default HEAD, '
65
+ + 'or a path-shaped snapshot file). Returns the meaningful diff — the small answer — not the file. '
66
+ + 'Secret-like values are masked by default.',
67
+ inputSchema: {
68
+ type: 'object',
69
+ properties: {
70
+ file: { type: 'string', description: 'Path to the config file, relative to the working directory.' },
71
+ ref: {
72
+ type: 'string',
73
+ description: 'Baseline to diff against: a git revision (default "HEAD"), or a snapshot file named as a path (absolute, ./ or ../).',
74
+ },
75
+ mask: {
76
+ type: 'boolean',
77
+ description: 'Mask secret-like values (default true). Set false only when the caller accepts disclosing them.',
78
+ },
79
+ },
80
+ required: ['file'],
81
+ additionalProperties: false,
82
+ },
83
+ },
84
+ {
85
+ name: 'flecto_check',
86
+ description:
87
+ 'Policy findings for one or more config files (or globs), evaluated over their changes '
88
+ + 'against HEAD. Optionally restrict to named policy packs. Secret-like values are masked by default.',
89
+ inputSchema: {
90
+ type: 'object',
91
+ properties: {
92
+ files: {
93
+ type: 'array',
94
+ items: { type: 'string' },
95
+ description: 'Config file paths or globs, relative to the working directory.',
96
+ },
97
+ packs: {
98
+ type: 'array',
99
+ items: { type: 'string' },
100
+ description: 'Policy pack ids to evaluate (default: the packs configured in .flectorc).',
101
+ },
102
+ mask: { type: 'boolean', description: 'Mask secret-like values (default true).' },
103
+ },
104
+ required: ['files'],
105
+ additionalProperties: false,
106
+ },
107
+ },
108
+ {
109
+ name: 'flecto_explain',
110
+ description:
111
+ 'What changed at one configuration path in a file (e.g. "database.pool_size"), against a '
112
+ + 'baseline (default HEAD): the before/after value and any policy findings that touch it. '
113
+ + 'Secret-like values are masked by default.',
114
+ inputSchema: {
115
+ type: 'object',
116
+ properties: {
117
+ file: { type: 'string', description: 'Path to the config file, relative to the working directory.' },
118
+ path: { type: 'string', description: 'The configuration path to explain, in dot/index notation.' },
119
+ ref: { type: 'string', description: 'Baseline to diff against: a git revision (default "HEAD"), or a snapshot file named as a path (absolute, ./ or ../).' },
120
+ mask: { type: 'boolean', description: 'Mask secret-like values (default true).' },
121
+ },
122
+ required: ['file', 'path'],
123
+ additionalProperties: false,
124
+ },
125
+ },
126
+ ];
127
+
128
+ /**
129
+ * Refuse a target argument that escapes the working directory before it is ever
130
+ * spawned. Globs are allowed (the CLI resolves and contains each match); a `..`
131
+ * segment or an absolute path outside `cwd` is not.
132
+ *
133
+ * Nor is a leading `-`. A file argument lands on the `ci` command line, and one
134
+ * spelled `--plugins=./p.mjs` or `--update-baseline` would be parsed as that
135
+ * option — code execution and a write from a single tool call. `ciArgs` already
136
+ * ends options with `--` before any file; this refuses the shape outright too, so
137
+ * the guarantee does not rest on one line of argv ordering.
138
+ * @param {unknown} arg
139
+ * @param {string} cwd
140
+ * @returns {string} the argument, when it is safe
141
+ */
142
+ export function assertSafeTargetArg(arg, cwd) {
143
+ if (typeof arg !== 'string' || arg === '') {
144
+ throw new Error('a file argument must be a non-empty string');
145
+ }
146
+ if (arg.includes('\0')) throw new Error('a file argument must not contain a NUL byte');
147
+ if (arg.startsWith('-')) {
148
+ throw new Error(`"${arg}" starts with "-" and would be read as a CLI option; it is refused`);
149
+ }
150
+ const segments = arg.split(/[\\/]/);
151
+ if (segments.includes('..')) {
152
+ throw new Error(`"${arg}" escapes the working directory ("..") and is refused`);
153
+ }
154
+ if (isAbsolute(arg)) {
155
+ const resolved = resolve(arg);
156
+ const root = resolve(cwd);
157
+ if (resolved !== root && !resolved.startsWith(root + sep)) {
158
+ throw new Error(`"${arg}" is outside the working directory and is refused`);
159
+ }
160
+ }
161
+ return arg;
162
+ }
163
+
164
+ /** The real path, following links; the lexical one when it cannot be resolved. */
165
+ function canonicalPath(path) {
166
+ try {
167
+ return realpathSync(path);
168
+ } catch {
169
+ return resolve(path);
170
+ }
171
+ }
172
+
173
+ /**
174
+ * A ref must be a plain value, not another flag and not a control character —
175
+ * it is passed to the CLI as the value of `--snapshot-ref`.
176
+ *
177
+ * It is also a *path*: `ci` reads a ref that names an existing file as a
178
+ * snapshot, resolved against the working directory. Uncontained, `ref:
179
+ * "/elsewhere/creds.json"` diffed that file and returned its values to the
180
+ * agent, and a non-JSON file leaked its opening bytes through the parse error.
181
+ * So a ref naming anything on disk gets the containment a file argument gets,
182
+ * checked on the real path so an in-repo symlink cannot point it outward.
183
+ * @param {unknown} ref
184
+ * @param {string} cwd
185
+ * @returns {string}
186
+ */
187
+ function assertSafeRef(ref, cwd) {
188
+ if (typeof ref !== 'string' || ref === '') throw new Error('ref must be a non-empty string');
189
+ if (ref.startsWith('-')) throw new Error(`ref "${ref}" must not start with "-"`);
190
+ if (/[\0\n\r]/.test(ref)) throw new Error('ref must not contain a newline or NUL byte');
191
+ const asPath = resolve(cwd, ref);
192
+ if (existsSync(asPath)) {
193
+ const real = canonicalPath(asPath);
194
+ const root = canonicalPath(cwd);
195
+ if (real !== root && !real.startsWith(root + sep)) {
196
+ throw new Error(`ref "${ref}" names a file outside the working directory and is refused`);
197
+ }
198
+ }
199
+ return ref;
200
+ }
201
+
202
+ /**
203
+ * Cap a list, reporting how much was withheld so a bounded result never claims
204
+ * to be the whole answer.
205
+ * @template T
206
+ * @param {T[]} items
207
+ * @param {number} [cap]
208
+ * @returns {{ items: T[], total: number, omitted: number, truncated: boolean }}
209
+ */
210
+ export function bound(items, cap = MAX_ITEMS) {
211
+ const list = Array.isArray(items) ? items : [];
212
+ const kept = list.slice(0, cap);
213
+ return { items: kept, total: list.length, omitted: list.length - kept.length, truncated: list.length > kept.length };
214
+ }
215
+
216
+ /**
217
+ * Build the argv for a read-only `ci` run. This is the *only* place tool inputs
218
+ * become CLI arguments, so the read-only guarantee is auditable in one function:
219
+ * nothing here can emit a write, a webhook, a plugin path, or `--command`.
220
+ *
221
+ * That holds only if no tool input is *parsed* as an option. So every option is
222
+ * emitted first, agent-supplied values ride in `--name=value` form (never as a
223
+ * separate argument the parser could take for a flag), and `--` ends option
224
+ * parsing before the files, which are therefore always operands.
225
+ * @param {{ files: string[], ref?: string, packs?: string[], mask?: boolean }} spec
226
+ * @returns {string[]}
227
+ */
228
+ function ciArgs({ files, ref, packs, mask }) {
229
+ const args = ['ci', `--snapshot-ref=${ref ?? 'HEAD'}`, '--format', 'json', '--allow-empty'];
230
+ if (mask !== false) args.push('--mask-secrets');
231
+ if (packs && packs.length > 0) args.push(`--policies=${packs.join(',')}`);
232
+ args.push('--', ...files);
233
+ return args;
234
+ }
235
+
236
+ /**
237
+ * Parse `ci --format json` output into the per-file results. `ci` exits non-zero
238
+ * whenever it finds a change or a finding — that is its gate, not an error — so
239
+ * the exit code is ignored and the presence of parseable stdout is the signal.
240
+ * A genuine failure (no baseline, unreadable file) prints `[error] …` to stderr
241
+ * and leaves stdout empty, which surfaces as a tool error.
242
+ * @param {{ status: number | null, stdout: string, stderr: string }} run
243
+ * @returns {Array<{ file: string, envelope: any, policies: any[] }>}
244
+ */
245
+ function parseCiResults(run) {
246
+ const stdout = (run.stdout ?? '').trim();
247
+ if (!stdout) {
248
+ const detail = (run.stderr ?? '').trim() || `flecto exited ${run.status}`;
249
+ throw new Error(detail.replace(/^\[error\]\s*/, ''));
250
+ }
251
+ let parsed;
252
+ try {
253
+ parsed = JSON.parse(stdout);
254
+ } catch {
255
+ throw new Error(`could not parse flecto output: ${stdout.slice(0, 200)}`);
256
+ }
257
+ return Array.isArray(parsed) ? parsed : [parsed];
258
+ }
259
+
260
+ /** True when `changePath` is `target` or nested beneath it (`a.b`, `a[0]`). */
261
+ function pathMatches(changePath, target) {
262
+ if (changePath === target) return true;
263
+ return changePath.startsWith(`${target}.`) || changePath.startsWith(`${target}[`);
264
+ }
265
+
266
+ /* --------------------------------------------------------------- the tools */
267
+
268
+ /**
269
+ * @typedef {(args: string[]) => Promise<{ status: number | null, stdout: string, stderr: string }>} FlectoRunner
270
+ */
271
+
272
+ /** @type {Record<string, (input: any, ctx: { runFlecto: FlectoRunner, cwd: string }) => Promise<object>>} */
273
+ const HANDLERS = {
274
+ async flecto_diff(input, { runFlecto, cwd }) {
275
+ const file = assertSafeTargetArg(input?.file, cwd);
276
+ const ref = input?.ref === undefined ? 'HEAD' : assertSafeRef(input.ref, cwd);
277
+ const run = await runFlecto(ciArgs({ files: [file], ref, mask: input?.mask }));
278
+ const results = parseCiResults(run);
279
+ const result = results.find((r) => r.file?.endsWith(file)) ?? results[0];
280
+ const changes = bound(result?.envelope?.changes ?? []);
281
+ return {
282
+ tool: 'flecto_diff',
283
+ file,
284
+ ref,
285
+ masked: input?.mask !== false,
286
+ changeCount: changes.total,
287
+ changes: changes.items,
288
+ ...(changes.truncated ? { truncated: { changes: changes.omitted } } : {}),
289
+ policies: bound(result?.policies ?? []).items,
290
+ };
291
+ },
292
+
293
+ async flecto_check(input, { runFlecto, cwd }) {
294
+ if (!Array.isArray(input?.files) || input.files.length === 0) {
295
+ throw new Error('files must be a non-empty array');
296
+ }
297
+ const files = input.files.map((f) => assertSafeTargetArg(f, cwd));
298
+ const packs = Array.isArray(input?.packs) ? input.packs.map(String) : undefined;
299
+ const run = await runFlecto(ciArgs({ files, ref: 'HEAD', packs, mask: input?.mask }));
300
+ const results = parseCiResults(run);
301
+ const findings = results.flatMap((r) => (r.policies ?? []).map((finding) => ({ file: r.file, ...finding })));
302
+ const capped = bound(findings);
303
+ return {
304
+ tool: 'flecto_check',
305
+ files,
306
+ ...(packs ? { packs } : {}),
307
+ masked: input?.mask !== false,
308
+ findingCount: capped.total,
309
+ findings: capped.items,
310
+ ...(capped.truncated ? { truncated: { findings: capped.omitted } } : {}),
311
+ };
312
+ },
313
+
314
+ async flecto_explain(input, { runFlecto, cwd }) {
315
+ const file = assertSafeTargetArg(input?.file, cwd);
316
+ if (typeof input?.path !== 'string' || input.path === '') {
317
+ throw new Error('path must be a non-empty string');
318
+ }
319
+ const target = input.path;
320
+ const ref = input?.ref === undefined ? 'HEAD' : assertSafeRef(input.ref, cwd);
321
+ const run = await runFlecto(ciArgs({ files: [file], ref, mask: input?.mask }));
322
+ const results = parseCiResults(run);
323
+ const result = results.find((r) => r.file?.endsWith(file)) ?? results[0];
324
+ const changes = (result?.envelope?.changes ?? []).filter((c) => pathMatches(String(c.path ?? ''), target));
325
+ const findings = (result?.policies ?? []).filter((f) => pathMatches(String(f.path ?? ''), target));
326
+ return {
327
+ tool: 'flecto_explain',
328
+ file,
329
+ path: target,
330
+ ref,
331
+ masked: input?.mask !== false,
332
+ changed: changes.length > 0,
333
+ changes: bound(changes).items,
334
+ findings: bound(findings).items,
335
+ ...(changes.length === 0 ? { note: `No change at "${target}" against ${ref}.` } : {}),
336
+ };
337
+ },
338
+ };
339
+
340
+ /* ------------------------------------------------------------ JSON-RPC core */
341
+
342
+ const jsonrpcError = (id, code, message) => ({ jsonrpc: '2.0', id: id ?? null, error: { code, message } });
343
+ const jsonrpcResult = (id, result) => ({ jsonrpc: '2.0', id, result });
344
+
345
+ /**
346
+ * A dispatcher over parsed JSON-RPC messages, with no I/O of its own so it can
347
+ * be driven directly in tests. Returns the response object, or `null` for a
348
+ * notification (which gets none).
349
+ * @param {{ version: string, cwd: string, runFlecto: FlectoRunner }} ctx
350
+ */
351
+ export function createServer({ version, cwd, runFlecto }) {
352
+ let protocolVersion = DEFAULT_PROTOCOL_VERSION;
353
+
354
+ return {
355
+ /**
356
+ * @param {any} msg a parsed JSON-RPC message
357
+ * @returns {Promise<object | null>}
358
+ */
359
+ async handle(msg) {
360
+ if (!msg || typeof msg !== 'object' || msg.jsonrpc !== '2.0' || typeof msg.method !== 'string') {
361
+ return jsonrpcError(msg?.id, -32600, 'Invalid Request');
362
+ }
363
+ const { id, method, params } = msg;
364
+ const isNotification = id === undefined || id === null;
365
+
366
+ switch (method) {
367
+ case 'initialize': {
368
+ if (typeof params?.protocolVersion === 'string') protocolVersion = params.protocolVersion;
369
+ return jsonrpcResult(id, {
370
+ protocolVersion,
371
+ capabilities: { tools: {} },
372
+ serverInfo: { name: 'flecto', version },
373
+ });
374
+ }
375
+ case 'ping':
376
+ return jsonrpcResult(id, {});
377
+ case 'tools/list':
378
+ return jsonrpcResult(id, { tools: TOOLS });
379
+ case 'tools/call': {
380
+ const name = params?.name;
381
+ const handler = HANDLERS[name];
382
+ if (!handler) {
383
+ return jsonrpcResult(id, {
384
+ content: [{ type: 'text', text: `Unknown tool: ${String(name)}` }],
385
+ isError: true,
386
+ });
387
+ }
388
+ try {
389
+ const payload = await handler(params?.arguments ?? {}, { runFlecto, cwd });
390
+ return jsonrpcResult(id, { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] });
391
+ } catch (err) {
392
+ // A tool-level failure is returned as an error *result*, not a
393
+ // JSON-RPC error, so the model sees the reason and can adjust.
394
+ return jsonrpcResult(id, {
395
+ content: [{ type: 'text', text: `flecto ${name} failed: ${err.message}` }],
396
+ isError: true,
397
+ });
398
+ }
399
+ }
400
+ default:
401
+ // Notifications we do not act on (e.g. notifications/initialized) get
402
+ // no response, per JSON-RPC; unknown requests get method-not-found.
403
+ if (isNotification) return null;
404
+ return jsonrpcError(id, -32601, `Method not found: ${method}`);
405
+ }
406
+ },
407
+ };
408
+ }
409
+
410
+ /**
411
+ * The default runner: spawn the read-only `flecto` CLI. `FLECTO_ALLOW_RC_PLUGINS`,
412
+ * `FLECTO_ALLOW_RC_WRITES`, and `FLECTO_ALLOW_SYMLINK_TARGETS` are stripped from
413
+ * the child so none can be turned on for a tool call, whatever the environment
414
+ * holds.
415
+ * @param {{ nodeExec: string, cliPath: string, cwd: string }} opts
416
+ * @returns {FlectoRunner}
417
+ */
418
+ export function makeCliRunner({ nodeExec, cliPath, cwd }) {
419
+ return async (args) => {
420
+ const env = { ...process.env };
421
+ delete env.FLECTO_ALLOW_RC_PLUGINS;
422
+ delete env.FLECTO_ALLOW_RC_WRITES;
423
+ // The symlink-escape check is one of the two containment layers a tool
424
+ // call relies on, so the operator's opt-out does not carry into it either.
425
+ delete env.FLECTO_ALLOW_SYMLINK_TARGETS;
426
+ const run = spawnSync(nodeExec, [cliPath, ...args], {
427
+ cwd,
428
+ env,
429
+ encoding: 'utf8',
430
+ maxBuffer: 64 * 1024 * 1024,
431
+ });
432
+ return { status: run.status, stdout: run.stdout ?? '', stderr: run.stderr ?? '' };
433
+ };
434
+ }
435
+
436
+ /**
437
+ * Serve the MCP protocol over stdio: newline-delimited JSON-RPC in, the same
438
+ * out. All diagnostics go to stderr so stdout carries protocol only.
439
+ * @param {{ version: string, cwd?: string, runFlecto: FlectoRunner, input?: NodeJS.ReadableStream, output?: NodeJS.WritableStream, onLog?: (msg: string) => void }} opts
440
+ * @returns {Promise<void>} resolves when the input stream ends
441
+ */
442
+ export function runStdioServer({ version, cwd = process.cwd(), runFlecto, input = process.stdin, output = process.stdout, onLog = (m) => process.stderr.write(`${m}\n`) }) {
443
+ const server = createServer({ version, cwd, runFlecto });
444
+ let buffer = '';
445
+
446
+ const send = (message) => output.write(`${JSON.stringify(message)}\n`);
447
+
448
+ const processLine = async (line) => {
449
+ const trimmed = line.trim();
450
+ if (!trimmed) return;
451
+ let msg;
452
+ try {
453
+ msg = JSON.parse(trimmed);
454
+ } catch {
455
+ send(jsonrpcError(null, -32700, 'Parse error'));
456
+ return;
457
+ }
458
+ try {
459
+ const response = await server.handle(msg);
460
+ if (response) send(response);
461
+ } catch (err) {
462
+ onLog(`handler error: ${err.stack ?? err.message}`);
463
+ if (msg && msg.id !== undefined && msg.id !== null) {
464
+ send(jsonrpcError(msg.id, -32603, 'Internal error'));
465
+ }
466
+ }
467
+ };
468
+
469
+ return new Promise((resolveDone) => {
470
+ input.setEncoding('utf8');
471
+ // Lines are processed strictly in order: a chain of promises so a slow tool
472
+ // call cannot interleave its response with the next line's.
473
+ let chain = Promise.resolve();
474
+ input.on('data', (chunk) => {
475
+ buffer += chunk;
476
+ let index;
477
+ while ((index = buffer.indexOf('\n')) !== -1) {
478
+ const line = buffer.slice(0, index);
479
+ buffer = buffer.slice(index + 1);
480
+ chain = chain.then(() => processLine(line));
481
+ }
482
+ });
483
+ input.on('end', () => {
484
+ chain = chain.then(() => processLine(buffer)).then(() => resolveDone());
485
+ });
486
+ });
487
+ }
package/src/parser.js CHANGED
@@ -380,8 +380,28 @@ function documentKeys(docs) {
380
380
  */
381
381
  export function parseYamlStream(raw) {
382
382
  const docs = yaml.loadAll(raw).filter((doc) => doc != null);
383
+ const keys = yamlDocumentKeys(docs);
384
+ if (keys === null) return withDocumentKeys(docs[0], []);
383
385
 
384
- if (docs.length === 0) return withDocumentKeys({}, []);
386
+ /** @type {Record<string, unknown>} */
387
+ const out = {};
388
+ for (let i = 0; i < docs.length; i++) {
389
+ out[keys[i]] = docs[i];
390
+ }
391
+ return withDocumentKeys(out, keys);
392
+ }
393
+
394
+ /**
395
+ * How {@link parseYamlStream} lays out a stream: `null` when a lone document is
396
+ * returned bare, otherwise the key each document is stored under. The position
397
+ * index (positions.js) addresses documents through this same function, so a
398
+ * diagnostic can never disagree with the differ about which document a path is
399
+ * in.
400
+ * @param {unknown[]} docs the stream's non-empty documents
401
+ * @returns {string[] | null}
402
+ */
403
+ export function yamlDocumentKeys(docs) {
404
+ if (docs.length === 0) return [];
385
405
 
386
406
  // A lone document is normally returned bare, preserving ordinary YAML paths.
387
407
  // The exception is a Kubernetes manifest with a resolvable identity: keying it
@@ -389,21 +409,11 @@ export function parseYamlStream(raw) {
389
409
  // of re-pathing the whole file and reporting the untouched resource as
390
410
  // removed-and-re-added. Ordinary single-document config is unaffected.
391
411
  if (docs.length === 1) {
392
- const [doc] = docs;
393
- const identity = isKubernetesDocument(doc) ? documentIdentity(doc) : null;
394
- if (identity == null || identity === '__proto__') {
395
- return withDocumentKeys(doc, []);
396
- }
397
- return withDocumentKeys({ [identity]: doc }, [identity]);
412
+ const identity = isKubernetesDocument(docs[0]) ? documentIdentity(docs[0]) : null;
413
+ return identity == null || identity === '__proto__' ? null : [identity];
398
414
  }
399
415
 
400
- const keys = documentKeys(docs);
401
- /** @type {Record<string, unknown>} */
402
- const out = {};
403
- for (let i = 0; i < docs.length; i++) {
404
- out[keys[i]] = docs[i];
405
- }
406
- return withDocumentKeys(out, keys);
416
+ return documentKeys(docs);
407
417
  }
408
418
 
409
419
  /**