@ultimat3/cli 5.0.1 → 7.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 +75 -6
- package/README.md +2 -2
- package/package.json +28 -24
- package/src/affected.ts +320 -0
- package/src/browser-launcher.ts +109 -0
- package/src/ci-log.ts +0 -0
- package/src/ci-runs.ts +179 -0
- package/src/cmd-affected.ts +109 -0
- package/src/cmd-build.ts +36 -3
- package/src/cmd-ci.ts +273 -0
- package/src/cmd-dev.ts +35 -2
- package/src/cmd-generate.ts +16 -348
- package/src/cmd-i18n.ts +32 -16
- package/src/cmd-pr.ts +308 -0
- package/src/cmd-shot.ts +320 -0
- package/src/cmd-test.ts +96 -7
- package/src/cmd-verify.ts +10 -427
- package/src/compile-externals.ts +34 -0
- package/src/dev-lock.ts +275 -0
- package/src/dev-render.ts +7 -17
- package/src/error-codes.ts +18 -0
- package/src/generate-files.ts +127 -0
- package/src/generate-write.ts +229 -0
- package/src/gh-target.ts +118 -0
- package/src/gh.ts +204 -0
- package/src/i18n-audit.ts +39 -1
- package/src/i18n-registration.ts +130 -0
- package/src/index.ts +37 -0
- package/src/island-bundle.ts +68 -2
- package/src/island-solid-production.ts +129 -0
- package/src/island-styles.ts +41 -0
- package/src/mcp-errors.ts +11 -0
- package/src/messages.ts +67 -0
- package/src/pr-threads.ts +291 -0
- package/src/prerender.ts +52 -10
- package/src/registry.ts +8 -0
- package/src/shot-verdict.ts +337 -0
- package/src/solid-loader.ts +127 -0
- package/src/static-report.ts +219 -0
- package/src/templates/admin-page.ts +46 -5
- package/src/templates/index.ts +1 -0
- package/src/templates/island-fixture.ts +76 -0
- package/src/templates/island.ts +129 -18
- package/src/templates/resource-form-island.ts +279 -0
- package/src/templates/resource.ts +52 -43
- package/src/templates/route.ts +45 -6
- package/src/templates/scaffold-app.ts +70 -19
- package/src/templates/scaffold-container.ts +2 -2
- package/src/templates/scaffold-db-package.ts +88 -39
- package/src/templates/scaffold-docs.ts +18 -1
- package/src/templates/scaffold-i18n.ts +9 -2
- package/src/templates/scaffold-mcp-package.ts +35 -2
- package/src/templates/scaffold-package-shape.ts +7 -2
- package/src/templates/scaffold-repo.ts +2 -2
- package/src/test-shards.ts +19 -3
- package/src/verify-checks.ts +349 -0
- package/src/verify-run.ts +122 -0
- package/src/verify-step.ts +7 -0
- package/src/workspace-graph.ts +241 -0
- package/types/babel-modules.d.ts +31 -0
package/CLAUDE.md
CHANGED
|
@@ -12,7 +12,7 @@ Tier 5. May import tiers 0–4. Nothing imports this except `create-ultimate`.
|
|
|
12
12
|
| Bare subcommands | `CommandSpec.defaultSubcommand`, **declared**. The parser answered `subcommands[0]` until 1.2.0, so `x db` ran `gen` — the migration GENERATOR — because it sorted first, and `x mcp` started a server. A command with no defensible default declares none and `MissingSubcommandError` refuses the bare form; `parse.test.ts` pins the set at exactly `db` and `mcp`. Its fix is `x help <command>`, never `x <command> --help`: the subcommand is resolved *after* the flag loop, so the latter throws the same error again — a fix line that reproduced its own failure |
|
|
13
13
|
| Closed flag values | a flag whose values are a closed set is READ through a function that refuses the rest — `cmd-build.ts`'s `readTarget`, `cmd-deploy.ts`'s `readMethod`, `cmd-routes.ts`'s `readSurfaceFilter`, `cmd-mcp.ts`'s `isTransport`. `=== 'helm' ? 'helm' : 'compose'` made `x deploy --method helmm` a COMPOSE deploy reporting `method: "compose"`, and `--surface App` reported `0 routes` and exit 0 — a typo and an empty table rendering identically. The set is the framework's own where one exists (`SURFACES` from `@ultimat3/render`), never a list restated here |
|
|
14
14
|
| App root | `CommandSpec.requiresApp`, **enforced by `dispatch.ts`** before `target.run` — the field's doc said so for 17 commands and nothing read it, so the promise was kept only by each command remembering to call `requireAppRoot` itself. Those 17 calls stay (they hand the command its root, and name subcommands the dispatcher cannot see), but the DECLARATION is what decides, ahead of any check a command makes about its own arguments; `--help` is exempt, because `target` is the help command by then |
|
|
15
|
-
| Result helpers | `command.ts`'s `ok()` / `failed()` write `ok` **after** the `extra` spread: the function's name is the verdict and nothing a caller passes can overturn it. Spread last, `failed('verify', '1 of
|
|
15
|
+
| Result helpers | `command.ts`'s `ok()` / `failed()` write `ok` **after** the `extra` spread: the function's name is the verdict and nothing a caller passes can overturn it. Spread last, `failed('verify', '1 of 19 steps failed', { ok: true })` answered `ok: true` and `exitCodeFor` exited **0** on it — a green CI over a red command (`command.test.ts`) |
|
|
16
16
|
| I/O | only `dispatch.ts` renders or exits; commands return `CommandResult` |
|
|
17
17
|
| Staying up | a command still listening when `run` resolves returns `hold` (`hold.ts`), or `bin.ts` exits out from under it |
|
|
18
18
|
| `--json` | every command, no exceptions — same data as the human render |
|
|
@@ -67,6 +67,17 @@ and the `tsc -b` that proves it took. Private packages are exempt (a generated a
|
|
|
67
67
|
private), and a root that declares **no** `references` array is not judged at all: project
|
|
68
68
|
references are opt-in, and a scaffolded app builds through `extends` + `include`.
|
|
69
69
|
|
|
70
|
+
`workspace-graph.ts` is `package-shape`'s fifth rule: **every cross-workspace import is declared
|
|
71
|
+
in the importing workspace's own manifest**. Without it a scaffolded repo's dependency graph exists
|
|
72
|
+
only inside `tsc` — imports resolve through the root `tsconfig.json` `paths`, so affected-package
|
|
73
|
+
detection, `bun --filter` ordering and "what breaks if I change this" all read manifests and all
|
|
74
|
+
answer too small a set (issue #239, found in a real app where a change reaching five packages
|
|
75
|
+
reported one). `X_WORKSPACE_DEP_UNDECLARED` names the manifest and the exact line to add. Shipped
|
|
76
|
+
source only: a test file's import is not judged, because `packages/*` here declares no
|
|
77
|
+
`devDependencies` by design and the root's hoist is what resolves them. A manifest the scan cannot
|
|
78
|
+
read is its own finding rather than a silent skip — a skipped workspace is a hiding place for the
|
|
79
|
+
very edge the rule is looking for.
|
|
80
|
+
|
|
70
81
|
`app-agents-md.ts` is why the `manifest` step declares no `applies` at all. The drift half needs
|
|
71
82
|
a committed `x.manifest.json` to compare against, but `AGENTS.md` is required of every repo the
|
|
72
83
|
gate runs in — so the step always has a question to answer, and gating both halves on the file
|
|
@@ -93,7 +104,7 @@ them is answered by this table rather than by a second convention:
|
|
|
93
104
|
| `x jobs` | `cmd-jobs.ts`, `jobs-{driver,report,drain,json,table}.ts` | `@ultimat3/jobs`' own introspection |
|
|
94
105
|
| `x tasks` | `cmd-tasks.ts`, `tasks-facts.ts` | `registeredTasks()` + `@ultimat3/time`'s cron resolution |
|
|
95
106
|
| `x policy` | `cmd-policy.ts`, `policy-facts.ts` | `@ultimat3/policy`'s `policyMatrix()` over the app's own `Policy` objects |
|
|
96
|
-
| `x i18n` | `cmd-i18n.ts`, `i18n-audit.ts` | `@ultimat3/i18n`'s `extractFromFiles` + `auditCatalogs
|
|
107
|
+
| `x i18n` | `cmd-i18n.ts`, `i18n-audit.ts`, `i18n-registration.ts` | `@ultimat3/i18n`'s `extractFromFiles` + `auditCatalogs`, then the live catalog registry |
|
|
97
108
|
|
|
98
109
|
Each pairs a `cmd-*.ts` of CLI wiring with a facts module that takes plain inputs and returns plain
|
|
99
110
|
data, so the projection is testable without a `ParsedArgs` — the `cmd-jobs.ts` / `jobs-report.ts`
|
|
@@ -108,6 +119,29 @@ mode `cmd-planned.ts` closes for planned commands and these close for real ones.
|
|
|
108
119
|
forbid: a `t()` call is not a primitive and no registry holds it. It uses `source-files.ts`, the
|
|
109
120
|
same walk `errors` and `filesize` use, so the three cannot disagree on what the app's source is.
|
|
110
121
|
|
|
122
|
+
**And then it asks the question the scan cannot answer.** A catalog complete on disk, its keys used
|
|
123
|
+
everywhere in source, and an audit of one against the other were all green for an app that rendered
|
|
124
|
+
`⟦key⟧` on every page — registration is a side effect of importing the module that calls
|
|
125
|
+
`defineCatalogs()`, and nothing imported it (issue #249). `i18n-registration.ts` loads the app
|
|
126
|
+
through `loadApp` — the same call `serveApp` makes at boot, so it is the boot's own answer and not a
|
|
127
|
+
simulation of one — and compares the catalogs on disk against the live registry, per locale
|
|
128
|
+
(`X_CATALOG_UNREGISTERED`). Two conditions, one code: a shipped catalog no module registered, and
|
|
129
|
+
no catalog anywhere while source calls `t()` — the second is the vacuous green an app with an
|
|
130
|
+
`app.config.ts` and no `packages/i18n/catalogs/` used to get. `loadApp`'s own findings ride along
|
|
131
|
+
ONLY when something is unregistered, because "packages/i18n/src/index.ts: SyntaxError" is the
|
|
132
|
+
evidence for the gap above it and noise on a pass.
|
|
133
|
+
|
|
134
|
+
`catalogFindings(root)` is the one composition both callers report: `x i18n check` renders it as a
|
|
135
|
+
table with a `registered` column, `x verify`'s **`i18n` step** returns it as findings. One
|
|
136
|
+
implementation, so the command and the gate can never disagree about an app.
|
|
137
|
+
|
|
138
|
+
**The generators emit `useT()` from the app's own catalog module, never `t` from `@ultimat3/i18n`.**
|
|
139
|
+
The specifier is `resolveCatalogModule(root)` — `packages/i18n/package.json`'s `name`, read off
|
|
140
|
+
disk, because a template is a pure string function and only a package name resolves as an import.
|
|
141
|
+
An app with no such package keeps the framework import: emitting one that cannot resolve trades a
|
|
142
|
+
wrong idiom for a file that does not compile. This is where the reported bug's idiom came from —
|
|
143
|
+
every generated page imported `t` directly, so no page depended on the module that registers.
|
|
144
|
+
|
|
111
145
|
**A catalog is authored nested and read flat.** `Catalog` (`{ 'nav.home': 'Home' }`) is the
|
|
112
146
|
translator's form; the file on disk holds `{ nav: { home: 'Home' } }`, and `parseNestedCatalog`
|
|
113
147
|
refuses a dot inside a key — so anything writing a catalog goes through `nestCatalog`
|
|
@@ -116,6 +150,38 @@ emits a file `defineCatalogs` rejects at the app's first boot. `merge: 'json'` u
|
|
|
116
150
|
(`json-merge.ts`) for the same reason: `x new` and `x g resource` both contribute under `app`, and
|
|
117
151
|
a shallow spread keeps one of them.
|
|
118
152
|
|
|
153
|
+
## Three commands that reach outside the process, and none of them is a gate step
|
|
154
|
+
|
|
155
|
+
`x shot`, `x pr` and `x ci` exist because of the one line in the root `CLAUDE.md` that shapes this
|
|
156
|
+
whole package: **the primary developer is an AI agent.** An agent cannot open a browser, cannot look
|
|
157
|
+
at a running dev server and cannot read the GitHub web UI. It can read a file, and it can run a
|
|
158
|
+
command that prints. These three turn each of those into a file and a print.
|
|
159
|
+
|
|
160
|
+
They are also the only three commands that need something the process does not have — a browser, a
|
|
161
|
+
network, a GitHub token — which is why **none of them is a step of `x verify`**, and why that is not
|
|
162
|
+
an oversight to be corrected later. A gate that needs a browser goes red for reasons unrelated to the
|
|
163
|
+
change, and CI does not install one.
|
|
164
|
+
|
|
165
|
+
| | Reaches for | Never |
|
|
166
|
+
|---|---|---|
|
|
167
|
+
| `x shot <route>` | `x dev` on a scratch port, plus the app's own `puppeteer-core` through `@ultimat3/scraping` | the static build — `--target static` prerenders `site/` only, so an `app/` route would photograph the landing page |
|
|
168
|
+
| `x pr review\|resolve\|reply` | `gh api graphql`, through the injected `Runner` | `gh pr view --comments`, which shows *issue* comments and not the line-anchored threads that carry the findings |
|
|
169
|
+
| `x ci` | `gh run view --log-failed`, one call | a per-job log fetch — the run and all its jobs come back together |
|
|
170
|
+
|
|
171
|
+
**`verdict.json` names its own blind spots, and that is the design.** `x shot` reports what it could
|
|
172
|
+
not observe alongside what it did. A capture tool that silently omits what it cannot see is worse
|
|
173
|
+
than one that says so, because the omission reads as a clean result.
|
|
174
|
+
|
|
175
|
+
**`x shot` reuses a running `x dev` rather than booting a second one.** Embedded Postgres is
|
|
176
|
+
single-writer, so a second boot is `X_DEV_ALREADY_RUNNING` and no picture is ever taken. A reused
|
|
177
|
+
server's `stop()` deliberately does not clear the other process's lock.
|
|
178
|
+
|
|
179
|
+
**`gh` is invoked through `ctx.runner`, never `Bun.spawn` directly** — that is what lets every test
|
|
180
|
+
supply a reply table and assert the exact argv with no network and no `gh` installed. `GhOptions.fix`
|
|
181
|
+
is a **required** field, so shelling out to GitHub without stating a remedy is a type error rather
|
|
182
|
+
than a review comment. A GraphQL response is untrusted input and is parsed against a schema, never
|
|
183
|
+
cast: a `null` where an id was expected would otherwise become a mutation against `undefined`.
|
|
184
|
+
|
|
119
185
|
## The `errors` step enforces the error contract
|
|
120
186
|
|
|
121
187
|
| File | Job |
|
|
@@ -793,10 +859,13 @@ typechecked there by default; an app whose tsconfig names an explicit `include`
|
|
|
793
859
|
## Two generators that scaffold something other than a primitive
|
|
794
860
|
|
|
795
861
|
`x g island <name> [--at <dir>]` writes a **client entry point**, not a component: the filename is
|
|
796
|
-
how the bundler discovers it and `mount` is how the hydration runtime calls it, so
|
|
797
|
-
what `templates/island.test.ts` pins
|
|
798
|
-
the
|
|
799
|
-
|
|
862
|
+
how the bundler discovers it and `mount` is how the hydration runtime calls it, so the filename,
|
|
863
|
+
the `mount` export and that `mount` RENDERS are what `templates/island.test.ts` pins — it builds
|
|
864
|
+
the emitted entry with `buildIslands` and drives it with `mountIsland`, so a template that
|
|
865
|
+
typechecks and does not mount is a failing test. It runs the mutation too, rather than describing
|
|
866
|
+
it: the same island with `{count()}` replaced by `{0}` must fail the assertion the live one passes.
|
|
867
|
+
`--at` takes the directory directly rather than deriving one, because the caller that cannot guess
|
|
868
|
+
is `X_ISLAND_INVALID` — its cause already holds the exact path a page's `src` resolved to, so its
|
|
800
869
|
`fix:` hands that path straight back.
|
|
801
870
|
|
|
802
871
|
`x g admin:page <name> --permission <perm> [--at <dir>]` writes an ordinary TSX component and **no
|
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@ Commands and the `x verify` step count, `As of 2026-08`:
|
|
|
11
11
|
| `x new <name>` | scaffolds the monorepo | interactive-free; auth, seeded DB, example route |
|
|
12
12
|
| `x dev` | every role in one process | embedded Postgres/events/storage, `/_x` mounted |
|
|
13
13
|
| `x build --target docker\|binary\|static` | one artifact | `ROLE` selects behaviour at start |
|
|
14
|
-
| `x verify` | **the gate** |
|
|
14
|
+
| `x verify` | **the gate** | 19 named steps, each with pass/fail + duration |
|
|
15
15
|
| `x g <primitive> <name>` | scaffolds a primitive **with a passing test** | never a TODO stub |
|
|
16
16
|
| `x db gen\|migrate\|reset\|branch\|backfill` | everything DB | `branch` = copy-on-write clone + preview URL; `backfill` dry-runs unless `--write`. `x db studio` is **planned** — it parses, and exits `X_NOT_IMPLEMENTED` naming `/_x`'s db panel |
|
|
17
17
|
| `x mcp serve` | `@ultimat3/mcp`'s 13 dev tools, over stdio or HTTP | one catalog, one scope set, both transports |
|
|
@@ -47,7 +47,7 @@ X_DB_DRIFT: schema differs from migrations
|
|
|
47
47
|
|
|
48
48
|
```sh
|
|
49
49
|
x verify --json
|
|
50
|
-
# {"ok":false,"command":"verify","summary":"1 of
|
|
50
|
+
# {"ok":false,"command":"verify","summary":"1 of 19 steps failed","steps":[...]}
|
|
51
51
|
```
|
|
52
52
|
|
|
53
53
|
## `x verify` steps
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/cli",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "7.0.0",
|
|
4
4
|
"description": "The `x` binary: new, dev, build, verify, generate, db, mcp, doctor, deploy",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
"files": [
|
|
23
23
|
"src",
|
|
24
24
|
"!src/**/*.test.ts",
|
|
25
|
+
"types",
|
|
25
26
|
"CLAUDE.md",
|
|
26
27
|
"README.md",
|
|
27
28
|
"LICENSE"
|
|
@@ -35,28 +36,31 @@
|
|
|
35
36
|
"dev": "bun run src/bin.ts dev"
|
|
36
37
|
},
|
|
37
38
|
"dependencies": {
|
|
38
|
-
"@
|
|
39
|
-
"@ultimat3/
|
|
40
|
-
"@ultimat3/
|
|
41
|
-
"@ultimat3/
|
|
42
|
-
"@ultimat3/
|
|
43
|
-
"@ultimat3/
|
|
44
|
-
"@ultimat3/
|
|
45
|
-
"@ultimat3/
|
|
46
|
-
"@ultimat3/
|
|
47
|
-
"@ultimat3/
|
|
48
|
-
"@ultimat3/
|
|
49
|
-
"@ultimat3/
|
|
50
|
-
"@ultimat3/
|
|
51
|
-
"@ultimat3/
|
|
52
|
-
"@ultimat3/
|
|
53
|
-
"@ultimat3/
|
|
54
|
-
"@ultimat3/
|
|
55
|
-
"@ultimat3/
|
|
56
|
-
"@ultimat3/
|
|
57
|
-
"@ultimat3/
|
|
58
|
-
"@ultimat3/
|
|
59
|
-
"@ultimat3/
|
|
60
|
-
"@ultimat3/
|
|
39
|
+
"@babel/core": "^7.28.4",
|
|
40
|
+
"@ultimat3/action": "7.0.0",
|
|
41
|
+
"@ultimat3/admin": "7.0.0",
|
|
42
|
+
"@ultimat3/ai": "7.0.0",
|
|
43
|
+
"@ultimat3/cache": "7.0.0",
|
|
44
|
+
"@ultimat3/core": "7.0.0",
|
|
45
|
+
"@ultimat3/db": "7.0.0",
|
|
46
|
+
"@ultimat3/entity": "7.0.0",
|
|
47
|
+
"@ultimat3/http": "7.0.0",
|
|
48
|
+
"@ultimat3/i18n": "7.0.0",
|
|
49
|
+
"@ultimat3/jobs": "7.0.0",
|
|
50
|
+
"@ultimat3/mail": "7.0.0",
|
|
51
|
+
"@ultimat3/manifest": "7.0.0",
|
|
52
|
+
"@ultimat3/mcp": "7.0.0",
|
|
53
|
+
"@ultimat3/policy": "7.0.0",
|
|
54
|
+
"@ultimat3/pwa": "7.0.0",
|
|
55
|
+
"@ultimat3/query": "7.0.0",
|
|
56
|
+
"@ultimat3/realtime": "7.0.0",
|
|
57
|
+
"@ultimat3/render": "7.0.0",
|
|
58
|
+
"@ultimat3/schema": "7.0.0",
|
|
59
|
+
"@ultimat3/scraping": "7.0.0",
|
|
60
|
+
"@ultimat3/seo": "7.0.0",
|
|
61
|
+
"@ultimat3/storage": "7.0.0",
|
|
62
|
+
"@ultimat3/testing": "7.0.0",
|
|
63
|
+
"@ultimat3/time": "7.0.0",
|
|
64
|
+
"babel-preset-solid": "^1.9.15"
|
|
61
65
|
}
|
|
62
66
|
}
|
package/src/affected.ts
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
// What a diff touches: the changed-file list read out of git, and the workspaces that list forces
|
|
2
|
+
// a re-test of — closed TRANSITIVELY over the workspace graph, because A → B → C means an edit in
|
|
3
|
+
// C breaks A and a single pass over an unordered list only ever reaches B.
|
|
4
|
+
//
|
|
5
|
+
// The diff defaults to a REF, never the working tree. Several agents share one checkout here (root
|
|
6
|
+
// `CLAUDE.md`, the "Note": no worktrees, "run them as a team in this same checkout"), so a
|
|
7
|
+
// working-tree diff returns every other agent's uncommitted work and the "affected" set silently
|
|
8
|
+
// widens to nearly the whole monorepo — the command stops narrowing anything and nobody can tell.
|
|
9
|
+
// A ref diff is stable under that concurrency; `--dirty` opts back in, which is right for one
|
|
10
|
+
// developer iterating alone and wrong as a default here.
|
|
11
|
+
|
|
12
|
+
// `join`/`relative` are `node:`-only by necessity: Bun exposes no path primitive, and a workspace
|
|
13
|
+
// directory is checkout-relative while a caller's scan yields paths relative to its own root.
|
|
14
|
+
import { join, relative } from 'node:path';
|
|
15
|
+
import { singleLine, UltimateError } from '@ultimat3/core';
|
|
16
|
+
import { docsFor } from './error-codes';
|
|
17
|
+
import { BadFlagError } from './errors';
|
|
18
|
+
import type { ExecResult, Runner } from './exec';
|
|
19
|
+
import { execOutput } from './exec';
|
|
20
|
+
import type { JsonValue } from './output';
|
|
21
|
+
import type { ParsedArgs } from './parse';
|
|
22
|
+
import { flagBool, flagString } from './parse';
|
|
23
|
+
import type { WorkspaceNode } from './workspace-graph';
|
|
24
|
+
import { readWorkspaceGraph } from './workspace-graph';
|
|
25
|
+
|
|
26
|
+
/** The branch a change is measured against when `--base` says nothing. */
|
|
27
|
+
export const DEFAULT_BASE = 'main';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Root files that belong to no workspace and change what every workspace compiles to: a compiler
|
|
31
|
+
* option, a lint rule, the root manifest, the resolved dependency tree, the test preload, the app
|
|
32
|
+
* config. A "scoped" run that skipped them reports green over packages the edit just broke.
|
|
33
|
+
*
|
|
34
|
+
* Matched on the WHOLE path — `packages/cli/package.json` is the cli workspace's own file and
|
|
35
|
+
* reaches only cli's dependents, `package.json` at the root reaches everything.
|
|
36
|
+
*/
|
|
37
|
+
export const ROOT_WIDE_FILES: readonly string[] = [
|
|
38
|
+
'app.config.ts',
|
|
39
|
+
'biome.json',
|
|
40
|
+
'bun.lock',
|
|
41
|
+
'bunfig.toml',
|
|
42
|
+
'package.json',
|
|
43
|
+
'tsconfig.json',
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A file with no compilation unit behind it. A doc or a plan re-checks nothing, so it maps to no
|
|
48
|
+
* workspace at all rather than to the one it happens to sit inside — `packages/cli/README.md` is
|
|
49
|
+
* not a reason to run `packages/cli`'s tests.
|
|
50
|
+
*/
|
|
51
|
+
const isDoc = (path: string): boolean => path.endsWith('.md');
|
|
52
|
+
|
|
53
|
+
const owns = (node: WorkspaceNode, path: string): boolean =>
|
|
54
|
+
path === node.dir || path.startsWith(`${node.dir}/`);
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The workspace a path belongs to, longest directory first: nested workspaces exist (a scaffolded
|
|
58
|
+
* app's `apps/web` inside its own root), and the shorter prefix would swallow the inner one.
|
|
59
|
+
*/
|
|
60
|
+
export function owningWorkspace(
|
|
61
|
+
graph: readonly WorkspaceNode[],
|
|
62
|
+
path: string,
|
|
63
|
+
): WorkspaceNode | undefined {
|
|
64
|
+
let best: WorkspaceNode | undefined;
|
|
65
|
+
for (const node of graph) {
|
|
66
|
+
if (!owns(node, path)) continue;
|
|
67
|
+
if (best === undefined || node.dir.length > best.dir.length) best = node;
|
|
68
|
+
}
|
|
69
|
+
return best;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Every workspace that depends on one of `seeds`, however many edges away.
|
|
74
|
+
*
|
|
75
|
+
* A queue with a growing cursor, not one pass over `seeds`: with `A → B → C`, one pass answers
|
|
76
|
+
* `{ C, B }` and leaves A untested while the change that broke it is in the diff — a green
|
|
77
|
+
* checkmark on a broken repo, which is the whole reason this command exists rather than each agent
|
|
78
|
+
* inventing its own scoping. `reached` doubles as the cycle guard.
|
|
79
|
+
*/
|
|
80
|
+
function withDependents(
|
|
81
|
+
graph: readonly WorkspaceNode[],
|
|
82
|
+
seeds: ReadonlySet<string>,
|
|
83
|
+
): ReadonlySet<string> {
|
|
84
|
+
const dependents = new Map<string, string[]>();
|
|
85
|
+
for (const node of graph) {
|
|
86
|
+
for (const dependency of node.dependencies) {
|
|
87
|
+
const known = dependents.get(dependency);
|
|
88
|
+
if (known === undefined) dependents.set(dependency, [node.name]);
|
|
89
|
+
else known.push(node.name);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const reached = new Set(seeds);
|
|
93
|
+
const queue = [...reached];
|
|
94
|
+
for (let cursor = 0; cursor < queue.length; cursor += 1) {
|
|
95
|
+
const name = queue[cursor];
|
|
96
|
+
if (name === undefined) continue;
|
|
97
|
+
for (const dependent of dependents.get(name) ?? []) {
|
|
98
|
+
if (reached.has(dependent)) continue;
|
|
99
|
+
reached.add(dependent);
|
|
100
|
+
queue.push(dependent);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return reached;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface AffectedPlan {
|
|
107
|
+
/** Every path the diff reported, verbatim and in git's order. */
|
|
108
|
+
readonly changed: readonly string[];
|
|
109
|
+
/** The subset with no compilation unit behind it, named so an empty answer explains itself. */
|
|
110
|
+
readonly ignored: readonly string[];
|
|
111
|
+
/** The root files that forced every workspace in, empty when none did. */
|
|
112
|
+
readonly rootWide: readonly string[];
|
|
113
|
+
readonly workspaces: readonly WorkspaceNode[];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const byName = (a: WorkspaceNode, b: WorkspaceNode): number => (a.name > b.name ? 1 : -1);
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Pure: the graph and the file list in, the plan out. No git, no disk — so the transitive rule is
|
|
120
|
+
* testable without a checkout whose state would decide the verdict.
|
|
121
|
+
*/
|
|
122
|
+
export function planAffected(
|
|
123
|
+
graph: readonly WorkspaceNode[],
|
|
124
|
+
changed: readonly string[],
|
|
125
|
+
): AffectedPlan {
|
|
126
|
+
const ignored = changed.filter(isDoc);
|
|
127
|
+
const considered = changed.filter((path) => !isDoc(path));
|
|
128
|
+
const rootWide = considered.filter((path) => ROOT_WIDE_FILES.includes(path));
|
|
129
|
+
if (rootWide.length > 0) {
|
|
130
|
+
return { changed, ignored, rootWide, workspaces: [...graph].sort(byName) };
|
|
131
|
+
}
|
|
132
|
+
const seeds = new Set<string>();
|
|
133
|
+
for (const path of considered) {
|
|
134
|
+
const node = owningWorkspace(graph, path);
|
|
135
|
+
if (node !== undefined) seeds.add(node.name);
|
|
136
|
+
}
|
|
137
|
+
const reached = withDependents(graph, seeds);
|
|
138
|
+
return {
|
|
139
|
+
changed,
|
|
140
|
+
ignored,
|
|
141
|
+
rootWide,
|
|
142
|
+
workspaces: graph.filter((node) => reached.has(node.name)).sort(byName),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* How the calling command spells this scoping, so a `fix:` re-runs the invocation that actually
|
|
148
|
+
* failed. `x test --base main` is refused by `x test` itself — without `--affected` the flag
|
|
149
|
+
* narrows nothing — so a fix line that dropped it would reproduce its own failure, verbatim.
|
|
150
|
+
*/
|
|
151
|
+
const invocationOf = (command: string): string =>
|
|
152
|
+
command === 'affected' ? 'x affected' : `x ${command} --affected`;
|
|
153
|
+
|
|
154
|
+
export interface AffectedSelection {
|
|
155
|
+
readonly base: string;
|
|
156
|
+
readonly dirty: boolean;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* One reader for `--base` and `--dirty`, shared by `x affected` and `x test --affected`: two
|
|
161
|
+
* readers would be two answers to "what is this diff measured against", and the second command's
|
|
162
|
+
* scoping is only trustworthy if it is the first command's.
|
|
163
|
+
*/
|
|
164
|
+
export function readAffectedSelection(args: ParsedArgs, command: string): AffectedSelection {
|
|
165
|
+
const base = flagString(args, 'base') ?? DEFAULT_BASE;
|
|
166
|
+
if (base.trim().length === 0) {
|
|
167
|
+
throw new BadFlagError({
|
|
168
|
+
flag: 'base',
|
|
169
|
+
command,
|
|
170
|
+
reason: 'needs a git ref and got an empty value',
|
|
171
|
+
fix: `${invocationOf(command)} --base ${DEFAULT_BASE} --json`,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
return { base, dirty: flagBool(args, 'dirty') };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const git = (runner: Runner, cwd: string, args: readonly string[]): Promise<ExecResult> =>
|
|
178
|
+
runner(['git', ...args], { cwd });
|
|
179
|
+
|
|
180
|
+
/** NUL-delimited, so a path holding a space, a quote or a newline survives the read intact. */
|
|
181
|
+
const paths = (stdout: string): readonly string[] =>
|
|
182
|
+
stdout.split('\0').filter((path) => path.length > 0);
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* The checkout's own root, which is what every path git prints is relative to — so it is also the
|
|
186
|
+
* root the workspace graph has to be read from, or a `packages/cli/...` path would be matched
|
|
187
|
+
* against dirs resolved somewhere else.
|
|
188
|
+
*/
|
|
189
|
+
export async function gitRoot(runner: Runner, cwd: string, command: string): Promise<string> {
|
|
190
|
+
const result = await git(runner, cwd, ['rev-parse', '--show-toplevel']);
|
|
191
|
+
if (result.ok) return result.stdout.trim();
|
|
192
|
+
throw new UltimateError({
|
|
193
|
+
code: 'X_CLI_UNEXPECTED',
|
|
194
|
+
cause: `x ${command} reads its diff from git and "git rev-parse --show-toplevel" exited ${result.code} in ${cwd}: ${singleLine(execOutput(result))}`,
|
|
195
|
+
fix: `run x ${command} from inside a git checkout — confirm with: git rev-parse --show-toplevel`,
|
|
196
|
+
docs: docsFor('X_CLI_UNEXPECTED'),
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export interface ChangedFilesOptions {
|
|
201
|
+
readonly cwd: string;
|
|
202
|
+
readonly command: string;
|
|
203
|
+
readonly selection: AffectedSelection;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* `<base>...HEAD` — three dots, so the answer is "what this branch changed since it forked",
|
|
208
|
+
* never "how this branch differs from a base that has moved on underneath it". A two-dot diff
|
|
209
|
+
* reports someone else's merged commits as this branch's work.
|
|
210
|
+
*
|
|
211
|
+
* `--dirty` unions the working tree on top: tracked edits against HEAD, plus untracked files that
|
|
212
|
+
* are not ignored. Both halves are needed — a brand-new file is invisible to `git diff`.
|
|
213
|
+
*/
|
|
214
|
+
export async function changedFiles(
|
|
215
|
+
runner: Runner,
|
|
216
|
+
options: ChangedFilesOptions,
|
|
217
|
+
): Promise<readonly string[]> {
|
|
218
|
+
const { base, dirty } = options.selection;
|
|
219
|
+
const resolved = await git(runner, options.cwd, [
|
|
220
|
+
'rev-parse',
|
|
221
|
+
'--verify',
|
|
222
|
+
'--quiet',
|
|
223
|
+
`${base}^{commit}`,
|
|
224
|
+
]);
|
|
225
|
+
if (!resolved.ok) {
|
|
226
|
+
throw new BadFlagError({
|
|
227
|
+
flag: 'base',
|
|
228
|
+
command: options.command,
|
|
229
|
+
reason: `git resolves no commit named "${base}" in this checkout`,
|
|
230
|
+
fix: `git fetch --no-tags origin ${base}:${base}, then re-run: ${invocationOf(options.command)} --base ${base} --json`,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
const runs = [
|
|
234
|
+
await git(runner, options.cwd, ['diff', '--name-only', '-z', `${base}...HEAD`]),
|
|
235
|
+
...(dirty
|
|
236
|
+
? [
|
|
237
|
+
await git(runner, options.cwd, ['diff', '--name-only', '-z', 'HEAD']),
|
|
238
|
+
await git(runner, options.cwd, ['ls-files', '-z', '--others', '--exclude-standard']),
|
|
239
|
+
]
|
|
240
|
+
: []),
|
|
241
|
+
];
|
|
242
|
+
const failed = runs.find((run) => !run.ok);
|
|
243
|
+
if (failed !== undefined) {
|
|
244
|
+
throw new UltimateError({
|
|
245
|
+
code: 'X_CLI_UNEXPECTED',
|
|
246
|
+
cause: `"${failed.command.join(' ')}" exited ${failed.code} in ${options.cwd}: ${singleLine(execOutput(failed))}`,
|
|
247
|
+
fix: `run it yourself to see why: ${failed.command.join(' ')}`,
|
|
248
|
+
docs: docsFor('X_CLI_UNEXPECTED'),
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
return [...new Set(runs.flatMap((run) => paths(run.stdout)))].sort();
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* One resolved answer, in the two shapes its two callers need: the `plan` (`x affected` reports
|
|
256
|
+
* it) and the `prefixes` (`x test --affected` selects with them). `plan.workspaces` holds dirs
|
|
257
|
+
* relative to the CHECKOUT; `prefixes` is the same set relative to the directory the caller scans,
|
|
258
|
+
* which is what its own paths are relative to.
|
|
259
|
+
*/
|
|
260
|
+
export interface AffectedScope {
|
|
261
|
+
readonly selection: AffectedSelection;
|
|
262
|
+
/** The checkout root git reported every path against. */
|
|
263
|
+
readonly root: string;
|
|
264
|
+
readonly plan: AffectedPlan;
|
|
265
|
+
readonly prefixes: readonly string[];
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* An empty prefix means the scan root IS an affected workspace, so every path it yields is in
|
|
270
|
+
* scope; a prefix starting with `..` is a workspace outside that root, which has no file there to
|
|
271
|
+
* select and must not be allowed to collapse into a match-everything empty string.
|
|
272
|
+
*/
|
|
273
|
+
const scopePrefixes = (root: string, cwd: string, dirs: readonly string[]): readonly string[] =>
|
|
274
|
+
dirs
|
|
275
|
+
.map((dir) => relative(cwd, join(root, dir)).split('\\').join('/'))
|
|
276
|
+
.filter((path) => !path.startsWith('..'));
|
|
277
|
+
|
|
278
|
+
export const inScope = (path: string, prefixes: readonly string[]): boolean =>
|
|
279
|
+
prefixes.some((prefix) => prefix === '' || path === prefix || path.startsWith(`${prefix}/`));
|
|
280
|
+
|
|
281
|
+
export interface AffectedScopeOptions {
|
|
282
|
+
readonly runner: Runner;
|
|
283
|
+
/** The directory the caller scans, which the prefixes come back relative to. */
|
|
284
|
+
readonly cwd: string;
|
|
285
|
+
readonly args: ParsedArgs;
|
|
286
|
+
readonly command: string;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* The whole answer, resolved once: the diff, the graph, the closure and the prefixes. `x affected`
|
|
291
|
+
* and `x test --affected` both come through here, so the second can never scope a run differently
|
|
292
|
+
* from what the first reports — an invented scoping that misses a transitive dependent is a green
|
|
293
|
+
* checkmark on a broken repo, and two implementations is how one of them gets it wrong.
|
|
294
|
+
*/
|
|
295
|
+
export async function affectedScope(options: AffectedScopeOptions): Promise<AffectedScope> {
|
|
296
|
+
const selection = readAffectedSelection(options.args, options.command);
|
|
297
|
+
const root = await gitRoot(options.runner, options.cwd, options.command);
|
|
298
|
+
const plan = planAffected(
|
|
299
|
+
await readWorkspaceGraph(root),
|
|
300
|
+
await changedFiles(options.runner, { cwd: root, command: options.command, selection }),
|
|
301
|
+
);
|
|
302
|
+
return {
|
|
303
|
+
selection,
|
|
304
|
+
root,
|
|
305
|
+
plan,
|
|
306
|
+
prefixes: scopePrefixes(
|
|
307
|
+
root,
|
|
308
|
+
options.cwd,
|
|
309
|
+
plan.workspaces.map((workspace) => workspace.dir),
|
|
310
|
+
),
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** The scope as `--json` carries it, from whichever command narrowed by it. */
|
|
315
|
+
export const affectedScopeJson = (scope: AffectedScope): JsonValue => ({
|
|
316
|
+
base: scope.selection.base,
|
|
317
|
+
dirty: scope.selection.dirty,
|
|
318
|
+
changed: scope.plan.changed.length,
|
|
319
|
+
workspaces: scope.plan.workspaces.map((workspace) => workspace.dir),
|
|
320
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// The app's own browser library, resolved from the app's own `node_modules` — never a dependency
|
|
2
|
+
// of this package. `@ultimat3/scraping` declares the launcher's shape structurally (`cdp-port.ts`)
|
|
3
|
+
// precisely so the framework can drive a browser without shipping one, and `x shot` is a CLI
|
|
4
|
+
// command holding to the same bargain: the app installs `puppeteer-core`, the CLI asks for it.
|
|
5
|
+
|
|
6
|
+
import { existsSync } from 'node:fs';
|
|
7
|
+
import { UltimateError } from '@ultimat3/core';
|
|
8
|
+
import type { CdpLauncherLike, ScrapeDriver } from '@ultimat3/scraping';
|
|
9
|
+
import { localBrowser } from '@ultimat3/scraping';
|
|
10
|
+
import { docsFor } from './error-codes';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The one library this works against. Playwright is not an alternative and is not a flag:
|
|
14
|
+
* `packages/scraping/src/cdp-port.ts` records that its `connectOverCDP` cannot perform the
|
|
15
|
+
* WebSocket upgrade under Bun (oven-sh/bun#9911), verified against puppeteer-core 25.8.0.
|
|
16
|
+
*/
|
|
17
|
+
export const BROWSER_PACKAGE = 'puppeteer-core';
|
|
18
|
+
|
|
19
|
+
/** Where a browser binary is named when the flag does not name one. Read in this order. */
|
|
20
|
+
export const BROWSER_PATH_VARS = ['PUPPETEER_EXECUTABLE_PATH', 'CHROME_PATH'] as const;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A missing browser is an instruction, not a crash (axiom 4). The cause distinguishes the two
|
|
24
|
+
* shapes — nothing resolved, or something resolved that is not a launcher — while the fix is the
|
|
25
|
+
* same install either way, because both are answered by putting the real package in the app.
|
|
26
|
+
*/
|
|
27
|
+
export class ShotBrowserMissingError extends UltimateError {
|
|
28
|
+
constructor(input: { root: string; detail: string }) {
|
|
29
|
+
super({
|
|
30
|
+
code: 'X_SHOT_BROWSER_MISSING',
|
|
31
|
+
cause: `x shot drives a real browser and ${BROWSER_PACKAGE} ${input.detail} from ${input.root}`,
|
|
32
|
+
// One literal, not `bun add -d ${BROWSER_PACKAGE}`: `fix-scan.ts` can only read a fix that IS
|
|
33
|
+
// one literal, and a fix line the gate cannot read is a fix line nothing holds to the
|
|
34
|
+
// contract. `browser-launcher.test.ts` pins it against the constant instead.
|
|
35
|
+
fix: 'bun add -d puppeteer-core',
|
|
36
|
+
docs: docsFor('X_SHOT_BROWSER_MISSING'),
|
|
37
|
+
meta: { root: input.root, package: BROWSER_PACKAGE },
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Structural, because this is somebody else's module: a namespace object, a CJS `default`, or a
|
|
44
|
+
* transpiled interop wrapper are all shapes `import()` legitimately hands back, and only one
|
|
45
|
+
* question decides — is there a `launch` to call?
|
|
46
|
+
*/
|
|
47
|
+
const launcherIn = (module: unknown): CdpLauncherLike | undefined => {
|
|
48
|
+
if (typeof module !== 'object' || module === null) return undefined;
|
|
49
|
+
const candidate = module as { launch?: unknown; default?: unknown };
|
|
50
|
+
if (typeof candidate.launch === 'function') return candidate as CdpLauncherLike;
|
|
51
|
+
// `module.exports.default = module.exports` is a real CJS interop shape, so the self-reference is
|
|
52
|
+
// refused rather than followed: one unbounded recursion here is a stack overflow instead of the
|
|
53
|
+
// instruction this whole function exists to produce.
|
|
54
|
+
if (candidate.default === undefined || candidate.default === candidate) return undefined;
|
|
55
|
+
return launcherIn(candidate.default);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export interface AppBrowserOptions {
|
|
59
|
+
readonly root: string;
|
|
60
|
+
/** `--browser`, then `PUPPETEER_EXECUTABLE_PATH`, then `CHROME_PATH`. */
|
|
61
|
+
readonly executablePath?: string | undefined;
|
|
62
|
+
/** Test seam: the resolver and the loader, so a test proves the refusal without an install. */
|
|
63
|
+
readonly resolve?: (specifier: string, from: string) => string;
|
|
64
|
+
readonly load?: (path: string) => Promise<unknown>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The path a run will launch, or `undefined` for "let the library find its own". */
|
|
68
|
+
export const executablePathFrom = (
|
|
69
|
+
flag: string | undefined,
|
|
70
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
71
|
+
): string | undefined => {
|
|
72
|
+
if (flag !== undefined && flag.length > 0) return flag;
|
|
73
|
+
for (const name of BROWSER_PATH_VARS) {
|
|
74
|
+
const value = env[name];
|
|
75
|
+
if (value !== undefined && value.length > 0) return value;
|
|
76
|
+
}
|
|
77
|
+
return undefined;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/** True when a named executable is really there — a bad `--browser` is refused before a boot. */
|
|
81
|
+
export const browserBinaryExists = (path: string): boolean => existsSync(path);
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The app's `puppeteer-core`, as a `ScrapeDriver`. Resolved FROM THE APP ROOT rather than from
|
|
85
|
+
* this module: `import('puppeteer-core')` here would find the CLI's own tree, which by design has
|
|
86
|
+
* no such dependency, and would answer "missing" for an app that installed it correctly.
|
|
87
|
+
*/
|
|
88
|
+
export async function appBrowser(options: AppBrowserOptions): Promise<ScrapeDriver> {
|
|
89
|
+
const resolve = options.resolve ?? ((specifier, from) => Bun.resolveSync(specifier, from));
|
|
90
|
+
const load = options.load ?? ((path: string) => import(path) as Promise<unknown>);
|
|
91
|
+
let entry: string;
|
|
92
|
+
try {
|
|
93
|
+
entry = resolve(BROWSER_PACKAGE, options.root);
|
|
94
|
+
} catch {
|
|
95
|
+
throw new ShotBrowserMissingError({ root: options.root, detail: 'does not resolve' });
|
|
96
|
+
}
|
|
97
|
+
const launcher = launcherIn(await load(entry));
|
|
98
|
+
if (launcher === undefined) {
|
|
99
|
+
throw new ShotBrowserMissingError({
|
|
100
|
+
root: options.root,
|
|
101
|
+
detail: `resolved to ${entry}, which exports no launch()`,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
return localBrowser({
|
|
105
|
+
launcher,
|
|
106
|
+
headless: true,
|
|
107
|
+
...(options.executablePath === undefined ? {} : { executablePath: options.executablePath }),
|
|
108
|
+
});
|
|
109
|
+
}
|
package/src/ci-log.ts
ADDED
|
Binary file
|