@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/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.some((record) => record.hash === current)) return [];
144
+ if (isRecorded(records, current)) return [];
105
145
  return [
106
146
  {
107
147
  code: 'X_DB_DRIFT',
@@ -36,6 +36,7 @@ export const CATALOG_PACKAGES = [
36
36
  '@ultimat3/realtime',
37
37
  '@ultimat3/render',
38
38
  '@ultimat3/schema',
39
+ '@ultimat3/scraping',
39
40
  '@ultimat3/seo',
40
41
  '@ultimat3/storage',
41
42
  '@ultimat3/testing',
@@ -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 = Bun.spawn([head, ...rest], {
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(),
@@ -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 { checkSourceDrift, recordedHashes, schemaHash, writeSchemaHash } from './drift';
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, quoteArg, reproduceFor, runShards, shardArgs } from './test-shards';
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`
@@ -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 server = Bun.serve({
49
- port: options.port ?? DEFAULT_METRICS_PORT,
50
- hostname: options.hostname ?? 'localhost',
51
- fetch(request: Request): Response {
52
- if (new URL(request.url).pathname !== METRICS_PATH) {
53
- return new Response('not found', { status: 404 });
54
- }
55
- // `collectMetrics()` is cumulative and never reset by a read, so two scrapers cannot steal
56
- // each other's samples — but a cache would hand the second one a stale window.
57
- return new Response(metricsText(), {
58
- headers: { 'content-type': METRICS_CONTENT_TYPE, 'cache-control': 'no-store' },
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("'\\''")}'`;
@@ -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;
@@ -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(2, Math.min(WORKER_CEILING, Math.round(available * WORKER_OVERSUBSCRIBE)));
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
- /** Index just past the closing quote of the literal opening at `from`, or the end of the text. */
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 message.
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
- .json()
57
+ .text()
58
+ .then(parseJsonc)
34
59
  .catch(() => undefined);
35
60
  const references =
36
61
  typeof payload === 'object' && payload !== null