@dailephd/my-dev-kit-lab 0.6.1 → 0.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +9 -7
  3. package/benchmarks/contracts/incremental-change-staleness-scenarios.json +360 -0
  4. package/dist/src/cli/help.js +16 -9
  5. package/dist/src/commands/runExperimentDescribeCommand.js +5 -2
  6. package/dist/src/commands/runExperimentRunCommand.js +4 -7
  7. package/dist/src/evaluation/affectedNeighborhoodMetrics.js +51 -0
  8. package/dist/src/evaluation/benchmarkMetadata.js +5 -1
  9. package/dist/src/evaluation/parseAgentAnswer.js +172 -24
  10. package/dist/src/evaluation/runMyDevKitRetrieval.js +120 -7
  11. package/dist/src/experiments/defaultRegistry.js +2 -0
  12. package/dist/src/experiments/plugins/incrementalChangeStaleness/comparison.js +101 -0
  13. package/dist/src/experiments/plugins/incrementalChangeStaleness/comparisonV2.js +49 -0
  14. package/dist/src/experiments/plugins/incrementalChangeStaleness/config.js +67 -0
  15. package/dist/src/experiments/plugins/incrementalChangeStaleness/disposableTarget.js +125 -0
  16. package/dist/src/experiments/plugins/incrementalChangeStaleness/execution.js +430 -0
  17. package/dist/src/experiments/plugins/incrementalChangeStaleness/executionArtifact.js +160 -0
  18. package/dist/src/experiments/plugins/incrementalChangeStaleness/executionArtifactV2.js +289 -0
  19. package/dist/src/experiments/plugins/incrementalChangeStaleness/executionV2.js +195 -0
  20. package/dist/src/experiments/plugins/incrementalChangeStaleness/index.js +13 -0
  21. package/dist/src/experiments/plugins/incrementalChangeStaleness/indexClone.js +76 -0
  22. package/dist/src/experiments/plugins/incrementalChangeStaleness/lifecycle.js +308 -0
  23. package/dist/src/experiments/plugins/incrementalChangeStaleness/lifecyclePolicy.js +154 -0
  24. package/dist/src/experiments/plugins/incrementalChangeStaleness/lifecyclePolicyV2.js +78 -0
  25. package/dist/src/experiments/plugins/incrementalChangeStaleness/lifecycleV2.js +298 -0
  26. package/dist/src/experiments/plugins/incrementalChangeStaleness/mutationExecution.js +144 -0
  27. package/dist/src/experiments/plugins/incrementalChangeStaleness/plugin.js +475 -0
  28. package/dist/src/experiments/plugins/incrementalChangeStaleness/scenarioCatalog.js +42 -0
  29. package/dist/src/experiments/plugins/incrementalChangeStaleness/scenarioMutation.js +116 -0
  30. package/dist/src/experiments/plugins/incrementalChangeStaleness/scenarioSelection.js +79 -0
  31. package/dist/src/experiments/plugins/incrementalChangeStaleness/scenarioTypes.js +37 -0
  32. package/dist/src/experiments/plugins/incrementalChangeStaleness/scenarioValidation.js +168 -0
  33. package/dist/src/experiments/plugins/incrementalChangeStaleness/sourceState.js +145 -0
  34. package/dist/src/experiments/plugins/incrementalChangeStaleness/treatmentSession.js +6 -0
  35. package/dist/src/experiments/plugins/incrementalChangeStaleness/treatmentSessionV2.js +5 -0
  36. package/dist/src/experiments/plugins/index.js +1 -0
  37. package/dist/src/experiments/plugins/warmIndexReuse/metrics.js +17 -20
  38. package/dist/src/experiments/plugins/warmIndexReuse/warmIndexSession.js +3 -1
  39. package/dist/src/mobile/android/advancedSecurity/candidateEvidence.js +9 -2
  40. package/dist/src/mobile/android/advancedSecurity/redaction.js +19 -10
  41. package/dist/src/mobile/android/advancedSecurity/secretCandidates/analyzeSecretCandidates.js +12 -2
  42. package/dist/src/mobile/android/advancedSecurity/signingConfiguration/analyzeSigningConfiguration.js +1 -1
  43. package/dist/src/mobile/android/advancedSecurity/signingConfiguration/extractSigningConfigurations.js +7 -7
  44. package/dist/src/report/experiments/buildIncrementalChangeStalenessPluginReport.js +33 -0
  45. package/dist/src/report/experiments/buildIncrementalChangeStalenessReport.js +112 -0
  46. package/dist/src/report/experiments/buildIncrementalChangeStalenessReportV2.js +80 -0
  47. package/dist/src/report/experiments/buildPluginExperimentReport.js +3 -0
  48. package/dist/src/report/experiments/incrementalChangeStalenessReportModel.js +4 -0
  49. package/dist/src/report/experiments/incrementalChangeStalenessReportModelV2.js +3 -0
  50. package/dist/src/report/experiments/incrementalChangeStalenessReportRowsV2.js +146 -0
  51. package/dist/src/report/experiments/index.js +6 -0
  52. package/dist/src/report/experiments/renderIncrementalChangeStalenessHtml.js +243 -0
  53. package/dist/src/report/experiments/renderIncrementalChangeStalenessHtmlV2.js +111 -0
  54. package/dist/src/report/experiments/renderIncrementalChangeStalenessTextV2.js +96 -0
  55. package/dist/src/report/experiments/renderPluginExperimentReportHtml.js +3 -0
  56. package/dist/src/report/experiments/renderPluginExperimentReportText.js +144 -0
  57. package/dist/src/securityValidation/staticScans/codeql.js +8 -8
  58. package/docs/METRICS.md +67 -1
  59. package/examples/tutorial-browser/prepare.mjs +13 -13
  60. package/examples/tutorial-browser/server.mjs +13 -13
  61. package/package.json +1 -1
@@ -0,0 +1,475 @@
1
+ import { existsSync } from "node:fs";
2
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { resolveWithinRoot } from "../../../core/pathSafety.js";
5
+ import { readBenchmarkProjectProfiles } from "../../../evaluation/benchmarkMetadata.js";
6
+ import { toAffectedNeighborhoodMetricValues } from "../../../evaluation/affectedNeighborhoodMetrics.js";
7
+ import { sanitizePathSegment } from "../../outputPaths.js";
8
+ import { summarizeExperimentRun } from "../../results.js";
9
+ import { defaultIncrementalChangeStalenessConfig, incrementalChangeStalenessConfigDefinition, validateIncrementalChangeStalenessConfig } from "./config.js";
10
+ import { DEFAULT_INCREMENTAL_CHANGE_STALENESS_RUNTIME_ROOT_RELATIVE, INCREMENTAL_CHANGE_STALENESS_V2_TREATMENT_IDS, INCREMENTAL_CHANGE_STALENESS_V2_TREATMENT_INTENTS, removeIncrementalChangeStalenessRuntimeRoot } from "./disposableTarget.js";
11
+ import { buildIncrementalChangeStalenessExecutionArtifactV2, writeIncrementalChangeStalenessExecutionArtifactV2 } from "./executionArtifactV2.js";
12
+ import { executeIncrementalChangeStalenessScenarioV2 } from "./executionV2.js";
13
+ import { prepareIncrementalChangeStalenessScenarioLifecycleV2 } from "./lifecycleV2.js";
14
+ import { BENCHMARK_PROJECT_PROFILES_PATH } from "./scenarioCatalog.js";
15
+ import { resolveIncrementalChangeStalenessScenarios } from "./scenarioSelection.js";
16
+ export const INCREMENTAL_CHANGE_STALENESS_PLUGIN_ID = "incremental-change-staleness";
17
+ export const STALE_INDEX_VARIANT_ID = "stale-index";
18
+ export const FULL_REFRESH_VARIANT_ID = "full-refresh";
19
+ /** Marker proving a runtime directory was created by (and belongs to) one plugin run. */
20
+ export const INCREMENTAL_CHANGE_STALENESS_RUN_OWNER_MARKER = ".incremental-change-staleness-run-owner.json";
21
+ export const incrementalChangeStalenessMetadata = {
22
+ id: INCREMENTAL_CHANGE_STALENESS_PLUGIN_ID,
23
+ name: "Incremental Change Staleness",
24
+ description: "Compares no refresh, my-dev-kit changed-files incremental refresh, my-dev-kit affected-neighborhood incremental refresh, and full refresh after the same deterministic controlled source change. Comparisons are scoped to the observed evidence (correctness and required-file presence); full refresh is a reference treatment, not an asserted winner, and a partial treatment may truthfully fall back to a full rebuild.",
25
+ schemaVersion: "1.0.0",
26
+ status: "experimental",
27
+ supportedTargets: ["self"],
28
+ supportedOutputs: ["json", "html"]
29
+ };
30
+ export const CHANGED_FILES_REFRESH_VARIANT_ID = "changed-files-refresh";
31
+ export const AFFECTED_NEIGHBORHOOD_REFRESH_VARIANT_ID = "affected-neighborhood-refresh";
32
+ /** Public v0.6.3 treatments, in the fixed lifecycle order. Factual descriptions only; no treatment is called best or preferred. */
33
+ const TREATMENT_VARIANTS = [
34
+ {
35
+ id: STALE_INDEX_VARIANT_ID,
36
+ name: "No refresh (stale index)",
37
+ description: "Keeps the pre-mutation baseline index and performs no post-mutation refresh."
38
+ },
39
+ {
40
+ id: CHANGED_FILES_REFRESH_VARIANT_ID,
41
+ name: "Changed-files refresh",
42
+ description: "Uses my-dev-kit's incremental changed-files refresh after the controlled mutation; upstream may truthfully fall back to a full rebuild."
43
+ },
44
+ {
45
+ id: AFFECTED_NEIGHBORHOOD_REFRESH_VARIANT_ID,
46
+ name: "Affected-neighborhood refresh",
47
+ description: "Uses my-dev-kit's affected-neighborhood incremental refresh after the controlled mutation; upstream may truthfully fall back to a full rebuild."
48
+ },
49
+ {
50
+ id: FULL_REFRESH_VARIANT_ID,
51
+ name: "Full refresh",
52
+ description: "Builds a complete post-mutation index and uses it as the comparison reference."
53
+ }
54
+ ];
55
+ /** The released v0.6.2 two-treatment variants, kept only for the historical V1 run mapper. */
56
+ const V1_TREATMENT_VARIANTS = [
57
+ {
58
+ id: STALE_INDEX_VARIANT_ID,
59
+ name: "Stale index",
60
+ description: "Keeps the pre-mutation baseline index as its active retrieval index after the controlled change; never reindexes."
61
+ },
62
+ {
63
+ id: FULL_REFRESH_VARIANT_ID,
64
+ name: "Full refresh",
65
+ description: "Builds a new complete post-mutation index in a separate directory and uses it as its active retrieval index."
66
+ }
67
+ ];
68
+ export function resolveIncrementalChangeStalenessRunOwnedRoot(toolRoot, runId) {
69
+ return resolveWithinRoot(path.resolve(toolRoot), path.join(DEFAULT_INCREMENTAL_CHANGE_STALENESS_RUNTIME_ROOT_RELATIVE, sanitizePathSegment(runId)));
70
+ }
71
+ async function claimRunOwnedRoot(runOwnedRoot, runId) {
72
+ if (existsSync(runOwnedRoot)) {
73
+ throw new Error(`Incremental-change-staleness run-owned runtime root already exists; refusing to reuse it: ${runOwnedRoot}`);
74
+ }
75
+ await mkdir(path.dirname(runOwnedRoot), { recursive: true });
76
+ // Non-recursive: fails if another run claimed the same directory in the meantime.
77
+ await mkdir(runOwnedRoot);
78
+ await writeFile(path.join(runOwnedRoot, INCREMENTAL_CHANGE_STALENESS_RUN_OWNER_MARKER), `${JSON.stringify({ pluginId: INCREMENTAL_CHANGE_STALENESS_PLUGIN_ID, runId }, null, 2)}\n`, "utf8");
79
+ }
80
+ async function isOwnedByRun(runOwnedRoot, runId) {
81
+ try {
82
+ const marker = JSON.parse(await readFile(path.join(runOwnedRoot, INCREMENTAL_CHANGE_STALENESS_RUN_OWNER_MARKER), "utf8"));
83
+ return marker.pluginId === INCREMENTAL_CHANGE_STALENESS_PLUGIN_ID && marker.runId === runId;
84
+ }
85
+ catch {
86
+ return false;
87
+ }
88
+ }
89
+ /**
90
+ * Removes the run-owned runtime root (targets, indexes, command logs, agent artifacts) only when
91
+ * it carries this run's ownership marker. Never touches the shared runtime parent, canonical
92
+ * benchmarks, or any other run's directory. Called by the framework's `cleanup` phase, strictly
93
+ * after `run()` has already written the persisted execution artifact to the (separate) output root.
94
+ */
95
+ export async function cleanupIncrementalChangeStalenessRun(toolRoot, runId) {
96
+ const runOwnedRoot = resolveIncrementalChangeStalenessRunOwnedRoot(toolRoot, runId);
97
+ if (!(await isOwnedByRun(runOwnedRoot, runId))) {
98
+ return false;
99
+ }
100
+ await removeIncrementalChangeStalenessRuntimeRoot(toolRoot, runOwnedRoot);
101
+ return true;
102
+ }
103
+ export const incrementalChangeStalenessPlugin = {
104
+ metadata: incrementalChangeStalenessMetadata,
105
+ defaultConfig: defaultIncrementalChangeStalenessConfig,
106
+ configDefinition: incrementalChangeStalenessConfigDefinition,
107
+ supportedVariants: [...INCREMENTAL_CHANGE_STALENESS_V2_TREATMENT_IDS],
108
+ validateConfig: validateIncrementalChangeStalenessConfig,
109
+ async run(context) {
110
+ if (!context.target.isSelf) {
111
+ throw new Error("incremental-change-staleness runs only against the lab's bundled benchmark projects; an external --target is not supported.");
112
+ }
113
+ const startedAt = context.startedAt.toISOString();
114
+ const runOwnedRoot = resolveIncrementalChangeStalenessRunOwnedRoot(context.toolRoot, context.runId);
115
+ await claimRunOwnedRoot(runOwnedRoot, context.runId);
116
+ const resolvedScenarios = await resolveIncrementalChangeStalenessScenarios({
117
+ repoRoot: context.toolRoot,
118
+ scenarioIds: context.config.caseIds
119
+ });
120
+ const projectProfiles = await readBenchmarkProjectProfiles(path.resolve(context.toolRoot, BENCHMARK_PROJECT_PROFILES_PATH), context.toolRoot);
121
+ const executions = [];
122
+ for (const resolved of resolvedScenarios) {
123
+ const lifecycle = await prepareIncrementalChangeStalenessScenarioLifecycleV2({
124
+ repoRoot: context.toolRoot,
125
+ runOwnedRoot,
126
+ scenario: resolved.scenario,
127
+ baseCase: resolved.baseCase,
128
+ kitCommand: context.config.kitCommand
129
+ });
130
+ const execution = await executeIncrementalChangeStalenessScenarioV2({
131
+ repoRoot: context.toolRoot,
132
+ scenario: resolved.scenario,
133
+ lifecycle,
134
+ baseCase: resolved.baseCase,
135
+ projectProfiles,
136
+ runOwnedRoot,
137
+ cwd: context.target.targetRoot
138
+ });
139
+ executions.push(execution);
140
+ }
141
+ const outDir = context.outputRoot ?? path.resolve(context.toolRoot, context.config.outDir);
142
+ const artifact = buildIncrementalChangeStalenessExecutionArtifactV2({
143
+ runId: context.runId,
144
+ pluginId: INCREMENTAL_CHANGE_STALENESS_PLUGIN_ID,
145
+ executions
146
+ });
147
+ // The artifact is written before the framework's separate `cleanup` phase runs, so it is
148
+ // written before the run-owned ephemeral index/target state is ever destroyed.
149
+ const artifactPath = await writeIncrementalChangeStalenessExecutionArtifactV2(outDir, artifact);
150
+ return mapIncrementalChangeStalenessExecutionsToRunV2({
151
+ runId: context.runId,
152
+ startedAt,
153
+ completedAt: new Date().toISOString(),
154
+ target: context.target,
155
+ kitCommand: context.config.kitCommand,
156
+ executions,
157
+ artifactPath
158
+ });
159
+ },
160
+ summarize(result) {
161
+ return result.summary ?? summarizeExperimentRun(result);
162
+ },
163
+ async cleanup(context) {
164
+ await cleanupIncrementalChangeStalenessRun(context.toolRoot, context.runId);
165
+ }
166
+ };
167
+ function metric(id, name, description, unit, value, variantId, caseId) {
168
+ return { id, name, description, unit, value, variantId, caseId };
169
+ }
170
+ function treatmentMetrics(treatment, caseId) {
171
+ const variantId = treatment.treatmentId;
172
+ const metrics = [];
173
+ if (treatment.retrieval) {
174
+ metrics.push(metric("context-character-count", "Context characters", "Characters in the retrieved my-dev-kit context.", "characters", treatment.retrieval.totalChars, variantId, caseId), metric("context-estimated-token-count", "Estimated context tokens", "Character-based estimate of retrieved context tokens; not provider token usage.", "estimated-tokens", treatment.retrieval.totalEstimatedTokens, variantId, caseId), metric("operation-duration-ms", "Operation duration", "Measured retrieval-only duration (search, lookup, slice, source); excludes index build.", "ms", treatment.retrieval.durationMs, variantId, caseId));
175
+ }
176
+ if (treatment.fakeAgent && treatment.fakeAgent.correctness.available && treatment.fakeAgent.correctness.score !== null) {
177
+ metrics.push(metric("agent-correctness-score", "Agent correctness score", "Deterministic benchmark answer-key correctness score for the selected agent output; not semantic LLM judging.", "score", treatment.fakeAgent.correctness.score, variantId, caseId));
178
+ }
179
+ const affectedMetricValues = toAffectedNeighborhoodMetricValues(treatment.affectedNeighborhood, "No affected-neighborhood assessment was recorded for this treatment.");
180
+ const affectedSpecs = [
181
+ { id: "affected-neighborhood-changed-file-count", name: "Changed indexed files", description: "Unique confirmed modified or missing files that the baseline index snapshot represents.", field: "changedFileCount" },
182
+ { id: "affected-neighborhood-changed-symbol-count", name: "Changed baseline symbols", description: "Baseline indexed symbol identities belonging to confirmed changed indexed files.", field: "changedSymbolCount" },
183
+ { id: "affected-neighborhood-node-count", name: "Affected graph nodes", description: "Unique resolved seed nodes plus their direct one-hop baseline graph neighbors.", field: "affectedNodeCount" },
184
+ { id: "affected-neighborhood-edge-count", name: "Affected graph edges", description: "Unique baseline graph edges incident to at least one resolved seed node.", field: "affectedEdgeCount" },
185
+ { id: "affected-neighborhood-task-overlap-count", name: "Task-overlap nodes", description: "Unique baseline graph nodes both in the affected neighborhood and resolved from the task's expected files/symbols.", field: "taskOverlapCount" },
186
+ { id: "affected-neighborhood-task-overlap-percent", name: "Task-overlap percent", description: "Task-overlap nodes as a percentage of resolvable task graph nodes.", field: "taskOverlapPercent" }
187
+ ];
188
+ for (const spec of affectedSpecs) {
189
+ const value = affectedMetricValues[spec.field];
190
+ if (value.availability === "available" && value.value !== null) {
191
+ metrics.push(metric(spec.id, spec.name, spec.description, value.unit, value.value, variantId, caseId));
192
+ }
193
+ }
194
+ return metrics;
195
+ }
196
+ function treatmentOutcome(execution, treatmentId) {
197
+ const caseId = execution.scenarioId;
198
+ const base = { id: `${caseId}:${treatmentId}`, caseId, variantId: treatmentId, artifacts: [] };
199
+ if (execution.status === "failed" || !execution.stale || !execution.fullRefresh) {
200
+ return {
201
+ ...base,
202
+ status: "failed",
203
+ metrics: [],
204
+ warnings: [],
205
+ failures: [
206
+ {
207
+ code: "incremental-change-staleness-scenario-failed",
208
+ message: execution.failureReason ?? "Scenario execution failed before treatment evidence was produced.",
209
+ variantId: treatmentId,
210
+ caseId,
211
+ recoverable: false
212
+ }
213
+ ],
214
+ metadata: { treatmentId, scenarioStatus: execution.status }
215
+ };
216
+ }
217
+ const treatment = treatmentId === "stale-index" ? execution.stale : execution.fullRefresh;
218
+ const status = treatment.status === "completed" ? "completed" : treatment.status === "failed" ? "failed" : "partial";
219
+ return {
220
+ ...base,
221
+ status,
222
+ metrics: treatmentMetrics(treatment, caseId),
223
+ warnings: treatment.retrievalStatus === "failed" || treatment.fakeAgent?.status === "failed"
224
+ ? [{ code: "incremental-change-staleness-treatment-partial", message: treatment.failureReason ?? "Treatment retrieval or fake-agent evaluation did not fully complete.", variantId: treatmentId, caseId }]
225
+ : [],
226
+ failures: [],
227
+ metadata: {
228
+ treatmentId,
229
+ activeIndexPhase: treatment.activeIndexPhase,
230
+ retrievalStatus: treatment.retrievalStatus,
231
+ fakeAgentStatus: treatment.fakeAgent?.status ?? "not-run",
232
+ requiredFileEvidenceStatus: treatment.requiredFileEvidence.status,
233
+ affectedNeighborhoodRelationship: treatment.affectedNeighborhood.relationship,
234
+ affectedNeighborhoodReindexRecommendation: treatment.affectedNeighborhood.reindexRecommendation,
235
+ comparisonCorrectnessRelation: execution.comparison.correctnessRelation,
236
+ comparisonRequiredFileEvidenceRelation: execution.comparison.requiredFileEvidenceRelation,
237
+ staleRiskClassification: execution.comparison.staleRiskClassification
238
+ }
239
+ };
240
+ }
241
+ export function mapIncrementalChangeStalenessExecutionsToRun(args) {
242
+ const cases = args.executions.map((execution) => ({
243
+ id: execution.scenarioId,
244
+ name: `${execution.scenarioId} (${execution.category})`,
245
+ // Exactly two outcomes per scenario, stale-index then full-refresh (never a third "raw" outcome).
246
+ outcomes: [treatmentOutcome(execution, "stale-index"), treatmentOutcome(execution, "full-refresh")],
247
+ metadata: {
248
+ category: execution.category,
249
+ benchmarkProject: execution.benchmarkProjectId,
250
+ baseCaseId: execution.baseCaseId,
251
+ scenarioStatus: execution.status,
252
+ staleRiskClassification: execution.comparison.staleRiskClassification
253
+ }
254
+ }));
255
+ const outcomeStatuses = cases.flatMap((experimentCase) => experimentCase.outcomes.map((outcome) => outcome.status));
256
+ const status = outcomeStatuses.length > 0 && outcomeStatuses.every((value) => value === "failed")
257
+ ? "failed"
258
+ : outcomeStatuses.every((value) => value === "completed")
259
+ ? "completed"
260
+ : "partial";
261
+ const run = {
262
+ runId: args.runId,
263
+ pluginId: INCREMENTAL_CHANGE_STALENESS_PLUGIN_ID,
264
+ startedAt: args.startedAt,
265
+ completedAt: args.completedAt,
266
+ status,
267
+ target: args.target,
268
+ variants: V1_TREATMENT_VARIANTS.map((variant) => ({ ...variant })),
269
+ cases,
270
+ metrics: [
271
+ { id: "incremental-change-staleness-scenario-count", name: "Scenario count", value: args.executions.length, unit: "count", description: "Selected scenarios in this run." },
272
+ {
273
+ id: "incremental-change-staleness-observed-regression-count",
274
+ name: "Observed stale regression count",
275
+ value: args.executions.filter((execution) => execution.comparison.staleRiskClassification === "observed-stale-regression").length,
276
+ unit: "count",
277
+ description: "Scenarios classified observed-stale-regression."
278
+ }
279
+ ],
280
+ artifacts: [
281
+ {
282
+ id: "incremental-change-staleness-execution",
283
+ label: "Incremental-change-staleness execution evidence",
284
+ path: args.artifactPath,
285
+ kind: "artifact",
286
+ mimeType: "application/json",
287
+ description: "Per-scenario matched stale-index/full-refresh execution and comparison evidence, without raw context bodies."
288
+ }
289
+ ],
290
+ warnings: [],
291
+ failures: [],
292
+ metadata: {
293
+ kitCommand: args.kitCommand,
294
+ scenarioIds: args.executions.map((execution) => execution.scenarioId),
295
+ executionArtifactPath: args.artifactPath
296
+ }
297
+ };
298
+ run.summary = summarizeExperimentRun(run);
299
+ return run;
300
+ }
301
+ // ---------------------------------------------------------------------------
302
+ // v0.6.3 four-treatment mapping. Maps already-executed V2 evidence; recomputes nothing.
303
+ // ---------------------------------------------------------------------------
304
+ const UPSTREAM_REFRESH_METRIC_SPECS = [
305
+ { id: "upstream-refresh-fresh-extraction-file-count", name: "Upstream fresh-extraction files", field: "freshExtractionFileCount", description: "Files my-dev-kit freshly re-extracted during the refresh invocation (upstream execution evidence, not Lab task overlap)." },
306
+ { id: "upstream-refresh-reused-file-count", name: "Upstream reused files", field: "reusedFileCount", description: "Files my-dev-kit reused from the previous index during the refresh invocation (upstream execution evidence)." },
307
+ { id: "upstream-refresh-forced-neighbor-reanalysis-file-count", name: "Upstream forced-neighbor reanalysis files", field: "forcedNeighborReanalysisFileCount", description: "Neighbor files my-dev-kit forced to re-analyze during the refresh invocation (upstream execution evidence)." },
308
+ { id: "upstream-refresh-seed-file-count", name: "Upstream seed files", field: "seedFileCount", description: "Seed files my-dev-kit selected for the affected-neighborhood refresh (upstream execution evidence; absent when upstream reported none)." },
309
+ { id: "upstream-refresh-seed-symbol-count", name: "Upstream seed symbols", field: "seedSymbolCount", description: "Seed symbols my-dev-kit selected for the affected-neighborhood refresh (upstream execution evidence; absent when upstream reported none)." },
310
+ { id: "upstream-refresh-affected-node-count", name: "Upstream affected nodes", field: "affectedNodeCount", description: "Graph nodes my-dev-kit treated as affected during the refresh (upstream execution evidence, distinct from the Lab affected-neighborhood metrics)." },
311
+ { id: "upstream-refresh-affected-edge-count", name: "Upstream affected edges", field: "affectedEdgeCount", description: "Graph edges my-dev-kit treated as affected during the refresh (upstream execution evidence, distinct from the Lab affected-neighborhood metrics)." }
312
+ ];
313
+ function upstreamRefreshMetrics(treatment, caseId) {
314
+ const refresh = treatment.refreshExecution;
315
+ if (refresh.kind !== "incremental")
316
+ return [];
317
+ const metrics = [];
318
+ for (const spec of UPSTREAM_REFRESH_METRIC_SPECS) {
319
+ const value = refresh.incrementalRefresh[spec.field];
320
+ // Null upstream fields are unavailable evidence, never a zero-valued metric.
321
+ if (typeof value === "number")
322
+ metrics.push(metric(spec.id, spec.name, spec.description, "count", value, treatment.treatmentId, caseId));
323
+ }
324
+ return metrics;
325
+ }
326
+ function comparisonMetadata(execution, treatmentId) {
327
+ const comparison = execution.referenceComparisons.find((entry) => entry.candidateTreatmentId === treatmentId);
328
+ if (!comparison)
329
+ return {};
330
+ if (comparison.kind === "stale-risk") {
331
+ return {
332
+ comparisonCorrectnessRelation: comparison.comparison.correctnessRelation,
333
+ comparisonRequiredFileEvidenceRelation: comparison.comparison.requiredFileEvidenceRelation,
334
+ staleRiskClassification: comparison.comparison.staleRiskClassification
335
+ };
336
+ }
337
+ return {
338
+ referenceComparisonCorrectnessRelation: comparison.correctnessRelation,
339
+ referenceComparisonRequiredFileEvidenceRelation: comparison.requiredFileEvidenceRelation,
340
+ referenceComparisonClassification: comparison.classification
341
+ };
342
+ }
343
+ function treatmentOutcomeV2(execution, treatmentId) {
344
+ const caseId = execution.scenarioId;
345
+ const treatmentIntent = INCREMENTAL_CHANGE_STALENESS_V2_TREATMENT_INTENTS[treatmentId];
346
+ const base = { id: `${caseId}:${treatmentId}`, caseId, variantId: treatmentId, artifacts: [] };
347
+ const treatment = execution.treatments.find((entry) => entry.treatmentId === treatmentId);
348
+ if (execution.status === "failed" || !treatment) {
349
+ return {
350
+ ...base,
351
+ status: "failed",
352
+ metrics: [],
353
+ warnings: [],
354
+ failures: [
355
+ {
356
+ code: "incremental-change-staleness-scenario-failed",
357
+ message: execution.failureReason ?? "Scenario execution failed before treatment evidence was produced.",
358
+ variantId: treatmentId,
359
+ caseId,
360
+ recoverable: false
361
+ }
362
+ ],
363
+ metadata: { treatmentId, treatmentIntent, scenarioStatus: execution.status }
364
+ };
365
+ }
366
+ const status = treatment.status === "completed" ? "completed" : treatment.status === "failed" ? "failed" : "partial";
367
+ const refresh = treatment.refreshExecution;
368
+ const incremental = refresh.kind === "incremental" ? refresh.incrementalRefresh : null;
369
+ return {
370
+ ...base,
371
+ status,
372
+ metrics: [...treatmentMetrics(treatment, caseId), ...upstreamRefreshMetrics(treatment, caseId)],
373
+ warnings: treatment.retrievalStatus === "failed" || treatment.fakeAgent?.status === "failed"
374
+ ? [{ code: "incremental-change-staleness-treatment-partial", message: treatment.failureReason ?? "Treatment retrieval or fake-agent evaluation did not fully complete.", variantId: treatmentId, caseId }]
375
+ : [],
376
+ failures: [],
377
+ metadata: {
378
+ treatmentId,
379
+ treatmentIntent,
380
+ activeIndexPhase: treatment.activeIndexPhase,
381
+ treatmentStatus: treatment.status,
382
+ retrievalStatus: treatment.retrievalStatus,
383
+ fakeAgentStatus: treatment.fakeAgent?.status ?? "not-run",
384
+ requiredFileEvidenceStatus: treatment.requiredFileEvidence.status,
385
+ affectedNeighborhoodRelationship: treatment.affectedNeighborhood.relationship,
386
+ affectedNeighborhoodReindexRecommendation: treatment.affectedNeighborhood.reindexRecommendation,
387
+ refreshKind: refresh.kind,
388
+ refreshRealization: refresh.realization,
389
+ // Upstream categorical evidence only for incremental treatments; never invented for the others.
390
+ ...(incremental
391
+ ? {
392
+ requestedScope: incremental.requestedScope,
393
+ appliedScope: incremental.appliedScope,
394
+ selectionStatus: incremental.selectionStatus,
395
+ fallbackReason: incremental.fallbackReason
396
+ }
397
+ : {}),
398
+ ...comparisonMetadata(execution, treatmentId)
399
+ }
400
+ };
401
+ }
402
+ function countByRealization(executions, treatmentId, realization) {
403
+ return executions.filter((execution) => execution.treatments.find((treatment) => treatment.treatmentId === treatmentId)?.refreshExecution.realization === realization).length;
404
+ }
405
+ export function mapIncrementalChangeStalenessExecutionsToRunV2(args) {
406
+ const cases = args.executions.map((execution) => {
407
+ const partialClass = (candidate) => {
408
+ const comparison = execution.referenceComparisons.find((entry) => entry.candidateTreatmentId === candidate);
409
+ return comparison && comparison.kind === "partial-refresh" ? comparison.classification : null;
410
+ };
411
+ const stale = execution.referenceComparisons.find((entry) => entry.candidateTreatmentId === "stale-index");
412
+ return {
413
+ id: execution.scenarioId,
414
+ name: `${execution.scenarioId} (${execution.category})`,
415
+ // Exactly four outcomes per scenario, in the fixed lifecycle order (never a "raw" outcome).
416
+ outcomes: INCREMENTAL_CHANGE_STALENESS_V2_TREATMENT_IDS.map((treatmentId) => treatmentOutcomeV2(execution, treatmentId)),
417
+ metadata: {
418
+ category: execution.category,
419
+ benchmarkProject: execution.benchmarkProjectId,
420
+ baseCaseId: execution.baseCaseId,
421
+ scenarioStatus: execution.status,
422
+ staleRiskClassification: stale && stale.kind === "stale-risk" ? stale.comparison.staleRiskClassification : null,
423
+ changedFilesReferenceClassification: partialClass("changed-files-refresh"),
424
+ affectedNeighborhoodReferenceClassification: partialClass("affected-neighborhood-refresh")
425
+ }
426
+ };
427
+ });
428
+ const outcomeStatuses = cases.flatMap((experimentCase) => experimentCase.outcomes.map((outcome) => outcome.status));
429
+ const status = outcomeStatuses.length > 0 && outcomeStatuses.every((value) => value === "failed")
430
+ ? "failed"
431
+ : outcomeStatuses.every((value) => value === "completed")
432
+ ? "completed"
433
+ : "partial";
434
+ const count = (id, name, value, description) => ({ id, name, value, unit: "count", description });
435
+ const run = {
436
+ runId: args.runId,
437
+ pluginId: INCREMENTAL_CHANGE_STALENESS_PLUGIN_ID,
438
+ startedAt: args.startedAt,
439
+ completedAt: args.completedAt,
440
+ status,
441
+ target: args.target,
442
+ variants: TREATMENT_VARIANTS.map((variant) => ({ ...variant })),
443
+ cases,
444
+ metrics: [
445
+ count("incremental-change-staleness-scenario-count", "Scenario count", args.executions.length, "Selected scenarios in this run."),
446
+ count("incremental-change-staleness-observed-regression-count", "Observed stale regression count", args.executions.filter((execution) => {
447
+ const stale = execution.referenceComparisons.find((entry) => entry.candidateTreatmentId === "stale-index");
448
+ return stale?.kind === "stale-risk" && stale.comparison.staleRiskClassification === "observed-stale-regression";
449
+ }).length, "Scenarios whose no-refresh treatment was classified observed-stale-regression against full refresh."),
450
+ count("incremental-change-staleness-changed-files-applied-partial-count", "Changed-files applied-partial count", countByRealization(args.executions, "changed-files-refresh", "APPLIED_PARTIAL"), "Scenarios where the changed-files refresh was applied as a partial refresh."),
451
+ count("incremental-change-staleness-changed-files-fallback-full-count", "Changed-files fallback-full count", countByRealization(args.executions, "changed-files-refresh", "FALLBACK_FULL"), "Scenarios where the changed-files refresh fell back to a full rebuild."),
452
+ count("incremental-change-staleness-affected-neighborhood-applied-partial-count", "Affected-neighborhood applied-partial count", countByRealization(args.executions, "affected-neighborhood-refresh", "APPLIED_PARTIAL"), "Scenarios where the affected-neighborhood refresh was applied as a partial refresh."),
453
+ count("incremental-change-staleness-affected-neighborhood-fallback-full-count", "Affected-neighborhood fallback-full count", countByRealization(args.executions, "affected-neighborhood-refresh", "FALLBACK_FULL"), "Scenarios where the affected-neighborhood refresh fell back to a full rebuild.")
454
+ ],
455
+ artifacts: [
456
+ {
457
+ id: "incremental-change-staleness-execution",
458
+ label: "Incremental-change-staleness execution evidence",
459
+ path: args.artifactPath,
460
+ kind: "artifact",
461
+ mimeType: "application/json",
462
+ description: "Per-scenario four-treatment execution, refresh-realization, and full-refresh reference-comparison evidence, without raw context bodies."
463
+ }
464
+ ],
465
+ warnings: [],
466
+ failures: [],
467
+ metadata: {
468
+ kitCommand: args.kitCommand,
469
+ scenarioIds: args.executions.map((execution) => execution.scenarioId),
470
+ executionArtifactPath: args.artifactPath
471
+ }
472
+ };
473
+ run.summary = summarizeExperimentRun(run);
474
+ return run;
475
+ }
@@ -0,0 +1,42 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { readBenchmarkProjectProfiles } from "../../../evaluation/benchmarkMetadata.js";
4
+ import { readEvaluationCases } from "../../../evaluation/readEvaluationCases.js";
5
+ import { validateIncrementalChangeStalenessCatalog } from "./scenarioValidation.js";
6
+ /** Canonical repository-relative locations reused by the production catalog. */
7
+ export const INCREMENTAL_CHANGE_STALENESS_SCENARIO_CATALOG_PATH = "benchmarks/contracts/incremental-change-staleness-scenarios.json";
8
+ export const BENCHMARK_PROJECT_PROFILES_PATH = "benchmarks/contracts/benchmark-project-profiles.json";
9
+ export const WARM_INDEX_BENCHMARK_CASES_PATH = "benchmarks/contracts/warm-index-benchmark-cases.json";
10
+ /**
11
+ * Parses and strictly validates an incremental-change-staleness scenario
12
+ * catalog JSON file. Never mutates the referenced benchmark project/case
13
+ * files or the catalog file itself; mutation validation runs entirely
14
+ * in-memory.
15
+ */
16
+ export async function readIncrementalChangeStalenessScenarioCatalog(catalogPath, profiles, cases, repoRoot = process.cwd(), options = { requireFrozenProductionScenarios: true }) {
17
+ let parsed;
18
+ try {
19
+ parsed = JSON.parse(await readFile(catalogPath, "utf8"));
20
+ }
21
+ catch (error) {
22
+ throw new Error(`Failed to parse incremental-change-staleness scenario catalog: ${error.message}`);
23
+ }
24
+ const errors = await validateIncrementalChangeStalenessCatalog(parsed, profiles, cases, repoRoot, {
25
+ requireFrozenProductionScenarios: options.requireFrozenProductionScenarios ?? true
26
+ });
27
+ if (errors.length > 0) {
28
+ throw new Error(`Invalid incremental-change-staleness scenario catalog:\n${errors.join("\n")}`);
29
+ }
30
+ return parsed;
31
+ }
32
+ /**
33
+ * Convenience loader for the canonical production catalog and its two
34
+ * existing benchmark-contract dependencies, resolved relative to `repoRoot`.
35
+ * This is the loader later batches (and this batch's tests) should use to
36
+ * obtain the validated, immutable production scenario set.
37
+ */
38
+ export async function readProductionIncrementalChangeStalenessScenarioCatalog(repoRoot = process.cwd()) {
39
+ const profiles = await readBenchmarkProjectProfiles(path.resolve(repoRoot, BENCHMARK_PROJECT_PROFILES_PATH), repoRoot);
40
+ const cases = await readEvaluationCases(path.resolve(repoRoot, WARM_INDEX_BENCHMARK_CASES_PATH), repoRoot);
41
+ return readIncrementalChangeStalenessScenarioCatalog(path.resolve(repoRoot, INCREMENTAL_CHANGE_STALENESS_SCENARIO_CATALOG_PATH), profiles, cases, repoRoot, { requireFrozenProductionScenarios: true });
42
+ }
@@ -0,0 +1,116 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { resolveWithinRoot } from "../../../core/pathSafety.js";
5
+ export function sha256Hex(data) {
6
+ return createHash("sha256").update(data).digest("hex");
7
+ }
8
+ /**
9
+ * True when `relativePath` (project-root-relative, either separator style) is
10
+ * covered by one of the declared source roots, i.e. equals a root or is
11
+ * nested underneath one. Shared by Batch 1 catalog validation and Batch 2
12
+ * execution-time re-validation so both enforce identical coverage semantics.
13
+ */
14
+ export function isPathCoveredBySourceRoots(relativePath, sourceRoots) {
15
+ const normalizedFilePath = relativePath.replace(/\\/g, "/");
16
+ return sourceRoots.some((root) => {
17
+ const normalizedRoot = root.replace(/\\/g, "/").replace(/\/+$/, "");
18
+ return normalizedFilePath === normalizedRoot || normalizedFilePath.startsWith(`${normalizedRoot}/`);
19
+ });
20
+ }
21
+ /**
22
+ * Applies one scenario's ordered, literal replacement operations to in-memory
23
+ * text. Every preimage must match exactly once at the point it is applied;
24
+ * this never touches the filesystem.
25
+ */
26
+ export function applyMutationOperations(text, operations) {
27
+ let mutated = text;
28
+ for (const [index, operation] of operations.entries()) {
29
+ const occurrences = countOccurrences(mutated, operation.expectedPreimage);
30
+ if (occurrences === 0) {
31
+ throw new Error(`operation ${index}: expectedPreimage not found: ${JSON.stringify(operation.expectedPreimage)}.`);
32
+ }
33
+ if (occurrences > 1) {
34
+ throw new Error(`operation ${index}: expectedPreimage matches ${occurrences} times; it must match exactly once: ${JSON.stringify(operation.expectedPreimage)}.`);
35
+ }
36
+ mutated = mutated.split(operation.expectedPreimage).join(operation.replacement);
37
+ }
38
+ return mutated;
39
+ }
40
+ function countOccurrences(text, needle) {
41
+ if (needle.length === 0) {
42
+ return 0;
43
+ }
44
+ return text.split(needle).length - 1;
45
+ }
46
+ /**
47
+ * Validates one controlled mutation file declaration against the real,
48
+ * on-disk canonical benchmark file:
49
+ * - the resolved path stays within the benchmark project root;
50
+ * - the resolved path is covered by one of the case's configured indexed
51
+ * source roots;
52
+ * - the current canonical SHA-256 equals `expectedPreSha256`;
53
+ * - the ordered operations apply cleanly to in-memory text;
54
+ * - the resulting in-memory SHA-256 equals `expectedPostSha256`.
55
+ *
56
+ * This function only reads the canonical file; it never writes to disk, so
57
+ * canonical benchmark source/test files remain byte-for-byte unchanged.
58
+ */
59
+ export async function validateMutationFile(label, file, projectAbsoluteRoot, caseSourceRoots) {
60
+ const errors = [];
61
+ if (typeof file.path !== "string" || file.path.length === 0) {
62
+ return { errors: [`${label}: mutation file path must be a nonempty string.`] };
63
+ }
64
+ if (path.isAbsolute(file.path) || file.path.includes("..")) {
65
+ return { errors: [`${label}: mutation file path must be a safe project-relative path: ${file.path}.`] };
66
+ }
67
+ const covered = isPathCoveredBySourceRoots(file.path, caseSourceRoots);
68
+ if (!covered) {
69
+ errors.push(`${label}: mutation file path ${file.path} is not covered by the referenced case's indexed source roots (${caseSourceRoots.join(", ")}).`);
70
+ }
71
+ let absolutePath;
72
+ try {
73
+ absolutePath = resolveWithinRoot(projectAbsoluteRoot, file.path);
74
+ }
75
+ catch (error) {
76
+ return { errors: [`${label}: ${error.message}`] };
77
+ }
78
+ if (typeof file.expectedPreSha256 !== "string" || !/^[0-9a-f]{64}$/.test(file.expectedPreSha256)) {
79
+ errors.push(`${label}: expectedPreSha256 must be a lowercase 64-character hex SHA-256 string.`);
80
+ }
81
+ if (typeof file.expectedPostSha256 !== "string" || !/^[0-9a-f]{64}$/.test(file.expectedPostSha256)) {
82
+ errors.push(`${label}: expectedPostSha256 must be a lowercase 64-character hex SHA-256 string.`);
83
+ }
84
+ if (!Array.isArray(file.operations) || file.operations.length === 0) {
85
+ errors.push(`${label}: operations must be a nonempty array.`);
86
+ }
87
+ if (errors.length > 0) {
88
+ return { errors };
89
+ }
90
+ let buffer;
91
+ try {
92
+ buffer = await readFile(absolutePath);
93
+ }
94
+ catch (error) {
95
+ return { errors: [`${label}: unable to read mutation file ${file.path}: ${error.message}`] };
96
+ }
97
+ const actualPreSha256 = sha256Hex(buffer);
98
+ if (actualPreSha256 !== file.expectedPreSha256) {
99
+ errors.push(`${label}: expectedPreSha256 ${file.expectedPreSha256} does not match actual canonical file SHA-256 ${actualPreSha256}.`);
100
+ return { errors };
101
+ }
102
+ let mutatedText;
103
+ try {
104
+ mutatedText = applyMutationOperations(buffer.toString("utf8"), file.operations);
105
+ }
106
+ catch (error) {
107
+ errors.push(`${label}: ${error.message}`);
108
+ return { errors };
109
+ }
110
+ const actualPostSha256 = sha256Hex(Buffer.from(mutatedText, "utf8"));
111
+ if (actualPostSha256 !== file.expectedPostSha256) {
112
+ errors.push(`${label}: expectedPostSha256 ${file.expectedPostSha256} does not match computed in-memory post-mutation SHA-256 ${actualPostSha256}.`);
113
+ return { errors };
114
+ }
115
+ return { errors: [], mutatedText };
116
+ }