@specific.dev/spectest 0.86.1 → 0.87.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -105,7 +105,7 @@ export interface SupabaseOptions {
105
105
  * ```
106
106
  * supabase/
107
107
  * config.toml ← auth rules, mail templates, per-function JWT verification
108
- * migrations/ ← applied in filename order once the stack is up
108
+ * migrations/ ← applied after Auth/Storage/Realtime, before REST
109
109
  * seed.sql ← applied after them, if it's there
110
110
  * functions/ ← one directory per function, served at /functions/v1/<name>
111
111
  * ```
@@ -1116,15 +1116,21 @@ function buildKongYaml(key, vals) {
1116
1116
  .join("\n");
1117
1117
  return y;
1118
1118
  }
1119
+ // ──────────────────────────────────────────────────────────────────────────
1120
+ // Project SQL runs after Auth, Storage and Realtime have created their own
1121
+ // schemas, but before PostgREST starts. The migrations part is a dependency
1122
+ // barrier: its setup finishes before REST and the gateway can become ready.
1123
+ // This prevents the DDL notifications from rebuilding a half-built schema.
1124
+ // ──────────────────────────────────────────────────────────────────────────
1125
+ /** Read-only project SQL inside the database container. */
1126
+ const SQL_MOUNT = "/spectest/supabase";
1119
1127
  /** Pipe one SQL file's contents into `psql` inside the db container. Uses
1120
1128
  * `psql -f -` (stdin) so multi-statement files and non-transactional DDL
1121
1129
  * (e.g. `CREATE INDEX CONCURRENTLY`) behave exactly as `supabase db reset`
1122
1130
  * applies them — not wrapped in a single transaction. `ON_ERROR_STOP=1` fails
1123
1131
  * fast on the first bad statement. */
1124
1132
  async function applySqlFile(ctx, sql, label, user,
1125
- /** The db service to run `psql` in. Defaults to the hook's own service,
1126
- * which is the db only for the bootstrap; the project's migrations run
1127
- * from the group hook, where `ctx.name` is the gateway. */
1133
+ /** The db service to run `psql` in; defaults to the hook's own service. */
1128
1134
  service = ctx.name) {
1129
1135
  const res = await ctx.exec(service, ["psql", "-v", "ON_ERROR_STOP=1", "-U", user, "-d", "postgres", "-f", "-"], { stdin: sql, timeoutMs: 300_000 });
1130
1136
  if (res.exitCode !== 0) {
@@ -1139,7 +1145,7 @@ service = ctx.name) {
1139
1145
  * Only ever called with a path the user gave: there is no assumed location,
1140
1146
  * so this never applies SQL nobody asked for. */
1141
1147
  async function applyMigrations(ctx, migrations,
1142
- /** Where `psql` runs — the db part, named from the group hook. */
1148
+ /** Where `psql` runs — the db part, named from the migration hook. */
1143
1149
  db) {
1144
1150
  const dir = migrations.startsWith("/")
1145
1151
  ? migrations
@@ -1153,97 +1159,34 @@ db) {
1153
1159
  `.spectestignore).`);
1154
1160
  }
1155
1161
  const files = (await readdir(dir)).filter((f) => f.endsWith(".sql")).sort();
1156
- for (const f of files) {
1157
- const sql = await ctx.readProjectFile(join(dir, f));
1158
- await applySqlFile(ctx, sql, `migration ${f}`, "postgres", db);
1159
- }
1160
- // `seed.sql` sits next to the migrations directory in the layout the
1161
- // Supabase CLI creates, so it needs no option of its own — pointing at
1162
- // the migrations is enough to say where the project's SQL lives. Absent
1163
- // is fine: plenty of projects have migrations and no seed.
1162
+ const paths = files.map((f) => `${SQL_MOUNT}/migrations/${f}`);
1164
1163
  const seedPath = join(dirname(dir), "seed.sql");
1165
- if (existsSync(seedPath)) {
1166
- const sql = await ctx.readProjectFile(seedPath);
1167
- await applySqlFile(ctx, sql, "seed.sql", "postgres", db);
1168
- }
1169
- }
1170
- /**
1171
- * Make PostgREST serve the schema the migrations just created, and do not
1172
- * return until it does so **stably**.
1173
- *
1174
- * PostgREST reads the schema once, at boot, and now boots before the
1175
- * project's migrations run. Without this, the first REST call for a new
1176
- * table answers `PGRST205 Could not find the table` — the failure the user
1177
- * would otherwise have to diagnose and work around themselves.
1178
- *
1179
- * Two things make this more than one request:
1180
- *
1181
- * - **A rebuild is not atomic from the outside.** While PostgREST reloads,
1182
- * a request can still miss the table. So a single successful check is not
1183
- * proof; the cache has to answer correctly several times in a row.
1184
- * - **Reloads are already in flight.** The `supabase/postgres` image ships
1185
- * `pgrst_ddl_watch`, an event trigger that notifies on every DDL, so a
1186
- * migration run queues several reloads of its own. Ours goes *first* and
1187
- * the settle loop then waits them all out — checking before sending the
1188
- * notify was a real bug: the poll passed, our own notify landed after it,
1189
- * and a test raced the rebuild it caused.
1190
- *
1191
- * The expectation comes from the database itself: every base table in the
1192
- * exposed `public` schema should be listed. A stack with no public tables
1193
- * has nothing to wait for.
1194
- */
1195
- async function reloadPostgrestSchema(ctx, db, rest) {
1196
- // Ours first, so every reload — ours and the DDL trigger's — is already
1197
- // queued before we start watching for the result.
1198
- await applySqlFile(ctx, "notify pgrst, 'reload schema';\n", "schema reload", "postgres", db);
1199
- // Only the tables PostgREST will actually publish. The spec below is
1200
- // fetched unauthenticated, so it is the `anon` role's view — and a table
1201
- // deliberately granted to nobody else (Supabase projects do this routinely:
1202
- // a lookup table only `supabase_auth_admin` may read, RLS with no anon
1203
- // grant) never appears in it. Waiting for those would wait for ever, on a
1204
- // schema cache that is in fact up to date.
1205
- const listed = await ctx.exec(db, [
1206
- "psql", "-U", "postgres", "-d", "postgres", "-t", "-A", "-c",
1207
- "select c.relname from pg_class c join pg_namespace n on n.oid = c.relnamespace " +
1208
- "where n.nspname = 'public' and c.relkind = 'r' " +
1209
- // `anon` is created by this component's own bootstrap, so it exists.
1210
- "and has_table_privilege('anon', c.oid, 'SELECT') order by 1",
1211
- ], { timeoutMs: 60_000 });
1212
- const tables = String(listed.stdout)
1213
- .split("\n")
1214
- .map((t) => t.trim())
1215
- .filter(Boolean);
1216
- if (tables.length === 0)
1164
+ if (existsSync(seedPath))
1165
+ paths.push(`${SQL_MOUNT}/seed.sql`);
1166
+ if (paths.length === 0)
1217
1167
  return;
1218
- /** One check: are all the tables served right now? */
1219
- const served = async () => {
1220
- const res = await fetch(`http://${rest}:3000/`);
1221
- if (!res.ok)
1222
- return tables;
1223
- const spec = (await res.json());
1224
- const paths = Object.keys(spec.paths ?? {});
1225
- return tables.filter((t) => !paths.includes(`/${t}`));
1226
- };
1227
- const SETTLE = 3;
1228
- const deadline = Date.now() + 30_000;
1229
- let streak = 0;
1230
- let missing = tables;
1231
- while (Date.now() < deadline) {
1232
- try {
1233
- missing = await served();
1234
- streak = missing.length === 0 ? streak + 1 : 0;
1235
- if (streak >= SETTLE)
1236
- return;
1237
- }
1238
- catch {
1239
- // Mid-reload PostgREST can refuse the connection outright.
1240
- streak = 0;
1168
+ // Repeated -f keeps psql's file boundaries (COPY, EOF without a semicolon,
1169
+ // and filename:line diagnostics), with one docker exec and one connection.
1170
+ // Do not use --single-transaction: concurrent indexes and explicit
1171
+ // transaction control in existing migrations must continue to work.
1172
+ const command = ["psql", "-X", "-q", "-v", "ON_ERROR_STOP=1", "-U", "postgres", "-d", "postgres"];
1173
+ for (const path of paths) {
1174
+ if (path !== paths[0]) {
1175
+ // Each file previously had its own session. Roll back unfinished work
1176
+ // and discard roles, GUCs, temporary tables, prepared statements and
1177
+ // advisory locks before the next file. Separate -c calls are essential:
1178
+ // DISCARD ALL cannot execute inside a multi-statement transaction.
1179
+ command.push("-c", "\\set ON_ERROR_STOP on", "-c", "\\set AUTOCOMMIT on");
1180
+ command.push("-c", "ROLLBACK", "-c", "DISCARD ALL");
1241
1181
  }
1242
- await new Promise((r) => setTimeout(r, 150));
1182
+ command.push("-f", path);
1243
1183
  }
1244
- throw new Error(`supabase: PostgREST did not settle on the migrated schema within 30s ` +
1245
- `(still missing: ${missing.join(", ") || "nothing, but not stably"}). ` +
1246
- `The migrations applied — this is the REST schema cache, not your SQL.`);
1184
+ const started = Date.now();
1185
+ const res = await ctx.exec(db, command, { timeoutMs: 300_000 });
1186
+ if (res.exitCode !== 0) {
1187
+ throw new Error(`supabase: applying migrations/seed failed (psql rc=${res.exitCode}):\n${res.stderr.trim() || res.stdout.trim()}`);
1188
+ }
1189
+ console.log(`supabase(): applied ${files.length} migrations${paths.length > files.length ? " and seed.sql" : ""} in ${((Date.now() - started) / 1000).toFixed(1)}s.`);
1247
1190
  }
1248
1191
  /**
1249
1192
  * A ready-to-use self-hosted Supabase stack. Mount `.group` at the key
@@ -1496,6 +1439,11 @@ export function supabase(opts = {}) {
1496
1439
  JWT_SECRET: jwtSecret,
1497
1440
  JWT_EXP: "3600",
1498
1441
  },
1442
+ // Read SQL directly, including large seeds, without copying it through
1443
+ // the harness and docker exec stdin. Keep this out of PGDATA.
1444
+ ...(migrationsPath === null ? {} : {
1445
+ volumes: [{ source: dirname(migrationsPath), target: SQL_MOUNT, readOnly: true }],
1446
+ }),
1499
1447
  ports: [5432],
1500
1448
  // `pg_isready` succeeds only once the image finishes initdb + its
1501
1449
  // baked init-scripts and starts serving for real.
@@ -1504,8 +1452,7 @@ export function supabase(opts = {}) {
1504
1452
  // Only the database's own bootstrap (role passwords / JWT GUC /
1505
1453
  // `_realtime` schema) runs here: the other services need it before
1506
1454
  // they can start. The project's migrations run later, from the
1507
- // group hook, once the whole stack is up — see the migration
1508
- // section above for why.
1455
+ // migrations hook, once the schema-owning services are up.
1509
1456
  await applySqlFile(ctx, buildBootstrapSql(dbPassword, jwtSecret, withRealtime), "supabase bootstrap",
1510
1457
  // The bootstrap alters reserved roles (authenticator, …) and the
1511
1458
  // database — superuser-only. `postgres` is deliberately NOT a
@@ -1537,10 +1484,10 @@ export function supabase(opts = {}) {
1537
1484
  ...(mapped?.rest ?? {}),
1538
1485
  },
1539
1486
  ports: [3000],
1540
- dependsOn: ["db"],
1541
- // PostgREST opens :3000 only after connecting to the DB and loading
1542
- // the schema cache, so a TCP connect is a good ready signal.
1543
- readyCheck: { type: "tcp", port: 3000, timeoutSecs: 120 },
1487
+ dependsOn: [migrationsPath === null ? "db" : "migrations"],
1488
+ // The HTTP socket can open before schema loading completes. /ready
1489
+ // checks both the database pool and the populated schema cache.
1490
+ readyCheck: { type: "http", port: 3001, path: "/ready", timeoutSecs: 120 },
1544
1491
  };
1545
1492
  // ── mail (SMTP capture, the standard email() component) ─────────
1546
1493
  if (withMail && !mailExternal) {
@@ -1719,7 +1666,9 @@ export function supabase(opts = {}) {
1719
1666
  },
1720
1667
  volumes: [storageData],
1721
1668
  ports: [5000],
1722
- dependsOn: ["db", "rest", "imgproxy"],
1669
+ // Storage initializes and serves /status using Postgres directly;
1670
+ // POSTGREST_URL does not require REST to run during its bootstrap.
1671
+ dependsOn: ["db", "imgproxy"],
1723
1672
  readyCheck: { type: "http", port: 5000, path: "/status", timeoutSecs: 90 },
1724
1673
  };
1725
1674
  }
@@ -1846,6 +1795,20 @@ export function supabase(opts = {}) {
1846
1795
  };
1847
1796
  parts.functions = functionsDef;
1848
1797
  }
1798
+ if (migrationsPath !== null) {
1799
+ parts.migrations = {
1800
+ // Reuse an already required image for this lightweight setup barrier.
1801
+ image: dbDef.image,
1802
+ command: "exec sleep infinity",
1803
+ dependsOn: [
1804
+ "db",
1805
+ ...(withAuth ? ["auth"] : []),
1806
+ ...(withStorage ? ["storage"] : []),
1807
+ ...(withRealtime ? ["realtime"] : []),
1808
+ ],
1809
+ setup: (ctx) => applyMigrations(ctx, migrationsPath, db),
1810
+ };
1811
+ }
1849
1812
  // ── kong (gateway / primary) — no explicit dependsOn: the group
1850
1813
  // expansion makes the primary depend on every member ──────────────
1851
1814
  parts.gateway = {
@@ -1879,16 +1842,6 @@ export function supabase(opts = {}) {
1879
1842
  };
1880
1843
  return parts;
1881
1844
  },
1882
- // The project's own SQL, applied with the whole stack up (the group
1883
- // hook chains after the primary, and the primary is the group's
1884
- // dependency sink). Still inside bring-up, so the result is captured
1885
- // into the cached environment like everything else.
1886
- setup: async (ctx) => {
1887
- if (migrationsPath === null)
1888
- return;
1889
- await applyMigrations(ctx, migrationsPath, dbKey);
1890
- await reloadPostgrestSchema(ctx, dbKey, `${name}-rest`);
1891
- },
1892
1845
  // The group's consolidated handle at `ctx.svc.<name>`.
1893
1846
  helpers: () => ({
1894
1847
  sql: new SQL(adminDbUrl, { label: name }),
package/dist/daemon.js CHANGED
@@ -24,7 +24,7 @@ import path from "node:path";
24
24
  import { pathToFileURL } from "node:url";
25
25
  import { ENVIRONMENT_CA_PATH as CA_PATH } from "./environment-ca.js";
26
26
  import { assert, expect, expectRaw, lowerIngress, dnsName as makeDnsDecl, isWildcard, validateServiceImage, proxy as makeProxyDecl, } from "./index.js";
27
- import { COVERAGE_CONTAINER_DIR, coverageBundleRef, coverageHostDir, encodeCoverageBundle, isEmptyReport, readCoverageDir, applyCoverageDelta, walkProjectFiles, ENVIRONMENT_SERVICE, REACH_REPORT, } from "./harness/coverage.js";
27
+ import { COVERAGE_CONTAINER_DIR, coverageBundleRef, coverageHostDir, encodeCoverageBundle, isEmptyReport, readCoverageDir, applyCoverageDelta, walkProjectFiles, ENVIRONMENT_SERVICE, REACH_REPORT, READS_REPORT, } from "./harness/coverage.js";
28
28
  import { configureBrowserCoverage } from "./browser-coverage.js";
29
29
  import { INVENTORY_REPORT, NODE_COVERAGE_MARKERS_SUBDIR, NODE_COVERAGE_SCRIPTS_SUBDIR, applyCoverageAdapters, coverageAdapters, coverageReportsMode, validateCoverage, } from "./coverage.js";
30
30
  import { serviceForHost as hostToService } from "./harness/browser-coverage.js";
@@ -41,10 +41,10 @@ import { summarizeBuildKit } from "./harness/buildkit-progress.js";
41
41
  import { LOG_DELTA_MAX_BYTES, capMiddle, streamDelta } from "./harness/log-delta.js";
42
42
  import { resolveHostPath as resolveVolumeHostPath, sanitizeSegment, } from "./harness/volume-paths.js";
43
43
  import { pollUntilReady } from "./harness/ready-poll.js";
44
- import { runWrapperRules } from "./harness/wrapper-rules.js";
44
+ import { fsUsageDiagnostics, runWrapperRules } from "./harness/wrapper-rules.js";
45
45
  import { cpus } from "node:os";
46
46
  import { APP_DIR, WORKSPACE, resolveExistingProjectPath, resolveProjectPath, takeProjectReads } from "./project-files.js";
47
- import { projectReach, renderReach, renderReadsLcov } from "./harness/reach.js";
47
+ import { compactReach, projectReach, renderReachRules, renderReadsLcov } from "./harness/reach.js";
48
48
  import { installFetchWrapper, isTransportError } from "./harness/fetch.js";
49
49
  import { bindInstrumentationScope, createInstrumentationScope, runInstrumented, runUninstrumented, } from "./harness/instrumentation-scope.js";
50
50
  import { encodeRegistry } from "./harness/names-registry.js";
@@ -3584,6 +3584,7 @@ async function computeProjectReachInner(all) {
3584
3584
  const sources = [];
3585
3585
  for (const svc of all) {
3586
3586
  const src = {
3587
+ service: svc.name,
3587
3588
  volumeSources: (svc.volumes ?? []).flatMap((v) => (v.source ? [v.source] : [])),
3588
3589
  };
3589
3590
  if (svc.image.type === "dockerfile") {
@@ -3603,29 +3604,33 @@ async function computeProjectReachInner(all) {
3603
3604
  }
3604
3605
  sources.push(src);
3605
3606
  }
3606
- return projectReach(files, sources);
3607
+ const reach = projectReach(files, sources);
3608
+ return { version: 1, sources, prefixes: compactReach(files, reach) };
3607
3609
  }
3608
3610
  /**
3609
3611
  * The synthetic `__environment__` capture: what the environment itself
3610
- * knows — the project's reach (bring-up only) and the project files the
3611
- * harness read through the SDK since the previous capture, as whole
3612
- * lcov records. Present whenever some service opted in, so the two ride
3613
- * the same bundle as the services' reports.
3612
+ * knows — the project's reach as rules (bring-up only) and the project
3613
+ * files the harness read through the SDK since the previous capture, as
3614
+ * whole lcov records. Present on every capture of every project, coverage
3615
+ * adapters or not: the reach is a property of the environment config,
3616
+ * and it is what lets the control plane tell that a change to `site/**`
3617
+ * can affect no test at all. When services opted in, the two ride the
3618
+ * same bundle as their reports.
3614
3619
  */
3615
3620
  async function captureEnvironmentCoverage(all, baseline) {
3616
3621
  const reports = [];
3617
3622
  if (baseline) {
3618
3623
  try {
3619
- const reach = await computeProjectReach(all);
3620
- reports.push({ name: REACH_REPORT, content: renderReach(reach) });
3621
- console.log(`[coverage] project reach: ${reach.length} file(s) some build or volume can carry`);
3624
+ const rules = await computeProjectReach(all);
3625
+ reports.push({ name: REACH_REPORT, content: renderReachRules(rules) });
3626
+ console.log(`[coverage] project reach: ${rules.sources.length} source(s), ${rules.prefixes.length} prefix(es) some build or volume can carry`);
3622
3627
  }
3623
3628
  catch (err) {
3624
3629
  console.warn(`[coverage] project reach not computed: ${err instanceof Error ? err.message : String(err)}`);
3625
3630
  }
3626
3631
  }
3627
3632
  const reads = takeProjectReads();
3628
- reports.push({ name: "reads.lcov", content: renderReadsLcov(reads) });
3633
+ reports.push({ name: READS_REPORT, content: renderReadsLcov(reads) });
3629
3634
  return { service: ENVIRONMENT_SERVICE, reports };
3630
3635
  }
3631
3636
  async function captureServiceCoverage(baseline = false) {
@@ -3639,8 +3644,8 @@ async function captureServiceCoverage(baseline = false) {
3639
3644
  byName.set(name, s);
3640
3645
  const all = [...byName.values()];
3641
3646
  const services = all.filter((s) => s.coverage !== undefined);
3642
- if (services.length === 0)
3643
- return [];
3647
+ // The environment's own capture is unconditional (see above); the
3648
+ // services' come from the ones that opted in, none included.
3644
3649
  const environment = captureEnvironmentCoverage(all, baseline);
3645
3650
  const captures = await Promise.all(services.map(async (svc) => {
3646
3651
  const { error, deltaReports } = await runCoverageAdapters(svc);
@@ -5352,7 +5357,7 @@ async function runTypecheck() {
5352
5357
  return { status: "failed", errors: [], totalErrors: 0, durationMs, detail: "typecheck timed out" };
5353
5358
  }
5354
5359
  const { errors, total, suppressed } = parseTscOutput(res.stdout + res.stderr);
5355
- const wrapper = await runWrapperRulesReport(config);
5360
+ const wrapper = [...(await runWrapperRulesReport(config)), ...(await rawFsReport())];
5356
5361
  const strip = ({ file, line, column, code, message }) => ({
5357
5362
  file,
5358
5363
  line,
@@ -5389,6 +5394,31 @@ async function runTypecheck() {
5389
5394
  ...(blocking.length > 0 ? { blocking } : {}),
5390
5395
  };
5391
5396
  }
5397
+ /**
5398
+ * The raw-fs rule (`SPECTEST2003`, advisory) over the test tree: a test
5399
+ * that opens a project file through `node:fs` hides a dependence from the
5400
+ * subset selector. Textual, so it needs no compiler; best-effort.
5401
+ */
5402
+ async function rawFsReport() {
5403
+ const root = path.join(APP_DIR, "tests");
5404
+ if (!existsSync(root))
5405
+ return [];
5406
+ try {
5407
+ const files = await walkProjectFiles(root, ["node_modules"]);
5408
+ const out = [];
5409
+ for (const rel of files) {
5410
+ if (!/\.(ts|tsx|js|jsx|mts|cts)$/.test(rel))
5411
+ continue;
5412
+ const text = await fs.readFile(path.join(root, rel), "utf8");
5413
+ out.push(...fsUsageDiagnostics(path.join("tests", rel), text));
5414
+ }
5415
+ return out;
5416
+ }
5417
+ catch (err) {
5418
+ console.warn(`[typecheck] raw fs rule threw: ${err?.message ?? err}`);
5419
+ return [];
5420
+ }
5421
+ }
5392
5422
  /**
5393
5423
  * The blocking rules, run against the BAKED compiler whatever the project
5394
5424
  * pins. Best-effort in every direction: an install that predates the API, a
@@ -3,9 +3,12 @@ export declare const COVERAGE_CONTAINER_DIR = "/spectest/coverage";
3
3
  /** The synthetic service the environment's own reports ride under: the
4
4
  * project's reach (bring-up) and the project files the harness read. */
5
5
  export declare const ENVIRONMENT_SERVICE = "__environment__";
6
- /** The bring-up report listing the project's reach, one project-relative
7
- * path per line (`harness/reach.ts`). */
8
- export declare const REACH_REPORT = "reach.paths";
6
+ /** The bring-up report carrying the project's reach as rules plus a
7
+ * compacted prefix list (`harness/reach.ts`, `ReachRules`). */
8
+ export declare const REACH_REPORT = "reach.json";
9
+ /** The per-capture report naming the project files the harness read
10
+ * through the SDK since the previous capture, as whole lcov records. */
11
+ export declare const READS_REPORT = "reads.lcov";
9
12
  /** Raw bytes of reports one service may ship per capture. Reports are
10
13
  * bounded by code size times chain depth, so anything past this is a
11
14
  * runaway (a tool writing a new file per request), not coverage. */
@@ -82,6 +85,9 @@ export declare function isUnitsReport(text: string): boolean;
82
85
  /** True when `text` is a report spectest stores: lcov, V8 JSON, a node()
83
86
  * script record, or (by name) a `.units` list. */
84
87
  export declare function isWellFormedReport(text: string, name?: string): boolean;
88
+ /** True when `text` is a `reach.json` document: a JSON object with a
89
+ * `sources` array and a `prefixes` array. */
90
+ export declare function isReachRules(text: string): boolean;
85
91
  /** True when a well-formed report names no source file at all (an lcov
86
92
  * with no `SF:`, a V8 document with an empty `result`, a units list
87
93
  * with no unit). */
@@ -36,9 +36,12 @@ export const COVERAGE_CONTAINER_DIR = "/spectest/coverage";
36
36
  /** The synthetic service the environment's own reports ride under: the
37
37
  * project's reach (bring-up) and the project files the harness read. */
38
38
  export const ENVIRONMENT_SERVICE = "__environment__";
39
- /** The bring-up report listing the project's reach, one project-relative
40
- * path per line (`harness/reach.ts`). */
41
- export const REACH_REPORT = "reach.paths";
39
+ /** The bring-up report carrying the project's reach as rules plus a
40
+ * compacted prefix list (`harness/reach.ts`, `ReachRules`). */
41
+ export const REACH_REPORT = "reach.json";
42
+ /** The per-capture report naming the project files the harness read
43
+ * through the SDK since the previous capture, as whole lcov records. */
44
+ export const READS_REPORT = "reads.lcov";
42
45
  /** Raw bytes of reports one service may ship per capture. Reports are
43
46
  * bounded by code size times chain depth, so anything past this is a
44
47
  * runaway (a tool writing a new file per request), not coverage. */
@@ -89,12 +92,27 @@ export function isUnitsReport(text) {
89
92
  export function isWellFormedReport(text, name = "") {
90
93
  if (name.endsWith(".units"))
91
94
  return isUnitsReport(text);
95
+ if (name === REACH_REPORT)
96
+ return isReachRules(text);
92
97
  return isLcovShaped(text) || isV8CoverageJson(text) || isSpectestScriptRecord(text);
93
98
  }
99
+ /** True when `text` is a `reach.json` document: a JSON object with a
100
+ * `sources` array and a `prefixes` array. */
101
+ export function isReachRules(text) {
102
+ try {
103
+ const v = JSON.parse(text);
104
+ return typeof v === "object" && v !== null && Array.isArray(v.sources) && Array.isArray(v.prefixes);
105
+ }
106
+ catch {
107
+ return false;
108
+ }
109
+ }
94
110
  /** True when a well-formed report names no source file at all (an lcov
95
111
  * with no `SF:`, a V8 document with an empty `result`, a units list
96
112
  * with no unit). */
97
113
  export function isEmptyReport(text) {
114
+ if (isReachRules(text))
115
+ return false;
98
116
  if (isV8CoverageJson(text))
99
117
  return /"result"\s*:\s*\[\s*\]/.test(text);
100
118
  if (isSpectestScriptRecord(text))
@@ -12,6 +12,8 @@ export declare function compileDockerignore(text: string): Pattern[];
12
12
  export declare function isExcluded(patterns: Pattern[], rel: string): boolean;
13
13
  /** One service's contribution to the reach. */
14
14
  export interface ReachSource {
15
+ /** The service the source belongs to (for the shadow page's verdicts). */
16
+ service?: string;
15
17
  /** Build context directory, project-relative (`.` for the root); absent
16
18
  * for a registry image. */
17
19
  context?: string;
@@ -30,8 +32,33 @@ export interface ReachSource {
30
32
  * Dockerfiles the builds read. Sorted, unique.
31
33
  */
32
34
  export declare function projectReach(files: readonly string[], sources: readonly ReachSource[]): string[];
33
- /** The `reach.paths` report: one path per line. */
34
- export declare function renderReach(paths: readonly string[]): string;
35
+ /** The `reach.json` report. Mirrors `reach::ReachRules` on the server. */
36
+ export interface ReachRules {
37
+ version: 1;
38
+ /** Every service's contribution, as rules. */
39
+ sources: ReachSource[];
40
+ /** The current file reach, compacted: `dir/` for a directory every
41
+ * project file of which is in the reach, a bare path for a file on its
42
+ * own, `.` when the whole project is. */
43
+ prefixes: string[];
44
+ }
45
+ /**
46
+ * Compact a file reach against the project's file list: a directory
47
+ * whose every file is in the reach becomes one `dir/` prefix (the root
48
+ * becomes `.`), and a file whose directory is not wholly reached stays a
49
+ * path. Only directories that hold a project file exist here; an empty
50
+ * directory has nothing to be in or out of the reach.
51
+ */
52
+ export declare function compactReach(files: readonly string[], reach: readonly string[]): string[];
53
+ /** Is `path` (project-relative) in the reach the rules describe? The
54
+ * compacted prefixes answer first; a path outside them is evaluated
55
+ * against every source, so a file that did not exist when the prefixes
56
+ * were computed gets the same answer the build would give. */
57
+ export declare function reachContains(rules: ReachRules, path: string): boolean;
58
+ /** The first source whose rules carry `path`, or `undefined`. */
59
+ export declare function sourceOf(sources: readonly ReachSource[], path: string): ReachSource | undefined;
60
+ /** The `reach.json` report text. */
61
+ export declare function renderReachRules(rules: ReachRules): string;
35
62
  /** An lcov document that names `paths` as files with no line data —
36
63
  * "whole" records, which the index reads as every line unknown, so any
37
64
  * change to the file selects the case that read it. */
@@ -4,14 +4,24 @@
4
4
  // a service's **build context** (so an image may carry it), it is the
5
5
  // **source of a volume** a service mounts, or a test **reads** it through
6
6
  // the SDK. The first two are known from the environment config before any
7
- // test runs; the harness computes their union here — the project's
8
- // **reach** — and ships it with the bring-up capture (`reach.paths`, one
9
- // project-relative path per line, under the synthetic `__environment__`
10
- // service). The subset selector then treats a changed file outside the
11
- // reach as one that cannot affect the suite: `site/**` behind a
7
+ // test runs; the harness ships them with the bring-up capture as
8
+ // **rules** (`reach.json`, under the synthetic `__environment__`
9
+ // service): per service its context directory, the composed
10
+ // `.dockerignore` text, the Dockerfile it reads and the volume sources it
11
+ // mounts. Rules, not a file list: the control plane evaluates any changed
12
+ // path against them, a file that did not exist at the base included, and
13
+ // a project of ten thousand files ships a few lines. Beside the rules
14
+ // rides a **compacted** view of the current file reach — a directory
15
+ // every file of which is in the reach as one `dir/` prefix, the odd file
16
+ // alone — which the control plane uses as a fast path and the shadow page
17
+ // as the summary. The subset selector then treats a changed file outside
18
+ // the reach as one that cannot affect the suite: `site/**` behind a
12
19
  // `.dockerignore`, `.github/**` outside every context. The third door is
13
20
  // recorded as it happens (`project-files.ts`, `takeProjectReads`).
14
21
  //
22
+ // The rules are shipped for every project, coverage adapters or not: the
23
+ // reach is a property of the environment config alone.
24
+ //
15
25
  // The context of a build is what `docker build` would send: every file
16
26
  // under the context directory minus what the composed `.dockerignore`
17
27
  // (the project's own rules plus the service's `exclude`) matches, with
@@ -144,9 +154,101 @@ export function projectReach(files, sources) {
144
154
  }
145
155
  return [...out].sort();
146
156
  }
147
- /** The `reach.paths` report: one path per line. */
148
- export function renderReach(paths) {
149
- return paths.join("\n") + (paths.length ? "\n" : "");
157
+ /**
158
+ * Compact a file reach against the project's file list: a directory
159
+ * whose every file is in the reach becomes one `dir/` prefix (the root
160
+ * becomes `.`), and a file whose directory is not wholly reached stays a
161
+ * path. Only directories that hold a project file exist here; an empty
162
+ * directory has nothing to be in or out of the reach.
163
+ */
164
+ export function compactReach(files, reach) {
165
+ const inReach = new Set(reach);
166
+ // Per directory: how many project files live below it, how many of
167
+ // those the reach holds. "" is the root.
168
+ const total = new Map();
169
+ const hit = new Map();
170
+ const children = new Map();
171
+ const filesIn = new Map();
172
+ for (const f of files) {
173
+ const parts = f.split("/");
174
+ let dir = "";
175
+ for (let i = 0; i < parts.length; i += 1) {
176
+ total.set(dir, (total.get(dir) ?? 0) + 1);
177
+ if (inReach.has(f))
178
+ hit.set(dir, (hit.get(dir) ?? 0) + 1);
179
+ if (i === parts.length - 1) {
180
+ let list = filesIn.get(dir);
181
+ if (!list)
182
+ filesIn.set(dir, (list = []));
183
+ list.push(f);
184
+ }
185
+ else {
186
+ const child = dir === "" ? parts[i] : `${dir}/${parts[i]}`;
187
+ let set = children.get(dir);
188
+ if (!set)
189
+ children.set(dir, (set = new Set()));
190
+ set.add(child);
191
+ dir = child;
192
+ }
193
+ }
194
+ }
195
+ const out = [];
196
+ const walk = (dir) => {
197
+ const n = total.get(dir) ?? 0;
198
+ if (n > 0 && (hit.get(dir) ?? 0) === n) {
199
+ out.push(dir === "" ? "." : `${dir}/`);
200
+ return;
201
+ }
202
+ for (const f of filesIn.get(dir) ?? [])
203
+ if (inReach.has(f))
204
+ out.push(f);
205
+ for (const c of children.get(dir) ?? [])
206
+ walk(c);
207
+ };
208
+ walk("");
209
+ return out.sort();
210
+ }
211
+ /** Is `path` (project-relative) in the reach the rules describe? The
212
+ * compacted prefixes answer first; a path outside them is evaluated
213
+ * against every source, so a file that did not exist when the prefixes
214
+ * were computed gets the same answer the build would give. */
215
+ export function reachContains(rules, path) {
216
+ const p = normRel(path);
217
+ for (const pre of rules.prefixes) {
218
+ if (pre === "." || pre === p || (pre.endsWith("/") && p.startsWith(pre)))
219
+ return true;
220
+ }
221
+ return sourceOf(rules.sources, p) !== undefined;
222
+ }
223
+ /** The first source whose rules carry `path`, or `undefined`. */
224
+ export function sourceOf(sources, path) {
225
+ const p = normRel(path);
226
+ for (const s of sources) {
227
+ if (s.context !== undefined) {
228
+ const ctx = normRel(s.context);
229
+ const prefix = ctx === "" ? "" : `${ctx}/`;
230
+ if (!prefix || p.startsWith(prefix)) {
231
+ const rel = p.slice(prefix.length);
232
+ if (rel === ".dockerignore" || rel === "Dockerfile" || !isExcluded(compileDockerignore(s.ignore ?? ""), rel))
233
+ return s;
234
+ }
235
+ }
236
+ for (const e of s.extraFiles ?? [])
237
+ if (normRel(e) === p)
238
+ return s;
239
+ for (const v of s.volumeSources ?? []) {
240
+ if (v.startsWith("/"))
241
+ continue;
242
+ const src = normRel(v);
243
+ if (p === src || p.startsWith(`${src}/`))
244
+ return s;
245
+ }
246
+ }
247
+ return undefined;
248
+ }
249
+ /** The `reach.json` report text. */
250
+ export function renderReachRules(rules) {
251
+ return JSON.stringify(rules);
150
252
  }
151
253
  /** An lcov document that names `paths` as files with no line data —
152
254
  * "whole" records, which the index reads as every line unknown, so any
@@ -1,3 +1,16 @@
1
+ /**
2
+ * A test that opens a project file through `node:fs` (or `Bun.file`)
3
+ * instead of `ctx.readProjectFile`. **Advisory.** The subset selector
4
+ * learns which project files a test depends on from the reads the SDK
5
+ * serves (`project-files.ts`); a raw read is a dependence it never sees,
6
+ * so a change to that file can skip the test. The rule is textual — an
7
+ * import or `require` of the fs modules, or a `Bun.file(` call — and is
8
+ * applied to the test tree only, where the alternative exists. It turns
9
+ * blocking when the reach gate enforces (`SUBSET_RUNS.md`).
10
+ */
11
+ export declare const CODE_RAW_FS = "SPECTEST2003";
12
+ /** The raw-fs findings of one test file, from its text alone. */
13
+ export declare function fsUsageDiagnostics(file: string, text: string): WrapperDiagnostic[];
1
14
  /** A finding, shaped like the `TypecheckError` the report already carries. */
2
15
  export interface WrapperDiagnostic {
3
16
  /** Path relative to the app dir, matching the advisory diagnostics. */