diff --git a/src/lib/detection/__tests__/agentic.test.ts b/src/lib/detection/__tests__/agentic.test.ts index a6a8c764d..86903a0c3 100644 --- a/src/lib/detection/__tests__/agentic.test.ts +++ b/src/lib/detection/__tests__/agentic.test.ts @@ -1,5 +1,7 @@ import { + AgentOutputParseError, coerceAgenticReport, + extractJson, manifestGlob, resolveProjectDir, } from '@lib/detection/agentic'; @@ -110,6 +112,44 @@ describe('coerceAgenticReport', () => { }); }); +describe('extractJson', () => { + const report = + '{"repoType":"monorepo","projects":[{"path":"apps/web","framework":"Next.js"}]}'; + + it('reads the object out of bare, fenced, and prose-wrapped output', () => { + expect(extractJson(report)).toMatchObject({ repoType: 'monorepo' }); + expect(extractJson('```json\n' + report + '\n```')).toMatchObject({ + repoType: 'monorepo', + }); + expect(extractJson(`Here it is:\n${report}\nDone.`)).toMatchObject({ + repoType: 'monorepo', + }); + }); + + it('balances braces instead of reaching for the last one', () => { + // Trailing prose with a closing brace used to be swallowed into the slice. + expect(extractJson(`${report}\nAll set }`)).toMatchObject({ + repoType: 'monorepo', + }); + }); + + it('reports truncation instead of a raw SyntaxError when the object is cut short', () => { + // Single-line output cut mid-`projects`: the last `}` closes a nested + // project, so a lastIndexOf-based slice ends inside the array. + const truncated = + '{"repoType":"monorepo","projects":[{"path":"apps/web","framework":"Next.js"},{"path":"apps/api"'; + expect(() => extractJson(truncated)).toThrow(AgentOutputParseError); + expect(() => extractJson(truncated)).toThrow(/truncated/i); + }); + + it('reports malformed JSON and missing objects as parse errors', () => { + expect(() => extractJson('{"repoType":"monorepo",}')).toThrow( + AgentOutputParseError, + ); + expect(() => extractJson('no json here')).toThrow(AgentOutputParseError); + }); +}); + describe('resolveProjectDir', () => { it('scopes to the chosen sub-app inside the repo', () => { expect(resolveProjectDir('/repo', 'apps/web')).toBe('/repo/apps/web'); diff --git a/src/lib/detection/__tests__/project-scope.test.ts b/src/lib/detection/__tests__/project-scope.test.ts index e9a9036c6..86b0e41d3 100644 --- a/src/lib/detection/__tests__/project-scope.test.ts +++ b/src/lib/detection/__tests__/project-scope.test.ts @@ -1,4 +1,5 @@ import { + AgentOutputParseError, detectProjectsWithAgent, type AgenticProject, } from '@lib/detection/agentic'; @@ -181,6 +182,26 @@ describe('scopeInstallDirToProject', () => { }); }); + it('fires error without an exception when the agent output was truncated', async () => { + // Unparseable agent output is expected input, not a wizard bug — it must + // not show up in error tracking, but the outcome still has to be recorded. + flagsSpy.mockResolvedValue(FLAG_ON); + scan.mockRejectedValue( + new AgentOutputParseError( + 'Agent output was truncated before the JSON object ended', + ), + ); + const session = buildSession({ installDir: '/repo' }); + await scopeInstallDirToProject(session); + + expect(session.installDir).toBe('/repo'); + expect(outcomeEvent()).toMatchObject({ + outcome: 'error', + error_message: 'Agent output was truncated before the JSON object ended', + }); + expect(exceptionSpy).not.toHaveBeenCalled(); + }); + it('leaves the session untouched and fires timeout when the scan outruns the budget', async () => { // The scan is abandoned, not cancelled — the run must not wait on it forever. vi.useFakeTimers(); diff --git a/src/lib/detection/agentic.ts b/src/lib/detection/agentic.ts index 301a4a31c..ddbc2bd15 100644 --- a/src/lib/detection/agentic.ts +++ b/src/lib/detection/agentic.ts @@ -165,15 +165,72 @@ function buildPrompt( ].join('\n'); } -function extractJson(text: string): unknown { +/** + * The agent's output didn't contain a usable JSON report. Expected input, not a + * wizard bug: callers report the outcome without also raising an exception. + */ +export class AgentOutputParseError extends Error { + constructor(message: string) { + super(message); + this.name = 'AgentOutputParseError'; + } +} + +/** + * Index of the `}` closing the object that opens at `start`, or -1 when the + * text ends first (the agent's output was cut off mid-object). + * + * Braces and brackets are balanced rather than reaching for the last `}`: + * with truncated single-line JSON the last `}` belongs to a nested project + * object, so the slice would end inside the `projects` array and JSON.parse + * would throw a raw SyntaxError. + */ +function findObjectEnd(text: string, start: number): number { + const stack: string[] = []; + let inString = false; + let escaped = false; + for (let i = start; i < text.length; i++) { + const char = text[i]; + if (inString) { + if (escaped) escaped = false; + else if (char === '\\') escaped = true; + else if (char === '"') inString = false; + continue; + } + if (char === '"') { + inString = true; + } else if (char === '{' || char === '[') { + stack.push(char); + } else if (char === '}' || char === ']') { + if (stack.pop() !== (char === '}' ? '{' : '[')) return -1; + if (stack.length === 0) return i; + } + } + return -1; +} + +export function extractJson(text: string): unknown { const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i); const candidate = fenced ? fenced[1] : text; const start = candidate.indexOf('{'); - const end = candidate.lastIndexOf('}'); - if (start === -1 || end === -1 || end < start) { - throw new Error('Agent did not return a JSON object'); + if (start === -1) { + throw new AgentOutputParseError('Agent did not return a JSON object'); + } + const end = findObjectEnd(candidate, start); + if (end === -1) { + throw new AgentOutputParseError( + 'Agent output was truncated before the JSON object ended', + ); + } + try { + return JSON.parse(candidate.slice(start, end + 1)); + } catch (err) { + throw new AgentOutputParseError( + `Agent returned malformed JSON: ${ + err instanceof Error ? err.message : String(err) + }`, + ); } - return JSON.parse(candidate.slice(start, end + 1)); } /** diff --git a/src/lib/detection/project-scope.ts b/src/lib/detection/project-scope.ts index fc3462e98..376612e39 100644 --- a/src/lib/detection/project-scope.ts +++ b/src/lib/detection/project-scope.ts @@ -1,6 +1,7 @@ /** Non-interactive project scoping — scan the repo agentically and pick which project the run integrates. */ import { + AgentOutputParseError, detectProjectsWithAgent, resolveProjectDir, type AgenticDetectionReport, @@ -96,7 +97,11 @@ export async function scopeInstallDirToProject( ]); } catch (err) { const error = err instanceof Error ? err : new Error(String(err)); - analytics.captureException(error, { step: 'agentic_detection' }); + // Unparseable/truncated agent output is expected input, not a wizard bug — + // the outcome event records it, so it doesn't also need to be an exception. + if (!(error instanceof AgentOutputParseError)) { + analytics.captureException(error, { step: 'agentic_detection' }); + } captureOutcome('error', { duration_ms: Date.now() - startedAt, error_message: error.message, diff --git a/src/lib/warehouse-sources/__tests__/detect.test.ts b/src/lib/warehouse-sources/__tests__/detect.test.ts index d504766e0..164a87278 100644 --- a/src/lib/warehouse-sources/__tests__/detect.test.ts +++ b/src/lib/warehouse-sources/__tests__/detect.test.ts @@ -49,6 +49,26 @@ describe('detectWarehouseSources', () => { expect(detectWarehouseSources(tmpDir)).toEqual([]); }); + it('still reads deps from a package.json with comments and trailing commas', () => { + // JSONC-style manifests exist in the wild; a strict parse used to drop + // every npm signal for the project. + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + '{\n // our db driver\n "dependencies": { "pg": "^8.0.0", },\n}\n', + ); + expect(kinds(tmpDir)).toEqual(['Postgres']); + }); + + it('skips an unparseable package.json without throwing', () => { + fs.writeFileSync(path.join(tmpDir, 'package.json'), 'not json at all'); + expect(detectWarehouseSources(tmpDir)).toEqual([]); + }); + + it('skips a package.json holding a non-object', () => { + fs.writeFileSync(path.join(tmpDir, 'package.json'), '"pg"'); + expect(detectWarehouseSources(tmpDir)).toEqual([]); + }); + it('detects Postgres from an npm driver dependency', () => { writePackageJson(tmpDir, { pg: '^8.0.0' }); expect(kinds(tmpDir)).toEqual(['Postgres']); diff --git a/src/lib/warehouse-sources/detect.ts b/src/lib/warehouse-sources/detect.ts index b153115c3..dd126ab10 100644 --- a/src/lib/warehouse-sources/detect.ts +++ b/src/lib/warehouse-sources/detect.ts @@ -9,7 +9,7 @@ * Note we only read `.env` KEY NAMES, never values. */ -import { analytics } from '@utils/analytics'; +import * as jsonc from 'jsonc-parser'; import { walkProjectFiles, safeReadFile } from '@utils/bounded-fs'; import type { PackageJson } from '@utils/package-json'; import { @@ -147,20 +147,20 @@ function ingestorFor(name: string): Ingestor | null { } function addNpmDeps(content: string, signals: ProjectSignals): void { - try { - const pkg = JSON.parse(content) as PackageJson; - for (const dep of Object.keys({ - ...pkg.dependencies, - ...pkg.devDependencies, - })) { - addCapped(signals.npm, dep); - } - } catch (error) { - // Malformed package.json — skip it, but record that we hit it. - analytics.captureException( - error instanceof Error ? error : new Error(String(error)), - { step: 'detectWarehouseSources.parsePackageJson' }, - ); + // Tolerant parse: the walk hands us every file named package.json in the + // tree, and some carry comments or trailing commas. A strict JSON.parse + // throws on those, so the project silently loses all its npm signals. + const pkg = jsonc.parse(content, undefined, { + allowTrailingComma: true, + disallowComments: false, + }) as PackageJson | undefined; + if (typeof pkg !== 'object' || pkg === null) return; + + for (const dep of Object.keys({ + ...pkg.dependencies, + ...pkg.devDependencies, + })) { + addCapped(signals.npm, dep); } }