@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.
- package/dist/components/supabase.d.ts +1 -1
- package/dist/components/supabase.js +61 -108
- package/dist/daemon.js +45 -15
- package/dist/harness/coverage.d.ts +9 -3
- package/dist/harness/coverage.js +21 -3
- package/dist/harness/reach.d.ts +29 -2
- package/dist/harness/reach.js +110 -8
- package/dist/harness/wrapper-rules.d.ts +13 -0
- package/dist/harness/wrapper-rules.js +37 -0
- package/package.json +1 -1
- package/src/components/supabase-dir.test.ts +1 -1
- package/src/components/supabase-migrations.test.ts +116 -0
- package/src/components/supabase.ts +68 -148
- package/src/daemon.ts +47 -15
- package/src/harness/coverage.ts +20 -3
- package/src/harness/reach.test.ts +67 -1
- package/src/harness/reach.ts +113 -8
- package/src/harness/wrapper-rules.test.ts +26 -0
- package/src/harness/wrapper-rules.ts +39 -0
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1167
|
-
|
|
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
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
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
|
-
|
|
1182
|
+
command.push("-f", path);
|
|
1243
1183
|
}
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
1542
|
-
//
|
|
1543
|
-
readyCheck: { type: "
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
3611
|
-
* harness read through the SDK since the previous capture, as
|
|
3612
|
-
* lcov records. Present
|
|
3613
|
-
* the
|
|
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
|
|
3620
|
-
reports.push({ name: REACH_REPORT, content:
|
|
3621
|
-
console.log(`[coverage] project reach: ${
|
|
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:
|
|
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
|
-
|
|
3643
|
-
|
|
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
|
|
7
|
-
*
|
|
8
|
-
export declare const REACH_REPORT = "reach.
|
|
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). */
|
package/dist/harness/coverage.js
CHANGED
|
@@ -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
|
|
40
|
-
*
|
|
41
|
-
export const REACH_REPORT = "reach.
|
|
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))
|
package/dist/harness/reach.d.ts
CHANGED
|
@@ -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.
|
|
34
|
-
export
|
|
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. */
|
package/dist/harness/reach.js
CHANGED
|
@@ -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
|
|
8
|
-
// **
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
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
|
-
/**
|
|
148
|
-
|
|
149
|
-
|
|
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. */
|