@dailephd/my-dev-kit-lab 0.6.0 → 0.6.2

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 (44) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +7 -4
  3. package/benchmarks/contracts/incremental-change-staleness-scenarios.json +360 -0
  4. package/dist/src/commands/runExperimentRunCommand.js +4 -3
  5. package/dist/src/evaluation/affectedNeighborhood.js +712 -0
  6. package/dist/src/evaluation/affectedNeighborhoodMetrics.js +51 -0
  7. package/dist/src/evaluation/indexSnapshot.js +1 -1
  8. package/dist/src/experiments/defaultRegistry.js +2 -0
  9. package/dist/src/experiments/plugins/incrementalChangeStaleness/comparison.js +101 -0
  10. package/dist/src/experiments/plugins/incrementalChangeStaleness/config.js +66 -0
  11. package/dist/src/experiments/plugins/incrementalChangeStaleness/disposableTarget.js +107 -0
  12. package/dist/src/experiments/plugins/incrementalChangeStaleness/execution.js +404 -0
  13. package/dist/src/experiments/plugins/incrementalChangeStaleness/executionArtifact.js +160 -0
  14. package/dist/src/experiments/plugins/incrementalChangeStaleness/index.js +13 -0
  15. package/dist/src/experiments/plugins/incrementalChangeStaleness/lifecycle.js +276 -0
  16. package/dist/src/experiments/plugins/incrementalChangeStaleness/lifecyclePolicy.js +152 -0
  17. package/dist/src/experiments/plugins/incrementalChangeStaleness/mutationExecution.js +144 -0
  18. package/dist/src/experiments/plugins/incrementalChangeStaleness/plugin.js +274 -0
  19. package/dist/src/experiments/plugins/incrementalChangeStaleness/scenarioCatalog.js +42 -0
  20. package/dist/src/experiments/plugins/incrementalChangeStaleness/scenarioMutation.js +116 -0
  21. package/dist/src/experiments/plugins/incrementalChangeStaleness/scenarioSelection.js +79 -0
  22. package/dist/src/experiments/plugins/incrementalChangeStaleness/scenarioTypes.js +37 -0
  23. package/dist/src/experiments/plugins/incrementalChangeStaleness/scenarioValidation.js +168 -0
  24. package/dist/src/experiments/plugins/incrementalChangeStaleness/sourceState.js +145 -0
  25. package/dist/src/experiments/plugins/incrementalChangeStaleness/treatmentSession.js +6 -0
  26. package/dist/src/experiments/plugins/index.js +1 -0
  27. package/dist/src/experiments/plugins/warmIndexReuse/execution.js +21 -0
  28. package/dist/src/experiments/plugins/warmIndexReuse/executionArtifact.js +1 -0
  29. package/dist/src/experiments/plugins/warmIndexReuse/metrics.js +68 -0
  30. package/dist/src/experiments/plugins/warmIndexReuse/warmIndexSession.js +9 -1
  31. package/dist/src/report/experiments/buildIncrementalChangeStalenessReport.js +112 -0
  32. package/dist/src/report/experiments/buildPluginExperimentReport.js +17 -2
  33. package/dist/src/report/experiments/buildWarmIndexReuseReport.js +80 -3
  34. package/dist/src/report/experiments/incrementalChangeStalenessReportModel.js +4 -0
  35. package/dist/src/report/experiments/index.js +3 -0
  36. package/dist/src/report/experiments/renderIncrementalChangeStalenessHtml.js +239 -0
  37. package/dist/src/report/experiments/renderPluginExperimentReportHtml.js +3 -0
  38. package/dist/src/report/experiments/renderPluginExperimentReportText.js +222 -0
  39. package/dist/src/report/experiments/renderWarmIndexReuseHtml.js +98 -0
  40. package/dist/src/report/experiments/warmIndexReuseReportModel.js +5 -0
  41. package/docs/METRICS.md +79 -3
  42. package/examples/tutorial-browser/prepare.mjs +13 -13
  43. package/examples/tutorial-browser/server.mjs +13 -13
  44. package/package.json +1 -1
@@ -0,0 +1,274 @@
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_TREATMENT_IDS, removeIncrementalChangeStalenessRuntimeRoot } from "./disposableTarget.js";
11
+ import { executeIncrementalChangeStalenessScenario } from "./execution.js";
12
+ import { buildIncrementalChangeStalenessExecutionArtifact, writeIncrementalChangeStalenessExecutionArtifact } from "./executionArtifact.js";
13
+ import { prepareIncrementalChangeStalenessScenarioLifecycle } from "./lifecycle.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 stale-index and full-refresh my-dev-kit retrieval after the same frozen controlled source change: matched correctness, required-file evidence, and a conservative stale-risk classification.",
25
+ schemaVersion: "1.0.0",
26
+ status: "experimental",
27
+ supportedTargets: ["self"],
28
+ supportedOutputs: ["json", "html"]
29
+ };
30
+ const TREATMENT_VARIANTS = [
31
+ {
32
+ id: STALE_INDEX_VARIANT_ID,
33
+ name: "Stale index",
34
+ description: "Keeps the pre-mutation baseline index as its active retrieval index after the controlled change; never reindexes."
35
+ },
36
+ {
37
+ id: FULL_REFRESH_VARIANT_ID,
38
+ name: "Full refresh",
39
+ description: "Builds a new complete post-mutation index in a separate directory and uses it as its active retrieval index."
40
+ }
41
+ ];
42
+ export function resolveIncrementalChangeStalenessRunOwnedRoot(toolRoot, runId) {
43
+ return resolveWithinRoot(path.resolve(toolRoot), path.join(DEFAULT_INCREMENTAL_CHANGE_STALENESS_RUNTIME_ROOT_RELATIVE, sanitizePathSegment(runId)));
44
+ }
45
+ async function claimRunOwnedRoot(runOwnedRoot, runId) {
46
+ if (existsSync(runOwnedRoot)) {
47
+ throw new Error(`Incremental-change-staleness run-owned runtime root already exists; refusing to reuse it: ${runOwnedRoot}`);
48
+ }
49
+ await mkdir(path.dirname(runOwnedRoot), { recursive: true });
50
+ // Non-recursive: fails if another run claimed the same directory in the meantime.
51
+ await mkdir(runOwnedRoot);
52
+ await writeFile(path.join(runOwnedRoot, INCREMENTAL_CHANGE_STALENESS_RUN_OWNER_MARKER), `${JSON.stringify({ pluginId: INCREMENTAL_CHANGE_STALENESS_PLUGIN_ID, runId }, null, 2)}\n`, "utf8");
53
+ }
54
+ async function isOwnedByRun(runOwnedRoot, runId) {
55
+ try {
56
+ const marker = JSON.parse(await readFile(path.join(runOwnedRoot, INCREMENTAL_CHANGE_STALENESS_RUN_OWNER_MARKER), "utf8"));
57
+ return marker.pluginId === INCREMENTAL_CHANGE_STALENESS_PLUGIN_ID && marker.runId === runId;
58
+ }
59
+ catch {
60
+ return false;
61
+ }
62
+ }
63
+ /**
64
+ * Removes the run-owned runtime root (targets, indexes, command logs, agent artifacts) only when
65
+ * it carries this run's ownership marker. Never touches the shared runtime parent, canonical
66
+ * benchmarks, or any other run's directory. Called by the framework's `cleanup` phase, strictly
67
+ * after `run()` has already written the persisted execution artifact to the (separate) output root.
68
+ */
69
+ export async function cleanupIncrementalChangeStalenessRun(toolRoot, runId) {
70
+ const runOwnedRoot = resolveIncrementalChangeStalenessRunOwnedRoot(toolRoot, runId);
71
+ if (!(await isOwnedByRun(runOwnedRoot, runId))) {
72
+ return false;
73
+ }
74
+ await removeIncrementalChangeStalenessRuntimeRoot(toolRoot, runOwnedRoot);
75
+ return true;
76
+ }
77
+ export const incrementalChangeStalenessPlugin = {
78
+ metadata: incrementalChangeStalenessMetadata,
79
+ defaultConfig: defaultIncrementalChangeStalenessConfig,
80
+ configDefinition: incrementalChangeStalenessConfigDefinition,
81
+ supportedVariants: [...INCREMENTAL_CHANGE_STALENESS_TREATMENT_IDS],
82
+ validateConfig: validateIncrementalChangeStalenessConfig,
83
+ async run(context) {
84
+ if (!context.target.isSelf) {
85
+ throw new Error("incremental-change-staleness runs only against the lab's bundled benchmark projects; an external --target is not supported.");
86
+ }
87
+ const startedAt = context.startedAt.toISOString();
88
+ const runOwnedRoot = resolveIncrementalChangeStalenessRunOwnedRoot(context.toolRoot, context.runId);
89
+ await claimRunOwnedRoot(runOwnedRoot, context.runId);
90
+ const resolvedScenarios = await resolveIncrementalChangeStalenessScenarios({
91
+ repoRoot: context.toolRoot,
92
+ scenarioIds: context.config.caseIds
93
+ });
94
+ const projectProfiles = await readBenchmarkProjectProfiles(path.resolve(context.toolRoot, BENCHMARK_PROJECT_PROFILES_PATH), context.toolRoot);
95
+ const executions = [];
96
+ for (const resolved of resolvedScenarios) {
97
+ const lifecycle = await prepareIncrementalChangeStalenessScenarioLifecycle({
98
+ repoRoot: context.toolRoot,
99
+ runOwnedRoot,
100
+ scenario: resolved.scenario,
101
+ baseCase: resolved.baseCase,
102
+ kitCommand: context.config.kitCommand
103
+ });
104
+ const execution = await executeIncrementalChangeStalenessScenario({
105
+ repoRoot: context.toolRoot,
106
+ scenario: resolved.scenario,
107
+ lifecycle,
108
+ baseCase: resolved.baseCase,
109
+ projectProfiles,
110
+ runOwnedRoot,
111
+ cwd: context.target.targetRoot
112
+ });
113
+ executions.push(execution);
114
+ }
115
+ const outDir = context.outputRoot ?? path.resolve(context.toolRoot, context.config.outDir);
116
+ const artifact = buildIncrementalChangeStalenessExecutionArtifact({
117
+ runId: context.runId,
118
+ pluginId: INCREMENTAL_CHANGE_STALENESS_PLUGIN_ID,
119
+ executions
120
+ });
121
+ // The artifact is written before the framework's separate `cleanup` phase runs, so it is
122
+ // written before the run-owned ephemeral index/target state is ever destroyed.
123
+ const artifactPath = await writeIncrementalChangeStalenessExecutionArtifact(outDir, artifact);
124
+ return mapIncrementalChangeStalenessExecutionsToRun({
125
+ runId: context.runId,
126
+ startedAt,
127
+ completedAt: new Date().toISOString(),
128
+ target: context.target,
129
+ kitCommand: context.config.kitCommand,
130
+ executions,
131
+ artifactPath
132
+ });
133
+ },
134
+ summarize(result) {
135
+ return result.summary ?? summarizeExperimentRun(result);
136
+ },
137
+ async cleanup(context) {
138
+ await cleanupIncrementalChangeStalenessRun(context.toolRoot, context.runId);
139
+ }
140
+ };
141
+ function metric(id, name, description, unit, value, variantId, caseId) {
142
+ return { id, name, description, unit, value, variantId, caseId };
143
+ }
144
+ function treatmentMetrics(treatment, caseId) {
145
+ const variantId = treatment.treatmentId;
146
+ const metrics = [];
147
+ if (treatment.retrieval) {
148
+ 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));
149
+ }
150
+ if (treatment.fakeAgent && treatment.fakeAgent.correctness.available && treatment.fakeAgent.correctness.score !== null) {
151
+ 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));
152
+ }
153
+ const affectedMetricValues = toAffectedNeighborhoodMetricValues(treatment.affectedNeighborhood, "No affected-neighborhood assessment was recorded for this treatment.");
154
+ const affectedSpecs = [
155
+ { 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" },
156
+ { id: "affected-neighborhood-changed-symbol-count", name: "Changed baseline symbols", description: "Baseline indexed symbol identities belonging to confirmed changed indexed files.", field: "changedSymbolCount" },
157
+ { id: "affected-neighborhood-node-count", name: "Affected graph nodes", description: "Unique resolved seed nodes plus their direct one-hop baseline graph neighbors.", field: "affectedNodeCount" },
158
+ { id: "affected-neighborhood-edge-count", name: "Affected graph edges", description: "Unique baseline graph edges incident to at least one resolved seed node.", field: "affectedEdgeCount" },
159
+ { 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" },
160
+ { id: "affected-neighborhood-task-overlap-percent", name: "Task-overlap percent", description: "Task-overlap nodes as a percentage of resolvable task graph nodes.", field: "taskOverlapPercent" }
161
+ ];
162
+ for (const spec of affectedSpecs) {
163
+ const value = affectedMetricValues[spec.field];
164
+ if (value.availability === "available" && value.value !== null) {
165
+ metrics.push(metric(spec.id, spec.name, spec.description, value.unit, value.value, variantId, caseId));
166
+ }
167
+ }
168
+ return metrics;
169
+ }
170
+ function treatmentOutcome(execution, treatmentId) {
171
+ const caseId = execution.scenarioId;
172
+ const base = { id: `${caseId}:${treatmentId}`, caseId, variantId: treatmentId, artifacts: [] };
173
+ if (execution.status === "failed" || !execution.stale || !execution.fullRefresh) {
174
+ return {
175
+ ...base,
176
+ status: "failed",
177
+ metrics: [],
178
+ warnings: [],
179
+ failures: [
180
+ {
181
+ code: "incremental-change-staleness-scenario-failed",
182
+ message: execution.failureReason ?? "Scenario execution failed before treatment evidence was produced.",
183
+ variantId: treatmentId,
184
+ caseId,
185
+ recoverable: false
186
+ }
187
+ ],
188
+ metadata: { treatmentId, scenarioStatus: execution.status }
189
+ };
190
+ }
191
+ const treatment = treatmentId === "stale-index" ? execution.stale : execution.fullRefresh;
192
+ const status = treatment.status === "completed" ? "completed" : treatment.status === "failed" ? "failed" : "partial";
193
+ return {
194
+ ...base,
195
+ status,
196
+ metrics: treatmentMetrics(treatment, caseId),
197
+ warnings: treatment.retrievalStatus === "failed" || treatment.fakeAgent?.status === "failed"
198
+ ? [{ code: "incremental-change-staleness-treatment-partial", message: treatment.failureReason ?? "Treatment retrieval or fake-agent evaluation did not fully complete.", variantId: treatmentId, caseId }]
199
+ : [],
200
+ failures: [],
201
+ metadata: {
202
+ treatmentId,
203
+ activeIndexPhase: treatment.activeIndexPhase,
204
+ retrievalStatus: treatment.retrievalStatus,
205
+ fakeAgentStatus: treatment.fakeAgent?.status ?? "not-run",
206
+ requiredFileEvidenceStatus: treatment.requiredFileEvidence.status,
207
+ affectedNeighborhoodRelationship: treatment.affectedNeighborhood.relationship,
208
+ affectedNeighborhoodReindexRecommendation: treatment.affectedNeighborhood.reindexRecommendation,
209
+ comparisonCorrectnessRelation: execution.comparison.correctnessRelation,
210
+ comparisonRequiredFileEvidenceRelation: execution.comparison.requiredFileEvidenceRelation,
211
+ staleRiskClassification: execution.comparison.staleRiskClassification
212
+ }
213
+ };
214
+ }
215
+ export function mapIncrementalChangeStalenessExecutionsToRun(args) {
216
+ const cases = args.executions.map((execution) => ({
217
+ id: execution.scenarioId,
218
+ name: `${execution.scenarioId} (${execution.category})`,
219
+ // Exactly two outcomes per scenario, stale-index then full-refresh (never a third "raw" outcome).
220
+ outcomes: [treatmentOutcome(execution, "stale-index"), treatmentOutcome(execution, "full-refresh")],
221
+ metadata: {
222
+ category: execution.category,
223
+ benchmarkProject: execution.benchmarkProjectId,
224
+ baseCaseId: execution.baseCaseId,
225
+ scenarioStatus: execution.status,
226
+ staleRiskClassification: execution.comparison.staleRiskClassification
227
+ }
228
+ }));
229
+ const outcomeStatuses = cases.flatMap((experimentCase) => experimentCase.outcomes.map((outcome) => outcome.status));
230
+ const status = outcomeStatuses.length > 0 && outcomeStatuses.every((value) => value === "failed")
231
+ ? "failed"
232
+ : outcomeStatuses.every((value) => value === "completed")
233
+ ? "completed"
234
+ : "partial";
235
+ const run = {
236
+ runId: args.runId,
237
+ pluginId: INCREMENTAL_CHANGE_STALENESS_PLUGIN_ID,
238
+ startedAt: args.startedAt,
239
+ completedAt: args.completedAt,
240
+ status,
241
+ target: args.target,
242
+ variants: TREATMENT_VARIANTS.map((variant) => ({ ...variant })),
243
+ cases,
244
+ metrics: [
245
+ { id: "incremental-change-staleness-scenario-count", name: "Scenario count", value: args.executions.length, unit: "count", description: "Selected scenarios in this run." },
246
+ {
247
+ id: "incremental-change-staleness-observed-regression-count",
248
+ name: "Observed stale regression count",
249
+ value: args.executions.filter((execution) => execution.comparison.staleRiskClassification === "observed-stale-regression").length,
250
+ unit: "count",
251
+ description: "Scenarios classified observed-stale-regression."
252
+ }
253
+ ],
254
+ artifacts: [
255
+ {
256
+ id: "incremental-change-staleness-execution",
257
+ label: "Incremental-change-staleness execution evidence",
258
+ path: args.artifactPath,
259
+ kind: "artifact",
260
+ mimeType: "application/json",
261
+ description: "Per-scenario matched stale-index/full-refresh execution and comparison evidence, without raw context bodies."
262
+ }
263
+ ],
264
+ warnings: [],
265
+ failures: [],
266
+ metadata: {
267
+ kitCommand: args.kitCommand,
268
+ scenarioIds: args.executions.map((execution) => execution.scenarioId),
269
+ executionArtifactPath: args.artifactPath
270
+ }
271
+ };
272
+ run.summary = summarizeExperimentRun(run);
273
+ return run;
274
+ }
@@ -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
+ }
@@ -0,0 +1,79 @@
1
+ import path from "node:path";
2
+ import { readBenchmarkProjectProfiles } from "../../../evaluation/benchmarkMetadata.js";
3
+ import { readEvaluationCases } from "../../../evaluation/readEvaluationCases.js";
4
+ import { normalizeRootForComparison } from "./lifecyclePolicy.js";
5
+ import { BENCHMARK_PROJECT_PROFILES_PATH, INCREMENTAL_CHANGE_STALENESS_SCENARIO_CATALOG_PATH, WARM_INDEX_BENCHMARK_CASES_PATH, readIncrementalChangeStalenessScenarioCatalog } from "./scenarioCatalog.js";
6
+ /**
7
+ * Policy: selects scenarios from the validated catalog. No selection means
8
+ * every catalog scenario. An explicit selection must be non-empty, contain no
9
+ * duplicates, and name only catalog scenarios. The result always follows
10
+ * catalog order, never request order, so run ordering is deterministic.
11
+ */
12
+ export function selectIncrementalChangeStalenessScenarios(catalog, scenarioIds) {
13
+ if (scenarioIds === undefined) {
14
+ return [...catalog.scenarios];
15
+ }
16
+ if (scenarioIds.length === 0) {
17
+ throw new Error("Scenario selection must name at least one scenario when provided.");
18
+ }
19
+ const duplicates = scenarioIds.filter((id, index) => scenarioIds.indexOf(id) !== index);
20
+ if (duplicates.length > 0) {
21
+ throw new Error(`Scenario selection contains duplicate scenario id(s): ${[...new Set(duplicates)].join(", ")}.`);
22
+ }
23
+ const known = new Set(catalog.scenarios.map((scenario) => scenario.id));
24
+ const unknown = scenarioIds.filter((id) => !known.has(id));
25
+ if (unknown.length > 0) {
26
+ throw new Error(`Unknown incremental-change-staleness scenario id(s): ${unknown.join(", ")}. Known: ${[...known].join(", ")}.`);
27
+ }
28
+ const requested = new Set(scenarioIds);
29
+ return catalog.scenarios.filter((scenario) => requested.has(scenario.id));
30
+ }
31
+ /**
32
+ * Policy: resolves a scenario's canonical base case identity from the existing
33
+ * benchmark case and project-profile contracts. Source roots always come from
34
+ * the base case; the canonical project root always comes from the project
35
+ * profile, and the two must agree.
36
+ */
37
+ export function resolveIncrementalChangeStalenessBaseCase(scenario, cases, profiles, repoRoot) {
38
+ const baseCase = cases.find((candidate) => candidate.id === scenario.baseCaseId);
39
+ if (!baseCase) {
40
+ throw new Error(`Scenario ${scenario.id}: base case ${scenario.baseCaseId} was not found.`);
41
+ }
42
+ const profile = profiles.find((candidate) => candidate.projectId === scenario.benchmarkProjectId);
43
+ if (!profile) {
44
+ throw new Error(`Scenario ${scenario.id}: benchmark project ${scenario.benchmarkProjectId} was not found.`);
45
+ }
46
+ if (baseCase.benchmarkProject !== scenario.benchmarkProjectId) {
47
+ throw new Error(`Scenario ${scenario.id}: base case ${baseCase.id} belongs to ${baseCase.benchmarkProject}, not ${scenario.benchmarkProjectId}.`);
48
+ }
49
+ const canonicalProjectRoot = path.resolve(repoRoot, profile.rootPath);
50
+ if (normalizeRootForComparison(canonicalProjectRoot) !== normalizeRootForComparison(baseCase.absoluteTargetRoot)) {
51
+ throw new Error(`Scenario ${scenario.id}: base case target root ${baseCase.absoluteTargetRoot} does not match project profile root ${canonicalProjectRoot}.`);
52
+ }
53
+ if (!Array.isArray(baseCase.sourceRoots) || baseCase.sourceRoots.length === 0) {
54
+ throw new Error(`Scenario ${scenario.id}: base case ${baseCase.id} declares no source roots.`);
55
+ }
56
+ return Object.freeze({
57
+ caseId: baseCase.id,
58
+ benchmarkProjectId: scenario.benchmarkProjectId,
59
+ canonicalProjectRootRelative: profile.rootPath,
60
+ canonicalProjectRoot,
61
+ sourceRoots: Object.freeze([...baseCase.sourceRoots])
62
+ });
63
+ }
64
+ /**
65
+ * Loads the canonical production scenario catalog (the only mutation
66
+ * authority) and its benchmark case/profile dependencies, then resolves the
67
+ * selected scenarios with their base-case identities in catalog order.
68
+ * Read-only.
69
+ */
70
+ export async function resolveIncrementalChangeStalenessScenarios(options) {
71
+ const { repoRoot } = options;
72
+ const profiles = await readBenchmarkProjectProfiles(path.resolve(repoRoot, BENCHMARK_PROJECT_PROFILES_PATH), repoRoot);
73
+ const cases = await readEvaluationCases(path.resolve(repoRoot, WARM_INDEX_BENCHMARK_CASES_PATH), repoRoot);
74
+ const catalog = await readIncrementalChangeStalenessScenarioCatalog(path.resolve(repoRoot, INCREMENTAL_CHANGE_STALENESS_SCENARIO_CATALOG_PATH), profiles, cases, repoRoot, { requireFrozenProductionScenarios: true });
75
+ return selectIncrementalChangeStalenessScenarios(catalog, options.scenarioIds).map((scenario) => ({
76
+ scenario,
77
+ baseCase: resolveIncrementalChangeStalenessBaseCase(scenario, cases, profiles, repoRoot)
78
+ }));
79
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * v0.6.2 incremental-change-staleness scenario catalog schema version.
3
+ *
4
+ * This is a separate, versioned contract from the warm-index benchmark case
5
+ * contract (`benchmarks/contracts/warm-index-benchmark-cases.json`), which
6
+ * remains unchanged. Scenario records here reference existing benchmark
7
+ * project/case ids rather than duplicating canonical project/target metadata.
8
+ */
9
+ export const INCREMENTAL_CHANGE_STALENESS_SCENARIO_SCHEMA_VERSION = "1.0.0";
10
+ /**
11
+ * The experimental dimension a scenario deliberately changes. Categories are
12
+ * not required to be structurally exclusive (for example, an exported
13
+ * function body is still implementation code); the selected category
14
+ * describes the intended experimental contrast, not an exhaustive structural
15
+ * classification.
16
+ */
17
+ export const INCREMENTAL_CHANGE_STALENESS_SCENARIO_CATEGORIES = [
18
+ "unrelated-file-change",
19
+ "local-implementation-change",
20
+ "exported-symbol-change",
21
+ "public-api-change",
22
+ "import-graph-change",
23
+ "test-only-change"
24
+ ];
25
+ /**
26
+ * Answer-key policy for a scenario's follow-up query:
27
+ * - `inherit`: reuse the referenced base case's existing query/answer key
28
+ * because the controlled change does not alter that task's semantic truth.
29
+ * - `scenario`: use a scenario-specific post-mutation query and answer key,
30
+ * expressed with the existing benchmark answer-key structure.
31
+ *
32
+ * Exactly these two policies are supported; no other correctness vocabulary
33
+ * is introduced.
34
+ */
35
+ export const INCREMENTAL_CHANGE_STALENESS_ANSWER_POLICIES = ["inherit", "scenario"];
36
+ /** The six planner-frozen production scenario ids, in their planned order. */
37
+ export const FROZEN_INCREMENTAL_CHANGE_STALENESS_SCENARIO_IDS = ["U1", "L2", "E1", "P1", "I1", "T1"];