ci-sdk 0.0.0-stage → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Diffling
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,32 @@
1
- # Temporary Holding Version
1
+ # ci-sdk
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Insanely fast CI. No migration. By [Diffling](https://diffling.com).
4
+
5
+ ## scan
6
+
7
+ See where your GitHub Actions minutes and dollars go, and how much ci-sdk would save. Run it in your repo:
8
+
9
+ ```bash
10
+ npx ci-sdk scan
11
+ ```
12
+
13
+ - Looks at the last 30 days of workflow runs (`--days`, `--max-runs` to change).
14
+ - Estimates savings from your repo's own history: repeated runs, tests a change could not reach (via an oxc import graph), and slow installs.
15
+ - `--markdown` for a PR or Slack, `--json` for tooling.
16
+ - Needs a GitHub token with **Actions: read**. Uses `GITHUB_TOKEN`, `GH_TOKEN`, or `gh auth token`.
17
+ - Runs locally. Your code and CI data never leave your machine.
18
+
19
+ ## test
20
+
21
+ Run only the tests your changes can reach, using each package's own `test` script:
22
+
23
+ ```bash
24
+ npx ci-sdk test
25
+ ```
26
+
27
+ - Looks at uncommitted and untracked changes. Add `--since origin/main` to include commits on your branch.
28
+ - `--list` prints the affected test files instead of running them.
29
+ - Follows imports through barrel files, so a change in a shared package only runs the tests that use it.
30
+ - If a change can affect everything, such as a root config, it runs the full suite.
31
+
32
+ Needs Node 22.18 or newer.
@@ -0,0 +1,31 @@
1
+ // Order matters: first match wins, so "Setup pnpm" is setup and "Run pnpm test" is test.
2
+ const RULES = [
3
+ // pnpm/action-setup usually runs the install itself (run_install: true); when it doesn't, it takes seconds.
4
+ ['install', /pnpm\/action-setup/i],
5
+ ['setup', /^(set up job|complete job|post )|checkout|\bset ?up\b|actions\/cache/i],
6
+ ['install', /\b(npm (ci|install|i)|pnpm (i|install)|yarn install|bun install|deno install|install dependencies)\b|^run yarn$|^install( deps| dependencies| packages)?$/i],
7
+ ['test', /\b(test|tests|vitest|jest|spec|e2e|playwright|cypress|coverage)\b/i],
8
+ ['lint', /\b(lint|eslint|oxlint|biome|prettier|format|typecheck|type-check|tsc|check)\b/i],
9
+ ['build', /\b(build|compile|bundle|tsup|rollup|webpack)\b/i],
10
+ ];
11
+ export function categorize(stepName, durationMs = 0) {
12
+ const cat = RULES.find(([, re]) => re.test(stepName))?.[0] ?? 'other';
13
+ // Custom setup actions ("Setup pnpm and Node.js") often install deps too; installing a toolchain alone takes seconds.
14
+ if (cat === 'setup' && durationMs > 60_000 && /pnpm|npm|yarn|bun|node|deps|dependencies/i.test(stepName))
15
+ return 'install';
16
+ return cat;
17
+ }
18
+ // GitHub's published per-minute USD rates: https://docs.github.com/en/billing/reference/actions-minute-multipliers
19
+ // shortcut: larger-runner sizes are not detected and are priced as 2-core; read runner size from the org's runner API when we need exact larger-runner pricing.
20
+ export function ratePerMinute(labels) {
21
+ const l = labels.join(' ').toLowerCase();
22
+ if (l.includes('self-hosted'))
23
+ return null;
24
+ if (l.includes('macos'))
25
+ return 0.062;
26
+ if (l.includes('windows'))
27
+ return 0.01;
28
+ if (l.includes('arm'))
29
+ return 0.005;
30
+ return 0.006;
31
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from 'node:util';
3
+ import { getToken, repoFromGit } from './github.js';
4
+ import { json, markdown, text } from './report.js';
5
+ import { scan } from './scan.js';
6
+ import { testAffected } from './test.js';
7
+ const HELP = `Usage: ci-sdk <command> [options]
8
+
9
+ Commands:
10
+ test Run only the tests your changes can reach
11
+ scan Show where your GitHub Actions minutes and dollars go, and what ci-sdk would save
12
+
13
+ test options:
14
+ --since <ref> Also include changes committed since <ref>, e.g. origin/main (default: uncommitted only)
15
+ --list Print the affected test files instead of running them
16
+
17
+ scan options:
18
+ --repo <owner/name> Repo to scan (default: this checkout's origin)
19
+ --days <n> Days of history (default: 30)
20
+ --max-runs <n> Runs to sample evenly across the window (default: 300)
21
+ --json Print JSON
22
+ --markdown Print markdown to paste into a PR or Slack
23
+ -h, --help Show this help
24
+
25
+ scan needs a GitHub token with Actions: read. Uses GITHUB_TOKEN, GH_TOKEN, or \`gh auth token\`.
26
+ Everything runs locally; nothing leaves your machine.`;
27
+ async function main() {
28
+ const { values, positionals } = parseArgs({
29
+ allowPositionals: true,
30
+ options: {
31
+ repo: { type: 'string' },
32
+ days: { type: 'string', default: '30' },
33
+ 'max-runs': { type: 'string', default: '300' },
34
+ json: { type: 'boolean' },
35
+ markdown: { type: 'boolean' },
36
+ since: { type: 'string' },
37
+ list: { type: 'boolean' },
38
+ help: { type: 'boolean', short: 'h' },
39
+ },
40
+ });
41
+ const command = positionals[0];
42
+ if (values.help || (command !== 'scan' && command !== 'test')) {
43
+ console.log(HELP);
44
+ process.exit(values.help ? 0 : 1);
45
+ }
46
+ const log = (m) => process.stderr.write(`ci-sdk: ${m}\n`);
47
+ if (command === 'test')
48
+ process.exit(await testAffected({ cwd: process.cwd(), since: values.since, list: values.list, log }));
49
+ const root = process.cwd();
50
+ const repo = values.repo ?? repoFromGit(root);
51
+ if (!repo)
52
+ throw new Error('Could not find a GitHub remote. Run inside a clone, or pass --repo owner/name.');
53
+ const token = getToken();
54
+ if (!token)
55
+ throw new Error('No GitHub token found. Set GITHUB_TOKEN (a fine-grained token with Actions: read), or run `gh auth login`.');
56
+ const days = Number(values.days);
57
+ const maxRuns = Number(values['max-runs']);
58
+ if (!(days > 0) || !(maxRuns > 0))
59
+ throw new Error('--days and --max-runs must be positive numbers.');
60
+ const result = await scan({ root, repo, token, days, maxRuns, log: (m) => process.stderr.write(m + '\n') });
61
+ console.log(values.json ? json(result) : values.markdown ? markdown(result) : text(result));
62
+ }
63
+ main().catch((err) => {
64
+ console.error(`ci-sdk: ${err.message}`);
65
+ process.exit(1);
66
+ });
package/dist/github.js ADDED
@@ -0,0 +1,171 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ const API = 'https://api.github.com';
3
+ export function getToken() {
4
+ const env = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
5
+ if (env)
6
+ return env;
7
+ try {
8
+ return execFileSync('gh', ['auth', 'token'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() || undefined;
9
+ }
10
+ catch {
11
+ return undefined;
12
+ }
13
+ }
14
+ export function repoFromGit(cwd) {
15
+ try {
16
+ const url = execFileSync('git', ['remote', 'get-url', 'origin'], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
17
+ return url.trim().match(/github\.com[:/]([^/]+\/[^/]+?)(?:\.git)?$/)?.[1];
18
+ }
19
+ catch {
20
+ return undefined;
21
+ }
22
+ }
23
+ async function gh(path, token, attempt = 1) {
24
+ const res = await fetch(API + path, {
25
+ headers: {
26
+ Accept: 'application/vnd.github+json',
27
+ Authorization: `Bearer ${token}`,
28
+ 'X-GitHub-Api-Version': '2022-11-28',
29
+ },
30
+ });
31
+ if (res.ok)
32
+ return res.json();
33
+ const outOfQuota = res.headers.get('x-ratelimit-remaining') === '0';
34
+ // Flaky 5xx and secondary rate limits (Retry-After) clear up; a spent hourly quota does not.
35
+ const retryAfter = Number(res.headers.get('retry-after'));
36
+ if (attempt < 4 && (res.status >= 500 || res.status === 429 || (res.status === 403 && retryAfter && !outOfQuota))) {
37
+ await new Promise((r) => setTimeout(r, retryAfter ? retryAfter * 1000 : 1000 * 2 ** attempt));
38
+ return gh(path, token, attempt + 1);
39
+ }
40
+ const body = (await res.json().catch(() => ({})));
41
+ const reset = new Date(Number(res.headers.get('x-ratelimit-reset')) * 1000).toLocaleTimeString();
42
+ const hint = res.status === 401 ? ' (the token is invalid or expired)'
43
+ : outOfQuota ? ` (GitHub API rate limit used up; it resets at ${reset})`
44
+ : res.status === 403 ? ' (the token needs Actions: read on this repo)'
45
+ : res.status === 404 ? ' (check the repo name and that your token can read it)'
46
+ : '';
47
+ throw new Error(`GitHub API ${res.status} on ${path}: ${body.message ?? res.statusText}${hint}`);
48
+ }
49
+ export function getRepo(repo, token) {
50
+ return gh(`/repos/${repo}`, token);
51
+ }
52
+ const COUNT_CAP = 2500;
53
+ /** How many runs a slot held. Past GitHub's 2,500 count cap, scale the spacing of the newest 100 runs to the whole slot. */
54
+ export function slotCount(totalCount, runs, width) {
55
+ if (totalCount < COUNT_CAP || runs.length < 2)
56
+ return totalCount;
57
+ const span = Date.parse(runs[0].created_at) - Date.parse(runs.at(-1).created_at);
58
+ return span > 0 ? Math.max(COUNT_CAP, Math.round(((runs.length - 1) * width) / span)) : COUNT_CAP;
59
+ }
60
+ /** `n` items spread evenly through `items`, so a slot's sample is not just its last minutes. */
61
+ const spread = (items, n) => (items.length <= n ? items : Array.from({ length: n }, (_, i) => items[Math.floor((i * items.length) / n)]));
62
+ /**
63
+ * Splits `budget` samples across workflows by `score`, never more than a workflow's run count.
64
+ * Samples a small workflow cannot use go back to the others, so the whole budget gets spent.
65
+ */
66
+ export function allocate(items, budget) {
67
+ const k = items.map(() => 0);
68
+ let open = items.map((_, i) => i).filter((i) => items[i].count > 0);
69
+ let left = budget;
70
+ while (left > 0 && open.length) {
71
+ const total = open.reduce((a, i) => a + items[i].score, 0) || 1;
72
+ let used = 0;
73
+ for (const i of open) {
74
+ const next = Math.min(items[i].count, k[i] + Math.max(1, Math.floor((left * items[i].score) / total)));
75
+ used += next - k[i];
76
+ k[i] = next;
77
+ }
78
+ left -= used;
79
+ open = open.filter((i) => k[i] < items[i].count);
80
+ }
81
+ return k;
82
+ }
83
+ const iso = (t) => new Date(t).toISOString().slice(0, 19) + 'Z';
84
+ const wallMs = (r) => Math.max(1000, Date.parse(r.updated_at ?? r.created_at) - Date.parse(r.run_started_at ?? r.created_at));
85
+ /**
86
+ * `k` runs of one workflow spread over time, each weighted by how many runs its time slot held.
87
+ * Small workflows are sampled straight from their first page; busy ones get one query per slot.
88
+ */
89
+ async function sampleWorkflow(repo, token, id, first, since, now, k) {
90
+ // Up to GitHub's 1,000-result listing limit, list every run (1 call per 100) and pick evenly: exact count, no empty slots.
91
+ if (first.total_count <= 1000) {
92
+ const all = [...first.workflow_runs];
93
+ const created = encodeURIComponent(`>=${iso(since).slice(0, 10)}`);
94
+ for (let page = 2; all.length < first.total_count; page++) {
95
+ const data = await gh(`/repos/${repo}/actions/workflows/${id}/runs?created=${created}&status=completed&per_page=100&page=${page}`, token);
96
+ if (!data.workflow_runs.length)
97
+ break;
98
+ all.push(...data.workflow_runs);
99
+ }
100
+ const picked = spread(all, k);
101
+ return { count: all.length, estimated: false, runs: picked.map((run) => ({ run, weight: all.length / picked.length })) };
102
+ }
103
+ const width = (now - since) / k;
104
+ const slots = await pool(Array.from({ length: k }, (_, i) => since + i * width), 4, async (start) => {
105
+ const created = encodeURIComponent(`${iso(start)}..${iso(start + width - 1000)}`);
106
+ const data = await gh(`/repos/${repo}/actions/workflows/${id}/runs?created=${created}&status=completed&per_page=100`, token);
107
+ const count = slotCount(data.total_count, data.workflow_runs, width);
108
+ return { count, estimated: data.total_count >= COUNT_CAP, runs: spread(data.workflow_runs, 1).map((run) => ({ run, weight: count })) };
109
+ });
110
+ return { count: slots.reduce((a, s) => a + s.count, 0), estimated: slots.some((s) => s.estimated), runs: slots.flatMap((s) => s.runs) };
111
+ }
112
+ /**
113
+ * About `max` completed runs since `since`, sampled per workflow and weighted so they add up to every run in the window.
114
+ * Busy repos mix 2-second bot workflows with 20-minute test workflows, so samples are split by each workflow's share of
115
+ * CI time (runs x typical length), and each sample stands only for runs of its own workflow.
116
+ * `estimated` means some period passed GitHub's count cap and its count was estimated from run spacing.
117
+ */
118
+ export async function listRuns(repo, token, since, max) {
119
+ // Small enough to take every run: exact, and repeats and push-to-push diffs stay visible.
120
+ const all = [];
121
+ const created = encodeURIComponent(`>=${since.toISOString().slice(0, 10)}`);
122
+ for (let page = 1;; page++) {
123
+ const data = await gh(`/repos/${repo}/actions/runs?created=${created}&status=completed&per_page=100&page=${page}`, token);
124
+ if (data.total_count > max)
125
+ break;
126
+ all.push(...data.workflow_runs);
127
+ if (data.workflow_runs.length < 100)
128
+ return { runs: all.map((run) => ({ run, weight: 1 })), total: all.length, estimated: false };
129
+ }
130
+ const now = Date.now();
131
+ const ids = [];
132
+ for (let page = 1;; page++) {
133
+ const data = await gh(`/repos/${repo}/actions/workflows?per_page=100&page=${page}`, token);
134
+ ids.push(...data.workflows.map((w) => w.id));
135
+ if (data.workflows.length < 100)
136
+ break;
137
+ }
138
+ const firsts = (await pool(ids, 8, async (id) => ({ id, first: await gh(`/repos/${repo}/actions/workflows/${id}/runs?created=${created}&status=completed&per_page=100`, token) }))).filter((w) => w.first.total_count > 0);
139
+ // Share of CI time: run count x average run length from the first page.
140
+ const score = (w) => w.first.total_count * (w.first.workflow_runs.reduce((a, r) => a + wallMs(r), 0) / w.first.workflow_runs.length);
141
+ const ks = allocate(firsts.map((w) => ({ count: w.first.total_count, score: score(w) })), max);
142
+ const sampled = await pool(firsts.map((w, i) => ({ w, k: ks[i] })), 4, ({ w, k }) => sampleWorkflow(repo, token, w.id, w.first, since.getTime(), now, k));
143
+ return {
144
+ runs: sampled.flatMap((s) => s.runs),
145
+ total: sampled.reduce((a, s) => a + s.count, 0),
146
+ estimated: sampled.some((s) => s.estimated),
147
+ };
148
+ }
149
+ /** Every attempt's jobs for a run, so re-runs are counted. */
150
+ export async function listJobs(repo, runId, token) {
151
+ const jobs = [];
152
+ for (let page = 1;; page++) {
153
+ const data = await gh(`/repos/${repo}/actions/runs/${runId}/jobs?filter=all&per_page=100&page=${page}`, token);
154
+ jobs.push(...data.jobs);
155
+ if (data.jobs.length < 100)
156
+ return jobs;
157
+ }
158
+ }
159
+ /** Runs `fn` over `items` with at most `limit` in flight. */
160
+ export async function pool(items, limit, fn) {
161
+ const out = new Array(items.length);
162
+ let next = 0;
163
+ const worker = async () => {
164
+ while (next < items.length) {
165
+ const i = next++;
166
+ out[i] = await fn(items[i]);
167
+ }
168
+ };
169
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
170
+ return out;
171
+ }
package/dist/graph.js ADDED
@@ -0,0 +1,315 @@
1
+ import { readFileSync, realpathSync } from 'node:fs';
2
+ import { join, relative, sep } from 'node:path';
3
+ import { parseSync } from 'oxc-parser';
4
+ import { ResolverFactory } from 'oxc-resolver';
5
+ import { glob } from 'tinyglobby';
6
+ import { isPure } from './purity.js';
7
+ const CODE = '**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}';
8
+ const IGNORE = ['**/node_modules/**', '**/dist/**', '**/build/**', '**/coverage/**', '**/.git/**', '**/*.d.ts'];
9
+ const TEST = /(\.(test|spec)\.[cm]?[jt]sx?$)|(^|\/)__tests__\//;
10
+ // CommonJS requires and test-runner mocks are not in oxc's ESM module record, so pick them up by pattern.
11
+ const EXTRA = /\b(?:require|(?:jest|vi)\.(?:mock|requireActual|importActual))\(\s*['"]([^'"]+)['"]/g;
12
+ // Export conditions every package uses; anything else (e.g. "@zod/source") is a repo's own way to point at source.
13
+ const STANDARD_CONDITIONS = new Set(['import', 'require', 'default', 'node', 'types', 'typings', 'browser', 'module', 'module-sync', 'production', 'development', 'worker', 'deno', 'bun', 'react-native', 'react-server', 'edge-light', 'workerd', 'electron', 'style']);
14
+ const PKG = 'pkg:';
15
+ const BUILD_OUTPUT = /(^|\/)(dist|build|out)\//;
16
+ const ALL = '*';
17
+ /**
18
+ * Where names really come from. A barrel (`export * from './dates'`) re-exports dozens of files; a test that imports
19
+ * `formatDate` through it depends on the file defining formatDate and the barrels on the way, not on every file the
20
+ * barrel exposes. Files with load-time side effects stay linked to everyone who loads them.
21
+ */
22
+ function exportsIndex(infos) {
23
+ const union = (a, b) => {
24
+ const out = a ?? new Set();
25
+ for (const x of b)
26
+ out.add(x);
27
+ return out;
28
+ };
29
+ const wholeMemo = new Map();
30
+ /** The module plus every file it re-exports from, transitively: what `import * as ns` can reach. */
31
+ const whole = (m) => {
32
+ const cached = wholeMemo.get(m);
33
+ if (cached)
34
+ return cached;
35
+ const out = new Set([m]);
36
+ wholeMemo.set(m, out); // seeded first so re-export cycles terminate
37
+ for (const r of infos.get(m)?.reexports ?? [])
38
+ union(out, whole(r.from));
39
+ return out;
40
+ };
41
+ const memo = new Map();
42
+ /** Files that define `name` as exported by `m`, plus the barrels it passes through; undefined if `m` lacks it. */
43
+ const definers = (m, name, seen = new Set()) => {
44
+ const key = `${m}\0${name}`;
45
+ if (memo.has(key))
46
+ return memo.get(key);
47
+ const info = infos.get(m);
48
+ // Unparsed targets (JSON, CSS) and names a module defines itself end here.
49
+ if (!info || m.startsWith(PKG) || info.local.has(name))
50
+ return new Set([m]);
51
+ if (seen.has(m))
52
+ return undefined;
53
+ seen.add(m);
54
+ let out;
55
+ for (const r of info.reexports) {
56
+ if (r.exported === name)
57
+ out = union(out, r.imported === ALL ? whole(r.from) : (definers(r.from, r.imported, seen) ?? whole(r.from)));
58
+ else if (r.exported === ALL && name !== 'default') {
59
+ const found = definers(r.from, name, seen);
60
+ if (found)
61
+ out = union(out, found);
62
+ }
63
+ }
64
+ if (out)
65
+ out.add(m);
66
+ memo.set(key, out);
67
+ return out;
68
+ };
69
+ return { definers, whole };
70
+ }
71
+ // `fx:<file>` stands for what loading <file> does. It changes when <file> has side effects and changes, or when
72
+ // anything <file> loads has side effects that change; a pure file's own edits never reach it.
73
+ const FX = 'fx:';
74
+ const bareName = (spec) => (/^[./]|:/.test(spec) ? undefined : spec.split('/').slice(0, spec.startsWith('@') ? 2 : 1).join('/'));
75
+ export async function buildGraph(dir) {
76
+ // The resolver returns real paths, so a symlinked checkout (e.g. macOS /var -> /private/var) must be compared by its real path.
77
+ const root = realpathSync(dir);
78
+ const files = await glob(CODE, { cwd: root, ignore: IGNORE });
79
+ // Workspace packages by name, so `import 'zod/v4'` resolves to repo source even without node_modules installed.
80
+ const packages = new Map();
81
+ const packageDirs = [];
82
+ const custom = new Set();
83
+ const collect = (o) => {
84
+ if (o && typeof o === 'object')
85
+ for (const [k, v] of Object.entries(o)) {
86
+ if (!k.startsWith('.') && !STANDARD_CONDITIONS.has(k))
87
+ custom.add(k);
88
+ collect(v);
89
+ }
90
+ };
91
+ for (const m of await glob('**/package.json', { cwd: root, ignore: IGNORE })) {
92
+ if (m !== 'package.json')
93
+ packageDirs.push(m.slice(0, -'package.json'.length));
94
+ try {
95
+ const pkg = JSON.parse(readFileSync(join(root, m), 'utf8'));
96
+ if (pkg.name)
97
+ packages.set(pkg.name, m.slice(0, -'package.json'.length));
98
+ collect(pkg.exports);
99
+ }
100
+ catch {
101
+ // unreadable manifest: its package just resolves through node_modules
102
+ }
103
+ }
104
+ const resolver = new ResolverFactory({
105
+ extensions: ['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs', '.json'],
106
+ extensionAlias: { '.js': ['.ts', '.tsx', '.js'], '.mjs': ['.mts', '.mjs'], '.cjs': ['.cts', '.cjs'] },
107
+ conditionNames: [...custom, 'source', 'node', 'import', 'require', 'default'],
108
+ });
109
+ const inRepo = (abs) => {
110
+ const rel = abs && relative(root, abs).split(sep).join('/');
111
+ return rel && !rel.startsWith('..') && !rel.includes('node_modules/') ? rel : undefined;
112
+ };
113
+ // Built output (dist/index.js) is what package exports point at once a build has run; an edit to src/ never reaches it.
114
+ // Map it back to the source file it was compiled from: dist/x.js -> src/x.ts, or x.ts when outDir mirrors the root.
115
+ const toSource = (rel) => {
116
+ for (const swap of ['$1src/', '$1']) {
117
+ const guess = rel.replace(BUILD_OUTPUT, swap).replace(/\.(d\.)?[cm]?js$/, '');
118
+ const hit = inRepo(resolver.sync(root, `./${guess}`).path);
119
+ if (hit && !BUILD_OUTPUT.test(hit))
120
+ return hit;
121
+ }
122
+ };
123
+ const sourceOf = (rel) => (rel && BUILD_OUTPUT.test(rel) ? toSource(rel) : rel);
124
+ const resolve = (abs, spec) => {
125
+ let rel = sourceOf(inRepo(resolver.resolveFileSync(abs, spec).path));
126
+ const pkgDir = rel ? undefined : packages.get(bareName(spec) ?? '');
127
+ if (pkgDir !== undefined) {
128
+ // Resolve as a self-reference from inside the package, which applies its exports with our conditions.
129
+ // Exports that point only at build output fall back to the same path under src/ or the package root,
130
+ // and failing that to the whole package.
131
+ const manifest = join(root, pkgDir, 'package.json');
132
+ const sub = spec.slice(bareName(spec).length);
133
+ rel =
134
+ sourceOf(inRepo(resolver.resolveFileSync(manifest, spec).path)) ??
135
+ inRepo(resolver.resolveFileSync(manifest, `./src${sub}`).path) ??
136
+ inRepo(resolver.resolveFileSync(manifest, `.${sub || '/index'}`).path) ??
137
+ PKG + pkgDir;
138
+ }
139
+ return rel;
140
+ };
141
+ const infos = new Map();
142
+ for (const file of files) {
143
+ const abs = join(root, file);
144
+ let src;
145
+ try {
146
+ src = readFileSync(abs, 'utf8');
147
+ }
148
+ catch {
149
+ continue;
150
+ }
151
+ const parsed = parseSync(file, src);
152
+ const info = { uses: new Map(), loads: new Set(), local: new Set(), reexports: [], pure: isPure(parsed.program) };
153
+ const resolved = new Map();
154
+ const target = (spec) => {
155
+ if (!resolved.has(spec))
156
+ resolved.set(spec, resolve(abs, spec));
157
+ const t = resolved.get(spec);
158
+ if (t)
159
+ info.loads.add(t);
160
+ return t;
161
+ };
162
+ // name: one export it asks for; ALL: the whole module (namespace import, require, dynamic import, mock).
163
+ const use = (spec, name) => {
164
+ const t = target(spec);
165
+ if (!t)
166
+ return;
167
+ const u = info.uses.get(t) ?? info.uses.set(t, { names: new Set(), all: false }).get(t);
168
+ if (name === ALL)
169
+ u.all = true;
170
+ else if (name)
171
+ u.names.add(name);
172
+ };
173
+ const mod = parsed.module;
174
+ for (const i of mod.staticImports) {
175
+ const live = i.entries.filter((e) => !e.isType);
176
+ if (i.entries.length && !live.length)
177
+ continue; // type-only imports are erased before tests run
178
+ if (!live.length)
179
+ use(i.moduleRequest.value); // import './setup': loads it for its effects
180
+ for (const e of live)
181
+ use(i.moduleRequest.value, e.importName.kind === 'Name' ? e.importName.name : e.importName.kind === 'Default' ? 'default' : ALL);
182
+ }
183
+ for (const e of mod.staticExports)
184
+ for (const entry of e.entries) {
185
+ if (entry.isType)
186
+ continue;
187
+ const exported = entry.exportName.kind === 'Name' ? entry.exportName.name : entry.exportName.kind === 'Default' ? 'default' : ALL;
188
+ if (!entry.moduleRequest)
189
+ info.local.add(exported);
190
+ else {
191
+ const from = target(entry.moduleRequest.value);
192
+ // export * as ns / export * : the whole module (all but default for the latter, which `definers` respects)
193
+ if (from)
194
+ info.reexports.push({ from, imported: entry.importName.kind === 'Name' ? entry.importName.name : ALL, exported });
195
+ }
196
+ }
197
+ for (const d of mod.dynamicImports) {
198
+ const text = src.slice(d.moduleRequest.start, d.moduleRequest.end);
199
+ if (/^['"`][^'"`$]*['"`]$/.test(text))
200
+ use(text.slice(1, -1), ALL);
201
+ }
202
+ for (const m of src.matchAll(EXTRA))
203
+ use(m[1], ALL);
204
+ infos.set(file, info);
205
+ }
206
+ const importers = new Map();
207
+ const nodes = new Set(files);
208
+ const edge = (target, from) => {
209
+ if (!target.startsWith(PKG) && !target.startsWith(FX))
210
+ nodes.add(target);
211
+ (importers.get(target) ?? importers.set(target, new Set()).get(target)).add(from);
212
+ };
213
+ const { definers, whole } = exportsIndex(infos);
214
+ for (const [file, info] of infos) {
215
+ if (!info.pure)
216
+ edge(file, FX + file);
217
+ for (const t of info.loads) {
218
+ if (t.startsWith(PKG)) {
219
+ edge(t, file);
220
+ continue;
221
+ }
222
+ // Loading t runs whatever t's loading does, so this file and its own load effects depend on it.
223
+ edge(FX + t, file);
224
+ edge(FX + t, FX + file);
225
+ }
226
+ for (const [t, u] of info.uses) {
227
+ edge(t, file);
228
+ if (t.startsWith(PKG))
229
+ continue;
230
+ const deps = u.all ? whole(t) : new Set([...u.names].flatMap((n) => [...(definers(t, n) ?? whole(t))]));
231
+ for (const d of deps)
232
+ edge(d, file);
233
+ }
234
+ }
235
+ for (const [key, from] of importers) {
236
+ if (!key.startsWith(PKG))
237
+ continue;
238
+ const pkgDir = key.slice(PKG.length);
239
+ // No package entry imports its tests, so a test change never flows through the package.
240
+ for (const n of nodes)
241
+ if (n.startsWith(pkgDir) && !TEST.test(n))
242
+ for (const f of from)
243
+ (importers.get(n) ?? importers.set(n, new Set()).get(n)).add(f);
244
+ importers.delete(key);
245
+ }
246
+ return { tests: new Set(files.filter((f) => TEST.test(f))), importers, nodes, packages: packageDirs.sort((a, b) => b.length - a.length) };
247
+ }
248
+ const CODE_FILE = /\.[cm]?[jt]sx?$/;
249
+ // Changes to these never affect test results. CI workflows change which commands run, not what tests read.
250
+ // Lint, format and editor configs change how code is checked or shown, not what tests do.
251
+ const INERT = new RegExp([
252
+ /\.(md|mdx|txt|png|jpe?g|gif|svg|ico|webp)$/,
253
+ /^(docs?|\.changeset|\.github)\//,
254
+ /(^|\/)(LICENSE|CHANGELOG)[^/]*$/,
255
+ /(^|\/)(\.vscode|\.idea)\//,
256
+ /(^|\/)(\.gitignore|\.editorconfig|\.?oxlintrc[^/]*|\.oxfmtrc[^/]*|biome\.jsonc?|\.eslintrc[^/]*|eslint\.config\.[^/]+|\.prettierrc[^/]*|prettier\.config\.[^/]+|\.prettierignore|knip\.jsonc?|\.lintstagedrc[^/]*|commitlint\.config\.[^/]+)$/,
257
+ ]
258
+ .map((r) => r.source)
259
+ .join('|'), 'i');
260
+ const LOCKFILE = /(^|\/)(pnpm-lock\.yaml|package-lock\.json|yarn\.lock|bun\.lockb?)$/;
261
+ /**
262
+ * Test files a change can reach, or 'all' when we cannot tell (root config, root lockfile alone, unknown root files).
263
+ * A non-code file inside a workspace package (its manifest, tsconfig, fixtures, nested lockfile) affects that package only.
264
+ */
265
+ export function affectedTests(graph, changed) {
266
+ const packageOf = (f) => graph.packages.find((p) => f.startsWith(p));
267
+ const changesManifests = changed.some((f) => f.endsWith('/package.json') && packageOf(f));
268
+ // Packages this change creates: nothing existing imports them, so only their own (changed, seeded) files matter.
269
+ const newPackages = changed
270
+ .filter((f) => /(^|\/)(package|deno|jsr)\.json$/.test(f) && f.includes('/'))
271
+ .map((f) => f.slice(0, f.lastIndexOf('/') + 1))
272
+ .filter((dir) => !graph.packages.includes(dir) && ![...graph.nodes].some((n) => n.startsWith(dir)));
273
+ const seen = new Set();
274
+ const queue = [];
275
+ const seed = (f) => {
276
+ if (!seen.has(f)) {
277
+ seen.add(f);
278
+ queue.push(f);
279
+ }
280
+ };
281
+ for (const f of changed) {
282
+ if (INERT.test(f))
283
+ continue;
284
+ if (graph.nodes.has(f))
285
+ seed(f);
286
+ else if (newPackages.some((dir) => f.startsWith(dir))) {
287
+ if (CODE_FILE.test(f))
288
+ seed(f);
289
+ }
290
+ // A code file the graph lacks is new in this change; only changed files can import it, and they are seeded too.
291
+ else if (CODE_FILE.test(f))
292
+ seed(f);
293
+ else if (packageOf(f)) {
294
+ const dir = packageOf(f);
295
+ for (const n of graph.nodes)
296
+ if (n.startsWith(dir))
297
+ seed(n);
298
+ }
299
+ // estimate-only: a root lockfile changed alongside package manifests is those packages' dependency bump.
300
+ // A real cache must not rely on this; a bump can shift shared transitive dependencies.
301
+ else if (LOCKFILE.test(f) && changesManifests)
302
+ continue;
303
+ else
304
+ return 'all';
305
+ }
306
+ while (queue.length) {
307
+ for (const importer of graph.importers.get(queue.pop()) ?? []) {
308
+ if (!seen.has(importer)) {
309
+ seen.add(importer);
310
+ queue.push(importer);
311
+ }
312
+ }
313
+ }
314
+ return new Set([...seen].filter((f) => graph.tests.has(f) || (TEST.test(f) && !f.startsWith(FX))));
315
+ }
package/dist/purity.js ADDED
@@ -0,0 +1,140 @@
1
+ // Does loading a module do anything besides defining bindings? A barrel loads every file it re-exports, so a file
2
+ // with load-time effects (registering handlers, patching globals, starting clients) can change a test that never
3
+ // names it. Anything we cannot prove harmless counts as a side effect: a wrong "pure" means a wrongly skipped test.
4
+ // Calls on these build values without touching anything else: Object.freeze({...}), new Map(), z.object({...}).
5
+ const PURE_GLOBALS = ['Symbol', 'Object', 'Array', 'Number', 'String', 'Boolean', 'BigInt', 'Math', 'JSON', 'Map', 'Set', 'WeakMap', 'WeakSet', 'RegExp', 'Date', 'URL', 'Error', 'z'];
6
+ // Libraries whose top-level calls define schemas, tables and styles: z.object(), pgTable(), cva().
7
+ const PURE_LIBRARIES = /^(zod(\/.*)?|drizzle-orm(\/.*)?|valibot|@sinclair\/typebox(\/.*)?|yup|superstruct|arktype|io-ts|class-variance-authority)$/;
8
+ // Methods that derive a new schema or column from an existing one: Base.extend({...}), uuid('id').notNull().
9
+ const BUILDER_METHODS = new Set('extend merge pick omit partial deepPartial required optional nullable nullish array and or default describe brand catch readonly refine superRefine transform pipe strict passthrough strip min max length regex email url uuid int positive negative nonnegative nonpositive keyof element unwrap meta notNull primaryKey defaultRandom defaultNow references unique $type $defaultFn $onUpdate $onUpdateFn'.split(' '));
10
+ // Set per file by isPure: globals plus names imported from PURE_LIBRARIES.
11
+ let pureRoots = new Set(PURE_GLOBALS);
12
+ const pureArgs = (e) => e.arguments.every((a) => pureExpr(a.type === 'SpreadElement' ? a.argument : a));
13
+ // A call chain like z.object({...}).extend({...}).optional() is pure when it starts at a pure root and every call's
14
+ // arguments are pure.
15
+ function pureChain(e) {
16
+ switch (e.type) {
17
+ case 'Identifier':
18
+ return pureRoots.has(e.name);
19
+ case 'MemberExpression':
20
+ return pureChain(e.object) && (!e.computed || pureExpr(e.property));
21
+ case 'ChainExpression':
22
+ return pureChain(e.expression);
23
+ case 'CallExpression':
24
+ case 'NewExpression':
25
+ return pureArgs(e) && (pureChain(e.callee) || builderCall(e.callee));
26
+ default:
27
+ return false;
28
+ }
29
+ }
30
+ // OtherSchema.extend({...}).optional(): builder methods on any existing value, ending at a plain identifier.
31
+ function builderCall(callee) {
32
+ if (callee.type !== 'MemberExpression' || callee.computed || !BUILDER_METHODS.has(callee.property.name))
33
+ return false;
34
+ const base = callee.object;
35
+ if (base.type === 'Identifier')
36
+ return true;
37
+ if (base.type === 'MemberExpression')
38
+ return !base.computed && (base.object.type === 'Identifier' || builderCall(base) || pureChain(base));
39
+ return (base.type === 'CallExpression' || base.type === 'NewExpression') && pureChain(base);
40
+ }
41
+ function pureClass(c) {
42
+ if (c.decorators?.length || (c.superClass && !pureExpr(c.superClass)))
43
+ return false;
44
+ return c.body.body.every((m) => {
45
+ if (m.type === 'StaticBlock' || m.decorators?.length)
46
+ return false;
47
+ if (m.computed && !pureExpr(m.key))
48
+ return false;
49
+ // Static field initializers run at load; instance ones only on `new`.
50
+ return !(m.static && m.value && m.type !== 'MethodDefinition' && !pureExpr(m.value));
51
+ });
52
+ }
53
+ export function pureExpr(e) {
54
+ if (!e)
55
+ return true;
56
+ switch (e.type) {
57
+ case 'Literal':
58
+ case 'Identifier':
59
+ case 'ThisExpression':
60
+ case 'MetaProperty':
61
+ case 'ArrowFunctionExpression':
62
+ case 'FunctionExpression':
63
+ case 'Super':
64
+ return true;
65
+ case 'TemplateLiteral':
66
+ return e.expressions.every(pureExpr);
67
+ case 'ClassExpression':
68
+ return pureClass(e);
69
+ case 'ObjectExpression':
70
+ return e.properties.every((p) => (p.type === 'SpreadElement' ? pureExpr(p.argument) : (!p.computed || pureExpr(p.key)) && pureExpr(p.value)));
71
+ case 'ArrayExpression':
72
+ return e.elements.every((x) => !x || pureExpr(x.type === 'SpreadElement' ? x.argument : x));
73
+ case 'UnaryExpression':
74
+ return e.operator !== 'delete' && pureExpr(e.argument);
75
+ case 'BinaryExpression':
76
+ case 'LogicalExpression':
77
+ return pureExpr(e.left) && pureExpr(e.right);
78
+ case 'ConditionalExpression':
79
+ return pureExpr(e.test) && pureExpr(e.consequent) && pureExpr(e.alternate);
80
+ case 'MemberExpression':
81
+ return pureExpr(e.object) && (!e.computed || pureExpr(e.property));
82
+ case 'ChainExpression':
83
+ case 'ParenthesizedExpression':
84
+ case 'TSAsExpression':
85
+ case 'TSSatisfiesExpression':
86
+ case 'TSNonNullExpression':
87
+ case 'TSTypeAssertion':
88
+ case 'TSInstantiationExpression':
89
+ return pureExpr(e.expression);
90
+ case 'CallExpression':
91
+ case 'NewExpression':
92
+ return pureChain(e);
93
+ case 'TaggedTemplateExpression':
94
+ // sql`now()` from drizzle-orm
95
+ return pureChain(e.tag) && e.quasi.expressions.every(pureExpr);
96
+ default:
97
+ return false;
98
+ }
99
+ }
100
+ function pureStatement(s) {
101
+ switch (s.type) {
102
+ case 'ImportDeclaration':
103
+ case 'ExportAllDeclaration':
104
+ case 'FunctionDeclaration':
105
+ case 'TSDeclareFunction':
106
+ case 'TSTypeAliasDeclaration':
107
+ case 'TSInterfaceDeclaration':
108
+ case 'TSEnumDeclaration':
109
+ case 'EmptyStatement':
110
+ return true;
111
+ case 'TSModuleDeclaration':
112
+ return Boolean(s.declare);
113
+ case 'ClassDeclaration':
114
+ return pureClass(s);
115
+ case 'VariableDeclaration':
116
+ return s.declarations.every((d) => pureExpr(d.init));
117
+ case 'ExportNamedDeclaration':
118
+ return !s.declaration || pureStatement(s.declaration);
119
+ case 'ExportDefaultDeclaration': {
120
+ const d = s.declaration;
121
+ return d.type === 'FunctionDeclaration' || d.type === 'TSInterfaceDeclaration' ? true : d.type === 'ClassDeclaration' ? pureClass(d) : pureExpr(d);
122
+ }
123
+ case 'TSExportAssignment':
124
+ return pureExpr(s.expression);
125
+ case 'ExpressionStatement':
126
+ // "use strict" and other directives
127
+ return Boolean(s.directive);
128
+ default:
129
+ return false;
130
+ }
131
+ }
132
+ /** True when the module's top level only defines bindings. */
133
+ export function isPure(program) {
134
+ pureRoots = new Set(PURE_GLOBALS);
135
+ for (const s of program.body)
136
+ if (s.type === 'ImportDeclaration' && PURE_LIBRARIES.test(s.source.value))
137
+ for (const spec of s.specifiers)
138
+ pureRoots.add(spec.local.name);
139
+ return program.body.every(pureStatement);
140
+ }
package/dist/report.js ADDED
@@ -0,0 +1,104 @@
1
+ const LABELS = {
2
+ test: 'test',
3
+ install: 'install',
4
+ build: 'build',
5
+ lint: 'lint & typecheck',
6
+ setup: 'setup',
7
+ other: 'other',
8
+ };
9
+ export function summarize(r) {
10
+ const low = { minutes: r.repeated.minutes + r.affected.minutes, dollars: r.repeated.dollars + r.affected.dollars };
11
+ const high = { minutes: low.minutes + r.install.minutes, dollars: low.dollars + r.install.dollars };
12
+ const billable = r.billable;
13
+ const pct = (a) => (billable.minutes ? (100 * a.minutes) / billable.minutes : 0);
14
+ const notes = [];
15
+ if (r.analyzedRuns < r.totalRuns)
16
+ notes.push(`Sampled ${r.analyzedRuns} of ${r.totalRuns} runs per workflow across the ${r.days} days; each stands in for its workflow's runs around it.`);
17
+ if (r.analyzedRuns < r.totalRuns)
18
+ notes.push('Repeated runs of the same commit are only counted when every run is analyzed; re-runs of jobs that already passed still count.');
19
+ if (r.estimated)
20
+ notes.push("Some periods passed GitHub's 2,500-run count limit; their run counts are estimated from how closely runs are spaced.");
21
+ if (!r.isPrivate)
22
+ notes.push('Public repo: standard runners are free, so dollars are list-price value.');
23
+ if (r.selfHostedMinutes)
24
+ notes.push(`${Math.round(r.selfHostedMinutes)} self-hosted minutes are counted in minutes only.`);
25
+ if (r.shallow)
26
+ notes.push('This clone is shallow, so past changes cannot be replayed and affected-only savings show as 0. Run `git fetch --unshallow` and scan again.');
27
+ else if (r.missingCommits)
28
+ notes.push(`${r.missingCommits} commits could not be fetched (deleted forks or branches), so those runs count as no saving.`);
29
+ notes.push(`Affected-only tests replayed ${r.affected.replayed} of ${r.affected.codeRuns} push and PR runs against the current import graph (${r.testFiles} test files); runs we could not replay count as no saving.`);
30
+ notes.push('Dollars use GitHub list prices; larger runners are priced as 2-core.');
31
+ return {
32
+ billable,
33
+ queueMinutes: r.queueMinutes,
34
+ low,
35
+ high,
36
+ lowPct: pct(low),
37
+ highPct: pct(high),
38
+ parts: { repeated: r.repeated, affected: r.affected, install: r.install },
39
+ notes,
40
+ };
41
+ }
42
+ const min = (n) => `${Math.round(n).toLocaleString('en-US')} min`;
43
+ const usd = (n) => `$${n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
44
+ const both = (a) => `${min(a.minutes)} · ${usd(a.dollars)}`;
45
+ // A range only when the ends differ once rounded; "7–7%" reads like a bug.
46
+ const range = (lo, hi) => (lo === hi ? lo : `${lo}–${hi}`);
47
+ const noRuns = (r) => `${r.repo} had no completed workflow runs in the last ${r.days} days. Try --days 90.`;
48
+ function breakdown(r) {
49
+ const total = Object.values(r.categories).reduce((a, b) => a + b, 0) || 1;
50
+ return Object.entries(r.categories)
51
+ .sort((a, b) => b[1] - a[1])
52
+ .filter(([, v]) => v > 0)
53
+ .map(([k, v]) => ({ label: LABELS[k], share: (100 * v) / total }));
54
+ }
55
+ export function text(r) {
56
+ if (!r.analyzedRuns)
57
+ return noRuns(r);
58
+ const s = summarize(r);
59
+ const rows = breakdown(r).map(({ label, share }) => ` ${label.padEnd(17)}${share.toFixed(0).padStart(3)}% ${'█'.repeat(Math.round(share / 4))}`);
60
+ return [
61
+ '',
62
+ `${r.repo} · last ${r.days} days · ${r.totalRuns} runs`,
63
+ `CI time: ${both(s.billable)} (plus ${min(s.queueMinutes)} waiting in queue)`,
64
+ '',
65
+ 'Where the time goes',
66
+ ...rows,
67
+ '',
68
+ `Estimated savings with ci-sdk: ${range(s.lowPct.toFixed(0), s.highPct.toFixed(0))}% (${range(min(s.low.minutes), min(s.high.minutes))}, ${range(usd(s.low.dollars), usd(s.high.dollars))} per ${r.days} days)`,
69
+ ` repeated work ${both(s.parts.repeated)} same commit run again`,
70
+ ` affected-only tests ${both(s.parts.affected)} tests the change could not reach`,
71
+ ` install cache ${both(s.parts.install)} installs slower than your fastest 10%`,
72
+ '',
73
+ ...s.notes.map((n) => ` · ${n}`),
74
+ '',
75
+ ].join('\n');
76
+ }
77
+ export function markdown(r) {
78
+ if (!r.analyzedRuns)
79
+ return noRuns(r);
80
+ const s = summarize(r);
81
+ return [
82
+ `### CI scan: ${r.repo} (last ${r.days} days)`,
83
+ '',
84
+ `**${r.totalRuns} runs · ${both(s.billable)}** · estimated savings with ci-sdk: **${range(s.lowPct.toFixed(0), s.highPct.toFixed(0))}%** (${range(usd(s.low.dollars), usd(s.high.dollars))})`,
85
+ '',
86
+ '| Where the time goes | Share |',
87
+ '| --- | --- |',
88
+ ...breakdown(r).map(({ label, share }) => `| ${label} | ${share.toFixed(0)}% |`),
89
+ '',
90
+ '| Saving | Minutes · dollars |',
91
+ '| --- | --- |',
92
+ `| Repeated work | ${both(s.parts.repeated)} |`,
93
+ `| Affected-only tests | ${both(s.parts.affected)} |`,
94
+ `| Install cache | ${both(s.parts.install)} |`,
95
+ '',
96
+ ...s.notes.map((n) => `- ${n}`),
97
+ '',
98
+ '_Generated by `npx ci-sdk scan`. Runs locally; no data leaves your machine._',
99
+ '',
100
+ ].join('\n');
101
+ }
102
+ export function json(r) {
103
+ return JSON.stringify({ ...r, summary: summarize(r) }, null, 2);
104
+ }
package/dist/scan.js ADDED
@@ -0,0 +1,157 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { categorize, ratePerMinute } from './classify.js';
3
+ import { getRepo, listJobs, listRuns, pool } from './github.js';
4
+ import { affectedTests, buildGraph } from './graph.js';
5
+ const ms = (a, b) => (a && b ? Math.max(0, Date.parse(b) - Date.parse(a)) : 0);
6
+ const add = (x, minutes, rate) => {
7
+ x.minutes += minutes;
8
+ x.dollars += minutes * (rate ?? 0);
9
+ };
10
+ /** Pure estimate over fetched runs; `scan` handles I/O. */
11
+ export function estimate(data, graph) {
12
+ const e = {
13
+ billable: { minutes: 0, dollars: 0 },
14
+ selfHostedMinutes: 0,
15
+ queueMinutes: 0,
16
+ categories: { setup: 0, install: 0, build: 0, lint: 0, test: 0, other: 0 },
17
+ repeated: { minutes: 0, dollars: 0 },
18
+ affected: { minutes: 0, dollars: 0, replayed: 0, codeRuns: 0 },
19
+ install: { minutes: 0, dollars: 0 },
20
+ };
21
+ const installs = data.flatMap((d) => d.jobs.flatMap((j) => (j.steps ?? []).map((s) => ms(s.started_at, s.completed_at)).filter((d, i) => categorize(j.steps[i].name, d) === 'install')));
22
+ const fastInstall = installs.sort((a, b) => a - b)[Math.floor(installs.length * 0.1)] ?? 0;
23
+ const seenRuns = new Set();
24
+ const ordered = [...data].sort((a, b) => Date.parse(a.run.created_at) - Date.parse(b.run.created_at));
25
+ for (const { run, jobs, changed, weight = 1 } of ordered) {
26
+ // Only code runs repeat cacheable work; comment, schedule and bot runs all sit on the default branch's head.
27
+ const isCode = run.event === 'push' || run.event === 'pull_request';
28
+ const key = `${run.workflow_id}:${run.event}:${run.head_sha}`;
29
+ // A sampled run stands for many, and spotting a repeat needs both runs in the sample: one lucky pair would swing
30
+ // the estimate by thousands of minutes. Only count repeats when every run is present (weight 1).
31
+ const repeatRun = isCode && weight === 1 && seenRuns.has(key);
32
+ seenRuns.add(key);
33
+ const affected = !repeatRun && changed && graph && graph.tests.size ? affectedTests(graph, changed) : undefined;
34
+ const skipShare = affected === undefined || affected === 'all' ? 0 : Math.max(0, 1 - affected.size / graph.tests.size);
35
+ if (affected !== undefined)
36
+ e.affected.replayed++;
37
+ if (isCode)
38
+ e.affected.codeRuns++;
39
+ const passedBefore = new Set();
40
+ for (const job of [...jobs].sort((a, b) => a.run_attempt - b.run_attempt)) {
41
+ const rate = ratePerMinute(job.labels);
42
+ const minutes = (weight * ms(job.started_at, job.completed_at)) / 60000;
43
+ const billable = weight * Math.ceil(minutes / weight);
44
+ add(e.billable, billable, rate);
45
+ if (rate === null)
46
+ e.selfHostedMinutes += billable;
47
+ e.queueMinutes += (weight * ms(job.created_at, job.started_at)) / 60000;
48
+ let testMs = 0;
49
+ let extraInstallMs = 0;
50
+ for (const step of job.steps ?? []) {
51
+ const d = ms(step.started_at, step.completed_at);
52
+ const cat = categorize(step.name, d);
53
+ e.categories[cat] += (weight * d) / 60000;
54
+ if (cat === 'test')
55
+ testMs += d;
56
+ if (cat === 'install')
57
+ extraInstallMs += Math.max(0, d - fastInstall);
58
+ }
59
+ // A repeated run, or a re-run of a job that already passed on the same commit, is a full cache hit.
60
+ if (repeatRun || passedBefore.has(job.name)) {
61
+ add(e.repeated, minutes, rate);
62
+ continue;
63
+ }
64
+ if (job.conclusion === 'success')
65
+ passedBefore.add(job.name);
66
+ add(e.affected, (weight * testMs * skipShare) / 60000, rate);
67
+ add(e.install, (weight * extraInstallMs) / 60000, rate);
68
+ }
69
+ }
70
+ return e;
71
+ }
72
+ const git = (root, args, input) => execFileSync('git', args, { cwd: root, encoding: 'utf8', input, stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'ignore'], maxBuffer: 1 << 26 });
73
+ const isShallow = (root) => {
74
+ try {
75
+ return git(root, ['rev-parse', '--is-shallow-repository']).trim() === 'true';
76
+ }
77
+ catch {
78
+ return false;
79
+ }
80
+ };
81
+ /**
82
+ * Commits the replay needs but this clone lacks (fork PRs, deleted branches), fetched by SHA: objects only, no refs.
83
+ * Returns how many are still missing.
84
+ */
85
+ function fetchMissing(root, shas, log) {
86
+ const wanted = [...new Set(shas)];
87
+ const missing = () => git(root, ['cat-file', '--batch-check'], wanted.map((s) => `${s}^{commit}`).join('\n') + '\n')
88
+ .split('\n')
89
+ .filter((l) => l.endsWith(' missing'))
90
+ .map((l) => l.slice(0, 40));
91
+ const fetch = (list) => {
92
+ try {
93
+ git(root, ['fetch', '--quiet', '--no-tags', '--no-write-fetch-head', 'origin', ...list]);
94
+ return true;
95
+ }
96
+ catch {
97
+ return false;
98
+ }
99
+ };
100
+ let todo = missing();
101
+ if (!todo.length)
102
+ return 0;
103
+ log(`Fetching ${todo.length} commits this clone lacks…`);
104
+ for (let i = 0; i < todo.length; i += 200) {
105
+ const chunk = todo.slice(i, i + 200);
106
+ // One unknown SHA fails the whole batch, so retry those one by one.
107
+ if (!fetch(chunk))
108
+ for (const sha of chunk.slice(0, 100))
109
+ fetch([sha]);
110
+ }
111
+ todo = missing();
112
+ return todo.length;
113
+ }
114
+ function changedFiles(root, base, head) {
115
+ try {
116
+ const out = execFileSync('git', ['diff', '--name-only', `${base}...${head}`], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
117
+ return out.split('\n').filter(Boolean);
118
+ }
119
+ catch {
120
+ return undefined; // commit not in the local clone
121
+ }
122
+ }
123
+ /** What each run tested: the PR diff against its base, or a push diffed against the previous push run on that branch. */
124
+ function attachChanges(root, data, defaultBranch) {
125
+ const ordered = [...data].sort((a, b) => Date.parse(a.run.created_at) - Date.parse(b.run.created_at));
126
+ const lastPush = new Map();
127
+ for (const d of ordered) {
128
+ const { run } = d;
129
+ if (run.event === 'pull_request') {
130
+ // Fork PRs come without pull_requests; their merge-base with the default branch gives the same diff.
131
+ d.changed = changedFiles(root, run.pull_requests[0]?.base.sha ?? `origin/${defaultBranch}`, run.head_sha);
132
+ }
133
+ else if (run.event === 'push') {
134
+ const branch = `${run.workflow_id}:${run.head_branch}`;
135
+ const prev = lastPush.get(branch);
136
+ if (prev && prev !== run.head_sha)
137
+ d.changed = changedFiles(root, prev, run.head_sha);
138
+ lastPush.set(branch, run.head_sha);
139
+ }
140
+ }
141
+ }
142
+ export async function scan(o) {
143
+ const since = new Date(Date.now() - o.days * 864e5);
144
+ const info = await getRepo(o.repo, o.token);
145
+ o.log(`Fetching workflow runs for ${info.full_name} since ${since.toISOString().slice(0, 10)}…`);
146
+ const { runs, total, estimated } = await listRuns(o.repo, o.token, since, o.maxRuns);
147
+ o.log(`Fetching jobs for ${runs.length} runs sampled from ${total}…`);
148
+ const data = await pool(runs, 8, async ({ run, weight }) => ({ run, weight, jobs: await listJobs(o.repo, run.id, o.token) }));
149
+ o.log('Building import graph with oxc…');
150
+ const graph = await buildGraph(o.root);
151
+ const shallow = isShallow(o.root);
152
+ const code = data.filter((d) => d.run.event === 'push' || d.run.event === 'pull_request');
153
+ const missingCommits = shallow ? 0 : fetchMissing(o.root, code.flatMap((d) => [d.run.head_sha, ...d.run.pull_requests.map((p) => p.base.sha)]), o.log);
154
+ attachChanges(o.root, data, info.default_branch);
155
+ const e = estimate(data, graph);
156
+ return { repo: info.full_name, isPrivate: info.private, days: o.days, totalRuns: total, estimated, analyzedRuns: runs.length, testFiles: graph.tests.size, shallow, missingCommits, ...e };
157
+ }
package/dist/test.js ADDED
@@ -0,0 +1,101 @@
1
+ import { execFileSync, spawnSync } from 'node:child_process';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { affectedTests, buildGraph } from './graph.js';
5
+ const git = (root, args) => execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 1 << 26 });
6
+ const lines = (s) => s.split('\n').filter(Boolean);
7
+ export const repoRoot = (cwd) => git(cwd, ['rev-parse', '--show-toplevel']).trim();
8
+ /** Files changed since `since` (committed on this branch), plus uncommitted and untracked changes. */
9
+ export function changedFiles(root, since = 'HEAD') {
10
+ const committed = since === 'HEAD' ? [] : lines(git(root, ['diff', '--name-only', `${since}...HEAD`]));
11
+ return [...new Set([...committed, ...lines(git(root, ['diff', '--name-only', 'HEAD'])), ...lines(git(root, ['ls-files', '--others', '--exclude-standard']))])];
12
+ }
13
+ const packageManager = (root) => existsSync(join(root, 'pnpm-lock.yaml')) ? 'pnpm' : existsSync(join(root, 'yarn.lock')) ? 'yarn' : existsSync(join(root, 'bun.lock')) || existsSync(join(root, 'bun.lockb')) ? 'bun' : 'npm';
14
+ // How each package manager appends extra arguments to a package script.
15
+ const runScript = (pm, script, args) => (pm === 'npm' ? ['npm', ['run', script, '--', ...args]] : [pm, ['run', script, ...args]]);
16
+ const readJson = (file) => {
17
+ try {
18
+ return JSON.parse(readFileSync(file, 'utf8'));
19
+ }
20
+ catch {
21
+ return undefined;
22
+ }
23
+ };
24
+ /** Affected test files grouped by the workspace package that owns them, each run through that package's own test script. */
25
+ export function plan(root, graph, tests) {
26
+ const byDir = new Map();
27
+ for (const t of tests) {
28
+ const dir = graph.packages.find((p) => t.startsWith(p)) ?? '';
29
+ (byDir.get(dir) ?? byDir.set(dir, []).get(dir)).push(t.slice(dir.length));
30
+ }
31
+ return [...byDir]
32
+ .map(([dir, files]) => {
33
+ const pkg = readJson(join(root, dir, 'package.json'));
34
+ // A root script that fans out to every package (turbo, nx, lerna, pnpm -r) can't be narrowed to files.
35
+ const script = pkg?.scripts?.test ?? '';
36
+ const fansOut = !dir && /\b(turbo|nx|lerna)\b|\s-r\b|--recursive|--workspaces?\b/.test(script);
37
+ return { dir, name: pkg?.name ?? (dir || '(root)'), files: files.sort(), hasTestScript: Boolean(script) && !fansOut };
38
+ })
39
+ .sort((a, b) => a.name.localeCompare(b.name));
40
+ }
41
+ /** Turbo repos whose test task depends on ^build test against built dependencies, so rebuild those first (cached when unchanged). */
42
+ function buildCommand(root, pm, runs) {
43
+ const deps = readJson(join(root, 'turbo.json'))?.tasks?.test?.dependsOn ?? [];
44
+ if (!deps.includes('^build'))
45
+ return undefined;
46
+ const filters = runs.filter((r) => r.dir).map((r) => `--filter=${r.name}^...`);
47
+ if (!filters.length)
48
+ return undefined;
49
+ return pm === 'npm' ? ['npx', ['turbo', 'run', 'build', ...filters]] : [pm, ['exec', 'turbo', 'run', 'build', ...filters]];
50
+ }
51
+ /** Runs only the tests a change can reach. Returns the process exit code. */
52
+ export async function testAffected(o) {
53
+ const root = repoRoot(o.cwd);
54
+ const changed = changedFiles(root, o.since);
55
+ if (!changed.length) {
56
+ o.log(`No changes since ${o.since ?? 'HEAD'}; nothing to test.`);
57
+ return 0;
58
+ }
59
+ const graph = await buildGraph(root);
60
+ const affected = affectedTests(graph, changed);
61
+ const pm = packageManager(root);
62
+ const exec = ([cmd, args], cwd) => {
63
+ o.log(`$ ${cmd} ${args.join(' ')}${cwd === root ? '' : ` (in ${cwd.slice(root.length + 1)})`}`);
64
+ return spawnSync(cmd, args, { cwd, stdio: 'inherit' }).status ?? 1;
65
+ };
66
+ if (affected === 'all') {
67
+ const why = changed.filter((f) => affectedTests(graph, [f]) === 'all');
68
+ o.log(`${changed.length} changed files; ${why.slice(0, 3).join(', ')} can affect every test, so running the full suite.`);
69
+ if (o.list) {
70
+ for (const t of [...graph.tests].sort())
71
+ console.log(t);
72
+ return 0;
73
+ }
74
+ return exec(runScript(pm, 'test', []), root);
75
+ }
76
+ const runs = plan(root, graph, affected);
77
+ o.log(`${changed.length} changed files → ${affected.size} of ${graph.tests.size} test files in ${runs.length} packages`);
78
+ if (o.list) {
79
+ for (const t of [...affected].sort())
80
+ console.log(t);
81
+ return 0;
82
+ }
83
+ if (!affected.size)
84
+ return 0;
85
+ const build = buildCommand(root, pm, runs);
86
+ if (build && exec(build, root) !== 0) {
87
+ o.log('Building dependencies failed; not running tests.');
88
+ return 1;
89
+ }
90
+ let failed = 0;
91
+ for (const r of runs) {
92
+ if (!r.hasTestScript) {
93
+ o.log(`${r.name} has no test script that runs single files; skipped ${r.files.length} test files: ${r.files.slice(0, 3).join(', ')}`);
94
+ continue;
95
+ }
96
+ if (exec(runScript(pm, 'test', r.files), join(root, r.dir)) !== 0)
97
+ failed++;
98
+ }
99
+ o.log(failed ? `${failed} of ${runs.length} packages had failing tests.` : `All affected tests passed (${affected.size} of ${graph.tests.size} test files).`);
100
+ return failed ? 1 : 0;
101
+ }
package/package.json CHANGED
@@ -1,6 +1,33 @@
1
1
  {
2
2
  "name": "ci-sdk",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.0.2",
4
+ "description": "Insanely fast CI. No migration. By Diffling.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "homepage": "https://ci-sdk.com",
8
+ "bin": {
9
+ "ci-sdk": "dist/cli.js"
10
+ },
11
+ "files": [
12
+ "dist",
13
+ "README.md",
14
+ "LICENSE"
15
+ ],
16
+ "engines": {
17
+ "node": ">=22.18"
18
+ },
19
+ "scripts": {
20
+ "build": "tsc",
21
+ "test": "node --test test/*.test.ts",
22
+ "prepublishOnly": "npm run build && npm test"
23
+ },
24
+ "dependencies": {
25
+ "oxc-parser": "^0.156.0",
26
+ "oxc-resolver": "^11.24.2",
27
+ "tinyglobby": "^0.2.17"
28
+ },
29
+ "devDependencies": {
30
+ "@types/node": "^26.6.6",
31
+ "typescript": "^7.0.2"
32
+ }
33
+ }