@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,76 @@
1
+ import { existsSync } from "node:fs";
2
+ import { cp, lstat, mkdir, readdir, readFile, realpath } from "node:fs/promises";
3
+ import { createHash } from "node:crypto";
4
+ import path from "node:path";
5
+ import { resolveWithinRoot } from "../../../core/pathSafety.js";
6
+ /**
7
+ * Narrow v0.6.3 lifecycle-preparation helper: copies one already-produced my-dev-kit index
8
+ * directory byte-for-byte to a new sibling directory so an upstream incremental refresh can run
9
+ * in place against the copy while the original baseline stays immutable change authority.
10
+ *
11
+ * Both paths must resolve inside `runOwnedRoot` (textually and, for the destination parent, by
12
+ * real path), the destination must not exist, symlinks in the source are refused, and the copy
13
+ * is verified file-for-file by SHA-256. Index contents are never edited. Any failure throws.
14
+ */
15
+ export async function cloneIncrementalChangeStalenessIndexDirectory(runOwnedRoot, sourceDir, destinationDir) {
16
+ const root = path.resolve(runOwnedRoot);
17
+ const source = resolveWithinRoot(root, path.resolve(sourceDir));
18
+ const destination = resolveWithinRoot(root, path.resolve(destinationDir));
19
+ if (!existsSync(source)) {
20
+ throw new Error(`Index clone source does not exist: ${source}.`);
21
+ }
22
+ if (existsSync(destination)) {
23
+ throw new Error(`Index clone destination already exists; refusing to overwrite: ${destination}.`);
24
+ }
25
+ const relativeToSource = path.relative(source, destination);
26
+ if (relativeToSource === "" || (!relativeToSource.startsWith("..") && !path.isAbsolute(relativeToSource))) {
27
+ throw new Error(`Index clone destination ${destination} must not be inside the source ${source}.`);
28
+ }
29
+ const sourceFiles = await listRegularFiles(source);
30
+ // The destination parent must be real-path contained by the real run-owned root (no symlink escape).
31
+ const parent = path.dirname(destination);
32
+ await mkdir(parent, { recursive: true });
33
+ const [realParent, realRoot] = await Promise.all([realpath(parent), realpath(root)]);
34
+ const parentRelative = path.relative(realRoot, realParent);
35
+ if (parentRelative === ".." || parentRelative.startsWith(`..${path.sep}`) || path.isAbsolute(parentRelative)) {
36
+ throw new Error(`Index clone destination parent escapes the run-owned root via a symbolic link: ${parent}.`);
37
+ }
38
+ await cp(source, destination, { recursive: true, errorOnExist: true, force: false, dereference: false });
39
+ const copiedFiles = await listRegularFiles(destination);
40
+ if (copiedFiles.length !== sourceFiles.length || copiedFiles.some((file, index) => file !== sourceFiles[index])) {
41
+ throw new Error(`Index clone is incomplete: ${destination} does not contain exactly the files of ${source}.`);
42
+ }
43
+ for (const file of sourceFiles) {
44
+ const [left, right] = await Promise.all([sha256(path.join(source, file)), sha256(path.join(destination, file))]);
45
+ if (left !== right) {
46
+ throw new Error(`Index clone content differs for ${file}.`);
47
+ }
48
+ }
49
+ }
50
+ async function sha256(filePath) {
51
+ return createHash("sha256").update(await readFile(filePath)).digest("hex");
52
+ }
53
+ /** Sorted POSIX-relative regular files; refuses symlinks and other non-regular entries. */
54
+ async function listRegularFiles(root) {
55
+ const files = [];
56
+ async function walk(dir) {
57
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
58
+ const full = path.join(dir, entry.name);
59
+ const stat = await lstat(full);
60
+ if (stat.isSymbolicLink()) {
61
+ throw new Error(`Index directory contains a symbolic link, which is not copied: ${full}.`);
62
+ }
63
+ if (stat.isDirectory()) {
64
+ await walk(full);
65
+ }
66
+ else if (stat.isFile()) {
67
+ files.push(path.relative(root, full).replace(/\\/g, "/"));
68
+ }
69
+ else {
70
+ throw new Error(`Index directory contains a non-regular entry: ${full}.`);
71
+ }
72
+ }
73
+ }
74
+ await walk(root);
75
+ return files.sort();
76
+ }
@@ -0,0 +1,308 @@
1
+ import { existsSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { resolveWithinRoot } from "../../../core/pathSafety.js";
4
+ import { assessIndexFreshness } from "../../../evaluation/indexFreshness.js";
5
+ import { prepareWarmIndexSession } from "../warmIndexReuse/warmIndexSession.js";
6
+ import { createDisposableTreatmentTarget } from "./disposableTarget.js";
7
+ import { evaluateBaselineStaleFreshness, evaluateChangedPathSymmetry, evaluateIndexEvidence, evaluateRefreshedFreshness, evaluateToolIdentity, normalizeRelativePath, normalizeRootForComparison } from "./lifecyclePolicy.js";
8
+ import { executeIncrementalChangeStalenessMutation } from "./mutationExecution.js";
9
+ import { captureIncrementalChangeStalenessSourceState, compareIncrementalChangeStalenessSourceStates } from "./sourceState.js";
10
+ import { INCREMENTAL_CHANGE_STALENESS_LIFECYCLE_SESSION_SCHEMA_VERSION } from "./treatmentSession.js";
11
+ export const defaultIncrementalChangeStalenessLifecycleDeps = {
12
+ prepareIndexSession: prepareWarmIndexSession,
13
+ assessIndexFreshness,
14
+ executeMutation: executeIncrementalChangeStalenessMutation,
15
+ captureSourceState: captureIncrementalChangeStalenessSourceState
16
+ };
17
+ /** Deterministic run-owned layout: targets/, indexes/, and commands/ are separate sibling trees. */
18
+ export function incrementalChangeStalenessTargetsRoot(runOwnedRoot) {
19
+ return path.join(path.resolve(runOwnedRoot), "targets");
20
+ }
21
+ export function incrementalChangeStalenessIndexDir(runOwnedRoot, scenarioId, treatmentId, role) {
22
+ return resolveWithinRoot(path.resolve(runOwnedRoot), path.join("indexes", scenarioId, treatmentId, role));
23
+ }
24
+ export function incrementalChangeStalenessCommandsDir(runOwnedRoot, scenarioId, treatmentId, role) {
25
+ return resolveWithinRoot(path.resolve(runOwnedRoot), path.join("commands", scenarioId, treatmentId, role));
26
+ }
27
+ export class LifecycleFailure extends Error {
28
+ failure;
29
+ constructor(failure) {
30
+ super(failure.message);
31
+ this.failure = failure;
32
+ }
33
+ }
34
+ export function fail(failure) {
35
+ if (failure)
36
+ throw new LifecycleFailure(failure);
37
+ }
38
+ /**
39
+ * Shared by the v0.6.2 and v0.6.3 lifecycles: runs the index through the established warm-index
40
+ * session owner (index -> version probe -> snapshot -> graph), then proves target/index identity.
41
+ * `mode` defaults to an ordinary full build. The index directory must be absent unless
42
+ * `existingIndexDir` is set (an in-place refresh of an already-cloned baseline).
43
+ */
44
+ export async function prepareTreatmentIndexEvidence(params) {
45
+ const { deps, target, role, indexDir, sourceRoots } = params;
46
+ const treatmentId = target.treatmentId;
47
+ if (params.existingIndexDir ? !existsSync(indexDir) : existsSync(indexDir)) {
48
+ fail({
49
+ code: "index-directory-collision",
50
+ message: params.existingIndexDir
51
+ ? `Expected an existing cloned ${treatmentId} ${role} index directory: ${indexDir}.`
52
+ : `Refusing to overwrite existing ${treatmentId} ${role} index directory: ${indexDir}.`,
53
+ treatmentId
54
+ });
55
+ }
56
+ const commandsDir = incrementalChangeStalenessCommandsDir(params.runOwnedRoot, params.scenarioId, treatmentId, role);
57
+ const prepared = await deps.prepareIndexSession({
58
+ target: { absoluteTargetRoot: target.targetRoot, sourceRoots: [...sourceRoots] },
59
+ kitCommand: params.kitCommand,
60
+ indexDir,
61
+ commandsDir,
62
+ requireKit: false,
63
+ ...(params.mode ? { mode: params.mode } : {})
64
+ });
65
+ if (!prepared.ok) {
66
+ fail({
67
+ code: role === "baseline" ? "baseline-index-build-failed" : "refresh-index-build-failed",
68
+ message: `${treatmentId} ${role} my-dev-kit index build failed: ${prepared.warnings.join(" ") || `exit code ${prepared.build.command.exitCode}`}`,
69
+ treatmentId
70
+ });
71
+ throw new Error("unreachable");
72
+ }
73
+ params.onIndexPrepared?.();
74
+ const evidence = Object.freeze({
75
+ role,
76
+ treatmentId,
77
+ builtRelativeToMutation: role === "baseline" ? "pre-mutation" : "post-mutation",
78
+ indexDir: prepared.session.indexDir,
79
+ commandsDir,
80
+ targetRoot: prepared.session.targetRoot,
81
+ sourceRoots: prepared.session.sourceRoots,
82
+ buildDurationMs: prepared.session.buildDurationMs,
83
+ buildCommand: prepared.session.buildCommand,
84
+ snapshot: prepared.session.indexSnapshot,
85
+ graph: prepared.session.affectedNeighborhoodGraph
86
+ });
87
+ fail(evaluateIndexEvidence(evidence, { targetRoot: target.targetRoot, sourceRoots, indexDir }));
88
+ return { evidence, build: prepared.build };
89
+ }
90
+ /**
91
+ * Owns the matched Batch 3 lifecycle for ONE frozen scenario:
92
+ *
93
+ * 1. two independent disposable copies (stale-index, full-refresh);
94
+ * 2. pre-mutation controlled source-state equivalence;
95
+ * 3. stale-index baseline: index -> snapshot -> graph, then identity proof;
96
+ * 4. full-refresh baseline: index -> snapshot -> graph, then identity proof;
97
+ * 5. BARRIER: only after both baselines are fully ready, the same frozen
98
+ * mutation is applied independently to both copies;
99
+ * 6. post-mutation controlled source-state equivalence;
100
+ * 7. each baseline snapshot assessed against its mutated target (complete
101
+ * `stale` on exactly the scenario-controlled paths, symmetric across
102
+ * treatments);
103
+ * 8. stale-index keeps its baseline index as active retrieval authority
104
+ * (no rebuild);
105
+ * 9. full-refresh builds a NEW complete index into a separate directory,
106
+ * captures its snapshot/graph, and proves it complete `fresh`.
107
+ *
108
+ * Exactly three full index builds and three freshness assessments happen per
109
+ * scenario. Performs no retrieval, affected-neighborhood traversal, metric
110
+ * computation, correctness evaluation, or comparison classification, and
111
+ * never acts on a reindex recommendation. Sequential by design. Does not
112
+ * delete anything: the caller owns and cleans the run-owned root after the
113
+ * session has been consumed.
114
+ */
115
+ export async function prepareIncrementalChangeStalenessScenarioLifecycle(options) {
116
+ const deps = { ...defaultIncrementalChangeStalenessLifecycleDeps, ...options.deps };
117
+ const { repoRoot, scenario, baseCase, kitCommand } = options;
118
+ const runOwnedRoot = path.resolve(options.runOwnedRoot);
119
+ const events = [];
120
+ const indexBuildCounts = { "stale-index": 0, "full-refresh": 0 };
121
+ const sourceRoots = baseCase.sourceRoots;
122
+ const controlledChangedPaths = scenario.mutation.files.map((file) => normalizeRelativePath(file.path)).sort();
123
+ async function prepareIndex(target, role, indexDir) {
124
+ const treatmentId = target.treatmentId;
125
+ if (existsSync(indexDir)) {
126
+ fail({ code: "index-directory-collision", message: `Refusing to overwrite existing ${treatmentId} ${role} index directory: ${indexDir}.`, treatmentId });
127
+ }
128
+ indexBuildCounts[treatmentId] += 1;
129
+ const { evidence } = await prepareTreatmentIndexEvidence({
130
+ deps,
131
+ runOwnedRoot,
132
+ scenarioId: scenario.id,
133
+ kitCommand,
134
+ sourceRoots,
135
+ target,
136
+ role,
137
+ indexDir,
138
+ // prepareIndexSession resolves only after the index command, snapshot capture, and graph
139
+ // loading have all completed, in that order.
140
+ onIndexPrepared: () => events.push(`${treatmentId}:${role}-index-built`, `${treatmentId}:${role}-snapshot-captured`, `${treatmentId}:${role}-graph-loaded`)
141
+ });
142
+ events.push(`${treatmentId}:${role}-identity-verified`);
143
+ return evidence;
144
+ }
145
+ async function captureState(target) {
146
+ return deps.captureSourceState(target.targetRoot, sourceRoots, target.treatmentId, baseCase.benchmarkProjectId);
147
+ }
148
+ async function mutate(target) {
149
+ const receipt = await deps.executeMutation(scenario, target, sourceRoots, repoRoot);
150
+ if (receipt.status !== "applied") {
151
+ fail({ code: "mutation-failed", message: `${target.treatmentId} mutation was rejected: ${receipt.errors.join(" ")}`, treatmentId: target.treatmentId });
152
+ }
153
+ events.push(`${target.treatmentId}:mutation-applied`);
154
+ return receipt;
155
+ }
156
+ async function assess(index, target) {
157
+ return deps.assessIndexFreshness({ snapshot: index.snapshot, targetRoot: target.targetRoot, sourceRoots });
158
+ }
159
+ try {
160
+ const indexDirs = {
161
+ staleBaseline: incrementalChangeStalenessIndexDir(runOwnedRoot, scenario.id, "stale-index", "baseline"),
162
+ fullBaseline: incrementalChangeStalenessIndexDir(runOwnedRoot, scenario.id, "full-refresh", "baseline"),
163
+ fullRefreshed: incrementalChangeStalenessIndexDir(runOwnedRoot, scenario.id, "full-refresh", "refreshed")
164
+ };
165
+ if (normalizeRootForComparison(indexDirs.staleBaseline) === normalizeRootForComparison(indexDirs.fullBaseline) ||
166
+ normalizeRootForComparison(indexDirs.fullBaseline) === normalizeRootForComparison(indexDirs.fullRefreshed) ||
167
+ normalizeRootForComparison(indexDirs.staleBaseline) === normalizeRootForComparison(indexDirs.fullRefreshed)) {
168
+ fail({ code: "index-directory-collision", message: "Treatment/phase index directories are not distinct.", treatmentId: null });
169
+ }
170
+ // 1. Two independent disposable copies through the Batch 2 owner.
171
+ const targets = {};
172
+ for (const treatmentId of ["stale-index", "full-refresh"]) {
173
+ try {
174
+ targets[treatmentId] = await createDisposableTreatmentTarget({
175
+ repoRoot,
176
+ runRoot: incrementalChangeStalenessTargetsRoot(runOwnedRoot),
177
+ scenarioId: scenario.id,
178
+ treatmentId,
179
+ benchmarkProjectId: baseCase.benchmarkProjectId,
180
+ canonicalProjectRootRelative: baseCase.canonicalProjectRootRelative
181
+ });
182
+ }
183
+ catch (error) {
184
+ fail({ code: "treatment-target-creation-failed", message: `${treatmentId}: ${error.message}`, treatmentId });
185
+ }
186
+ }
187
+ const stale = targets["stale-index"];
188
+ const full = targets["full-refresh"];
189
+ events.push("treatment-targets-created");
190
+ // 2. Pre-mutation equivalence gates all indexing and mutation.
191
+ const stalePre = await captureState(stale);
192
+ const fullPre = await captureState(full);
193
+ events.push("pre-mutation-source-state-captured");
194
+ const preMutationEquivalence = compareIncrementalChangeStalenessSourceStates(stalePre, fullPre);
195
+ if (preMutationEquivalence.result !== "equivalent") {
196
+ fail({
197
+ code: "pre-mutation-not-equivalent",
198
+ message: `Treatment copies are not pre-mutation equivalent (${preMutationEquivalence.result}): ${preMutationEquivalence.reasons.join("; ")}`,
199
+ treatmentId: null
200
+ });
201
+ }
202
+ events.push("pre-mutation-equivalence-proven");
203
+ // 3-4. Baselines: each index -> snapshot -> graph -> identity, sequentially.
204
+ const staleBaseline = await prepareIndex(stale, "baseline", indexDirs.staleBaseline);
205
+ const fullBaseline = await prepareIndex(full, "baseline", indexDirs.fullBaseline);
206
+ const baselineTools = evaluateToolIdentity([staleBaseline, fullBaseline]);
207
+ fail(baselineTools.failure);
208
+ events.push("baseline-tool-identity-verified");
209
+ // 5. Barrier: both baselines are index + snapshot + graph READY. Only now may mutation begin.
210
+ events.push("baseline-barrier-ready");
211
+ const staleReceipt = await mutate(stale);
212
+ const fullReceipt = await mutate(full);
213
+ // 6. Post-mutation equivalence.
214
+ const stalePost = await captureState(stale);
215
+ const fullPost = await captureState(full);
216
+ events.push("post-mutation-source-state-captured");
217
+ const postMutationEquivalence = compareIncrementalChangeStalenessSourceStates(stalePost, fullPost);
218
+ if (postMutationEquivalence.result !== "equivalent") {
219
+ fail({
220
+ code: "post-mutation-not-equivalent",
221
+ message: `Treatment copies are not post-mutation equivalent (${postMutationEquivalence.result}): ${postMutationEquivalence.reasons.join("; ")}`,
222
+ treatmentId: null
223
+ });
224
+ }
225
+ events.push("post-mutation-equivalence-proven");
226
+ // 7. Baseline (change-authority) freshness against each mutated target.
227
+ const staleBaselineFreshness = await assess(staleBaseline, stale);
228
+ fail(evaluateBaselineStaleFreshness(staleBaselineFreshness, controlledChangedPaths, "stale-index"));
229
+ events.push("stale-index:baseline-freshness-stale");
230
+ const fullBaselineFreshness = await assess(fullBaseline, full);
231
+ fail(evaluateBaselineStaleFreshness(fullBaselineFreshness, controlledChangedPaths, "full-refresh"));
232
+ events.push("full-refresh:baseline-freshness-stale");
233
+ fail(evaluateChangedPathSymmetry(staleBaselineFreshness, fullBaselineFreshness));
234
+ events.push("baseline-changed-paths-symmetric");
235
+ // 8. stale-index: no rebuild; baseline stays the active retrieval index.
236
+ events.push("stale-index:baseline-retained-as-active");
237
+ // 9. full-refresh: new complete post-mutation index in a separate directory.
238
+ const refreshed = await prepareIndex(full, "refreshed", indexDirs.fullRefreshed);
239
+ const refreshedFreshness = await assess(refreshed, full);
240
+ fail(evaluateRefreshedFreshness(refreshedFreshness));
241
+ events.push("full-refresh:refreshed-freshness-fresh");
242
+ const allTools = evaluateToolIdentity([staleBaseline, fullBaseline, refreshed]);
243
+ fail(allTools.failure);
244
+ events.push("tool-identity-consistent");
245
+ events.push("full-refresh:refreshed-active");
246
+ events.push("lifecycle-ready");
247
+ const staleSession = Object.freeze({
248
+ treatmentId: "stale-index",
249
+ target: stale,
250
+ preMutationSourceState: stalePre,
251
+ postMutationSourceState: stalePost,
252
+ mutationReceipt: staleReceipt,
253
+ changeAuthority: Object.freeze({ baselineIndex: staleBaseline, postMutationBaselineFreshness: staleBaselineFreshness }),
254
+ activeRetrieval: Object.freeze({ role: "baseline", index: staleBaseline }),
255
+ postMutationIndexBuilt: false,
256
+ refreshedIndex: null,
257
+ refreshedFreshness: null,
258
+ indexBuildCount: indexBuildCounts["stale-index"],
259
+ freshnessAssessmentCount: 1,
260
+ lifecycleStatus: "ready",
261
+ warnings: Object.freeze([])
262
+ });
263
+ const fullSession = Object.freeze({
264
+ treatmentId: "full-refresh",
265
+ target: full,
266
+ preMutationSourceState: fullPre,
267
+ postMutationSourceState: fullPost,
268
+ mutationReceipt: fullReceipt,
269
+ changeAuthority: Object.freeze({ baselineIndex: fullBaseline, postMutationBaselineFreshness: fullBaselineFreshness }),
270
+ activeRetrieval: Object.freeze({ role: "refreshed", index: refreshed }),
271
+ postMutationIndexBuilt: true,
272
+ refreshedIndex: refreshed,
273
+ refreshedFreshness,
274
+ indexBuildCount: indexBuildCounts["full-refresh"],
275
+ freshnessAssessmentCount: 2,
276
+ lifecycleStatus: "ready",
277
+ warnings: Object.freeze([])
278
+ });
279
+ const session = Object.freeze({
280
+ schemaVersion: INCREMENTAL_CHANGE_STALENESS_LIFECYCLE_SESSION_SCHEMA_VERSION,
281
+ scenarioId: scenario.id,
282
+ scenario,
283
+ baseCase,
284
+ runOwnedRoot,
285
+ kitCommand,
286
+ toolIdentity: allTools.tool,
287
+ controlledChangedPaths: Object.freeze(controlledChangedPaths),
288
+ preMutationEquivalence,
289
+ postMutationEquivalence,
290
+ treatments: Object.freeze({ "stale-index": staleSession, "full-refresh": fullSession }),
291
+ lifecycleEvents: Object.freeze([...events]),
292
+ status: "ready"
293
+ });
294
+ return { status: "ready", session };
295
+ }
296
+ catch (error) {
297
+ const failure = error instanceof LifecycleFailure
298
+ ? error.failure
299
+ : { code: "lifecycle-error", message: `Lifecycle failed unexpectedly: ${error instanceof Error ? error.message : String(error)}`, treatmentId: null };
300
+ return {
301
+ status: "failed",
302
+ scenarioId: scenario.id,
303
+ failure,
304
+ lifecycleEvents: Object.freeze([...events]),
305
+ indexBuildCounts: Object.freeze({ ...indexBuildCounts })
306
+ };
307
+ }
308
+ }
@@ -0,0 +1,154 @@
1
+ import path from "node:path";
2
+ /**
3
+ * Pure v0.6.2 Batch 3 lifecycle-readiness policy. Every function here only
4
+ * inspects already-produced evidence and returns an explicit failure or
5
+ * null; none touches the filesystem, runs my-dev-kit, mutates, or retrieves.
6
+ * These rules are scoped to this experiment's matched production lifecycle
7
+ * and deliberately do not change the released v0.6.0/v0.6.1 semantics of the
8
+ * evidence they read.
9
+ */
10
+ export function normalizeRootForComparison(root) {
11
+ const resolved = path.resolve(root);
12
+ return process.platform === "win32" ? resolved.toLowerCase() : resolved;
13
+ }
14
+ export function normalizeRelativePath(relativePath) {
15
+ return relativePath.replace(/\\/g, "/");
16
+ }
17
+ function normalizeSourceRoot(root) {
18
+ return root.replace(/\\/g, "/").replace(/\/+$/, "");
19
+ }
20
+ /** Exact, order-preserving source-root equality after separator/trailing-slash normalization. */
21
+ export function sameSourceRoots(left, right) {
22
+ return left.length === right.length && left.every((root, index) => normalizeSourceRoot(root) === normalizeSourceRoot(right[index]));
23
+ }
24
+ /**
25
+ * Graph evidence is usable for this lifecycle only when an indexed-file claim
26
+ * exists (not `unavailable`) AND the code graph was actually loaded, since a
27
+ * null code graph makes later node mapping impossible.
28
+ */
29
+ export function isUsableGraphEvidence(graph) {
30
+ return graph.status !== "unavailable" && graph.codeGraph !== null;
31
+ }
32
+ function failure(code, message, treatmentId) {
33
+ return { code, message, treatmentId };
34
+ }
35
+ /**
36
+ * Proves one prepared index belongs to the intended treatment target, uses
37
+ * the canonical source roots, carries a complete IndexSnapshotV1 (whose
38
+ * manifest projectRoot/sourceRoots were already checked against the target by
39
+ * captureIndexSnapshot), and carries usable graph evidence loaded from the
40
+ * same index directory. Never relies on directory naming alone.
41
+ */
42
+ export function evaluateIndexEvidence(evidence, expected) {
43
+ const refreshed = evidence.role === "refreshed";
44
+ const label = `${evidence.treatmentId} ${evidence.role} index`;
45
+ const targetMismatchCode = refreshed ? "active-index-target-mismatch" : "index-target-mismatch";
46
+ if (normalizeRootForComparison(evidence.targetRoot) !== normalizeRootForComparison(expected.targetRoot)) {
47
+ return failure(targetMismatchCode, `${label} target root ${evidence.targetRoot} does not match treatment target ${expected.targetRoot}.`, evidence.treatmentId);
48
+ }
49
+ if (normalizeRootForComparison(evidence.indexDir) !== normalizeRootForComparison(expected.indexDir)) {
50
+ return failure(targetMismatchCode, `${label} directory ${evidence.indexDir} does not match the owned index directory ${expected.indexDir}.`, evidence.treatmentId);
51
+ }
52
+ if (!sameSourceRoots(evidence.sourceRoots, expected.sourceRoots)) {
53
+ return failure("source-root-mismatch", `${label} source roots [${evidence.sourceRoots.join(", ")}] do not match canonical base-case source roots [${expected.sourceRoots.join(", ")}].`, evidence.treatmentId);
54
+ }
55
+ const snapshot = evidence.snapshot;
56
+ if (snapshot.status === "unavailable" && snapshot.unavailable?.code === "manifest-target-mismatch") {
57
+ return failure(targetMismatchCode, `${label} manifest does not belong to the treatment target: ${snapshot.unavailable.message}`, evidence.treatmentId);
58
+ }
59
+ if (snapshot.status !== "complete") {
60
+ const reason = snapshot.unavailable ? `${snapshot.unavailable.code}: ${snapshot.unavailable.message}` : `${snapshot.unresolvedFileCount} unresolved indexed file(s)`;
61
+ return failure(refreshed ? "refreshed-snapshot-unavailable" : "baseline-snapshot-unavailable", `${label} IndexSnapshotV1 is ${snapshot.status} (${reason}).`, evidence.treatmentId);
62
+ }
63
+ if (normalizeRootForComparison(evidence.graph.indexRoot) !== normalizeRootForComparison(expected.indexDir)) {
64
+ return failure(targetMismatchCode, `${label} graph evidence was loaded from ${evidence.graph.indexRoot}, not ${expected.indexDir}.`, evidence.treatmentId);
65
+ }
66
+ if (!isUsableGraphEvidence(evidence.graph)) {
67
+ const reason = evidence.graph.unavailable ? `${evidence.graph.unavailable.code}: ${evidence.graph.unavailable.message}` : "code graph was not loaded";
68
+ return failure(refreshed ? "refreshed-graph-unavailable" : "baseline-graph-unavailable", `${label} graph evidence is not usable (${evidence.graph.status}; ${reason}).`, evidence.treatmentId);
69
+ }
70
+ return null;
71
+ }
72
+ /**
73
+ * Every index of one matched scenario must come from the same configured
74
+ * my-dev-kit command and the same probed tool version. An unavailable
75
+ * version is never guessed: identity that cannot be established blocks
76
+ * readiness explicitly.
77
+ */
78
+ export function evaluateToolIdentity(indexes) {
79
+ for (const index of indexes) {
80
+ if (index.snapshot.tool.availability !== "available" || index.snapshot.tool.version === null) {
81
+ return {
82
+ failure: failure("tool-identity-unavailable", `${index.treatmentId} ${index.role} index my-dev-kit version could not be established: ${index.snapshot.tool.reason ?? "unavailable"}.`, index.treatmentId),
83
+ tool: null
84
+ };
85
+ }
86
+ }
87
+ const versions = [...new Set(indexes.map((index) => index.snapshot.tool.version))];
88
+ if (versions.length > 1) {
89
+ return {
90
+ failure: failure("tool-identity-mismatch", `Matched scenario indexes were built with different my-dev-kit versions: ${indexes.map((index) => `${index.treatmentId}/${index.role}=${index.snapshot.tool.version}`).join(", ")}.`, null),
91
+ tool: null
92
+ };
93
+ }
94
+ const commands = [...new Set(indexes.map((index) => index.snapshot.indexCommand.commandString))];
95
+ if (commands.length > 1) {
96
+ return {
97
+ failure: failure("tool-identity-mismatch", `Matched scenario indexes were built with different my-dev-kit commands: ${commands.join(" | ")}.`, null),
98
+ tool: null
99
+ };
100
+ }
101
+ return { failure: null, tool: indexes.length > 0 ? { ...indexes[0].snapshot.tool } : null };
102
+ }
103
+ function changedPaths(assessment) {
104
+ return assessment.changes.map((change) => normalizeRelativePath(change.path)).sort();
105
+ }
106
+ /**
107
+ * A frozen production scenario must be observed as COMPLETE `stale` against
108
+ * the pre-mutation baseline snapshot, and the confirmed changed set must be
109
+ * exactly the scenario-declared controlled file(s) (all modifications, none
110
+ * missing). `fresh`, `unknown`, and `partially-stale` never count as a proven
111
+ * controlled stale state.
112
+ */
113
+ export function evaluateBaselineStaleFreshness(assessment, controlledChangedPaths, treatmentId) {
114
+ if (assessment.status !== "stale") {
115
+ return failure("baseline-freshness-not-stale", `${treatmentId} baseline freshness after mutation is ${assessment.status}, not complete stale.`, treatmentId);
116
+ }
117
+ if (assessment.baselineSnapshotStatus !== "complete" || assessment.unresolvedFileCount !== 0 || assessment.changesTruncated) {
118
+ return failure("baseline-freshness-not-stale", `${treatmentId} baseline freshness comparison is incomplete (snapshot ${assessment.baselineSnapshotStatus}, ${assessment.unresolvedFileCount} unresolved, truncated=${assessment.changesTruncated}).`, treatmentId);
119
+ }
120
+ const expected = [...controlledChangedPaths].map(normalizeRelativePath).sort();
121
+ const observed = changedPaths(assessment);
122
+ const sameSet = observed.length === expected.length && observed.every((value, index) => value === expected[index]);
123
+ if (!sameSet || assessment.missingFileCount !== 0) {
124
+ return failure("baseline-changed-paths-mismatch", `${treatmentId} baseline freshness changed paths [${observed.join(", ")}] (missing=${assessment.missingFileCount}) do not match scenario-controlled paths [${expected.join(", ")}].`, treatmentId);
125
+ }
126
+ return null;
127
+ }
128
+ /**
129
+ * Both treatments applied the same mutation to equivalent copies, so their
130
+ * baseline freshness change evidence must agree path-for-path, including
131
+ * baseline and current content identity.
132
+ */
133
+ export function evaluateChangedPathSymmetry(stale, fullRefresh, labels = { reference: "stale-index", other: "full-refresh" }) {
134
+ const key = (assessment) => assessment.changes
135
+ .map((change) => `${normalizeRelativePath(change.path)}\t${change.changeType}\t${change.baselineSha256}\t${change.currentSha256 ?? "null"}`)
136
+ .sort();
137
+ const left = key(stale);
138
+ const right = key(fullRefresh);
139
+ const same = left.length === right.length && left.every((value, index) => value === right[index]);
140
+ if (!same) {
141
+ return failure("treatment-changed-paths-conflict", `${labels.reference} and ${labels.other} baseline freshness disagree about the controlled change: ${labels.reference}=[${left.join("; ")}] ${labels.other}=[${right.join("; ")}].`, null);
142
+ }
143
+ return null;
144
+ }
145
+ /** The refreshed index must be proven complete `fresh` against the unchanged mutated target. */
146
+ export function evaluateRefreshedFreshness(assessment, treatmentId = "full-refresh") {
147
+ const complete = assessment.baselineSnapshotStatus === "complete" && assessment.unresolvedFileCount === 0;
148
+ if (assessment.status !== "fresh" || !complete || assessment.changedFileCount !== 0 || assessment.missingFileCount !== 0) {
149
+ return failure("refreshed-freshness-not-fresh", `${treatmentId} refreshed index freshness is ${assessment.status} (snapshot ${assessment.baselineSnapshotStatus}, ${assessment.changedFileCount} changed, ${assessment.missingFileCount} missing, ${assessment.unresolvedFileCount} unresolved); complete fresh is required.`, treatmentId);
150
+ }
151
+ return null;
152
+ }
153
+ /** Shared failure constructor for the v0.6.3 lifecycle policy module. */
154
+ export { failure as lifecycleFailure };
@@ -0,0 +1,78 @@
1
+ import { lifecycleFailure } from "./lifecyclePolicy.js";
2
+ /** Pure v0.6.3 four-treatment lifecycle policy; inspects already-produced evidence only. */
3
+ /**
4
+ * v0.6.3: every treatment baseline is bootstrapped with `--incremental --refresh-scope changed-files`
5
+ * against a brand-new index directory. Upstream must truthfully report the expected bootstrap full
6
+ * fallback (`cache-missing`); this is bootstrap evidence, never an experimental partial-refresh fallback.
7
+ */
8
+ export function evaluateBaselineBootstrapEvidence(build, treatmentId) {
9
+ const evidence = build.incrementalRefresh;
10
+ const label = `${treatmentId} baseline bootstrap`;
11
+ if (build.mode.kind !== "incremental" || build.mode.refreshScope !== "changed-files") {
12
+ return lifecycleFailure("baseline-bootstrap-contract-mismatch", `${label} was not requested as incremental changed-files.`, treatmentId);
13
+ }
14
+ if (!evidence) {
15
+ return lifecycleFailure("baseline-bootstrap-contract-mismatch", `${label} returned no incrementalRefresh evidence.`, treatmentId);
16
+ }
17
+ if (evidence.requestedScope !== "changed-files" ||
18
+ evidence.appliedScope !== "full" ||
19
+ evidence.selectionStatus !== "fallback-full" ||
20
+ evidence.fallbackReason !== "cache-missing") {
21
+ return lifecycleFailure("baseline-bootstrap-contract-mismatch", `${label} expected requested=changed-files applied=full status=fallback-full reason=cache-missing but observed requested=${evidence.requestedScope} applied=${evidence.appliedScope} status=${evidence.selectionStatus} reason=${evidence.fallbackReason}.`, treatmentId);
22
+ }
23
+ return null;
24
+ }
25
+ /**
26
+ * v0.6.3: classifies a post-mutation partial-refresh build. A truthful `fallback-full` is a valid
27
+ * realization (FALLBACK_FULL), never collapsed to APPLIED_PARTIAL. `not-needed` after an already
28
+ * proven controlled mutation contradicts that mutation and fails the lifecycle. Missing or invalid
29
+ * evidence fails too.
30
+ */
31
+ export function evaluatePartialRefreshRealization(build, requestedScope, treatmentId) {
32
+ const evidence = build.incrementalRefresh;
33
+ if (build.mode.kind !== "incremental" || build.mode.refreshScope !== requestedScope || !evidence || evidence.requestedScope !== requestedScope) {
34
+ return {
35
+ failure: lifecycleFailure("refresh-evidence-invalid", `${treatmentId} refresh did not carry valid ${requestedScope} incrementalRefresh evidence.`, treatmentId),
36
+ realization: null
37
+ };
38
+ }
39
+ if (evidence.selectionStatus === "not-needed" || evidence.appliedScope === "none") {
40
+ return {
41
+ failure: lifecycleFailure("refresh-not-needed-after-mutation", `${treatmentId} refresh reported not-needed (applied=${evidence.appliedScope}) although the controlled mutation was already proven.`, treatmentId),
42
+ realization: null
43
+ };
44
+ }
45
+ if (evidence.selectionStatus === "applied" && evidence.appliedScope === requestedScope) {
46
+ return { failure: null, realization: "APPLIED_PARTIAL" };
47
+ }
48
+ if (evidence.selectionStatus === "fallback-full" && evidence.appliedScope === "full") {
49
+ return { failure: null, realization: "FALLBACK_FULL" };
50
+ }
51
+ return {
52
+ failure: lifecycleFailure("refresh-evidence-invalid", `${treatmentId} refresh reported an unclassifiable outcome (status=${evidence.selectionStatus}, applied=${evidence.appliedScope}).`, treatmentId),
53
+ realization: null
54
+ };
55
+ }
56
+ /** v0.6.3 per-scenario counts: 7 index invocations and 7 freshness assessments. */
57
+ export const INCREMENTAL_CHANGE_STALENESS_V2_EXPECTED_INDEX_INVOCATIONS = {
58
+ "stale-index": 1,
59
+ "changed-files-refresh": 2,
60
+ "affected-neighborhood-refresh": 2,
61
+ "full-refresh": 2
62
+ };
63
+ export const INCREMENTAL_CHANGE_STALENESS_V2_EXPECTED_FRESHNESS_ASSESSMENTS = {
64
+ "stale-index": 1,
65
+ "changed-files-refresh": 2,
66
+ "affected-neighborhood-refresh": 2,
67
+ "full-refresh": 2
68
+ };
69
+ export function evaluateV2InvocationCounts(indexInvocationCounts, freshnessAssessmentCounts) {
70
+ for (const treatmentId of Object.keys(INCREMENTAL_CHANGE_STALENESS_V2_EXPECTED_INDEX_INVOCATIONS)) {
71
+ const expectedIndex = INCREMENTAL_CHANGE_STALENESS_V2_EXPECTED_INDEX_INVOCATIONS[treatmentId];
72
+ const expectedFreshness = INCREMENTAL_CHANGE_STALENESS_V2_EXPECTED_FRESHNESS_ASSESSMENTS[treatmentId];
73
+ if (indexInvocationCounts[treatmentId] !== expectedIndex || freshnessAssessmentCounts[treatmentId] !== expectedFreshness) {
74
+ return lifecycleFailure("invocation-count-mismatch", `${treatmentId} performed ${indexInvocationCounts[treatmentId]} index invocation(s) and ${freshnessAssessmentCounts[treatmentId]} freshness assessment(s); expected ${expectedIndex} and ${expectedFreshness}.`, treatmentId);
75
+ }
76
+ }
77
+ return null;
78
+ }