mirror of
https://github.com/jparkerweb/plan2code.git
synced 2026-09-17 16:22:23 -07:00
474c76e564
Adds a CLI flow that lists open community-feedback issues via gh, validates and parses each METRICS_JSON payload, imports them deduped by run_id, re-aggregates and closes the issue. Community runs cohort by Plan2Code version rather than prompt fingerprint, and local cohorts stay preferred so ingested feedback never displaces the maintainer's current generation. Adds Devin CLI as an AI backend. AI Assisted
254 lines
10 KiB
TypeScript
254 lines
10 KiB
TypeScript
import fs from 'fs';
|
|
import os from 'os';
|
|
import path from 'path';
|
|
import { describe, it, expect, afterEach } from 'vitest';
|
|
import { avg, rate, buildCohortKey, backfillPromptVersions, cohortKeyForRun, aggregate } from './aggregator.js';
|
|
import type { PromptVersions, RunMetrics } from './types.js';
|
|
|
|
// ── avg() ─────────────────────────────────────────────────────────────────────
|
|
|
|
describe('avg', () => {
|
|
it('returns null for empty array', () => {
|
|
expect(avg([])).toBeNull();
|
|
});
|
|
|
|
it('returns null for all-null/undefined values', () => {
|
|
expect(avg([null, undefined, null])).toBeNull();
|
|
});
|
|
|
|
it('computes correct average for valid numbers', () => {
|
|
expect(avg([10, 20, 30])).toBe(20);
|
|
});
|
|
|
|
it('filters out null/undefined/NaN from mixed arrays', () => {
|
|
expect(avg([10, null, 20, undefined, NaN, 30])).toBe(20);
|
|
});
|
|
});
|
|
|
|
// ── rate() ────────────────────────────────────────────────────────────────────
|
|
|
|
describe('rate', () => {
|
|
it('returns null for empty array', () => {
|
|
expect(rate([])).toBeNull();
|
|
});
|
|
|
|
it('returns null for all-null values', () => {
|
|
expect(rate([null, null])).toBeNull();
|
|
});
|
|
|
|
it('returns 1.0 for all-true', () => {
|
|
expect(rate([true, true, true])).toBe(1.0);
|
|
});
|
|
|
|
it('returns 0.0 for all-false', () => {
|
|
expect(rate([false, false, false])).toBe(0.0);
|
|
});
|
|
|
|
it('computes correct rate for mixed true/false', () => {
|
|
expect(rate([true, false, true, false])).toBe(0.5);
|
|
});
|
|
|
|
it('filters out null/undefined from mixed arrays', () => {
|
|
expect(rate([true, null, false, undefined])).toBe(0.5);
|
|
});
|
|
});
|
|
|
|
// ── backfillPromptVersions() ──────────────────────────────────────────────────
|
|
|
|
const FULL_VERSIONS: PromptVersions = {
|
|
plan: 'sha256:aaa',
|
|
revise_plan: 'sha256:bbb',
|
|
document: 'sha256:ccc',
|
|
implement: 'sha256:ddd',
|
|
finalize: 'sha256:eee',
|
|
init: 'sha256:fff',
|
|
init_update: 'sha256:ggg',
|
|
quick_task: 'sha256:hhh',
|
|
};
|
|
|
|
describe('backfillPromptVersions', () => {
|
|
it('returns all 8 fields with sentinels when given empty-ish object', () => {
|
|
const result = backfillPromptVersions({} as PromptVersions);
|
|
expect(Object.keys(result)).toHaveLength(8);
|
|
for (const val of Object.values(result)) {
|
|
expect(val).toBe('sha256:missing');
|
|
}
|
|
});
|
|
|
|
it('preserves existing values, fills missing with sha256:missing', () => {
|
|
const partial = { plan: 'sha256:aaa', implement: 'sha256:ddd' } as PromptVersions;
|
|
const result = backfillPromptVersions(partial);
|
|
expect(result.plan).toBe('sha256:aaa');
|
|
expect(result.implement).toBe('sha256:ddd');
|
|
expect(result.revise_plan).toBe('sha256:missing');
|
|
expect(result.document).toBe('sha256:missing');
|
|
expect(result.finalize).toBe('sha256:missing');
|
|
expect(result.init).toBe('sha256:missing');
|
|
expect(result.init_update).toBe('sha256:missing');
|
|
expect(result.quick_task).toBe('sha256:missing');
|
|
});
|
|
|
|
it('returns unchanged object when all 8 fields present', () => {
|
|
const result = backfillPromptVersions(FULL_VERSIONS);
|
|
expect(result).toEqual(FULL_VERSIONS);
|
|
});
|
|
});
|
|
|
|
// ── buildCohortKey() ──────────────────────────────────────────────────────────
|
|
|
|
describe('buildCohortKey', () => {
|
|
it('returns 12-char hex string', () => {
|
|
const key = buildCohortKey(FULL_VERSIONS);
|
|
expect(key).toMatch(/^[0-9a-f]{12}$/);
|
|
});
|
|
|
|
it('is deterministic (same input → same output)', () => {
|
|
const key1 = buildCohortKey(FULL_VERSIONS);
|
|
const key2 = buildCohortKey(FULL_VERSIONS);
|
|
expect(key1).toBe(key2);
|
|
});
|
|
|
|
it('old 4-field run with backfill sentinels produces same key as raw 4-field object', () => {
|
|
// Simulate an old run that only had 4 fields
|
|
const oldRun = {
|
|
plan: 'sha256:aaa',
|
|
implement: 'sha256:ddd',
|
|
document: 'sha256:ccc',
|
|
finalize: 'sha256:eee',
|
|
} as PromptVersions;
|
|
|
|
// After backfill, the missing fields get 'sha256:missing'
|
|
const backfilled = backfillPromptVersions(oldRun);
|
|
|
|
// buildCohortKey filters out 'sha256:missing', so both should match
|
|
const keyDirect = buildCohortKey(oldRun);
|
|
const keyBackfilled = buildCohortKey(backfilled);
|
|
expect(keyDirect).toBe(keyBackfilled);
|
|
});
|
|
|
|
it('different prompt versions → different keys', () => {
|
|
const altered = { ...FULL_VERSIONS, plan: 'sha256:zzz' };
|
|
expect(buildCohortKey(FULL_VERSIONS)).not.toBe(buildCohortKey(altered));
|
|
});
|
|
|
|
it('key is independent of field insertion order', () => {
|
|
const ordered: PromptVersions = {
|
|
plan: 'sha256:aaa',
|
|
revise_plan: 'sha256:bbb',
|
|
document: 'sha256:ccc',
|
|
implement: 'sha256:ddd',
|
|
finalize: 'sha256:eee',
|
|
init: 'sha256:fff',
|
|
init_update: 'sha256:ggg',
|
|
quick_task: 'sha256:hhh',
|
|
};
|
|
const reversed: PromptVersions = {
|
|
quick_task: 'sha256:hhh',
|
|
init_update: 'sha256:ggg',
|
|
init: 'sha256:fff',
|
|
finalize: 'sha256:eee',
|
|
implement: 'sha256:ddd',
|
|
document: 'sha256:ccc',
|
|
revise_plan: 'sha256:bbb',
|
|
plan: 'sha256:aaa',
|
|
};
|
|
expect(buildCohortKey(ordered)).toBe(buildCohortKey(reversed));
|
|
});
|
|
});
|
|
|
|
// ── Run fixtures for cohort keying / aggregation ──────────────────────────────
|
|
|
|
function makeRun(overrides: Partial<RunMetrics> = {}): RunMetrics {
|
|
return {
|
|
schema_version: '1.0',
|
|
run_id: 'run-20260101-000000-0000',
|
|
plan2code_version: '1.17.0',
|
|
prompt_versions: { ...FULL_VERSIONS },
|
|
project: { name: 'proj', started_at: null, completed_at: null },
|
|
step1_plan: { present: false, final_confidence: null, confidence_breakdown: null, clarification_rounds: null, tech_stack_revision_rounds: null, verification_gaps_found: null, functional_requirements_count: null, non_functional_requirements_count: null, risk_count: null, phase_count: null },
|
|
step2_document: { present: false, total_tasks: null, tasks_per_phase: null, phase_count: null, parallel_groups_identified: null, requirement_coverage_percent: null, verification_items_added: null },
|
|
step3_implement: { present: false, task_completion_rate: null, tasks_completed: null, tasks_total: null, blocker_count: null },
|
|
step4_finalize: { present: false, completion_rate_at_audit: null, verification_failures_found: null, documentation_updates_needed: null, archival_succeeded: null },
|
|
user_feedback: null,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
// ── cohortKeyForRun() ─────────────────────────────────────────────────────────
|
|
|
|
describe('cohortKeyForRun', () => {
|
|
it('keys local runs by the prompt-version hash (unchanged from buildCohortKey)', () => {
|
|
const run = makeRun({ source: 'local' });
|
|
expect(cohortKeyForRun(run)).toBe(buildCohortKey(run.prompt_versions));
|
|
});
|
|
|
|
it('treats a run with no source as local', () => {
|
|
const run = makeRun();
|
|
delete run.source;
|
|
expect(cohortKeyForRun(run)).toBe(buildCohortKey(run.prompt_versions));
|
|
});
|
|
|
|
it('keys community runs by plan2code_version, ignoring prompt fingerprints', () => {
|
|
const run = makeRun({ source: 'community', plan2code_version: '1.17.0' });
|
|
expect(cohortKeyForRun(run)).toBe('community:v1.17.0');
|
|
});
|
|
|
|
it('groups two community runs of the same version together regardless of prompt fingerprint', () => {
|
|
const a = makeRun({ source: 'community', plan2code_version: '1.17.0', prompt_versions: { ...FULL_VERSIONS } });
|
|
const b = makeRun({ source: 'community', plan2code_version: '1.17.0', prompt_versions: backfillPromptVersions({} as PromptVersions) });
|
|
expect(cohortKeyForRun(a)).toBe(cohortKeyForRun(b));
|
|
});
|
|
|
|
it('separates community runs from different versions', () => {
|
|
const a = makeRun({ source: 'community', plan2code_version: '1.17.0' });
|
|
const b = makeRun({ source: 'community', plan2code_version: '1.18.0' });
|
|
expect(cohortKeyForRun(a)).not.toBe(cohortKeyForRun(b));
|
|
});
|
|
});
|
|
|
|
// ── aggregate() cohort separation ─────────────────────────────────────────────
|
|
|
|
describe('aggregate', () => {
|
|
let tmpDir: string;
|
|
|
|
afterEach(() => {
|
|
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
function writeRuns(runs: RunMetrics[]): { runsDir: string; outPath: string } {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'plan2code-agg-test-'));
|
|
const runsDir = path.join(tmpDir, 'runs');
|
|
fs.mkdirSync(runsDir, { recursive: true });
|
|
for (const run of runs) {
|
|
fs.writeFileSync(path.join(runsDir, `${run.run_id}.json`), JSON.stringify(run), 'utf8');
|
|
}
|
|
return { runsDir, outPath: path.join(tmpDir, 'aggregated.json') };
|
|
}
|
|
|
|
it('places local and community runs of the same version in separate cohorts', () => {
|
|
const local = makeRun({ run_id: 'run-20260101-000001-0001', source: 'local' });
|
|
const community = makeRun({ run_id: 'run-20260101-000002-0002', source: 'community' });
|
|
const { runsDir, outPath } = writeRuns([local, community]);
|
|
|
|
const result = aggregate(runsDir, outPath);
|
|
|
|
expect(result.total_runs).toBe(2);
|
|
expect(result.cohorts).toHaveLength(2);
|
|
const communityCohort = result.cohorts.find(c => c.source === 'community');
|
|
const localCohort = result.cohorts.find(c => c.source === 'local');
|
|
expect(communityCohort?.cohort_key).toBe('community:v1.17.0');
|
|
expect(localCohort?.cohort_key).toBe(buildCohortKey(local.prompt_versions));
|
|
});
|
|
|
|
it('never selects a community cohort as current when a local cohort exists', () => {
|
|
// Community run sorts last by run_id, but current must stay on the local cohort.
|
|
const local = makeRun({ run_id: 'run-20260101-000001-0001', source: 'local' });
|
|
const community = makeRun({ run_id: 'run-29991231-235959-9999', source: 'community' });
|
|
const { runsDir, outPath } = writeRuns([local, community]);
|
|
|
|
const result = aggregate(runsDir, outPath);
|
|
|
|
expect(result.current_cohort_key).toBe(buildCohortKey(local.prompt_versions));
|
|
});
|
|
});
|