@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.
Files changed (76) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +295 -229
  3. package/action.yml +154 -154
  4. package/dist-client/src/client/cli.js +213 -95
  5. package/dist-client/src/client/economics.js +31 -0
  6. package/dist-client/src/client/full-command.js +46 -0
  7. package/dist-client/src/client/mcp.js +196 -0
  8. package/dist-client/src/client/observe.js +40 -9
  9. package/dist-client/src/client/verify-savings.js +8 -2
  10. package/docs/agent-adoption-kit.md +96 -0
  11. package/docs/agent-adoption-targets.md +52 -0
  12. package/docs/ai-agents.md +27 -3
  13. package/docs/claude-code.md +3 -2
  14. package/docs/codex.md +3 -2
  15. package/docs/copilot.md +4 -4
  16. package/docs/cursor.md +4 -4
  17. package/docs/grok.md +3 -2
  18. package/docs/language-support.md +118 -70
  19. package/docs/mcp.md +21 -0
  20. package/docs/npm-adoption.md +6 -0
  21. package/llms.txt +3 -0
  22. package/node_modules/@babel/parser/bin/babel-parser.js +0 -0
  23. package/node_modules/@diffci.com/core/README.md +9 -1
  24. package/node_modules/@diffci.com/core/dist/cache/economics-context.d.ts +2 -0
  25. package/node_modules/@diffci.com/core/dist/cache/economics-context.js +21 -0
  26. package/node_modules/@diffci.com/core/dist/cache/economics-context.js.map +1 -0
  27. package/node_modules/@diffci.com/core/dist/cache/vue-analysis-cache.d.ts +5 -0
  28. package/node_modules/@diffci.com/core/dist/cache/vue-analysis-cache.js +100 -0
  29. package/node_modules/@diffci.com/core/dist/cache/vue-analysis-cache.js.map +1 -0
  30. package/node_modules/@diffci.com/core/dist/planner/test-command.js +32 -0
  31. package/node_modules/@diffci.com/core/dist/planner/test-command.js.map +1 -1
  32. package/node_modules/@diffci.com/core/dist/repo/adapters/go.d.ts +3 -0
  33. package/node_modules/@diffci.com/core/dist/repo/adapters/go.js +24 -6
  34. package/node_modules/@diffci.com/core/dist/repo/adapters/go.js.map +1 -1
  35. package/node_modules/@diffci.com/core/dist/repo/adapters/index.js +2 -1
  36. package/node_modules/@diffci.com/core/dist/repo/adapters/index.js.map +1 -1
  37. package/node_modules/@diffci.com/core/dist/repo/adapters/maven.d.ts +3 -0
  38. package/node_modules/@diffci.com/core/dist/repo/adapters/maven.js +65 -0
  39. package/node_modules/@diffci.com/core/dist/repo/adapters/maven.js.map +1 -0
  40. package/node_modules/@diffci.com/core/dist/repo/adapters/types.d.ts +11 -0
  41. package/node_modules/@diffci.com/core/dist/repo/adapters/types.js.map +1 -1
  42. package/node_modules/@diffci.com/core/dist/repo/adapters/vue.js +142 -14
  43. package/node_modules/@diffci.com/core/dist/repo/adapters/vue.js.map +1 -1
  44. package/node_modules/@diffci.com/core/dist/repo/graph.d.ts +4 -0
  45. package/node_modules/@diffci.com/core/dist/repo/graph.js +169 -19
  46. package/node_modules/@diffci.com/core/dist/repo/graph.js.map +1 -1
  47. package/node_modules/@diffci.com/core/dist/repo/impact.js +22 -5
  48. package/node_modules/@diffci.com/core/dist/repo/impact.js.map +1 -1
  49. package/node_modules/@diffci.com/core/dist/repo/repo-config.d.ts +15 -0
  50. package/node_modules/@diffci.com/core/dist/repo/repo-config.js +13 -2
  51. package/node_modules/@diffci.com/core/dist/repo/repo-config.js.map +1 -1
  52. package/node_modules/@diffci.com/core/dist/repo/test-discovery.js +2 -0
  53. package/node_modules/@diffci.com/core/dist/repo/test-discovery.js.map +1 -1
  54. package/node_modules/@diffci.com/core/dist/repo/types.d.ts +17 -0
  55. package/node_modules/@diffci.com/core/dist/repo/vue-scope.d.ts +4 -0
  56. package/node_modules/@diffci.com/core/dist/repo/vue-scope.js +157 -0
  57. package/node_modules/@diffci.com/core/dist/repo/vue-scope.js.map +1 -0
  58. package/node_modules/@diffci.com/core/src/cache/economics-context.ts +19 -0
  59. package/node_modules/@diffci.com/core/src/cache/vue-analysis-cache.ts +80 -0
  60. package/node_modules/@diffci.com/core/src/planner/test-command.ts +28 -0
  61. package/node_modules/@diffci.com/core/src/repo/adapters/go.ts +24 -5
  62. package/node_modules/@diffci.com/core/src/repo/adapters/index.ts +2 -1
  63. package/node_modules/@diffci.com/core/src/repo/adapters/maven.ts +38 -0
  64. package/node_modules/@diffci.com/core/src/repo/adapters/types.ts +5 -0
  65. package/node_modules/@diffci.com/core/src/repo/adapters/vue.ts +120 -15
  66. package/node_modules/@diffci.com/core/src/repo/graph.ts +146 -20
  67. package/node_modules/@diffci.com/core/src/repo/impact.ts +20 -4
  68. package/node_modules/@diffci.com/core/src/repo/repo-config.ts +20 -2
  69. package/node_modules/@diffci.com/core/src/repo/test-discovery.ts +2 -0
  70. package/node_modules/@diffci.com/core/src/repo/types.ts +11 -0
  71. package/node_modules/@diffci.com/core/src/repo/vue-scope.ts +122 -0
  72. package/node_modules/nanoid/bin/nanoid.cjs +0 -0
  73. package/node_modules/typescript/bin/tsc +0 -0
  74. package/node_modules/typescript/bin/tsserver +0 -0
  75. package/node_modules/yaml/bin.mjs +0 -0
  76. package/package.json +155 -138
@@ -1,20 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * `diffci` - the client-side command (Phase 02, 2026-08-26).
3
+ * `diffci` - the client-side command.
4
4
  *
5
- * This is the binary a third-party repository runs in its own CI. Everything it does is observation:
6
- * it reads a checkout, writes one JSON report to a path outside that checkout, prints a summary,
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 agent-friendly alias for observe --no-send
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
- - writes a JSON report outside the repository by default;
116
- - prints selected tests, fallback reasons, and proposed commands;
117
- - sends nothing unless an endpoint and token are explicitly configured;
118
- - runs, skips, cancels, or reorders no tests.
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
- 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.
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 is observation-only by default. It analyzes the change, writes a report outside the checkout, and sends nothing without explicit configuration. Do not use a DiffCI selection as permission to skip required project CI.
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's default agent command is observation-only. It should inform which tests and CI paths are relevant, while the repository's required checks remain authoritative.
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": "observe",
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} check
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(" DiffCI changed nothing: no test was run, skipped, cancelled or re-ordered by this step.");
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 - observation-only change-aware CI analysis
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 is the default AI-agent command: it is observe with sending disabled.
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 runObserve({ ...flags, "no-send": true }, env);
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
+ }