@dailephd/my-dev-kit-lab 0.7.1 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +6 -5
  3. package/dist/src/cli/help.js +15 -4
  4. package/dist/src/commands/runExperimentDescribeCommand.js +5 -2
  5. package/dist/src/commands/runExperimentRunCommand.js +117 -18
  6. package/dist/src/core/fileGlobs.js +44 -0
  7. package/dist/src/evaluation/localRepositorySubject/config.js +296 -0
  8. package/dist/src/evaluation/localRepositorySubject/evaluationCase.js +36 -0
  9. package/dist/src/evaluation/localRepositorySubject/expectedFiles.js +51 -0
  10. package/dist/src/evaluation/localRepositorySubject/gitRepository.js +128 -0
  11. package/dist/src/evaluation/localRepositorySubject/index.js +8 -0
  12. package/dist/src/evaluation/localRepositorySubject/inventory.js +127 -0
  13. package/dist/src/evaluation/localRepositorySubject/loadLocalRepositorySubject.js +48 -0
  14. package/dist/src/evaluation/localRepositorySubject/manifest.js +52 -0
  15. package/dist/src/evaluation/localRepositorySubject/types.js +55 -0
  16. package/dist/src/evaluation/projectFileTree.js +45 -1
  17. package/dist/src/evaluation/runMyDevKitRetrieval.js +3 -1
  18. package/dist/src/evaluation/runRawFullFileBaseline.js +18 -4
  19. package/dist/src/evaluation/targetImmutability/captureTargetSnapshot.js +29 -7
  20. package/dist/src/evaluation/targetImmutability/compareTargetSnapshots.js +4 -0
  21. package/dist/src/experiments/plugins/contextWindowScaling/localSubjectErrors.js +35 -0
  22. package/dist/src/experiments/plugins/contextWindowScaling/localSubjectExclusions.js +75 -0
  23. package/dist/src/experiments/plugins/contextWindowScaling/localSubjectExecution.js +136 -0
  24. package/dist/src/experiments/plugins/contextWindowScaling/localSubjectPrivacy.js +128 -0
  25. package/dist/src/experiments/plugins/contextWindowScaling/localSubjectScratch.js +85 -0
  26. package/dist/src/experiments/plugins/contextWindowScaling/metadata.js +2 -2
  27. package/dist/src/experiments/plugins/contextWindowScaling/plugin.js +93 -0
  28. package/dist/src/experiments/plugins/contextWindowScaling/projectProfile.js +4 -9
  29. package/dist/src/report/experiments/renderPluginExperimentReportHtml.js +4 -3
  30. package/dist/src/report/experiments/writePluginExperimentReports.js +8 -2
  31. package/docs/METRICS.md +2 -0
  32. package/package.json +1 -1
@@ -0,0 +1,296 @@
1
+ import { validateAnswerKey, validateTaskLocality } from "../benchmarkMetadata.js";
2
+ import { LOCAL_REPOSITORY_SUBJECT_CASE_FIELDS, LOCAL_REPOSITORY_SUBJECT_CONFIG_FIELDS, LOCAL_REPOSITORY_SUBJECT_CONFIG_SCHEMA_VERSION, LOCAL_REPOSITORY_SUBJECT_LIMITS, LocalRepositorySubjectConfigError, compareCodeUnits, } from "./types.js";
3
+ const SUBJECT_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
4
+ const REQUIRED_CASE_FIELDS = ["id", "title", "sourceRoots", "query", "expectedFiles", "expectedSymbols", "rawIncludeGlobs"];
5
+ const OPTIONAL_STRING_FIELDS = ["promptComplexityHint", "projectComplexityRelevance", "notes"];
6
+ // eslint-disable-next-line no-control-regex
7
+ const CONTROL_CHARACTER_PATTERN = /[\u0000-\u001f\u007f]/;
8
+ function isPlainObject(value) {
9
+ if (typeof value !== "object" || value === null || Array.isArray(value))
10
+ return false;
11
+ const prototype = Object.getPrototypeOf(value);
12
+ return prototype === Object.prototype || prototype === null;
13
+ }
14
+ function hasOwn(value, key) {
15
+ return Object.prototype.hasOwnProperty.call(value, key);
16
+ }
17
+ function describeValue(value) {
18
+ if (typeof value === "string") {
19
+ return JSON.stringify(value.length > 40 ? `${value.slice(0, 40)}...` : value);
20
+ }
21
+ if (typeof value === "number" || typeof value === "boolean")
22
+ return String(value);
23
+ if (value === null)
24
+ return "null";
25
+ if (Array.isArray(value))
26
+ return "array";
27
+ return typeof value;
28
+ }
29
+ /**
30
+ * Canonicalizes a platform-portable repository-relative logical path (or glob) to forward-slash form.
31
+ * Rejects absolute, drive-qualified, UNC, backslash, traversal, and empty-segment forms. Returns null on rejection.
32
+ */
33
+ export function canonicalizeRepositoryRelativePath(value, label, errors, options) {
34
+ if (typeof value !== "string") {
35
+ errors.push(`${label}: must be a string (received ${describeValue(value)}).`);
36
+ return null;
37
+ }
38
+ if (value.length === 0 || value.trim().length === 0) {
39
+ errors.push(`${label}: must not be empty.`);
40
+ return null;
41
+ }
42
+ if (CONTROL_CHARACTER_PATTERN.test(value)) {
43
+ errors.push(`${label}: must not contain control characters.`);
44
+ return null;
45
+ }
46
+ if (value.includes("\\")) {
47
+ errors.push(`${label}: must use "/" separators, not backslashes (received ${describeValue(value)}).`);
48
+ return null;
49
+ }
50
+ if (value.startsWith("/")) {
51
+ errors.push(`${label}: must be repository-relative, not absolute or UNC (received ${describeValue(value)}).`);
52
+ return null;
53
+ }
54
+ if (/^[A-Za-z]:/.test(value)) {
55
+ errors.push(`${label}: must not be drive-qualified (received ${describeValue(value)}).`);
56
+ return null;
57
+ }
58
+ const segments = value.split("/");
59
+ const kept = [];
60
+ for (let index = 0; index < segments.length; index += 1) {
61
+ const segment = segments[index];
62
+ if (segment === "..") {
63
+ errors.push(`${label}: must not contain parent traversal (received ${describeValue(value)}).`);
64
+ return null;
65
+ }
66
+ if (segment === "") {
67
+ if (index === segments.length - 1 && index > 0)
68
+ continue; // single trailing slash
69
+ errors.push(`${label}: must not contain empty path segments (received ${describeValue(value)}).`);
70
+ return null;
71
+ }
72
+ if (segment === ".")
73
+ continue;
74
+ kept.push(segment);
75
+ }
76
+ if (kept.length === 0) {
77
+ if (options.allowDot)
78
+ return ".";
79
+ errors.push(`${label}: must name a path below the repository root (received ${describeValue(value)}).`);
80
+ return null;
81
+ }
82
+ return kept.join("/");
83
+ }
84
+ function validateSubjectId(value, errors) {
85
+ if (typeof value !== "string") {
86
+ errors.push(`config.subjectId: must be a string (received ${describeValue(value)}).`);
87
+ return false;
88
+ }
89
+ const max = LOCAL_REPOSITORY_SUBJECT_LIMITS.subjectIdMaxLength;
90
+ if (value.length < 1 || value.length > max) {
91
+ errors.push(`config.subjectId: length must be between 1 and ${max}.`);
92
+ return false;
93
+ }
94
+ if (!SUBJECT_ID_PATTERN.test(value)) {
95
+ errors.push(`config.subjectId: must begin with an ASCII letter or digit and contain only ASCII letters, digits, ".", "_" and "-" (received ${describeValue(value)}).`);
96
+ return false;
97
+ }
98
+ return true;
99
+ }
100
+ function validateStringArray(value, label, errors, options) {
101
+ if (!Array.isArray(value)) {
102
+ errors.push(`${label}: must be an array (received ${describeValue(value)}).`);
103
+ return null;
104
+ }
105
+ if (options.nonEmptyArray && value.length === 0) {
106
+ errors.push(`${label}: must not be empty.`);
107
+ return null;
108
+ }
109
+ let ok = true;
110
+ const strings = [];
111
+ value.forEach((entry, index) => {
112
+ if (typeof entry !== "string" || entry.trim().length === 0) {
113
+ errors.push(`${label}[${index}]: must be a non-empty string (received ${describeValue(entry)}).`);
114
+ ok = false;
115
+ }
116
+ else {
117
+ strings.push(entry);
118
+ }
119
+ });
120
+ return ok ? strings : null;
121
+ }
122
+ function validatePathArray(value, label, errors, options) {
123
+ if (!Array.isArray(value)) {
124
+ errors.push(`${label}: must be an array (received ${describeValue(value)}).`);
125
+ return null;
126
+ }
127
+ if (value.length === 0) {
128
+ errors.push(`${label}: must not be empty.`);
129
+ return null;
130
+ }
131
+ let ok = true;
132
+ const canonical = [];
133
+ value.forEach((entry, index) => {
134
+ const path = canonicalizeRepositoryRelativePath(entry, `${label}[${index}]`, errors, options);
135
+ if (path === null) {
136
+ ok = false;
137
+ }
138
+ else if (!canonical.includes(path)) {
139
+ canonical.push(path);
140
+ }
141
+ });
142
+ return ok ? canonical : null;
143
+ }
144
+ function validateCase(value, index, seenIds, errors) {
145
+ const label = `config.cases[${index}]`;
146
+ if (!isPlainObject(value)) {
147
+ errors.push(`${label}: must be an object (received ${describeValue(value)}).`);
148
+ return null;
149
+ }
150
+ const before = errors.length;
151
+ const allowed = new Set(LOCAL_REPOSITORY_SUBJECT_CASE_FIELDS);
152
+ for (const key of Object.keys(value).sort(compareCodeUnits)) {
153
+ if (!allowed.has(key))
154
+ errors.push(`${label}: unknown field ${JSON.stringify(key)}.`);
155
+ }
156
+ for (const field of REQUIRED_CASE_FIELDS) {
157
+ if (!hasOwn(value, field))
158
+ errors.push(`${label}: missing required field ${field}.`);
159
+ }
160
+ let id = null;
161
+ if (hasOwn(value, "id")) {
162
+ const rawId = value.id;
163
+ if (typeof rawId !== "string" || rawId.length < 1 || rawId.length > LOCAL_REPOSITORY_SUBJECT_LIMITS.subjectIdMaxLength) {
164
+ errors.push(`${label}.id: must be a string of length 1 to ${LOCAL_REPOSITORY_SUBJECT_LIMITS.subjectIdMaxLength} (received ${describeValue(rawId)}).`);
165
+ }
166
+ else if (!SUBJECT_ID_PATTERN.test(rawId)) {
167
+ errors.push(`${label}.id: must begin with an ASCII letter or digit and contain only ASCII letters, digits, ".", "_" and "-" (received ${describeValue(rawId)}).`);
168
+ }
169
+ else if (seenIds.has(rawId)) {
170
+ errors.push(`${label}.id: duplicate case id ${JSON.stringify(rawId)}.`);
171
+ }
172
+ else {
173
+ seenIds.add(rawId);
174
+ id = rawId;
175
+ }
176
+ }
177
+ for (const field of ["title", "query"]) {
178
+ if (hasOwn(value, field) && (typeof value[field] !== "string" || value[field].trim().length === 0)) {
179
+ errors.push(`${label}.${field}: must be a non-empty string (received ${describeValue(value[field])}).`);
180
+ }
181
+ }
182
+ const sourceRoots = hasOwn(value, "sourceRoots")
183
+ ? validatePathArray(value.sourceRoots, `${label}.sourceRoots`, errors, { allowDot: true })
184
+ : null;
185
+ const expectedFiles = hasOwn(value, "expectedFiles")
186
+ ? validatePathArray(value.expectedFiles, `${label}.expectedFiles`, errors, { allowDot: false })
187
+ : null;
188
+ const expectedSymbols = hasOwn(value, "expectedSymbols")
189
+ ? validateStringArray(value.expectedSymbols, `${label}.expectedSymbols`, errors, { nonEmptyArray: false })
190
+ : null;
191
+ const rawIncludeGlobs = hasOwn(value, "rawIncludeGlobs")
192
+ ? validatePathArray(value.rawIncludeGlobs, `${label}.rawIncludeGlobs`, errors, { allowDot: false })
193
+ : null;
194
+ if (hasOwn(value, "taskLocality"))
195
+ errors.push(...validateTaskLocality(value.taskLocality, `${label}`));
196
+ if (hasOwn(value, "answerKey"))
197
+ errors.push(...validateAnswerKey(value.answerKey, `${label}`));
198
+ if (hasOwn(value, "expectedFacts")) {
199
+ const facts = value.expectedFacts;
200
+ if (!Array.isArray(facts)) {
201
+ errors.push(`${label}.expectedFacts: must be an array (received ${describeValue(facts)}).`);
202
+ }
203
+ else {
204
+ facts.forEach((fact, factIndex) => {
205
+ if (!isPlainObject(fact))
206
+ errors.push(`${label}.expectedFacts[${factIndex}]: must be an object.`);
207
+ });
208
+ }
209
+ }
210
+ for (const field of OPTIONAL_STRING_FIELDS) {
211
+ if (hasOwn(value, field) && typeof value[field] !== "string") {
212
+ errors.push(`${label}.${field}: must be a string (received ${describeValue(value[field])}).`);
213
+ }
214
+ }
215
+ if (errors.length > before || id === null || sourceRoots === null || expectedFiles === null || expectedSymbols === null || rawIncludeGlobs === null) {
216
+ return null;
217
+ }
218
+ const result = {
219
+ id,
220
+ title: value.title,
221
+ sourceRoots,
222
+ query: value.query,
223
+ expectedFiles,
224
+ expectedSymbols: [...expectedSymbols],
225
+ rawIncludeGlobs,
226
+ };
227
+ if (hasOwn(value, "answerKey"))
228
+ result.answerKey = structuredClone(value.answerKey);
229
+ if (hasOwn(value, "expectedFacts"))
230
+ result.expectedFacts = structuredClone(value.expectedFacts);
231
+ if (hasOwn(value, "taskLocality"))
232
+ result.taskLocality = value.taskLocality;
233
+ for (const field of OPTIONAL_STRING_FIELDS) {
234
+ if (hasOwn(value, field))
235
+ result[field] = value[field];
236
+ }
237
+ return result;
238
+ }
239
+ /** Pure, strict validation of a LocalRepositorySubjectConfigV1 input. No filesystem or Git access. */
240
+ export function validateLocalRepositorySubjectConfig(input) {
241
+ const errors = [];
242
+ if (!isPlainObject(input)) {
243
+ return { ok: false, errors: [`config: must be an object (received ${describeValue(input)}).`] };
244
+ }
245
+ const allowed = new Set(LOCAL_REPOSITORY_SUBJECT_CONFIG_FIELDS);
246
+ for (const key of Object.keys(input).sort(compareCodeUnits)) {
247
+ if (!allowed.has(key))
248
+ errors.push(`config: unknown field ${JSON.stringify(key)}.`);
249
+ }
250
+ if (!hasOwn(input, "schemaVersion")) {
251
+ errors.push("config: missing required field schemaVersion.");
252
+ }
253
+ else if (input.schemaVersion !== LOCAL_REPOSITORY_SUBJECT_CONFIG_SCHEMA_VERSION) {
254
+ errors.push(`config.schemaVersion: must be ${JSON.stringify(LOCAL_REPOSITORY_SUBJECT_CONFIG_SCHEMA_VERSION)} (received ${describeValue(input.schemaVersion)}).`);
255
+ }
256
+ let subjectId = null;
257
+ if (!hasOwn(input, "subjectId")) {
258
+ errors.push("config: missing required field subjectId.");
259
+ }
260
+ else if (validateSubjectId(input.subjectId, errors)) {
261
+ subjectId = input.subjectId;
262
+ }
263
+ const cases = [];
264
+ if (!hasOwn(input, "cases")) {
265
+ errors.push("config: missing required field cases.");
266
+ }
267
+ else if (!Array.isArray(input.cases)) {
268
+ errors.push(`config.cases: must be an array (received ${describeValue(input.cases)}).`);
269
+ }
270
+ else if (input.cases.length === 0) {
271
+ errors.push("config.cases: must not be empty.");
272
+ }
273
+ else {
274
+ const seenIds = new Set();
275
+ input.cases.forEach((entry, index) => {
276
+ const parsed = validateCase(entry, index, seenIds, errors);
277
+ if (parsed)
278
+ cases.push(parsed);
279
+ });
280
+ }
281
+ if (errors.length > 0 || subjectId === null) {
282
+ return { ok: false, errors };
283
+ }
284
+ return {
285
+ ok: true,
286
+ errors: [],
287
+ config: { schemaVersion: LOCAL_REPOSITORY_SUBJECT_CONFIG_SCHEMA_VERSION, subjectId, cases },
288
+ };
289
+ }
290
+ /** Throwing variant used by the loader. */
291
+ export function parseLocalRepositorySubjectConfig(input) {
292
+ const result = validateLocalRepositorySubjectConfig(input);
293
+ if (!result.ok)
294
+ throw new LocalRepositorySubjectConfigError(result.errors);
295
+ return result.config;
296
+ }
@@ -0,0 +1,36 @@
1
+ import { logicalTargetRootForSubject } from "./types.js";
2
+ /**
3
+ * Adapts a validated local-subject config to existing EvaluationCase objects in memory. The logical targetRoot is
4
+ * the durable identity; absoluteTargetRoot is the only place the physical repository path appears.
5
+ */
6
+ export function buildLocalRepositorySubjectEvaluationCases(config, repositoryRoot) {
7
+ const targetRoot = logicalTargetRootForSubject(config.subjectId);
8
+ return config.cases.map((subjectCase) => {
9
+ const evaluationCase = {
10
+ id: subjectCase.id,
11
+ title: subjectCase.title,
12
+ benchmarkProject: config.subjectId,
13
+ targetRoot,
14
+ sourceRoots: [...subjectCase.sourceRoots],
15
+ query: subjectCase.query,
16
+ expectedFiles: [...subjectCase.expectedFiles],
17
+ expectedSymbols: [...subjectCase.expectedSymbols],
18
+ rawIncludeGlobs: [...subjectCase.rawIncludeGlobs],
19
+ absoluteTargetRoot: repositoryRoot,
20
+ };
21
+ if (subjectCase.answerKey !== undefined)
22
+ evaluationCase.answerKey = structuredClone(subjectCase.answerKey);
23
+ if (subjectCase.expectedFacts !== undefined)
24
+ evaluationCase.expectedFacts = structuredClone(subjectCase.expectedFacts);
25
+ if (subjectCase.taskLocality !== undefined)
26
+ evaluationCase.taskLocality = subjectCase.taskLocality;
27
+ if (subjectCase.promptComplexityHint !== undefined)
28
+ evaluationCase.promptComplexityHint = subjectCase.promptComplexityHint;
29
+ if (subjectCase.projectComplexityRelevance !== undefined) {
30
+ evaluationCase.projectComplexityRelevance = subjectCase.projectComplexityRelevance;
31
+ }
32
+ if (subjectCase.notes !== undefined)
33
+ evaluationCase.notes = subjectCase.notes;
34
+ return evaluationCase;
35
+ });
36
+ }
@@ -0,0 +1,51 @@
1
+ import path from "node:path";
2
+ function isWithinCanonicalRoot(filePath, root) {
3
+ return root === "." || filePath === root || filePath.startsWith(`${root}/`);
4
+ }
5
+ async function describeUnavailableFile(repositoryRoot, filePath, io) {
6
+ let current = repositoryRoot;
7
+ const segments = filePath.split("/");
8
+ for (let index = 0; index < segments.length; index += 1) {
9
+ current = path.join(current, segments[index]);
10
+ let stats;
11
+ try {
12
+ stats = await io.lstat(current);
13
+ }
14
+ catch {
15
+ return "does not exist";
16
+ }
17
+ if (stats.isSymbolicLink())
18
+ return "is or passes through a symlink";
19
+ if (index === segments.length - 1 && !stats.isFile())
20
+ return "is not a regular file";
21
+ }
22
+ return "is not eligible subject material";
23
+ }
24
+ /**
25
+ * Validates every required expected file of every case against the safe inventory. A required file is never
26
+ * silently dropped: ignored, oversized, symlinked, missing, non-regular, or out-of-root files each produce an error.
27
+ */
28
+ export async function validateExpectedFiles(repositoryRoot, cases, inventory, io) {
29
+ const eligible = new Set(inventory.eligibleFiles.map((file) => file.path));
30
+ const errors = [];
31
+ for (let caseIndex = 0; caseIndex < cases.length; caseIndex += 1) {
32
+ const subjectCase = cases[caseIndex];
33
+ for (let fileIndex = 0; fileIndex < subjectCase.expectedFiles.length; fileIndex += 1) {
34
+ const filePath = subjectCase.expectedFiles[fileIndex];
35
+ const label = `config.cases[${caseIndex}].expectedFiles[${fileIndex}]`;
36
+ if (!subjectCase.sourceRoots.some((root) => isWithinCanonicalRoot(filePath, root))) {
37
+ errors.push(`${label}: expected file is outside the case source roots (${JSON.stringify(filePath)}).`);
38
+ }
39
+ else if (inventory.ignoredFiles.has(filePath)) {
40
+ errors.push(`${label}: expected file is ignored by Git (${JSON.stringify(filePath)}).`);
41
+ }
42
+ else if (inventory.oversizedFiles.has(filePath)) {
43
+ errors.push(`${label}: expected file exceeds the maximum file size policy (${JSON.stringify(filePath)}).`);
44
+ }
45
+ else if (!eligible.has(filePath)) {
46
+ errors.push(`${label}: expected file ${await describeUnavailableFile(repositoryRoot, filePath, io)} (${JSON.stringify(filePath)}).`);
47
+ }
48
+ }
49
+ }
50
+ return errors;
51
+ }
@@ -0,0 +1,128 @@
1
+ import { execFile } from "node:child_process";
2
+ import { realpath as nativeRealpath } from "node:fs";
3
+ import { stat } from "node:fs/promises";
4
+ import { promisify } from "node:util";
5
+ import { LocalRepositorySubjectRepositoryError } from "./types.js";
6
+ // Native realpath expands Windows short (8.3) names so the selected path and Git's reported root compare equal.
7
+ const realpath = promisify(nativeRealpath.native);
8
+ const GIT_MAX_BUFFER_BYTES = 64 * 1024 * 1024;
9
+ const CHECK_IGNORE_CHUNK_SIZE = 5000;
10
+ const FULL_SHA_PATTERN = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
11
+ function gitEnvironment() {
12
+ const env = { ...process.env };
13
+ // A subject repository must be inspected on its own terms, never through an inherited git context.
14
+ for (const key of ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_COMMON_DIR", "GIT_PREFIX"])
15
+ delete env[key];
16
+ env.GIT_OPTIONAL_LOCKS = "0";
17
+ return env;
18
+ }
19
+ /**
20
+ * Runs git with a structured argument array (no shell). Resolves with the exit code for ordinary non-zero exits so
21
+ * callers can classify them; rejects only when git cannot be started. Raw stderr is never surfaced.
22
+ */
23
+ function runGit(cwd, args, input) {
24
+ return new Promise((resolve, reject) => {
25
+ const child = execFile("git", [...args], { cwd, env: gitEnvironment(), encoding: "utf8", maxBuffer: GIT_MAX_BUFFER_BYTES, windowsHide: true }, (error, stdout) => {
26
+ if (error) {
27
+ const code = error.code;
28
+ if (typeof code === "number") {
29
+ resolve({ exitCode: code, stdout: String(stdout ?? "") });
30
+ return;
31
+ }
32
+ if (code === "ENOENT") {
33
+ reject(new LocalRepositorySubjectRepositoryError("GIT_UNAVAILABLE", "the git executable is not available."));
34
+ return;
35
+ }
36
+ if (code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") {
37
+ resolve({ exitCode: 0, stdout: String(stdout ?? "") });
38
+ return;
39
+ }
40
+ reject(new LocalRepositorySubjectRepositoryError("GIT_UNAVAILABLE", "the git executable could not be run."));
41
+ return;
42
+ }
43
+ resolve({ exitCode: 0, stdout: String(stdout ?? "") });
44
+ });
45
+ // When the process cannot be started (for example a working directory beyond the Windows path limit), Node can
46
+ // still emit an error on the never-connected stdio sockets; without listeners that becomes an uncaught exception
47
+ // instead of the classified rejection above.
48
+ for (const stream of [child.stdin, child.stdout, child.stderr])
49
+ stream?.on("error", () => undefined);
50
+ if (input !== undefined) {
51
+ child.stdin?.end(input);
52
+ }
53
+ });
54
+ }
55
+ function normalizeForComparison(value) {
56
+ const slashed = value.replace(/\\/g, "/").replace(/\/+$/, "");
57
+ return process.platform === "win32" ? slashed.toLowerCase() : slashed;
58
+ }
59
+ /** Resolves the selected path to its physical directory, rejecting missing or non-directory paths. */
60
+ export async function resolveSelectedRepositoryPath(selectedPath) {
61
+ if (typeof selectedPath !== "string" || selectedPath.trim().length === 0) {
62
+ throw new LocalRepositorySubjectRepositoryError("REPOSITORY_PATH_INVALID", "the repository path must be a non-empty string.");
63
+ }
64
+ let physical;
65
+ try {
66
+ physical = await realpath(selectedPath);
67
+ if (!(await stat(physical)).isDirectory())
68
+ throw new Error("not a directory");
69
+ }
70
+ catch {
71
+ throw new LocalRepositorySubjectRepositoryError("REPOSITORY_PATH_INVALID", "the repository path does not exist or is not a directory.");
72
+ }
73
+ return physical;
74
+ }
75
+ /**
76
+ * Reads Git-backed identity for the selected path. The selected path must be the exact worktree root. Only
77
+ * read-only Git commands are issued. Dirty state is reduced to a boolean; status output is discarded.
78
+ */
79
+ export async function readRepositoryIdentity(selectedPath) {
80
+ const repositoryRoot = await resolveSelectedRepositoryPath(selectedPath);
81
+ const topLevel = await runGit(repositoryRoot, ["rev-parse", "--show-toplevel"]);
82
+ if (topLevel.exitCode !== 0 || topLevel.stdout.trim().length === 0) {
83
+ throw new LocalRepositorySubjectRepositoryError("NOT_A_GIT_REPOSITORY", "the selected path is not inside a usable Git worktree.");
84
+ }
85
+ let gitRoot;
86
+ try {
87
+ gitRoot = await realpath(topLevel.stdout.trim());
88
+ }
89
+ catch {
90
+ throw new LocalRepositorySubjectRepositoryError("NOT_A_GIT_REPOSITORY", "the Git worktree root could not be resolved.");
91
+ }
92
+ if (normalizeForComparison(gitRoot) !== normalizeForComparison(repositoryRoot)) {
93
+ throw new LocalRepositorySubjectRepositoryError("NOT_WORKTREE_ROOT", "the selected path is inside a Git worktree but is not the worktree root.");
94
+ }
95
+ const head = await runGit(repositoryRoot, ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"]);
96
+ const commit = head.stdout.trim();
97
+ if (head.exitCode !== 0 || !FULL_SHA_PATTERN.test(commit)) {
98
+ throw new LocalRepositorySubjectRepositoryError("NO_HEAD_COMMIT", "the repository has no resolvable HEAD commit.");
99
+ }
100
+ const symbolic = await runGit(repositoryRoot, ["symbolic-ref", "--quiet", "--short", "HEAD"]);
101
+ const branch = symbolic.exitCode === 0 && symbolic.stdout.trim().length > 0 ? symbolic.stdout.trim() : null;
102
+ const status = await runGit(repositoryRoot, ["status", "--porcelain=v1", "-z"]);
103
+ if (status.exitCode !== 0) {
104
+ throw new LocalRepositorySubjectRepositoryError("NOT_A_GIT_REPOSITORY", "the working tree state could not be read.");
105
+ }
106
+ return { repositoryRoot, identity: { commit, branch, workingTreeDirty: status.stdout.length > 0 } };
107
+ }
108
+ /**
109
+ * Returns the subset of repository-relative paths that Git itself reports as ignored. No independent ignore
110
+ * parsing exists here. Tracked files are never reported ignored by Git; `--no-index` is intentionally not used.
111
+ */
112
+ export async function classifyGitIgnoredPaths(repositoryRoot, relativePaths) {
113
+ const ignored = new Set();
114
+ for (let start = 0; start < relativePaths.length; start += CHECK_IGNORE_CHUNK_SIZE) {
115
+ const chunk = relativePaths.slice(start, start + CHECK_IGNORE_CHUNK_SIZE);
116
+ const result = await runGit(repositoryRoot, ["check-ignore", "--stdin", "-z"], chunk.map((entry) => `${entry}\0`).join(""));
117
+ if (result.exitCode === 1)
118
+ continue;
119
+ if (result.exitCode !== 0) {
120
+ throw new LocalRepositorySubjectRepositoryError("GIT_CHECK_IGNORE_FAILED", "git could not classify ignored files.");
121
+ }
122
+ for (const entry of result.stdout.split("\0")) {
123
+ if (entry.length > 0)
124
+ ignored.add(entry);
125
+ }
126
+ }
127
+ return ignored;
128
+ }
@@ -0,0 +1,8 @@
1
+ export * from "./types.js";
2
+ export { canonicalizeRepositoryRelativePath, parseLocalRepositorySubjectConfig, validateLocalRepositorySubjectConfig } from "./config.js";
3
+ export { classifyGitIgnoredPaths, readRepositoryIdentity, resolveSelectedRepositoryPath } from "./gitRepository.js";
4
+ export { buildLocalRepositorySubjectInventory, defaultLocalRepositorySubjectFsIo, validateSourceRootOnDisk, } from "./inventory.js";
5
+ export { validateExpectedFiles } from "./expectedFiles.js";
6
+ export { buildLocalRepositorySubjectManifest, extensionOf, serializeLocalRepositorySubjectManifest } from "./manifest.js";
7
+ export { buildLocalRepositorySubjectEvaluationCases } from "./evaluationCase.js";
8
+ export { loadLocalRepositorySubject } from "./loadLocalRepositorySubject.js";
@@ -0,0 +1,127 @@
1
+ import { lstat, readdir } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { resolveWithinRoot } from "../../core/pathSafety.js";
4
+ import { classifyGitIgnoredPaths } from "./gitRepository.js";
5
+ import { compareCodeUnits } from "./types.js";
6
+ export const defaultLocalRepositorySubjectFsIo = {
7
+ lstat: (absolutePath) => lstat(absolutePath),
8
+ readdir: (absolutePath) => readdir(absolutePath),
9
+ };
10
+ function isNotFound(error) {
11
+ const code = error?.code;
12
+ return code === "ENOENT" || code === "ENOTDIR";
13
+ }
14
+ /**
15
+ * Validates, for one declared source root, that it is contained, exists, is a directory, and neither is nor passes
16
+ * through a symlink. Returns labelled error strings (empty when valid).
17
+ */
18
+ export async function validateSourceRootOnDisk(repositoryRoot, canonicalRoot, label, io) {
19
+ if (canonicalRoot === ".")
20
+ return [];
21
+ try {
22
+ resolveWithinRoot(repositoryRoot, canonicalRoot);
23
+ }
24
+ catch {
25
+ return [`${label}: resolves outside the selected repository (${JSON.stringify(canonicalRoot)}).`];
26
+ }
27
+ let current = repositoryRoot;
28
+ const segments = canonicalRoot.split("/");
29
+ for (let index = 0; index < segments.length; index += 1) {
30
+ current = path.join(current, segments[index]);
31
+ let stats;
32
+ try {
33
+ stats = await io.lstat(current);
34
+ }
35
+ catch (error) {
36
+ if (isNotFound(error))
37
+ return [`${label}: source root does not exist (${JSON.stringify(canonicalRoot)}).`];
38
+ return [`${label}: source root could not be inspected (${JSON.stringify(canonicalRoot)}).`];
39
+ }
40
+ if (stats.isSymbolicLink()) {
41
+ return [`${label}: source root is or passes through a symlink (${JSON.stringify(canonicalRoot)}).`];
42
+ }
43
+ if (index === segments.length - 1 && !stats.isDirectory()) {
44
+ return [`${label}: source root is not a directory (${JSON.stringify(canonicalRoot)}).`];
45
+ }
46
+ }
47
+ return [];
48
+ }
49
+ /**
50
+ * Walks the union of source roots without following links and without reading contents. Symlinks and non-regular
51
+ * entries are counted and excluded; `.git` is never traversed. Result is deterministic (code-unit order).
52
+ */
53
+ async function walkSourceRoots(repositoryRoot, canonicalRoots, io) {
54
+ const regularFiles = new Map();
55
+ const visitedDirectories = new Set();
56
+ let symlinkCount = 0;
57
+ let otherExcludedCount = 0;
58
+ const walkDirectory = async (relativeDirectory) => {
59
+ if (visitedDirectories.has(relativeDirectory))
60
+ return;
61
+ visitedDirectories.add(relativeDirectory);
62
+ const absoluteDirectory = relativeDirectory === "." ? repositoryRoot : path.join(repositoryRoot, relativeDirectory);
63
+ const names = (await io.readdir(absoluteDirectory)).slice().sort(compareCodeUnits);
64
+ for (const name of names) {
65
+ if (name === ".git")
66
+ continue;
67
+ const relativePath = relativeDirectory === "." ? name : `${relativeDirectory}/${name}`;
68
+ const stats = await io.lstat(path.join(absoluteDirectory, name));
69
+ if (stats.isSymbolicLink()) {
70
+ symlinkCount += 1;
71
+ }
72
+ else if (stats.isDirectory()) {
73
+ await walkDirectory(relativePath);
74
+ }
75
+ else if (stats.isFile()) {
76
+ if (!regularFiles.has(relativePath))
77
+ regularFiles.set(relativePath, stats.size);
78
+ }
79
+ else {
80
+ otherExcludedCount += 1;
81
+ }
82
+ }
83
+ };
84
+ for (const root of [...canonicalRoots].sort(compareCodeUnits)) {
85
+ await walkDirectory(root);
86
+ }
87
+ return { regularFiles, symlinkCount, otherExcludedCount };
88
+ }
89
+ /**
90
+ * Builds the safety inventory: walk, ask Git which files are ignored, then classify remaining regular files by
91
+ * size from filesystem metadata only. Categories are exclusive (ignored, then oversized, else eligible).
92
+ */
93
+ export async function buildLocalRepositorySubjectInventory(repositoryRoot, canonicalRoots, maxFileBytes, io = defaultLocalRepositorySubjectFsIo) {
94
+ const walked = await walkSourceRoots(repositoryRoot, canonicalRoots, io);
95
+ const candidatePaths = [...walked.regularFiles.keys()].sort(compareCodeUnits);
96
+ const ignoredByGit = await classifyGitIgnoredPaths(repositoryRoot, candidatePaths);
97
+ const eligibleFiles = [];
98
+ const ignoredFiles = new Set();
99
+ const oversizedFiles = new Set();
100
+ let eligibleByteCount = 0;
101
+ for (const filePath of candidatePaths) {
102
+ const size = walked.regularFiles.get(filePath);
103
+ if (ignoredByGit.has(filePath)) {
104
+ ignoredFiles.add(filePath);
105
+ }
106
+ else if (size > maxFileBytes) {
107
+ oversizedFiles.add(filePath);
108
+ }
109
+ else {
110
+ eligibleFiles.push({ path: filePath, size });
111
+ eligibleByteCount += size;
112
+ }
113
+ }
114
+ return {
115
+ eligibleFiles,
116
+ ignoredFiles,
117
+ oversizedFiles,
118
+ counts: {
119
+ eligibleFileCount: eligibleFiles.length,
120
+ eligibleByteCount,
121
+ gitIgnoredCount: ignoredFiles.size,
122
+ oversizedCount: oversizedFiles.size,
123
+ symlinkCount: walked.symlinkCount,
124
+ otherExcludedCount: walked.otherExcludedCount,
125
+ },
126
+ };
127
+ }