@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.
- package/CHANGELOG.md +32 -0
- package/README.md +7 -4
- package/benchmarks/contracts/incremental-change-staleness-scenarios.json +360 -0
- package/dist/src/commands/runExperimentRunCommand.js +4 -3
- package/dist/src/evaluation/affectedNeighborhood.js +712 -0
- package/dist/src/evaluation/affectedNeighborhoodMetrics.js +51 -0
- package/dist/src/evaluation/indexSnapshot.js +1 -1
- package/dist/src/experiments/defaultRegistry.js +2 -0
- package/dist/src/experiments/plugins/incrementalChangeStaleness/comparison.js +101 -0
- package/dist/src/experiments/plugins/incrementalChangeStaleness/config.js +66 -0
- package/dist/src/experiments/plugins/incrementalChangeStaleness/disposableTarget.js +107 -0
- package/dist/src/experiments/plugins/incrementalChangeStaleness/execution.js +404 -0
- package/dist/src/experiments/plugins/incrementalChangeStaleness/executionArtifact.js +160 -0
- package/dist/src/experiments/plugins/incrementalChangeStaleness/index.js +13 -0
- package/dist/src/experiments/plugins/incrementalChangeStaleness/lifecycle.js +276 -0
- package/dist/src/experiments/plugins/incrementalChangeStaleness/lifecyclePolicy.js +152 -0
- package/dist/src/experiments/plugins/incrementalChangeStaleness/mutationExecution.js +144 -0
- package/dist/src/experiments/plugins/incrementalChangeStaleness/plugin.js +274 -0
- package/dist/src/experiments/plugins/incrementalChangeStaleness/scenarioCatalog.js +42 -0
- package/dist/src/experiments/plugins/incrementalChangeStaleness/scenarioMutation.js +116 -0
- package/dist/src/experiments/plugins/incrementalChangeStaleness/scenarioSelection.js +79 -0
- package/dist/src/experiments/plugins/incrementalChangeStaleness/scenarioTypes.js +37 -0
- package/dist/src/experiments/plugins/incrementalChangeStaleness/scenarioValidation.js +168 -0
- package/dist/src/experiments/plugins/incrementalChangeStaleness/sourceState.js +145 -0
- package/dist/src/experiments/plugins/incrementalChangeStaleness/treatmentSession.js +6 -0
- package/dist/src/experiments/plugins/index.js +1 -0
- package/dist/src/experiments/plugins/warmIndexReuse/execution.js +21 -0
- package/dist/src/experiments/plugins/warmIndexReuse/executionArtifact.js +1 -0
- package/dist/src/experiments/plugins/warmIndexReuse/metrics.js +68 -0
- package/dist/src/experiments/plugins/warmIndexReuse/warmIndexSession.js +9 -1
- package/dist/src/report/experiments/buildIncrementalChangeStalenessReport.js +112 -0
- package/dist/src/report/experiments/buildPluginExperimentReport.js +17 -2
- package/dist/src/report/experiments/buildWarmIndexReuseReport.js +80 -3
- package/dist/src/report/experiments/incrementalChangeStalenessReportModel.js +4 -0
- package/dist/src/report/experiments/index.js +3 -0
- package/dist/src/report/experiments/renderIncrementalChangeStalenessHtml.js +239 -0
- package/dist/src/report/experiments/renderPluginExperimentReportHtml.js +3 -0
- package/dist/src/report/experiments/renderPluginExperimentReportText.js +222 -0
- package/dist/src/report/experiments/renderWarmIndexReuseHtml.js +98 -0
- package/dist/src/report/experiments/warmIndexReuseReportModel.js +5 -0
- package/docs/METRICS.md +79 -3
- package/examples/tutorial-browser/prepare.mjs +13 -13
- package/examples/tutorial-browser/server.mjs +13 -13
- package/package.json +1 -1
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
const FIELD_LABELS = {
|
|
2
|
+
changedFileCount: "the changed indexed file count",
|
|
3
|
+
changedSymbolCount: "the changed baseline symbol count",
|
|
4
|
+
affectedNodeCount: "the affected graph node count",
|
|
5
|
+
affectedEdgeCount: "the affected graph edge count",
|
|
6
|
+
taskOverlapCount: "the task-overlap node count",
|
|
7
|
+
taskOverlapPercent: "the task-overlap percent"
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Converts a persisted (or in-memory) affected-neighborhood assessment into the six frozen v0.6.1
|
|
11
|
+
* metrics, once. A finite value (including zero) is available; a missing assessment or a null
|
|
12
|
+
* field is unavailable with a bounded reason, never zero. Pure; this is the ONLY owner of this
|
|
13
|
+
* conversion, reused unchanged by every caller.
|
|
14
|
+
*/
|
|
15
|
+
export function toAffectedNeighborhoodMetricValues(assessment, noAssessmentReason) {
|
|
16
|
+
const one = (field) => {
|
|
17
|
+
const unit = field === "taskOverlapPercent" ? "percent" : "count";
|
|
18
|
+
if (!assessment) {
|
|
19
|
+
return { availability: "unavailable", value: null, unit, reason: noAssessmentReason };
|
|
20
|
+
}
|
|
21
|
+
const value = assessment[field];
|
|
22
|
+
if (typeof value === "number") {
|
|
23
|
+
return { availability: "available", value, unit, reason: null };
|
|
24
|
+
}
|
|
25
|
+
const context = `assessment ${assessment.status}; seed mapping ${assessment.seedMappingStatus}; ` +
|
|
26
|
+
`graph ${assessment.graphEvidenceStatus}; neighborhood ${assessment.neighborhoodStatus}; task mapping ${assessment.taskMapping.status}` +
|
|
27
|
+
(field === "taskOverlapPercent" ? `; resolvable task nodes ${assessment.taskMapping.resolvableTaskNodeCount}` : "");
|
|
28
|
+
return {
|
|
29
|
+
availability: "unavailable",
|
|
30
|
+
value: null,
|
|
31
|
+
unit,
|
|
32
|
+
reason: `Affected-neighborhood evidence did not establish ${FIELD_LABELS[field]} (${context}).`
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
return {
|
|
36
|
+
changedFileCount: one("changedFileCount"),
|
|
37
|
+
changedSymbolCount: one("changedSymbolCount"),
|
|
38
|
+
affectedNodeCount: one("affectedNodeCount"),
|
|
39
|
+
affectedEdgeCount: one("affectedEdgeCount"),
|
|
40
|
+
taskOverlapCount: one("taskOverlapCount"),
|
|
41
|
+
taskOverlapPercent: one("taskOverlapPercent")
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
export const AFFECTED_NEIGHBORHOOD_METRIC_FIELDS = [
|
|
45
|
+
"changedFileCount",
|
|
46
|
+
"changedSymbolCount",
|
|
47
|
+
"affectedNodeCount",
|
|
48
|
+
"affectedEdgeCount",
|
|
49
|
+
"taskOverlapCount",
|
|
50
|
+
"taskOverlapPercent"
|
|
51
|
+
];
|
|
@@ -228,7 +228,7 @@ async function enumerateIndexArtifacts(indexDir) {
|
|
|
228
228
|
artifacts.sort((left, right) => compareCodeUnits(left.path, right.path));
|
|
229
229
|
return { artifacts, truncated };
|
|
230
230
|
}
|
|
231
|
-
async function readJson(filePath) {
|
|
231
|
+
export async function readJson(filePath) {
|
|
232
232
|
let text;
|
|
233
233
|
try {
|
|
234
234
|
text = await readFile(filePath, "utf8");
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { ExperimentPluginRegistry } from "./registry.js";
|
|
2
2
|
import { contextStrategyComparisonPlugin } from "./plugins/contextStrategyComparison/index.js";
|
|
3
|
+
import { incrementalChangeStalenessPlugin } from "./plugins/incrementalChangeStaleness/index.js";
|
|
3
4
|
import { warmIndexReusePlugin } from "./plugins/warmIndexReuse/index.js";
|
|
4
5
|
export function createDefaultExperimentPluginRegistry() {
|
|
5
6
|
const registry = new ExperimentPluginRegistry();
|
|
6
7
|
registry.register(contextStrategyComparisonPlugin);
|
|
7
8
|
registry.register(warmIndexReusePlugin);
|
|
9
|
+
registry.register(incrementalChangeStalenessPlugin);
|
|
8
10
|
return registry;
|
|
9
11
|
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// v0.6.2 Batch 4 -- pure, deterministic comparison/classification owner.
|
|
2
|
+
//
|
|
3
|
+
// This module owns exactly:
|
|
4
|
+
// - CorrectnessRelationV1;
|
|
5
|
+
// - RequiredFileEvidenceStatusV1 / RequiredFileEvidenceV1;
|
|
6
|
+
// - RequiredFileEvidenceRelationV1;
|
|
7
|
+
// - StaleRiskClassificationV1;
|
|
8
|
+
// - the pure functions that compute them.
|
|
9
|
+
//
|
|
10
|
+
// Every function here is a pure, side-effect-free transform over already
|
|
11
|
+
// collected evidence. No retrieval, no filesystem access, no my-dev-kit
|
|
12
|
+
// invocation, and no numeric risk score: see the frozen batch prompt
|
|
13
|
+
// sections 22-35 for the exact rules this file implements.
|
|
14
|
+
/** Section 23: exact correctness comparison rules. */
|
|
15
|
+
export function compareCorrectness(stale, fullRefresh) {
|
|
16
|
+
if (!stale.available || !fullRefresh.available) {
|
|
17
|
+
return "unknown";
|
|
18
|
+
}
|
|
19
|
+
if (stale.score < fullRefresh.score)
|
|
20
|
+
return "stale-worse";
|
|
21
|
+
if (stale.score > fullRefresh.score)
|
|
22
|
+
return "stale-better";
|
|
23
|
+
return "same";
|
|
24
|
+
}
|
|
25
|
+
/** Repository-standard forward-slash path normalization, matching existing lifecycle policy. */
|
|
26
|
+
export function normalizeRequiredFilePath(value) {
|
|
27
|
+
return value.replace(/\\/g, "/").replace(/^(\.\/)+/, "");
|
|
28
|
+
}
|
|
29
|
+
function sortedUnique(values) {
|
|
30
|
+
return [...new Set(values.map(normalizeRequiredFilePath))].sort((left, right) => (left < right ? -1 : left > right ? 1 : 0));
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Section 27: builds one treatment's required-file evidence.
|
|
34
|
+
*
|
|
35
|
+
* - `observedFilesComplete: false` means the treatment's actual-files-read set could not be
|
|
36
|
+
* established completely (retrieval failed/partial or the contract could not prove completeness);
|
|
37
|
+
* the result is always `unknown` in that case, regardless of `observedFiles` contents.
|
|
38
|
+
* - Otherwise: every normalized required file present in `observedFiles` is `present`; any
|
|
39
|
+
* absence is `missing`.
|
|
40
|
+
*/
|
|
41
|
+
export function buildRequiredFileEvidence(args) {
|
|
42
|
+
const requiredFiles = sortedUnique(args.requiredFiles);
|
|
43
|
+
const observedFiles = sortedUnique(args.observedFiles);
|
|
44
|
+
if (!args.observedFilesComplete) {
|
|
45
|
+
return {
|
|
46
|
+
status: "unknown",
|
|
47
|
+
requiredFiles,
|
|
48
|
+
observedFiles,
|
|
49
|
+
missingFiles: [],
|
|
50
|
+
reason: args.unknownReason ?? "Retrieval evidence did not establish a complete actual-files-read set."
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
const observedSet = new Set(observedFiles);
|
|
54
|
+
const missingFiles = requiredFiles.filter((file) => !observedSet.has(file));
|
|
55
|
+
return {
|
|
56
|
+
status: missingFiles.length === 0 ? "present" : "missing",
|
|
57
|
+
requiredFiles,
|
|
58
|
+
observedFiles,
|
|
59
|
+
missingFiles,
|
|
60
|
+
reason: null
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
/** Section 30: exact required-file comparison rules. */
|
|
64
|
+
export function compareRequiredFileEvidence(stale, fullRefresh) {
|
|
65
|
+
if (stale.status === "unknown" || fullRefresh.status === "unknown") {
|
|
66
|
+
return "unknown";
|
|
67
|
+
}
|
|
68
|
+
if (stale.status === "missing" && fullRefresh.status === "present")
|
|
69
|
+
return "stale-worse";
|
|
70
|
+
if (stale.status === "present" && fullRefresh.status === "missing")
|
|
71
|
+
return "stale-better";
|
|
72
|
+
return "same";
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Sections 31-35: the frozen precedence algorithm. Correctness and required-file evidence are the
|
|
76
|
+
* only two inputs; affected-neighborhood evidence and reindexRecommendation are never inputs here.
|
|
77
|
+
*/
|
|
78
|
+
export function classifyStaleRisk(correctnessRelation, requiredFileEvidenceRelation) {
|
|
79
|
+
const reasonCodes = [];
|
|
80
|
+
// RULE 1 -- observed regression wins even if the other dimension is unknown.
|
|
81
|
+
if (correctnessRelation === "stale-worse" || requiredFileEvidenceRelation === "stale-worse") {
|
|
82
|
+
if (correctnessRelation === "stale-worse")
|
|
83
|
+
reasonCodes.push("stale-correctness-lower");
|
|
84
|
+
if (requiredFileEvidenceRelation === "stale-worse")
|
|
85
|
+
reasonCodes.push("stale-missing-required-file");
|
|
86
|
+
return { staleRiskClassification: "observed-stale-regression", reasonCodes: dedupe(reasonCodes) };
|
|
87
|
+
}
|
|
88
|
+
// RULE 2 -- inconclusive.
|
|
89
|
+
if (correctnessRelation === "unknown" || requiredFileEvidenceRelation === "unknown") {
|
|
90
|
+
if (correctnessRelation === "unknown")
|
|
91
|
+
reasonCodes.push("correctness-unavailable");
|
|
92
|
+
if (requiredFileEvidenceRelation === "unknown")
|
|
93
|
+
reasonCodes.push("required-file-evidence-unavailable");
|
|
94
|
+
return { staleRiskClassification: "inconclusive", reasonCodes: dedupe(reasonCodes) };
|
|
95
|
+
}
|
|
96
|
+
// RULE 3 -- no observed regression.
|
|
97
|
+
return { staleRiskClassification: "no-observed-stale-regression", reasonCodes: ["no-stale-specific-difference-observed"] };
|
|
98
|
+
}
|
|
99
|
+
function dedupe(values) {
|
|
100
|
+
return [...new Set(values)];
|
|
101
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { invalidExperimentConfig, isPlainObject, mergeConfig, validExperimentConfig } from "../../config.js";
|
|
2
|
+
import { FROZEN_INCREMENTAL_CHANGE_STALENESS_SCENARIO_IDS } from "./scenarioTypes.js";
|
|
3
|
+
/**
|
|
4
|
+
* The frozen v0.6.2 compatibility baseline for the upstream tool. Every index
|
|
5
|
+
* of one matched scenario must come from this one configured command.
|
|
6
|
+
*/
|
|
7
|
+
export const INCREMENTAL_CHANGE_STALENESS_DEFAULT_KIT_COMMAND = "npx @dailephd/my-dev-kit@1.12.4";
|
|
8
|
+
export const defaultIncrementalChangeStalenessConfig = {
|
|
9
|
+
outDir: "lab-output/incremental-change-staleness",
|
|
10
|
+
kitCommand: INCREMENTAL_CHANGE_STALENESS_DEFAULT_KIT_COMMAND
|
|
11
|
+
};
|
|
12
|
+
const SUPPORTED_FIELDS = ["outDir", "kitCommand", "caseIds"];
|
|
13
|
+
export const incrementalChangeStalenessConfigDefinition = {
|
|
14
|
+
fields: [
|
|
15
|
+
{ name: "outDir", type: "string", required: true, description: "Experiment output root." },
|
|
16
|
+
{ name: "kitCommand", type: "string", description: "my-dev-kit command used for every treatment index build." },
|
|
17
|
+
{
|
|
18
|
+
name: "caseIds",
|
|
19
|
+
type: "array",
|
|
20
|
+
description: `Frozen scenario ids to include (${FROZEN_INCREMENTAL_CHANGE_STALENESS_SCENARIO_IDS.join(", ")}); defaults to all. Both stale-index and full-refresh always run for each selected scenario.`
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Closed validation: unknown fields (including any attempt to supply variant
|
|
26
|
+
* filters, catalog paths, or mutation text) are rejected. Scenario ids must be
|
|
27
|
+
* frozen production ids, non-empty, and free of duplicates, so invalid
|
|
28
|
+
* selections fail before any target or index work.
|
|
29
|
+
*/
|
|
30
|
+
export function validateIncrementalChangeStalenessConfig(config) {
|
|
31
|
+
if (config !== undefined && !isPlainObject(config)) {
|
|
32
|
+
return invalidExperimentConfig(["incremental-change-staleness config must be an object."]);
|
|
33
|
+
}
|
|
34
|
+
const errors = [];
|
|
35
|
+
const unsupported = Object.keys(config ?? {}).filter((key) => !SUPPORTED_FIELDS.includes(key));
|
|
36
|
+
if (unsupported.length > 0) {
|
|
37
|
+
errors.push(`Unsupported incremental-change-staleness config field(s): ${unsupported.sort().join(", ")}.`);
|
|
38
|
+
}
|
|
39
|
+
const normalized = mergeConfig(defaultIncrementalChangeStalenessConfig, config);
|
|
40
|
+
for (const field of ["outDir", "kitCommand"]) {
|
|
41
|
+
const value = normalized[field];
|
|
42
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
43
|
+
errors.push(`${field} must be a non-empty string.`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const caseIds = normalized.caseIds;
|
|
47
|
+
if (caseIds !== undefined) {
|
|
48
|
+
if (!Array.isArray(caseIds) || caseIds.length === 0 || !caseIds.every((item) => typeof item === "string" && item.trim())) {
|
|
49
|
+
errors.push("caseIds must be a non-empty array of non-empty scenario id strings when provided.");
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
const duplicates = caseIds.filter((id, index) => caseIds.indexOf(id) !== index);
|
|
53
|
+
if (duplicates.length > 0) {
|
|
54
|
+
errors.push(`caseIds contains duplicate scenario id(s): ${[...new Set(duplicates)].join(", ")}.`);
|
|
55
|
+
}
|
|
56
|
+
const unknown = caseIds.filter((id) => !FROZEN_INCREMENTAL_CHANGE_STALENESS_SCENARIO_IDS.includes(id));
|
|
57
|
+
if (unknown.length > 0) {
|
|
58
|
+
errors.push(`caseIds contains unknown scenario id(s): ${unknown.join(", ")}. Expected one of ${FROZEN_INCREMENTAL_CHANGE_STALENESS_SCENARIO_IDS.join(", ")}.`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (errors.length > 0) {
|
|
63
|
+
return invalidExperimentConfig(errors);
|
|
64
|
+
}
|
|
65
|
+
return validExperimentConfig(normalized);
|
|
66
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { cp, realpath, rm } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { resolveWithinRoot } from "../../../core/pathSafety.js";
|
|
5
|
+
/**
|
|
6
|
+
* The two primary v0.6.2 treatment identities, already frozen by the planned
|
|
7
|
+
* plugin design. Batch 2 uses them only to name independent disposable
|
|
8
|
+
* target copies; it never builds a treatment index or runs retrieval.
|
|
9
|
+
*/
|
|
10
|
+
export const INCREMENTAL_CHANGE_STALENESS_TREATMENT_IDS = ["stale-index", "full-refresh"];
|
|
11
|
+
/**
|
|
12
|
+
* Default project-relative, gitignored runtime location for disposable
|
|
13
|
+
* treatment targets. Callers may pass a different `runRoot`, but it must
|
|
14
|
+
* still resolve inside `repoRoot` (see `createDisposableTreatmentTarget`).
|
|
15
|
+
*/
|
|
16
|
+
export const DEFAULT_INCREMENTAL_CHANGE_STALENESS_RUNTIME_ROOT_RELATIVE = ".my-dev-kit-context/runtime/incremental-change-staleness";
|
|
17
|
+
function assertSafeRuntimeBoundary(repoRoot, runRoot) {
|
|
18
|
+
// runRoot itself must live inside the repository; this also rejects a
|
|
19
|
+
// caller-supplied runRoot that points outside the my-dev-kit-lab worktree.
|
|
20
|
+
return resolveWithinRoot(repoRoot, path.resolve(runRoot));
|
|
21
|
+
}
|
|
22
|
+
function assertDisposableRootDoesNotOverlapCanonical(targetRoot, canonicalProjectRoot) {
|
|
23
|
+
const relativeFromCanonical = path.relative(canonicalProjectRoot, targetRoot);
|
|
24
|
+
const targetIsInsideCanonical = relativeFromCanonical !== "" && !relativeFromCanonical.startsWith("..") && !path.isAbsolute(relativeFromCanonical);
|
|
25
|
+
const relativeFromTarget = path.relative(targetRoot, canonicalProjectRoot);
|
|
26
|
+
const canonicalIsInsideTarget = relativeFromTarget !== "" && !relativeFromTarget.startsWith("..") && !path.isAbsolute(relativeFromTarget);
|
|
27
|
+
if (targetRoot === canonicalProjectRoot || targetIsInsideCanonical || canonicalIsInsideTarget) {
|
|
28
|
+
throw new Error(`Disposable treatment target root ${targetRoot} unsafely overlaps the canonical benchmark project root ${canonicalProjectRoot}.`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Creates one independent disposable copy of a canonical benchmark project
|
|
33
|
+
* for one scenario/treatment pair. Always copies the whole benchmark
|
|
34
|
+
* project (not just the changed file), since later index/retrieval
|
|
35
|
+
* execution requires a coherent project. Never overwrites an existing
|
|
36
|
+
* destination and never allows the destination to overlap the canonical
|
|
37
|
+
* project tree.
|
|
38
|
+
*/
|
|
39
|
+
export async function createDisposableTreatmentTarget(options) {
|
|
40
|
+
const { repoRoot, scenarioId, treatmentId, benchmarkProjectId, canonicalProjectRootRelative } = options;
|
|
41
|
+
if (!INCREMENTAL_CHANGE_STALENESS_TREATMENT_IDS.includes(treatmentId)) {
|
|
42
|
+
throw new Error(`Unknown treatment id: ${treatmentId}. Expected one of ${INCREMENTAL_CHANGE_STALENESS_TREATMENT_IDS.join(", ")}.`);
|
|
43
|
+
}
|
|
44
|
+
if (typeof scenarioId !== "string" || scenarioId.length === 0) {
|
|
45
|
+
throw new Error("scenarioId must be a nonempty string.");
|
|
46
|
+
}
|
|
47
|
+
const resolvedRepoRoot = path.resolve(repoRoot);
|
|
48
|
+
const resolvedRunRoot = assertSafeRuntimeBoundary(resolvedRepoRoot, options.runRoot);
|
|
49
|
+
const canonicalProjectRoot = resolveWithinRoot(resolvedRepoRoot, canonicalProjectRootRelative);
|
|
50
|
+
const targetRoot = resolveWithinRoot(resolvedRunRoot, path.join(scenarioId, treatmentId));
|
|
51
|
+
assertDisposableRootDoesNotOverlapCanonical(targetRoot, canonicalProjectRoot);
|
|
52
|
+
if (!existsSync(canonicalProjectRoot)) {
|
|
53
|
+
throw new Error(`Canonical benchmark project root does not exist: ${canonicalProjectRoot}.`);
|
|
54
|
+
}
|
|
55
|
+
if (existsSync(targetRoot)) {
|
|
56
|
+
throw new Error(`Disposable treatment target already exists; refusing to overwrite: ${targetRoot}.`);
|
|
57
|
+
}
|
|
58
|
+
await cp(canonicalProjectRoot, targetRoot, { recursive: true, errorOnExist: true, force: false });
|
|
59
|
+
return {
|
|
60
|
+
scenarioId,
|
|
61
|
+
treatmentId,
|
|
62
|
+
benchmarkProjectId,
|
|
63
|
+
canonicalProjectRoot,
|
|
64
|
+
targetRoot
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Removes one disposable treatment target directory. Only ever deletes a
|
|
69
|
+
* path that resolves inside the caller-owned `runRoot`; refuses to touch
|
|
70
|
+
* anything else, including a canonical benchmark project. Idempotent: a
|
|
71
|
+
* missing target is a successful no-op.
|
|
72
|
+
*/
|
|
73
|
+
export async function removeDisposableTreatmentTarget(target, repoRoot, runRoot) {
|
|
74
|
+
const resolvedRepoRoot = path.resolve(repoRoot);
|
|
75
|
+
const resolvedRunRoot = assertSafeRuntimeBoundary(resolvedRepoRoot, runRoot);
|
|
76
|
+
// Re-derive containment from the caller-owned root rather than trusting the descriptor blindly.
|
|
77
|
+
const containedTargetRoot = resolveWithinRoot(resolvedRunRoot, path.resolve(target.targetRoot));
|
|
78
|
+
await rm(containedTargetRoot, { recursive: true, force: true });
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Removes the entire caller-owned runtime root (both treatment copies for
|
|
82
|
+
* every scenario created under it). Refuses to operate unless `runRoot`
|
|
83
|
+
* resolves inside `repoRoot`, and never touches anything outside it.
|
|
84
|
+
*/
|
|
85
|
+
export async function removeIncrementalChangeStalenessRuntimeRoot(repoRoot, runRoot) {
|
|
86
|
+
const resolvedRepoRoot = path.resolve(repoRoot);
|
|
87
|
+
const resolvedRunRoot = assertSafeRuntimeBoundary(resolvedRepoRoot, runRoot);
|
|
88
|
+
await rm(resolvedRunRoot, { recursive: true, force: true });
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Resolves a target-relative path inside a disposable treatment target,
|
|
92
|
+
* rejecting both textual path traversal and symlink escape: the resolved
|
|
93
|
+
* real path (once the file exists) must still be contained by the real
|
|
94
|
+
* path of the treatment root.
|
|
95
|
+
*/
|
|
96
|
+
export async function resolveWithinDisposableTarget(target, relativePath) {
|
|
97
|
+
const resolved = resolveWithinRoot(target.targetRoot, relativePath);
|
|
98
|
+
if (!existsSync(resolved)) {
|
|
99
|
+
return resolved;
|
|
100
|
+
}
|
|
101
|
+
const [realResolved, realRoot] = await Promise.all([realpath(resolved), realpath(target.targetRoot)]);
|
|
102
|
+
const relativeFromRoot = path.relative(realRoot, realResolved);
|
|
103
|
+
if (relativeFromRoot === ".." || relativeFromRoot.startsWith(`..${path.sep}`) || path.isAbsolute(relativeFromRoot)) {
|
|
104
|
+
throw new Error(`Resolved path escapes the disposable treatment root via a symbolic link: ${relativePath}`);
|
|
105
|
+
}
|
|
106
|
+
return resolved;
|
|
107
|
+
}
|