@diffci.com/diffci 0.1.0-alpha.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,300 @@
1
+ /**
2
+ * Can this installation change what the repository's CI does? (Phase 02, 2026-08-26.)
3
+ *
4
+ * The Phase 02 exit criterion is "seven days in a third-party repository, CI byte-identical". Two
5
+ * different things have to hold for that, and only one of them is about DiffCI's own code:
6
+ *
7
+ * 1. The observer must not touch the checkout. That is checked at run time, per run, by comparing
8
+ * HEAD and `git status --porcelain` before and after (src/client/observe.ts).
9
+ * 2. The observer's JOB must not be able to influence any other job. That is a property of the
10
+ * workflow file, not of DiffCI, and no amount of care inside the observer can establish it.
11
+ *
12
+ * This module is (2). It reads the repository's own workflow YAML and looks for the specific ways an
13
+ * added job stops being inert:
14
+ *
15
+ * - Another job `needs:` it, so a DiffCI failure blocks real work.
16
+ * - It is not `continue-on-error: true`, so a DiffCI failure becomes the WORKFLOW's conclusion - which
17
+ * is what a required status check and a merge queue read. This is the one that surprises people:
18
+ * the build jobs all pass, and the pull request is still red.
19
+ * - It is not a dedicated job: DiffCI steps sit in a job that also builds or tests, where a slow
20
+ * install or a mutated file is no longer isolated from anything.
21
+ * - It can write. An observer with `contents: write` is one bug away from not being an observer.
22
+ *
23
+ * Findings are severity-ranked and carry stable codes. BLOCKING means the byte-identical claim cannot
24
+ * be made from this workflow, and the operator should fix the workflow before the seven days start -
25
+ * not that DiffCI failed.
26
+ */
27
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
28
+ import { join } from "node:path";
29
+ import YAML from "yaml";
30
+ /** Steps a dedicated observation job may legitimately contain besides the DiffCI action itself. */
31
+ const ALLOWED_STEP_ACTIONS = [
32
+ "actions/checkout",
33
+ "actions/setup-node",
34
+ "actions/cache",
35
+ "actions/upload-artifact",
36
+ ];
37
+ /** Permission scopes that make a job something other than a reader. `contents: read` is fine. */
38
+ const WRITE_PERMISSION = "write";
39
+ function isRecord(value) {
40
+ return value !== null && typeof value === "object" && !Array.isArray(value);
41
+ }
42
+ function asArray(value) {
43
+ if (Array.isArray(value))
44
+ return value;
45
+ if (value === undefined || value === null)
46
+ return [];
47
+ return [value];
48
+ }
49
+ function permissionsGrantWrite(permissions) {
50
+ if (permissions === "write-all")
51
+ return ["write-all"];
52
+ if (!isRecord(permissions))
53
+ return [];
54
+ return Object.entries(permissions)
55
+ .filter(([, level]) => level === WRITE_PERMISSION)
56
+ .map(([scope]) => scope);
57
+ }
58
+ function listWorkflowFiles(repoPath) {
59
+ const dir = join(repoPath, ".github", "workflows");
60
+ if (!existsSync(dir))
61
+ return [];
62
+ return readdirSync(dir)
63
+ .filter((name) => name.endsWith(".yml") || name.endsWith(".yaml"))
64
+ .sort()
65
+ .map((name) => join(".github", "workflows", name));
66
+ }
67
+ /**
68
+ * `uses: owner/repo@ref` is pinned when `ref` is a full commit SHA. A tag or branch means the action's
69
+ * contents can change without the repository changing, which is exactly the property an installation
70
+ * promising byte-identical CI should not have.
71
+ */
72
+ /**
73
+ * Does this step run DiffCI? Two forms have to be recognised, and only one of them has "diffci" in the
74
+ * string: a third-party repository writes `uses: owner/DiffCI.com@<sha>`, while this repository (and
75
+ * anyone vendoring the action) writes `uses: ./`, which names a directory. A guard that missed the
76
+ * local form would report "no job runs the DiffCI action" for the one installation we control - and
77
+ * silently check nothing.
78
+ */
79
+ /**
80
+ * Does this step run DiffCI via a `run:` command rather than `uses:`?
81
+ *
82
+ * Since DiffCI became a proprietary package rather than a public Action, the generated workflow
83
+ * installs and invokes the agent with `run:` steps. A guard that only understood `uses:` reported
84
+ * "no job runs DiffCI" for the installation the product itself generates - and therefore checked
85
+ * nothing at all, while still returning a clean result. That is the worst possible failure for a
86
+ * safety check, so it is matched explicitly here.
87
+ */
88
+ function runReferencesDiffCi(run, actionPattern) {
89
+ return actionPattern.test(commandText(run));
90
+ }
91
+ /**
92
+ * The part of a `run:` script that can invoke something: shell comments and URLs are removed line by
93
+ * line before matching. A URL is data handed to a command, not a command - DiffCI's own deploy
94
+ * workflow curls `https://diffci-research-sandbox.….workers.dev/…` and was reported as running the
95
+ * observer, then told its deploy job was a badly installed DiffCI job (2026-09-06). Everything the
96
+ * product generates keeps matching: `npm install … @diffci/observer@1.4.2`,
97
+ * `"${RUNNER_TEMP}/diffci/node_modules/.bin/diffci" observe`, `docker run … <image>@sha256:… observe`,
98
+ * `npx @diffci/observer observe` all name DiffCI outside any URL.
99
+ */
100
+ function commandText(run) {
101
+ return run
102
+ .split("\n")
103
+ .map((line) => line.replace(/(^|\s)#.*$/, "$1").replace(/[a-z][a-z0-9+.-]*:\/\/\S+/gi, " "))
104
+ .join("\n");
105
+ }
106
+ /**
107
+ * Returns the offending specifier if a `run:` command installs DiffCI at anything other than an exact
108
+ * version or an image digest. This is the `run:`-shaped equivalent of the commit-SHA rule above: a
109
+ * customer who edits the generated "1.4.2" into "latest" has re-opened exactly the hole the generated
110
+ * workflow was written to close, and should be told so rather than getting a clean report.
111
+ */
112
+ function findMutableAgentReference(run) {
113
+ for (const match of commandText(run).matchAll(/(@?[A-Za-z0-9._/-]*diffci[A-Za-z0-9._/-]*)@([^\s"']+)/gi)) {
114
+ const version = match[2];
115
+ if (/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(version))
116
+ continue;
117
+ if (/^sha256:[0-9a-f]{64}$/.test(version))
118
+ continue;
119
+ return match[0];
120
+ }
121
+ return undefined;
122
+ }
123
+ function referencesDiffCi(uses, repoPath, actionPattern) {
124
+ if (actionPattern.test(uses))
125
+ return true;
126
+ if (!uses.startsWith("./"))
127
+ return false;
128
+ for (const file of ["action.yml", "action.yaml"]) {
129
+ const candidate = join(repoPath, uses.slice(2), file);
130
+ if (!existsSync(candidate))
131
+ continue;
132
+ try {
133
+ const parsed = YAML.parse(readFileSync(candidate, "utf8"));
134
+ if (isRecord(parsed) && typeof parsed.name === "string" && actionPattern.test(parsed.name))
135
+ return true;
136
+ }
137
+ catch {
138
+ // An unparseable local action is reported by the workflow scan itself, not silently trusted.
139
+ }
140
+ }
141
+ return false;
142
+ }
143
+ function isPinnedToSha(uses) {
144
+ const at = uses.lastIndexOf("@");
145
+ if (at === -1)
146
+ return uses.startsWith("./") || uses.startsWith("docker://");
147
+ return /^[0-9a-f]{40}$/i.test(uses.slice(at + 1));
148
+ }
149
+ export function auditWorkflows(repoPath, options = {}) {
150
+ const actionPattern = options.actionPattern ?? /diffci/i;
151
+ const findings = [];
152
+ const observerJobs = [];
153
+ const workflowsScanned = [];
154
+ for (const relativePath of listWorkflowFiles(repoPath)) {
155
+ let document;
156
+ try {
157
+ document = YAML.parse(readFileSync(join(repoPath, relativePath), "utf8"));
158
+ }
159
+ catch (error) {
160
+ findings.push({
161
+ severity: "WARNING",
162
+ code: "WORKFLOW_UNPARSEABLE",
163
+ message: `could not parse this workflow, so it was not checked: ${error.message.split("\n")[0]}`,
164
+ workflow: relativePath,
165
+ });
166
+ continue;
167
+ }
168
+ workflowsScanned.push(relativePath);
169
+ if (!isRecord(document))
170
+ continue;
171
+ const jobs = isRecord(document.jobs) ? document.jobs : {};
172
+ const workflowPermissions = permissionsGrantWrite(document.permissions);
173
+ for (const [jobId, rawJob] of Object.entries(jobs)) {
174
+ if (!isRecord(rawJob))
175
+ continue;
176
+ const steps = asArray(rawJob.steps).filter(isRecord);
177
+ const isDiffCiStep = (step) => (typeof step.uses === "string" && referencesDiffCi(step.uses, repoPath, actionPattern)) ||
178
+ (typeof step.run === "string" && runReferencesDiffCi(step.run, actionPattern));
179
+ const diffciSteps = steps.filter(isDiffCiStep);
180
+ if (diffciSteps.length === 0)
181
+ continue;
182
+ observerJobs.push(`${relativePath}#${jobId}`);
183
+ // 1. Nothing may depend on the observer.
184
+ const dependants = Object.entries(jobs)
185
+ .filter(([otherId, otherJob]) => otherId !== jobId && isRecord(otherJob) && asArray(otherJob.needs).includes(jobId))
186
+ .map(([otherId]) => otherId);
187
+ if (dependants.length > 0) {
188
+ findings.push({
189
+ severity: "BLOCKING",
190
+ code: "JOB_IS_A_DEPENDENCY",
191
+ message: `job "${jobId}" is listed in needs: by ${dependants.join(", ")} - a DiffCI failure would block ${dependants.length === 1 ? "that job" : "those jobs"}. Remove the dependency.`,
192
+ workflow: relativePath,
193
+ job: jobId,
194
+ });
195
+ }
196
+ // 2. The observer's outcome must not become the workflow's outcome.
197
+ if (rawJob["continue-on-error"] !== true) {
198
+ findings.push({
199
+ severity: "BLOCKING",
200
+ code: "JOB_NOT_CONTINUE_ON_ERROR",
201
+ message: `job "${jobId}" is missing continue-on-error: true - without it a DiffCI failure fails the whole workflow run, which a required status check or merge queue will read as a failed build.`,
202
+ workflow: relativePath,
203
+ job: jobId,
204
+ });
205
+ }
206
+ // 3. The observer must be alone in its job.
207
+ const foreignSteps = steps.filter((step) => {
208
+ // DiffCI's own steps are never foreign, whichever form they take. This has to come first: since
209
+ // DiffCI became a package rather than an Action it invokes itself with `run:`, and the rule
210
+ // below - "any run: step is real work" - was written when that could not happen. Leaving the
211
+ // order the other way round made the product's own generated workflow fail its own guard.
212
+ if (isDiffCiStep(step))
213
+ return false;
214
+ // Any other shell step in this job IS real work, and DiffCI must not share a job with it.
215
+ if (typeof step.run === "string")
216
+ return true;
217
+ if (typeof step.uses !== "string")
218
+ return false;
219
+ return !ALLOWED_STEP_ACTIONS.some((allowed) => step.uses === allowed || step.uses.startsWith(`${allowed}@`));
220
+ });
221
+ if (foreignSteps.length > 0) {
222
+ findings.push({
223
+ severity: "BLOCKING",
224
+ code: "JOB_NOT_DEDICATED",
225
+ message: `job "${jobId}" contains ${foreignSteps.length} step(s) that are not part of observation - DiffCI must run in a job of its own so it shares nothing with real work.`,
226
+ workflow: relativePath,
227
+ job: jobId,
228
+ });
229
+ }
230
+ // 4. An observer that can write is not an observer.
231
+ const jobPermissions = rawJob.permissions === undefined ? workflowPermissions : permissionsGrantWrite(rawJob.permissions);
232
+ if (jobPermissions.length > 0) {
233
+ findings.push({
234
+ severity: "WARNING",
235
+ code: "JOB_HAS_WRITE_PERMISSIONS",
236
+ message: `job "${jobId}" ${rawJob.permissions === undefined ? "inherits" : "declares"} write permission for: ${jobPermissions.join(", ")}. Set permissions: { contents: read } on the job.`,
237
+ workflow: relativePath,
238
+ job: jobId,
239
+ });
240
+ }
241
+ // 5. A mutable reference means the code that runs here can change without this repository
242
+ // changing. Two forms have to be checked, because DiffCI is invoked both ways: a `uses:` step
243
+ // must name a commit SHA, and a `run:` step installing the agent must name an exact version.
244
+ // Checking only the first would let someone edit the generated "1.4.2" into "latest" and still
245
+ // get a clean report - which is the same hole, reopened by hand.
246
+ for (const step of diffciSteps) {
247
+ if (typeof step.uses === "string") {
248
+ if (!isPinnedToSha(step.uses)) {
249
+ findings.push({
250
+ severity: "WARNING",
251
+ code: "ACTION_NOT_PINNED",
252
+ message: `"${step.uses}" is not pinned to a 40-character commit SHA, so what runs here can change without this repository changing.`,
253
+ workflow: relativePath,
254
+ job: jobId,
255
+ });
256
+ }
257
+ continue;
258
+ }
259
+ if (typeof step.run === "string") {
260
+ const mutable = findMutableAgentReference(step.run);
261
+ if (mutable) {
262
+ findings.push({
263
+ severity: "WARNING",
264
+ code: "AGENT_NOT_PINNED",
265
+ message: `"${mutable}" is not an exact version, so the DiffCI agent running here can change without this repository changing.`,
266
+ workflow: relativePath,
267
+ job: jobId,
268
+ });
269
+ }
270
+ }
271
+ }
272
+ // 6. A shared concurrency group lets this job cancel another one.
273
+ const group = isRecord(rawJob.concurrency) ? rawJob.concurrency.group : rawJob.concurrency;
274
+ if (typeof group === "string") {
275
+ const sharing = Object.entries(jobs)
276
+ .filter(([otherId, otherJob]) => {
277
+ if (otherId === jobId || !isRecord(otherJob))
278
+ return false;
279
+ const otherGroup = isRecord(otherJob.concurrency) ? otherJob.concurrency.group : otherJob.concurrency;
280
+ return otherGroup === group;
281
+ })
282
+ .map(([otherId]) => otherId);
283
+ if (sharing.length > 0) {
284
+ findings.push({
285
+ severity: "WARNING",
286
+ code: "JOB_SHARES_CONCURRENCY_GROUP",
287
+ message: `job "${jobId}" shares concurrency group "${group}" with ${sharing.join(", ")}, so one can cancel the other.`,
288
+ workflow: relativePath,
289
+ job: jobId,
290
+ });
291
+ }
292
+ }
293
+ }
294
+ }
295
+ return { observerJobs, findings, workflowsScanned };
296
+ }
297
+ /** True when nothing found would let the observation change what the rest of CI does. */
298
+ export function isNonInterfering(result) {
299
+ return result.findings.every((finding) => finding.severity !== "BLOCKING");
300
+ }
@@ -0,0 +1,379 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { dirname } from "node:path";
3
+ export const EMPTY_TREE_SHA = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
4
+ export const ZERO_SHA = "0000000000000000000000000000000000000000";
5
+ function runGit(args, repoPath) {
6
+ const result = spawnSync("git", args, {
7
+ cwd: repoPath,
8
+ encoding: "utf8",
9
+ maxBuffer: 64 * 1024 * 1024,
10
+ });
11
+ return {
12
+ stdout: result.stdout ?? "",
13
+ stderr: result.stderr ?? "",
14
+ status: result.status,
15
+ };
16
+ }
17
+ function runGitOrThrow(args, repoPath, context) {
18
+ const result = runGit(args, repoPath);
19
+ if (result.status !== 0) {
20
+ const message = result.stderr.trim() || result.stdout.trim() || "unknown error";
21
+ throw new GitDiffError(`${context} failed: ${message}`, {
22
+ command: `git ${args.join(" ")}`,
23
+ stderr: result.stderr,
24
+ exitCode: result.status ?? undefined,
25
+ });
26
+ }
27
+ return result.stdout;
28
+ }
29
+ class GitDiffError extends Error {
30
+ details;
31
+ constructor(message, details = {}) {
32
+ super(message);
33
+ this.name = "GitDiffError";
34
+ this.details = details;
35
+ }
36
+ }
37
+ const STATUS_TO_CHANGE_TYPE = {
38
+ A: "added",
39
+ C: "copied",
40
+ D: "deleted",
41
+ M: "modified",
42
+ R: "renamed",
43
+ T: "modified",
44
+ U: "unmerged",
45
+ X: "unknown",
46
+ B: "unknown",
47
+ };
48
+ function mapStatusCode(code) {
49
+ const first = code.charAt(0);
50
+ return STATUS_TO_CHANGE_TYPE[first] ?? "unknown";
51
+ }
52
+ export function parseNameStatus(buffer) {
53
+ const text = buffer.toString("utf8");
54
+ const tokens = text.split("\0");
55
+ const files = [];
56
+ let i = 0;
57
+ while (i < tokens.length) {
58
+ const statusToken = tokens[i];
59
+ if (!statusToken) {
60
+ i += 1;
61
+ continue;
62
+ }
63
+ const code = statusToken.charAt(0);
64
+ const similarity = statusToken.length > 1 ? parseInt(statusToken.slice(1), 10) : undefined;
65
+ i += 1;
66
+ if (i >= tokens.length) {
67
+ break;
68
+ }
69
+ const firstPath = tokens[i];
70
+ i += 1;
71
+ if (code === "R" || code === "C") {
72
+ if (i >= tokens.length) {
73
+ break;
74
+ }
75
+ const secondPath = tokens[i];
76
+ i += 1;
77
+ files.push({
78
+ path: secondPath,
79
+ oldPath: firstPath,
80
+ changeType: mapStatusCode(code),
81
+ similarityScore: Number.isFinite(similarity) ? similarity : undefined,
82
+ });
83
+ }
84
+ else {
85
+ files.push({
86
+ path: firstPath,
87
+ changeType: mapStatusCode(code),
88
+ });
89
+ }
90
+ }
91
+ return files;
92
+ }
93
+ function parseNumstatRecord(record) {
94
+ if (!record)
95
+ return undefined;
96
+ const fields = record.split("\t");
97
+ if (fields.length < 3)
98
+ return undefined;
99
+ const isBinary = fields[0]?.trimStart().startsWith("-") ?? false;
100
+ const hasRename = fields.length >= 4;
101
+ const newPath = hasRename ? fields[fields.length - 1] : fields[2];
102
+ if (!newPath)
103
+ return undefined;
104
+ return { path: newPath, isBinary };
105
+ }
106
+ export function parseNumstat(buffer) {
107
+ const text = buffer.toString("utf8");
108
+ const records = text.split("\0");
109
+ const binaryByNewPath = new Map();
110
+ for (const record of records) {
111
+ const parsed = parseNumstatRecord(record);
112
+ if (parsed) {
113
+ binaryByNewPath.set(parsed.path, parsed.isBinary);
114
+ }
115
+ }
116
+ return binaryByNewPath;
117
+ }
118
+ export function normalizeDirectories(paths) {
119
+ const seen = new Set();
120
+ const dirs = [];
121
+ for (const filePath of paths) {
122
+ if (!filePath)
123
+ continue;
124
+ const dir = dirname(filePath);
125
+ if (dir === ".")
126
+ continue;
127
+ if (!seen.has(dir)) {
128
+ seen.add(dir);
129
+ dirs.push(dir);
130
+ }
131
+ }
132
+ dirs.sort((a, b) => a.localeCompare(b));
133
+ return dirs;
134
+ }
135
+ const CONFIG_PATTERNS = [
136
+ /^next\.config\./,
137
+ /^tsconfig\.json$/,
138
+ /^jsconfig\.json$/,
139
+ /^eslint\.config\./,
140
+ /^eslint\.rc/,
141
+ /^\.eslint/,
142
+ /^postcss\.config\./,
143
+ /^tailwind\.config\./,
144
+ /^\.prettier/,
145
+ /^prettier\.config\./,
146
+ /^vitest\.config\./,
147
+ /^jest\.config\./,
148
+ /^playwright\.config\./,
149
+ /^webpack\.config\./,
150
+ /^rollup\.config\./,
151
+ /^esbuild\.config\./,
152
+ /^\.github\//,
153
+ /^\.docker/,
154
+ /^Dockerfile/,
155
+ /^docker-compose/,
156
+ /^ops\//,
157
+ /^database\//,
158
+ /^package\.json$/,
159
+ /^package-lock\.json$/,
160
+ /^yarn\.lock$/,
161
+ /^pnpm-lock\.yaml$/,
162
+ /^bun\.lockb$/,
163
+ /^(npm|pnpm|yarn)-lock\./,
164
+ ];
165
+ // Stage 1B fix (2026-08-21, docs/research/2026-08-21-stage1b-*.md): root-caused in Stage 1A's fallback-
166
+ // composition analysis - dependencyManifestChanged previously fired on ANY change to the root
167
+ // package.json, including fields with zero behavioral effect (description, keywords, author, homepage,
168
+ // license, contributors, bugs, repository). Only these fields can actually change what gets built/
169
+ // tested/resolved; everything else is metadata a change to which should never force FULL fallback.
170
+ const PACKAGE_JSON_RELEVANT_FIELDS = [
171
+ "dependencies", "devDependencies", "peerDependencies", "optionalDependencies",
172
+ "scripts", "exports", "main", "module", "type", "bin", "engines", "workspaces", "browser",
173
+ ];
174
+ /** Narrows a path-based "package.json changed" signal to whether any behaviorally-relevant field
175
+ * actually changed, given both versions' raw content. Returns true (never narrows) whenever either
176
+ * side is missing/unparseable - a file that was added, deleted, or became invalid JSON is unambiguously
177
+ * not a case for field-level narrowing, and an unparseable "before" or "after" state means there is
178
+ * nothing safe to compare. This function only ever narrows true -> false, never invents a change that
179
+ * a path-based check didn't already flag. */
180
+ export function hasRelevantPackageJsonChange(oldContent, newContent) {
181
+ if (oldContent === undefined || newContent === undefined)
182
+ return true;
183
+ let oldJson;
184
+ let newJson;
185
+ try {
186
+ oldJson = JSON.parse(oldContent);
187
+ newJson = JSON.parse(newContent);
188
+ }
189
+ catch {
190
+ return true;
191
+ }
192
+ return PACKAGE_JSON_RELEVANT_FIELDS.some((field) => JSON.stringify(oldJson[field]) !== JSON.stringify(newJson[field]));
193
+ }
194
+ /** Reads a file's content at a specific commit via `git show <sha>:<path>`, without touching the
195
+ * working tree (safe to call regardless of what's currently checked out). Returns undefined - not an
196
+ * error - when the file doesn't exist at that commit (a newly-added or since-deleted file), which is
197
+ * the expected, common case this is called for, not an exceptional one. */
198
+ function readFileAtSha(sha, path, repoPath) {
199
+ const result = runGit(["show", `${sha}:${path}`], repoPath);
200
+ if (result.status !== 0)
201
+ return undefined;
202
+ return result.stdout;
203
+ }
204
+ export function computeAnalysis(files) {
205
+ const paths = files.flatMap((f) => f.oldPath ? [f.path, f.oldPath] : [f.path]);
206
+ const matches = (pattern) => paths.some((p) => pattern.test(p));
207
+ return {
208
+ empty: files.length === 0,
209
+ configChanged: CONFIG_PATTERNS.some(matches),
210
+ dependencyManifestChanged: matches(/^package\.json$/),
211
+ lockfileChanged: matches(/^package-lock\.json$/) ||
212
+ matches(/^yarn\.lock$/) ||
213
+ matches(/^pnpm-lock\.yaml$/) ||
214
+ matches(/^bun\.lockb$/),
215
+ workflowChanged: matches(/^\.github\/workflows\//),
216
+ infrastructureChanged: matches(/^ops\//),
217
+ databaseChanged: matches(/^database\//),
218
+ };
219
+ }
220
+ export function summarize(files) {
221
+ const s = {
222
+ added: 0,
223
+ modified: 0,
224
+ deleted: 0,
225
+ renamed: 0,
226
+ copied: 0,
227
+ unmerged: 0,
228
+ unknown: 0,
229
+ total: files.length,
230
+ };
231
+ for (const file of files) {
232
+ switch (file.changeType) {
233
+ case "added":
234
+ s.added += 1;
235
+ break;
236
+ case "modified":
237
+ s.modified += 1;
238
+ break;
239
+ case "deleted":
240
+ s.deleted += 1;
241
+ break;
242
+ case "renamed":
243
+ s.renamed += 1;
244
+ break;
245
+ case "copied":
246
+ s.copied += 1;
247
+ break;
248
+ case "unmerged":
249
+ s.unmerged += 1;
250
+ break;
251
+ default:
252
+ s.unknown += 1;
253
+ break;
254
+ }
255
+ }
256
+ return s;
257
+ }
258
+ export function resolveCommitParents(sha, repoPath) {
259
+ const output = runGitOrThrow(["rev-list", "--parents", "-n", "1", sha], repoPath, "Resolve commit parents").trim();
260
+ const parts = output.split(/\s+/);
261
+ return parts.slice(1); // first token is the commit itself
262
+ }
263
+ function validateCommits(baseSha, headSha, repoPath) {
264
+ for (const sha of [baseSha, headSha]) {
265
+ if (!sha || sha === ZERO_SHA || (!/^[0-9a-f]{4,40}$/i.test(sha) && !/^[A-Za-z0-9/_.^~@-]+$/i.test(sha))) {
266
+ throw new GitDiffError(`Invalid commit SHA: ${sha}`);
267
+ }
268
+ }
269
+ for (const sha of [baseSha, headSha]) {
270
+ if (sha === EMPTY_TREE_SHA)
271
+ continue;
272
+ runGitOrThrow(["cat-file", "-t", `${sha}^{commit}`], repoPath, "SHA validation");
273
+ }
274
+ }
275
+ function headHasParent(headSha, repoPath) {
276
+ const parents = resolveCommitParents(headSha, repoPath);
277
+ return parents.length > 0;
278
+ }
279
+ /**
280
+ * Canonical HEAD file inventory (see RepositoryInventory). One `git ls-tree` per analysis - cheap
281
+ * (~tens of ms for ~10k paths) and already co-located with the only code that knows repoPath+headSha.
282
+ * Failure is NOT fatal: returns undefined so the delta analysis still succeeds and relationship-based
283
+ * classification simply stays inert (conservative: affected files remain "unknown" -> fallback).
284
+ */
285
+ export function readRepositoryInventory(headSha, repoPath) {
286
+ const result = runGit(["ls-tree", "-r", "--name-only", "-z", headSha], repoPath);
287
+ if (result.status !== 0)
288
+ return undefined;
289
+ const files = new Set(result.stdout.split("\0").filter((p) => p.length > 0));
290
+ if (files.size === 0)
291
+ return undefined; // an empty listing is never a trustworthy inventory
292
+ return { headSha, files, source: "git-ls-tree" };
293
+ }
294
+ export async function analyzeGitDelta(options = {}) {
295
+ const repoPath = options.repoPath;
296
+ try {
297
+ const headSha = options.headSha ??
298
+ runGitOrThrow(["rev-parse", "HEAD"], repoPath, "Resolve HEAD").trim();
299
+ let baseSha = options.baseSha;
300
+ if (!baseSha) {
301
+ baseSha = headHasParent(headSha, repoPath)
302
+ ? runGitOrThrow(["rev-parse", `${headSha}^`], repoPath, "Resolve base").trim()
303
+ : EMPTY_TREE_SHA;
304
+ }
305
+ validateCommits(baseSha, headSha, repoPath);
306
+ const findRenamesArg = options.findRenames
307
+ ? `--find-renames=${typeof options.findRenames === "string" ? options.findRenames : "50%"}`
308
+ : "--find-renames=50%";
309
+ const nameStatus = runGitOrThrow([
310
+ "diff-tree",
311
+ "-r",
312
+ "--name-status",
313
+ "-z",
314
+ findRenamesArg,
315
+ baseSha,
316
+ headSha,
317
+ ], repoPath, "Resolve changed files");
318
+ const files = parseNameStatus(Buffer.from(nameStatus, "utf8"));
319
+ const numstat = runGitOrThrow(["diff-tree", "-r", "--numstat", "-z", findRenamesArg, baseSha, headSha], repoPath, "Resolve binary metadata");
320
+ const binaryMap = parseNumstat(Buffer.from(numstat, "utf8"));
321
+ const filesWithBinary = files.map((file) => ({
322
+ ...file,
323
+ isBinary: binaryMap.get(file.path),
324
+ }));
325
+ const allPaths = filesWithBinary.flatMap((f) => f.oldPath ? [f.path, f.oldPath] : [f.path]);
326
+ const analysis = computeAnalysis(filesWithBinary);
327
+ // Stage 1B fix (2026-08-21): narrow the path-based dependencyManifestChanged signal to whether a
328
+ // behaviorally-relevant field actually changed - only attempted when the root package.json itself
329
+ // was modified (not added/deleted/renamed, which stay unambiguously true - see
330
+ // hasRelevantPackageJsonChange()'s own doc comment) and only ever narrows true -> false.
331
+ if (analysis.dependencyManifestChanged) {
332
+ const changedPackageJson = filesWithBinary.find((f) => f.path === "package.json" && f.changeType === "modified");
333
+ if (changedPackageJson) {
334
+ const oldContent = readFileAtSha(baseSha, "package.json", repoPath);
335
+ const newContent = readFileAtSha(headSha, "package.json", repoPath);
336
+ analysis.dependencyManifestChanged = hasRelevantPackageJsonChange(oldContent, newContent);
337
+ }
338
+ }
339
+ const delta = {
340
+ baseSha,
341
+ headSha,
342
+ files: filesWithBinary,
343
+ directories: normalizeDirectories(allPaths),
344
+ summary: summarize(filesWithBinary),
345
+ analysis,
346
+ };
347
+ return { success: true, delta, inventory: readRepositoryInventory(headSha, repoPath) };
348
+ }
349
+ catch (error) {
350
+ const message = error instanceof Error ? error.message : String(error);
351
+ return {
352
+ success: false,
353
+ error: message,
354
+ };
355
+ }
356
+ }
357
+ export function gitDeltaToJson(delta) {
358
+ return JSON.stringify(delta, (_key, value) => (typeof value === "bigint" ? value.toString() : value), 2);
359
+ }
360
+ export { GitDiffError };
361
+ export function isAllZeroSha(sha) {
362
+ return typeof sha === "string" && /^0+$/.test(sha);
363
+ }
364
+ export function findRecentNonEmptyCommitPair(repoPath, maxWalk = 32) {
365
+ const head = runGitOrThrow(["rev-parse", "HEAD"], repoPath, "Resolve HEAD").trim();
366
+ const candidates = runGitOrThrow(["rev-list", "--first-parent", "-n", String(maxWalk + 1), head], repoPath, "List first-parent commits").trim().split("\n");
367
+ for (let i = 0; i < candidates.length - 1; i++) {
368
+ const headSha = candidates[i];
369
+ const baseSha = candidates[i + 1];
370
+ const diffFiles = runGit(["diff-tree", "--no-commit-id", "--name-only", "-r", baseSha, headSha], repoPath);
371
+ if (diffFiles.status === 0 && diffFiles.stdout.trim().length > 0) {
372
+ return { baseSha, headSha, emptyBase: false, firstParent: true };
373
+ }
374
+ }
375
+ if (candidates.length > 0) {
376
+ return { baseSha: EMPTY_TREE_SHA, headSha: candidates[0], emptyBase: true, firstParent: true };
377
+ }
378
+ return undefined;
379
+ }
@@ -0,0 +1 @@
1
+ export {};