Skip to content

Commit 00792eb

Browse files
committed
fix(misc): unify JSON extraction to handle NDJSON, multi-line, and noisy buffers
extractJsonObject only finds one pretty-printed multi-line JSON blob and uses lastIndexOf('}'), missing NDJSON-with-noise streams that the bin will emit once CLOUD-4496 lands. extractJsonPayloads parses all top-level JSON objects from a stdout buffer — handles NDJSON, pretty-printed multi-line, mixed shapes, and interleaved non-JSON noise. Top-level boundary = `{` or `}` at column 0; indented inner braces stay part of their parent object. Spec covers all shapes plus malformed and truncated input. NXC-4401
1 parent e5fc13d commit 00792eb

3 files changed

Lines changed: 257 additions & 87 deletions

File tree

‎packages/create-nx-workspace/src/utils/nx/agentic-onboard.ts‎

Lines changed: 57 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -319,10 +319,12 @@ export async function runAgenticOnboard(
319319
let terminalResult: AgenticOnboardResult | null = null;
320320

321321
const handleLine = (line: string) => {
322-
const result = translateOnboardPayload(line);
323-
const parsed = safeParse(line);
324-
if (parsed) writeAiOutput(parsed as any);
325-
if (result) terminalResult = result;
322+
const parsed = tryParseObject(line);
323+
if (parsed) {
324+
writeAiOutput(parsed as any);
325+
const result = translateOnboardPayload(line);
326+
if (result) terminalResult = result;
327+
}
326328
};
327329

328330
child.stdout.on('data', (chunk: Buffer) => {
@@ -355,12 +357,10 @@ export async function runAgenticOnboard(
355357
if (lineBuf.trim()) handleLine(lineBuf);
356358

357359
if (!terminalResult && fullStdout.trim()) {
358-
const blob = extractJsonObject(fullStdout);
359-
if (blob) {
360-
const result = translateOnboardPayload(blob);
360+
for (const payload of extractJsonPayloads(fullStdout)) {
361+
const result = translateOnboardPayload(JSON.stringify(payload));
361362
if (result) {
362-
const parsed = safeParse(blob);
363-
if (parsed) writeAiOutput(parsed as any);
363+
writeAiOutput(payload as any);
364364
terminalResult = result;
365365
}
366366
}
@@ -385,29 +385,58 @@ export async function runAgenticOnboard(
385385
});
386386
}
387387

388-
function safeParse(line: string): Record<string, unknown> | null {
389-
const trimmed = line.trim();
390-
if (!trimmed) return null;
391-
try {
392-
const parsed = JSON.parse(trimmed);
393-
return typeof parsed === 'object' && parsed !== null ? parsed : null;
394-
} catch {
395-
return null;
388+
/**
389+
* Parse every top-level JSON object out of a stdout buffer.
390+
*
391+
* Handles three shapes from `nx-cloud onboard connect-workspace`:
392+
* - NDJSON: one parseable object per line (target after CLOUD-4496)
393+
* - Pretty-printed multi-line: `{` and matching `}` at column 0 (current bin)
394+
* - Either, with non-JSON noise lines interleaved (always possible — a stray
395+
* `output.note(...)` writing to stdout will leak through any contract)
396+
*
397+
* "Top-level" = line starts with `{` or `}` at column 0. Inner braces inside
398+
* a pretty-printed object are indented and ignored.
399+
*/
400+
export function extractJsonPayloads(
401+
text: string
402+
): Array<Record<string, unknown>> {
403+
const out: Array<Record<string, unknown>> = [];
404+
const lines = text.split(/\r?\n/);
405+
let i = 0;
406+
while (i < lines.length) {
407+
if (!lines[i].startsWith('{')) {
408+
i++;
409+
continue;
410+
}
411+
const single = tryParseObject(lines[i]);
412+
if (single) {
413+
out.push(single);
414+
i++;
415+
continue;
416+
}
417+
let j = i + 1;
418+
while (j < lines.length && !lines[j].startsWith('}')) j++;
419+
if (j < lines.length) {
420+
const multi = tryParseObject(lines.slice(i, j + 1).join('\n'));
421+
if (multi) {
422+
out.push(multi);
423+
i = j + 1;
424+
continue;
425+
}
426+
}
427+
i++;
396428
}
429+
return out;
397430
}
398431

399-
/**
400-
* Slice the JSON object out of a buffer that may contain human-readable noise
401-
* before it. Workaround for CLOUD-4496 — drop once the bin emits clean `--json`.
402-
*/
403-
export function extractJsonObject(text: string): string | null {
404-
const start = text.indexOf('{');
405-
const end = text.lastIndexOf('}');
406-
if (start === -1 || end < start) return null;
407-
const candidate = text.slice(start, end + 1);
432+
function tryParseObject(text: string): Record<string, unknown> | null {
433+
const trimmed = text.trim();
434+
if (!trimmed) return null;
408435
try {
409-
JSON.parse(candidate);
410-
return candidate;
436+
const v = JSON.parse(trimmed);
437+
return typeof v === 'object' && v !== null && !Array.isArray(v)
438+
? (v as Record<string, unknown>)
439+
: null;
411440
} catch {
412441
return null;
413442
}

‎packages/nx/src/command-line/nx-cloud/onboard/agentic-onboard.spec.ts‎

Lines changed: 139 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
import { extractJsonObject, translateOnboardPayload } from './agentic-onboard';
1+
import {
2+
extractJsonPayloads,
3+
translateOnboardPayload,
4+
} from './agentic-onboard';
25

36
describe('translateOnboardPayload', () => {
47
it('returns null for empty or whitespace lines', () => {
@@ -232,47 +235,157 @@ describe('translateOnboardPayload', () => {
232235
});
233236
});
234237

235-
describe('extractJsonObject', () => {
236-
it('returns null when there is no JSON object', () => {
237-
expect(extractJsonObject('')).toBeNull();
238-
expect(extractJsonObject('just human text, no braces')).toBeNull();
238+
describe('extractJsonPayloads', () => {
239+
it('returns [] for empty or whitespace-only input', () => {
240+
expect(extractJsonPayloads('')).toEqual([]);
241+
expect(extractJsonPayloads(' \n\n ')).toEqual([]);
239242
});
240243

241-
it('extracts a flat JSON object from a clean buffer', () => {
242-
expect(extractJsonObject('{"a":1}')).toBe('{"a":1}');
244+
it('returns [] when there is no JSON', () => {
245+
expect(extractJsonPayloads('just human text, no braces')).toEqual([]);
246+
expect(
247+
extractJsonPayloads(
248+
' NX Updating nx.json\n\nNo JSON in this output anywhere.'
249+
)
250+
).toEqual([]);
243251
});
244252

245-
it('extracts the JSON blob when preceded by human-readable text', () => {
246-
const buf = `
247-
NX Updating nx.json with Nx Cloud ID
253+
// NDJSON case (target shape after CLOUD-4496).
254+
it('parses a single NDJSON line', () => {
255+
expect(extractJsonPayloads('{"stage":"configuring"}')).toEqual([
256+
{ stage: 'configuring' },
257+
]);
258+
});
259+
260+
it('parses multiple NDJSON lines in order', () => {
261+
const buf = [
262+
'{"stage":"configuring","message":"Connecting..."}',
263+
'{"success":true,"workspace":{"id":"w","nxCloudId":"abc"},"configWritten":true}',
264+
].join('\n');
265+
expect(extractJsonPayloads(buf)).toEqual([
266+
{ stage: 'configuring', message: 'Connecting...' },
267+
{
268+
success: true,
269+
workspace: { id: 'w', nxCloudId: 'abc' },
270+
configWritten: true,
271+
},
272+
]);
273+
});
248274

249-
Your nx.json has been updated to use an Nx Cloud ID for authentication.
275+
it('parses NDJSON with non-JSON noise interleaved', () => {
276+
const buf = [
277+
' NX Some bin output',
278+
'{"stage":"configuring"}',
279+
'plain text leak',
280+
'{"success":true,"workspace":{"nxCloudId":"abc"}}',
281+
'',
282+
].join('\n');
283+
expect(extractJsonPayloads(buf)).toEqual([
284+
{ stage: 'configuring' },
285+
{ success: true, workspace: { nxCloudId: 'abc' } },
286+
]);
287+
});
250288

251-
{
289+
// Multi-line pretty-printed case (current bin via CLOUD-4496 workaround).
290+
it('parses a single multi-line pretty-printed JSON object', () => {
291+
const buf = `{
252292
"success": true,
253293
"workspace": {
254294
"id": "abc",
255295
"nxCloudId": "abc123"
256296
},
257297
"configWritten": true
258298
}`;
259-
const blob = extractJsonObject(buf);
260-
expect(JSON.parse(blob!)).toEqual({
261-
success: true,
262-
workspace: { id: 'abc', nxCloudId: 'abc123' },
263-
configWritten: true,
264-
});
299+
expect(extractJsonPayloads(buf)).toEqual([
300+
{
301+
success: true,
302+
workspace: { id: 'abc', nxCloudId: 'abc123' },
303+
configWritten: true,
304+
},
305+
]);
265306
});
266307

267-
it('ignores braces inside JSON string values', () => {
268-
expect(extractJsonObject('{"msg":"hello {world}","ok":true}')).toBe(
269-
'{"msg":"hello {world}","ok":true}'
270-
);
308+
it('parses multi-line JSON preceded by human-readable noise', () => {
309+
const buf = `
310+
NX Updating nx.json with Nx Cloud ID
311+
312+
Your nx.json has been updated.
313+
314+
{
315+
"success": true,
316+
"workspace": { "nxCloudId": "abc" },
317+
"configWritten": true
318+
}`;
319+
expect(extractJsonPayloads(buf)).toEqual([
320+
{
321+
success: true,
322+
workspace: { nxCloudId: 'abc' },
323+
configWritten: true,
324+
},
325+
]);
271326
});
272327

273-
it('handles escaped quotes inside strings without losing brace tracking', () => {
274-
expect(extractJsonObject('{"a":"a \\"quoted\\" }","b":1}')).toBe(
275-
'{"a":"a \\"quoted\\" }","b":1}'
276-
);
328+
it('parses multi-line JSON followed by trailing noise', () => {
329+
const buf = `{
330+
"success": true,
331+
"workspace": { "nxCloudId": "abc" }
332+
}
333+
NX Some trailing message`;
334+
expect(extractJsonPayloads(buf)).toEqual([
335+
{ success: true, workspace: { nxCloudId: 'abc' } },
336+
]);
337+
});
338+
339+
// Mixed shapes — multi-line followed by NDJSON, etc.
340+
it('parses a mix of multi-line JSON and NDJSON in the same buffer', () => {
341+
const buf = `{"stage":"configuring"}
342+
{
343+
"success": true,
344+
"workspace": { "nxCloudId": "abc" }
345+
}
346+
{"trailing":"event"}`;
347+
expect(extractJsonPayloads(buf)).toEqual([
348+
{ stage: 'configuring' },
349+
{ success: true, workspace: { nxCloudId: 'abc' } },
350+
{ trailing: 'event' },
351+
]);
352+
});
353+
354+
// Robustness against JSON content that itself contains braces.
355+
it('treats indented inner braces as part of the multi-line object', () => {
356+
// The inner `{`/`}` are indented (column > 0), so they don't terminate.
357+
const buf = `{
358+
"workspace": {
359+
"nested": { "deep": true }
360+
}
361+
}`;
362+
expect(extractJsonPayloads(buf)).toEqual([
363+
{ workspace: { nested: { deep: true } } },
364+
]);
365+
});
366+
367+
it('handles braces inside JSON string values (single-line)', () => {
368+
expect(extractJsonPayloads('{"msg":"hello {world}","ok":true}')).toEqual([
369+
{ msg: 'hello {world}', ok: true },
370+
]);
371+
});
372+
373+
// Failure modes — should not throw, just return [].
374+
it('skips truncated multi-line JSON (no closing })', () => {
375+
const buf = `{
376+
"success": true,
377+
"workspace": { "nxCloudId": "abc" }`;
378+
expect(extractJsonPayloads(buf)).toEqual([]);
379+
});
380+
381+
it('skips malformed JSON without crashing', () => {
382+
expect(extractJsonPayloads('{not valid json}')).toEqual([]);
383+
expect(extractJsonPayloads('{\n "success": true,\n bad,\n}')).toEqual([]);
384+
});
385+
386+
it('rejects non-object top-level values (arrays, primitives)', () => {
387+
// Bin only emits objects; arrays/strings would be a contract violation.
388+
expect(extractJsonPayloads('[1,2,3]')).toEqual([]);
389+
expect(extractJsonPayloads('"just a string"')).toEqual([]);
277390
});
278391
});

0 commit comments

Comments
 (0)