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/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
+ }
@@ -0,0 +1,83 @@
1
+ import { createHash } from 'node:crypto';
2
+ const sha = (parts) => {
3
+ const h = createHash('sha256');
4
+ for (const p of parts)
5
+ h.update(p).update('\0');
6
+ return h.digest('hex');
7
+ };
8
+ const ENTRY = /^(\s*)('[^']*'|[^\s:'][^:]*):(?:\s+(.*))?$/;
9
+ const DEP_GROUPS = new Set(['dependencies', 'devDependencies', 'optionalDependencies']);
10
+ /**
11
+ * Per-package fingerprints from a pnpm v9 lockfile, so a lockfile change reruns only packages whose dependency tree changed.
12
+ * Reads just the block layout pnpm writes; returns undefined for anything else, and callers then hash the whole file.
13
+ */
14
+ export function pnpmLockKeys(text) {
15
+ const docs = text.split(/^---$/m);
16
+ const mainAt = docs.findLastIndex((d) => /^importers:/m.test(d));
17
+ if (mainAt < 0 || !/^lockfileVersion: '9\.\d+'/m.test(docs[mainAt]))
18
+ return undefined;
19
+ const other = docs.filter((_, i) => i !== mainAt);
20
+ // Top-level sections of the main document.
21
+ const sections = new Map();
22
+ let cur = '';
23
+ for (const line of docs[mainAt].split('\n')) {
24
+ const m = /^([^\s#][^:]*):/.exec(line);
25
+ if (m)
26
+ sections.set((cur = m[1]), []);
27
+ else
28
+ sections.get(cur)?.push(line);
29
+ }
30
+ for (const [name, lines] of sections)
31
+ if (!['importers', 'packages', 'snapshots'].includes(name))
32
+ other.push(`${name}\n${lines.join('\n')}`);
33
+ // importers: 2 spaces importer, 4 group, 6 dependency name, 8 specifier/version.
34
+ const direct = new Map();
35
+ let imp = '';
36
+ let group = '';
37
+ let dep = '';
38
+ for (const line of sections.get('importers') ?? []) {
39
+ const m = ENTRY.exec(line);
40
+ if (!m)
41
+ continue;
42
+ const [indent, key, value] = [m[1].length, m[2].replace(/^'(.*)'$/, '$1'), m[3]?.trim()];
43
+ if (indent === 2)
44
+ direct.set((imp = key), []);
45
+ else if (indent === 4)
46
+ group = key;
47
+ else if (indent === 6)
48
+ dep = key;
49
+ else if (indent === 8 && key === 'version' && DEP_GROUPS.has(group) && value)
50
+ direct.get(imp).push(node(dep, value));
51
+ }
52
+ // snapshots: 2 spaces id, 4 group, 6 `name: version`.
53
+ const children = new Map();
54
+ let id = '';
55
+ for (const line of sections.get('snapshots') ?? []) {
56
+ const m = ENTRY.exec(line);
57
+ if (!m)
58
+ continue;
59
+ const [indent, key, value] = [m[1].length, m[2].replace(/^'(.*)'$/, '$1'), m[3]?.trim()];
60
+ if (indent === 2)
61
+ children.set((id = key), []);
62
+ else if (indent === 4)
63
+ group = key;
64
+ else if (indent === 6 && DEP_GROUPS.has(group) && value)
65
+ children.get(id).push(node(key, value));
66
+ }
67
+ const byImporter = new Map();
68
+ for (const [importer, roots] of direct) {
69
+ const seen = new Set();
70
+ const stack = roots.filter((r) => !r.includes('@link:'));
71
+ while (stack.length) {
72
+ const n = stack.pop();
73
+ if (seen.has(n))
74
+ continue;
75
+ seen.add(n);
76
+ stack.push(...(children.get(n) ?? []));
77
+ }
78
+ byImporter.set(importer, sha([...seen].sort()));
79
+ }
80
+ return { shared: sha(other), byImporter };
81
+ }
82
+ // A dependency's snapshot id: `name@version`, or the version itself for an alias (`string-width: string-width@4.2.3`).
83
+ const node = (name, version) => (/^(@[^/]+\/)?[^@/(]+@/.test(version) ? version : `${name}@${version}`);
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
+ }