@ultimat3/cli 2.0.0 → 3.0.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/CLAUDE.md +40 -3
- package/README.md +1 -0
- package/package.json +24 -24
- package/src/budgets.ts +23 -3
- package/src/cmd-db-branch.ts +6 -2
- package/src/cmd-db.ts +138 -10
- package/src/cmd-dev.ts +9 -2
- package/src/cmd-doctor.ts +16 -7
- package/src/cmd-new.ts +1 -1
- package/src/cmd-test.ts +14 -3
- package/src/cmd-verify.ts +25 -7
- package/src/db-branch.ts +18 -0
- package/src/db-generate.ts +38 -6
- package/src/db-seed.ts +294 -0
- package/src/dev-assets.ts +22 -3
- package/src/dev-roles.ts +5 -3
- package/src/dev-storage.ts +6 -4
- package/src/dev-traces.ts +26 -4
- package/src/drift.ts +41 -1
- package/src/error-catalog.ts +1 -0
- package/src/error-codes.ts +6 -0
- package/src/exec.ts +42 -8
- package/src/flag-number.ts +11 -0
- package/src/index.ts +11 -4
- package/src/mcp-errors.ts +8 -0
- package/src/messages.ts +12 -0
- package/src/metrics-endpoint.ts +60 -13
- package/src/serve.ts +15 -3
- package/src/shell-quote.ts +15 -0
- package/src/test-shards.ts +1 -10
- package/src/test-workers.ts +4 -1
- package/src/ts-scan.ts +13 -2
- package/src/tsconfig-references.ts +27 -2
package/src/drift.ts
CHANGED
|
@@ -62,6 +62,41 @@ export async function writeSchemaHash(root: string, migrationId: string): Promis
|
|
|
62
62
|
return hash;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
/**
|
|
66
|
+
* "Some migration recorded this hash" — the one predicate, read by the check below AND by the
|
|
67
|
+
* reconcile above it. Two spellings would let `x db gen` report the sidecar written while
|
|
68
|
+
* `x verify` still reports drift over the same two files.
|
|
69
|
+
*/
|
|
70
|
+
const isRecorded = (records: readonly MigrationRecord[], hash: string): boolean =>
|
|
71
|
+
records.some((record) => record.hash === hash);
|
|
72
|
+
|
|
73
|
+
export interface HashReconciliation {
|
|
74
|
+
readonly hash: string;
|
|
75
|
+
/** False when a sidecar already held this hash — nothing was written, and nothing needed to be. */
|
|
76
|
+
readonly written: boolean;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Re-record the sidecar for a migration that is already the right one. `SCHEMA_GLOB` covers every
|
|
81
|
+
* non-test file under `packages/db/src`, not only the ones that imply DDL, so editing a seed or a
|
|
82
|
+
* helper moves the hash with no diff behind it — and `X_DB_DRIFT`'s `fix:` has to have somewhere to
|
|
83
|
+
* land or the instruction is unfollowable. The caller owes the proof that the DDL genuinely did not
|
|
84
|
+
* move (`db-generate.ts` reaches this only on an empty diff off a fully loaded registry); this
|
|
85
|
+
* function decides only whether a write is needed.
|
|
86
|
+
*
|
|
87
|
+
* A hash an OLDER migration recorded is left alone: `checkSourceDrift` already answers clean on it,
|
|
88
|
+
* and stamping the newest sidecar would claim that migration produced a schema it did not.
|
|
89
|
+
*/
|
|
90
|
+
export async function reconcileSchemaHash(
|
|
91
|
+
root: string,
|
|
92
|
+
migrationId: string,
|
|
93
|
+
): Promise<HashReconciliation> {
|
|
94
|
+
const hash = await schemaHash(root);
|
|
95
|
+
if (isRecorded(await recordedHashes(root), hash)) return { hash, written: false };
|
|
96
|
+
await Bun.write(join(root, MIGRATIONS_DIR, hashFileName(migrationId)), `${hash}\n`);
|
|
97
|
+
return { hash, written: true };
|
|
98
|
+
}
|
|
99
|
+
|
|
65
100
|
/**
|
|
66
101
|
* How many entities the app declares. Injected so this module's own tests need no app on disk, and
|
|
67
102
|
* so a caller that has already loaded the app can answer without loading it twice.
|
|
@@ -90,6 +125,11 @@ export async function checkSourceDrift(
|
|
|
90
125
|
// `x db gen "initial"`, which has an empty diff there, writes no `.hash`, and exits ok: a fix
|
|
91
126
|
// that succeeds and changes nothing. Drift resumes the moment the author declares an entity,
|
|
92
127
|
// and by then the fix genuinely writes one.
|
|
128
|
+
//
|
|
129
|
+
// An empty diff still writes no `.hash` HERE, and must: `reconcileSchemaHash` needs a migration
|
|
130
|
+
// id to record against and there is no migration at all in this branch. With an entity declared
|
|
131
|
+
// the diff is never empty — a registry against zero migrations is `create table` for all of
|
|
132
|
+
// it — so this branch's fix stays the real generation it always was.
|
|
93
133
|
if ((await declaredEntities()) === 0) return [];
|
|
94
134
|
return [
|
|
95
135
|
{
|
|
@@ -101,7 +141,7 @@ export async function checkSourceDrift(
|
|
|
101
141
|
},
|
|
102
142
|
];
|
|
103
143
|
}
|
|
104
|
-
if (records
|
|
144
|
+
if (isRecorded(records, current)) return [];
|
|
105
145
|
return [
|
|
106
146
|
{
|
|
107
147
|
code: 'X_DB_DRIFT',
|
package/src/error-catalog.ts
CHANGED
package/src/error-codes.ts
CHANGED
|
@@ -67,6 +67,11 @@ export const CLI_OWNED_ERROR_CODES = [
|
|
|
67
67
|
'X_DB_MIGRATE_FAILED',
|
|
68
68
|
'X_DB_BRANCH_FAILED',
|
|
69
69
|
'X_DB_STUDIO_FAILED',
|
|
70
|
+
// Refusing to seed production is a REFUSAL, not a malformed invocation, so it is not
|
|
71
|
+
// `X_CLI_BAD_FLAG`: the argv was well formed and the answer is no. Its own code is what lets
|
|
72
|
+
// `x errors explain` hand back the one remedy — name the tier — instead of the flag code's
|
|
73
|
+
// "unknown flag, missing value, or a value the command refuses", which covers a dozen causes.
|
|
74
|
+
'X_SEED_ENVIRONMENT',
|
|
70
75
|
// The five app-surface boundary codes. `@ultimat3/render` owns the *rule* (`checkSurfaceBoundary`)
|
|
71
76
|
// and the CLI owns the diagnostic, because `x verify` and `x fix boundary` are the two commands
|
|
72
77
|
// that report it — see `app-boundaries.ts`, which holds the one rule-to-code table.
|
|
@@ -165,6 +170,7 @@ export const CLI_ERROR_TITLES: Readonly<Record<CliOwnedErrorCode, string>> = {
|
|
|
165
170
|
X_DB_MIGRATE_FAILED: 'x db migrate failed',
|
|
166
171
|
X_DB_BRANCH_FAILED: 'an x db branch step failed',
|
|
167
172
|
X_DB_STUDIO_FAILED: 'x db studio failed',
|
|
173
|
+
X_SEED_ENVIRONMENT: 'the seed tier is not one this environment runs',
|
|
168
174
|
X_BOUNDARY_SITE_TO_APP: 'site/ imported app/',
|
|
169
175
|
X_BOUNDARY_SHARED_LEAF: 'shared/ imported a surface',
|
|
170
176
|
X_BOUNDARY_APP_TO_API: 'app/ imported api/ at runtime',
|
package/src/exec.ts
CHANGED
|
@@ -5,7 +5,10 @@
|
|
|
5
5
|
// `UltimateError` straight from core rather than a class in `./errors`: this module is imported by
|
|
6
6
|
// every command, and `./errors` runs `registerErrorCodes` on import — a subprocess boundary must
|
|
7
7
|
// not decide when the CLI's registry is populated. `X_CLI_UNEXPECTED` is owned there all the same.
|
|
8
|
-
import { UltimateError } from '@ultimat3/core';
|
|
8
|
+
import { renderThrowable, UltimateError } from '@ultimat3/core';
|
|
9
|
+
// `shell-quote.ts` is a leaf — it imports nothing, so the subprocess boundary stays importable
|
|
10
|
+
// from anywhere, this file's header rule about a single boundary included.
|
|
11
|
+
import { quoteArg } from './shell-quote';
|
|
9
12
|
|
|
10
13
|
export interface ExecResult {
|
|
11
14
|
readonly command: readonly string[];
|
|
@@ -27,6 +30,43 @@ export type Runner = (command: readonly string[], options: ExecOptions) => Promi
|
|
|
27
30
|
/** performance.now(), not Date.now(): the test preload freezes the wall clock on purpose. */
|
|
28
31
|
const now = (): number => performance.now();
|
|
29
32
|
|
|
33
|
+
/**
|
|
34
|
+
* The failure mode this file's header already promised was identical everywhere, and the one it
|
|
35
|
+
* never coded. `x deploy` on a machine without `docker` threw Bun's own `Error: Executable not
|
|
36
|
+
* found in $PATH`; `dispatch.ts` rendered it as `X_CLI_UNEXPECTED` with `fix: x doctor --json`,
|
|
37
|
+
* and `runDoctor` checks nothing about an absent binary — an instruction that cannot close the
|
|
38
|
+
* error is axiom 4 inverted. The code stays `X_CLI_UNEXPECTED` (the CLI already owns it for a
|
|
39
|
+
* failure of its own machinery); what changes is that the fix names the program to install.
|
|
40
|
+
*
|
|
41
|
+
* The program name goes through `quoteArg` at BOTH references: `docker compose` or any name a
|
|
42
|
+
* shell would resplit produced a `fix:` that runs something else, which is axiom 4 inverted twice
|
|
43
|
+
* in one line.
|
|
44
|
+
*
|
|
45
|
+
* The thrown value goes through core's render helper and is never interpolated: an `unknown`
|
|
46
|
+
* reaching a `cause:` through `${…}` is what `bun run error-render` refuses, and this one is
|
|
47
|
+
* genuinely unknown — Bun raises `ENOENT` for a missing program, `EACCES` for an unrunnable one.
|
|
48
|
+
*
|
|
49
|
+
* The return type is inferred so this stays one statement of `Bun.spawn`'s own shape.
|
|
50
|
+
*/
|
|
51
|
+
function spawnOrRefuse(command: readonly string[], options: ExecOptions) {
|
|
52
|
+
const [head = '', ...rest] = command;
|
|
53
|
+
try {
|
|
54
|
+
return Bun.spawn([head, ...rest], {
|
|
55
|
+
cwd: options.cwd,
|
|
56
|
+
env: options.env === undefined ? Bun.env : { ...Bun.env, ...options.env },
|
|
57
|
+
stdin: options.stdin === undefined ? 'ignore' : new TextEncoder().encode(options.stdin),
|
|
58
|
+
stdout: 'pipe',
|
|
59
|
+
stderr: 'pipe',
|
|
60
|
+
});
|
|
61
|
+
} catch (error) {
|
|
62
|
+
throw new UltimateError({
|
|
63
|
+
code: 'X_CLI_UNEXPECTED',
|
|
64
|
+
cause: `the CLI could not run "${head}" from ${options.cwd}: ${renderThrowable(error)}`,
|
|
65
|
+
fix: `install ${quoteArg(head)} and put it on PATH, then re-run — confirm with: command -v ${quoteArg(head)}`,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
30
70
|
export const exec: Runner = async (command, options) => {
|
|
31
71
|
const started = now();
|
|
32
72
|
const [head, ...rest] = command;
|
|
@@ -40,13 +80,7 @@ export const exec: Runner = async (command, options) => {
|
|
|
40
80
|
fix: 'pass the program as the first element: exec(["bun", "test"], { cwd })',
|
|
41
81
|
});
|
|
42
82
|
}
|
|
43
|
-
const proc =
|
|
44
|
-
cwd: options.cwd,
|
|
45
|
-
env: options.env === undefined ? Bun.env : { ...Bun.env, ...options.env },
|
|
46
|
-
stdin: options.stdin === undefined ? 'ignore' : new TextEncoder().encode(options.stdin),
|
|
47
|
-
stdout: 'pipe',
|
|
48
|
-
stderr: 'pipe',
|
|
49
|
-
});
|
|
83
|
+
const proc = spawnOrRefuse([head, ...rest], options);
|
|
50
84
|
const [stdout, stderr, code] = await Promise.all([
|
|
51
85
|
new Response(proc.stdout).text(),
|
|
52
86
|
new Response(proc.stderr).text(),
|
package/src/flag-number.ts
CHANGED
|
@@ -54,3 +54,14 @@ export const intFlagOr = (args: ParsedArgs, flag: IntFlag, fallback: number): nu
|
|
|
54
54
|
* free port. Two ranges for one concept is the drift this constant exists to prevent.
|
|
55
55
|
*/
|
|
56
56
|
export const PORT_RANGE = { min: 0, max: 65_535 } as const;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A free-port suggestion the thing being fixed will actually accept. `port + 1` at the top of the
|
|
60
|
+
* range names 65536, which is not a port — so a `fix:` built that way reproduces a failure instead
|
|
61
|
+
* of ending one: `x doctor` emitted `x dev --port 65536`, which `x dev` refuses with
|
|
62
|
+
* `X_CLI_BAD_FLAG`. The neighbour below is a port; the one above does not exist. Here rather than
|
|
63
|
+
* beside either caller, because two ports-are-bounded rules is the drift `PORT_RANGE` above
|
|
64
|
+
* already exists to prevent.
|
|
65
|
+
*/
|
|
66
|
+
export const neighbouringPort = (port: number): number =>
|
|
67
|
+
port < PORT_RANGE.max ? port + 1 : PORT_RANGE.max - 1;
|
package/src/index.ts
CHANGED
|
@@ -75,7 +75,7 @@ export {
|
|
|
75
75
|
pgliteBranchName,
|
|
76
76
|
previewUrl,
|
|
77
77
|
} from './db-branch';
|
|
78
|
-
export type { GeneratedFiles, GenerateMigrationOptions } from './db-generate';
|
|
78
|
+
export type { GeneratedFiles, GenerateMigrationOptions, GenerateOutcome } from './db-generate';
|
|
79
79
|
export { generateAppMigration, migrationSql } from './db-generate';
|
|
80
80
|
export type { AssetRoutesOptions } from './dev-assets';
|
|
81
81
|
export {
|
|
@@ -99,8 +99,14 @@ export type { DevServices, ServiceBinding } from './dev-services';
|
|
|
99
99
|
export { describeServices, resolveServices } from './dev-services';
|
|
100
100
|
export type { DispatchOptions } from './dispatch';
|
|
101
101
|
export { dispatch } from './dispatch';
|
|
102
|
-
export type { DeclaredEntityCount } from './drift';
|
|
103
|
-
export {
|
|
102
|
+
export type { DeclaredEntityCount, HashReconciliation } from './drift';
|
|
103
|
+
export {
|
|
104
|
+
checkSourceDrift,
|
|
105
|
+
reconcileSchemaHash,
|
|
106
|
+
recordedHashes,
|
|
107
|
+
schemaHash,
|
|
108
|
+
writeSchemaHash,
|
|
109
|
+
} from './drift';
|
|
104
110
|
export type { ErrorCatalog } from './error-catalog';
|
|
105
111
|
export {
|
|
106
112
|
buildErrorCatalog,
|
|
@@ -213,6 +219,7 @@ export {
|
|
|
213
219
|
runRole,
|
|
214
220
|
serveApp,
|
|
215
221
|
} from './serve';
|
|
222
|
+
export { quoteArg } from './shell-quote';
|
|
216
223
|
export {
|
|
217
224
|
eachSourceFile,
|
|
218
225
|
isGenerated,
|
|
@@ -225,7 +232,7 @@ export { countsOf } from './test-counts';
|
|
|
225
232
|
export type { TestFile } from './test-select';
|
|
226
233
|
export { belongsToType, discoverTests, sampleFiles } from './test-select';
|
|
227
234
|
export type { ReproduceOptions, RunShardsOptions, Shard } from './test-shards';
|
|
228
|
-
export { planShards,
|
|
235
|
+
export { planShards, reproduceFor, runShards, shardArgs } from './test-shards';
|
|
229
236
|
export { availableCpus, defaultWorkers, WORKER_CEILING } from './test-workers';
|
|
230
237
|
export type { CodeFixSite, CodeSite, FixSite, SourceSite } from './ts-scan';
|
|
231
238
|
export {
|
package/src/mcp-errors.ts
CHANGED
|
@@ -93,6 +93,14 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
|
|
|
93
93
|
X_DB_MIGRATE_FAILED: 'x doctor --json # cause carries the Postgres error verbatim',
|
|
94
94
|
X_DB_BRANCH_FAILED: 'x db branch ls --json',
|
|
95
95
|
X_DB_STUDIO_FAILED: 'x doctor --json',
|
|
96
|
+
// Runnable first, the narrowing behind a `#`, exactly as X_CLI_UNKNOWN_COMMAND above: naming
|
|
97
|
+
// the tier IS the consent, and which seed to consent to is the one thing this table cannot
|
|
98
|
+
// know — a bare `x db seed --tier dev` would seed every dev fixture in production to answer a
|
|
99
|
+
// refusal about one. The dry run is what lists them, and the raised error's own `fix:` already
|
|
100
|
+
// carries the fully named invocation. `ULTIMATE_SEED_TIER=<tier>` is the other half of the
|
|
101
|
+
// consent and stays in the cause: it is the answer only for a container with a fixed argv.
|
|
102
|
+
X_SEED_ENVIRONMENT:
|
|
103
|
+
'x db seed --dry-run --json # then name the tier: x db seed <name> --tier dev --json',
|
|
96
104
|
X_BOUNDARY_SITE_TO_APP:
|
|
97
105
|
'x verify --json # then: x fix boundary <the file the finding names> --json',
|
|
98
106
|
X_BOUNDARY_SHARED_LEAF:
|
package/src/messages.ts
CHANGED
|
@@ -46,10 +46,22 @@ const CATALOG = {
|
|
|
46
46
|
'cli.db.branch.unknown': '-',
|
|
47
47
|
'cli.db.gen.failed': 'migration not generated',
|
|
48
48
|
'cli.db.gen.unchanged': 'entities and migrations agree — nothing to generate',
|
|
49
|
+
// A THIRD outcome, and it is neither of the other two: nothing to generate, but the sidecar the
|
|
50
|
+
// `drift` step reads did move — an edit under `packages/db/src` that implies no DDL. Rendering it
|
|
51
|
+
// as `written` would name a migration nobody can apply; as `unchanged`, it would hide a file this
|
|
52
|
+
// command wrote. `GeneratedFiles.outcome` is what `--json` carries the same distinction on.
|
|
53
|
+
'cli.db.gen.recorded': 'no migration needed — schema hash re-recorded in {file}',
|
|
49
54
|
'cli.db.gen.written': 'migration {id} generated',
|
|
50
55
|
'cli.db.migrate.applied': 'migrations applied',
|
|
51
56
|
'cli.db.migrate.failed': 'migration failed',
|
|
52
57
|
'cli.db.reset.done': 'database reset and migrated',
|
|
58
|
+
// Every seed counted per outcome, exactly as the backfill summary is: a replayed seed writes
|
|
59
|
+
// nothing and skips everything, and a total that hid that would make the second run look idle.
|
|
60
|
+
'cli.db.seed.done':
|
|
61
|
+
'{count} seed(s): {inserted} inserted, {updated} updated, {skipped} already stored',
|
|
62
|
+
'cli.db.seed.dryRun': '{count} seed(s) would run — nothing written while --dry-run is set',
|
|
63
|
+
'cli.db.seed.failed': '{failed} of {count} seed(s) failed',
|
|
64
|
+
'cli.db.seed.none': 'no seed matched — nothing to run',
|
|
53
65
|
'cli.dev.ready': 'dev ready on {url} — /_x mounted ({panels} panels), {services}',
|
|
54
66
|
// The mail and CDN halves of that boot line. Rendered text, so it lives here — while
|
|
55
67
|
// `describeMail`/`describeCdn` keep the same wording as the fixed vocabulary `x dev --json`
|
package/src/metrics-endpoint.ts
CHANGED
|
@@ -8,7 +8,11 @@ import {
|
|
|
8
8
|
METRICS_PATH,
|
|
9
9
|
markListening,
|
|
10
10
|
metricsText,
|
|
11
|
+
stringField,
|
|
12
|
+
UltimateError,
|
|
11
13
|
} from '@ultimat3/core';
|
|
14
|
+
import { docsFor } from './error-codes';
|
|
15
|
+
import { neighbouringPort } from './flag-number';
|
|
12
16
|
|
|
13
17
|
/**
|
|
14
18
|
* A port of its own, and NOT the role's HTTP port, for one reason the chart makes concrete:
|
|
@@ -25,6 +29,37 @@ import {
|
|
|
25
29
|
*/
|
|
26
30
|
export const DEFAULT_METRICS_PORT = 9090;
|
|
27
31
|
|
|
32
|
+
/**
|
|
33
|
+
* `X_PORT_IN_USE` is the code the CLI already registers for "this dev port is taken", and the
|
|
34
|
+
* scrape port is one — a synonym here would be a second code for one condition. The fix moves the
|
|
35
|
+
* port rather than naming a process to kill, because `METRICS_PORT` is the one knob both `x dev`
|
|
36
|
+
* and the container read (`serve.ts`'s `metricsPortFromEnv`).
|
|
37
|
+
*
|
|
38
|
+
* The port it names comes from `neighbouringPort`, never `port + 1`: at the top of the range
|
|
39
|
+
* that is 65536, and an instruction that cannot run is the failure this code exists to end.
|
|
40
|
+
*/
|
|
41
|
+
export class MetricsPortInUseError extends UltimateError {
|
|
42
|
+
constructor(input: { port: number }) {
|
|
43
|
+
super({
|
|
44
|
+
code: 'X_PORT_IN_USE',
|
|
45
|
+
cause: `the metrics port ${input.port} is already bound, so no role could open its scrape listener`,
|
|
46
|
+
fix: `METRICS_PORT=${neighbouringPort(input.port)} x dev --json`,
|
|
47
|
+
docs: docsFor('X_PORT_IN_USE'),
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Bun surfaces the bind failure as an `Error` carrying the libc code; nothing else is ours. Read
|
|
54
|
+
* through `stringField`, never `error instanceof Error` plus a property access: both run on a value
|
|
55
|
+
* this process did not build, and either can throw one line before the guard that was meant to make
|
|
56
|
+
* the path safe. Exported because whether the kernel refuses a second bind is the OS's business,
|
|
57
|
+
* not this package's — the contract worth pinning is that an EADDRINUSE-shaped throw becomes a
|
|
58
|
+
* coded refusal, and that is testable without racing a socket.
|
|
59
|
+
*/
|
|
60
|
+
export const isAddressInUse = (error: unknown): boolean =>
|
|
61
|
+
stringField(error, 'code') === 'EADDRINUSE';
|
|
62
|
+
|
|
28
63
|
export interface MetricsEndpointOptions {
|
|
29
64
|
/** 0 asks the kernel for an ephemeral port, which is what a test wants. */
|
|
30
65
|
readonly port?: number;
|
|
@@ -45,20 +80,32 @@ export interface MetricsEndpoint {
|
|
|
45
80
|
* signal at the moment of load is worse than no autoscaler.
|
|
46
81
|
*/
|
|
47
82
|
export function startMetricsEndpoint(options: MetricsEndpointOptions = {}): MetricsEndpoint {
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
83
|
+
const port = options.port ?? DEFAULT_METRICS_PORT;
|
|
84
|
+
// `startRoles` opens this FIRST, before any role, so `Bun.serve`'s own bare `Error` was what a
|
|
85
|
+
// second `x dev` on one machine reported: no code, no fix, at the boot path this package owns.
|
|
86
|
+
// The return type is inferred, keeping `Bun.serve`'s own shape stated once.
|
|
87
|
+
function listen() {
|
|
88
|
+
try {
|
|
89
|
+
return Bun.serve({
|
|
90
|
+
port,
|
|
91
|
+
hostname: options.hostname ?? 'localhost',
|
|
92
|
+
fetch(request: Request): Response {
|
|
93
|
+
if (new URL(request.url).pathname !== METRICS_PATH) {
|
|
94
|
+
return new Response('not found', { status: 404 });
|
|
95
|
+
}
|
|
96
|
+
// `collectMetrics()` is cumulative and never reset by a read, so two scrapers cannot
|
|
97
|
+
// steal each other's samples — but a cache would hand the second one a stale window.
|
|
98
|
+
return new Response(metricsText(), {
|
|
99
|
+
headers: { 'content-type': METRICS_CONTENT_TYPE, 'cache-control': 'no-store' },
|
|
100
|
+
});
|
|
101
|
+
},
|
|
59
102
|
});
|
|
60
|
-
}
|
|
61
|
-
|
|
103
|
+
} catch (error) {
|
|
104
|
+
if (!isAddressInUse(error)) throw error;
|
|
105
|
+
throw new MetricsPortInUseError({ port });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const server = listen();
|
|
62
109
|
// Same rule as every other socket the framework opens: announce it, so a request back to it is
|
|
63
110
|
// recognisably this process calling itself rather than egress the test seal must refuse.
|
|
64
111
|
const stopListening = markListening(server.url.origin);
|
package/src/serve.ts
CHANGED
|
@@ -86,6 +86,20 @@ export function metricsPortFromEnv(env: Env): number {
|
|
|
86
86
|
return portValue(env, 'METRICS_PORT', DEFAULT_METRICS_PORT);
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
/**
|
|
90
|
+
* The scrape port a boot uses, given the app port it already resolved. One expression, and it is
|
|
91
|
+
* exported because `x dev` is the second caller: `cmd-dev.ts` passed no `metricsPort` at all, so
|
|
92
|
+
* `METRICS_PORT` was honoured in the container and ignored on a laptop — the dev/prod parity break
|
|
93
|
+
* `dev-roles.ts`'s own header forbids, and a second copy of this rule would be the same break
|
|
94
|
+
* one edit later.
|
|
95
|
+
*
|
|
96
|
+
* An in-process caller asking for an ephemeral app port is a test, and a test that grabbed the
|
|
97
|
+
* fixed 9090 would fail the next suite to boot beside it. An environment that names the port still
|
|
98
|
+
* wins — that is the deploy talking.
|
|
99
|
+
*/
|
|
100
|
+
export const metricsPortFor = (env: Env, port: number, override?: number): number =>
|
|
101
|
+
override ?? (port === 0 && env['METRICS_PORT'] === undefined ? 0 : metricsPortFromEnv(env));
|
|
102
|
+
|
|
89
103
|
/**
|
|
90
104
|
* The one env var that turns error monitoring on, and the only vendor-shaped name in the boot
|
|
91
105
|
* path. Not a platform primitive (axiom 7): the value is a URL to whatever the operator runs, the
|
|
@@ -302,9 +316,7 @@ async function bootRoles(boot: {
|
|
|
302
316
|
// An in-process caller asking for an ephemeral app port is a test, and a test that grabbed the
|
|
303
317
|
// fixed 9090 would fail the next suite to boot beside it. An environment that names the port
|
|
304
318
|
// still wins — that is the deploy talking.
|
|
305
|
-
const metricsPort =
|
|
306
|
-
options.metricsPort ??
|
|
307
|
-
(port === 0 && options.env['METRICS_PORT'] === undefined ? 0 : metricsPortFromEnv(options.env));
|
|
319
|
+
const metricsPort = metricsPortFor(options.env, port, options.metricsPort);
|
|
308
320
|
const running = await startRoles({
|
|
309
321
|
roles: [role],
|
|
310
322
|
port,
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// POSIX single-quoting for a value the CLI pastes into a line a reader runs — a `fix:`, a
|
|
2
|
+
// reproduce command. Its own module, and not `test-shards.ts` where it started, because the
|
|
3
|
+
// subprocess boundary needs it too and `test-shards.ts` imports `exec.ts`: one leaf both can
|
|
4
|
+
// reach is the alternative to an import cycle or a second quoter.
|
|
5
|
+
|
|
6
|
+
const SHELL_SAFE = /^[\w@%+=:,./-]+$/;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A program name, a `--filter` or a path holding a space, a `$` or a `;` pastes back as two
|
|
10
|
+
* arguments or as a second command, so an unquoted line runs something other than what it claims.
|
|
11
|
+
* `'\''` is the only escape a single-quoted string has. A shell-safe value is left alone, so the
|
|
12
|
+
* common case stays readable.
|
|
13
|
+
*/
|
|
14
|
+
export const quoteArg = (value: string): string =>
|
|
15
|
+
SHELL_SAFE.test(value) ? value : `'${value.split("'").join("'\\''")}'`;
|
package/src/test-shards.ts
CHANGED
|
@@ -8,6 +8,7 @@ import type { Runner } from './exec';
|
|
|
8
8
|
import { execOutput } from './exec';
|
|
9
9
|
import { msg } from './messages';
|
|
10
10
|
import type { CommandResult, Finding, JsonValue, StepResult } from './output';
|
|
11
|
+
import { quoteArg } from './shell-quote';
|
|
11
12
|
import type { TestFile } from './test-select';
|
|
12
13
|
import { bySizeThenPath } from './test-select';
|
|
13
14
|
import type { TestType } from './verify-tests';
|
|
@@ -64,16 +65,6 @@ export const shardArgs = (shard: Shard): readonly string[] => [
|
|
|
64
65
|
...shard.files,
|
|
65
66
|
];
|
|
66
67
|
|
|
67
|
-
const SHELL_SAFE = /^[\w@%+=:,./-]+$/;
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* POSIX single-quoting for the values a caller supplies. A `--filter` holding a space, a `$` or a
|
|
71
|
-
* `;` pastes back as two arguments or as a second command, so an unquoted line would run something
|
|
72
|
-
* other than the run it claims to reproduce. `'\''` is the only escape a single-quoted string has.
|
|
73
|
-
*/
|
|
74
|
-
export const quoteArg = (value: string): string =>
|
|
75
|
-
SHELL_SAFE.test(value) ? value : `'${value.split("'").join("'\\''")}'`;
|
|
76
|
-
|
|
77
68
|
export interface ReproduceOptions {
|
|
78
69
|
/** The *effective* worker count: `planShards` clamps to the file count, and the split follows. */
|
|
79
70
|
readonly workers: number;
|
package/src/test-workers.ts
CHANGED
|
@@ -43,5 +43,8 @@ export const WORKER_CEILING = 8;
|
|
|
43
43
|
/** Oversubscription factor. See the table above — it is measured, not chosen for roundness. */
|
|
44
44
|
export const WORKER_OVERSUBSCRIBE = 1.5;
|
|
45
45
|
|
|
46
|
+
/** The floor the paragraph above names: a 1-core box shards rather than reverting to serial. */
|
|
47
|
+
export const WORKER_FLOOR = 2;
|
|
48
|
+
|
|
46
49
|
export const defaultWorkers = (available: number = availableCpus()): number =>
|
|
47
|
-
Math.max(
|
|
50
|
+
Math.max(WORKER_FLOOR, Math.min(WORKER_CEILING, Math.round(available * WORKER_OVERSUBSCRIBE)));
|
package/src/ts-scan.ts
CHANGED
|
@@ -28,14 +28,23 @@ const REGEX_AFTER_WORDS = new Set(
|
|
|
28
28
|
'await case delete do else in instanceof new of return throw typeof void yield'.split(' '),
|
|
29
29
|
);
|
|
30
30
|
|
|
31
|
-
/**
|
|
31
|
+
/**
|
|
32
|
+
* Index just past the closing quote of the literal opening at `from`, or `from + 1` when a `'`/`"`
|
|
33
|
+
* does not close on its own line — which makes it text, not a literal. Only a template literal may
|
|
34
|
+
* span a newline, so the apostrophe in `<p>Don't panic</p>` is JSX text; read as an opener it ran
|
|
35
|
+
* forward to the next `'` in the FILE (the next `fix:` line) and blanked every declaration between,
|
|
36
|
+
* silently emptying the `errors` gate for the whole file. Same rule `endOfRegex` applies to a `/`.
|
|
37
|
+
* An escaped newline is still a continuation: the escape is consumed before the line test.
|
|
38
|
+
*/
|
|
32
39
|
function endOfLiteral(text: string, from: number): number {
|
|
33
40
|
const quote = text[from] as string;
|
|
41
|
+
const spansLines = quote === '`';
|
|
34
42
|
for (let i = from + 1; i < text.length; i += 1) {
|
|
35
43
|
if (text[i] === '\\') i += 1;
|
|
36
44
|
else if (text[i] === quote) return i + 1;
|
|
45
|
+
else if (!spansLines && text[i] === '\n') return from + 1;
|
|
37
46
|
}
|
|
38
|
-
return text.length;
|
|
47
|
+
return spansLines ? text.length : from + 1;
|
|
39
48
|
}
|
|
40
49
|
|
|
41
50
|
/**
|
|
@@ -156,6 +165,8 @@ function valueLiterals(
|
|
|
156
165
|
const ch = masked[i] as string;
|
|
157
166
|
if (QUOTES.has(ch)) {
|
|
158
167
|
const end = endOfLiteral(masked, i);
|
|
168
|
+
// A quote that never closes is one character of code, not an empty literal to report.
|
|
169
|
+
if (end === i + 1) continue;
|
|
159
170
|
if (depth === 0) found.push({ value: source.slice(i + 1, end - 1), index: i });
|
|
160
171
|
i = end - 1;
|
|
161
172
|
} else if (OPENERS.has(ch)) depth += 1;
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { join } from 'node:path';
|
|
10
10
|
import { docsFor } from './error-codes';
|
|
11
11
|
import type { Finding } from './output';
|
|
12
|
+
import { maskLiterals, stripComments } from './ts-scan';
|
|
12
13
|
|
|
13
14
|
const ROOT_TSCONFIG = 'tsconfig.json';
|
|
14
15
|
|
|
@@ -22,15 +23,39 @@ const ROOT_TSCONFIG = 'tsconfig.json';
|
|
|
22
23
|
export const normalizeReferencePath = (path: string): string =>
|
|
23
24
|
path.replace(/^\.\//, '').replace(/\/+$/, '');
|
|
24
25
|
|
|
26
|
+
/**
|
|
27
|
+
* A tsconfig is JSONC and `JSON.parse` is not, so the comments and trailing commas `tsc` accepts —
|
|
28
|
+
* and `tsc --init` writes — are removed before the parse. `Bun.file().json()` rejects both, and
|
|
29
|
+
* the rejection was mapped to "this root does not use project references": the check went dark on
|
|
30
|
+
* exactly the roots most likely to have been written by hand, while `typecheck` stayed green over
|
|
31
|
+
* every package it then skipped. `stripComments` is `ts-scan.ts`'s, so a `//` inside a string is a
|
|
32
|
+
* URL and not a comment; the trailing-comma pass reads MASKED text for the same reason.
|
|
33
|
+
*/
|
|
34
|
+
function parseJsonc(text: string): unknown {
|
|
35
|
+
const uncommented = stripComments(text);
|
|
36
|
+
const masked = maskLiterals(uncommented);
|
|
37
|
+
const chars = [...uncommented];
|
|
38
|
+
for (let i = 0; i < masked.length; i += 1) {
|
|
39
|
+
if (masked[i] !== ',') continue;
|
|
40
|
+
let next = i + 1;
|
|
41
|
+
while (next < masked.length && /\s/.test(masked[next] as string)) next += 1;
|
|
42
|
+
if (masked[next] === '}' || masked[next] === ']') chars[i] = ' ';
|
|
43
|
+
}
|
|
44
|
+
return JSON.parse(chars.join('')) as unknown;
|
|
45
|
+
}
|
|
46
|
+
|
|
25
47
|
/**
|
|
26
48
|
* `undefined` means "this root does not use project references" — a different repo shape, not an
|
|
27
49
|
* empty graph. A scaffolded app is that shape (`extends` + `include`, no references at all), and
|
|
28
50
|
* telling its author to add an entry to a list that does not exist is a fix that makes the build
|
|
29
|
-
* worse. A tsconfig that will not parse is `typecheck`'s to report, with tsc's own
|
|
51
|
+
* worse. A tsconfig that will not parse EVEN AS JSONC is `typecheck`'s to report, with tsc's own
|
|
52
|
+
* message — this check has no code that would mean "the file is broken" and must not borrow one
|
|
53
|
+
* that means something else.
|
|
30
54
|
*/
|
|
31
55
|
async function referencedPaths(root: string): Promise<ReadonlySet<string> | undefined> {
|
|
32
56
|
const payload: unknown = await Bun.file(join(root, ROOT_TSCONFIG))
|
|
33
|
-
.
|
|
57
|
+
.text()
|
|
58
|
+
.then(parseJsonc)
|
|
34
59
|
.catch(() => undefined);
|
|
35
60
|
const references =
|
|
36
61
|
typeof payload === 'object' && payload !== null
|