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
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ describe('error-tracking ciPreRun', () => {
const session = {
installDir: '/tmp/error-tracking-ci',
frameworkContext: {},
projectTraits: {},
} as unknown as WizardSession;

const stopped = errorTracking.ciPreRun?.(session, testCiRunnerContext());
Expand All @@ -157,6 +158,7 @@ describe('error-tracking ciPreRun', () => {
const session = {
installDir: '/tmp/error-tracking-ci',
frameworkContext: {},
projectTraits: {},
} as unknown as WizardSession;

const runner = testCiRunnerContext();
Expand All @@ -181,6 +183,7 @@ describe('error-tracking posthog-cli pre-install', () => {
const session = {
installDir: '/tmp/error-tracking-ci',
frameworkContext: {},
projectTraits: {},
} as unknown as WizardSession;

await errorTracking.ciPreRun?.(session, runner);
Expand Down
4 changes: 2 additions & 2 deletions src/programs/error-tracking/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
import { detectFramework } from '@services/detection';
import { scopeInstallDirToProject } from '@services/detection';
import { FRAMEWORK_REGISTRY } from '@services/frameworks';
import { applyFramework } from '@services/project-traits';
import type { ProgramRun } from '@programs/program-contract';
import {
ERROR_TRACKING_UNSUPPORTED,
Expand Down Expand Up @@ -196,8 +197,7 @@ export const config: ProgramConfig = {
if (ERROR_TRACKING_UNSUPPORTED.has(integration)) {
abortUnsupportedPlatform(integration);
}
session.integration = integration;
analytics.setTag('integration', integration);
applyFramework(session, integration);
session.frameworkConfig = FRAMEWORK_REGISTRY[integration];
session.skillId = integration;

Expand Down
5 changes: 5 additions & 0 deletions src/programs/feature-flags/__tests__/feature-flags.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,17 @@ describe('feature-flags program', () => {
installDir: '/tmp/feature-flags-ci',
frameworkContext: {},
integration: null,
projectTraits: {},
skillId: null,
} as unknown as WizardSession;

await featureFlagsConfig.ciPreRun?.(session, testCiRunnerContext());

expect(session.integration).toBe(Integration.nextjs);
expect(session.projectTraits.framework).toEqual({
value: Integration.nextjs,
source: 'static',
});
expect(session.skillId).toBe(Integration.nextjs);
});
});
4 changes: 2 additions & 2 deletions src/programs/feature-flags/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { gatherFrameworkContext } from '@services/detection';
import { noteDetectedFramework } from '@services/detection';
import { scopeInstallDirToProject } from '@services/detection';
import { FRAMEWORK_REGISTRY } from '@services/frameworks';
import { applyFramework } from '@services/project-traits';
import { ProgramAbort } from '@programs/program-contract';
import type { ProgramConfig } from '@programs/program-contract';
import type { ProgramSession } from '@programs/program-contract';
Expand Down Expand Up @@ -89,8 +90,7 @@ export const config: ProgramConfig = {
if (FEATURE_FLAGS_UNSUPPORTED.has(integration)) {
abortUnsupportedPlatform(integration);
}
session.integration = integration;
analytics.setTag('integration', integration);
applyFramework(session, integration);

const frameworkConfig = FRAMEWORK_REGISTRY[integration];
session.frameworkConfig = frameworkConfig;
Expand Down
4 changes: 2 additions & 2 deletions src/programs/posthog-integration/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import type {
RunnerContext,
} from '@programs/program-contract';
import { FRAMEWORK_REGISTRY } from '@services/frameworks';
import { applyFramework } from '@services/project-traits';
import { abortNoFrameworkDetected } from '../shared/abort-no-framework';
import {
SETUP_REPORT_FILE,
Expand Down Expand Up @@ -296,8 +297,7 @@ export const config: ProgramConfig = {
if (!integration) {
abortNoFrameworkDetected();
}
session.integration = integration;
analytics.setTag('integration', integration);
applyFramework(session, integration);

const frameworkConfig = FRAMEWORK_REGISTRY[integration];
session.frameworkConfig = frameworkConfig;
Expand Down
7 changes: 6 additions & 1 deletion src/programs/program-contract/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,12 @@ export {
export { ProgramAbort } from './program-abort';
export type { ProgramRun } from './program-run';
export type { ProgramSession, ScanReport } from './program-session';
export type { ProjectTraits, Trait, TraitName } from './project-traits';
export type {
ProjectTraits,
Trait,
TraitName,
TraitSource,
} from './project-traits';
export type {
DecisionQuestionKey,
ProgramConfig,
Expand Down
3 changes: 3 additions & 0 deletions src/programs/program-contract/program-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { ApiUser, Credentials } from '@shared/api';
import type { DiscoveredFeature } from '@shared/discovered-feature';
import type { ScanConsent } from '@shared/run-state';
import type { FrameworkConfig } from './framework-config';
import type { ProjectTraits } from './project-traits';
import type { OutroData } from '@agent/types';

export interface ProgramSession {
Expand Down Expand Up @@ -64,6 +65,8 @@ export interface ProgramSession {
/** The scan the user agreed to share. Set once per run; the task stream sends it. */
codebaseScanReport: ScanReport | null;
integration: Integration | null;
/** The run's written traits, each with its source; `integration` mirrors the framework. Read them through `@services/project-traits`. */
projectTraits: ProjectTraits;
frameworkContext: Record<string, unknown>;
typescript: boolean;

Expand Down
2 changes: 2 additions & 0 deletions src/programs/program-contract/program-step.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { ProgramRun } from './program-run';
import type { Integration } from '@shared/constants';
import type { FrameworkConfig } from './framework-config';
import type { CiRunnerContext, RunnerContext } from './runner-context';
import type { TraitSource } from './project-traits';

/** A question a program may ask the Decisions service; the catalog in `@services/detection/decisions` has exactly these keys. */
export type DecisionQuestionKey =
Expand Down Expand Up @@ -40,6 +41,7 @@ export interface ProgramReadyContext {
readonly setFrameworkConfig: (
integration: Integration,
config: FrameworkConfig,
source?: TraitSource,
) => void;
readonly setDetectedFramework: (label: string) => void;
readonly setSkillId: (skillId: string | null) => void;
Expand Down
5 changes: 4 additions & 1 deletion src/programs/program-contract/project-traits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@

export type TraitName = 'framework' | 'callsLlmApi';

/** Where a trait's value came from: static detection, or the person (the intro picker or `--integration`). */
export type TraitSource = 'static' | 'user';

/** A predicate trait's value is `yes` or `no`; the framework's is its integration id. */
export type Trait = {
value: string;
source: 'static';
source: TraitSource;
};

export type ProjectTraits = Partial<Record<TraitName, Trait>>;
19 changes: 18 additions & 1 deletion src/programs/program-runner/run-program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,11 @@ import {
type ResolvedProgramCredentials,
} from './credentials';
import { reportCodebaseScan } from '@services/codebase-scan';
import { getProjectTraits, traitsToSkillTags } from '@services/project-traits';
import {
frameworkTraitMismatch,
getProjectTraits,
traitsToSkillTags,
} from '@services/project-traits';
import { IS_PRODUCTION_BUILD } from '@env';
import type { QuestionKey } from '@services/detection/decisions';
import { logIn } from './login';
Expand Down Expand Up @@ -398,6 +402,19 @@ async function invokeProgram(
planned.own ? undefined : planned.stepId,
);
const current = session();
// Every framework write records the trait too; a disagreement means one went around it.
const mismatch = frameworkTraitMismatch(current);
if (mismatch) {
logToFile(
`[traits] framework trait ${
mismatch.trait ?? 'none'
} disagrees with integration ${mismatch.integration ?? 'none'}`,
);
analytics.wizardCapture('framework trait mismatch', {
...mismatch,
program_id: planned.config.id,
});
}
const result = await runAgent(
{
programId: planned.config.id,
Expand Down
4 changes: 2 additions & 2 deletions src/programs/replay-vision/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { noteDetectedFramework } from '@services/detection';
import { scopeInstallDirToProject } from '@services/detection';
import type { CiRunnerContext } from '@programs/program-contract';
import { FRAMEWORK_REGISTRY } from '@services/frameworks';
import { applyFramework } from '@services/project-traits';
import { createSkillProgram } from '../shared/skill-program';
import { detectPostHogIntegration } from '@services/detection';
import type {
Expand Down Expand Up @@ -168,8 +169,7 @@ export const config: ProgramConfig = {
if (!REPLAY_VISION_SUPPORTED.has(integration)) {
abortUnsupportedPlatform(integration);
}
session.integration = integration;
analytics.setTag('integration', integration);
applyFramework(session, integration);

const frameworkConfig = FRAMEWORK_REGISTRY[integration];
session.frameworkConfig = frameworkConfig;
Expand Down
1 change: 1 addition & 0 deletions src/programs/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export type {
} from '@programs/program-contract';
export type { ProgramRun } from '@programs/program-contract';
export type { ProgramSession } from '@programs/program-contract';
export type { TraitSource } from '@programs/program-contract';
export type {
CiRunnerContext,
RunnerContext,
Expand Down
72 changes: 70 additions & 2 deletions src/services/project-traits/__tests__/traits.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import { DiscoveredFeature } from '@shared/discovered-feature';
import { Integration } from '@shared/constants';
import { analytics } from '@utils/analytics';
import {
applyFramework,
frameworkTraitMismatch,
getProjectTraits,
traitsFromDetectors,
traitsToSkillTags,
withFramework,
} from '@services/project-traits/traits';
import type { ProjectTraits } from '@programs/program-contract';

vi.mock('@utils/analytics', () => ({ analytics: { setTag: vi.fn() } }));

describe('traitsFromDetectors', () => {
it('reads the framework from detection', () => {
Expand Down Expand Up @@ -37,14 +44,75 @@ describe('traitsFromDetectors', () => {
}).callsLlmApi,
).toBeUndefined();
});
});

it('gives every program in a run the same traits', () => {
describe('getProjectTraits', () => {
it('reads detection when no framework trait was written', () => {
const session = {
integration: Integration.django,
discoveredFeatures: [DiscoveredFeature.LLM],
projectTraits: {},
};
expect(getProjectTraits(session)).toEqual(traitsFromDetectors(session));
});

it('keeps the written framework trait and its source', () => {
expect(
getProjectTraits({
integration: Integration.flask,
discoveredFeatures: [],
projectTraits: { framework: { value: 'flask', source: 'user' } },
}).framework,
).toEqual({ value: 'flask', source: 'user' });
});
});

describe('framework writes', () => {
it('replaces the framework trait, or removes it for no framework', () => {
const traits: ProjectTraits = {
framework: { value: 'nextjs', source: 'static' },
callsLlmApi: { value: 'yes', source: 'static' },
};
expect(withFramework(traits, Integration.nuxt, 'user')).toEqual({
framework: { value: 'nuxt', source: 'user' },
callsLlmApi: { value: 'yes', source: 'static' },
});
expect(withFramework(traits, null, 'static')).toEqual({
callsLlmApi: { value: 'yes', source: 'static' },
});
});

it('writes integration, the trait and both tags on a draft', () => {
const draft = {
integration: null as Integration | null,
projectTraits: {} as ProjectTraits,
};
applyFramework(draft, Integration.rails);
expect(draft).toEqual({
integration: 'rails',
projectTraits: { framework: { value: 'rails', source: 'static' } },
});
expect(analytics.setTag).toHaveBeenCalledWith('integration', 'rails');
expect(analytics.setTag).toHaveBeenCalledWith('framework_source', 'static');
});

it('finds a write that went around the trait', () => {
expect(
frameworkTraitMismatch({ integration: null, projectTraits: {} }),
).toBeUndefined();
expect(
frameworkTraitMismatch({
integration: Integration.nextjs,
projectTraits: { framework: { value: 'nextjs', source: 'static' } },
}),
).toBeUndefined();
expect(
frameworkTraitMismatch({
integration: Integration.nextjs,
projectTraits: {},
}),
).toEqual({ trait: null, integration: 'nextjs' });
});
});

describe('traitsToSkillTags', () => {
Expand Down
4 changes: 4 additions & 0 deletions src/services/project-traits/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
export {
applyFramework,
frameworkTraitMismatch,
getProjectTraits,
tagFramework,
traitsFromDetectors,
traitsToSkillTags,
withFramework,
type DetectionResults,
} from './traits';
Loading
Loading