ci-sdk 0.0.1 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/run.js ADDED
@@ -0,0 +1,257 @@
1
+ import { execFileSync, spawn } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
3
+ import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
4
+ import { availableParallelism } from 'node:os';
5
+ import { delimiter, dirname, join } from 'node:path';
6
+ import { glob } from 'tinyglobby';
7
+ import { pnpmLockKeys } from './lockfile.js';
8
+ import { packageManager, readJson, repoRoot, runScript } from './test.js';
9
+ // Bump when the key recipe changes, so old entries can never hit.
10
+ const VERSION = '2';
11
+ const gitZ = (root, args) => execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 1 << 28 }).split('\0').filter(Boolean);
12
+ /** Workspace packages from pnpm-workspace.yaml or package.json `workspaces`; a repo without either is one package. */
13
+ export async function workspace(root, scriptName) {
14
+ const rootPkg = readJson(join(root, 'package.json')) ?? {};
15
+ let patterns = Array.isArray(rootPkg.workspaces) ? rootPkg.workspaces : (rootPkg.workspaces?.packages ?? []);
16
+ const yaml = existsSync(join(root, 'pnpm-workspace.yaml')) ? readFileSync(join(root, 'pnpm-workspace.yaml'), 'utf8') : '';
17
+ // shortcut: reads only the `packages:` list of pnpm-workspace.yaml; use a YAML parser if repos put it in flow style.
18
+ const block = yaml.match(/^packages:\s*\n((?:\s+-.*\n?|\s*#.*\n?|\s*\n)*)/m)?.[1] ?? '';
19
+ patterns = [...patterns, ...[...block.matchAll(/^\s+-\s*['"]?([^'"#\n]+?)['"]?\s*$/gm)].map((m) => m[1])];
20
+ const manifests = patterns.length
21
+ ? await glob(patterns.filter((p) => !p.startsWith('!')).map((p) => `${p.replace(/\/$/, '')}/package.json`), { cwd: root, ignore: ['**/node_modules/**', ...patterns.filter((p) => p.startsWith('!')).map((p) => p.slice(1))] })
22
+ : [];
23
+ const dirs = manifests.length ? manifests.map((m) => m.slice(0, -'package.json'.length)) : [''];
24
+ const pkgs = dirs.map((dir) => ({ dir, json: readJson(join(root, dir, 'package.json')) ?? {} }));
25
+ const names = new Set(pkgs.map((p) => p.json.name).filter(Boolean));
26
+ return pkgs.map(({ dir, json }) => ({
27
+ dir,
28
+ name: json.name ?? (dir || '(root)'),
29
+ script: json.scripts?.[scriptName],
30
+ deps: Object.keys({ ...json.dependencies, ...json.devDependencies, ...json.peerDependencies, ...json.optionalDependencies }).filter((d) => names.has(d)),
31
+ }));
32
+ }
33
+ const sha = (...parts) => {
34
+ const h = createHash('sha256');
35
+ for (const p of parts)
36
+ h.update(p).update('\0');
37
+ return h.digest('hex');
38
+ };
39
+ // Files outside every package that no package script reads: docs, changelogs, licenses, CI and editor config.
40
+ // Without this, a CHANGELOG or docs edit reruns everything. A package's own README or LICENSE still counts, since it is
41
+ // inside the package. shortcut: a build that copies a root README/LICENSE into its output will not rerun when that file changes.
42
+ const READ_BY_NO_SCRIPT = /(^|\/)(LICEN[CS]E[^/]*|[^/]+\.mdx?)$|^\.(github|changeset|vscode|idea)\//i;
43
+ // Release notes no build reads, even inside a package (other markdown there counts: docs packages build from it).
44
+ const RELEASE_NOTES = /(^|\/)(CHANGELOG[^/]*\.mdx?|\.changeset\/.+|\.changes\/.+)$/i;
45
+ /**
46
+ * One key per package: its own files, every file outside all packages (root configs, scripts), its dependency tree from
47
+ * the lockfile, and the keys of the workspace packages it depends on. Conservative on purpose: a root config change reruns everything.
48
+ */
49
+ export function inputKeys(root, pkgs, salt) {
50
+ const files = gitZ(root, ['ls-files', '-z', '--cached', '--others', '--exclude-standard']);
51
+ const deleted = new Set(gitZ(root, ['ls-files', '-z', '--deleted']));
52
+ const present = files.filter((f) => !deleted.has(f));
53
+ // git hash-object fails on symlinks to folders and on submodule folders, so hash those by their link target or kind.
54
+ const hashes = new Map();
55
+ const regular = [];
56
+ for (const f of present) {
57
+ const s = lstatSync(join(root, f), { throwIfNoEntry: false });
58
+ if (s?.isFile())
59
+ regular.push(f);
60
+ else
61
+ hashes.set(f, s?.isSymbolicLink() ? `link:${readlinkSync(join(root, f))}` : s ? 'dir' : 'missing');
62
+ }
63
+ if (regular.length) {
64
+ const out = execFileSync('git', ['hash-object', '--stdin-paths'], { cwd: root, input: regular.join('\n'), encoding: 'utf8', maxBuffer: 1 << 28 }).trim().split('\n');
65
+ regular.forEach((f, i) => hashes.set(f, out[i]));
66
+ }
67
+ // Deepest folder first, so a nested package owns its files.
68
+ const owners = pkgs.filter((p) => p.dir).sort((a, b) => b.dir.length - a.dir.length);
69
+ const own = new Map(pkgs.map((p) => [p.dir, []]));
70
+ const outside = [];
71
+ // pnpm: key each package on its own dependency tree instead of the whole lockfile, so a lockfile change reruns only
72
+ // packages whose dependencies changed. Root dependencies count for every package, since package scripts use them.
73
+ const lock = present.includes('pnpm-lock.yaml') ? pnpmLockKeys(readFileSync(join(root, 'pnpm-lock.yaml'), 'utf8')) : undefined;
74
+ const treeOf = (dir) => [lock?.byImporter.get('.') ?? '', lock?.byImporter.get(dir.replace(/\/$/, '') || '.') ?? ''];
75
+ present.forEach((f) => {
76
+ if (RELEASE_NOTES.test(f) || (lock && f === 'pnpm-lock.yaml'))
77
+ return;
78
+ const entry = `${f}:${hashes.get(f)}`;
79
+ const owner = owners.find((p) => f.startsWith(p.dir));
80
+ if (owner)
81
+ own.get(owner.dir).push(entry);
82
+ else if (!READ_BY_NO_SCRIPT.test(f))
83
+ outside.push(entry);
84
+ });
85
+ if (own.has(''))
86
+ own.get('').push(...outside);
87
+ const shared = sha(...outside, lock?.shared ?? '');
88
+ const byName = new Map(pkgs.map((p) => [p.name, p]));
89
+ const keys = new Map();
90
+ const keyOf = (p, seen) => {
91
+ if (keys.has(p.dir))
92
+ return keys.get(p.dir);
93
+ if (seen.has(p.dir))
94
+ return 'cycle'; // dependency cycles: each member still hashes its own files
95
+ seen.add(p.dir);
96
+ const key = sha(VERSION, salt, shared, ...treeOf(p.dir), ...own.get(p.dir), ...p.deps.sort().map((d) => keyOf(byName.get(d), seen)));
97
+ keys.set(p.dir, key);
98
+ return key;
99
+ };
100
+ for (const p of pkgs)
101
+ keyOf(p, new Set());
102
+ return keys;
103
+ }
104
+ /** Files under `dir` that git does not track (outputs live there), with size and mtime; node_modules excluded. */
105
+ function untracked(root, dir) {
106
+ const files = gitZ(root, ['ls-files', '-z', '--others', '--', dir || '.', ':(exclude,glob)**/node_modules/**']);
107
+ const stamp = new Map();
108
+ for (const f of files) {
109
+ try {
110
+ const s = statSync(join(root, f));
111
+ stamp.set(f, `${s.size}:${s.mtimeMs}`);
112
+ }
113
+ catch {
114
+ // vanished while listing
115
+ }
116
+ }
117
+ return stamp;
118
+ }
119
+ /**
120
+ * Runs the script with sh the way npm and pnpm do, skipping a package-manager launch (~0.35s per package, which adds up
121
+ * along a dependency chain). Falls back to `<pm> run` where that isn't equivalent: yarn (PnP), bun, Windows, pre/post scripts.
122
+ */
123
+ export function scriptCommand(pm, root, dir, script) {
124
+ const json = readJson(join(root, dir, 'package.json')) ?? {};
125
+ const body = json.scripts?.[script];
126
+ if (!body || (pm !== 'npm' && pm !== 'pnpm') || process.platform === 'win32' || json.scripts?.[`pre${script}`] || json.scripts?.[`post${script}`])
127
+ return [...runScript(pm, script, []), process.env];
128
+ const bins = [];
129
+ for (let d = join(root, dir);; d = dirname(d)) {
130
+ bins.push(join(d, 'node_modules/.bin'));
131
+ if (d === root || d === dirname(d))
132
+ break;
133
+ }
134
+ const env = {
135
+ ...process.env,
136
+ PATH: [...bins, process.env.PATH].join(delimiter),
137
+ npm_lifecycle_event: script,
138
+ npm_lifecycle_script: body,
139
+ npm_package_name: json.name,
140
+ npm_package_version: json.version,
141
+ npm_package_json: join(root, dir, 'package.json'),
142
+ // Tools such as wireit pick the package manager to spawn from this prefix.
143
+ npm_config_user_agent: process.env.npm_config_user_agent?.startsWith(`${pm}/`) ? process.env.npm_config_user_agent : `${pm}/unknown node/${process.version} ${process.platform} ${process.arch}`,
144
+ INIT_CWD: process.env.INIT_CWD ?? process.cwd(),
145
+ };
146
+ return ['sh', ['-c', body], env];
147
+ }
148
+ /** Runs a package script in every workspace package (dependencies first), skipping packages whose inputs already passed. */
149
+ export async function runCached(o) {
150
+ const root = repoRoot(o.cwd);
151
+ const pm = packageManager(root);
152
+ const all = await workspace(root, o.script);
153
+ const allowed = o.filter?.length ? new Set((await glob(o.filter.map((f) => `${f.replace(/\/$/, '')}/package.json`), { cwd: root, ignore: ['**/node_modules/**'] })).map((m) => m.slice(0, -'package.json'.length))) : undefined;
154
+ const todo = all.filter((p) => p.script && (!allowed || allowed.has(p.dir)));
155
+ if (!todo.length) {
156
+ o.log(`No package has a "${o.script}" script.`);
157
+ return 0;
158
+ }
159
+ const node = process.version;
160
+ const keys = inputKeys(root, all, [o.script, node, process.platform, process.arch].join('|'));
161
+ // One folder per script, so pruning after `run build` never touches the entries of `run test`.
162
+ const cache = join(o.cacheDir ?? join(root, 'node_modules/.cache/ci-sdk'), encodeURIComponent(o.script));
163
+ const used = new Set();
164
+ const byName = new Map(all.map((p) => [p.name, p]));
165
+ const waitingOn = new Map(todo.map((p) => [p.dir, new Set(p.deps.map((d) => byName.get(d).dir).filter((d) => todo.some((t) => t.dir === d && d !== p.dir)))]));
166
+ const done = new Set();
167
+ const failed = [];
168
+ const failedDirs = new Set();
169
+ let hits = 0;
170
+ const started = Date.now();
171
+ const runOne = async (p) => {
172
+ const key = sha(keys.get(p.dir), p.script);
173
+ const dir = join(cache, key.slice(0, 2), key);
174
+ const meta = join(dir, 'meta.json');
175
+ used.add(join(key.slice(0, 2), key));
176
+ if (!o.noCache && existsSync(meta)) {
177
+ const entry = JSON.parse(readFileSync(meta, 'utf8'));
178
+ for (const f of entry.outputs) {
179
+ mkdirSync(dirname(join(root, f)), { recursive: true });
180
+ cpSync(join(dir, 'out', f), join(root, f));
181
+ }
182
+ hits++;
183
+ o.log(`${p.name}: cache hit, ${entry.outputs.length} outputs restored (saved ${(entry.ms / 1000).toFixed(1)}s)`);
184
+ process.stdout.write(entry.log);
185
+ return;
186
+ }
187
+ const before = untracked(root, p.dir);
188
+ const t = Date.now();
189
+ const [cmd, args, env] = scriptCommand(pm, root, p.dir, o.script);
190
+ let log = '';
191
+ const code = await new Promise((resolve) => {
192
+ const child = spawn(cmd, args, { cwd: join(root, p.dir), env, stdio: ['ignore', 'pipe', 'pipe'] });
193
+ child.stdout.on('data', (d) => (log += d));
194
+ child.stderr.on('data', (d) => (log += d));
195
+ child.on('close', (c) => resolve(c ?? 1));
196
+ });
197
+ const ms = Date.now() - t;
198
+ o.log(`${p.name}: ran in ${(ms / 1000).toFixed(1)}s${code ? `, failed with exit code ${code}` : ''}`);
199
+ process.stdout.write(log);
200
+ if (code) {
201
+ failed.push(p.name);
202
+ failedDirs.add(p.dir);
203
+ return;
204
+ }
205
+ // Outputs are whatever the script created or changed outside git, inferred with no config.
206
+ const outputs = [...untracked(root, p.dir)].filter(([f, s]) => before.get(f) !== s).map(([f]) => f);
207
+ const tmp = `${dir}.tmp-${process.pid}`;
208
+ rmSync(tmp, { recursive: true, force: true });
209
+ for (const f of outputs) {
210
+ mkdirSync(dirname(join(tmp, 'out', f)), { recursive: true });
211
+ cpSync(join(root, f), join(tmp, 'out', f));
212
+ }
213
+ mkdirSync(tmp, { recursive: true });
214
+ writeFileSync(join(tmp, 'meta.json'), JSON.stringify({ log, outputs, ms }));
215
+ rmSync(dir, { recursive: true, force: true });
216
+ mkdirSync(dirname(dir), { recursive: true });
217
+ renameSync(tmp, dir); // atomic: a half-written entry is never read
218
+ };
219
+ // Dependencies first, up to `concurrency` packages at a time, like `pnpm -r`.
220
+ const limit = o.concurrency ?? Math.max(1, Math.min(4, availableParallelism()));
221
+ const running = new Map();
222
+ while (done.size < todo.length) {
223
+ const blocked = (p) => [...waitingOn.get(p.dir)].some((d) => !done.has(d));
224
+ for (const p of todo) {
225
+ if (running.size >= limit)
226
+ break;
227
+ if (done.has(p.dir) || running.has(p.dir) || blocked(p))
228
+ continue;
229
+ if ([...waitingOn.get(p.dir)].some((d) => failedDirs.has(d))) {
230
+ o.log(`${p.name}: skipped, a dependency failed`);
231
+ failed.push(p.name);
232
+ failedDirs.add(p.dir);
233
+ done.add(p.dir);
234
+ continue;
235
+ }
236
+ running.set(p.dir, runOne(p).then(() => void done.add(p.dir), () => void (failed.push(p.name), failedDirs.add(p.dir), done.add(p.dir))));
237
+ }
238
+ if (!running.size) {
239
+ // A dependency cycle: run the rest in order rather than hang.
240
+ for (const p of todo.filter((p) => !done.has(p.dir)))
241
+ waitingOn.set(p.dir, new Set());
242
+ continue;
243
+ }
244
+ await Promise.race(running.values());
245
+ for (const d of [...running.keys()])
246
+ if (done.has(d))
247
+ running.delete(d);
248
+ }
249
+ if (o.prune ?? Boolean(process.env.CI)) {
250
+ for (const shard of existsSync(cache) ? readdirSync(cache) : [])
251
+ for (const entry of readdirSync(join(cache, shard)))
252
+ if (!used.has(join(shard, entry)))
253
+ rmSync(join(cache, shard, entry), { recursive: true, force: true });
254
+ }
255
+ o.log(`${o.script}: ${todo.length} packages, ${hits} from cache, ${failed.length} failed, ${((Date.now() - started) / 1000).toFixed(1)}s`);
256
+ return failed.length ? 1 : 0;
257
+ }
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,112 @@
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
+ export 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
+ export const runScript = (pm, script, args) => (pm === 'npm' ? ['npm', ['run', script, '--', ...args]] : [pm, ['run', script, ...args]]);
16
+ export const readJson = (file) => {
17
+ try {
18
+ return JSON.parse(readFileSync(file, 'utf8'));
19
+ }
20
+ catch {
21
+ return undefined;
22
+ }
23
+ };
24
+ // A script that fans out to every package (turbo, nx, lerna, pnpm -r) can't be narrowed to files.
25
+ const FANS_OUT = /\b(turbo|nx|lerna)\b|\s-r\b|--recursive|--workspaces?\b/;
26
+ // File paths appended to a chained script reach only its last command.
27
+ const CHAINED = /&&|\|\||;/;
28
+ const takesFiles = (script) => Boolean(script) && !FANS_OUT.test(script) && !CHAINED.test(script);
29
+ /** Affected test files grouped by the workspace package that owns them; each group runs through its own script, else the root's. */
30
+ export function plan(root, graph, tests, script = 'test') {
31
+ const byDir = new Map();
32
+ for (const t of tests) {
33
+ const dir = graph.packages.find((p) => t.startsWith(p)) ?? '';
34
+ (byDir.get(dir) ?? byDir.set(dir, []).get(dir)).push(t.slice(dir.length));
35
+ }
36
+ const rootTakesFiles = takesFiles(readJson(join(root, 'package.json'))?.scripts?.[script]);
37
+ return [...byDir]
38
+ .map(([dir, files]) => {
39
+ const pkg = readJson(join(root, dir, 'package.json'));
40
+ const via = dir && takesFiles(pkg?.scripts?.[script]) ? 'package' : rootTakesFiles ? 'root' : undefined;
41
+ return { dir, name: pkg?.name ?? (dir || '(root)'), files: files.sort(), via };
42
+ })
43
+ .sort((a, b) => a.name.localeCompare(b.name));
44
+ }
45
+ /** Turbo repos whose test task depends on ^build test against built dependencies, so rebuild those first (cached when unchanged). */
46
+ function buildCommand(root, pm, runs) {
47
+ const deps = readJson(join(root, 'turbo.json'))?.tasks?.test?.dependsOn ?? [];
48
+ if (!deps.includes('^build'))
49
+ return undefined;
50
+ const filters = runs.filter((r) => r.dir).map((r) => `--filter=${r.name}^...`);
51
+ if (!filters.length)
52
+ return undefined;
53
+ return pm === 'npm' ? ['npx', ['turbo', 'run', 'build', ...filters]] : [pm, ['exec', 'turbo', 'run', 'build', ...filters]];
54
+ }
55
+ /** Runs only the tests a change can reach. Returns the process exit code. */
56
+ export async function testAffected(o) {
57
+ const root = repoRoot(o.cwd);
58
+ const changed = changedFiles(root, o.since);
59
+ if (!changed.length) {
60
+ o.log(`No changes since ${o.since ?? 'HEAD'}; nothing to test.`);
61
+ return 0;
62
+ }
63
+ const graph = await buildGraph(root);
64
+ const affected = affectedTests(graph, changed);
65
+ const pm = packageManager(root);
66
+ const exec = ([cmd, args], cwd) => {
67
+ o.log(`$ ${cmd} ${args.join(' ')}${cwd === root ? '' : ` (in ${cwd.slice(root.length + 1)})`}`);
68
+ return spawnSync(cmd, args, { cwd, stdio: 'inherit' }).status ?? 1;
69
+ };
70
+ if (affected === 'all') {
71
+ const why = changed.filter((f) => affectedTests(graph, [f]) === 'all');
72
+ o.log(`${changed.length} changed files; ${why.slice(0, 3).join(', ')} can affect every test, so running the full suite.`);
73
+ if (o.list) {
74
+ for (const t of [...graph.tests].sort())
75
+ console.log(t);
76
+ return 0;
77
+ }
78
+ return exec(runScript(pm, o.script ?? 'test', []), root);
79
+ }
80
+ const script = o.script ?? 'test';
81
+ const runs = plan(root, graph, affected, script);
82
+ o.log(`${changed.length} changed files → ${affected.size} of ${graph.tests.size} test files in ${runs.length} packages`);
83
+ if (o.list) {
84
+ for (const t of [...affected].sort())
85
+ console.log(t);
86
+ return 0;
87
+ }
88
+ if (!affected.size)
89
+ return 0;
90
+ const build = buildCommand(root, pm, runs);
91
+ if (build && exec(build, root) !== 0) {
92
+ o.log('Building dependencies failed; not running tests.');
93
+ return 1;
94
+ }
95
+ // Never report success for tests that did not run.
96
+ const stranded = runs.filter((r) => !r.via);
97
+ if (stranded.length) {
98
+ for (const r of stranded)
99
+ o.log(`${r.name}: no "${script}" script here or at the root that accepts file paths, so ${r.files.length} affected test files can't run: ${r.files.slice(0, 3).join(', ')}`);
100
+ o.log(`Not running a partial suite. Pass --script <name> for a script that takes test files, or run the full suite.`);
101
+ return 1;
102
+ }
103
+ let failed = 0;
104
+ for (const r of runs.filter((r) => r.via === 'package'))
105
+ if (exec(runScript(pm, script, r.files), join(root, r.dir)) !== 0)
106
+ failed++;
107
+ const viaRoot = runs.filter((r) => r.via === 'root').flatMap((r) => r.files.map((f) => r.dir + f));
108
+ if (viaRoot.length && exec(runScript(pm, script, viaRoot), root) !== 0)
109
+ failed++;
110
+ o.log(failed ? 'Some affected tests failed.' : `All affected tests passed (${affected.size} of ${graph.tests.size} test files).`);
111
+ return failed ? 1 : 0;
112
+ }
package/package.json CHANGED
@@ -1,7 +1,37 @@
1
1
  {
2
2
  "name": "ci-sdk",
3
- "version": "0.0.1",
4
- "description": "Placeholder — coming soon.",
3
+ "version": "0.0.3",
4
+ "description": "Insanely fast CI. No migration. By Diffling.",
5
5
  "license": "MIT",
6
- "files": ["README.md"]
6
+ "type": "module",
7
+ "homepage": "https://ci-sdk.com",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/jaymalve/ci-sdk.git"
11
+ },
12
+ "bin": {
13
+ "ci-sdk": "dist/cli.js"
14
+ },
15
+ "files": [
16
+ "dist",
17
+ "README.md",
18
+ "LICENSE"
19
+ ],
20
+ "engines": {
21
+ "node": ">=22.18"
22
+ },
23
+ "scripts": {
24
+ "build": "tsc",
25
+ "test": "node --test test/*.test.ts",
26
+ "prepublishOnly": "npm run build && npm test"
27
+ },
28
+ "dependencies": {
29
+ "oxc-parser": "^0.156.0",
30
+ "oxc-resolver": "^11.24.2",
31
+ "tinyglobby": "^0.2.17"
32
+ },
33
+ "devDependencies": {
34
+ "@types/node": "^26.6.6",
35
+ "typescript": "^7.0.2"
36
+ }
7
37
  }