@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.
- package/README.md +98 -0
- package/action.yml +155 -0
- package/dist-client/src/client/cli.js +281 -0
- package/dist-client/src/client/context.js +173 -0
- package/dist-client/src/client/observe.js +230 -0
- package/dist-client/src/client/report.js +63 -0
- package/dist-client/src/client/submit.js +89 -0
- package/dist-client/src/client/workflow-guard.js +300 -0
- package/dist-client/src/git/git-diff.js +379 -0
- package/dist-client/src/git/types.js +1 -0
- package/dist-client/src/planner/path-baseline.js +131 -0
- package/dist-client/src/planner/test-command.js +124 -0
- package/dist-client/src/planner/types.js +1 -0
- package/dist-client/src/repo/analyzer.js +454 -0
- package/dist-client/src/repo/graph.js +958 -0
- package/dist-client/src/repo/impact-types.js +1 -0
- package/dist-client/src/repo/impact.js +625 -0
- package/dist-client/src/repo/layout.js +75 -0
- package/dist-client/src/repo/repo-config.js +63 -0
- package/dist-client/src/repo/runner-universe.js +253 -0
- package/dist-client/src/repo/test-discovery.js +383 -0
- package/dist-client/src/repo/test-fixture-ownership.js +76 -0
- package/dist-client/src/repo/test-framework.js +117 -0
- package/dist-client/src/repo/types.js +1 -0
- package/docs/distribution.md +81 -0
- package/package.json +118 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where am I, and which two commits am I being asked about? (Phase 02, 2026-08-26.)
|
|
3
|
+
*
|
|
4
|
+
* Running inside someone else's CI means the commit range is not a parameter a human chose - it has to
|
|
5
|
+
* be recovered from the environment, and the environment lies in specific, well-known ways:
|
|
6
|
+
*
|
|
7
|
+
* - `actions/checkout` defaults to `fetch-depth: 1`. The base commit of a pull request is then simply
|
|
8
|
+
* absent from the local object store, and `git diff base..head` fails with a message about a bad
|
|
9
|
+
* revision that reads, to anyone who has not seen it before, like a DiffCI bug.
|
|
10
|
+
* - On a pull request, the checked-out HEAD is a MERGE commit GitHub created, not the head of the
|
|
11
|
+
* contributor's branch. Diffing HEAD^..HEAD there compares against the base branch tip, which is a
|
|
12
|
+
* different question from "what did this pull request change".
|
|
13
|
+
* - On the first push to a new branch, `event.before` is forty zeros. Treating that as a commit SHA
|
|
14
|
+
* produces an empty diff, and an empty diff reads downstream as "nothing changed, skip everything".
|
|
15
|
+
*
|
|
16
|
+
* Every one of those has the same shape: a plausible-looking range that answers the wrong question. So
|
|
17
|
+
* this module resolves a range only when it can name where both ends came from and verify both exist
|
|
18
|
+
* locally, and otherwise REFUSES with a reason that says what to change. It never falls back to a range
|
|
19
|
+
* it had to guess.
|
|
20
|
+
*/
|
|
21
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
22
|
+
/** Triggers that carry no inherent "what changed" - a range for these would have to be invented. */
|
|
23
|
+
const SYNTHETIC_TRIGGERS = new Set(["schedule", "workflow_dispatch", "repository_dispatch", "release"]);
|
|
24
|
+
const ZERO_SHA = "0000000000000000000000000000000000000000";
|
|
25
|
+
function readEventPayload(env) {
|
|
26
|
+
const path = env.GITHUB_EVENT_PATH;
|
|
27
|
+
if (!path || !existsSync(path))
|
|
28
|
+
return undefined;
|
|
29
|
+
try {
|
|
30
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
31
|
+
return parsed !== null && typeof parsed === "object" ? parsed : undefined;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
// A malformed event payload is the runner's problem, not ours. Callers degrade to the flat
|
|
35
|
+
// GITHUB_* variables, which is strictly less information but never wrong information.
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function nested(record, ...keys) {
|
|
40
|
+
let current = record;
|
|
41
|
+
for (const key of keys) {
|
|
42
|
+
if (current === null || typeof current !== "object")
|
|
43
|
+
return undefined;
|
|
44
|
+
current = current[key];
|
|
45
|
+
}
|
|
46
|
+
return current;
|
|
47
|
+
}
|
|
48
|
+
function asString(value) {
|
|
49
|
+
return typeof value === "string" && value !== "" ? value : undefined;
|
|
50
|
+
}
|
|
51
|
+
export function readCiEnvironment(env) {
|
|
52
|
+
if (env.GITHUB_ACTIONS !== "true") {
|
|
53
|
+
return { provider: env.CI === "true" ? "unknown" : "local" };
|
|
54
|
+
}
|
|
55
|
+
const payload = readEventPayload(env);
|
|
56
|
+
const event = asString(env.GITHUB_EVENT_NAME);
|
|
57
|
+
return {
|
|
58
|
+
provider: "github-actions",
|
|
59
|
+
ownerName: asString(env.GITHUB_REPOSITORY),
|
|
60
|
+
providerRepositoryId: asString(env.GITHUB_REPOSITORY_ID),
|
|
61
|
+
defaultBranch: asString(nested(payload, "repository", "default_branch")) ?? asString(env.GITHUB_DEFAULT_BRANCH),
|
|
62
|
+
runId: asString(env.GITHUB_RUN_ID),
|
|
63
|
+
runAttempt: asString(env.GITHUB_RUN_ATTEMPT),
|
|
64
|
+
workflow: asString(env.GITHUB_WORKFLOW),
|
|
65
|
+
job: asString(env.GITHUB_JOB),
|
|
66
|
+
event,
|
|
67
|
+
ref: asString(env.GITHUB_REF),
|
|
68
|
+
syntheticTrigger: event !== undefined && SYNTHETIC_TRIGGERS.has(event),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
function commitExists(git, sha) {
|
|
72
|
+
if (!/^[0-9a-f]{7,40}$/i.test(sha))
|
|
73
|
+
return false;
|
|
74
|
+
return git(["cat-file", "-e", `${sha}^{commit}`]).ok;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* `--verify <rev>^{commit}` rather than a bare `rev-parse`: a bare rev-parse hands back any
|
|
78
|
+
* well-formed 40-character hex string unchanged, whether or not that commit exists in this clone. A
|
|
79
|
+
* `--base` naming a commit the shallow checkout does not have would then sail through here and fail
|
|
80
|
+
* three stages later, as a delta error, which reads like a DiffCI defect rather than a missing fetch.
|
|
81
|
+
*/
|
|
82
|
+
function resolveSha(git, rev) {
|
|
83
|
+
const result = git(["rev-parse", "--verify", `${rev}^{commit}`]);
|
|
84
|
+
return result.ok ? result.stdout.trim() || undefined : undefined;
|
|
85
|
+
}
|
|
86
|
+
function mergeBase(git, a, b) {
|
|
87
|
+
const result = git(["merge-base", a, b]);
|
|
88
|
+
return result.ok ? result.stdout.trim() || undefined : undefined;
|
|
89
|
+
}
|
|
90
|
+
/** The one sentence every "commit missing locally" refusal ends with, so the fix is never a guess. */
|
|
91
|
+
const DEEPEN_HINT = "the commit is not in this checkout - add `fetch-depth: 0` to the actions/checkout step in the job that runs DiffCI (this does not affect any other job)";
|
|
92
|
+
export function resolveCommitRange(options) {
|
|
93
|
+
const { env, git, baseOverride, headOverride } = options;
|
|
94
|
+
if (baseOverride !== undefined || headOverride !== undefined) {
|
|
95
|
+
if (baseOverride === undefined || headOverride === undefined) {
|
|
96
|
+
return { ok: false, reason: "--base and --head must be given together" };
|
|
97
|
+
}
|
|
98
|
+
const base = resolveSha(git, baseOverride);
|
|
99
|
+
const head = resolveSha(git, headOverride);
|
|
100
|
+
if (!base)
|
|
101
|
+
return { ok: false, reason: `base revision ${baseOverride} could not be resolved: ${DEEPEN_HINT}` };
|
|
102
|
+
if (!head)
|
|
103
|
+
return { ok: false, reason: `head revision ${headOverride} could not be resolved: ${DEEPEN_HINT}` };
|
|
104
|
+
return { ok: true, range: { baseSha: base, headSha: head, source: "explicit-flags" } };
|
|
105
|
+
}
|
|
106
|
+
const payload = readEventPayload(env);
|
|
107
|
+
const event = env.GITHUB_EVENT_NAME;
|
|
108
|
+
if (event === "pull_request" || event === "pull_request_target") {
|
|
109
|
+
const headSha = asString(nested(payload, "pull_request", "head", "sha"));
|
|
110
|
+
const baseSha = asString(nested(payload, "pull_request", "base", "sha"));
|
|
111
|
+
if (!headSha || !baseSha) {
|
|
112
|
+
return { ok: false, reason: "pull_request event payload carried no head/base SHA" };
|
|
113
|
+
}
|
|
114
|
+
if (!commitExists(git, headSha)) {
|
|
115
|
+
return {
|
|
116
|
+
ok: false,
|
|
117
|
+
reason: `pull request head ${headSha.slice(0, 12)} is not present locally: ${DEEPEN_HINT}`,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
if (!commitExists(git, baseSha)) {
|
|
121
|
+
return {
|
|
122
|
+
ok: false,
|
|
123
|
+
reason: `pull request base ${baseSha.slice(0, 12)} is not present locally: ${DEEPEN_HINT}`,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
// GitHub's base.sha is the base branch tip as of the event. When the branch has moved on since,
|
|
127
|
+
// diffing from it attributes other people's commits to this pull request, so the merge base is
|
|
128
|
+
// used when git can compute one - and the fact that it was used is recorded, not hidden.
|
|
129
|
+
const common = mergeBase(git, baseSha, headSha);
|
|
130
|
+
const effectiveBase = common ?? baseSha;
|
|
131
|
+
return {
|
|
132
|
+
ok: true,
|
|
133
|
+
range: {
|
|
134
|
+
baseSha: effectiveBase,
|
|
135
|
+
headSha,
|
|
136
|
+
source: "pull-request-event",
|
|
137
|
+
mergeBaseSha: common && common !== baseSha ? common : undefined,
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
if (event === "push") {
|
|
142
|
+
const headSha = asString(env.GITHUB_SHA) ?? resolveSha(git, "HEAD");
|
|
143
|
+
if (!headSha)
|
|
144
|
+
return { ok: false, reason: "could not determine the head commit of this push" };
|
|
145
|
+
const before = asString(nested(payload, "before"));
|
|
146
|
+
if (before && before !== ZERO_SHA && commitExists(git, before)) {
|
|
147
|
+
return { ok: true, range: { baseSha: before, headSha, source: "push-event" } };
|
|
148
|
+
}
|
|
149
|
+
// A branch's first push has no `before`. The head's own parent is the honest substitute and is
|
|
150
|
+
// labelled as such - it answers "what did this commit change", not "what did this push change".
|
|
151
|
+
const parent = resolveSha(git, `${headSha}^`);
|
|
152
|
+
if (!parent) {
|
|
153
|
+
return {
|
|
154
|
+
ok: false,
|
|
155
|
+
reason: before === ZERO_SHA
|
|
156
|
+
? `this push created the branch and its head commit has no parent in this checkout: ${DEEPEN_HINT}`
|
|
157
|
+
: `no usable base commit for this push: ${DEEPEN_HINT}`,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
return { ok: true, range: { baseSha: parent, headSha, source: "head-parent" } };
|
|
161
|
+
}
|
|
162
|
+
const head = resolveSha(git, "HEAD");
|
|
163
|
+
if (!head)
|
|
164
|
+
return { ok: false, reason: "not a git repository, or HEAD could not be resolved" };
|
|
165
|
+
const parent = resolveSha(git, "HEAD^");
|
|
166
|
+
if (!parent) {
|
|
167
|
+
return {
|
|
168
|
+
ok: false,
|
|
169
|
+
reason: `HEAD has no parent commit in this checkout, so there is no range to analyse: ${DEEPEN_HINT}`,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
return { ok: true, range: { baseSha: parent, headSha: head, source: "head-parent" } };
|
|
173
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The client-side observation run (Phase 02, 2026-08-26).
|
|
3
|
+
*
|
|
4
|
+
* This is the whole product, executed on someone else's runner: resolve a commit range, build the real
|
|
5
|
+
* dependency graph from the checkout that is already there, work out which tests DiffCI would have run,
|
|
6
|
+
* work out what a simple path-rule CI would have run, and write a report. It runs nothing, changes
|
|
7
|
+
* nothing, and cancels nothing.
|
|
8
|
+
*
|
|
9
|
+
* Two properties are load-bearing, and both are enforced here rather than promised:
|
|
10
|
+
*
|
|
11
|
+
* READ-ONLY. The engine modules called below only read (verified: no write in src/repo, src/git). This
|
|
12
|
+
* function additionally records HEAD and `git status --porcelain` before and after itself, so a run that
|
|
13
|
+
* DID dirty the tree says so in its own report instead of being discovered weeks later.
|
|
14
|
+
*
|
|
15
|
+
* NEVER THROWS. A crash inside an observer that a repository installed on trust must not take their CI
|
|
16
|
+
* step down with it, and must not be silent either. Every failure becomes a report with status ERROR or
|
|
17
|
+
* REFUSED and the stage it happened at. The distinction is deliberate and is preserved everywhere:
|
|
18
|
+
* REFUSED is a limit DiffCI is stating (no TypeScript project, base commit not fetched), ERROR is a
|
|
19
|
+
* defect in DiffCI. Counting them together would let a week of crashes read as a week of honest limits.
|
|
20
|
+
*/
|
|
21
|
+
import { execFileSync } from "node:child_process";
|
|
22
|
+
import { createHash } from "node:crypto";
|
|
23
|
+
import { relative, resolve } from "node:path";
|
|
24
|
+
import { analyzeGitDelta } from "../git/git-diff.js";
|
|
25
|
+
import { classifyTypeScriptProject, buildDependencyGraph } from "../repo/graph.js";
|
|
26
|
+
import { ImpactAnalyzer } from "../repo/impact.js";
|
|
27
|
+
import { runPathBaseline } from "../planner/path-baseline.js";
|
|
28
|
+
import { commandSpecToString, planSelectiveTestCommands } from "../planner/test-command.js";
|
|
29
|
+
import { readCiEnvironment, resolveCommitRange, } from "./context.js";
|
|
30
|
+
import { OBSERVATION_SCHEMA, redactPath, } from "./report.js";
|
|
31
|
+
import { auditWorkflows } from "./workflow-guard.js";
|
|
32
|
+
/** Build output and dependencies are never sources of truth about a repository's own structure. */
|
|
33
|
+
const EXCLUDE_DIRS = ["node_modules", ".next", "dist", "build"];
|
|
34
|
+
function makeGitRunner(repoPath) {
|
|
35
|
+
return (args) => {
|
|
36
|
+
try {
|
|
37
|
+
const stdout = execFileSync("git", args, {
|
|
38
|
+
cwd: repoPath,
|
|
39
|
+
encoding: "utf8",
|
|
40
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
41
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
42
|
+
});
|
|
43
|
+
return { ok: true, stdout };
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
return { ok: false, error: error.message.split("\n")[0] ?? "git failed" };
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
function sha256(input) {
|
|
51
|
+
return createHash("sha256").update(input).digest("hex");
|
|
52
|
+
}
|
|
53
|
+
/** A digest rather than the porcelain text itself: the point is only whether it changed. */
|
|
54
|
+
function worktreeDigest(git) {
|
|
55
|
+
const result = git(["status", "--porcelain"]);
|
|
56
|
+
return result.ok ? sha256(result.stdout) : undefined;
|
|
57
|
+
}
|
|
58
|
+
function headSha(git) {
|
|
59
|
+
const result = git(["rev-parse", "HEAD"]);
|
|
60
|
+
return result.ok ? result.stdout.trim() || undefined : undefined;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* True when the report would land inside the observed checkout. That is not a style preference: an
|
|
64
|
+
* untracked file in the working tree changes `git status`, and CI jobs that assert a clean tree (a
|
|
65
|
+
* generated-code check, a lockfile check, `git diff --exit-code`) would start failing because DiffCI
|
|
66
|
+
* was installed. The report is written outside the repository by default for exactly that reason.
|
|
67
|
+
*/
|
|
68
|
+
export function isInsideRepository(repoPath, candidate) {
|
|
69
|
+
const rel = relative(resolve(repoPath), resolve(candidate));
|
|
70
|
+
return rel !== "" && !rel.startsWith("..") && !/^[a-zA-Z]:/.test(rel);
|
|
71
|
+
}
|
|
72
|
+
export async function observe(options) {
|
|
73
|
+
const startedAt = Date.now();
|
|
74
|
+
const repoPath = resolve(options.repoPath);
|
|
75
|
+
const git = options.git ?? makeGitRunner(repoPath);
|
|
76
|
+
const ci = readCiEnvironment(options.env);
|
|
77
|
+
const [owner, name] = (ci.ownerName ?? "").split("/");
|
|
78
|
+
const before = { head: headSha(git), worktree: worktreeDigest(git) };
|
|
79
|
+
const hashPath = (path) => (options.redactPaths ? redactPath(path, sha256) : path);
|
|
80
|
+
// Declared before `finish` closes over it: the range is part of every report, including the reports
|
|
81
|
+
// produced by failures that happen after it was resolved.
|
|
82
|
+
let range;
|
|
83
|
+
const finish = (status, stage, extra) => {
|
|
84
|
+
const after = { head: headSha(git), worktree: worktreeDigest(git) };
|
|
85
|
+
const nonInterference = {
|
|
86
|
+
headShaBefore: before.head,
|
|
87
|
+
headShaAfter: after.head,
|
|
88
|
+
worktreeDigestBefore: before.worktree,
|
|
89
|
+
worktreeDigestAfter: after.worktree,
|
|
90
|
+
worktreeUnchanged: before.head !== undefined &&
|
|
91
|
+
before.worktree !== undefined &&
|
|
92
|
+
before.head === after.head &&
|
|
93
|
+
before.worktree === after.worktree,
|
|
94
|
+
reportWrittenOutsideRepository: options.reportPath === undefined ? true : !isInsideRepository(repoPath, options.reportPath),
|
|
95
|
+
workflowFindings: safeAuditWorkflows(repoPath),
|
|
96
|
+
};
|
|
97
|
+
return {
|
|
98
|
+
schema: OBSERVATION_SCHEMA,
|
|
99
|
+
producedAt: new Date().toISOString(),
|
|
100
|
+
observer: {
|
|
101
|
+
version: options.version,
|
|
102
|
+
engineSha: options.engineSha,
|
|
103
|
+
node: process.version,
|
|
104
|
+
platform: process.platform,
|
|
105
|
+
},
|
|
106
|
+
repository: {
|
|
107
|
+
provider: ci.provider === "github-actions" ? "github" : "unknown",
|
|
108
|
+
ownerName: owner && name ? `${owner}/${name}` : undefined,
|
|
109
|
+
defaultBranch: ci.defaultBranch,
|
|
110
|
+
providerRepositoryId: ci.providerRepositoryId,
|
|
111
|
+
},
|
|
112
|
+
ci: {
|
|
113
|
+
provider: ci.provider,
|
|
114
|
+
runId: ci.runId,
|
|
115
|
+
runAttempt: ci.runAttempt,
|
|
116
|
+
workflow: ci.workflow,
|
|
117
|
+
job: ci.job,
|
|
118
|
+
event: ci.event,
|
|
119
|
+
ref: ci.ref,
|
|
120
|
+
syntheticTrigger: ci.syntheticTrigger,
|
|
121
|
+
},
|
|
122
|
+
commitRange: range ? { ...range } : undefined,
|
|
123
|
+
status,
|
|
124
|
+
stage,
|
|
125
|
+
reason: extra.reason,
|
|
126
|
+
result: extra.result,
|
|
127
|
+
payload: {
|
|
128
|
+
includesFilePaths: options.redactPaths !== true,
|
|
129
|
+
includesFileContents: false,
|
|
130
|
+
includesEnvironment: false,
|
|
131
|
+
includesCredentials: false,
|
|
132
|
+
pathRedaction: options.redactPaths ? "sha256-12" : undefined,
|
|
133
|
+
},
|
|
134
|
+
nonInterference,
|
|
135
|
+
timings: { totalMs: Date.now() - startedAt },
|
|
136
|
+
};
|
|
137
|
+
};
|
|
138
|
+
try {
|
|
139
|
+
const resolved = resolveCommitRange({
|
|
140
|
+
env: options.env,
|
|
141
|
+
git,
|
|
142
|
+
baseOverride: options.baseOverride,
|
|
143
|
+
headOverride: options.headOverride,
|
|
144
|
+
});
|
|
145
|
+
if (!resolved.ok)
|
|
146
|
+
return finish("REFUSED", "context", { reason: resolved.reason });
|
|
147
|
+
range = resolved.range;
|
|
148
|
+
// The eligibility gate is asked of the graph builder itself (classifyTypeScriptProject), not of a
|
|
149
|
+
// separate list of conditions that can drift away from it. Phase 01 F3 is what that drift costs.
|
|
150
|
+
const capability = classifyTypeScriptProject(repoPath);
|
|
151
|
+
if (!capability.capable) {
|
|
152
|
+
return finish("REFUSED", "eligibility", {
|
|
153
|
+
reason: `DiffCI can only analyse TypeScript/JavaScript projects today: ${capability.reason}`,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
const deltaResult = await analyzeGitDelta({ baseSha: range.baseSha, headSha: range.headSha, repoPath });
|
|
157
|
+
if (!deltaResult.success) {
|
|
158
|
+
return finish("REFUSED", "delta", { reason: deltaResult.error });
|
|
159
|
+
}
|
|
160
|
+
const delta = deltaResult.delta;
|
|
161
|
+
const graphResult = await buildDependencyGraph({ repoPath, excludeDirs: EXCLUDE_DIRS });
|
|
162
|
+
if (graphResult.graph.nodes.length === 0) {
|
|
163
|
+
return finish("REFUSED", "graph", {
|
|
164
|
+
reason: "the dependency graph came back empty - DiffCI will not propose a selection from a graph that sees none of this repository",
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
const profile = graphResult.profile;
|
|
168
|
+
const impact = new ImpactAnalyzer().analyze(delta, graphResult, profile, {
|
|
169
|
+
repositoryFiles: deltaResult.inventory?.files,
|
|
170
|
+
});
|
|
171
|
+
const baseline = runPathBaseline(profile.testFilePaths, delta.files, profile);
|
|
172
|
+
const selectedTests = impact.affectedTests.map((test) => test.path).sort();
|
|
173
|
+
// The comparator's own identities, sorted and redacted identically so the two arms of an economics
|
|
174
|
+
// experiment are executed the same way rather than merely counted the same way.
|
|
175
|
+
const comparatorSelectedTests = [...baseline.selectedTests].sort().map(hashPath);
|
|
176
|
+
const commandPlan = impact.fallbackRequired
|
|
177
|
+
? undefined
|
|
178
|
+
: planSelectiveTestCommands(profile, selectedTests);
|
|
179
|
+
return finish("OBSERVED", "complete", {
|
|
180
|
+
result: {
|
|
181
|
+
mode: impact.fallbackRequired ? "FULL" : "SELECTIVE",
|
|
182
|
+
changedFileCount: delta.files.length,
|
|
183
|
+
changedFiles: delta.files.map((file) => hashPath(file.path)),
|
|
184
|
+
affectedSourceFileCount: impact.affectedSourceFiles.length,
|
|
185
|
+
selectedTests: selectedTests.map(hashPath),
|
|
186
|
+
totalTestCount: profile.testFilePaths.length,
|
|
187
|
+
fallbackReasons: impact.fallbackReasons,
|
|
188
|
+
proposedCommands: (commandPlan?.commands ?? []).map(commandSpecToString),
|
|
189
|
+
commandRefusalReason: commandPlan?.refusalReason,
|
|
190
|
+
unroutedTestPaths: (commandPlan?.unroutedPaths ?? []).map(hashPath),
|
|
191
|
+
blindSpot: profile.testUniverse?.blindSpot === true,
|
|
192
|
+
riskSignals: impact.riskSignals.map((signal) => ({ level: signal.level, reason: signal.reason })),
|
|
193
|
+
graph: {
|
|
194
|
+
nodes: graphResult.graph.nodes.length,
|
|
195
|
+
edges: graphResult.graph.edges.length,
|
|
196
|
+
confidence: graphResult.confidence,
|
|
197
|
+
effectiveConfidence: impact.effectiveGraphConfidence,
|
|
198
|
+
durationMs: Math.round(graphResult.performance.durationMs),
|
|
199
|
+
},
|
|
200
|
+
pathBaseline: {
|
|
201
|
+
mode: baseline.fallbackRequired ? "FULL" : "SELECTIVE",
|
|
202
|
+
// Count and list derived from ONE array, so they cannot disagree. Sorted and redacted exactly
|
|
203
|
+
// like DiffCI's own `selectedTests` above, so a harness can execute either arm identically.
|
|
204
|
+
selectedTestCount: comparatorSelectedTests.length,
|
|
205
|
+
selectedTests: comparatorSelectedTests,
|
|
206
|
+
matchedRules: baseline.matchedRules,
|
|
207
|
+
},
|
|
208
|
+
analysisStatus: impact.analysisStatus,
|
|
209
|
+
},
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
catch (error) {
|
|
213
|
+
return finish("ERROR", "complete", {
|
|
214
|
+
reason: `${error.name}: ${error.message.split("\n")[0]}`,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* The workflow audit runs on every observation so the byte-identical claim is re-checked continuously,
|
|
220
|
+
* not once at install. It is best-effort: a repository whose workflows cannot be read still gets its
|
|
221
|
+
* observation, with the audit absent rather than the run lost.
|
|
222
|
+
*/
|
|
223
|
+
function safeAuditWorkflows(repoPath) {
|
|
224
|
+
try {
|
|
225
|
+
return auditWorkflows(repoPath).findings;
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
return [];
|
|
229
|
+
}
|
|
230
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The observation report - DiffCI's client-side wire format (Phase 02, 2026-08-26).
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS. Every earlier pipeline in this repository analysed OTHER people's repositories on
|
|
5
|
+
* DiffCI's own infrastructure: a container cloned the source, built the graph, and kept the evidence.
|
|
6
|
+
* That shape cannot be installed by a stranger - it asks them to hand a private tree to a service they
|
|
7
|
+
* have not evaluated, before they have seen a single number. Phase 02 inverts it. The analysis runs on
|
|
8
|
+
* the repository's own runner, inside its own CI, against a checkout it already has, and the only thing
|
|
9
|
+
* that ever leaves is this document.
|
|
10
|
+
*
|
|
11
|
+
* So this file is the privacy boundary as much as it is a type. Everything DiffCI could learn about a
|
|
12
|
+
* repository it does not host is a field below, and `payload` states in the document itself what the
|
|
13
|
+
* document contains. If a future field would carry file CONTENT, an environment variable, or anything
|
|
14
|
+
* a `git show` would print, it does not belong here - it belongs in a design conversation first.
|
|
15
|
+
*
|
|
16
|
+
* VERSIONING. `schema` is checked by consumers before anything else is read. A change that removes or
|
|
17
|
+
* repurposes a field is a new version, not an edit to this one: reports are produced by a pinned action
|
|
18
|
+
* inside someone else's CI, and old producers keep sending v1 long after the server has moved on.
|
|
19
|
+
*/
|
|
20
|
+
/** Wire identifier. Consumers MUST reject a document whose `schema` they do not recognise. */
|
|
21
|
+
export const OBSERVATION_SCHEMA = "diffci.observation.v1";
|
|
22
|
+
/**
|
|
23
|
+
* Path redaction for repositories that will observe but will not send paths off their runner.
|
|
24
|
+
*
|
|
25
|
+
* A truncated SHA-256 is stable across runs and across repositories, which is exactly the trade being
|
|
26
|
+
* made: the same file redacts to the same digest every time (so selection ratios and per-file trends
|
|
27
|
+
* still work), and a holder of the digest can confirm a GUESSED path but cannot enumerate paths from
|
|
28
|
+
* the digest alone. It is redaction, not anonymisation, and is described that way.
|
|
29
|
+
*/
|
|
30
|
+
export function redactPath(path, hash) {
|
|
31
|
+
return hash(path).slice(0, 12);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Minimal structural validation, shared by the producer's own tests and (from Phase 03) by ingest.
|
|
35
|
+
* Deliberately shallow: it checks that a document is the shape this version promises, not that its
|
|
36
|
+
* numbers are true.
|
|
37
|
+
*/
|
|
38
|
+
export function validateObservationReport(value) {
|
|
39
|
+
if (value === null || typeof value !== "object")
|
|
40
|
+
return { ok: false, error: "report is not an object" };
|
|
41
|
+
const record = value;
|
|
42
|
+
if (record.schema !== OBSERVATION_SCHEMA) {
|
|
43
|
+
return { ok: false, error: `unsupported schema ${String(record.schema)} (expected ${OBSERVATION_SCHEMA})` };
|
|
44
|
+
}
|
|
45
|
+
for (const field of ["producedAt", "status", "stage"]) {
|
|
46
|
+
if (typeof record[field] !== "string")
|
|
47
|
+
return { ok: false, error: `missing or non-string field "${field}"` };
|
|
48
|
+
}
|
|
49
|
+
if (!["OBSERVED", "REFUSED", "ERROR"].includes(record.status)) {
|
|
50
|
+
return { ok: false, error: `unknown status ${String(record.status)}` };
|
|
51
|
+
}
|
|
52
|
+
if (record.payload === null || typeof record.payload !== "object") {
|
|
53
|
+
return { ok: false, error: "missing payload description" };
|
|
54
|
+
}
|
|
55
|
+
const payload = record.payload;
|
|
56
|
+
if (payload.includesFileContents !== false || payload.includesCredentials !== false) {
|
|
57
|
+
return { ok: false, error: "payload claims to carry file contents or credentials, which this schema forbids" };
|
|
58
|
+
}
|
|
59
|
+
if (record.nonInterference === null || typeof record.nonInterference !== "object") {
|
|
60
|
+
return { ok: false, error: "missing non-interference evidence" };
|
|
61
|
+
}
|
|
62
|
+
return { ok: true, report: value };
|
|
63
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
2
|
+
function isLoopback(url) {
|
|
3
|
+
return url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]";
|
|
4
|
+
}
|
|
5
|
+
async function defaultSleep(ms) {
|
|
6
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
7
|
+
}
|
|
8
|
+
export async function submitObservation(options) {
|
|
9
|
+
if (!options.token) {
|
|
10
|
+
return { ok: false, kind: "misconfigured", message: "No ingest token was given, so the report was not sent." };
|
|
11
|
+
}
|
|
12
|
+
let url;
|
|
13
|
+
try {
|
|
14
|
+
url = new URL(options.apiUrl);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return { ok: false, kind: "misconfigured", message: `"${options.apiUrl}" is not a valid URL.` };
|
|
18
|
+
}
|
|
19
|
+
if (url.protocol !== "https:" && !isLoopback(url)) {
|
|
20
|
+
return {
|
|
21
|
+
ok: false,
|
|
22
|
+
kind: "misconfigured",
|
|
23
|
+
message: `Refusing to send an ingest token over ${url.protocol}// to ${url.host}. Use https.`,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
27
|
+
const sleep = options.sleep ?? defaultSleep;
|
|
28
|
+
const attempts = 1 + Math.max(0, options.retries ?? 1);
|
|
29
|
+
const body = JSON.stringify(options.report);
|
|
30
|
+
let last = { ok: false, kind: "unreachable", message: "no attempt was made" };
|
|
31
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
32
|
+
if (attempt > 1)
|
|
33
|
+
await sleep(options.delayMs ?? 1000);
|
|
34
|
+
const controller = new AbortController();
|
|
35
|
+
const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
36
|
+
try {
|
|
37
|
+
const response = await fetchImpl(url.toString(), {
|
|
38
|
+
method: "POST",
|
|
39
|
+
headers: {
|
|
40
|
+
"content-type": "application/json",
|
|
41
|
+
authorization: `Bearer ${options.token}`,
|
|
42
|
+
"user-agent": `diffci-observer/${options.report.observer?.version ?? "unknown"}`,
|
|
43
|
+
},
|
|
44
|
+
body,
|
|
45
|
+
signal: controller.signal,
|
|
46
|
+
});
|
|
47
|
+
const text = await response.text().catch(() => "");
|
|
48
|
+
const parsed = (() => {
|
|
49
|
+
try {
|
|
50
|
+
return JSON.parse(text);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
})();
|
|
56
|
+
if (response.ok) {
|
|
57
|
+
return { ok: true, duplicate: parsed?.duplicate === true, status: response.status };
|
|
58
|
+
}
|
|
59
|
+
if (response.status >= 500) {
|
|
60
|
+
last = {
|
|
61
|
+
ok: false,
|
|
62
|
+
kind: "unreachable",
|
|
63
|
+
status: response.status,
|
|
64
|
+
message: `DiffCI returned ${response.status}. The report is on disk and can be sent again.`,
|
|
65
|
+
};
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
// 4xx: the server has told us what is wrong with this request. Its message is written for the
|
|
69
|
+
// person reading this CI log, so it is passed through rather than summarised.
|
|
70
|
+
return {
|
|
71
|
+
ok: false,
|
|
72
|
+
kind: "rejected",
|
|
73
|
+
status: response.status,
|
|
74
|
+
rejection: typeof parsed?.rejection === "string" ? parsed.rejection : undefined,
|
|
75
|
+
message: typeof parsed?.error === "string" ? parsed.error : `DiffCI rejected the report (${response.status}).`,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
// Deliberately does not include the request in the message: on some runtimes a thrown fetch error
|
|
80
|
+
// stringifies the whole request, headers included.
|
|
81
|
+
const reason = error instanceof Error && error.name === "AbortError" ? "timed out" : "failed";
|
|
82
|
+
last = { ok: false, kind: "unreachable", message: `The request to ${url.host} ${reason}. The report is on disk and can be sent again.` };
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
clearTimeout(timer);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return last;
|
|
89
|
+
}
|