@ultimat3/cli 6.0.0 → 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 +50 -4
- package/package.json +25 -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 +29 -3
- package/src/cmd-ci.ts +273 -0
- package/src/cmd-pr.ts +308 -0
- package/src/cmd-shot.ts +320 -0
- package/src/cmd-test.ts +96 -7
- package/src/error-codes.ts +16 -0
- package/src/gh-target.ts +118 -0
- package/src/gh.ts +204 -0
- package/src/index.ts +37 -0
- package/src/island-bundle.ts +62 -3
- package/src/island-solid-production.ts +129 -0
- package/src/island-styles.ts +41 -0
- package/src/mcp-errors.ts +9 -0
- package/src/messages.ts +64 -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/static-report.ts +219 -0
- 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 +20 -41
- package/src/templates/route.ts +14 -1
- package/src/templates/scaffold-app.ts +17 -3
- package/src/templates/scaffold-db-package.ts +32 -1
- package/src/templates/scaffold-mcp-package.ts +35 -2
- package/src/templates/scaffold-package-shape.ts +7 -2
- package/src/test-shards.ts +19 -3
- package/src/verify-checks.ts +11 -1
- package/src/workspace-graph.ts +241 -0
package/CLAUDE.md
CHANGED
|
@@ -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
|
|
@@ -139,6 +150,38 @@ emits a file `defineCatalogs` rejects at the app's first boot. `merge: 'json'` u
|
|
|
139
150
|
(`json-merge.ts`) for the same reason: `x new` and `x g resource` both contribute under `app`, and
|
|
140
151
|
a shallow spread keeps one of them.
|
|
141
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
|
+
|
|
142
185
|
## The `errors` step enforces the error contract
|
|
143
186
|
|
|
144
187
|
| File | Job |
|
|
@@ -816,10 +859,13 @@ typechecked there by default; an app whose tsconfig names an explicit `include`
|
|
|
816
859
|
## Two generators that scaffold something other than a primitive
|
|
817
860
|
|
|
818
861
|
`x g island <name> [--at <dir>]` writes a **client entry point**, not a component: the filename is
|
|
819
|
-
how the bundler discovers it and `mount` is how the hydration runtime calls it, so
|
|
820
|
-
what `templates/island.test.ts` pins
|
|
821
|
-
the
|
|
822
|
-
|
|
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
|
|
823
869
|
`fix:` hands that path straight back.
|
|
824
870
|
|
|
825
871
|
`x g admin:page <name> --permission <perm> [--at <dir>]` writes an ordinary TSX component and **no
|
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",
|
|
@@ -37,29 +37,30 @@
|
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
39
|
"@babel/core": "^7.28.4",
|
|
40
|
-
"@ultimat3/action": "
|
|
41
|
-
"@ultimat3/admin": "
|
|
42
|
-
"@ultimat3/ai": "
|
|
43
|
-
"@ultimat3/cache": "
|
|
44
|
-
"@ultimat3/core": "
|
|
45
|
-
"@ultimat3/db": "
|
|
46
|
-
"@ultimat3/entity": "
|
|
47
|
-
"@ultimat3/http": "
|
|
48
|
-
"@ultimat3/i18n": "
|
|
49
|
-
"@ultimat3/jobs": "
|
|
50
|
-
"@ultimat3/mail": "
|
|
51
|
-
"@ultimat3/manifest": "
|
|
52
|
-
"@ultimat3/mcp": "
|
|
53
|
-
"@ultimat3/policy": "
|
|
54
|
-
"@ultimat3/pwa": "
|
|
55
|
-
"@ultimat3/query": "
|
|
56
|
-
"@ultimat3/realtime": "
|
|
57
|
-
"@ultimat3/render": "
|
|
58
|
-
"@ultimat3/schema": "
|
|
59
|
-
"@ultimat3/
|
|
60
|
-
"@ultimat3/
|
|
61
|
-
"@ultimat3/
|
|
62
|
-
"@ultimat3/
|
|
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",
|
|
63
64
|
"babel-preset-solid": "^1.9.15"
|
|
64
65
|
}
|
|
65
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
|