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
89 changes: 85 additions & 4 deletions src/lib/programs/__tests__/source-maps-detect-agentic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as os from 'os';
import * as path from 'path';
import {
coerceReport,
goSdkVerifier,
rustSdkVerifier,
SOURCE_MAPS_TARGETS,
} from '@lib/programs/error-tracking-upload-source-maps/detect-agentic';
Expand Down Expand Up @@ -535,29 +536,109 @@ describe('coerceReport', () => {
);
});

it('recognises Go but blocks it as not yet supported', () => {
it('retains a Go project with PostHog as instrumentable', () => {
const report = coerceReport({
repoType: 'single',
projects: [
{
path: '.',
framework: 'Go',
framework: 'Go (Gin)',
targetId: 'go',
hasPostHog: true,
},
],
});

expect(report.projects[0]).toEqual({
path: '.',
framework: 'Go (Gin)',
variant: 'go',
hasPostHog: true,
instrumentable: true,
});
});

it('points a Go project without the SDK at a manual module install', () => {
const report = coerceReport({
repoType: 'single',
projects: [
{
path: '.',
framework: 'Go',
targetId: 'go',
hasPostHog: false,
},
],
});

expect(report.projects[0]).toEqual(
expect.objectContaining({
variant: null,
variant: 'go',
instrumentable: false,
reason: expect.stringMatching(/isn't supported/i),
reason: expect.stringMatching(/add the posthog-go module/i),
}),
);
});
});

describe('goSdkVerifier', () => {
let tmpDir: string;

beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wizard-go-detect-'));
});

afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});

it('confirms the SDK from the module go.mod, root or nested', () => {
fs.writeFileSync(
path.join(tmpDir, 'go.mod'),
'module example.com/svc\n\ngo 1.22\n\nrequire github.com/posthog/posthog-go v1.22.0\n',
);
fs.mkdirSync(path.join(tmpDir, 'services', 'api'), { recursive: true });
fs.writeFileSync(
path.join(tmpDir, 'services', 'api', 'go.mod'),
'module example.com/api\n\ngo 1.22\n',
);

const verify = goSdkVerifier(tmpDir);
expect(verify('.')).toBe(true);
expect(verify('services/api')).toBe(false);
});

it('ignores a commented-out requirement', () => {
fs.writeFileSync(
path.join(tmpDir, 'go.mod'),
'module example.com/svc\n\ngo 1.22\n\n// require github.com/posthog/posthog-go v1.22.0\n',
);
expect(goSdkVerifier(tmpDir)('.')).toBe(false);
});

it('finds the SDK inside a require block', () => {
fs.writeFileSync(
path.join(tmpDir, 'go.mod'),
'module example.com/svc\n\ngo 1.22\n\nrequire (\n\tgithub.com/gin-gonic/gin v1.10.0\n\tgithub.com/posthog/posthog-go v1.22.0\n)\n',
);
expect(goSdkVerifier(tmpDir)('.')).toBe(true);
});

it('ignores non-require mentions of the module path', () => {
// A replace/exclude directive (or the SDK's own module declaration) is
// not a dependency — only `require` counts.
fs.writeFileSync(
path.join(tmpDir, 'go.mod'),
'module example.com/svc\n\ngo 1.22\n\nreplace github.com/posthog/posthog-go => ../fork\n\nexclude github.com/posthog/posthog-go v1.21.0\n',
);
expect(goSdkVerifier(tmpDir)('.')).toBe(false);
});

it('returns false when go.mod is missing', () => {
expect(goSdkVerifier(tmpDir)('.')).toBe(false);
});
});

describe('rustSdkVerifier', () => {
let tmpDir: string;

Expand Down
116 changes: 76 additions & 40 deletions src/lib/programs/error-tracking-upload-source-maps/detect-agentic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

import { existsSync, readFileSync, readdirSync } from 'fs';
import { join } from 'path';
import { IGNORED_DIRS } from '@utils/bounded-fs';
import { IGNORED_DIRS, readProjectFile } from '@utils/bounded-fs';
import {
detectProjectsWithAgent,
coerceAgenticReport,
Expand All @@ -21,8 +21,9 @@ import type { WizardSession } from '@lib/wizard-session';
import {
VARIANT_DISPLAY_NAME,
AUTOMATABLE_VARIANTS,
MANUAL_SDK_VARIANTS,
MANUAL_SDK_INSTALL,
RUST_SDK_CRATE,
GO_SDK_MODULE,
type SkillVariant,
} from './detect.js';

Expand All @@ -49,14 +50,6 @@ export type DetectionReport = {
projects: DetectedProject[];
};

/**
* Variants the detector recognises so the picker can name and block them,
* without a shipped skill behind them yet. They rank LOW — a go.mod signal
* must not shadow a real JS/native target in the same directory, it only
* needs to beat the generic web fallback.
*/
const DETECTION_ONLY_VARIANTS: readonly SkillVariant[] = ['go'];

/**
* Variant precedence for the agentic picker (most specific first). The detector
* keeps the EARLIEST matching target. React Native and Flutter both outrank
Expand Down Expand Up @@ -95,30 +88,21 @@ const precedenceRank = (v: SkillVariant): number => {
return i === -1 ? Number.MAX_SAFE_INTEGER : i;
};

/**
* Source-map detection targets, ordered by VARIANT_PRECEDENCE. Detection-only
* variants stay in the target list so the detector can identify and block
* them instead of falling through to a JS target or the web fallback.
*/
export const SOURCE_MAPS_TARGETS: DetectTarget[] = [
...DETECTION_ONLY_VARIANTS,
...AUTOMATABLE_VARIANTS,
]
/** Source-map detection targets, ordered by VARIANT_PRECEDENCE. */
export const SOURCE_MAPS_TARGETS: DetectTarget[] = [...AUTOMATABLE_VARIANTS]
.sort((a, b) => precedenceRank(a) - precedenceRank(b))
.map((v) => ({ id: v, name: VARIANT_DISPLAY_NAME[v] }));

/** Comment-stripped substring check for the Rust SDK in one manifest. */
function manifestMentionsSdk(manifestPath: string): boolean {
try {
return readFileSync(manifestPath, 'utf-8')
.split('\n')
.some((line) => {
const code = line.split('#', 1)[0];
return code.includes(RUST_SDK_CRATE);
});
} catch {
return false;
}
const content = readProjectFile(manifestPath);
return (
content != null &&
content.split('\n').some((line) => {
const code = line.split('#', 1)[0];
return code.includes(RUST_SDK_CRATE);
})
);
}

/**
Expand Down Expand Up @@ -172,6 +156,52 @@ export function rustSdkVerifier(
};
}

/**
* True when go.mod actively `require`s the SDK — single-line requires and
* `require ( … )` blocks only, so a `module` declaration, `replace`, or
* `exclude` mention of the path never counts.
*/
function goModRequiresSdk(content: string): boolean {
let inBlock: 'require' | 'other' | null = null;
for (const raw of content.split('\n')) {
const code = raw.split('//', 1)[0].trim();
if (code === '') continue;
if (inBlock != null) {
if (code === ')') {
inBlock = null;
} else if (inBlock === 'require' && code.includes(GO_SDK_MODULE)) {
return true;
}
continue;
}
if (code.endsWith('(')) {
inBlock = code.startsWith('require') ? 'require' : 'other';
continue;
}
if (code.startsWith('require ') && code.includes(GO_SDK_MODULE)) {
return true;
}
}
return false;
}

/**
* Checks a project's go.mod for the Go SDK — the same authoritative override
* as `rustSdkVerifier`, for the same reason. Only `require` directives count;
* no multi-module walk — unlike Cargo workspaces, a Go module's requirements
* always live in the selected module's own go.mod. Exported for testing.
*/
export function goSdkVerifier(
installDir: string,
): (projectPath: string) => boolean {
return (projectPath) => {
const dir =
projectPath === '.' ? installDir : join(installDir, projectPath);
const content = readProjectFile(join(dir, 'go.mod'));
return content != null && goModRequiresSdk(content);
};
}

function isAutomatableVariant(value: string | null): value is SkillVariant {
return value !== null && AUTOMATABLE_VARIANTS.includes(value as SkillVariant);
}
Expand Down Expand Up @@ -203,6 +233,8 @@ export type ProjectProbes = {
* present, because any unrelated PostHog dependency can satisfy it.
*/
verifyRustSdk?: (path: string) => boolean;
/** Go: does the project's go.mod require posthog-go? Same semantics as `verifyRustSdk`. */
verifyGoSdk?: (path: string) => boolean;
};

const NO_PROBES: ProjectProbes = {
Expand Down Expand Up @@ -251,12 +283,16 @@ function classifyProject(
p: AgenticDetectionReport['projects'][number],
probes: ProjectProbes,
): DetectedProject {
// For rust the deterministic Cargo.toml read overrides the agent's single
// hasPostHog boolean, which any unrelated PostHog dependency can satisfy.
const hasPostHog =
p.targetId === 'rust' && probes.verifyRustSdk
? probes.verifyRustSdk(p.path)
: p.hasPostHog;
// For the native-binary variants the deterministic manifest read overrides
// the agent's single hasPostHog boolean, which any unrelated PostHog
// dependency can satisfy.
const sdkVerifier =
p.targetId === 'rust'
? probes.verifyRustSdk
: p.targetId === 'go'
? probes.verifyGoSdk
: undefined;
const hasPostHog = sdkVerifier ? sdkVerifier(p.path) : p.hasPostHog;
const base = {
path: p.path,
framework: p.framework,
Expand Down Expand Up @@ -302,11 +338,10 @@ function classifyProject(
}

if (!hasPostHog) {
// The wizard's default flow can't install the Rust SDK, so don't point
// users at it for that stack.
const install = MANUAL_SDK_VARIANTS.includes(p.targetId)
? 'add the posthog-rs crate first'
: 'run `npx @posthog/wizard` first';
// The wizard's default flow can't install the Go/Rust SDKs, so don't
// point users at it for those stacks.
const install =
MANUAL_SDK_INSTALL[p.targetId] ?? 'run `npx @posthog/wizard` first';
return {
...base,
variant: p.targetId,
Expand Down Expand Up @@ -363,5 +398,6 @@ export async function detectSourceMapsProjects(
hasBuildTarget: (path) => projectHasBuildTarget(session.installDir, path),
isExpoProject: (path) => projectHasExpo(session.installDir, path),
verifyRustSdk: rustSdkVerifier(session.installDir),
verifyGoSdk: goSdkVerifier(session.installDir),
});
}
32 changes: 21 additions & 11 deletions src/lib/programs/error-tracking-upload-source-maps/detect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,9 @@ const DISPLAY_NAME: Record<SkillVariant, string> = {
};

/**
* Variants the wizard can wire up source-map upload for automatically. Go is
* recognised but not yet automatable, so the agentic picker treats it as
* non-instrumentable. Rust uploads native debug symbols
* (`posthog-cli symbol-sets upload`) instead of source maps, but the wiring
* is the same shape.
* Variants the wizard can wire up source-map upload for automatically. Go and
* Rust upload native debug symbols (`posthog-cli symbol-sets upload`) instead
* of source maps, but the wiring is the same shape.
*
* Invariant: every variant listed here must have a published
* `error-tracking-upload-source-maps-<variant>` skill in context-mill —
Expand All @@ -76,6 +74,7 @@ export const AUTOMATABLE_VARIANTS: readonly SkillVariant[] = [
'ios',
'react-native',
'flutter',
'go',
'rust',
'web',
'nextjs',
Expand All @@ -97,18 +96,29 @@ export const AUTOMATABLE_VARIANTS: readonly SkillVariant[] = [
* or local dependency to fall back to when the post-build step invokes the CLI.
*/
export const VARIANTS_REQUIRING_POSTHOG_CLI: ReadonlySet<SkillVariant> =
new Set(['ios', 'android', 'react-native', 'flutter', 'rust']);
new Set(['ios', 'android', 'react-native', 'flutter', 'go', 'rust']);

/**
* Automatable variants whose SDK the wizard's default flow cannot install —
* remediation for a missing SDK is a manual install (posthog-rs), not
* `npx @posthog/wizard`. Drives the missing-SDK copy in the picker.
* remediation for a missing SDK is a manual install (posthog-go /
* posthog-rs), not `npx @posthog/wizard`. Drives the missing-SDK copy in the
* picker and the classifier.
*/
export const MANUAL_SDK_VARIANTS: readonly SkillVariant[] = ['rust'];
export const MANUAL_SDK_INSTALL: Partial<Record<SkillVariant, string>> = {
go: 'add the posthog-go module first',
rust: 'add the posthog-rs crate first',
};

export const MANUAL_SDK_VARIANTS: readonly SkillVariant[] = Object.keys(
MANUAL_SDK_INSTALL,
) as SkillVariant[];

/** Dependency string that identifies the Rust SDK in Cargo.toml. */
export const RUST_SDK_CRATE = 'posthog-rs';

/** Module path that identifies the Go SDK in go.mod. */
export const GO_SDK_MODULE = 'github.com/posthog/posthog-go';

const POSTHOG_SDKS = new Set([
'posthog-js',
'posthog-node',
Expand Down Expand Up @@ -147,8 +157,8 @@ export const SOURCE_MAPS_ABORT_CASES: AbortCase[] = [
'The agent could not find a PostHog SDK in your project. ' +
'Source map upload requires the SDK to already be installed so it can ' +
'report errors. Run `npx @posthog/wizard` first to install the SDK ' +
'(for Rust, add the posthog-rs crate by hand first — the wizard ' +
'cannot install it for that stack yet).',
'(for Go and Rust, add posthog-go / posthog-rs by hand first — the ' +
'wizard cannot install the SDK for those stacks yet).',
docsUrl: 'https://posthog.com/docs/error-tracking',
},
{
Expand Down
8 changes: 4 additions & 4 deletions src/ui/tui/screens/SourceMapsDetectScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -215,8 +215,8 @@ export const SourceMapsDetectScreen = ({
*/
const BlockedSummary = ({ blocked }: { blocked: DetectedProject[] }) => {
const unsupported = blocked.filter((p) => p.variant == null).length;
// The wizard can't install the Rust SDK, so its missing-SDK remediation is
// a manual install, not `npx @posthog/wizard`.
// The wizard can't install the Go/Rust SDKs, so their missing-SDK
// remediation is a manual install, not `npx @posthog/wizard`.
const manualSdk = blocked.filter(
(p) => p.variant != null && MANUAL_SDK_VARIANTS.includes(p.variant),
).length;
Expand All @@ -238,8 +238,8 @@ const BlockedSummary = ({ blocked }: { blocked: DetectedProject[] }) => {
)}
{manualSdk > 0 && (
<Text dimColor>
(… {manualSdk} supported but missing the PostHog SDK — add the
posthog-rs crate to the project first, then re-run)
(… {manualSdk} supported but missing the PostHog SDK — add it to the
project first (posthog-go / posthog-rs), then re-run)
</Text>
)}
</Box>
Expand Down
Loading