@specific.dev/spectest 0.91.2 → 0.92.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/daemon.js +36 -269
- package/dist/harness/certificate.d.ts +15 -0
- package/dist/harness/certificate.js +67 -0
- package/dist/harness/eval-typecheck.d.ts +9 -0
- package/dist/harness/eval-typecheck.js +93 -0
- package/dist/harness/service-graph.d.ts +3 -0
- package/dist/harness/service-graph.js +19 -9
- package/dist/harness/typecheck.d.ts +53 -0
- package/dist/harness/typecheck.js +184 -0
- package/dist/harness/wrapper-rules.d.ts +2 -0
- package/dist/harness/wrapper-rules.js +8 -6
- package/dist/index.d.ts +4 -5
- package/package.json +1 -1
- package/src/daemon.ts +40 -320
- package/src/harness/certificate.test.ts +31 -0
- package/src/harness/certificate.ts +73 -0
- package/src/harness/eval-typecheck.test.ts +191 -0
- package/src/harness/eval-typecheck.ts +100 -0
- package/src/harness/fetch.test.ts +28 -0
- package/src/harness/service-graph.test.ts +31 -1
- package/src/harness/service-graph.ts +23 -0
- package/src/harness/typecheck.ts +237 -0
- package/src/harness/wrapper-rules.ts +12 -7
- package/src/index.ts +10 -7
package/src/daemon.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { generateHostCert as mintHostCert } from "./harness/certificate.js";
|
|
1
2
|
import { componentBuildTasks, COMPONENT_RUNTIME } from "./harness/component-images.js";
|
|
2
3
|
import { waitForAll } from "./harness/concurrency.js";
|
|
3
4
|
import { androidScope, androidRecordingChunk, releaseAndroidRecording, isAndroidApp, type AndroidRecording } from "./android.js";
|
|
@@ -89,7 +90,7 @@ import {
|
|
|
89
90
|
serviceDockerignore as composeServiceDockerignore,
|
|
90
91
|
unionDockerignore,
|
|
91
92
|
} from "./harness/build-context.js";
|
|
92
|
-
import {
|
|
93
|
+
import { startServices } from "./harness/service-graph.js";
|
|
93
94
|
import {
|
|
94
95
|
casesMetadata as catalogueCases,
|
|
95
96
|
groupsMetadata as catalogueGroups,
|
|
@@ -103,7 +104,9 @@ import {
|
|
|
103
104
|
sanitizeSegment,
|
|
104
105
|
} from "./harness/volume-paths.js";
|
|
105
106
|
import { pollUntilReady } from "./harness/ready-poll.js";
|
|
106
|
-
import { fsUsageDiagnostics
|
|
107
|
+
import { fsUsageDiagnostics } from "./harness/wrapper-rules.js";
|
|
108
|
+
import { runTypecheck, type TypecheckReport } from "./harness/typecheck.js";
|
|
109
|
+
import { evaluateChecked } from "./harness/eval-typecheck.js";
|
|
107
110
|
import type { WrapperDiagnostic } from "./harness/wrapper-rules.js";
|
|
108
111
|
import { cpus, tmpdir } from "node:os";
|
|
109
112
|
import { APP_DIR, WORKSPACE, resolveExistingProjectPath, resolveProjectPath, takeProjectReads } from "./project-files.js";
|
|
@@ -1857,35 +1860,6 @@ async function withContainerPostMortem(name: string, err: unknown): Promise<unkn
|
|
|
1857
1860
|
}
|
|
1858
1861
|
}
|
|
1859
1862
|
|
|
1860
|
-
/** Validate the `dependsOn` graph and return the name→service map used to
|
|
1861
|
-
* walk it. Rules live in `harness/service-graph.ts`. */
|
|
1862
|
-
function validateServiceGraph(services: NamedService[]): Map<string, NamedService> {
|
|
1863
|
-
return validateGraph(services);
|
|
1864
|
-
}
|
|
1865
|
-
|
|
1866
|
-
async function startServices(
|
|
1867
|
-
services: NamedService[],
|
|
1868
|
-
startOne: (svc: NamedService) => Promise<void>,
|
|
1869
|
-
): Promise<void> {
|
|
1870
|
-
const byName = validateServiceGraph(services);
|
|
1871
|
-
const started = new Map<string, Promise<void>>();
|
|
1872
|
-
const start = (svc: NamedService): Promise<void> => {
|
|
1873
|
-
const existing = started.get(svc.name);
|
|
1874
|
-
if (existing) return existing;
|
|
1875
|
-
const p = (async () => {
|
|
1876
|
-
await Promise.all(
|
|
1877
|
-
(svc.dependsOn ?? [])
|
|
1878
|
-
.filter((d) => byName.has(d))
|
|
1879
|
-
.map((d) => start(byName.get(d)!)),
|
|
1880
|
-
);
|
|
1881
|
-
await startOne(svc);
|
|
1882
|
-
})();
|
|
1883
|
-
started.set(svc.name, p);
|
|
1884
|
-
return p;
|
|
1885
|
-
};
|
|
1886
|
-
await Promise.all(services.map(start));
|
|
1887
|
-
}
|
|
1888
|
-
|
|
1889
1863
|
// ────────────────────────────────────────────────────────────────────────
|
|
1890
1864
|
// Ingress — in-daemon HTTP/HTTPS listeners that route by Host header.
|
|
1891
1865
|
//
|
|
@@ -2055,72 +2029,6 @@ function buildIngress(project: Project): void {
|
|
|
2055
2029
|
}
|
|
2056
2030
|
}
|
|
2057
2031
|
|
|
2058
|
-
/**
|
|
2059
|
-
* Generate a leaf cert + key for one ingress route (fake or service
|
|
2060
|
-
* proxy), signed by the in-VM root CA at {CA_PATH}. SANs cover every
|
|
2061
|
-
* hostname the route answers to, so a client connecting with TLS
|
|
2062
|
-
* verifies cleanly regardless of which hostname it used. Shells out
|
|
2063
|
-
* to `openssl req -x509 -CA ... -CAkey ...` (OpenSSL 3.0+; Debian
|
|
2064
|
-
* bookworm ships 3.0.x).
|
|
2065
|
-
*
|
|
2066
|
-
* `label` is a short tag baked into the cert Subject CN and the temp
|
|
2067
|
-
* file names — only used for diagnostics, not for TLS verification.
|
|
2068
|
-
*/
|
|
2069
|
-
async function generateHostCert(
|
|
2070
|
-
label: string,
|
|
2071
|
-
hostnames: string[],
|
|
2072
|
-
): Promise<{ cert: string; key: string }> {
|
|
2073
|
-
const id = `spectest-host-${sanitizeSegment(label)}-${randomUUID().slice(0, 8)}`;
|
|
2074
|
-
const keyPath = path.join("/tmp", `${id}.key`);
|
|
2075
|
-
const crtPath = path.join("/tmp", `${id}.crt`);
|
|
2076
|
-
const sans = hostnames.map((h) => `DNS:${h}`).join(",");
|
|
2077
|
-
const args = [
|
|
2078
|
-
"req",
|
|
2079
|
-
"-newkey",
|
|
2080
|
-
"rsa:2048",
|
|
2081
|
-
"-nodes",
|
|
2082
|
-
"-keyout",
|
|
2083
|
-
keyPath,
|
|
2084
|
-
"-out",
|
|
2085
|
-
crtPath,
|
|
2086
|
-
"-x509",
|
|
2087
|
-
"-CA",
|
|
2088
|
-
CA_PATH,
|
|
2089
|
-
"-CAkey",
|
|
2090
|
-
CA_KEY_PATH,
|
|
2091
|
-
"-days",
|
|
2092
|
-
"3650",
|
|
2093
|
-
"-subj",
|
|
2094
|
-
`/CN=spectest-${label}`,
|
|
2095
|
-
"-addext",
|
|
2096
|
-
`subjectAltName=${sans}`,
|
|
2097
|
-
"-addext",
|
|
2098
|
-
"basicConstraints=CA:FALSE",
|
|
2099
|
-
"-addext",
|
|
2100
|
-
"extendedKeyUsage=serverAuth",
|
|
2101
|
-
"-addext",
|
|
2102
|
-
"keyUsage=digitalSignature,keyEncipherment",
|
|
2103
|
-
];
|
|
2104
|
-
const r = await shx("openssl", args, 30_000);
|
|
2105
|
-
if (r.code !== 0) {
|
|
2106
|
-
throw new Error(
|
|
2107
|
-
`openssl req for ${label} failed (rc=${r.code}): ${r.stderr.trim() || r.stdout.trim()}`,
|
|
2108
|
-
);
|
|
2109
|
-
}
|
|
2110
|
-
try {
|
|
2111
|
-
const [cert, key] = await Promise.all([
|
|
2112
|
-
fs.readFile(crtPath, "utf8"),
|
|
2113
|
-
fs.readFile(keyPath, "utf8"),
|
|
2114
|
-
]);
|
|
2115
|
-
return { cert, key };
|
|
2116
|
-
} finally {
|
|
2117
|
-
await Promise.all([
|
|
2118
|
-
fs.unlink(keyPath).catch(() => {}),
|
|
2119
|
-
fs.unlink(crtPath).catch(() => {}),
|
|
2120
|
-
]);
|
|
2121
|
-
}
|
|
2122
|
-
}
|
|
2123
|
-
|
|
2124
2032
|
/** Resolve the spectest-net bridge gateway IP — the address containers
|
|
2125
2033
|
* use to reach the VM host. Asks dockerd via the docker CLI; cached for
|
|
2126
2034
|
* the daemon's life because the network is recreated only on reload. */
|
|
@@ -2163,7 +2071,11 @@ type Route = IngressRoute<FakeRuntime>;
|
|
|
2163
2071
|
*/
|
|
2164
2072
|
const NATIVE_FETCH: typeof fetch = globalThis.fetch.bind(globalThis);
|
|
2165
2073
|
|
|
2166
|
-
/**
|
|
2074
|
+
/** Mint ingress and container certificates with the environment CA. */
|
|
2075
|
+
function generateHostCert(label: string, hostnames: string[]): Promise<{ cert: string; key: string }> {
|
|
2076
|
+
return mintHostCert(label, hostnames, CA_PATH, CA_KEY_PATH);
|
|
2077
|
+
}
|
|
2078
|
+
|
|
2167
2079
|
async function startIngress(): Promise<void> {
|
|
2168
2080
|
const hasIngress =
|
|
2169
2081
|
FAKES.size > 0 ||
|
|
@@ -3517,9 +3429,13 @@ async function bootstrap(prepared?: PreparedImages): Promise<BootstrapTimings> {
|
|
|
3517
3429
|
// hook's — into the boot log, so GET /boot-log can explain a slow or
|
|
3518
3430
|
// finished boot. Restored in the finally at the end.
|
|
3519
3431
|
const restoreBootLog = captureConsole(BOOT_LOG_SINK);
|
|
3432
|
+
// One dispatcher for the whole bootstrap: service hooks run concurrently.
|
|
3433
|
+
// Only each hook's async scope opts into wrapped fetch responses.
|
|
3434
|
+
const restoreFetch = installFetchWrapper();
|
|
3520
3435
|
try {
|
|
3521
3436
|
return await bootstrapInner(prepared);
|
|
3522
3437
|
} finally {
|
|
3438
|
+
restoreFetch();
|
|
3523
3439
|
restoreBootLog();
|
|
3524
3440
|
}
|
|
3525
3441
|
}
|
|
@@ -3693,20 +3609,25 @@ async function bootstrapInner(prepared?: PreparedImages, identity?: string): Pro
|
|
|
3693
3609
|
await waitForAll([...builds.map(task => prep.get(task.name)!), (async () => {
|
|
3694
3610
|
if (svc.setup) {
|
|
3695
3611
|
progressService(svc.name, { status: "probing", detail: "running setup" });
|
|
3696
|
-
const
|
|
3612
|
+
const fetchScope = createInstrumentationScope(false);
|
|
3697
3613
|
try {
|
|
3698
|
-
await
|
|
3699
|
-
|
|
3700
|
-
|
|
3701
|
-
|
|
3702
|
-
|
|
3703
|
-
|
|
3704
|
-
|
|
3705
|
-
|
|
3706
|
-
|
|
3614
|
+
await runInstrumented(fetchScope, async () => {
|
|
3615
|
+
const helpers = await ensureHelpers(svc.name, svc);
|
|
3616
|
+
await svc.setup!({
|
|
3617
|
+
name: svc.name,
|
|
3618
|
+
helpers,
|
|
3619
|
+
...(await spectestContext({ service: svc.name, includeSelf: true })),
|
|
3620
|
+
...{ [COMPONENT_RUNTIME]: {
|
|
3621
|
+
images: builds.map(task => ({ source: imageTag(task.name), reference: task.reference, ready: prep.get(task.name)! })),
|
|
3622
|
+
gateway: await bridgeGatewayIp(),
|
|
3623
|
+
caBundle: await ensureCaBundle(),
|
|
3624
|
+
} },
|
|
3625
|
+
});
|
|
3707
3626
|
});
|
|
3708
3627
|
} catch (err) {
|
|
3709
3628
|
throw await withContainerPostMortem(svc.name, err);
|
|
3629
|
+
} finally {
|
|
3630
|
+
fetchScope.active = false;
|
|
3710
3631
|
}
|
|
3711
3632
|
}
|
|
3712
3633
|
})()]);
|
|
@@ -4855,7 +4776,7 @@ async function spectestContext(scope: ContextScope = {}): Promise<SpectestContex
|
|
|
4855
4776
|
exec: (service, command, opts) =>
|
|
4856
4777
|
componentExec(service, command, opts, execTimeoutMs),
|
|
4857
4778
|
// Read `globalThis.fetch` at call time: the wrapper is installed for
|
|
4858
|
-
// the duration of a test / eval / project setup, so a context built
|
|
4779
|
+
// the duration of bootstrap / a test / eval / project setup, so a context built
|
|
4859
4780
|
// before it lands still gets the wrapped one.
|
|
4860
4781
|
fetch: ((input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) =>
|
|
4861
4782
|
globalThis.fetch(input, init)) as unknown as SpectestContext["fetch"],
|
|
@@ -6246,7 +6167,12 @@ async function evalCode(code: string): Promise<EvalResult> {
|
|
|
6246
6167
|
filePath = path.join(EVAL_DIR, `${randomUUID()}.ts`);
|
|
6247
6168
|
await fs.writeFile(filePath, prepared.code);
|
|
6248
6169
|
const moduleUrl = pathToFileURL(filePath).href;
|
|
6249
|
-
const mod =
|
|
6170
|
+
const mod = await evaluateChecked(
|
|
6171
|
+
{ file: filePath, appDir: APP_DIR, typecheckDir: TYPECHECK_DIR, run: shx,
|
|
6172
|
+
projectEntry: loaded ? resolveEntry() : undefined },
|
|
6173
|
+
(text) => { chunks.push(text); },
|
|
6174
|
+
() => runInstrumented(fetchScope, () => import(moduleUrl)) as Promise<{ default?: unknown }>,
|
|
6175
|
+
);
|
|
6250
6176
|
outcome = { ok: true, result: safeSerialize(mod.default) };
|
|
6251
6177
|
} catch (err) {
|
|
6252
6178
|
const e = err as Error;
|
|
@@ -6327,46 +6253,11 @@ async function evalCode(code: string): Promise<EvalResult> {
|
|
|
6327
6253
|
* blocking rules, which always use THIS copy rather than the project's own —
|
|
6328
6254
|
* can be driven outside a VM. */
|
|
6329
6255
|
const TYPECHECK_DIR = process.env.SPECTEST_TYPECHECK_DIR ?? "/opt/spectest/typecheck";
|
|
6330
|
-
/** Cap on errors shipped in the report; `totalErrors` carries the true count. */
|
|
6331
|
-
const TYPECHECK_ERROR_CAP = 50;
|
|
6332
|
-
|
|
6333
|
-
interface TypecheckError {
|
|
6334
|
-
/** Path relative to the project's spectest/ dir (e.g. `tests/checkout.ts`). */
|
|
6335
|
-
file: string;
|
|
6336
|
-
line: number;
|
|
6337
|
-
column: number;
|
|
6338
|
-
/** `TS2345` from the compiler, or a `SPECTEST….` rule code. */
|
|
6339
|
-
code: string;
|
|
6340
|
-
message: string;
|
|
6341
|
-
}
|
|
6342
|
-
|
|
6343
|
-
interface TypecheckReport {
|
|
6344
|
-
status: "ok" | "errors" | "skipped" | "failed";
|
|
6345
|
-
errors: TypecheckError[];
|
|
6346
|
-
totalErrors: number;
|
|
6347
|
-
durationMs: number;
|
|
6348
|
-
/** Why the check was skipped / how it failed. */
|
|
6349
|
-
detail?: string;
|
|
6350
|
-
/**
|
|
6351
|
-
* Findings that FAIL the run, as opposed to `errors`, which only advise.
|
|
6352
|
-
* A diagnostic qualifies only when it is true of the program that runs
|
|
6353
|
-
* rather than of the declared types alone — see `harness/wrapper-rules.ts`
|
|
6354
|
-
* for why no `tsc` code can be in here and why these can.
|
|
6355
|
-
*
|
|
6356
|
-
* The list is the rollout mechanism as well as the payload: an older SDK
|
|
6357
|
-
* never sends the field, `#[serde(default)]` leaves it empty on the server,
|
|
6358
|
-
* and the gate is inert for every project that has not moved to a version
|
|
6359
|
-
* carrying it. The version a project runs is already its own choice
|
|
6360
|
-
* (`sdk.rs` honours the declared lower bound), so the bump is the consent.
|
|
6361
|
-
*/
|
|
6362
|
-
blocking?: TypecheckError[];
|
|
6363
|
-
}
|
|
6364
|
-
|
|
6365
6256
|
let TYPECHECK: Promise<TypecheckReport> | null = null;
|
|
6366
6257
|
|
|
6367
6258
|
/** Fire-and-forget kickoff; the promise is parked for POST /typecheck. */
|
|
6368
6259
|
function startTypecheck(): void {
|
|
6369
|
-
TYPECHECK =
|
|
6260
|
+
TYPECHECK = runProjectTypecheck().catch((err) => ({
|
|
6370
6261
|
status: "failed" as const,
|
|
6371
6262
|
errors: [],
|
|
6372
6263
|
totalErrors: 0,
|
|
@@ -6377,149 +6268,9 @@ function startTypecheck(): void {
|
|
|
6377
6268
|
TYPECHECK.catch(() => {});
|
|
6378
6269
|
}
|
|
6379
6270
|
|
|
6380
|
-
|
|
6381
|
-
|
|
6382
|
-
|
|
6383
|
-
* install via `typeRoots` (the walk-up from APP_DIR never reaches it). */
|
|
6384
|
-
function generatedTsconfig(): string {
|
|
6385
|
-
return JSON.stringify(
|
|
6386
|
-
{
|
|
6387
|
-
compilerOptions: {
|
|
6388
|
-
target: "esnext",
|
|
6389
|
-
module: "esnext",
|
|
6390
|
-
moduleResolution: "bundler",
|
|
6391
|
-
strict: true,
|
|
6392
|
-
noEmit: true,
|
|
6393
|
-
skipLibCheck: true,
|
|
6394
|
-
esModuleInterop: true,
|
|
6395
|
-
resolveJsonModule: true,
|
|
6396
|
-
jsx: "react-jsx",
|
|
6397
|
-
types: ["bun"],
|
|
6398
|
-
typeRoots: [
|
|
6399
|
-
path.join(APP_DIR, "node_modules", "@types"),
|
|
6400
|
-
path.join(TYPECHECK_DIR, "node_modules", "@types"),
|
|
6401
|
-
],
|
|
6402
|
-
},
|
|
6403
|
-
include: ["**/*.ts", "**/*.tsx"],
|
|
6404
|
-
exclude: ["node_modules"],
|
|
6405
|
-
},
|
|
6406
|
-
null,
|
|
6407
|
-
2,
|
|
6408
|
-
);
|
|
6409
|
-
}
|
|
6410
|
-
|
|
6411
|
-
/** Parse `--pretty false` tsc output: `file(line,col): error TScode: msg`,
|
|
6412
|
-
* with indented elaboration lines folded into the preceding error. */
|
|
6413
|
-
function parseTscOutput(output: string): {
|
|
6414
|
-
errors: TypecheckError[];
|
|
6415
|
-
total: number;
|
|
6416
|
-
suppressed: number;
|
|
6417
|
-
} {
|
|
6418
|
-
const errors: TypecheckError[] = [];
|
|
6419
|
-
let total = 0;
|
|
6420
|
-
let suppressed = 0;
|
|
6421
|
-
let last: TypecheckError | null = null;
|
|
6422
|
-
for (const line of output.split("\n")) {
|
|
6423
|
-
const m = /^(.+?)\((\d+),(\d+)\): error (TS\d+): (.*)$/.exec(line);
|
|
6424
|
-
if (!m) {
|
|
6425
|
-
// Elaboration lines are indented; fold them into the last kept error.
|
|
6426
|
-
if (last && /^\s+\S/.test(line)) last.message += `\n${line.trimEnd()}`;
|
|
6427
|
-
continue;
|
|
6428
|
-
}
|
|
6429
|
-
const [, rawFile, ln, col, code, message] = m;
|
|
6430
|
-
// Keep only diagnostics in the user's files. tsc runs with cwd=APP_DIR
|
|
6431
|
-
// (the daemon's cwd), so project files come out relative ("tests/x.ts")
|
|
6432
|
-
// and anything outside — the SDK at /opt/spectest/sdk, node_modules —
|
|
6433
|
-
// is `../…` or absolute. Errors in our own SDK are ours to fix, not the
|
|
6434
|
-
// user's to read.
|
|
6435
|
-
const abs = path.resolve(APP_DIR, rawFile!);
|
|
6436
|
-
const inApp = abs.startsWith(APP_DIR + path.sep) && !abs.includes(`${path.sep}node_modules${path.sep}`);
|
|
6437
|
-
if (!inApp) {
|
|
6438
|
-
suppressed += 1;
|
|
6439
|
-
last = null;
|
|
6440
|
-
continue;
|
|
6441
|
-
}
|
|
6442
|
-
total += 1;
|
|
6443
|
-
const err: TypecheckError = {
|
|
6444
|
-
file: path.relative(APP_DIR, abs),
|
|
6445
|
-
line: Number(ln),
|
|
6446
|
-
column: Number(col),
|
|
6447
|
-
code: code!,
|
|
6448
|
-
message: message!,
|
|
6449
|
-
};
|
|
6450
|
-
if (errors.length < TYPECHECK_ERROR_CAP) {
|
|
6451
|
-
errors.push(err);
|
|
6452
|
-
last = err;
|
|
6453
|
-
} else {
|
|
6454
|
-
last = null;
|
|
6455
|
-
}
|
|
6456
|
-
}
|
|
6457
|
-
return { errors, total, suppressed };
|
|
6458
|
-
}
|
|
6459
|
-
|
|
6460
|
-
async function runTypecheck(): Promise<TypecheckReport> {
|
|
6461
|
-
const started = Date.now();
|
|
6462
|
-
// The project's own typescript wins over the baked copy.
|
|
6463
|
-
const tsc = [
|
|
6464
|
-
path.join(APP_DIR, "node_modules", "typescript", "bin", "tsc"),
|
|
6465
|
-
path.join(TYPECHECK_DIR, "node_modules", "typescript", "bin", "tsc"),
|
|
6466
|
-
].find((p) => existsSync(p));
|
|
6467
|
-
if (!tsc) {
|
|
6468
|
-
return {
|
|
6469
|
-
status: "skipped",
|
|
6470
|
-
errors: [],
|
|
6471
|
-
totalErrors: 0,
|
|
6472
|
-
durationMs: 0,
|
|
6473
|
-
detail: "typescript is not installed (base image predates the baked typechecker)",
|
|
6474
|
-
};
|
|
6475
|
-
}
|
|
6476
|
-
let config = path.join(APP_DIR, "tsconfig.json");
|
|
6477
|
-
if (!existsSync(config)) {
|
|
6478
|
-
config = path.join(APP_DIR, ".spectest-tsconfig.json");
|
|
6479
|
-
await fs.writeFile(config, generatedTsconfig());
|
|
6480
|
-
}
|
|
6481
|
-
const res = await shx(process.execPath, [tsc, "--noEmit", "--pretty", "false", "-p", config], 120_000);
|
|
6482
|
-
const durationMs = Date.now() - started;
|
|
6483
|
-
if (res.code === 124) {
|
|
6484
|
-
return { status: "failed", errors: [], totalErrors: 0, durationMs, detail: "typecheck timed out" };
|
|
6485
|
-
}
|
|
6486
|
-
const { errors, total, suppressed } = parseTscOutput(res.stdout + res.stderr);
|
|
6487
|
-
const wrapper = [...(await runWrapperRulesReport(config)), ...(await rawFsReport())];
|
|
6488
|
-
const strip = ({ file, line, column, code, message }: WrapperDiagnostic): TypecheckError => ({
|
|
6489
|
-
file,
|
|
6490
|
-
line,
|
|
6491
|
-
column,
|
|
6492
|
-
code,
|
|
6493
|
-
message,
|
|
6494
|
-
});
|
|
6495
|
-
const blocking = wrapper.filter((d) => d.blocking).map(strip);
|
|
6496
|
-
const advisory = wrapper.filter((d) => !d.blocking).map(strip);
|
|
6497
|
-
if (errors.length === 0 && wrapper.length === 0) {
|
|
6498
|
-
// Exit 0 → clean. Non-zero with no *user-file* diagnostics is either
|
|
6499
|
-
// all-suppressed (still ok from the user's perspective) or a compiler
|
|
6500
|
-
// crash (config not found, OOM) — surface the latter.
|
|
6501
|
-
if (res.code !== 0 && suppressed === 0) {
|
|
6502
|
-
const tail = (res.stderr || res.stdout).trim().split("\n").slice(-5).join("\n");
|
|
6503
|
-
return { status: "failed", errors: [], totalErrors: 0, durationMs, detail: tail || `tsc exited ${res.code}` };
|
|
6504
|
-
}
|
|
6505
|
-
if (suppressed > 0) {
|
|
6506
|
-
console.warn(`[typecheck] ${suppressed} diagnostic(s) outside the project suppressed`);
|
|
6507
|
-
}
|
|
6508
|
-
return { status: "ok", errors: [], totalErrors: 0, durationMs };
|
|
6509
|
-
}
|
|
6510
|
-
// The wrapper findings ride the same list the CLI and the dashboard already
|
|
6511
|
-
// render; `blocking` is a second view of the ones that also fail the run, so
|
|
6512
|
-
// nothing has to learn a new shape to show them. Blocking leads, because
|
|
6513
|
-
// `errors` is capped and the entries that stopped the run must never be the
|
|
6514
|
-
// ones the cap drops.
|
|
6515
|
-
const all = [...blocking, ...advisory, ...errors];
|
|
6516
|
-
return {
|
|
6517
|
-
status: "errors",
|
|
6518
|
-
errors: all.slice(0, TYPECHECK_ERROR_CAP),
|
|
6519
|
-
totalErrors: total + wrapper.length,
|
|
6520
|
-
durationMs,
|
|
6521
|
-
...(blocking.length > 0 ? { blocking } : {}),
|
|
6522
|
-
};
|
|
6271
|
+
async function runProjectTypecheck(): Promise<TypecheckReport> {
|
|
6272
|
+
return runTypecheck({ appDir: APP_DIR, typecheckDir: TYPECHECK_DIR, run: shx,
|
|
6273
|
+
projectEntry: loaded ? resolveEntry() : undefined, extraDiagnostics: rawFsReport });
|
|
6523
6274
|
}
|
|
6524
6275
|
|
|
6525
6276
|
/**
|
|
@@ -6545,37 +6296,6 @@ async function rawFsReport(): Promise<WrapperDiagnostic[]> {
|
|
|
6545
6296
|
}
|
|
6546
6297
|
}
|
|
6547
6298
|
|
|
6548
|
-
/**
|
|
6549
|
-
* The blocking rules, run against the BAKED compiler whatever the project
|
|
6550
|
-
* pins. Best-effort in every direction: an install that predates the API, a
|
|
6551
|
-
* config the rules cannot open, or a throw from `typescript/unstable/*` all
|
|
6552
|
-
* yield no findings, so a run proceeds exactly as it does today. Only a
|
|
6553
|
-
* definite finding can stop one.
|
|
6554
|
-
*/
|
|
6555
|
-
async function runWrapperRulesReport(config: string): Promise<WrapperDiagnostic[]> {
|
|
6556
|
-
const typescriptDir = path.join(TYPECHECK_DIR, "node_modules", "typescript");
|
|
6557
|
-
if (!existsSync(path.join(typescriptDir, "dist", "api", "async", "api.js"))) return [];
|
|
6558
|
-
try {
|
|
6559
|
-
const run = await runWrapperRules({
|
|
6560
|
-
typescriptDir,
|
|
6561
|
-
configFile: config,
|
|
6562
|
-
appDir: APP_DIR,
|
|
6563
|
-
readFile: (f) => fs.readFile(f, "utf8"),
|
|
6564
|
-
relative: path.relative,
|
|
6565
|
-
join: path.join,
|
|
6566
|
-
dirname: path.dirname,
|
|
6567
|
-
});
|
|
6568
|
-
if (run.status !== "ok") {
|
|
6569
|
-
console.warn(`[typecheck] wrapper rules ${run.status}: ${run.detail ?? "no detail"}`);
|
|
6570
|
-
return [];
|
|
6571
|
-
}
|
|
6572
|
-
return run.diagnostics;
|
|
6573
|
-
} catch (err) {
|
|
6574
|
-
console.warn(`[typecheck] wrapper rules threw: ${(err as Error)?.message ?? err}`);
|
|
6575
|
-
return [];
|
|
6576
|
-
}
|
|
6577
|
-
}
|
|
6578
|
-
|
|
6579
6299
|
// ────────────────────────────────────────────────────────────────────────
|
|
6580
6300
|
// HTTP server
|
|
6581
6301
|
// ────────────────────────────────────────────────────────────────────────
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { X509Certificate } from "node:crypto";
|
|
3
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { generateHostCert } from "./certificate.js";
|
|
7
|
+
|
|
8
|
+
test("long ingress names retain their full SAN while the diagnostic CN stays bounded", async () => {
|
|
9
|
+
const dir = await mkdtemp(join(tmpdir(), "spectest-leaf-"));
|
|
10
|
+
const ca = join(dir, "ca.crt");
|
|
11
|
+
const key = join(dir, "ca.key");
|
|
12
|
+
try {
|
|
13
|
+
const root = Bun.spawnSync(["openssl", "req", "-x509", "-newkey", "rsa:2048",
|
|
14
|
+
"-nodes", "-days", "1", "-subj", "/CN=test-ca", "-keyout", key, "-out", ca]);
|
|
15
|
+
expect(root.exitCode, root.stderr.toString()).toBe(0);
|
|
16
|
+
// Long enough to exceed both the former CN and temporary filename limits.
|
|
17
|
+
const host = `${"a".repeat(63)}.${"b".repeat(63)}.${"c".repeat(63)}.${"d".repeat(61)}`;
|
|
18
|
+
const { cert, key: leafKey } = await generateHostCert(host, [host, "*.example.test"], ca, key);
|
|
19
|
+
const leaf = new X509Certificate(cert);
|
|
20
|
+
expect(leaf.subject.length).toBeLessThanOrEqual(67); // "CN=" + 64 bytes
|
|
21
|
+
expect(leaf.checkHost(host)).toBe(host);
|
|
22
|
+
expect(leaf.checkHost("api.example.test")).toBe("*.example.test");
|
|
23
|
+
expect(leafKey).toContain("PRIVATE KEY");
|
|
24
|
+
const file = join(dir, "leaf.crt");
|
|
25
|
+
await writeFile(file, cert);
|
|
26
|
+
const verified = Bun.spawnSync(["openssl", "verify", "-CAfile", ca, "-verify_hostname", host, file]);
|
|
27
|
+
expect(verified.exitCode, verified.stderr.toString()).toBe(0);
|
|
28
|
+
} finally {
|
|
29
|
+
await rm(dir, { recursive: true, force: true });
|
|
30
|
+
}
|
|
31
|
+
}, 10_000);
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { promises as fs } from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Generate a leaf cert + key for one ingress route (fake or service
|
|
11
|
+
* proxy), signed by the in-VM root CA. SANs cover every
|
|
12
|
+
* hostname the route answers to, so a client connecting with TLS
|
|
13
|
+
* verifies cleanly regardless of which hostname it used. Shells out
|
|
14
|
+
* to `openssl req -x509 -CA ... -CAkey ...` (OpenSSL 3.0+; Debian
|
|
15
|
+
* bookworm ships 3.0.x).
|
|
16
|
+
*
|
|
17
|
+
* `label` is a diagnostic tag baked into the cert Subject CN and the temp
|
|
18
|
+
* file names — only used for diagnostics, not for TLS verification.
|
|
19
|
+
*/
|
|
20
|
+
export async function generateHostCert(
|
|
21
|
+
label: string,
|
|
22
|
+
hostnames: string[],
|
|
23
|
+
caPath: string,
|
|
24
|
+
caKeyPath: string,
|
|
25
|
+
): Promise<{ cert: string; key: string }> {
|
|
26
|
+
// CN is diagnostic only; SANs retain the full hostnames. Bound the label
|
|
27
|
+
// for both OpenSSL's 64-byte CN limit and filesystem component limits.
|
|
28
|
+
const commonName = `spectest-${label.replace(/[^a-zA-Z0-9.-]/g, "_").slice(0, 55)}`;
|
|
29
|
+
const id = `${commonName}-${randomUUID()}`;
|
|
30
|
+
const keyPath = path.join("/tmp", `${id}.key`);
|
|
31
|
+
const crtPath = path.join("/tmp", `${id}.crt`);
|
|
32
|
+
const sans = hostnames.map((h) => `DNS:${h}`).join(",");
|
|
33
|
+
const args = [
|
|
34
|
+
"req",
|
|
35
|
+
"-newkey",
|
|
36
|
+
"rsa:2048",
|
|
37
|
+
"-nodes",
|
|
38
|
+
"-keyout",
|
|
39
|
+
keyPath,
|
|
40
|
+
"-out",
|
|
41
|
+
crtPath,
|
|
42
|
+
"-x509",
|
|
43
|
+
"-CA",
|
|
44
|
+
caPath,
|
|
45
|
+
"-CAkey",
|
|
46
|
+
caKeyPath,
|
|
47
|
+
"-days",
|
|
48
|
+
"3650",
|
|
49
|
+
"-subj",
|
|
50
|
+
`/CN=${commonName}`,
|
|
51
|
+
"-addext",
|
|
52
|
+
`subjectAltName=${sans}`,
|
|
53
|
+
"-addext",
|
|
54
|
+
"basicConstraints=CA:FALSE",
|
|
55
|
+
"-addext",
|
|
56
|
+
"extendedKeyUsage=serverAuth",
|
|
57
|
+
"-addext",
|
|
58
|
+
"keyUsage=digitalSignature,keyEncipherment",
|
|
59
|
+
];
|
|
60
|
+
try {
|
|
61
|
+
await execFileAsync("openssl", args, { timeout: 30_000 });
|
|
62
|
+
const [cert, key] = await Promise.all([
|
|
63
|
+
fs.readFile(crtPath, "utf8"),
|
|
64
|
+
fs.readFile(keyPath, "utf8"),
|
|
65
|
+
]);
|
|
66
|
+
return { cert, key };
|
|
67
|
+
} finally {
|
|
68
|
+
await Promise.all([
|
|
69
|
+
fs.unlink(keyPath).catch(() => {}),
|
|
70
|
+
fs.unlink(crtPath).catch(() => {}),
|
|
71
|
+
]);
|
|
72
|
+
}
|
|
73
|
+
}
|