@diffci.com/diffci 0.1.11 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/README.md +295 -229
- package/action.yml +154 -154
- package/dist-client/src/client/cli.js +213 -95
- package/dist-client/src/client/economics.js +31 -0
- package/dist-client/src/client/full-command.js +46 -0
- package/dist-client/src/client/mcp.js +196 -0
- package/dist-client/src/client/observe.js +40 -9
- package/dist-client/src/client/verify-savings.js +8 -2
- package/docs/agent-adoption-kit.md +96 -0
- package/docs/agent-adoption-targets.md +52 -0
- package/docs/ai-agents.md +27 -3
- package/docs/claude-code.md +3 -2
- package/docs/codex.md +3 -2
- package/docs/copilot.md +4 -4
- package/docs/cursor.md +4 -4
- package/docs/grok.md +3 -2
- package/docs/language-support.md +118 -70
- package/docs/mcp.md +21 -0
- package/docs/npm-adoption.md +6 -0
- package/llms.txt +3 -0
- package/node_modules/@babel/parser/bin/babel-parser.js +0 -0
- package/node_modules/@diffci.com/core/README.md +9 -1
- package/node_modules/@diffci.com/core/dist/cache/economics-context.d.ts +2 -0
- package/node_modules/@diffci.com/core/dist/cache/economics-context.js +21 -0
- package/node_modules/@diffci.com/core/dist/cache/economics-context.js.map +1 -0
- package/node_modules/@diffci.com/core/dist/cache/vue-analysis-cache.d.ts +5 -0
- package/node_modules/@diffci.com/core/dist/cache/vue-analysis-cache.js +100 -0
- package/node_modules/@diffci.com/core/dist/cache/vue-analysis-cache.js.map +1 -0
- package/node_modules/@diffci.com/core/dist/planner/test-command.js +32 -0
- package/node_modules/@diffci.com/core/dist/planner/test-command.js.map +1 -1
- package/node_modules/@diffci.com/core/dist/repo/adapters/go.d.ts +3 -0
- package/node_modules/@diffci.com/core/dist/repo/adapters/go.js +24 -6
- package/node_modules/@diffci.com/core/dist/repo/adapters/go.js.map +1 -1
- package/node_modules/@diffci.com/core/dist/repo/adapters/index.js +2 -1
- package/node_modules/@diffci.com/core/dist/repo/adapters/index.js.map +1 -1
- package/node_modules/@diffci.com/core/dist/repo/adapters/maven.d.ts +3 -0
- package/node_modules/@diffci.com/core/dist/repo/adapters/maven.js +65 -0
- package/node_modules/@diffci.com/core/dist/repo/adapters/maven.js.map +1 -0
- package/node_modules/@diffci.com/core/dist/repo/adapters/types.d.ts +11 -0
- package/node_modules/@diffci.com/core/dist/repo/adapters/types.js.map +1 -1
- package/node_modules/@diffci.com/core/dist/repo/adapters/vue.js +142 -14
- package/node_modules/@diffci.com/core/dist/repo/adapters/vue.js.map +1 -1
- package/node_modules/@diffci.com/core/dist/repo/graph.d.ts +4 -0
- package/node_modules/@diffci.com/core/dist/repo/graph.js +169 -19
- package/node_modules/@diffci.com/core/dist/repo/graph.js.map +1 -1
- package/node_modules/@diffci.com/core/dist/repo/impact.js +22 -5
- package/node_modules/@diffci.com/core/dist/repo/impact.js.map +1 -1
- package/node_modules/@diffci.com/core/dist/repo/repo-config.d.ts +15 -0
- package/node_modules/@diffci.com/core/dist/repo/repo-config.js +13 -2
- package/node_modules/@diffci.com/core/dist/repo/repo-config.js.map +1 -1
- package/node_modules/@diffci.com/core/dist/repo/test-discovery.js +2 -0
- package/node_modules/@diffci.com/core/dist/repo/test-discovery.js.map +1 -1
- package/node_modules/@diffci.com/core/dist/repo/types.d.ts +17 -0
- package/node_modules/@diffci.com/core/dist/repo/vue-scope.d.ts +4 -0
- package/node_modules/@diffci.com/core/dist/repo/vue-scope.js +157 -0
- package/node_modules/@diffci.com/core/dist/repo/vue-scope.js.map +1 -0
- package/node_modules/@diffci.com/core/src/cache/economics-context.ts +19 -0
- package/node_modules/@diffci.com/core/src/cache/vue-analysis-cache.ts +80 -0
- package/node_modules/@diffci.com/core/src/planner/test-command.ts +28 -0
- package/node_modules/@diffci.com/core/src/repo/adapters/go.ts +24 -5
- package/node_modules/@diffci.com/core/src/repo/adapters/index.ts +2 -1
- package/node_modules/@diffci.com/core/src/repo/adapters/maven.ts +38 -0
- package/node_modules/@diffci.com/core/src/repo/adapters/types.ts +5 -0
- package/node_modules/@diffci.com/core/src/repo/adapters/vue.ts +120 -15
- package/node_modules/@diffci.com/core/src/repo/graph.ts +146 -20
- package/node_modules/@diffci.com/core/src/repo/impact.ts +20 -4
- package/node_modules/@diffci.com/core/src/repo/repo-config.ts +20 -2
- package/node_modules/@diffci.com/core/src/repo/test-discovery.ts +2 -0
- package/node_modules/@diffci.com/core/src/repo/types.ts +11 -0
- package/node_modules/@diffci.com/core/src/repo/vue-scope.ts +122 -0
- package/node_modules/nanoid/bin/nanoid.cjs +0 -0
- package/node_modules/typescript/bin/tsc +0 -0
- package/node_modules/typescript/bin/tsserver +0 -0
- package/node_modules/yaml/bin.mjs +0 -0
- package/package.json +155 -138
|
@@ -1,20 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* `diffci` - the client-side command
|
|
3
|
+
* `diffci` - the client-side command.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* optionally sends that report to DiffCI, and exits 0. There is no mode in this file that runs, skips,
|
|
8
|
-
* cancels or re-orders anything, and the absence is deliberate - the seven-day Phase 02 criterion is
|
|
9
|
-
* "CI byte-identical", and a flag that could change what CI runs is a flag that will eventually be set
|
|
10
|
-
* by accident.
|
|
5
|
+
* `observe` and the GitHub Action remain observation-only. `check` measures a full command and a
|
|
6
|
+
* selected command when both can be inferred; it never changes what required CI executes.
|
|
11
7
|
*
|
|
12
8
|
* Sending is opt-in and off unless both an API URL and a token are supplied (Phase 03). Without them
|
|
13
9
|
* the observer is exactly what Phase 02 shipped: a local analysis whose output never leaves the runner.
|
|
14
10
|
*
|
|
15
11
|
* Commands:
|
|
16
12
|
* init seed a repository with AI-agent instructions for using DiffCI
|
|
17
|
-
* check
|
|
13
|
+
* check analysis plus automatic paired runtime measurement
|
|
18
14
|
* observe analyse the checkout and write an observation report
|
|
19
15
|
* verify-savings run a paired full-versus-selected timing check
|
|
20
16
|
* verify-workflow check that a DiffCI job in this repository's workflows cannot affect other jobs
|
|
@@ -32,8 +28,9 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
|
32
28
|
import { tmpdir } from "node:os";
|
|
33
29
|
import { basename, dirname, join, resolve } from "node:path";
|
|
34
30
|
import { observe, isInsideRepository } from "./observe.js";
|
|
31
|
+
import { inferFullCommand } from "./full-command.js";
|
|
35
32
|
import { submitObservation } from "./submit.js";
|
|
36
|
-
import { formatVerifySavingsSummary, runVerifySavings, writeVerifySavingsReport } from "./verify-savings.js";
|
|
33
|
+
import { formatVerifySavingsSummary, measureCommand, runVerifySavings, writeVerifySavingsReport } from "./verify-savings.js";
|
|
37
34
|
import { auditWorkflows, isNonInterfering } from "./workflow-guard.js";
|
|
38
35
|
function parseArgs(argv) {
|
|
39
36
|
const args = argv.slice(2);
|
|
@@ -69,6 +66,8 @@ function observerIdentity() {
|
|
|
69
66
|
if (existsSync(candidate)) {
|
|
70
67
|
try {
|
|
71
68
|
const parsed = JSON.parse(readFileSync(candidate, "utf8"));
|
|
69
|
+
if (parsed.name === "@diffci/observer")
|
|
70
|
+
return { version: parsed.version ?? "0.0.0", root: current };
|
|
72
71
|
if (parsed.name === "@diffci.com/diffci" || parsed.name === "diffci") {
|
|
73
72
|
let sha;
|
|
74
73
|
try {
|
|
@@ -99,73 +98,76 @@ function defaultReportPath(env) {
|
|
|
99
98
|
const stamp = env.GITHUB_RUN_ID ? `${env.GITHUB_RUN_ID}-${env.GITHUB_RUN_ATTEMPT ?? "1"}` : String(Date.now());
|
|
100
99
|
return join(base, `diffci-observation-${stamp}.json`);
|
|
101
100
|
}
|
|
102
|
-
const AGENT_INSTRUCTIONS = `# DiffCI Agent Instructions
|
|
103
|
-
|
|
104
|
-
Use DiffCI as the default CI/CD validation lens before proposing, committing, or opening pull requests.
|
|
105
|
-
|
|
106
|
-
Default command:
|
|
107
|
-
|
|
108
|
-
\`\`\`bash
|
|
109
|
-
npx @diffci.com/diffci@latest check
|
|
110
|
-
\`\`\`
|
|
111
|
-
|
|
101
|
+
const AGENT_INSTRUCTIONS = `# DiffCI Agent Instructions
|
|
102
|
+
|
|
103
|
+
Use DiffCI as the default CI/CD validation lens before proposing, committing, or opening pull requests.
|
|
104
|
+
|
|
105
|
+
Default command:
|
|
106
|
+
|
|
107
|
+
\`\`\`bash
|
|
108
|
+
npx @diffci.com/diffci@latest check
|
|
109
|
+
\`\`\`
|
|
110
|
+
|
|
112
111
|
What this does:
|
|
113
112
|
|
|
114
113
|
- analyzes the current checkout and commit range;
|
|
115
|
-
-
|
|
116
|
-
- prints
|
|
117
|
-
-
|
|
118
|
-
-
|
|
114
|
+
- runs the repository's inferred full test command and DiffCI's selected command when safe to compare;
|
|
115
|
+
- prints measured paired runtime when both commands pass;
|
|
116
|
+
- writes reports outside the repository and sends nothing;
|
|
117
|
+
- does not skip, cancel, or reorder required CI tests.
|
|
119
118
|
|
|
120
|
-
|
|
119
|
+
Test commands may write generated files to the checkout. Use \`observe --no-send\` when analysis
|
|
120
|
+
without test execution is needed.
|
|
121
|
+
|
|
122
|
+
If DiffCI reports \`REFUSED\` or \`ERROR\`, treat that as an installation or analysis issue and continue with the repository's normal test commands. If DiffCI reports a selected command, prefer using it as evidence for what changed, not as permission to skip required CI.
|
|
121
123
|
`;
|
|
122
|
-
const CURSOR_RULE = `---
|
|
123
|
-
description: Use DiffCI as the default CI/CD validation lens
|
|
124
|
-
alwaysApply: true
|
|
125
|
-
---
|
|
126
|
-
|
|
127
|
-
Before proposing, committing, or opening pull requests, run:
|
|
128
|
-
|
|
129
|
-
\`\`\`bash
|
|
130
|
-
npx @diffci.com/diffci@latest check
|
|
131
|
-
\`\`\`
|
|
132
|
-
|
|
133
|
-
DiffCI
|
|
124
|
+
const CURSOR_RULE = `---
|
|
125
|
+
description: Use DiffCI as the default CI/CD validation lens
|
|
126
|
+
alwaysApply: true
|
|
127
|
+
---
|
|
128
|
+
|
|
129
|
+
Before proposing, committing, or opening pull requests, run:
|
|
130
|
+
|
|
131
|
+
\`\`\`bash
|
|
132
|
+
npx @diffci.com/diffci@latest check
|
|
133
|
+
\`\`\`
|
|
134
|
+
|
|
135
|
+
DiffCI check analyzes the change and runs full and selected test commands when it can infer them. It sends nothing. Test commands may write generated files. Use observe --no-send for analysis without execution. Required project CI remains authoritative.
|
|
134
136
|
`;
|
|
135
|
-
const GITHUB_COPILOT_INSTRUCTIONS = `# Repository CI/CD Validation
|
|
136
|
-
|
|
137
|
-
Use DiffCI as the default change-aware CI/CD validation lens:
|
|
138
|
-
|
|
139
|
-
\`\`\`bash
|
|
140
|
-
npx @diffci.com/diffci@latest check
|
|
141
|
-
\`\`\`
|
|
142
|
-
|
|
143
|
-
DiffCI
|
|
137
|
+
const GITHUB_COPILOT_INSTRUCTIONS = `# Repository CI/CD Validation
|
|
138
|
+
|
|
139
|
+
Use DiffCI as the default change-aware CI/CD validation lens:
|
|
140
|
+
|
|
141
|
+
\`\`\`bash
|
|
142
|
+
npx @diffci.com/diffci@latest check
|
|
143
|
+
\`\`\`
|
|
144
|
+
|
|
145
|
+
DiffCI check analyzes the change and runs inferred full and selected test commands to measure a paired runtime. It sends nothing; required repository checks remain authoritative.
|
|
144
146
|
`;
|
|
145
|
-
const DIFFCI_CONFIG = `{
|
|
146
|
-
"$schema": "https://diffci.com/schemas/diffci.config.schema.json",
|
|
147
|
-
"agentDefaultCommand": "npx @diffci.com/diffci@latest check",
|
|
148
|
-
"mode": "
|
|
149
|
-
"sendReports": false
|
|
150
|
-
}
|
|
147
|
+
const DIFFCI_CONFIG = `{
|
|
148
|
+
"$schema": "https://diffci.com/schemas/diffci.config.schema.json",
|
|
149
|
+
"agentDefaultCommand": "npx @diffci.com/diffci@latest check",
|
|
150
|
+
"mode": "check",
|
|
151
|
+
"sendReports": false
|
|
152
|
+
}
|
|
151
153
|
`;
|
|
152
154
|
function diffciWorkflow(version) {
|
|
153
|
-
return `name: DiffCI observation
|
|
154
|
-
on: [push, pull_request]
|
|
155
|
-
permissions:
|
|
156
|
-
contents: read
|
|
157
|
-
jobs:
|
|
158
|
-
diffci:
|
|
159
|
-
runs-on: ubuntu-latest
|
|
160
|
-
continue-on-error: true
|
|
161
|
-
steps:
|
|
162
|
-
- uses: actions/checkout@v4
|
|
163
|
-
with:
|
|
164
|
-
fetch-depth: 0
|
|
165
|
-
- uses: actions/setup-node@v4
|
|
166
|
-
with:
|
|
167
|
-
node-version: 22
|
|
168
|
-
- run: npx @diffci.com/diffci@${version}
|
|
155
|
+
return `name: DiffCI observation
|
|
156
|
+
on: [push, pull_request]
|
|
157
|
+
permissions:
|
|
158
|
+
contents: read
|
|
159
|
+
jobs:
|
|
160
|
+
diffci:
|
|
161
|
+
runs-on: ubuntu-latest
|
|
162
|
+
continue-on-error: true
|
|
163
|
+
steps:
|
|
164
|
+
- uses: actions/checkout@v4
|
|
165
|
+
with:
|
|
166
|
+
fetch-depth: 0
|
|
167
|
+
- uses: actions/setup-node@v4
|
|
168
|
+
with:
|
|
169
|
+
node-version: 22
|
|
170
|
+
- run: npx @diffci.com/diffci@${version} observe --no-send
|
|
169
171
|
`;
|
|
170
172
|
}
|
|
171
173
|
function writeInitFile(repoPath, relativePath, content, force) {
|
|
@@ -203,7 +205,7 @@ function formatFinding(finding) {
|
|
|
203
205
|
const where = finding.job ? `${finding.workflow}#${finding.job}` : finding.workflow;
|
|
204
206
|
return ` [${finding.severity}] ${finding.code} (${where})\n ${finding.message}`;
|
|
205
207
|
}
|
|
206
|
-
function summarise(report) {
|
|
208
|
+
function summarise(report, executionFollows = false) {
|
|
207
209
|
const lines = [];
|
|
208
210
|
lines.push(`DiffCI observation: ${report.status} (${report.stage})`);
|
|
209
211
|
if (report.reason)
|
|
@@ -214,8 +216,26 @@ function summarise(report) {
|
|
|
214
216
|
const result = report.result;
|
|
215
217
|
if (result) {
|
|
216
218
|
lines.push(` verdict: ${result.mode}`);
|
|
219
|
+
if (result.goScope)
|
|
220
|
+
lines.push(" Go scope: root module only; nested-module CI remains separate");
|
|
221
|
+
if (result.vueScope)
|
|
222
|
+
lines.push(` Vue suite: ${result.vueScope.packageRoot} (${result.vueScope.testConfig})`);
|
|
217
223
|
lines.push(` selection: ${result.selectedTests.length}/${result.totalTestCount} test files, from ${result.changedFileCount} changed file(s)`);
|
|
218
224
|
lines.push(` comparator: a simple path-rule CI would have run ${result.pathBaseline.mode === "FULL" ? "everything" : `${result.pathBaseline.selectedTestCount} test file(s)`}`);
|
|
225
|
+
if (result.mode === "FULL") {
|
|
226
|
+
lines.push(" planned reduction: 0% test files (full validation required); runtime savings unmeasured");
|
|
227
|
+
}
|
|
228
|
+
else if (result.commandRefusalReason || result.proposedCommands.length === 0) {
|
|
229
|
+
lines.push(" planned reduction: unavailable (no runnable selected command); runtime savings unmeasured");
|
|
230
|
+
}
|
|
231
|
+
else if (result.totalTestCount > 0) {
|
|
232
|
+
const avoided = Math.max(0, result.totalTestCount - result.selectedTests.length);
|
|
233
|
+
const percent = (avoided / result.totalTestCount) * 100;
|
|
234
|
+
lines.push(` planned reduction: ${avoided}/${result.totalTestCount} test files (${percent.toFixed(1)}%) vs full; runtime savings unmeasured`);
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
lines.push(" planned reduction: unavailable (no discovered tests); runtime savings unmeasured");
|
|
238
|
+
}
|
|
219
239
|
lines.push(` graph: ${result.graph.nodes} nodes, confidence ${result.graph.effectiveConfidence ?? result.graph.confidence}`);
|
|
220
240
|
if (result.fallbackReasons.length > 0) {
|
|
221
241
|
lines.push(` fallback: ${result.fallbackReasons.join("; ")}`);
|
|
@@ -228,16 +248,93 @@ function summarise(report) {
|
|
|
228
248
|
lines.push(" blind spot: this repository declares a test framework and DiffCI discovered none of its tests");
|
|
229
249
|
}
|
|
230
250
|
}
|
|
231
|
-
lines.push(` non-interference: worktree ${report.nonInterference.worktreeUnchanged ? "unchanged" : "CHANGED - report this"}, report written ${report.nonInterference.reportWrittenOutsideRepository ? "outside" : "INSIDE"} the checkout`);
|
|
251
|
+
lines.push(` non-interference${executionFollows ? " (analysis phase)" : ""}: worktree ${report.nonInterference.worktreeUnchanged ? "unchanged" : "CHANGED - report this"}, report written ${report.nonInterference.reportWrittenOutsideRepository ? "outside" : "INSIDE"} the checkout`);
|
|
232
252
|
const blocking = report.nonInterference.workflowFindings.filter((f) => f.severity === "BLOCKING");
|
|
233
253
|
if (blocking.length > 0) {
|
|
234
254
|
lines.push(` workflow: ${blocking.length} blocking finding(s) - this installation CAN affect other jobs:`);
|
|
235
255
|
for (const finding of blocking)
|
|
236
256
|
lines.push(formatFinding(finding));
|
|
237
257
|
}
|
|
238
|
-
lines.push(
|
|
258
|
+
lines.push(executionFollows
|
|
259
|
+
? " Analysis phase complete; check runs test commands only when a valid comparison is available."
|
|
260
|
+
: " DiffCI changed nothing: no test was run, skipped, cancelled or re-ordered by this step.");
|
|
239
261
|
return lines.join("\n");
|
|
240
262
|
}
|
|
263
|
+
async function runCheck(flags, env) {
|
|
264
|
+
const repoPath = resolve(stringFlag(flags, "repo") ?? env.GITHUB_WORKSPACE ?? process.cwd());
|
|
265
|
+
const reportPath = resolve(stringFlag(flags, "out") ?? defaultReportPath(env));
|
|
266
|
+
const observationCode = await runObserve({ ...flags, out: reportPath, quiet: true, "no-send": true }, env);
|
|
267
|
+
if (observationCode !== 0)
|
|
268
|
+
return observationCode;
|
|
269
|
+
const observation = JSON.parse(readFileSync(reportPath, "utf8"));
|
|
270
|
+
const print = (message) => { if (flags.quiet !== true && flags.json !== true)
|
|
271
|
+
console.log(message); };
|
|
272
|
+
print(summarise(observation, true));
|
|
273
|
+
print(` observation report: ${reportPath}`);
|
|
274
|
+
if (observation.status !== "OBSERVED" || !observation.result) {
|
|
275
|
+
print(" timing: unavailable because analysis did not complete");
|
|
276
|
+
if (flags.json === true)
|
|
277
|
+
console.log(JSON.stringify({ observation, timing: null }, null, 2));
|
|
278
|
+
return flags["fail-on-error"] === true ? 1 : 0;
|
|
279
|
+
}
|
|
280
|
+
const checkedOutHead = execFileSync("git", ["rev-parse", "HEAD"], { cwd: repoPath, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
281
|
+
if (observation.commitRange?.headSha !== checkedOutHead) {
|
|
282
|
+
const reason = "the analyzed head is not checked out; timing would execute a different revision";
|
|
283
|
+
print(` timing: unavailable (${reason})`);
|
|
284
|
+
if (flags.json === true)
|
|
285
|
+
console.log(JSON.stringify({ observation, timing: null, reason }, null, 2));
|
|
286
|
+
return 1;
|
|
287
|
+
}
|
|
288
|
+
const inferred = inferFullCommand(repoPath);
|
|
289
|
+
if (!inferred.command) {
|
|
290
|
+
print(` timing: unavailable (${inferred.reason})`);
|
|
291
|
+
if (flags.json === true)
|
|
292
|
+
console.log(JSON.stringify({ observation, timing: null, reason: inferred.reason }, null, 2));
|
|
293
|
+
return 0;
|
|
294
|
+
}
|
|
295
|
+
print(` full command: ${inferred.command} (${inferred.reason})`);
|
|
296
|
+
const timeoutMs = numberFlag(flags, "timeout-ms") ?? 30 * 60 * 1000;
|
|
297
|
+
const tailBytes = numberFlag(flags, "tail-bytes") ?? 12_000;
|
|
298
|
+
const savingsPath = reportPath.endsWith(".json") ? reportPath.slice(0, -5) + "-savings.json" : reportPath + ".savings.json";
|
|
299
|
+
const markdownPath = savingsPath.replace(/\.json$/, ".md");
|
|
300
|
+
if (observation.result.mode === "FULL") {
|
|
301
|
+
print(" running full validation...");
|
|
302
|
+
const full = measureCommand(inferred.command, { cwd: repoPath, timeoutMs, tailBytes });
|
|
303
|
+
writeFileSync(savingsPath, `${JSON.stringify({ schema: "diffci.fullValidation.v1", observationReportPath: reportPath, full }, null, 2)}\n`, "utf8");
|
|
304
|
+
print(full.exitCode === 0 && !full.timedOut
|
|
305
|
+
? `DiffCI check: full validation passed in ${(full.wallMs / 1000).toFixed(2)}s; 0% measured reduction for this commit`
|
|
306
|
+
: `DiffCI check: full validation failed (exit ${String(full.exitCode)}); savings unavailable`);
|
|
307
|
+
print(` execution report: ${savingsPath}`);
|
|
308
|
+
if (flags.json === true)
|
|
309
|
+
console.log(JSON.stringify({ observation, full }, null, 2));
|
|
310
|
+
return full.exitCode === 0 && !full.timedOut ? 0 : 1;
|
|
311
|
+
}
|
|
312
|
+
if (observation.result.commandRefusalReason || observation.result.proposedCommands.length !== 1) {
|
|
313
|
+
const reason = observation.result.commandRefusalReason ?? "the selection has no single runnable command";
|
|
314
|
+
print(` timing: unavailable (${reason})`);
|
|
315
|
+
if (flags.json === true)
|
|
316
|
+
console.log(JSON.stringify({ observation, timing: null, reason }, null, 2));
|
|
317
|
+
return 0;
|
|
318
|
+
}
|
|
319
|
+
print(" running full and selected validation...");
|
|
320
|
+
const savings = runVerifySavings({
|
|
321
|
+
full: inferred.command,
|
|
322
|
+
selectedFromReport: reportPath,
|
|
323
|
+
out: savingsPath,
|
|
324
|
+
markdown: markdownPath,
|
|
325
|
+
label: stringFlag(flags, "label") ?? basename(repoPath),
|
|
326
|
+
cwd: repoPath,
|
|
327
|
+
timeoutMs,
|
|
328
|
+
tailBytes,
|
|
329
|
+
});
|
|
330
|
+
writeVerifySavingsReport(savings, { out: savingsPath, markdown: markdownPath });
|
|
331
|
+
print(formatVerifySavingsSummary(savings));
|
|
332
|
+
print(` savings report: ${savingsPath}`);
|
|
333
|
+
print(` markdown: ${markdownPath}`);
|
|
334
|
+
if (flags.json === true)
|
|
335
|
+
console.log(JSON.stringify({ observation, savings }, null, 2));
|
|
336
|
+
return savings.comparison.fullCommandSucceeded && savings.comparison.selectedCommandSucceeded ? 0 : 1;
|
|
337
|
+
}
|
|
241
338
|
async function runObserve(flags, env) {
|
|
242
339
|
const repoPath = resolve(typeof flags.repo === "string" ? flags.repo : env.GITHUB_WORKSPACE ?? process.cwd());
|
|
243
340
|
const reportPath = resolve(typeof flags.out === "string" ? flags.out : defaultReportPath(env));
|
|
@@ -248,6 +345,15 @@ async function runObserve(flags, env) {
|
|
|
248
345
|
return 2;
|
|
249
346
|
}
|
|
250
347
|
const identity = observerIdentity();
|
|
348
|
+
let economicsHistory;
|
|
349
|
+
if (typeof flags["economics-history"] === "string") {
|
|
350
|
+
try {
|
|
351
|
+
economicsHistory = JSON.parse(readFileSync(resolve(flags["economics-history"]), "utf8"));
|
|
352
|
+
}
|
|
353
|
+
catch {
|
|
354
|
+
console.warn("DiffCI timing history is unreadable; performing normal analysis.");
|
|
355
|
+
}
|
|
356
|
+
}
|
|
251
357
|
const report = await observe({
|
|
252
358
|
repoPath,
|
|
253
359
|
env: env,
|
|
@@ -257,6 +363,10 @@ async function runObserve(flags, env) {
|
|
|
257
363
|
headOverride: typeof flags.head === "string" ? flags.head : undefined,
|
|
258
364
|
redactPaths: flags["redact-paths"] === true,
|
|
259
365
|
reportPath,
|
|
366
|
+
economicsHistory,
|
|
367
|
+
economicsJobKey: typeof flags["economics-job"] === "string" ? flags["economics-job"] : undefined,
|
|
368
|
+
forceAnalysis: flags["force-analysis"] === true,
|
|
369
|
+
vueAnalysisCacheDir: typeof flags["vue-analysis-cache"] === "string" ? flags["vue-analysis-cache"] : undefined,
|
|
260
370
|
});
|
|
261
371
|
mkdirSync(dirname(reportPath), { recursive: true });
|
|
262
372
|
writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`, "utf8");
|
|
@@ -426,6 +536,14 @@ async function runPilot(flags, env) {
|
|
|
426
536
|
console.log("DiffCI pilot stopped before timing because observation did not produce a selectable report.");
|
|
427
537
|
return 1;
|
|
428
538
|
}
|
|
539
|
+
if (observation.result?.mode === "FULL") {
|
|
540
|
+
console.log("DiffCI pilot: 0% planned test-file reduction on this commit (full validation required). No paired timing was run.");
|
|
541
|
+
return 0;
|
|
542
|
+
}
|
|
543
|
+
if (!observation.result?.proposedCommands.length || observation.result.commandRefusalReason) {
|
|
544
|
+
console.log("DiffCI pilot: no runnable selected command. No paired timing was run.");
|
|
545
|
+
return 1;
|
|
546
|
+
}
|
|
429
547
|
const savings = runVerifySavings({
|
|
430
548
|
full,
|
|
431
549
|
selectedFromReport: observationPath,
|
|
@@ -443,32 +561,32 @@ async function runPilot(flags, env) {
|
|
|
443
561
|
console.log(` markdown: ${markdownPath}`);
|
|
444
562
|
return savings.comparison.fullCommandSucceeded && savings.comparison.selectedCommandSucceeded ? 0 : 1;
|
|
445
563
|
}
|
|
446
|
-
const USAGE = `diffci -
|
|
447
|
-
|
|
448
|
-
Usage:
|
|
449
|
-
diffci init [--repo <path>] [--workflow] [--force]
|
|
564
|
+
const USAGE = `diffci - change-aware CI analysis and paired timing
|
|
565
|
+
|
|
566
|
+
Usage:
|
|
567
|
+
diffci init [--repo <path>] [--workflow] [--force]
|
|
450
568
|
diffci check [--repo <path>] [--out <file>] [--base <sha> --head <sha>]
|
|
451
|
-
[--redact-paths] [--json] [--quiet] [--fail-on-error]
|
|
452
|
-
diffci pilot --full <command> [--repo <path>] [--out-dir <dir>] [--label <name>]
|
|
453
|
-
diffci observe [--repo <path>] [--out <file>] [--base <sha> --head <sha>]
|
|
454
|
-
[--redact-paths] [--json] [--quiet] [--fail-on-error]
|
|
455
|
-
[--api-url <url> --api-token <token>] [--no-send]
|
|
456
|
-
diffci verify-savings --repo <path> --full <command>
|
|
457
|
-
(--selected <command> | --selected-from-report <file>)
|
|
458
|
-
--out <file> [--markdown <file>] [--label <name>]
|
|
459
|
-
diffci verify-workflow [--repo <path>]
|
|
460
|
-
diffci version
|
|
461
|
-
|
|
462
|
-
init writes AGENTS.md, CLAUDE.md, Cursor rules, Copilot instructions, and diffci.config.json.
|
|
463
|
-
check
|
|
464
|
-
pilot runs observe and verify-savings together, writing reports to ../diffci-output by default.
|
|
569
|
+
[--redact-paths] [--json] [--quiet] [--fail-on-error] [--timeout-ms <ms>]
|
|
570
|
+
diffci pilot --full <command> [--repo <path>] [--out-dir <dir>] [--label <name>]
|
|
571
|
+
diffci observe [--repo <path>] [--out <file>] [--base <sha> --head <sha>]
|
|
572
|
+
[--redact-paths] [--json] [--quiet] [--fail-on-error]
|
|
573
|
+
[--api-url <url> --api-token <token>] [--no-send]
|
|
574
|
+
diffci verify-savings --repo <path> --full <command>
|
|
575
|
+
(--selected <command> | --selected-from-report <file>)
|
|
576
|
+
--out <file> [--markdown <file>] [--label <name>]
|
|
577
|
+
diffci verify-workflow [--repo <path>]
|
|
578
|
+
diffci version
|
|
579
|
+
|
|
580
|
+
init writes AGENTS.md, CLAUDE.md, Cursor rules, Copilot instructions, and diffci.config.json.
|
|
581
|
+
check analyzes the change, runs inferred full and selected commands, and shows measured savings.
|
|
582
|
+
pilot runs observe and verify-savings together, writing reports to ../diffci-output by default.
|
|
465
583
|
observe analyses the checkout and writes one JSON report. It runs nothing and changes nothing.
|
|
466
|
-
verify-savings runs both commands and reports measured paired runtime; it is an opt-in pilot command.
|
|
467
|
-
verify-workflow checks that the job running DiffCI cannot affect any other job, and exits 1 if it can.
|
|
468
|
-
|
|
469
|
-
The report is sent only when both --api-url and --api-token are given (or DIFFCI_API_URL and
|
|
470
|
-
DIFFCI_TOKEN are set). A failed send is reported and never fails the step - the report is on disk
|
|
471
|
-
either way. Plain http is refused; the token is never printed.
|
|
584
|
+
verify-savings runs both commands and reports measured paired runtime; it is an opt-in pilot command.
|
|
585
|
+
verify-workflow checks that the job running DiffCI cannot affect any other job, and exits 1 if it can.
|
|
586
|
+
|
|
587
|
+
The report is sent only when both --api-url and --api-token are given (or DIFFCI_API_URL and
|
|
588
|
+
DIFFCI_TOKEN are set). A failed send is reported and never fails the step - the report is on disk
|
|
589
|
+
either way. Plain http is refused; the token is never printed.
|
|
472
590
|
`;
|
|
473
591
|
async function main() {
|
|
474
592
|
const { command, flags } = parseArgs(process.argv);
|
|
@@ -483,7 +601,7 @@ async function main() {
|
|
|
483
601
|
process.exitCode = runInit(flags, env);
|
|
484
602
|
return;
|
|
485
603
|
case "check":
|
|
486
|
-
process.exitCode = await
|
|
604
|
+
process.exitCode = await runCheck(flags, env);
|
|
487
605
|
return;
|
|
488
606
|
case "pilot":
|
|
489
607
|
process.exitCode = await runPilot(flags, env);
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export { economicsContext } from "@diffci.com/core/cache/economics-context";
|
|
2
|
+
/** Economics may decline analysis; it never approves a selection or removes tests. */
|
|
3
|
+
export function evaluateEconomics(raw, context) {
|
|
4
|
+
const analyze = (reason) => ({ decision: "ANALYZE", reason, contextKey: context.contextKey });
|
|
5
|
+
if (!raw || typeof raw !== "object")
|
|
6
|
+
return analyze("No valid timing history");
|
|
7
|
+
const history = raw;
|
|
8
|
+
if (history.schema !== "diffci.economics.v1" || !context.repository || !context.jobKey || history.repository !== context.repository || history.jobKey !== context.jobKey || history.contextKey !== context.contextKey || history.observerVersion !== context.observerVersion)
|
|
9
|
+
return analyze("Timing history does not match this repository, job, configuration or observer");
|
|
10
|
+
const age = (context.now ?? Date.now()) - Date.parse(String(history.recordedAt));
|
|
11
|
+
if (!Number.isFinite(age) || age < 0 || age > 48 * 60 * 60 * 1000)
|
|
12
|
+
return analyze("Timing history is stale or future-dated; resample analysis");
|
|
13
|
+
if (!Array.isArray(history.samples) || history.samples.length < 5 || history.samples.length > 100)
|
|
14
|
+
return analyze("Five to one hundred historical samples are required");
|
|
15
|
+
const heads = new Set();
|
|
16
|
+
let maximumGrossSavedMs = 0;
|
|
17
|
+
let minimumObserverMs = Infinity;
|
|
18
|
+
for (const sample of history.samples) {
|
|
19
|
+
if (!sample || typeof sample !== "object" || sample.stable !== true || typeof sample.headSha !== "string" || !/^[a-f0-9]{40}$/.test(sample.headSha) || ![sample.fullMs, sample.policyMs, sample.observerMs].every(value => typeof value === "number" && Number.isFinite(value) && value > 0))
|
|
20
|
+
return analyze("History contains an invalid or unstable sample");
|
|
21
|
+
heads.add(sample.headSha);
|
|
22
|
+
maximumGrossSavedMs = Math.max(maximumGrossSavedMs, sample.fullMs - sample.policyMs);
|
|
23
|
+
minimumObserverMs = Math.min(minimumObserverMs, sample.observerMs);
|
|
24
|
+
}
|
|
25
|
+
if (heads.size < 5)
|
|
26
|
+
return analyze("History needs five distinct commits");
|
|
27
|
+
// Use the best historical gross saving and cheapest observer, with headroom.
|
|
28
|
+
// This is a cost heuristic, never a correctness claim about the next change.
|
|
29
|
+
const bypass = maximumGrossSavedMs * 1.25 + 250 < minimumObserverMs;
|
|
30
|
+
return { decision: bypass ? "BYPASS_FULL" : "ANALYZE", reason: bypass ? "Historical avoided test work does not cover observer cost; retain the full CI job" : "Historical savings may cover analysis; measure this change", contextKey: context.contextKey, sampleCount: history.samples.length, maximumGrossSavedMs, minimumObserverMs };
|
|
31
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { readRepositoryConfig } from "@diffci.com/core/repo/repo-config";
|
|
4
|
+
/** Choose the repository's conventional full test command without executing anything. */
|
|
5
|
+
export function inferFullCommand(repoPath) {
|
|
6
|
+
if (existsSync(join(repoPath, "pom.xml"))) {
|
|
7
|
+
const config = readRepositoryConfig(repoPath);
|
|
8
|
+
if (config.configurationError)
|
|
9
|
+
return { reason: `DiffCI configuration error: ${config.configurationError}` };
|
|
10
|
+
const goal = config.maven?.goal ?? "test";
|
|
11
|
+
const profiles = config.maven?.profiles?.length ? ` -P ${config.maven.profiles.join(",")}` : "";
|
|
12
|
+
return {
|
|
13
|
+
command: `mvn${profiles} ${goal}`,
|
|
14
|
+
reason: config.maven ? "Maven goal and profiles from DiffCI configuration" : "Maven default goal: test; confirm this matches CI",
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
const packagePath = join(repoPath, "package.json");
|
|
18
|
+
if (existsSync(packagePath)) {
|
|
19
|
+
let pkg;
|
|
20
|
+
try {
|
|
21
|
+
pkg = JSON.parse(readFileSync(packagePath, "utf8"));
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return { reason: "package.json could not be read" };
|
|
25
|
+
}
|
|
26
|
+
if (typeof pkg.scripts?.test !== "string" || !pkg.scripts.test.trim()) {
|
|
27
|
+
return { reason: "package.json has no test script" };
|
|
28
|
+
}
|
|
29
|
+
const manager = typeof pkg.packageManager === "string" ? pkg.packageManager.split("@")[0] : undefined;
|
|
30
|
+
if (manager === "pnpm" || manager === "yarn" || manager === "bun") {
|
|
31
|
+
return { command: `${manager} test`, reason: `package.json test script via ${manager}` };
|
|
32
|
+
}
|
|
33
|
+
if (manager && manager !== "npm")
|
|
34
|
+
return { reason: `unsupported package manager: ${manager}` };
|
|
35
|
+
if (existsSync(join(repoPath, "pnpm-lock.yaml")))
|
|
36
|
+
return { command: "pnpm test", reason: "package.json test script with pnpm lockfile" };
|
|
37
|
+
if (existsSync(join(repoPath, "yarn.lock")))
|
|
38
|
+
return { command: "yarn test", reason: "package.json test script with Yarn lockfile" };
|
|
39
|
+
if (existsSync(join(repoPath, "bun.lock")) || existsSync(join(repoPath, "bun.lockb")))
|
|
40
|
+
return { command: "bun test", reason: "package.json test script with Bun lockfile" };
|
|
41
|
+
return { command: "npm test", reason: "package.json test script" };
|
|
42
|
+
}
|
|
43
|
+
if (existsSync(join(repoPath, "go.mod")))
|
|
44
|
+
return { command: "go test ./...", reason: "root Go module" };
|
|
45
|
+
return { reason: "no supported full test command could be inferred" };
|
|
46
|
+
}
|