Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions src/lib/detection/__tests__/agentic.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import {
AgentOutputParseError,
coerceAgenticReport,
extractJson,
manifestGlob,
resolveProjectDir,
} from '@lib/detection/agentic';
Expand Down Expand Up @@ -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');
Expand Down
21 changes: 21 additions & 0 deletions src/lib/detection/__tests__/project-scope.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
AgentOutputParseError,
detectProjectsWithAgent,
type AgenticProject,
} from '@lib/detection/agentic';
Expand Down Expand Up @@ -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();
Expand Down
67 changes: 62 additions & 5 deletions src/lib/detection/agentic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}

/**
Expand Down
7 changes: 6 additions & 1 deletion src/lib/detection/project-scope.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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,
Expand Down
20 changes: 20 additions & 0 deletions src/lib/warehouse-sources/__tests__/detect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
Expand Down
30 changes: 15 additions & 15 deletions src/lib/warehouse-sources/detect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
}
}

Expand Down
Loading