@ultimat3/cli 11.1.0 → 11.2.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.
@@ -0,0 +1,90 @@
1
+ // The server a shot is taken against, and the hosts the page may reach while it is: the two
2
+ // facts a ROUTE capture and a COMPONENT capture share, and the only ones. Its own file so
3
+ // `island-shot.ts` can have them without importing the command that photographs a route, which
4
+ // would be a cycle between two files that otherwise have nothing to say to each other.
5
+
6
+ // why: no Bun native joins a path; `.x/shot` is a path both capture paths write under.
7
+ import { join } from 'node:path';
8
+ import { startDev } from './cmd-dev';
9
+ import { clearLock, isProcessAlive, lockPath, parseLock, preflight, writeLock } from './dev-lock';
10
+ import { DEV_BINDING } from './dev-roles';
11
+ import { resolveServices } from './dev-services';
12
+
13
+ export const SHOT_DIR = join('.x', 'shot');
14
+
15
+ /**
16
+ * How `devServerFor` starts a scratch server. A parameter with a default rather than a direct
17
+ * call, for the reason every `Runner` in this package is one: the failure path below — a boot that
18
+ * throws, and the lock it has to hand back — is otherwise only reachable by breaking a real app.
19
+ */
20
+ export type BootDevServer = (input: {
21
+ readonly root: string;
22
+ readonly port: number;
23
+ readonly env: Readonly<Record<string, string | undefined>>;
24
+ }) => Promise<{ readonly url: string; stop(): Promise<void> }>;
25
+
26
+ export interface ShotServer {
27
+ readonly url: string;
28
+ /** Which server the picture is of. Reported, because the two have different failure modes. */
29
+ readonly origin: 'booted' | 'reused';
30
+ stop(): Promise<void>;
31
+ }
32
+
33
+ /**
34
+ * One rule, two branches: photograph the `x dev` this checkout already has, or boot a scratch one.
35
+ * Reusing is not a convenience — embedded Postgres is a single-writer directory, so a second boot
36
+ * on one checkout is `X_DEV_ALREADY_RUNNING` and the picture would never be taken at all.
37
+ */
38
+ export async function devServerFor(
39
+ root: string,
40
+ env: Readonly<Record<string, string | undefined>>,
41
+ port: number,
42
+ boot: BootDevServer = (input) => startDev(input),
43
+ ): Promise<ShotServer> {
44
+ const services = resolveServices(root, env);
45
+ const file = Bun.file(lockPath(services.stateDir));
46
+ if (await file.exists()) {
47
+ const lock = parseLock(await file.text());
48
+ if (lock !== null && isProcessAlive(lock.pid)) {
49
+ return { url: lock.url, origin: 'reused', stop: () => Promise.resolve() };
50
+ }
51
+ }
52
+ const { release } = await preflight({
53
+ stateDir: services.stateDir,
54
+ port,
55
+ hostname: DEV_BINDING.hostname,
56
+ embeddedDb: services.db.mode === 'embedded',
57
+ });
58
+ // The directory is CLAIMED from here down — `preflight` returns holding it, never having merely
59
+ // looked — so a boot that throws has to give it back. `cmd-dev.ts` states the same rule at the
60
+ // same seam. Without it one failed `x shot` refused every later `x dev` and `x shot` on this
61
+ // checkout, naming a pid that had already exited. The original error is re-thrown untouched: a
62
+ // teardown must never replace the failure it is cleaning up after.
63
+ const dev = await boot({ root, port, env }).catch((error: unknown) => {
64
+ release();
65
+ throw error;
66
+ });
67
+ await writeLock(services.stateDir, {
68
+ pid: process.pid,
69
+ port,
70
+ url: dev.url,
71
+ startedAt: new Date().toISOString(),
72
+ });
73
+ return {
74
+ url: dev.url,
75
+ origin: 'booted',
76
+ async stop() {
77
+ clearLock(services.stateDir);
78
+ await dev.stop();
79
+ },
80
+ };
81
+ }
82
+
83
+ /** `--allow-hosts a.com,b.com` on top of the app's own host. Empty means the app's host alone. */
84
+ export const allowHostsFrom = (url: string, extra: string | undefined): readonly string[] => {
85
+ const named = (extra ?? '')
86
+ .split(',')
87
+ .map((host) => host.trim())
88
+ .filter((host) => host.length > 0);
89
+ return [new URL(url).hostname, ...named];
90
+ };
@@ -1,7 +1,8 @@
1
- // When a shot may be TAKEN: the two rules that decide whether the page has finished hydrating,
2
- // separated from both the command that drives a browser and the verdict that judges what came
3
- // back. Plain values and an injected sleep, so the whole loop is proved with neither.
1
+ // When a shot may be TAKEN: the rules that decide whether the page has finished, separated from
2
+ // both the command that drives a browser and the verdict that judges what came back. Plain values
3
+ // and an injected sleep, so every loop here is proved with neither.
4
4
 
5
+ import type { IslandReadiness } from './island-verdict';
5
6
  import type { IslandCount } from './shot-verdict';
6
7
 
7
8
  /**
@@ -55,3 +56,32 @@ export async function settleIslands(
55
56
  }
56
57
  return answer;
57
58
  }
59
+
60
+ /**
61
+ * Read the harness's readiness until the page goes QUIET or the window runs out.
62
+ *
63
+ * Quiet and not idle, which is the whole rule: the page's own watcher sets `ready` after N
64
+ * consecutive frames in which no request started and none settled, so a state whose fixture is
65
+ * deliberately `pending` still reaches it. Waiting for nothing in flight would hang on exactly the
66
+ * state an author declared on purpose, and a fixed sleep would photograph whatever a slow machine
67
+ * had painted by then.
68
+ *
69
+ * A `null` answer never overwrites a real one, for `settleIslands`' reason: `null` is "the page
70
+ * answered no probe", and a probe that fails once must not turn a ready page into an unready one.
71
+ */
72
+ export async function settleReadiness(
73
+ probe: () => Promise<IslandReadiness | null>,
74
+ options: SettleOptions,
75
+ ): Promise<IslandReadiness | null> {
76
+ const sleep = options.sleep ?? ((ms: number): Promise<void> => Bun.sleep(ms));
77
+ let answer = await probe();
78
+ let waited = 0;
79
+ while (answer?.ready !== true && waited < options.windowMs) {
80
+ // At least 1ms, or a `pollMs` of zero is a loop with no exit while the window stands.
81
+ const step = Math.max(1, Math.min(options.pollMs, options.windowMs - waited));
82
+ await sleep(step);
83
+ waited += step;
84
+ answer = (await probe()) ?? answer;
85
+ }
86
+ return answer;
87
+ }
package/src/ts-scan.ts CHANGED
@@ -161,10 +161,18 @@ export function lineIndex(text: string): (index: number) => number {
161
161
  }
162
162
 
163
163
  /**
164
- * Every string literal in the value expression starting at `from`, at the expression's own bracket
165
- * depth. Spanning the expression is what makes `cond ? 'a' : 'b'` and `table[k] ?? 'c'` checkable
166
- * instead of silently skipped; the depth rule is what keeps `command.join(' ')`'s separator and
167
- * `table['key']`'s key out — an argument is not a fix.
164
+ * Every string literal the value expression starting at `from` can EVALUATE TO, at the
165
+ * expression's own bracket depth. Spanning the expression is what makes `cond ? 'a' : 'b'` and
166
+ * `table[k] ?? 'c'` checkable instead of silently skipped; the depth rule is what keeps
167
+ * `command.join(' ')`'s separator and `table['key']`'s key out — an argument is not a fix.
168
+ *
169
+ * A ternary's CONDITION is dropped, which is the difference between reading the expression and
170
+ * reading every literal in it. `fix: input.slug === '' ? 'x g …' : 'x g …'` published the empty
171
+ * string as a fix line — `X_ERROR_FIX_INVALID`, "the fix line is empty", against source whose two
172
+ * real fixes are both correct — and `input.key === 'timeZone' ? … : …` published `timeZone`, a
173
+ * string then judged for banned phrases and cited paths that is not a fix at all. Costly enough
174
+ * that `@ultimat3/testing`'s island-state errors carry two classes under one code rather than one
175
+ * class with a ternary in it.
168
176
  */
169
177
  export function valueLiterals(
170
178
  masked: string,
@@ -173,21 +181,42 @@ export function valueLiterals(
173
181
  lineAt: (index: number) => number,
174
182
  ): readonly FixSite[] {
175
183
  const found: { value: string; index: number }[] = [];
184
+ // The literals of the segment being read. A segment ended by `?` is a condition and is dropped
185
+ // whole; one ended by `:` or by the end of the expression is a value the fix can evaluate to.
186
+ let segment: { value: string; index: number }[] = [];
187
+ const keep = (): void => {
188
+ found.push(...segment);
189
+ segment = [];
190
+ };
176
191
  let depth = 0;
192
+ /** Open `?`s still waiting for their `:`, so a `:` outside a ternary stays an ordinary char. */
193
+ let conditionals = 0;
177
194
  for (let i = from; i < masked.length; i += 1) {
178
195
  const ch = masked[i] as string;
179
196
  if (QUOTES.has(ch)) {
180
197
  const end = endOfLiteral(masked, i);
181
198
  // A quote that never closes is one character of code, not an empty literal to report.
182
199
  if (end === i + 1) continue;
183
- if (depth === 0) found.push({ value: source.slice(i + 1, end - 1), index: i });
200
+ if (depth === 0) segment.push({ value: source.slice(i + 1, end - 1), index: i });
184
201
  i = end - 1;
185
202
  } else if (OPENERS.has(ch)) depth += 1;
186
203
  else if (CLOSERS.has(ch)) {
187
204
  if (depth === 0) break;
188
205
  depth -= 1;
206
+ } else if (depth === 0 && ch === '?') {
207
+ // `??` and `?.` are operators and end no segment: `input?.fix ?? 'x help'` evaluates to the
208
+ // literal, and dropping what came before it would drop the only answer the expression has.
209
+ if (masked[i + 1] === '?') i += 1;
210
+ else if (masked[i + 1] !== '.') {
211
+ segment = [];
212
+ conditionals += 1;
213
+ }
214
+ } else if (depth === 0 && ch === ':' && conditionals > 0) {
215
+ keep();
216
+ conditionals -= 1;
189
217
  } else if (depth === 0 && (ch === ',' || ch === ';')) break;
190
218
  }
219
+ keep();
191
220
  return found.map((literal) => ({
192
221
  at: '',
193
222
  line: lineAt(literal.index),
@@ -1,12 +1,20 @@
1
- // Four shape rules the gate owns: one file, one job (a hard line ceiling), every workspace
2
- // package shipping the same contract files, every published package's tarball matching what its
3
- // manifest promises, and every published package being in the root build graph. All report
4
- // findings — a shape rule that is only written down is not a rule (axiom 3).
1
+ // Four shape rules the gate owns: one file, one job (a hard line ceiling on REVIEWABLE LOGIC),
2
+ // every workspace package shipping the same contract files, every published package's tarball
3
+ // matching what its manifest promises, and every published package being in the root build graph.
4
+ // All report findings — a shape rule that is only written down is not a rule (axiom 3).
5
+ //
6
+ // The ceiling exempts a pure re-export manifest, and only that. Such a file has one job by
7
+ // construction and its length is a function of the package's API size rather than of its
8
+ // complexity: `@ultimat3/core`'s `src/index.ts` reached 514 lines with 513 statements and not one
9
+ // of them logic, so the ceiling had stopped protecting anything and started refusing every new
10
+ // public subject. `isReExportManifest` is the whole carve-out — one statement of logic in such a
11
+ // file re-arms the ceiling on the same save, which is what keeps it from being a hole.
5
12
 
6
13
  import { existsSync } from 'node:fs';
7
14
  import { join } from 'node:path';
8
15
  import { ERROR_DOCS_URL, renderCauseValue } from '@ultimat3/core';
9
16
  import type { Finding } from './output';
17
+ import { isReExportManifest } from './reexport-manifest';
10
18
  import { eachSourceFile, isGenerated } from './source-files';
11
19
  import { checkRootReferences } from './tsconfig-references';
12
20
 
@@ -30,13 +38,21 @@ export const tooLongFinding = (path: string, lines: number): Finding => ({
30
38
  export const countLines = (text: string): number =>
31
39
  text === '' ? 0 : text.split('\n').length - (text.endsWith('\n') ? 1 : 0);
32
40
 
33
- /** Files are the unit of review: one file, one job, hard ceiling 500 lines. */
41
+ /**
42
+ * Files are the unit of review: one file, one job, hard ceiling 500 lines of reviewable logic.
43
+ *
44
+ * The source is read before the count is judged rather than after, because the exemption is a
45
+ * question about CONTENTS: a 3,000-line file of re-exports is one job and a 501-line file with one
46
+ * statement of logic in it is not, and only reading tells them apart.
47
+ */
34
48
  export async function checkFileSizes(root: string): Promise<readonly Finding[]> {
35
49
  const findings: Finding[] = [];
36
50
  for await (const path of eachSourceFile(root)) {
37
51
  if (isGenerated(path)) continue;
38
- const lines = countLines(await Bun.file(join(root, path)).text());
39
- if (lines > LINE_CEILING) findings.push(tooLongFinding(path, lines));
52
+ const source = await Bun.file(join(root, path)).text();
53
+ const lines = countLines(source);
54
+ if (lines <= LINE_CEILING || isReExportManifest(source)) continue;
55
+ findings.push(tooLongFinding(path, lines));
40
56
  }
41
57
  return findings;
42
58
  }