cursedbelt-server 4.39.0 → 4.40.1
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/server/activity/migrations.js +10 -0
- package/dist/server/activity/schema.js +8 -0
- package/package.json +2 -2
- package/src/barrelsReachNoOptionalPeer.spec.ts +16 -12
- package/src/leafSubpathsImportNothing.spec.ts +22 -16
- package/src/lockfileIsTracked.spec.ts +9 -5
- package/src/server/activity/actorIndex.spec.ts +25 -0
- package/src/server/activity/d1.spec.ts +1 -1
- package/src/server/activity/migrations.ts +8 -0
- package/src/server/activity/schema.ts +8 -0
- package/src/server/serveBunOverload.spec.ts +12 -7
- package/src/shippedFilesAreTracked.spec.ts +8 -8
|
@@ -27,4 +27,14 @@ export const ACTIVITY_MIGRATIONS = [
|
|
|
27
27
|
db.run(statement);
|
|
28
28
|
},
|
|
29
29
|
},
|
|
30
|
+
{
|
|
31
|
+
// A store that ran 0001 before the case-insensitive actor index existed never gets it from
|
|
32
|
+
// 0001 again. Idempotent, so on a fresh store (where 0001 already built it) it is a no-op.
|
|
33
|
+
name: "0002_activity_actor_ci",
|
|
34
|
+
up(db) {
|
|
35
|
+
for (const statement of ACTIVITY_DDL)
|
|
36
|
+
if (statement.includes("idx_activity_events_actor_ci"))
|
|
37
|
+
db.run(statement);
|
|
38
|
+
},
|
|
39
|
+
},
|
|
30
40
|
];
|
|
@@ -33,6 +33,14 @@ export const ACTIVITY_DDL = [
|
|
|
33
33
|
// question, and the one a fleet-wide search runs first.
|
|
34
34
|
`CREATE INDEX IF NOT EXISTS idx_activity_events_actor
|
|
35
35
|
ON ${ACTIVITY_EVENTS_TABLE} (actor, event_id DESC)`,
|
|
36
|
+
// 🔴 The index the actor filter can actually USE. `buildStream` matches an actor
|
|
37
|
+
// case-insensitively — `LOWER(actor) = LOWER(?)` — and SQLite cannot use the plain
|
|
38
|
+
// `actor` index above for that, so every actor query scanned the stream: measured
|
|
39
|
+
// 2026-09-28 (task 2254) on auth-stage, auth's sign-in familiarity read (actor + action +
|
|
40
|
+
// outcome, on every sign-in and app-open) cost 760 rows a call and 2.2M rows in four
|
|
41
|
+
// days, growing with the log for ever. With this index: 41 rows, flat.
|
|
42
|
+
`CREATE INDEX IF NOT EXISTS idx_activity_events_actor_ci
|
|
43
|
+
ON ${ACTIVITY_EVENTS_TABLE} (LOWER(actor), action, at DESC)`,
|
|
36
44
|
// "Everything that ever happened to this record."
|
|
37
45
|
`CREATE INDEX IF NOT EXISTS idx_activity_events_resource
|
|
38
46
|
ON ${ACTIVITY_EVENTS_TABLE} (kind, resource_id, event_id DESC)`,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursedbelt-server",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.40.1",
|
|
4
4
|
"license": "ISC",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "The app-facing Bun/Hono server tier of the cursedbelt split — storage, sharing, activity, guard, sync. React-free; cursedbelt-core below it.",
|
|
@@ -352,7 +352,7 @@
|
|
|
352
352
|
"dependencies": {
|
|
353
353
|
"argon2id": "1.0.1",
|
|
354
354
|
"cursedbelt-core": "^2.1.1",
|
|
355
|
-
"cursedops": ">=0.8.1 <0.
|
|
355
|
+
"cursedops": ">=0.8.1 <0.12.0",
|
|
356
356
|
"cwip": "^4.6.0",
|
|
357
357
|
"jose": "^6.2.3"
|
|
358
358
|
},
|
|
@@ -132,19 +132,23 @@ const packageOf = (specifier: string): string | undefined => {
|
|
|
132
132
|
* non-zero exit or an empty bundle THROWS rather than returning an empty set. That is the
|
|
133
133
|
* one way a check like this dies quietly.
|
|
134
134
|
*/
|
|
135
|
-
const scan = (entry: string, subpath: string): { packages: Set<string>; bun: Set<string> } => {
|
|
135
|
+
const scan = async (entry: string, subpath: string): Promise<{ packages: Set<string>; bun: Set<string> }> => {
|
|
136
136
|
const outdir = `${process.env.TMPDIR ?? '/tmp'}/cursedbelt-server-barrel-scan/${subpath.replace(/[^a-z0-9]+/gi, '-')}`;
|
|
137
|
-
const
|
|
137
|
+
const child = Bun.spawn(['bun', 'build', entry, '--target=bun', '--packages=external', '--outdir', outdir], {
|
|
138
138
|
cwd: REPO,
|
|
139
|
+
stdin: 'ignore',
|
|
139
140
|
stdout: 'pipe',
|
|
140
141
|
stderr: 'pipe',
|
|
141
142
|
});
|
|
143
|
+
const [, stderr, exitCode] = await Promise.all([
|
|
144
|
+
new Response(child.stdout).text(),
|
|
145
|
+
new Response(child.stderr).text(),
|
|
146
|
+
child.exited,
|
|
147
|
+
]);
|
|
142
148
|
const emitted = [...new Bun.Glob('**/*.js').scanSync({ cwd: outdir, onlyFiles: true })];
|
|
143
149
|
const bundle = emitted.map((f) => readFileSync(`${outdir}/${f}`, 'utf8')).join('\n');
|
|
144
|
-
if (
|
|
145
|
-
throw new Error(
|
|
146
|
-
`could not bundle ${subpath} (${entry}, exit ${build.exitCode}) — a check that cannot measure is not a passing check:\n${build.stderr.toString()}`,
|
|
147
|
-
);
|
|
150
|
+
if (exitCode !== 0 || bundle.trim() === '') {
|
|
151
|
+
throw new Error(`could not bundle ${subpath} (${entry}, exit ${exitCode}) — a check that cannot measure is not a passing check:\n${stderr}`);
|
|
148
152
|
}
|
|
149
153
|
const packages = new Set<string>();
|
|
150
154
|
const bun = new Set<string>();
|
|
@@ -168,8 +172,8 @@ const scan = (entry: string, subpath: string): { packages: Set<string>; bun: Set
|
|
|
168
172
|
* One bundle per subpath, shared by both assertions. Bundling is the expensive part of this
|
|
169
173
|
* file and the answer cannot change between two `it`s in the same run.
|
|
170
174
|
*/
|
|
171
|
-
const scans = new Map<string, { packages: Set<string>; bun: Set<string> }
|
|
172
|
-
const scanOnce = (entry: string, subpath: string): { packages: Set<string>; bun: Set<string> } => {
|
|
175
|
+
const scans = new Map<string, Promise<{ packages: Set<string>; bun: Set<string> }>>();
|
|
176
|
+
const scanOnce = (entry: string, subpath: string): Promise<{ packages: Set<string>; bun: Set<string> }> => {
|
|
173
177
|
const cached = scans.get(subpath);
|
|
174
178
|
if (cached !== undefined) return cached;
|
|
175
179
|
const fresh = scan(entry, subpath);
|
|
@@ -190,8 +194,8 @@ describe('no public subpath statically reaches what its consumers may not have',
|
|
|
190
194
|
|
|
191
195
|
for (const { subpath, entry } of SUBPATHS) {
|
|
192
196
|
const allowed = MAY_DRAG[subpath] ?? [];
|
|
193
|
-
it(`${subpath} drags ${allowed.length === 0 ? 'no optional peer' : allowed.join(' + ') + ' and nothing more'}`, () => {
|
|
194
|
-
const dragged = [...scanOnce(entry, subpath).packages].filter((n) => OPTIONAL_PEERS.has(n)).sort();
|
|
197
|
+
it(`${subpath} drags ${allowed.length === 0 ? 'no optional peer' : allowed.join(' + ') + ' and nothing more'}`, async () => {
|
|
198
|
+
const dragged = [...(await scanOnce(entry, subpath)).packages].filter((n) => OPTIONAL_PEERS.has(n)).sort();
|
|
195
199
|
const unexpected = dragged.filter((n) => !allowed.includes(n));
|
|
196
200
|
expect(
|
|
197
201
|
unexpected,
|
|
@@ -213,8 +217,8 @@ describe('no public subpath statically reaches what its consumers may not have',
|
|
|
213
217
|
|
|
214
218
|
for (const { subpath, entry } of SUBPATHS) {
|
|
215
219
|
const allowed = MAY_NEED_BUN[subpath] ?? [];
|
|
216
|
-
it(`${subpath} ${allowed.length === 0 ? 'loads on a Worker — no bun builtin' : `needs ${allowed.join(' + ')} and nothing more`}`, () => {
|
|
217
|
-
const reached = [...scanOnce(entry, subpath).bun].sort();
|
|
220
|
+
it(`${subpath} ${allowed.length === 0 ? 'loads on a Worker — no bun builtin' : `needs ${allowed.join(' + ')} and nothing more`}`, async () => {
|
|
221
|
+
const reached = [...(await scanOnce(entry, subpath)).bun].sort();
|
|
218
222
|
const unexpected = reached.filter((n) => !allowed.includes(n));
|
|
219
223
|
expect(
|
|
220
224
|
unexpected,
|
|
@@ -361,16 +361,20 @@ console.log(JSON.stringify(out));
|
|
|
361
361
|
`,
|
|
362
362
|
);
|
|
363
363
|
|
|
364
|
-
const child = Bun.
|
|
364
|
+
const child = Bun.spawn(['bun', 'run', 'entry.ts'], {
|
|
365
365
|
cwd: root,
|
|
366
|
+
stdin: 'ignore',
|
|
366
367
|
stdout: 'pipe',
|
|
367
368
|
stderr: 'pipe',
|
|
368
369
|
});
|
|
369
|
-
const
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
370
|
+
const [stdoutRaw, stderr, exitCode] = await Promise.all([
|
|
371
|
+
new Response(child.stdout).text(),
|
|
372
|
+
new Response(child.stderr).text(),
|
|
373
|
+
child.exited,
|
|
374
|
+
]);
|
|
375
|
+
const stdout = stdoutRaw.trim();
|
|
376
|
+
if (exitCode !== 0 || stdout === '') {
|
|
377
|
+
throw new Error(`the fixture could not run at all (exit ${exitCode}) — that is a broken check, not a passing one:\n${stderr}`);
|
|
374
378
|
}
|
|
375
379
|
return JSON.parse(stdout) as Record<string, Outcome>;
|
|
376
380
|
};
|
|
@@ -412,16 +416,18 @@ const OPTIONAL_PEERS = new Set(
|
|
|
412
416
|
/** `import x from "pkg"`, `import "pkg"`, `export … from "pkg"` — one line each, as bun emits. */
|
|
413
417
|
const STATIC_SPECIFIER = /^(?:import|export)\s[^\n]*?["']([^"'\n]+)["'];?$|^import\s*["']([^"'\n]+)["'];?$/gm;
|
|
414
418
|
|
|
415
|
-
const staticOptionalPeersOf = (entry: string): string[] => {
|
|
416
|
-
const
|
|
419
|
+
const staticOptionalPeersOf = async (entry: string): Promise<string[]> => {
|
|
420
|
+
const child = Bun.spawn(
|
|
417
421
|
['bun', 'build', entry, '--target=bun', '--packages=external'],
|
|
418
|
-
{ cwd: REPO, stdout: 'pipe', stderr: 'pipe' },
|
|
422
|
+
{ cwd: REPO, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' },
|
|
419
423
|
);
|
|
420
|
-
const bundle =
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
424
|
+
const [bundle, stderr, exitCode] = await Promise.all([
|
|
425
|
+
new Response(child.stdout).text(),
|
|
426
|
+
new Response(child.stderr).text(),
|
|
427
|
+
child.exited,
|
|
428
|
+
]);
|
|
429
|
+
if (exitCode !== 0 || bundle.trim() === '') {
|
|
430
|
+
throw new Error(`could not bundle ${entry} (exit ${exitCode}) — a check that cannot measure is not a passing check:\n${stderr}`);
|
|
425
431
|
}
|
|
426
432
|
const found = new Set<string>();
|
|
427
433
|
for (const match of bundle.matchAll(STATIC_SPECIFIER)) {
|
|
@@ -478,9 +484,9 @@ describe('barrels reach no optional peer statically', () => {
|
|
|
478
484
|
});
|
|
479
485
|
|
|
480
486
|
for (const target of NO_STATIC_OPTIONAL_PEERS) {
|
|
481
|
-
it(`${pkg.name}/${target.subpath.slice(2)} costs a consumer no optional peer to import`, () => {
|
|
487
|
+
it(`${pkg.name}/${target.subpath.slice(2)} costs a consumer no optional peer to import`, async () => {
|
|
482
488
|
expect(
|
|
483
|
-
staticOptionalPeersOf(target.entry),
|
|
489
|
+
await staticOptionalPeersOf(target.entry),
|
|
484
490
|
`importing ${pkg.name}/${target.subpath.slice(2)} statically pulls these OPTIONAL peers, so every consumer must install them for code it may never call. Either import the leaf module that has what you need instead of a sibling INDEX (that is how 'otplib' got in here twice), or make the dependency lazy: \`const { x } = await import('pkg')\` inside the function that needs it.`,
|
|
485
491
|
).toEqual([]);
|
|
486
492
|
});
|
|
@@ -23,7 +23,6 @@
|
|
|
23
23
|
* to skip it. Ignored-vs-not is the property that actually regresses.
|
|
24
24
|
*/
|
|
25
25
|
import { describe, expect, test } from 'bun:test'
|
|
26
|
-
import { spawnSync } from 'node:child_process'
|
|
27
26
|
import { existsSync } from 'node:fs'
|
|
28
27
|
import { fileURLToPath } from 'node:url'
|
|
29
28
|
|
|
@@ -37,13 +36,18 @@ describe('bun.lock is part of the checkout', () => {
|
|
|
37
36
|
).toBe(true)
|
|
38
37
|
})
|
|
39
38
|
|
|
40
|
-
test('git is not told to ignore it', () => {
|
|
41
|
-
|
|
42
|
-
|
|
39
|
+
test('git is not told to ignore it', async () => {
|
|
40
|
+
let status: number
|
|
41
|
+
try {
|
|
42
|
+
const child = Bun.spawn(['git', 'check-ignore', '-q', 'bun.lock'], { cwd: repoRoot, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' })
|
|
43
|
+
status = await child.exited
|
|
44
|
+
} catch {
|
|
45
|
+
return // no git on this host: nothing to assert about ignore rules
|
|
46
|
+
}
|
|
43
47
|
|
|
44
48
|
// exit 0 = the path IS ignored; 1 = it is not; anything else = git could not tell us.
|
|
45
49
|
expect(
|
|
46
|
-
|
|
50
|
+
status,
|
|
47
51
|
'bun.lock is gitignored, so a fresh clone has no lockfile and `bun install --frozen-lockfile` cannot pass there. Delete the stanza from .gitignore rather than the assertion.',
|
|
48
52
|
).not.toBe(0)
|
|
49
53
|
})
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 🔴 The actor filter must reach an index (task 2254).
|
|
3
|
+
*
|
|
4
|
+
* `buildStream` matches an actor with `LOWER(actor) = LOWER(?)`, which the plain `actor` index
|
|
5
|
+
* cannot serve — so until `idx_activity_events_actor_ci` every actor query scanned the stream.
|
|
6
|
+
* Measured on auth-stage, 2026-09-28: 760 rows a call on auth's sign-in familiarity read, growing
|
|
7
|
+
* with the log; 41 with the index. This pins the PLAN, so dropping the index or changing the
|
|
8
|
+
* filter's shape fails here rather than on the next bill.
|
|
9
|
+
*/
|
|
10
|
+
import { Database } from "bun:sqlite";
|
|
11
|
+
import { expect, test } from "bun:test";
|
|
12
|
+
import { buildStream } from "./query.js";
|
|
13
|
+
import { ACTIVITY_DDL } from "./schema.js";
|
|
14
|
+
|
|
15
|
+
test("a case-insensitive actor + action filter searches idx_activity_events_actor_ci, never a scan", () => {
|
|
16
|
+
const db = new Database(":memory:");
|
|
17
|
+
for (const ddl of ACTIVITY_DDL) db.run(ddl);
|
|
18
|
+
const insert = db.prepare("INSERT INTO activity_events (at, app, actor, action, outcome) VALUES (?, 'auth', ?, ?, 'ok')");
|
|
19
|
+
for (let i = 0; i < 500; i++) insert.run(new Date(1_790_000_000_000 + i).toISOString(), `user${i % 50}@example.com`, i % 2 ? "auth.sign-in" : "auth.app-open");
|
|
20
|
+
db.run("ANALYZE");
|
|
21
|
+
const { sql, params } = buildStream({ actor: "User7@Example.com", action: "auth.sign-in", outcome: "ok", limit: 20 }, []);
|
|
22
|
+
const plan = (db.query(`EXPLAIN QUERY PLAN ${sql}`).all(...(params as never[])) as { detail: string }[]).map((r) => r.detail).join(" | ");
|
|
23
|
+
expect(plan).toContain("idx_activity_events_actor_ci");
|
|
24
|
+
expect(plan).not.toMatch(/SCAN activity_events/);
|
|
25
|
+
});
|
|
@@ -129,7 +129,7 @@ describe("cursedbelt-server/activity/d1", () => {
|
|
|
129
129
|
const migrated = new Database(":memory:");
|
|
130
130
|
runMigrations(migrated, ACTIVITY_MIGRATIONS);
|
|
131
131
|
expect(shape(withDdl(ACTIVITY_DDL))).toEqual(shape(migrated));
|
|
132
|
-
expect(shape(migrated).length).toBe(
|
|
132
|
+
expect(shape(migrated).length).toBe(6);
|
|
133
133
|
});
|
|
134
134
|
|
|
135
135
|
test("🔴 refuses an event that cannot be filtered or attributed, and writes nothing", async () => {
|
|
@@ -30,4 +30,12 @@ export const ACTIVITY_MIGRATIONS: readonly Migration[] = [
|
|
|
30
30
|
for (const statement of ACTIVITY_DDL) db.run(statement);
|
|
31
31
|
},
|
|
32
32
|
},
|
|
33
|
+
{
|
|
34
|
+
// A store that ran 0001 before the case-insensitive actor index existed never gets it from
|
|
35
|
+
// 0001 again. Idempotent, so on a fresh store (where 0001 already built it) it is a no-op.
|
|
36
|
+
name: "0002_activity_actor_ci",
|
|
37
|
+
up(db: Database): void {
|
|
38
|
+
for (const statement of ACTIVITY_DDL) if (statement.includes("idx_activity_events_actor_ci")) db.run(statement);
|
|
39
|
+
},
|
|
40
|
+
},
|
|
33
41
|
];
|
|
@@ -35,6 +35,14 @@ export const ACTIVITY_DDL: readonly string[] = [
|
|
|
35
35
|
// question, and the one a fleet-wide search runs first.
|
|
36
36
|
`CREATE INDEX IF NOT EXISTS idx_activity_events_actor
|
|
37
37
|
ON ${ACTIVITY_EVENTS_TABLE} (actor, event_id DESC)`,
|
|
38
|
+
// 🔴 The index the actor filter can actually USE. `buildStream` matches an actor
|
|
39
|
+
// case-insensitively — `LOWER(actor) = LOWER(?)` — and SQLite cannot use the plain
|
|
40
|
+
// `actor` index above for that, so every actor query scanned the stream: measured
|
|
41
|
+
// 2026-09-28 (task 2254) on auth-stage, auth's sign-in familiarity read (actor + action +
|
|
42
|
+
// outcome, on every sign-in and app-open) cost 760 rows a call and 2.2M rows in four
|
|
43
|
+
// days, growing with the log for ever. With this index: 41 rows, flat.
|
|
44
|
+
`CREATE INDEX IF NOT EXISTS idx_activity_events_actor_ci
|
|
45
|
+
ON ${ACTIVITY_EVENTS_TABLE} (LOWER(actor), action, at DESC)`,
|
|
38
46
|
// "Everything that ever happened to this record."
|
|
39
47
|
`CREATE INDEX IF NOT EXISTS idx_activity_events_resource
|
|
40
48
|
ON ${ACTIVITY_EVENTS_TABLE} (kind, resource_id, event_id DESC)`,
|
|
@@ -20,26 +20,31 @@ import { fileURLToPath } from 'node:url';
|
|
|
20
20
|
const DIR = fileURLToPath(new URL('./typetests/', import.meta.url));
|
|
21
21
|
const TSC = fileURLToPath(new URL('../../node_modules/typescript/bin/tsc', import.meta.url));
|
|
22
22
|
|
|
23
|
-
const tsc = (project: string) => {
|
|
24
|
-
const
|
|
25
|
-
|
|
23
|
+
const tsc = async (project: string) => {
|
|
24
|
+
const child = Bun.spawn(['bun', TSC, '-p', project], { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' });
|
|
25
|
+
const [stdout, stderr, code] = await Promise.all([
|
|
26
|
+
new Response(child.stdout).text(),
|
|
27
|
+
new Response(child.stderr).text(),
|
|
28
|
+
child.exited,
|
|
29
|
+
]);
|
|
30
|
+
return { code, out: `${stdout}${stderr}` };
|
|
26
31
|
};
|
|
27
32
|
|
|
28
33
|
describe("Bun.serve's websocket overload", () => {
|
|
29
|
-
it('the trap is still live in bun’s types, and serveBun is the seam that never meets it', () => {
|
|
30
|
-
const run = tsc(`${DIR}tsconfig.json`);
|
|
34
|
+
it('the trap is still live in bun’s types, and serveBun is the seam that never meets it', async () => {
|
|
35
|
+
const run = await tsc(`${DIR}tsconfig.json`);
|
|
31
36
|
expect(run.out).toBe('');
|
|
32
37
|
expect(run.code).toBe(0);
|
|
33
38
|
}, 60_000);
|
|
34
39
|
|
|
35
|
-
it('🔴 without the directives the SAME lines fail — so the check above can go red', () => {
|
|
40
|
+
it('🔴 without the directives the SAME lines fail — so the check above can go red', async () => {
|
|
36
41
|
const source = readFileSync(`${DIR}serveBunOverload.ts`, 'utf8').replace(/^\s*\/\/ @ts-expect-error.*$/gm, '');
|
|
37
42
|
const file = `${DIR}serveBunOverload.stripped.ts`;
|
|
38
43
|
const project = `${DIR}tsconfig.stripped.json`;
|
|
39
44
|
writeFileSync(file, source);
|
|
40
45
|
writeFileSync(project, JSON.stringify({ extends: './tsconfig.json', include: ['serveBunOverload.stripped.ts'] }));
|
|
41
46
|
try {
|
|
42
|
-
const run = tsc(project);
|
|
47
|
+
const run = await tsc(project);
|
|
43
48
|
expect(run.code).not.toBe(0);
|
|
44
49
|
const errors = run.out.split('\n').filter((line) => line.includes('error TS'));
|
|
45
50
|
expect(errors).toHaveLength(2);
|
|
@@ -25,7 +25,6 @@
|
|
|
25
25
|
* when `git` cannot answer — a check that silently degrades to green is worse than none.
|
|
26
26
|
*/
|
|
27
27
|
import { describe, expect, test } from 'bun:test';
|
|
28
|
-
import { spawnSync } from 'node:child_process';
|
|
29
28
|
import { existsSync } from 'node:fs';
|
|
30
29
|
import { fileURLToPath } from 'node:url';
|
|
31
30
|
import pkg from '../package.json';
|
|
@@ -36,14 +35,15 @@ const repoRoot = fileURLToPath(new URL('..', import.meta.url));
|
|
|
36
35
|
const shippedDirs = (): string[] =>
|
|
37
36
|
((pkg as { files?: string[] }).files ?? []).filter((f) => f !== 'dist' && existsSync(`${repoRoot}/${f}`));
|
|
38
37
|
|
|
39
|
-
const git = (...args: string[]): { ok: boolean; out: string } => {
|
|
40
|
-
const
|
|
41
|
-
|
|
38
|
+
const git = async (...args: string[]): Promise<{ ok: boolean; out: string }> => {
|
|
39
|
+
const child = Bun.spawn(['git', '-C', repoRoot, ...args], { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' });
|
|
40
|
+
const [out, , code] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited]);
|
|
41
|
+
return { ok: code === 0, out: out.trim() };
|
|
42
42
|
};
|
|
43
43
|
|
|
44
44
|
describe('every file inside the published surface is tracked', () => {
|
|
45
|
-
test('git can answer at all — a degraded check must refuse, not pass', () => {
|
|
46
|
-
expect(git('rev-parse', '--is-inside-work-tree').ok, 'not a git checkout, so this check proves nothing').toBe(true);
|
|
45
|
+
test('git can answer at all — a degraded check must refuse, not pass', async () => {
|
|
46
|
+
expect((await git('rev-parse', '--is-inside-work-tree')).ok, 'not a git checkout, so this check proves nothing').toBe(true);
|
|
47
47
|
});
|
|
48
48
|
|
|
49
49
|
test('package.json names a shippable directory to scan', () => {
|
|
@@ -52,10 +52,10 @@ describe('every file inside the published surface is tracked', () => {
|
|
|
52
52
|
expect(shippedDirs().length, '`files` names no committed directory — nothing would be scanned').toBeGreaterThan(0);
|
|
53
53
|
});
|
|
54
54
|
|
|
55
|
-
test('no untracked file sits inside a directory `files` ships', () => {
|
|
55
|
+
test('no untracked file sits inside a directory `files` ships', async () => {
|
|
56
56
|
// --others = untracked; --exclude-standard honours .gitignore, so deliberately
|
|
57
57
|
// ignored paths (.agent.noindex, node_modules) are not offences.
|
|
58
|
-
const { ok, out } = git('ls-files', '--others', '--exclude-standard', '--', ...shippedDirs());
|
|
58
|
+
const { ok, out } = await git('ls-files', '--others', '--exclude-standard', '--', ...shippedDirs());
|
|
59
59
|
expect(ok, 'git ls-files failed').toBe(true);
|
|
60
60
|
const untracked = out === '' ? [] : out.split('\n');
|
|
61
61
|
expect(
|