@dailephd/my-dev-kit-lab 0.6.2 → 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 (44) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +9 -8
  3. package/dist/src/cli/help.js +16 -9
  4. package/dist/src/commands/runExperimentDescribeCommand.js +5 -2
  5. package/dist/src/commands/runExperimentRunCommand.js +0 -4
  6. package/dist/src/evaluation/benchmarkMetadata.js +5 -1
  7. package/dist/src/evaluation/parseAgentAnswer.js +172 -24
  8. package/dist/src/evaluation/runMyDevKitRetrieval.js +120 -7
  9. package/dist/src/experiments/plugins/incrementalChangeStaleness/comparisonV2.js +49 -0
  10. package/dist/src/experiments/plugins/incrementalChangeStaleness/config.js +5 -4
  11. package/dist/src/experiments/plugins/incrementalChangeStaleness/disposableTarget.js +20 -2
  12. package/dist/src/experiments/plugins/incrementalChangeStaleness/execution.js +42 -16
  13. package/dist/src/experiments/plugins/incrementalChangeStaleness/executionArtifact.js +4 -4
  14. package/dist/src/experiments/plugins/incrementalChangeStaleness/executionArtifactV2.js +289 -0
  15. package/dist/src/experiments/plugins/incrementalChangeStaleness/executionV2.js +195 -0
  16. package/dist/src/experiments/plugins/incrementalChangeStaleness/indexClone.js +76 -0
  17. package/dist/src/experiments/plugins/incrementalChangeStaleness/lifecycle.js +64 -32
  18. package/dist/src/experiments/plugins/incrementalChangeStaleness/lifecyclePolicy.js +6 -4
  19. package/dist/src/experiments/plugins/incrementalChangeStaleness/lifecyclePolicyV2.js +78 -0
  20. package/dist/src/experiments/plugins/incrementalChangeStaleness/lifecycleV2.js +298 -0
  21. package/dist/src/experiments/plugins/incrementalChangeStaleness/plugin.js +213 -12
  22. package/dist/src/experiments/plugins/incrementalChangeStaleness/treatmentSessionV2.js +5 -0
  23. package/dist/src/experiments/plugins/warmIndexReuse/warmIndexSession.js +3 -1
  24. package/dist/src/mobile/android/advancedSecurity/candidateEvidence.js +9 -2
  25. package/dist/src/mobile/android/advancedSecurity/redaction.js +19 -10
  26. package/dist/src/mobile/android/advancedSecurity/secretCandidates/analyzeSecretCandidates.js +12 -2
  27. package/dist/src/mobile/android/advancedSecurity/signingConfiguration/analyzeSigningConfiguration.js +1 -1
  28. package/dist/src/mobile/android/advancedSecurity/signingConfiguration/extractSigningConfigurations.js +7 -7
  29. package/dist/src/report/experiments/buildIncrementalChangeStalenessPluginReport.js +33 -0
  30. package/dist/src/report/experiments/buildIncrementalChangeStalenessReport.js +1 -1
  31. package/dist/src/report/experiments/buildIncrementalChangeStalenessReportV2.js +80 -0
  32. package/dist/src/report/experiments/buildPluginExperimentReport.js +2 -2
  33. package/dist/src/report/experiments/incrementalChangeStalenessReportModelV2.js +3 -0
  34. package/dist/src/report/experiments/incrementalChangeStalenessReportRowsV2.js +146 -0
  35. package/dist/src/report/experiments/index.js +3 -0
  36. package/dist/src/report/experiments/renderIncrementalChangeStalenessHtml.js +4 -0
  37. package/dist/src/report/experiments/renderIncrementalChangeStalenessHtmlV2.js +111 -0
  38. package/dist/src/report/experiments/renderIncrementalChangeStalenessTextV2.js +96 -0
  39. package/dist/src/report/experiments/renderPluginExperimentReportText.js +6 -0
  40. package/dist/src/securityValidation/staticScans/codeql.js +8 -8
  41. package/docs/METRICS.md +32 -0
  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,195 @@
1
+ import path from "node:path";
2
+ import { resolveWithinRoot } from "../../../core/pathSafety.js";
3
+ import { classifyStaleRisk, compareCorrectness, compareRequiredFileEvidence } from "./comparison.js";
4
+ import { compareCandidateCorrectness, compareCandidateRequiredFileEvidence, classifyPartialRefreshAgainstFull } from "./comparisonV2.js";
5
+ import { INCREMENTAL_CHANGE_STALENESS_V2_TREATMENT_IDS, INCREMENTAL_CHANGE_STALENESS_V2_TREATMENT_INTENTS } from "./disposableTarget.js";
6
+ import { assessIncrementalChangeStalenessAffectedNeighborhood, checkIncrementalChangeStalenessAffectedNeighborhoodSymmetry, correctnessComparable, defaultIncrementalChangeStalenessExecutionDeps, executeTreatmentEvaluation, INCONCLUSIVE_NO_EXECUTION, resolveIncrementalChangeStalenessScenarioTask } from "./execution.js";
7
+ /**
8
+ * Four-way check with stale-index only as the deterministic reference. Reuses the v0.6.2 pairwise check
9
+ * (status, relationship, reindexRecommendation, and the six numeric metrics) and additionally requires
10
+ * equal seedNodeCount. Conflicting evidence is never averaged or merged.
11
+ */
12
+ export function checkAffectedNeighborhoodFourWaySymmetry(assessments) {
13
+ const reference = assessments["stale-index"];
14
+ const reasons = [];
15
+ for (const treatmentId of INCREMENTAL_CHANGE_STALENESS_V2_TREATMENT_IDS) {
16
+ if (treatmentId === "stale-index")
17
+ continue;
18
+ const other = assessments[treatmentId];
19
+ const pairwise = checkIncrementalChangeStalenessAffectedNeighborhoodSymmetry(reference, other);
20
+ if (!pairwise.symmetric)
21
+ reasons.push(`${treatmentId}: ${pairwise.reason}`);
22
+ if (reference.seedNodeCount !== other.seedNodeCount) {
23
+ reasons.push(`${treatmentId}: seedNodeCount (${reference.seedNodeCount} vs ${other.seedNodeCount})`);
24
+ }
25
+ }
26
+ return reasons.length === 0 ? { symmetric: true } : { symmetric: false, reason: `Affected-neighborhood assessments are asymmetric across treatments: ${reasons.join(" | ")}` };
27
+ }
28
+ const PARTIAL_TREATMENT_IDS = ["changed-files-refresh", "affected-neighborhood-refresh"];
29
+ function placeholderComparisons() {
30
+ return [
31
+ { kind: "stale-risk", candidateTreatmentId: "stale-index", referenceTreatmentId: "full-refresh", comparison: { ...INCONCLUSIVE_NO_EXECUTION, reasonCodes: [...INCONCLUSIVE_NO_EXECUTION.reasonCodes] } },
32
+ ...PARTIAL_TREATMENT_IDS.map((candidateTreatmentId) => ({
33
+ kind: "partial-refresh",
34
+ candidateTreatmentId,
35
+ referenceTreatmentId: "full-refresh",
36
+ refreshRealization: null,
37
+ correctnessRelation: "unknown",
38
+ requiredFileEvidenceRelation: "unknown",
39
+ classification: "inconclusive",
40
+ reasonCodes: ["correctness-unavailable", "required-file-evidence-unavailable"]
41
+ }))
42
+ ];
43
+ }
44
+ function refreshExecutionOf(session, treatmentId) {
45
+ const treatment = session.treatments[treatmentId];
46
+ if (treatment.refreshRealization !== null) {
47
+ const incrementalRefresh = treatment.refreshedIndex.incrementalRefresh;
48
+ if (!incrementalRefresh) {
49
+ throw new Error(`${treatmentId} lifecycle reported ${treatment.refreshRealization} without upstream incrementalRefresh evidence.`);
50
+ }
51
+ return { kind: "incremental", realization: treatment.refreshRealization, incrementalRefresh: structuredClone(incrementalRefresh) };
52
+ }
53
+ return treatment.postMutationIndexBuilt
54
+ ? { kind: "full", realization: "FULL_REFRESH", incrementalRefresh: null }
55
+ : { kind: "no-refresh", realization: "NO_REFRESH", incrementalRefresh: null };
56
+ }
57
+ /**
58
+ * Executes the four matched treatments in fixed order for one lifecycle-ready V2 session, then builds
59
+ * exactly three comparisons against full-refresh. A lifecycle that is not ready, or an affected-neighborhood
60
+ * asymmetry, is a failed scenario with no fabricated treatment evidence.
61
+ */
62
+ export async function executeIncrementalChangeStalenessScenarioV2(options) {
63
+ const deps = { ...defaultIncrementalChangeStalenessExecutionDeps, ...options.deps };
64
+ const scenario = options.scenario;
65
+ if (options.lifecycle.status !== "ready") {
66
+ return {
67
+ scenarioId: scenario.id,
68
+ category: scenario.category,
69
+ benchmarkProjectId: options.baseCase.benchmarkProjectId,
70
+ baseCaseId: options.baseCase.caseId,
71
+ answerPolicy: scenario.answerPolicy,
72
+ query: null,
73
+ expectedFiles: [],
74
+ expectedSymbols: [],
75
+ status: "failed",
76
+ failureReason: `${options.lifecycle.failure.code}: ${options.lifecycle.failure.message}`,
77
+ session: null,
78
+ treatments: [],
79
+ referenceComparisons: placeholderComparisons()
80
+ };
81
+ }
82
+ const session = options.lifecycle.session;
83
+ const { resolved, derivedTask } = await resolveIncrementalChangeStalenessScenarioTask({ repoRoot: options.repoRoot, scenario });
84
+ const scenarioBase = {
85
+ scenarioId: scenario.id,
86
+ category: scenario.category,
87
+ benchmarkProjectId: scenario.benchmarkProjectId,
88
+ baseCaseId: scenario.baseCaseId,
89
+ answerPolicy: scenario.answerPolicy,
90
+ query: resolved.query,
91
+ expectedFiles: resolved.expectedFiles,
92
+ expectedSymbols: resolved.expectedSymbols,
93
+ session
94
+ };
95
+ // Affected-neighborhood evidence is derived from each treatment's ORIGINAL BASELINE change authority.
96
+ const affected = {};
97
+ for (const treatmentId of INCREMENTAL_CHANGE_STALENESS_V2_TREATMENT_IDS) {
98
+ affected[treatmentId] = assessIncrementalChangeStalenessAffectedNeighborhood(session.treatments[treatmentId], derivedTask);
99
+ }
100
+ const symmetry = checkAffectedNeighborhoodFourWaySymmetry(affected);
101
+ if (!symmetry.symmetric) {
102
+ return {
103
+ ...scenarioBase,
104
+ status: "failed",
105
+ failureReason: `AFFECTED_NEIGHBORHOOD_ASYMMETRY: ${symmetry.reason}`,
106
+ treatments: [],
107
+ referenceComparisons: placeholderComparisons()
108
+ };
109
+ }
110
+ const retrievalCommandsDir = (treatmentId) => resolveWithinRoot(options.runOwnedRoot, path.join("commands", scenario.id, treatmentId, "retrieval"));
111
+ const agentOutDir = (treatmentId) => resolveWithinRoot(options.runOwnedRoot, path.join("agents", scenario.id, treatmentId));
112
+ // Fixed deterministic order, sequential; each treatment retrieves only from its own active index.
113
+ const treatments = [];
114
+ for (const treatmentId of INCREMENTAL_CHANGE_STALENESS_V2_TREATMENT_IDS) {
115
+ const lifecycleTreatment = session.treatments[treatmentId];
116
+ const evaluation = await executeTreatmentEvaluation({
117
+ treatmentId,
118
+ baselineFreshness: lifecycleTreatment.changeAuthority.postMutationBaselineFreshness,
119
+ activeIndexPhase: lifecycleTreatment.activeRetrieval.role,
120
+ activeIndexDir: lifecycleTreatment.activeRetrieval.index.indexDir,
121
+ derivedTask,
122
+ affectedNeighborhood: affected[treatmentId],
123
+ requiredFiles: resolved.expectedFiles,
124
+ kitCommand: session.kitCommand,
125
+ retrievalCommandsDir: retrievalCommandsDir(treatmentId),
126
+ agentOutDir: agentOutDir(treatmentId),
127
+ projectProfiles: options.projectProfiles,
128
+ cwd: options.cwd,
129
+ env: options.env,
130
+ deps
131
+ });
132
+ treatments.push({
133
+ ...evaluation,
134
+ treatmentIntent: INCREMENTAL_CHANGE_STALENESS_V2_TREATMENT_INTENTS[treatmentId],
135
+ refreshedFreshness: lifecycleTreatment.refreshedFreshness,
136
+ refreshExecution: refreshExecutionOf(session, treatmentId)
137
+ });
138
+ }
139
+ return {
140
+ ...scenarioBase,
141
+ status: "ready",
142
+ failureReason: null,
143
+ treatments,
144
+ referenceComparisons: buildReferenceComparisons(treatments)
145
+ };
146
+ }
147
+ /** Exactly three comparisons against full-refresh: stale-index, changed-files-refresh, affected-neighborhood-refresh. */
148
+ export function buildReferenceComparisons(treatments) {
149
+ const byId = new Map(treatments.map((treatment) => [treatment.treatmentId, treatment]));
150
+ const reference = byId.get("full-refresh");
151
+ const stale = byId.get("stale-index");
152
+ if (!reference || !stale)
153
+ throw new Error("Reference comparisons require the stale-index and full-refresh treatment executions.");
154
+ // Historical v0.6.2 semantics, unchanged.
155
+ const staleCorrectness = compareCorrectness(correctnessComparable(stale.fakeAgent), correctnessComparable(reference.fakeAgent));
156
+ const staleRequired = compareRequiredFileEvidence(stale.requiredFileEvidence, reference.requiredFileEvidence);
157
+ const staleRisk = classifyStaleRisk(staleCorrectness, staleRequired);
158
+ const comparisons = [
159
+ {
160
+ kind: "stale-risk",
161
+ candidateTreatmentId: "stale-index",
162
+ referenceTreatmentId: "full-refresh",
163
+ comparison: {
164
+ correctnessRelation: staleCorrectness,
165
+ requiredFileEvidenceRelation: staleRequired,
166
+ staleRiskClassification: staleRisk.staleRiskClassification,
167
+ reasonCodes: staleRisk.reasonCodes
168
+ }
169
+ }
170
+ ];
171
+ for (const candidateTreatmentId of PARTIAL_TREATMENT_IDS) {
172
+ const candidate = byId.get(candidateTreatmentId);
173
+ if (!candidate || candidate.refreshExecution.kind !== "incremental") {
174
+ throw new Error(`${candidateTreatmentId} has no incremental refresh execution to compare.`);
175
+ }
176
+ const correctnessRelation = compareCandidateCorrectness(correctnessComparable(candidate.fakeAgent), correctnessComparable(reference.fakeAgent));
177
+ const requiredFileEvidenceRelation = compareCandidateRequiredFileEvidence(candidate.requiredFileEvidence, reference.requiredFileEvidence);
178
+ const classified = classifyPartialRefreshAgainstFull({
179
+ realization: candidate.refreshExecution.realization,
180
+ correctnessRelation,
181
+ requiredFileEvidenceRelation
182
+ });
183
+ comparisons.push({
184
+ kind: "partial-refresh",
185
+ candidateTreatmentId,
186
+ referenceTreatmentId: "full-refresh",
187
+ refreshRealization: candidate.refreshExecution.realization,
188
+ correctnessRelation,
189
+ requiredFileEvidenceRelation,
190
+ classification: classified.classification,
191
+ reasonCodes: classified.reasonCodes
192
+ });
193
+ }
194
+ return comparisons;
195
+ }
@@ -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
+ }
@@ -24,17 +24,69 @@ export function incrementalChangeStalenessIndexDir(runOwnedRoot, scenarioId, tre
24
24
  export function incrementalChangeStalenessCommandsDir(runOwnedRoot, scenarioId, treatmentId, role) {
25
25
  return resolveWithinRoot(path.resolve(runOwnedRoot), path.join("commands", scenarioId, treatmentId, role));
26
26
  }
27
- class LifecycleFailure extends Error {
27
+ export class LifecycleFailure extends Error {
28
28
  failure;
29
29
  constructor(failure) {
30
30
  super(failure.message);
31
31
  this.failure = failure;
32
32
  }
33
33
  }
34
- function fail(failure) {
34
+ export function fail(failure) {
35
35
  if (failure)
36
36
  throw new LifecycleFailure(failure);
37
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
+ }
38
90
  /**
39
91
  * Owns the matched Batch 3 lifecycle for ONE frozen scenario:
40
92
  *
@@ -73,40 +125,20 @@ export async function prepareIncrementalChangeStalenessScenarioLifecycle(options
73
125
  if (existsSync(indexDir)) {
74
126
  fail({ code: "index-directory-collision", message: `Refusing to overwrite existing ${treatmentId} ${role} index directory: ${indexDir}.`, treatmentId });
75
127
  }
76
- const commandsDir = incrementalChangeStalenessCommandsDir(runOwnedRoot, scenario.id, treatmentId, role);
77
128
  indexBuildCounts[treatmentId] += 1;
78
- const prepared = await deps.prepareIndexSession({
79
- target: { absoluteTargetRoot: target.targetRoot, sourceRoots: [...sourceRoots] },
129
+ const { evidence } = await prepareTreatmentIndexEvidence({
130
+ deps,
131
+ runOwnedRoot,
132
+ scenarioId: scenario.id,
80
133
  kitCommand,
81
- indexDir,
82
- commandsDir,
83
- requireKit: false
84
- });
85
- if (!prepared.ok) {
86
- fail({
87
- code: role === "baseline" ? "baseline-index-build-failed" : "refresh-index-build-failed",
88
- message: `${treatmentId} ${role} my-dev-kit index build failed: ${prepared.warnings.join(" ") || `exit code ${prepared.build.command.exitCode}`}`,
89
- treatmentId
90
- });
91
- throw new Error("unreachable");
92
- }
93
- // prepareIndexSession resolves only after the index command, snapshot capture, and graph
94
- // loading have all completed, in that order.
95
- events.push(`${treatmentId}:${role}-index-built`, `${treatmentId}:${role}-snapshot-captured`, `${treatmentId}:${role}-graph-loaded`);
96
- const evidence = Object.freeze({
134
+ sourceRoots,
135
+ target,
97
136
  role,
98
- treatmentId,
99
- builtRelativeToMutation: role === "baseline" ? "pre-mutation" : "post-mutation",
100
- indexDir: prepared.session.indexDir,
101
- commandsDir,
102
- targetRoot: prepared.session.targetRoot,
103
- sourceRoots: prepared.session.sourceRoots,
104
- buildDurationMs: prepared.session.buildDurationMs,
105
- buildCommand: prepared.session.buildCommand,
106
- snapshot: prepared.session.indexSnapshot,
107
- graph: prepared.session.affectedNeighborhoodGraph
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`)
108
141
  });
109
- fail(evaluateIndexEvidence(evidence, { targetRoot: target.targetRoot, sourceRoots, indexDir }));
110
142
  events.push(`${treatmentId}:${role}-identity-verified`);
111
143
  return evidence;
112
144
  }
@@ -130,7 +130,7 @@ export function evaluateBaselineStaleFreshness(assessment, controlledChangedPath
130
130
  * baseline freshness change evidence must agree path-for-path, including
131
131
  * baseline and current content identity.
132
132
  */
133
- export function evaluateChangedPathSymmetry(stale, fullRefresh) {
133
+ export function evaluateChangedPathSymmetry(stale, fullRefresh, labels = { reference: "stale-index", other: "full-refresh" }) {
134
134
  const key = (assessment) => assessment.changes
135
135
  .map((change) => `${normalizeRelativePath(change.path)}\t${change.changeType}\t${change.baselineSha256}\t${change.currentSha256 ?? "null"}`)
136
136
  .sort();
@@ -138,15 +138,17 @@ export function evaluateChangedPathSymmetry(stale, fullRefresh) {
138
138
  const right = key(fullRefresh);
139
139
  const same = left.length === right.length && left.every((value, index) => value === right[index]);
140
140
  if (!same) {
141
- return failure("treatment-changed-paths-conflict", `stale-index and full-refresh baseline freshness disagree about the controlled change: stale=[${left.join("; ")}] full-refresh=[${right.join("; ")}].`, null);
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
142
  }
143
143
  return null;
144
144
  }
145
145
  /** The refreshed index must be proven complete `fresh` against the unchanged mutated target. */
146
- export function evaluateRefreshedFreshness(assessment) {
146
+ export function evaluateRefreshedFreshness(assessment, treatmentId = "full-refresh") {
147
147
  const complete = assessment.baselineSnapshotStatus === "complete" && assessment.unresolvedFileCount === 0;
148
148
  if (assessment.status !== "fresh" || !complete || assessment.changedFileCount !== 0 || assessment.missingFileCount !== 0) {
149
- return failure("refreshed-freshness-not-fresh", `full-refresh refreshed index freshness is ${assessment.status} (snapshot ${assessment.baselineSnapshotStatus}, ${assessment.changedFileCount} changed, ${assessment.missingFileCount} missing, ${assessment.unresolvedFileCount} unresolved); complete fresh is required.`, "full-refresh");
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
150
  }
151
151
  return null;
152
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
+ }