@dailephd/my-dev-kit-lab 0.8.2 → 0.9.0

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 (99) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +18 -3
  3. package/benchmarks/contracts/agent-success-rate-project-profiles.json +33 -0
  4. package/benchmarks/contracts/agent-success-rate-tasks.json +671 -0
  5. package/benchmarks/projects/agent-success-inventory-node/.gitattributes +2 -0
  6. package/benchmarks/projects/agent-success-inventory-node/README.md +74 -0
  7. package/benchmarks/projects/agent-success-inventory-node/package.json +14 -0
  8. package/benchmarks/projects/agent-success-inventory-node/src/fulfillmentReport.js +38 -0
  9. package/benchmarks/projects/agent-success-inventory-node/src/index.js +26 -0
  10. package/benchmarks/projects/agent-success-inventory-node/src/inventoryStore.js +114 -0
  11. package/benchmarks/projects/agent-success-inventory-node/src/quantity.js +19 -0
  12. package/benchmarks/projects/agent-success-inventory-node/src/reservationService.js +45 -0
  13. package/benchmarks/projects/agent-success-inventory-node/tests/quantity-edge.check.mjs +51 -0
  14. package/benchmarks/projects/agent-success-inventory-node/tests/quantity-primary.check.mjs +46 -0
  15. package/benchmarks/projects/agent-success-inventory-node/tests/regression.check.mjs +115 -0
  16. package/benchmarks/projects/agent-success-inventory-node/tests/report-edge.check.mjs +109 -0
  17. package/benchmarks/projects/agent-success-inventory-node/tests/report-primary.check.mjs +47 -0
  18. package/benchmarks/projects/agent-success-inventory-node/tests/reservation-edge.check.mjs +63 -0
  19. package/benchmarks/projects/agent-success-inventory-node/tests/reservation-primary.check.mjs +44 -0
  20. package/benchmarks/projects/agent-success-task-board-node/.gitattributes +2 -0
  21. package/benchmarks/projects/agent-success-task-board-node/README.md +77 -0
  22. package/benchmarks/projects/agent-success-task-board-node/package.json +14 -0
  23. package/benchmarks/projects/agent-success-task-board-node/src/index.js +24 -0
  24. package/benchmarks/projects/agent-success-task-board-node/src/projectSummary.js +15 -0
  25. package/benchmarks/projects/agent-success-task-board-node/src/taskService.js +58 -0
  26. package/benchmarks/projects/agent-success-task-board-node/src/taskStore.js +58 -0
  27. package/benchmarks/projects/agent-success-task-board-node/src/validation.js +31 -0
  28. package/benchmarks/projects/agent-success-task-board-node/tests/import-edge.check.mjs +65 -0
  29. package/benchmarks/projects/agent-success-task-board-node/tests/import-primary.check.mjs +31 -0
  30. package/benchmarks/projects/agent-success-task-board-node/tests/regression.check.mjs +107 -0
  31. package/benchmarks/projects/agent-success-task-board-node/tests/summary-edge.check.mjs +67 -0
  32. package/benchmarks/projects/agent-success-task-board-node/tests/summary-primary.check.mjs +54 -0
  33. package/benchmarks/projects/agent-success-task-board-node/tests/title-edge.check.mjs +39 -0
  34. package/benchmarks/projects/agent-success-task-board-node/tests/title-primary.check.mjs +29 -0
  35. package/dist/src/cli/help.js +38 -1
  36. package/dist/src/commands/runExperimentDescribeCommand.js +52 -1
  37. package/dist/src/commands/runExperimentRunCommand.js +118 -2
  38. package/dist/src/core/processTree.js +55 -0
  39. package/dist/src/core/runMeasuredCommand.js +104 -22
  40. package/dist/src/evaluation/agentSuccess/applyPatchToSandbox.js +37 -0
  41. package/dist/src/evaluation/agentSuccess/assessBaseline.js +23 -0
  42. package/dist/src/evaluation/agentSuccess/corpusTypes.js +74 -0
  43. package/dist/src/evaluation/agentSuccess/extractPatchCandidate.js +46 -0
  44. package/dist/src/evaluation/agentSuccess/index.js +14 -0
  45. package/dist/src/evaluation/agentSuccess/parseUnifiedDiff.js +210 -0
  46. package/dist/src/evaluation/agentSuccess/patchTypes.js +5 -0
  47. package/dist/src/evaluation/agentSuccess/readAgentSuccessCorpus.js +183 -0
  48. package/dist/src/evaluation/agentSuccess/runVerificationCheck.js +100 -0
  49. package/dist/src/evaluation/agentSuccess/taskPaths.js +34 -0
  50. package/dist/src/evaluation/agentSuccess/taskTypes.js +14 -0
  51. package/dist/src/evaluation/agentSuccess/validateAgentSuccessCorpus.js +307 -0
  52. package/dist/src/evaluation/agentSuccess/validateAgentSuccessTask.js +297 -0
  53. package/dist/src/evaluation/agentSuccess/validatePatchPolicy.js +66 -0
  54. package/dist/src/evaluation/agentSuccess/verificationTypes.js +1 -0
  55. package/dist/src/evaluation/benchmarkSandbox/createBenchmarkSandbox.js +127 -0
  56. package/dist/src/evaluation/benchmarkSandbox/errors.js +11 -0
  57. package/dist/src/evaluation/benchmarkSandbox/gitExecutor.js +60 -0
  58. package/dist/src/evaluation/benchmarkSandbox/index.js +8 -0
  59. package/dist/src/evaluation/benchmarkSandbox/minimalHostEnv.js +37 -0
  60. package/dist/src/evaluation/benchmarkSandbox/pathPolicy.js +49 -0
  61. package/dist/src/evaluation/benchmarkSandbox/removeBenchmarkSandbox.js +74 -0
  62. package/dist/src/evaluation/benchmarkSandbox/treeSnapshot.js +57 -0
  63. package/dist/src/evaluation/benchmarkSandbox/types.js +3 -0
  64. package/dist/src/evaluation/changeSet/captureChangeSet.js +98 -0
  65. package/dist/src/evaluation/changeSet/index.js +2 -0
  66. package/dist/src/evaluation/changeSet/types.js +9 -0
  67. package/dist/src/experiments/defaultRegistry.js +2 -0
  68. package/dist/src/experiments/plugins/agentSuccessRate/agentTaskProjection.js +12 -0
  69. package/dist/src/experiments/plugins/agentSuccessRate/analysis.js +425 -0
  70. package/dist/src/experiments/plugins/agentSuccessRate/analysisArtifact.js +155 -0
  71. package/dist/src/experiments/plugins/agentSuccessRate/analysisTypes.js +65 -0
  72. package/dist/src/experiments/plugins/agentSuccessRate/attemptEvidence.js +7 -0
  73. package/dist/src/experiments/plugins/agentSuccessRate/config.js +123 -0
  74. package/dist/src/experiments/plugins/agentSuccessRate/contextGeneration.js +497 -0
  75. package/dist/src/experiments/plugins/agentSuccessRate/execution.js +441 -0
  76. package/dist/src/experiments/plugins/agentSuccessRate/executionArtifact.js +126 -0
  77. package/dist/src/experiments/plugins/agentSuccessRate/executionTypes.js +2 -0
  78. package/dist/src/experiments/plugins/agentSuccessRate/index.js +20 -0
  79. package/dist/src/experiments/plugins/agentSuccessRate/metadata.js +28 -0
  80. package/dist/src/experiments/plugins/agentSuccessRate/metrics.js +76 -0
  81. package/dist/src/experiments/plugins/agentSuccessRate/plugin.js +507 -0
  82. package/dist/src/experiments/plugins/agentSuccessRate/realAgentExecution.js +250 -0
  83. package/dist/src/experiments/plugins/agentSuccessRate/realAgentPrompt.js +43 -0
  84. package/dist/src/experiments/plugins/agentSuccessRate/repairAnalysis.js +148 -0
  85. package/dist/src/experiments/plugins/agentSuccessRate/repairExecution.js +103 -0
  86. package/dist/src/experiments/plugins/agentSuccessRate/repairFeedback.js +91 -0
  87. package/dist/src/experiments/plugins/agentSuccessRate/repairPolicy.js +88 -0
  88. package/dist/src/experiments/plugins/agentSuccessRate/types.js +1 -0
  89. package/dist/src/experiments/plugins/warmIndexReuse/plugin.js +2 -3
  90. package/dist/src/report/experiments/agentSuccessRateReportModel.js +6 -0
  91. package/dist/src/report/experiments/buildAgentSuccessRateReport.js +255 -0
  92. package/dist/src/report/experiments/buildPluginExperimentReport.js +27 -2
  93. package/dist/src/report/experiments/index.js +4 -0
  94. package/dist/src/report/experiments/renderAgentSuccessRateHtml.js +169 -0
  95. package/dist/src/report/experiments/renderAgentSuccessRateText.js +139 -0
  96. package/dist/src/report/experiments/renderPluginExperimentReportHtml.js +3 -0
  97. package/dist/src/report/experiments/renderPluginExperimentReportText.js +5 -0
  98. package/docs/METRICS.md +70 -0
  99. package/package.json +1 -1
@@ -0,0 +1,297 @@
1
+ import { TASK_LOCALITIES } from "../types.js";
2
+ import { normalizeProjectRelativePath } from "./taskPaths.js";
3
+ import { AGENT_SUCCESS_TASK_SCHEMA_VERSION, AgentSuccessTaskValidationError, MAX_VERIFICATION_CHECK_TIMEOUT_MS } from "./taskTypes.js";
4
+ const MAX_ISSUES = 50;
5
+ const MAX_SHORT_TEXT = 1000;
6
+ const MAX_INSTRUCTION = 20_000;
7
+ const MAX_FIXTURE_PATCH_BYTES = 1_048_576;
8
+ const MAX_CHECK_ARGS = 64;
9
+ const MAX_CHECK_ARG_LENGTH = 4096;
10
+ const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
11
+ const CHECK_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
12
+ const TASK_FIELDS = [
13
+ "schemaVersion",
14
+ "id",
15
+ "title",
16
+ "benchmarkProject",
17
+ "projectProfileRef",
18
+ "taskLocality",
19
+ "query",
20
+ "sourceRoots",
21
+ "rawIncludeGlobs",
22
+ "instruction",
23
+ "expectedEditFiles",
24
+ "allowedEditFiles",
25
+ "protectedFiles",
26
+ "taskChecks",
27
+ "regressionChecks",
28
+ "behaviorFacts",
29
+ "deterministicFixture"
30
+ ];
31
+ const CHECK_FIELDS = ["id", "executable", "args", "timeoutMs"];
32
+ const FACT_FIELDS = ["id", "text", "required", "verificationCheckIds"];
33
+ const FIXTURE_FIELDS = ["id", "patch", "notes"];
34
+ function isRecord(value) {
35
+ return typeof value === "object" && value !== null && !Array.isArray(value);
36
+ }
37
+ class IssueCollector {
38
+ issues = [];
39
+ add(code, path, message) {
40
+ if (this.issues.length < MAX_ISSUES)
41
+ this.issues.push({ code, path, message });
42
+ }
43
+ }
44
+ function rejectUnknownFields(collector, value, allowed, base) {
45
+ for (const key of Object.keys(value)) {
46
+ if (!allowed.includes(key))
47
+ collector.add("UNKNOWN_FIELD", base === "" ? key : `${base}.${key}`, `unknown field "${key}".`);
48
+ }
49
+ }
50
+ function readText(collector, source, key, base, maxLength) {
51
+ const path = base === "" ? key : `${base}.${key}`;
52
+ const value = source[key];
53
+ if (value === undefined) {
54
+ collector.add("MISSING_FIELD", path, "required field is missing.");
55
+ return "";
56
+ }
57
+ if (typeof value !== "string" || value.trim() === "") {
58
+ collector.add("INVALID_TYPE", path, "must be a non-empty string.");
59
+ return "";
60
+ }
61
+ if (value.length > maxLength) {
62
+ collector.add("INVALID_VALUE", path, `must be at most ${maxLength} characters.`);
63
+ }
64
+ return value;
65
+ }
66
+ function readArray(collector, source, key) {
67
+ const value = source[key];
68
+ if (value === undefined) {
69
+ collector.add("MISSING_FIELD", key, "required field is missing.");
70
+ return undefined;
71
+ }
72
+ if (!Array.isArray(value)) {
73
+ collector.add("INVALID_TYPE", key, "must be an array.");
74
+ return undefined;
75
+ }
76
+ return value;
77
+ }
78
+ /** Normalizes, rejects duplicates, and returns the list sorted in code-unit order. */
79
+ function readPathList(collector, source, key, requireNonEmpty) {
80
+ const raw = readArray(collector, source, key);
81
+ if (raw === undefined)
82
+ return [];
83
+ if (requireNonEmpty && raw.length === 0)
84
+ collector.add("INVALID_VALUE", key, "must not be empty.");
85
+ const seen = new Set();
86
+ raw.forEach((entry, index) => {
87
+ const normalized = normalizeProjectRelativePath(entry);
88
+ const path = `${key}[${index}]`;
89
+ if (!normalized.ok) {
90
+ collector.add(normalized.code, path, `unsafe or malformed project-relative path.`);
91
+ return;
92
+ }
93
+ if (seen.has(normalized.path)) {
94
+ collector.add("DUPLICATE_PATH", path, `duplicate path after normalization: ${normalized.path}.`);
95
+ return;
96
+ }
97
+ seen.add(normalized.path);
98
+ });
99
+ return [...seen].sort();
100
+ }
101
+ function readGlobList(collector, source, key) {
102
+ const raw = readArray(collector, source, key);
103
+ if (raw === undefined)
104
+ return [];
105
+ if (raw.length === 0)
106
+ collector.add("INVALID_VALUE", key, "must not be empty.");
107
+ const globs = [];
108
+ raw.forEach((entry, index) => {
109
+ const path = `${key}[${index}]`;
110
+ if (typeof entry !== "string" || entry.trim() === "" || entry.includes("\0")) {
111
+ collector.add("INVALID_TYPE", path, "must be a non-empty string without NUL.");
112
+ }
113
+ else if (/^([A-Za-z]:|[\\/])/.test(entry) || entry.replace(/\\/g, "/").split("/").includes("..")) {
114
+ collector.add("INVALID_VALUE", path, "must be a project-relative glob without traversal.");
115
+ }
116
+ else {
117
+ globs.push(entry);
118
+ }
119
+ });
120
+ return globs;
121
+ }
122
+ function readChecks(collector, source, key) {
123
+ const raw = readArray(collector, source, key);
124
+ if (raw === undefined)
125
+ return [];
126
+ if (raw.length < 1)
127
+ collector.add("TOO_FEW_CHECKS", key, "at least one check is required.");
128
+ const checks = [];
129
+ raw.forEach((entry, index) => {
130
+ const base = `${key}[${index}]`;
131
+ if (!isRecord(entry)) {
132
+ collector.add("NOT_OBJECT", base, "must be an object.");
133
+ return;
134
+ }
135
+ rejectUnknownFields(collector, entry, CHECK_FIELDS, base);
136
+ const id = entry.id;
137
+ if (typeof id !== "string" || !CHECK_ID_PATTERN.test(id)) {
138
+ collector.add("INVALID_VALUE", `${base}.id`, `must match ${CHECK_ID_PATTERN.source}.`);
139
+ }
140
+ if (entry.executable !== "node") {
141
+ collector.add("INVALID_EXECUTABLE", `${base}.executable`, 'the only supported executable is "node".');
142
+ }
143
+ const args = entry.args;
144
+ if (!Array.isArray(args) ||
145
+ args.length > MAX_CHECK_ARGS ||
146
+ !args.every((arg) => typeof arg === "string" && !arg.includes("\0") && arg.length <= MAX_CHECK_ARG_LENGTH)) {
147
+ collector.add("INVALID_TYPE", `${base}.args`, `must be an array of at most ${MAX_CHECK_ARGS} strings without NUL.`);
148
+ }
149
+ const timeoutMs = entry.timeoutMs;
150
+ if (typeof timeoutMs !== "number" || !Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > MAX_VERIFICATION_CHECK_TIMEOUT_MS) {
151
+ collector.add("INVALID_TIMEOUT", `${base}.timeoutMs`, `must be a finite number in (0, ${MAX_VERIFICATION_CHECK_TIMEOUT_MS}].`);
152
+ }
153
+ if (typeof id === "string" && Array.isArray(args) && typeof timeoutMs === "number") {
154
+ checks.push({ id, executable: "node", args: args, timeoutMs });
155
+ }
156
+ });
157
+ return checks;
158
+ }
159
+ function readFacts(collector, source, checkIds) {
160
+ const raw = readArray(collector, source, "behaviorFacts");
161
+ if (raw === undefined)
162
+ return [];
163
+ const facts = [];
164
+ const seen = new Set();
165
+ raw.forEach((entry, index) => {
166
+ const base = `behaviorFacts[${index}]`;
167
+ if (!isRecord(entry)) {
168
+ collector.add("NOT_OBJECT", base, "must be an object.");
169
+ return;
170
+ }
171
+ rejectUnknownFields(collector, entry, FACT_FIELDS, base);
172
+ const id = readText(collector, entry, "id", base, 128);
173
+ const text = readText(collector, entry, "text", base, MAX_SHORT_TEXT);
174
+ if (id !== "" && seen.has(id))
175
+ collector.add("DUPLICATE_FACT_ID", `${base}.id`, `duplicate fact id: ${id}.`);
176
+ seen.add(id);
177
+ if (typeof entry.required !== "boolean")
178
+ collector.add("INVALID_TYPE", `${base}.required`, "must be a boolean.");
179
+ const ids = entry.verificationCheckIds;
180
+ if (!Array.isArray(ids) || !ids.every((value) => typeof value === "string")) {
181
+ collector.add("INVALID_TYPE", `${base}.verificationCheckIds`, "must be an array of strings.");
182
+ return;
183
+ }
184
+ ids.forEach((checkId, checkIndex) => {
185
+ if (!checkIds.has(checkId)) {
186
+ collector.add("UNRESOLVED_CHECK_ID", `${base}.verificationCheckIds[${checkIndex}]`, `unknown check id: ${checkId}.`);
187
+ }
188
+ });
189
+ if (entry.required === true && ids.length === 0) {
190
+ collector.add("REQUIRED_FACT_UNMAPPED", `${base}.verificationCheckIds`, "a required fact must reference at least one check.");
191
+ }
192
+ facts.push({ id, text, required: entry.required === true, verificationCheckIds: [...ids] });
193
+ });
194
+ return facts;
195
+ }
196
+ function readFixture(collector, source) {
197
+ const raw = source.deterministicFixture;
198
+ if (raw === undefined)
199
+ return undefined;
200
+ if (!isRecord(raw)) {
201
+ collector.add("NOT_OBJECT", "deterministicFixture", "must be an object.");
202
+ return undefined;
203
+ }
204
+ rejectUnknownFields(collector, raw, FIXTURE_FIELDS, "deterministicFixture");
205
+ const id = readText(collector, raw, "id", "deterministicFixture", 128);
206
+ const patch = readText(collector, raw, "patch", "deterministicFixture", MAX_FIXTURE_PATCH_BYTES);
207
+ const fixture = { id, patch };
208
+ if (raw.notes !== undefined) {
209
+ if (typeof raw.notes === "string")
210
+ fixture.notes = raw.notes;
211
+ else
212
+ collector.add("INVALID_TYPE", "deterministicFixture.notes", "must be a string.");
213
+ }
214
+ return fixture;
215
+ }
216
+ /**
217
+ * Validates an AgentSuccessTaskV1 candidate. Closed-set: unknown fields are rejected. Returns the normalized
218
+ * task (POSIX paths, sorted, de-duplicated by rejection) or a bounded, deterministically ordered issue list.
219
+ */
220
+ export function validateAgentSuccessTask(input) {
221
+ const collector = new IssueCollector();
222
+ if (!isRecord(input)) {
223
+ collector.add("NOT_OBJECT", "", "a task must be an object.");
224
+ return { ok: false, issues: collector.issues };
225
+ }
226
+ rejectUnknownFields(collector, input, TASK_FIELDS, "");
227
+ if (input.schemaVersion !== AGENT_SUCCESS_TASK_SCHEMA_VERSION) {
228
+ collector.add("INVALID_SCHEMA_VERSION", "schemaVersion", `must equal ${AGENT_SUCCESS_TASK_SCHEMA_VERSION}.`);
229
+ }
230
+ const id = readText(collector, input, "id", "", 128);
231
+ if (id !== "" && !ID_PATTERN.test(id))
232
+ collector.add("INVALID_VALUE", "id", `must match ${ID_PATTERN.source}.`);
233
+ const title = readText(collector, input, "title", "", MAX_SHORT_TEXT);
234
+ const benchmarkProject = readText(collector, input, "benchmarkProject", "", MAX_SHORT_TEXT);
235
+ const projectProfileRef = readText(collector, input, "projectProfileRef", "", MAX_SHORT_TEXT);
236
+ const query = readText(collector, input, "query", "", MAX_SHORT_TEXT);
237
+ const instruction = readText(collector, input, "instruction", "", MAX_INSTRUCTION);
238
+ const taskLocality = input.taskLocality;
239
+ if (!TASK_LOCALITIES.includes(taskLocality)) {
240
+ collector.add("INVALID_VALUE", "taskLocality", `must be one of ${TASK_LOCALITIES.join(", ")}.`);
241
+ }
242
+ const sourceRoots = readPathList(collector, input, "sourceRoots", true);
243
+ const rawIncludeGlobs = readGlobList(collector, input, "rawIncludeGlobs");
244
+ const expectedEditFiles = readPathList(collector, input, "expectedEditFiles", false);
245
+ const allowedEditFiles = readPathList(collector, input, "allowedEditFiles", false);
246
+ const protectedFiles = readPathList(collector, input, "protectedFiles", false);
247
+ const allowed = new Set(allowedEditFiles);
248
+ expectedEditFiles.forEach((file) => {
249
+ if (!allowed.has(file))
250
+ collector.add("EXPECTED_NOT_ALLOWED", "expectedEditFiles", `${file} is not in allowedEditFiles.`);
251
+ });
252
+ const protectedSet = new Set(protectedFiles);
253
+ for (const file of [...expectedEditFiles, ...allowedEditFiles]) {
254
+ if (protectedSet.has(file))
255
+ collector.add("PROTECTED_OVERLAP", "protectedFiles", `${file} is also an expected or allowed edit file.`);
256
+ }
257
+ const taskChecks = readChecks(collector, input, "taskChecks");
258
+ const regressionChecks = readChecks(collector, input, "regressionChecks");
259
+ const checkIds = new Set();
260
+ for (const check of [...taskChecks, ...regressionChecks]) {
261
+ if (checkIds.has(check.id))
262
+ collector.add("DUPLICATE_CHECK_ID", "taskChecks", `check id used more than once: ${check.id}.`);
263
+ checkIds.add(check.id);
264
+ }
265
+ const behaviorFacts = readFacts(collector, input, checkIds);
266
+ const deterministicFixture = readFixture(collector, input);
267
+ if (collector.issues.length > 0) {
268
+ return { ok: false, issues: collector.issues };
269
+ }
270
+ const task = {
271
+ schemaVersion: AGENT_SUCCESS_TASK_SCHEMA_VERSION,
272
+ id,
273
+ title,
274
+ benchmarkProject,
275
+ projectProfileRef,
276
+ taskLocality: taskLocality,
277
+ query,
278
+ sourceRoots,
279
+ rawIncludeGlobs,
280
+ instruction,
281
+ expectedEditFiles,
282
+ allowedEditFiles,
283
+ protectedFiles,
284
+ taskChecks,
285
+ regressionChecks,
286
+ behaviorFacts,
287
+ ...(deterministicFixture ? { deterministicFixture } : {})
288
+ };
289
+ return { ok: true, task };
290
+ }
291
+ /** Throwing variant for callers that treat an invalid task as a programming/configuration error. */
292
+ export function assertAgentSuccessTask(input) {
293
+ const result = validateAgentSuccessTask(input);
294
+ if (!result.ok)
295
+ throw new AgentSuccessTaskValidationError(result.issues);
296
+ return result.task;
297
+ }
@@ -0,0 +1,66 @@
1
+ import { normalizeProjectRelativePath } from "./taskPaths.js";
2
+ import { DEFAULT_PATCH_BOUNDS } from "./patchTypes.js";
3
+ const MAX_REPORTED_REJECTIONS = 20;
4
+ const SYMLINK_MODE = "120000";
5
+ const GITLINK_MODE = "160000";
6
+ function describeStatus(file) {
7
+ if (file.isNew || (file.oldPath === null && file.newPath !== null))
8
+ return "added";
9
+ if (file.isDelete || (file.newPath === null && file.oldPath !== null))
10
+ return "deleted";
11
+ return "modified";
12
+ }
13
+ /**
14
+ * Applies the Lab's patch policy to a parsed diff. Everything a patch could use to escape the benchmark
15
+ * copy or to alter protected files is rejected here, before Git sees the patch. A file outside the task's
16
+ * allowed edit scope is NOT rejected: such edits are legitimate evidence for later measurement.
17
+ */
18
+ export function validatePatchPolicy(files, options) {
19
+ const bounds = options.bounds ?? DEFAULT_PATCH_BOUNDS;
20
+ const rejections = [];
21
+ const reject = (rejection) => {
22
+ if (rejections.length < MAX_REPORTED_REJECTIONS)
23
+ rejections.push(rejection);
24
+ };
25
+ const protectedSet = new Set();
26
+ for (const entry of options.protectedFiles) {
27
+ const normalized = normalizeProjectRelativePath(entry);
28
+ if (normalized.ok)
29
+ protectedSet.add(normalized.path);
30
+ }
31
+ const accepted = new Map();
32
+ for (const file of files) {
33
+ const label = file.newPath ?? file.oldPath ?? file.gitHeaderPath ?? undefined;
34
+ if (file.binary)
35
+ reject({ code: "BINARY_PATCH", path: label, message: "binary patches are not accepted." });
36
+ if (file.rename || file.copy)
37
+ reject({ code: "RENAME_OR_COPY", path: label, message: "rename and copy patches are not accepted." });
38
+ if (file.oldPath !== null && file.newPath !== null && file.oldPath !== file.newPath) {
39
+ reject({ code: "PATH_MISMATCH", path: label, message: "the old and new paths differ." });
40
+ }
41
+ if (file.oldMode === SYMLINK_MODE || file.newMode === SYMLINK_MODE) {
42
+ reject({ code: "SYMLINK_MODE", path: label, message: "symbolic-link entries are not accepted." });
43
+ }
44
+ if (file.oldMode === GITLINK_MODE || file.newMode === GITLINK_MODE || file.subproject) {
45
+ reject({ code: "SUBMODULE", path: label, message: "submodule/gitlink changes are not accepted." });
46
+ }
47
+ const candidates = new Set([file.oldPath, file.newPath, file.oldPath === null && file.newPath === null ? file.gitHeaderPath : null].filter((value) => value !== null));
48
+ for (const candidate of candidates) {
49
+ const normalized = normalizeProjectRelativePath(candidate);
50
+ if (!normalized.ok) {
51
+ reject({ code: "UNSAFE_PATH", message: `unsafe patch path (${normalized.code}).` });
52
+ continue;
53
+ }
54
+ if (protectedSet.has(normalized.path)) {
55
+ reject({ code: "PROTECTED_PATH", path: normalized.path, message: "the patch touches a protected file." });
56
+ }
57
+ accepted.set(normalized.path, describeStatus(file));
58
+ }
59
+ }
60
+ if (accepted.size > bounds.maxChangedPaths) {
61
+ rejections.unshift({ code: "TOO_MANY_PATHS", message: `the patch changes more than ${bounds.maxChangedPaths} paths.` });
62
+ }
63
+ if (rejections.length > 0)
64
+ return { ok: false, rejections: rejections.slice(0, MAX_REPORTED_REJECTIONS) };
65
+ return { ok: true, files: [...accepted].map(([path, status]) => ({ path, status })).sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)) };
66
+ }
@@ -0,0 +1 @@
1
+ export const VERIFICATION_OUTPUT_LIMIT_BYTES = 1_048_576;
@@ -0,0 +1,127 @@
1
+ import { constants as fsConstants } from "node:fs";
2
+ import { copyFile, lstat, mkdir, realpath, rm, stat, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { resolveWithinRoot } from "../../core/pathSafety.js";
5
+ import { BenchmarkSandboxError } from "./errors.js";
6
+ import { excerptGitFailure, runSandboxGit } from "./gitExecutor.js";
7
+ import { BENCHMARK_SANDBOX_EXCLUDED_NAMES, isSameOrInside, resolvePhysicalPath, SANDBOX_ID_PATTERN } from "./pathPolicy.js";
8
+ import { digestTreeSnapshot, scanProjectTree, snapshotProjectTree } from "./treeSnapshot.js";
9
+ import { BENCHMARK_SANDBOX_BASELINE_BRANCH, BENCHMARK_SANDBOX_OWNER_MARKER_FILE, BENCHMARK_SANDBOX_SCHEMA_VERSION } from "./types.js";
10
+ const BASELINE_COMMIT_MESSAGE = "benchmark sandbox baseline";
11
+ /** Fixed dates make the baseline commit id a pure function of the copied content, so repeated runs agree. */
12
+ const BASELINE_COMMIT_DATE_ENV = Object.freeze({ GIT_AUTHOR_DATE: "2000-01-01T00:00:00Z", GIT_COMMITTER_DATE: "2000-01-01T00:00:00Z" });
13
+ async function pathExists(target) {
14
+ try {
15
+ await lstat(target);
16
+ return true;
17
+ }
18
+ catch {
19
+ return false;
20
+ }
21
+ }
22
+ async function resolveCanonicalProjectRoot(canonicalProjectRoot) {
23
+ let stats;
24
+ try {
25
+ stats = await lstat(path.resolve(canonicalProjectRoot));
26
+ }
27
+ catch {
28
+ throw new BenchmarkSandboxError("CANONICAL_ROOT_INVALID", "the canonical project root does not exist.");
29
+ }
30
+ if (stats.isSymbolicLink() || !(await stat(path.resolve(canonicalProjectRoot))).isDirectory()) {
31
+ throw new BenchmarkSandboxError("CANONICAL_ROOT_INVALID", "the canonical project root must be a real directory.");
32
+ }
33
+ return realpath(path.resolve(canonicalProjectRoot));
34
+ }
35
+ async function mustGit(target, args, label, env) {
36
+ const result = await runSandboxGit(target, args, { label, env });
37
+ if (result.unavailable) {
38
+ throw new BenchmarkSandboxError("GIT_UNAVAILABLE", "the git executable is not available.");
39
+ }
40
+ if (!result.ok) {
41
+ throw new BenchmarkSandboxError("GIT_FAILED", `git ${args[0] ?? ""} failed: ${excerptGitFailure(result)}`);
42
+ }
43
+ return result;
44
+ }
45
+ /**
46
+ * Creates one independent, Git-backed disposable copy of a controlled benchmark project inside the
47
+ * caller-supplied runtime root. Nothing is ever written to the canonical project, and the runtime root is
48
+ * never defaulted. Order: validate -> scan source (reject links) -> copy -> manifest -> fresh Git baseline.
49
+ * Any failure after the sandbox directory exists removes that (owned) directory before rethrowing.
50
+ */
51
+ export async function createBenchmarkSandbox(options) {
52
+ if (!SANDBOX_ID_PATTERN.test(options.sandboxId)) {
53
+ throw new BenchmarkSandboxError("INVALID_SANDBOX_ID", `sandbox id must match ${SANDBOX_ID_PATTERN.source}.`);
54
+ }
55
+ const canonicalProjectRoot = await resolveCanonicalProjectRoot(options.canonicalProjectRoot);
56
+ const runtimeRoot = path.resolve(options.runtimeRoot);
57
+ const physicalRuntimeRoot = await resolvePhysicalPath(runtimeRoot);
58
+ if (isSameOrInside(canonicalProjectRoot, physicalRuntimeRoot) || isSameOrInside(physicalRuntimeRoot, canonicalProjectRoot)) {
59
+ throw new BenchmarkSandboxError("RUNTIME_OVERLAP", "the runtime root and the canonical project must not overlap.");
60
+ }
61
+ const sandboxRoot = resolveWithinRoot(runtimeRoot, options.sandboxId);
62
+ if (await pathExists(sandboxRoot)) {
63
+ throw new BenchmarkSandboxError("SANDBOX_EXISTS", "refusing to overwrite an existing sandbox directory.");
64
+ }
65
+ // Reject links and unsupported entries before anything is created under the runtime root.
66
+ const sourceFiles = await scanProjectTree(canonicalProjectRoot, BENCHMARK_SANDBOX_EXCLUDED_NAMES);
67
+ if (sourceFiles.length === 0) {
68
+ throw new BenchmarkSandboxError("EMPTY_PROJECT", "the canonical project has no copyable files.");
69
+ }
70
+ await mkdir(runtimeRoot, { recursive: true });
71
+ await mkdir(sandboxRoot);
72
+ try {
73
+ const marker = {
74
+ schemaVersion: BENCHMARK_SANDBOX_SCHEMA_VERSION,
75
+ sandboxId: options.sandboxId,
76
+ runtimeRoot,
77
+ canonicalProjectRoot
78
+ };
79
+ await writeFile(path.join(sandboxRoot, BENCHMARK_SANDBOX_OWNER_MARKER_FILE), `${JSON.stringify(marker, null, 2)}\n`, "utf8");
80
+ const projectRoot = path.join(sandboxRoot, "project");
81
+ const evidenceRoot = path.join(sandboxRoot, "evidence");
82
+ await mkdir(projectRoot);
83
+ await mkdir(evidenceRoot);
84
+ for (const relativePath of sourceFiles) {
85
+ const segments = relativePath.split("/");
86
+ const destination = path.join(projectRoot, ...segments);
87
+ await mkdir(path.dirname(destination), { recursive: true });
88
+ await copyFile(path.join(canonicalProjectRoot, ...segments), destination, fsConstants.COPYFILE_EXCL);
89
+ }
90
+ const manifest = await snapshotProjectTree(projectRoot, { excludedNames: [".git"] });
91
+ if (manifest.length !== sourceFiles.length || manifest.some((entry, index) => entry.path !== sourceFiles[index])) {
92
+ throw new BenchmarkSandboxError("COPY_MISMATCH", "the copied tree does not match the scanned source file list.");
93
+ }
94
+ const git = { projectRoot, evidenceRoot };
95
+ await mustGit(git, ["init", "-q", `--initial-branch=${BENCHMARK_SANDBOX_BASELINE_BRANCH}`], "init");
96
+ await mustGit(git, ["config", "user.name", "my-dev-kit-lab sandbox"], "config-name");
97
+ await mustGit(git, ["config", "user.email", "sandbox@my-dev-kit-lab.invalid"], "config-email");
98
+ await mustGit(git, ["config", "commit.gpgsign", "false"], "config-gpg");
99
+ await mustGit(git, ["config", "core.autocrlf", "false"], "config-autocrlf");
100
+ await mustGit(git, ["config", "core.longpaths", "true"], "config-longpaths");
101
+ await mustGit(git, ["config", "core.symlinks", "false"], "config-symlinks");
102
+ await mustGit(git, ["add", "-A", "--force"], "add");
103
+ await mustGit(git, ["commit", "-q", "--no-verify", "-m", BASELINE_COMMIT_MESSAGE], "commit", BASELINE_COMMIT_DATE_ENV);
104
+ const commit = (await mustGit(git, ["rev-parse", "HEAD"], "rev-parse")).stdout.trim();
105
+ const status = await mustGit(git, ["status", "--porcelain"], "status");
106
+ if (status.stdout.trim() !== "") {
107
+ throw new BenchmarkSandboxError("BASELINE_NOT_CLEAN", "git status is not clean after the baseline commit.");
108
+ }
109
+ return {
110
+ schemaVersion: BENCHMARK_SANDBOX_SCHEMA_VERSION,
111
+ sandboxId: options.sandboxId,
112
+ sandboxRoot,
113
+ projectRoot,
114
+ evidenceRoot,
115
+ runtimeRoot,
116
+ canonicalProjectRoot,
117
+ baseline: { commit, fileCount: manifest.length, digest: digestTreeSnapshot(manifest), manifest }
118
+ };
119
+ }
120
+ catch (error) {
121
+ // Only this call's own directory exists at this point; removal cannot touch anything else.
122
+ await rm(sandboxRoot, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }).catch(() => undefined);
123
+ if (error instanceof BenchmarkSandboxError)
124
+ throw error;
125
+ throw new BenchmarkSandboxError("COPY_FAILED", error instanceof Error ? error.message : String(error));
126
+ }
127
+ }
@@ -0,0 +1,11 @@
1
+ const MAX_MESSAGE_LENGTH = 500;
2
+ /** Bounded, typed failure of sandbox creation, baseline, or tree inspection. */
3
+ export class BenchmarkSandboxError extends Error {
4
+ code;
5
+ constructor(code, message) {
6
+ const bounded = message.length > MAX_MESSAGE_LENGTH ? `${message.slice(0, MAX_MESSAGE_LENGTH)}...` : message;
7
+ super(`Benchmark sandbox error (${code}): ${bounded}`);
8
+ this.name = "BenchmarkSandboxError";
9
+ this.code = code;
10
+ }
11
+ }
@@ -0,0 +1,60 @@
1
+ import path from "node:path";
2
+ import { runMeasuredCommand } from "../../core/runMeasuredCommand.js";
3
+ import { buildMinimalHostEnv } from "./minimalHostEnv.js";
4
+ // Git (including Git for Windows) opens this as an empty config file; os.devNull (\\.\nul) is rejected by Git on Windows.
5
+ const NULL_CONFIG_PATH = process.platform === "win32" ? "NUL" : "/dev/null";
6
+ const DEFAULT_GIT_TIMEOUT_MS = 60_000;
7
+ const GIT_OUTPUT_LIMIT_BYTES = 8 * 1024 * 1024;
8
+ const sequenceByEvidenceRoot = new Map();
9
+ function nextSequence(evidenceRoot) {
10
+ const next = (sequenceByEvidenceRoot.get(evidenceRoot) ?? 0) + 1;
11
+ sequenceByEvidenceRoot.set(evidenceRoot, next);
12
+ return next;
13
+ }
14
+ /**
15
+ * Runs Git for a sandbox through the shared measured-command owner (structured arguments, shell:false,
16
+ * bounded output, awaited tree termination, evidence files). The environment is isolated from the user's
17
+ * and the system's Git configuration so hashes and diffs do not depend on the developer machine.
18
+ */
19
+ export async function runSandboxGit(target, args, options = {}) {
20
+ const label = (options.label ?? args[0] ?? "git").replace(/[^A-Za-z0-9_-]/g, "-").slice(0, 32);
21
+ const commandId = `git-${String(nextSequence(target.evidenceRoot)).padStart(4, "0")}-${label}`;
22
+ const env = buildMinimalHostEnv(process.env, {
23
+ GIT_CONFIG_NOSYSTEM: "1",
24
+ GIT_CONFIG_GLOBAL: NULL_CONFIG_PATH,
25
+ GIT_TERMINAL_PROMPT: "0",
26
+ GIT_OPTIONAL_LOCKS: "0",
27
+ HOME: target.evidenceRoot,
28
+ USERPROFILE: target.evidenceRoot,
29
+ ...options.env
30
+ });
31
+ const result = await runMeasuredCommand({
32
+ commandId,
33
+ executable: "git",
34
+ args: ["-c", "core.quotepath=false", "-c", "core.autocrlf=false", "-c", "core.fsmonitor=false", ...args],
35
+ cwd: target.projectRoot,
36
+ outDir: path.join(target.evidenceRoot, "git"),
37
+ env,
38
+ inheritParentEnv: false,
39
+ stdinText: options.stdin,
40
+ timeoutMs: options.timeoutMs ?? DEFAULT_GIT_TIMEOUT_MS,
41
+ stdoutMaxBytes: GIT_OUTPUT_LIMIT_BYTES,
42
+ stderrMaxBytes: GIT_OUTPUT_LIMIT_BYTES,
43
+ terminateProcessTree: true
44
+ });
45
+ const unavailable = result.resolvedCommand?.resolutionKind === "unavailable";
46
+ return {
47
+ ok: result.ok,
48
+ exitCode: result.exitCode,
49
+ stdout: result.stdout,
50
+ stderr: result.stderr,
51
+ timedOut: result.timedOut === true,
52
+ unavailable,
53
+ error: result.error
54
+ };
55
+ }
56
+ /** Bounded single-line excerpt of Git diagnostics for error messages. */
57
+ export function excerptGitFailure(result) {
58
+ const text = (result.stderr || result.stdout || result.error || "no output").trim().replace(/\s+/g, " ");
59
+ return text.length > 300 ? `${text.slice(0, 300)}...` : text;
60
+ }
@@ -0,0 +1,8 @@
1
+ export * from "./errors.js";
2
+ export * from "./types.js";
3
+ export { createBenchmarkSandbox } from "./createBenchmarkSandbox.js";
4
+ export { removeBenchmarkSandbox } from "./removeBenchmarkSandbox.js";
5
+ export { digestTreeSnapshot, scanProjectTree, sha256OfBuffer, snapshotProjectTree } from "./treeSnapshot.js";
6
+ export { buildMinimalHostEnv } from "./minimalHostEnv.js";
7
+ export { excerptGitFailure, runSandboxGit } from "./gitExecutor.js";
8
+ export { BENCHMARK_SANDBOX_EXCLUDED_NAMES, SANDBOX_ID_PATTERN } from "./pathPolicy.js";
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Variables a child process needs to locate and start `node` or `git` on the supported platforms. Nothing
3
+ * else is forwarded from the parent: provider keys, tokens, cookies and project secrets must never reach
4
+ * Lab-run verification or Git processes. Names are matched case-insensitively (Windows environments are).
5
+ */
6
+ const FORWARDED_NAMES = [
7
+ "path",
8
+ "systemroot",
9
+ "systemdrive",
10
+ "windir",
11
+ "comspec",
12
+ "pathext",
13
+ "temp",
14
+ "tmp",
15
+ "tmpdir",
16
+ "lang",
17
+ "lc_all"
18
+ ];
19
+ /** Defense in depth: even an allowed name is dropped if it looks credential-like. */
20
+ const CREDENTIAL_LIKE = /(key|token|secret|passw|credential|auth|cookie|session)/i;
21
+ export function buildMinimalHostEnv(source = process.env, extras = {}) {
22
+ const env = {};
23
+ const actualNames = Object.keys(source);
24
+ for (const wanted of FORWARDED_NAMES) {
25
+ const actual = actualNames.find((name) => name.toLowerCase() === wanted);
26
+ if (actual === undefined)
27
+ continue;
28
+ const value = source[actual];
29
+ if (value === undefined || CREDENTIAL_LIKE.test(actual))
30
+ continue;
31
+ env[actual] = value;
32
+ }
33
+ for (const [name, value] of Object.entries(extras)) {
34
+ env[name] = value;
35
+ }
36
+ return env;
37
+ }
@@ -0,0 +1,49 @@
1
+ import { realpath } from "node:fs/promises";
2
+ import path from "node:path";
3
+ /** Sandbox identities become one directory name beneath the caller's runtime root. */
4
+ export const SANDBOX_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
5
+ /**
6
+ * Directory/file names never copied into a sandbox and never part of its baseline. `.git` would import the
7
+ * canonical history; the rest are generated or Lab-internal outputs that are not benchmark source.
8
+ */
9
+ export const BENCHMARK_SANDBOX_EXCLUDED_NAMES = Object.freeze([
10
+ ".git",
11
+ "node_modules",
12
+ "dist",
13
+ "build",
14
+ "coverage",
15
+ "lab-output",
16
+ ".my-dev-kit-context",
17
+ ".my-dev-kit-orchestrator"
18
+ ]);
19
+ /**
20
+ * Physical path of a path that may not exist yet: the realpath of the deepest existing ancestor plus the
21
+ * not-yet-created remainder. Resolving physically (not lexically) is what makes overlap checks hold across
22
+ * links, junctions, and Windows drive aliases.
23
+ */
24
+ export async function resolvePhysicalPath(candidate) {
25
+ const resolved = path.resolve(candidate);
26
+ const remainder = [];
27
+ let current = resolved;
28
+ for (;;) {
29
+ try {
30
+ const physical = await realpath(current);
31
+ return remainder.length === 0 ? physical : path.join(physical, ...remainder.reverse());
32
+ }
33
+ catch (error) {
34
+ const code = error.code;
35
+ const parent = path.dirname(current);
36
+ if ((code !== "ENOENT" && code !== "ENOTDIR") || parent === current)
37
+ return resolved;
38
+ remainder.push(path.basename(current));
39
+ current = parent;
40
+ }
41
+ }
42
+ }
43
+ /** True when `candidate` equals `root` or lies beneath it. Both arguments must already be physical paths. */
44
+ export function isSameOrInside(root, candidate) {
45
+ const relative = path.relative(root, candidate);
46
+ if (relative === "")
47
+ return true;
48
+ return !(relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative));
49
+ }