@ultimat3/cli 19.1.3 → 19.3.1
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 +125 -8
- package/package.json +29 -29
- package/src/app-boundaries.ts +11 -2
- package/src/app-load.ts +5 -1
- package/src/app-openapi.ts +13 -5
- package/src/app-permissions.ts +0 -0
- package/src/browser-launcher.ts +53 -4
- package/src/budgets.ts +60 -7
- package/src/cmd-dev.ts +49 -39
- package/src/cmd-doctor.ts +61 -23
- package/src/cmd-generate.ts +5 -2
- package/src/cmd-i18n.ts +10 -3
- package/src/cmd-jobs.ts +56 -10
- package/src/cmd-shot.ts +3 -1
- package/src/cmd-test.ts +15 -10
- package/src/db-seed.ts +2 -1
- package/src/dev-queue.ts +16 -2
- package/src/dev-reload.ts +46 -0
- package/src/dev-render.ts +28 -7
- package/src/dev-roles.ts +9 -8
- package/src/dev-runtime.ts +4 -1
- package/src/dev-sync.ts +17 -3
- package/src/dev-watch-tree.ts +226 -0
- package/src/dev-watch.ts +75 -0
- package/src/doctor-offline.ts +122 -0
- package/src/duplicate-packages.ts +278 -0
- package/src/error-catalog.ts +4 -5
- package/src/error-codes.ts +6 -0
- package/src/fix-command.ts +40 -1
- package/src/fix-path.ts +10 -11
- package/src/flag-number.ts +15 -0
- package/src/generate-kinds.ts +54 -4
- package/src/generate-write.ts +25 -2
- package/src/gitignore.ts +145 -0
- package/src/hold.ts +50 -17
- package/src/i18n-registration.ts +34 -5
- package/src/index.ts +3 -1
- package/src/island-bundle.ts +123 -10
- package/src/island-harness.ts +11 -4
- package/src/island-states-load.ts +2 -1
- package/src/jobs-driver.ts +4 -1
- package/src/mcp-errors.ts +2 -0
- package/src/mcp-host.ts +21 -9
- package/src/parse.ts +17 -0
- package/src/path-segments.ts +14 -0
- package/src/prerender.ts +68 -16
- package/src/retry-memo.ts +37 -0
- package/src/serve.ts +17 -2
- package/src/shot-browser.ts +23 -4
- package/src/source-files.ts +3 -1
- package/src/static-report.ts +21 -1
- package/src/style-bundle.ts +124 -0
- package/src/style-csp.ts +14 -12
- package/src/style-routes.ts +56 -0
- package/src/sw-artifacts.ts +84 -12
- package/src/templates/admin-page.ts +49 -1
- package/src/templates/resource-form-island.ts +13 -3
- package/src/templates/scaffold-container.ts +12 -0
- package/src/templates/scaffold-repo.ts +13 -2
- package/src/test-passes.ts +79 -0
- package/src/test-shards.ts +110 -36
- package/src/verify-checks.ts +13 -7
- package/src/verify-step.ts +4 -4
- package/src/verify-tests.ts +33 -8
- package/src/web-binding.ts +22 -0
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
// Two installed copies of one registry-holding framework package, found before either registry is
|
|
2
|
+
// asked anything. `@ultimat3/i18n`, `@ultimat3/policy` and `@ultimat3/entity` each keep their
|
|
3
|
+
// registry at MODULE scope, so a second module instance is a second, EMPTY registry: an app whose
|
|
4
|
+
// `packages/i18n` workspace pinned `@ultimat3/i18n@19.0.0` while its root had 19.1.0 registered
|
|
5
|
+
// every catalog into the workspace's copy, the CLI read the root's, and `x i18n check` answered
|
|
6
|
+
// `X_CATALOG_UNREGISTERED` with a fix that said to move a `defineCatalogs()` call that was already
|
|
7
|
+
// exactly where the fix said to put it (ai-maxxing, 2026-09-05). The same split is the one
|
|
8
|
+
// `local-cli.ts` hands over for — a global `x` is a second copy of `@ultimat3/entity` — except this
|
|
9
|
+
// one lives inside the app's own `node_modules`, where no hand-over can reach it.
|
|
10
|
+
|
|
11
|
+
// why: Bun ships no symlink-resolving stat and no path API of its own. `realpathSync` is the whole
|
|
12
|
+
// decision — two symlinks to one directory are one module instance, and two store entries at one
|
|
13
|
+
// version are still two — and `dirname`/`join` walk a resolved entry up to the package that owns it.
|
|
14
|
+
import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
15
|
+
// why: Bun exposes no path API — `dirname` walks a resolved entry up to the package that owns it,
|
|
16
|
+
// `join` reaches its manifest, and `relative` is what turns an absolute hit into the repo-relative
|
|
17
|
+
// path a finding names.
|
|
18
|
+
import { dirname, join, relative } from 'node:path';
|
|
19
|
+
import { ERROR_DOCS_URL } from '@ultimat3/core';
|
|
20
|
+
import type { Finding } from './output';
|
|
21
|
+
import { readWorkspaceGraph } from './workspace-graph';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The packages whose registry is a module-scope table, and what that table is called in a finding.
|
|
25
|
+
* A second copy of any other `@ultimat3/*` package is a mixed-version install the CHANGELOG already
|
|
26
|
+
* warns against; a second copy of one of these is an app that registers into a registry nothing
|
|
27
|
+
* reads.
|
|
28
|
+
*/
|
|
29
|
+
export const REGISTRY_PACKAGES: Readonly<Record<string, string>> = {
|
|
30
|
+
'@ultimat3/i18n': 'catalog registry',
|
|
31
|
+
'@ultimat3/policy': 'permission registry',
|
|
32
|
+
'@ultimat3/entity': 'entity registry',
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** One resolution of one package from one directory. `dir` is the package's REAL directory. */
|
|
36
|
+
export interface InstalledCopy {
|
|
37
|
+
readonly pkg: string;
|
|
38
|
+
/** Where the resolution started, app-root-relative: `.`, a workspace dir, or `CLI_ORIGIN`. */
|
|
39
|
+
readonly from: string;
|
|
40
|
+
readonly dir: string;
|
|
41
|
+
readonly version: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The `from` label of the copy the running CLI itself imports. */
|
|
45
|
+
export const CLI_ORIGIN = 'the x CLI';
|
|
46
|
+
|
|
47
|
+
/** One distinct copy of a duplicated package, and every origin that resolves to it. */
|
|
48
|
+
export interface DuplicateCopy {
|
|
49
|
+
readonly dir: string;
|
|
50
|
+
readonly version: string;
|
|
51
|
+
readonly from: readonly string[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface DuplicateInstall {
|
|
55
|
+
readonly pkg: string;
|
|
56
|
+
/** Newest version first; ties in `dir` order, so a report is stable across runs. */
|
|
57
|
+
readonly copies: readonly DuplicateCopy[];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Semver order, through Bun's own comparator so `19.1.0-beta.1` sorts BELOW `19.1.0` and
|
|
62
|
+
* `19.10.0` above `19.9.0`; a string that is not a version falls back to its text, because the
|
|
63
|
+
* report still has to be stable over whatever a hand-edited manifest carries.
|
|
64
|
+
*/
|
|
65
|
+
function compareVersions(left: string, right: string): number {
|
|
66
|
+
try {
|
|
67
|
+
return Bun.semver.order(left, right);
|
|
68
|
+
} catch {
|
|
69
|
+
return left.localeCompare(right);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The pure half: every package that resolves to more than one real directory. Keyed by REALPATH,
|
|
75
|
+
* never by version — two workspace symlinks to one checkout are one instance whatever the labels
|
|
76
|
+
* say, and two store entries both stamped `19.1.0` are two instances all the same, because a
|
|
77
|
+
* module registry is per module instance and not per version string.
|
|
78
|
+
*/
|
|
79
|
+
export function duplicateInstalls(copies: readonly InstalledCopy[]): readonly DuplicateInstall[] {
|
|
80
|
+
const byPackage = new Map<string, Map<string, { version: string; from: string[] }>>();
|
|
81
|
+
for (const copy of copies) {
|
|
82
|
+
const dirs = byPackage.get(copy.pkg) ?? new Map<string, { version: string; from: string[] }>();
|
|
83
|
+
const entry = dirs.get(copy.dir) ?? { version: copy.version, from: [] };
|
|
84
|
+
if (!entry.from.includes(copy.from)) entry.from.push(copy.from);
|
|
85
|
+
dirs.set(copy.dir, entry);
|
|
86
|
+
byPackage.set(copy.pkg, dirs);
|
|
87
|
+
}
|
|
88
|
+
const duplicates: DuplicateInstall[] = [];
|
|
89
|
+
for (const [pkg, dirs] of byPackage) {
|
|
90
|
+
if (dirs.size < 2) continue;
|
|
91
|
+
const sorted = [...dirs.entries()]
|
|
92
|
+
.map(([dir, entry]) => ({ dir, version: entry.version, from: [...entry.from].sort() }))
|
|
93
|
+
.sort(
|
|
94
|
+
(left, right) =>
|
|
95
|
+
compareVersions(right.version, left.version) || left.dir.localeCompare(right.dir),
|
|
96
|
+
);
|
|
97
|
+
duplicates.push({ pkg, copies: sorted });
|
|
98
|
+
}
|
|
99
|
+
return duplicates.sort((left, right) => left.pkg.localeCompare(right.pkg));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The filesystem this reader touches, injectable so a fixture needs no install. */
|
|
103
|
+
export interface DuplicateIo {
|
|
104
|
+
/** The resolved entry file, or throws — `Bun.resolveSync`'s contract. */
|
|
105
|
+
resolve(specifier: string, from: string): string;
|
|
106
|
+
realpath(path: string): string;
|
|
107
|
+
exists(path: string): boolean;
|
|
108
|
+
readText(path: string): string;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const nodeIo: DuplicateIo = {
|
|
112
|
+
resolve: (specifier, from) => Bun.resolveSync(specifier, from),
|
|
113
|
+
realpath: (path) => realpathSync(path),
|
|
114
|
+
exists: (path) => existsSync(path),
|
|
115
|
+
readText: (path) => readFileSync(path, 'utf8'),
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Deep enough for `src/index.ts` and for any entry an `exports` map could point at, shallow enough
|
|
120
|
+
* that a resolver answering something unexpected stops rather than walking to `/` — the same bound
|
|
121
|
+
* `framework-scope.ts` walks under.
|
|
122
|
+
*/
|
|
123
|
+
const MAX_DEPTH = 6;
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The real directory owning `entry`, judged by the `package.json` that NAMES `pkg`. The entry alone
|
|
127
|
+
* is not enough: a workspace checkout resolves to `packages/i18n/src/index.ts` and a published
|
|
128
|
+
* install to `…/dist/index.js`, and the first `package.json` upward from either is the one that
|
|
129
|
+
* says which package it is — a stray manifest in a `src/` would otherwise be reported as a copy.
|
|
130
|
+
*/
|
|
131
|
+
function packageDirOf(
|
|
132
|
+
pkg: string,
|
|
133
|
+
entry: string,
|
|
134
|
+
io: DuplicateIo,
|
|
135
|
+
): { dir: string; version: string } | undefined {
|
|
136
|
+
let dir = dirname(entry);
|
|
137
|
+
for (let depth = 0; depth < MAX_DEPTH; depth += 1) {
|
|
138
|
+
const manifest = join(dir, 'package.json');
|
|
139
|
+
if (io.exists(manifest)) {
|
|
140
|
+
let parsed: unknown;
|
|
141
|
+
try {
|
|
142
|
+
parsed = JSON.parse(io.readText(manifest));
|
|
143
|
+
} catch {
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
if (typeof parsed !== 'object' || parsed === null) return undefined;
|
|
147
|
+
const fields = parsed as Record<string, unknown>;
|
|
148
|
+
if (fields['name'] !== pkg) return undefined;
|
|
149
|
+
const version = fields['version'];
|
|
150
|
+
return { dir: io.realpath(dir), version: typeof version === 'string' ? version : '0.0.0' };
|
|
151
|
+
}
|
|
152
|
+
const parent = dirname(dir);
|
|
153
|
+
if (parent === dir) return undefined;
|
|
154
|
+
dir = parent;
|
|
155
|
+
}
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* One resolution per (package, origin), for every origin that IMPORTS the package in a real boot:
|
|
161
|
+
* the app root, every workspace the root manifest claims, and this CLI's own directory — which is
|
|
162
|
+
* what `x i18n check` reads its registry through. A directory that cannot resolve the package
|
|
163
|
+
* contributes nothing: a workspace with no dependency on `@ultimat3/policy` is not a copy of it.
|
|
164
|
+
*
|
|
165
|
+
* `cliDir` is injectable so a test can stand the CLI somewhere with no framework under it;
|
|
166
|
+
* production passes nothing, and the only defensible base is this module's own directory.
|
|
167
|
+
*/
|
|
168
|
+
export async function installedCopies(
|
|
169
|
+
root: string,
|
|
170
|
+
packages: readonly string[],
|
|
171
|
+
io: DuplicateIo = nodeIo,
|
|
172
|
+
cliDir: string = import.meta.dir,
|
|
173
|
+
): Promise<readonly InstalledCopy[]> {
|
|
174
|
+
const origins: { from: string; dir: string }[] = [{ from: '.', dir: root }];
|
|
175
|
+
for (const node of await readWorkspaceGraph(root)) {
|
|
176
|
+
origins.push({ from: node.dir, dir: join(root, node.dir) });
|
|
177
|
+
}
|
|
178
|
+
origins.push({ from: CLI_ORIGIN, dir: cliDir });
|
|
179
|
+
|
|
180
|
+
const copies: InstalledCopy[] = [];
|
|
181
|
+
for (const pkg of packages) {
|
|
182
|
+
for (const origin of origins) {
|
|
183
|
+
let entry: string;
|
|
184
|
+
try {
|
|
185
|
+
entry = io.resolve(pkg, origin.dir);
|
|
186
|
+
} catch {
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
const owner = packageDirOf(pkg, entry, io);
|
|
190
|
+
if (owner === undefined) continue;
|
|
191
|
+
copies.push({ pkg, from: origin.from, dir: owner.dir, version: owner.version });
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return copies;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** The two steps composed — what `x i18n check` and the gate's `policy` step call. */
|
|
198
|
+
export async function findDuplicateInstalls(
|
|
199
|
+
root: string,
|
|
200
|
+
packages: readonly string[],
|
|
201
|
+
): Promise<readonly DuplicateInstall[]> {
|
|
202
|
+
return duplicateInstalls(await installedCopies(root, packages));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** What a caller injects in place of `findDuplicateInstalls`, so a fixture needs no install. */
|
|
206
|
+
export type DuplicateProbe = (
|
|
207
|
+
root: string,
|
|
208
|
+
packages: readonly string[],
|
|
209
|
+
) => Promise<readonly DuplicateInstall[]>;
|
|
210
|
+
|
|
211
|
+
/** `<dir>@<version> (resolved from ., packages/i18n)` — one clause per copy. */
|
|
212
|
+
const renderCopy = (root: string, copy: DuplicateCopy): string => {
|
|
213
|
+
const shown = relative(root, copy.dir).replaceAll('\\', '/');
|
|
214
|
+
const dir = shown === '' || shown.startsWith('..') ? copy.dir : shown;
|
|
215
|
+
return `${dir}@${copy.version} (resolved from ${copy.from.join(', ')})`;
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
/** The manifest an origin pins its dependencies in — the file the fix names an edit to. */
|
|
219
|
+
const manifestOf = (from: string): string | undefined =>
|
|
220
|
+
from === CLI_ORIGIN ? undefined : from === '.' ? 'package.json' : `${from}/package.json`;
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* The one sentence both findings share: which copies, where each is read from, and why that is
|
|
224
|
+
* an empty registry rather than a version skew. `root` is what turns a store path into the
|
|
225
|
+
* app-relative one an agent can open.
|
|
226
|
+
*/
|
|
227
|
+
export function duplicateCause(root: string, duplicate: DuplicateInstall): string {
|
|
228
|
+
const registry = REGISTRY_PACKAGES[duplicate.pkg] ?? 'registry';
|
|
229
|
+
const copies = duplicate.copies.map((copy) => renderCopy(root, copy)).join(' and ');
|
|
230
|
+
return (
|
|
231
|
+
`${duplicate.copies.length} copies of ${duplicate.pkg} are installed: ${copies} — each is ` +
|
|
232
|
+
`its own module instance with its own ${registry}, so what the app registers into one, the ` +
|
|
233
|
+
'other never sees'
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* The command that leaves ONE copy, then the command that re-checks. Two shapes, because the two
|
|
239
|
+
* causes repair differently: copies at different versions are a workspace pinning an older
|
|
240
|
+
* range, and the fix names that manifest and the version to pin; copies at ONE version are two
|
|
241
|
+
* resolutions of one range — a stale nested `node_modules`, or two peer sets in the store — and
|
|
242
|
+
* the only edit that can collapse them is the install itself.
|
|
243
|
+
*/
|
|
244
|
+
export function duplicateFix(duplicate: DuplicateInstall, recheck: string): string {
|
|
245
|
+
const newest = duplicate.copies[0];
|
|
246
|
+
const older = duplicate.copies.filter((copy) => copy.version !== newest?.version);
|
|
247
|
+
if (newest !== undefined && older.length > 0) {
|
|
248
|
+
const manifests = [
|
|
249
|
+
...new Set(older.flatMap((copy) => copy.from.map(manifestOf)).filter(Boolean)),
|
|
250
|
+
];
|
|
251
|
+
const where = manifests.length === 0 ? 'every package.json that pins it' : manifests.join(', ');
|
|
252
|
+
return `set "${duplicate.pkg}": "${newest.version}" in ${where}, then: bun install && ${recheck}`;
|
|
253
|
+
}
|
|
254
|
+
return `bun install --force # one resolution of ${duplicate.pkg} for every workspace; then: ${recheck}`;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* The finding both steps report. Its own code rather than the registry's own (`X_CATALOG_UNREGISTERED`,
|
|
259
|
+
* `X_PERMISSION_UNKNOWN`): those name a symptom that has a source-level repair, and this names an
|
|
260
|
+
* install with no source-level repair at all — an agent handed the registry's fix edits a file that
|
|
261
|
+
* is already right, re-runs, and is red again.
|
|
262
|
+
*/
|
|
263
|
+
export function duplicateFinding(
|
|
264
|
+
root: string,
|
|
265
|
+
duplicate: DuplicateInstall,
|
|
266
|
+
recheck: string,
|
|
267
|
+
): Finding {
|
|
268
|
+
const at = duplicate.copies
|
|
269
|
+
.flatMap((copy) => copy.from.map(manifestOf))
|
|
270
|
+
.find((manifest): manifest is string => manifest !== undefined);
|
|
271
|
+
return {
|
|
272
|
+
code: 'X_PACKAGE_DUPLICATED',
|
|
273
|
+
cause: duplicateCause(root, duplicate),
|
|
274
|
+
fix: duplicateFix(duplicate, recheck),
|
|
275
|
+
docs: ERROR_DOCS_URL,
|
|
276
|
+
...(at === undefined ? {} : { at }),
|
|
277
|
+
};
|
|
278
|
+
}
|
package/src/error-catalog.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// commands actually need — so without this, `x errors explain X_UNAUTHENTICATED` answered "not a
|
|
4
4
|
// registered error code" for a code the framework throws on every unauthenticated request.
|
|
5
5
|
|
|
6
|
-
import { listErrorCodes } from '@ultimat3/core';
|
|
6
|
+
import { listErrorCodes, stringField } from '@ultimat3/core';
|
|
7
7
|
import type { Finding } from './output';
|
|
8
8
|
import { findingFrom } from './output';
|
|
9
9
|
|
|
@@ -82,10 +82,9 @@ let cached: Promise<ErrorCatalog> | undefined;
|
|
|
82
82
|
* the host gap. Anything else escaped the package's own module evaluation and is its defect.
|
|
83
83
|
*/
|
|
84
84
|
const isUnresolved = (thrown: unknown): boolean =>
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
'code'
|
|
88
|
-
thrown.code === 'ERR_MODULE_NOT_FOUND';
|
|
85
|
+
// `stringField`, never `'code' in thrown && thrown.code`: see `cmd-i18n.ts`'s `isAlreadyExists`
|
|
86
|
+
// — an `in` guard narrows the type and reads the property off a foreign value all the same.
|
|
87
|
+
stringField(thrown, 'code') === 'ERR_MODULE_NOT_FOUND';
|
|
89
88
|
|
|
90
89
|
/** The package's own error, named and located, so the report says what broke and what to run. */
|
|
91
90
|
function initFailure(specifier: string, thrown: unknown): Finding {
|
package/src/error-codes.ts
CHANGED
|
@@ -148,6 +148,11 @@ export const CLI_OWNED_ERROR_CODES = [
|
|
|
148
148
|
'X_SECRETS_EDITOR_MISSING',
|
|
149
149
|
'X_SECRETS_EDIT_FAILED',
|
|
150
150
|
'X_WORKSPACE_DEP_UNDECLARED',
|
|
151
|
+
// Two module instances of a registry-holding package inside ONE app's `node_modules`. Its own
|
|
152
|
+
// code and not the registry's (`X_CATALOG_UNREGISTERED`, `X_PERMISSION_UNKNOWN`): those carry a
|
|
153
|
+
// source-level fix, and an agent following it here edits a file that is already right — the
|
|
154
|
+
// repair is an install, and only `duplicate-packages.ts` can see that.
|
|
155
|
+
'X_PACKAGE_DUPLICATED',
|
|
151
156
|
'X_SHOT_BROWSER_MISSING',
|
|
152
157
|
// `x shot --island` — one code per way a component's named state fails to become a picture.
|
|
153
158
|
// The last of the four is the one that gates: it is checked against the expansion computed
|
|
@@ -290,6 +295,7 @@ export const CLI_ERROR_TITLES: Readonly<Record<CliOwnedErrorCode, string>> = {
|
|
|
290
295
|
X_SECRETS_EDITOR_MISSING: 'no $EDITOR to open the decrypted secrets in',
|
|
291
296
|
X_SECRETS_EDIT_FAILED: 'the editor exited non-zero, so nothing was resealed',
|
|
292
297
|
X_WORKSPACE_DEP_UNDECLARED: 'a workspace imports another workspace it does not declare',
|
|
298
|
+
X_PACKAGE_DUPLICATED: 'two copies of one registry-holding framework package are installed',
|
|
293
299
|
X_SHOT_BROWSER_MISSING: 'x shot found no browser library in the app',
|
|
294
300
|
X_SHOT_ISLAND_STATES_EMPTY: 'an island states file declares no manifest',
|
|
295
301
|
X_SHOT_ISLAND_UNPHOTOGRAPHABLE: 'the island never reached a state worth photographing',
|
package/src/fix-command.ts
CHANGED
|
@@ -45,6 +45,18 @@ const CITATION = new RegExp(
|
|
|
45
45
|
*/
|
|
46
46
|
const FLAG = /(?:^|\s)--(?:no-)?([a-z][a-z\d-]*)/g;
|
|
47
47
|
|
|
48
|
+
/**
|
|
49
|
+
* A bare `--`: the boundary past which the words belong to ANOTHER tool, not to `x`. Only `x test`
|
|
50
|
+
* declares one (`CommandSpec.passthrough`), and every other command refuses a non-empty tail — so
|
|
51
|
+
* this is read for two reasons at once: the tail's flags are not the command's, and a tail cited
|
|
52
|
+
* on a command that hands nothing on is a documented `X_CLI_BAD_FLAG`.
|
|
53
|
+
*
|
|
54
|
+
* Without it, `x test unit -- --coverage --bail` — the only spelling that reaches bun's own flags
|
|
55
|
+
* — read as `x test --coverage`, and the one line documenting the passthrough was a standing
|
|
56
|
+
* false finding on the rule written to keep documented invocations runnable.
|
|
57
|
+
*/
|
|
58
|
+
const BARE_TAIL = /(?:^|\s)--(?=\s|$)/;
|
|
59
|
+
|
|
48
60
|
/**
|
|
49
61
|
* Where a citation's argument list ends. `;`, `|` and `&` start a second shell word, `#` starts a
|
|
50
62
|
* comment, and a backtick or a quote closes the span the citation was written in — past any of
|
|
@@ -60,6 +72,11 @@ export interface FixCitation {
|
|
|
60
72
|
readonly positional: string | undefined;
|
|
61
73
|
/** Long flags written after it, in order, `--` and any `no-` stripped. */
|
|
62
74
|
readonly flags: readonly string[];
|
|
75
|
+
/**
|
|
76
|
+
* The words after a bare `--`, when the citation has one. Absent otherwise, so a citation with
|
|
77
|
+
* no tail is the same object it has always been.
|
|
78
|
+
*/
|
|
79
|
+
readonly tail?: readonly string[];
|
|
63
80
|
}
|
|
64
81
|
|
|
65
82
|
/**
|
|
@@ -82,11 +99,24 @@ export function fixCitations(fix: string): readonly FixCitation[] {
|
|
|
82
99
|
const tail = fix.slice(start, next);
|
|
83
100
|
const stop = ARGUMENT_END.exec(tail)?.index;
|
|
84
101
|
const args = stop === undefined ? tail : tail.slice(0, stop);
|
|
102
|
+
// The tail is split off BEFORE the flags are read: `--coverage` after a bare `--` is bun's
|
|
103
|
+
// flag, and charging it to `x test` is the same error as charging a second citation's flags
|
|
104
|
+
// to the first one, which the slice above already exists to prevent.
|
|
105
|
+
const cut = BARE_TAIL.exec(args);
|
|
106
|
+
const head = cut === null ? args : args.slice(0, cut.index);
|
|
107
|
+
const handed =
|
|
108
|
+
cut === null
|
|
109
|
+
? undefined
|
|
110
|
+
: args
|
|
111
|
+
.slice(cut.index + cut[0].length)
|
|
112
|
+
.split(/\s+/)
|
|
113
|
+
.filter((word) => word !== '');
|
|
85
114
|
return {
|
|
86
115
|
command: match[1] as string,
|
|
87
116
|
sub: match[2],
|
|
88
117
|
positional: match[3],
|
|
89
|
-
flags: [...
|
|
118
|
+
flags: [...head.matchAll(FLAG)].map((flag) => flag[1] as string),
|
|
119
|
+
...(handed === undefined ? {} : { tail: handed }),
|
|
90
120
|
};
|
|
91
121
|
});
|
|
92
122
|
}
|
|
@@ -222,6 +252,15 @@ export function citationFault(
|
|
|
222
252
|
}
|
|
223
253
|
}
|
|
224
254
|
if (planned) return undefined;
|
|
255
|
+
// Judged before the flags, because a tail is why they are not the command's. A command that
|
|
256
|
+
// declares no `passthrough` refuses a non-empty `--` outright (`parse.ts`), so a page handing a
|
|
257
|
+
// reader one is handing them `X_CLI_BAD_FLAG` — the same class as an undeclared flag.
|
|
258
|
+
if (citation.tail !== undefined && citation.tail.length > 0 && spec.passthrough !== true) {
|
|
259
|
+
return {
|
|
260
|
+
subject: `x ${spec.name} --`,
|
|
261
|
+
reason: `and ${spec.name} hands nothing to another tool — the parser refuses the -- with X_CLI_BAD_FLAG rather than dropping ${citation.tail.join(' ')}`,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
225
264
|
const declared = declaredFlags(spec);
|
|
226
265
|
const unknown = citation.flags.find((flag) => !declared.has(flag));
|
|
227
266
|
if (unknown === undefined) return undefined;
|
package/src/fix-path.ts
CHANGED
|
@@ -5,10 +5,11 @@
|
|
|
5
5
|
|
|
6
6
|
// why: Bun exposes no synchronous existence primitive — `Bun.file(p).exists()` is async and answers
|
|
7
7
|
// false for a DIRECTORY, and this rule has to judge both. Delete when Bun ships one.
|
|
8
|
-
import { existsSync
|
|
8
|
+
import { existsSync } from 'node:fs';
|
|
9
9
|
// why: Bun exposes no path-join or dirname primitive. The same necessity `error-contract.ts`
|
|
10
10
|
// already records for `join`.
|
|
11
11
|
import { dirname, join } from 'node:path';
|
|
12
|
+
import { readIgnoreFile } from './gitignore';
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* The extensions a fix line may cite a file by — the SAME set `COMMAND_TOKENS`' file pattern is
|
|
@@ -77,22 +78,20 @@ function isJudgeable(token: string, root: string): boolean {
|
|
|
77
78
|
/**
|
|
78
79
|
* The directories the root `.gitignore` lists as ignored, as `dir/` prefixes. Only the plain
|
|
79
80
|
* directory form is read (`.personal/`, `/tmp/`, `dist`): a negation, a glob or a nested pattern
|
|
80
|
-
* is a rule about files the repo may still hold, and this exclusion errs towards judging
|
|
81
|
+
* is a rule about files the repo may still hold, and this exclusion errs towards judging — which
|
|
82
|
+
* is why it keeps its own narrow policy over `parseGitignore`'s answer rather than asking
|
|
83
|
+
* `isGitIgnored`. What it may NOT keep is a second PARSER: `dev-watch.ts` needs full gitignore
|
|
84
|
+
* semantics, and two readers of one file are two answers to what an app committed.
|
|
81
85
|
*/
|
|
82
86
|
const ignoredDirs = new Map<string, readonly string[]>();
|
|
83
87
|
|
|
84
88
|
function ignoredDirectories(root: string): readonly string[] {
|
|
85
89
|
const known = ignoredDirs.get(root);
|
|
86
90
|
if (known !== undefined) return known;
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
.map((
|
|
91
|
-
.filter((line) => line !== '' && !line.startsWith('#') && !line.startsWith('!'))
|
|
92
|
-
.filter((line) => !/[*?[\]]/.test(line))
|
|
93
|
-
.map((line) => line.replace(/^\//, '').replace(/\/$/, ''))
|
|
94
|
-
.filter((line) => line !== '' && !line.includes('/'))
|
|
95
|
-
.map((dir) => `${dir}/`);
|
|
91
|
+
const dirs = readIgnoreFile(root)
|
|
92
|
+
.filter((pattern) => !pattern.negated)
|
|
93
|
+
.filter((pattern) => !/[*?[\]]/.test(pattern.glob) && !pattern.glob.includes('/'))
|
|
94
|
+
.map((pattern) => `${pattern.glob}/`);
|
|
96
95
|
ignoredDirs.set(root, dirs);
|
|
97
96
|
return dirs;
|
|
98
97
|
}
|
package/src/flag-number.ts
CHANGED
|
@@ -65,3 +65,18 @@ export const PORT_RANGE = { min: 0, max: 65_535 } as const;
|
|
|
65
65
|
*/
|
|
66
66
|
export const neighbouringPort = (port: number): number =>
|
|
67
67
|
port < PORT_RANGE.max ? port + 1 : PORT_RANGE.max - 1;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The same suggestion for a caller that binds a PAIR. `x dev --port N` occupies N and N + 1, so
|
|
71
|
+
* `neighbouringPort` hands back the neighbour — which, when it is the neighbour that was taken, is
|
|
72
|
+
* the very socket the refusal is about: `x dev --port 3999` died on 4000 and its `fix:` said
|
|
73
|
+
* `x dev --port 4000`, and `x doctor` said it too, about the same pair (both pinned by a test
|
|
74
|
+
* named "its fix is a command that ends the failure"). Two above is the nearest base whose own
|
|
75
|
+
* pair touches neither.
|
|
76
|
+
*
|
|
77
|
+
* Downward at the top of the range, for `neighbouringPort`'s reason and one further: the answer's
|
|
78
|
+
* OWN neighbour has to be a port, or `syncPortFor` refuses the suggestion with `X_PORT_INVALID`.
|
|
79
|
+
* Only reachable above 65532, so the subtraction can never go below the range.
|
|
80
|
+
*/
|
|
81
|
+
export const portPairAfter = (port: number): number =>
|
|
82
|
+
port + 2 < PORT_RANGE.max ? port + 2 : port - 2;
|
package/src/generate-kinds.ts
CHANGED
|
@@ -3,7 +3,12 @@
|
|
|
3
3
|
// that held both had reached the 500-line ceiling, one generator short of failing its own gate.
|
|
4
4
|
|
|
5
5
|
import { nearestName } from '@ultimat3/core';
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
BadFlagError,
|
|
8
|
+
MissingPositionalError,
|
|
9
|
+
MissingSubcommandError,
|
|
10
|
+
UnknownCommandError,
|
|
11
|
+
} from './errors';
|
|
7
12
|
import type { Surface } from './templates';
|
|
8
13
|
|
|
9
14
|
export const GENERATORS = [
|
|
@@ -57,14 +62,26 @@ export function assertSurfaceSupported(kind: Generator, surface: Surface, name:
|
|
|
57
62
|
* pins for a command that resembles nothing, and the reason `nearestName` is never asked about an
|
|
58
63
|
* ABSENT kind: the empty string is within the cutoff of `job`, so `x g` would "suggest" a
|
|
59
64
|
* generator nobody typed.
|
|
65
|
+
*
|
|
66
|
+
* And a THIRD rule, for the word that is not there at all: no generator is a missing subcommand,
|
|
67
|
+
* never an unknown command — see the branch below.
|
|
60
68
|
*/
|
|
61
69
|
export function readKind(raw: string | undefined): Generator {
|
|
62
70
|
const kinds: readonly string[] = GENERATORS;
|
|
63
71
|
if (raw !== undefined && kinds.includes(raw)) return raw as Generator;
|
|
64
|
-
|
|
72
|
+
// A MISSING generator is not an unknown command. `x g --json` answered `X_CLI_UNKNOWN_COMMAND:
|
|
73
|
+
// "x g" is not a command` — false, and it sends an agent hunting a typo it did not make: `g` is
|
|
74
|
+
// in `x help`, the parser reaches it, and `x g route foo` runs. The same argument `readName`
|
|
75
|
+
// makes below about the missing `<name>`, one word earlier. `MissingSubcommandError` because
|
|
76
|
+
// the generator is a closed vocabulary the caller did not choose from — its cause lists every
|
|
77
|
+
// one, which is the answer to "which of these did I leave out"; the near-miss reader below is
|
|
78
|
+
// deliberately never asked about an absent word, since the empty string is within `job`'s
|
|
79
|
+
// cutoff and would "suggest" a generator nobody typed.
|
|
80
|
+
if (raw === undefined) throw new MissingSubcommandError({ command: 'g', known: GENERATORS });
|
|
81
|
+
const near = nearestName(raw, kinds);
|
|
65
82
|
const suggestion = GENERATORS.find((kind) => kind === near);
|
|
66
83
|
throw new UnknownCommandError({
|
|
67
|
-
path: `g ${raw
|
|
84
|
+
path: `g ${raw}`.trim(),
|
|
68
85
|
known: GENERATORS,
|
|
69
86
|
suggestion: suggestion === undefined ? 'help g' : `g ${suggestion} ${EXAMPLE_NAME[suggestion]}`,
|
|
70
87
|
});
|
|
@@ -93,7 +110,40 @@ export function readName(raw: string | undefined, kind: Generator): string {
|
|
|
93
110
|
throw new MissingPositionalError({
|
|
94
111
|
command: `g ${kind}`,
|
|
95
112
|
positional: 'name',
|
|
96
|
-
example:
|
|
113
|
+
example: exampleFor(kind),
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The one read of `EXAMPLE_NAME`, and it is guarded: `kind` is a validated union today, which is
|
|
119
|
+
* the argument every instance `scripts/proto-index.ts` reports had before it stopped being true —
|
|
120
|
+
* a `Record` object literal answers an `Object.prototype` member for a key nobody declared, and
|
|
121
|
+
* this string is pasted into a shell. Two callers, one read.
|
|
122
|
+
*/
|
|
123
|
+
const exampleFor = (kind: Generator): string =>
|
|
124
|
+
`x g ${kind} ${Object.hasOwn(EXAMPLE_NAME, kind) ? EXAMPLE_NAME[kind] : '<name>'}`;
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* `resource:verb`, or nothing is written. `--permission` reaches the generated page in three
|
|
128
|
+
* places — the `permissions:` array, a `definePermissions()` call and a `PermissionRegistry`
|
|
129
|
+
* augmentation — and `@ultimat3/policy`'s `Permission` type is `${string}:${string}`, so
|
|
130
|
+
* `x g admin:page ops --permission ops` used to emit a page that does not compile. A quote or a
|
|
131
|
+
* space is refused for the same reason one step earlier: the value is spliced into a string
|
|
132
|
+
* literal in emitted source, and neither is a permission any app declares.
|
|
133
|
+
*
|
|
134
|
+
* It cannot ask whether the app DECLARES the permission — `x g` writes files against a root, it
|
|
135
|
+
* never loads the app, and an app that will not import is exactly when a generator is reached for.
|
|
136
|
+
* The generated page declaring it is what closes that half (`templates/admin-page.ts`).
|
|
137
|
+
*/
|
|
138
|
+
const PERMISSION_SHAPE = /^[a-z0-9][a-z0-9_.-]*:[a-z0-9*][a-z0-9_.*-]*$/i;
|
|
139
|
+
|
|
140
|
+
export function readPermission(raw: string | undefined, kind: Generator): string | undefined {
|
|
141
|
+
if (raw === undefined || PERMISSION_SHAPE.test(raw)) return raw;
|
|
142
|
+
throw new BadFlagError({
|
|
143
|
+
flag: 'permission',
|
|
144
|
+
command: 'g',
|
|
145
|
+
reason: `expects a permission of the form <resource>:<verb>, got "${raw}"`,
|
|
146
|
+
fix: `${exampleFor(kind)} --permission ops:read`,
|
|
97
147
|
});
|
|
98
148
|
}
|
|
99
149
|
|
package/src/generate-write.ts
CHANGED
|
@@ -27,11 +27,34 @@ function parseJsonObject(text: string): Record<string, unknown> | undefined {
|
|
|
27
27
|
: undefined;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Every key of every level, sorted. A catalog is authored NESTED (`{ nav: { home: … } }`) and this
|
|
32
|
+
* sorted the top level only, so the keys INSIDE `app` kept the order their generators ran in —
|
|
33
|
+
* `x g route zebra` then `x g route alpha` wrote different bytes from the same two runs in the
|
|
34
|
+
* other order, which is the reordering diff the sort exists to prevent. Arrays keep their order:
|
|
35
|
+
* a list's order is its content.
|
|
36
|
+
*
|
|
37
|
+
* CODE UNIT, never `localeCompare`: it is `Intl`-backed, so the bytes a catalog is written with
|
|
38
|
+
* moved with the machine's locale and its ICU version — `x g route` on a `tr-TR` box and the same
|
|
39
|
+
* command in CI produced two orderings of one file, which is exactly the reordering diff this sort
|
|
40
|
+
* exists to prevent, one layer down. `@ultimat3/render` states the same rule as `byCodeUnit`; the
|
|
41
|
+
* comparison is inlined rather than imported because that one is package-internal and this is the
|
|
42
|
+
* whole of it.
|
|
43
|
+
*/
|
|
44
|
+
function sortDeep(value: unknown): unknown {
|
|
45
|
+
if (Array.isArray(value)) return value.map(sortDeep);
|
|
46
|
+
if (typeof value !== 'object' || value === null) return value;
|
|
47
|
+
return Object.fromEntries(
|
|
48
|
+
Object.entries(value)
|
|
49
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
50
|
+
.map(([key, entry]) => [key, sortDeep(entry)]),
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
30
54
|
/** Deterministic catalog bytes: sorted keys, 2-space indent, trailing newline — a diff shows only
|
|
31
55
|
* the keys a run actually changed, never a reordering. */
|
|
32
56
|
function prettyJson(value: Record<string, unknown>): string {
|
|
33
|
-
|
|
34
|
-
return `${JSON.stringify(sorted, null, 2)}\n`;
|
|
57
|
+
return `${JSON.stringify(sortDeep(value), null, 2)}\n`;
|
|
35
58
|
}
|
|
36
59
|
|
|
37
60
|
/**
|