@ultimat3/cli 19.2.0 → 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 +108 -6
- package/package.json +29 -29
- package/src/app-boundaries.ts +11 -2
- package/src/app-load.ts +5 -1
- package/src/budgets.ts +17 -6
- package/src/cmd-dev.ts +29 -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-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-runtime.ts +4 -1
- package/src/dev-sync.ts +11 -3
- package/src/dev-watch-tree.ts +226 -0
- package/src/dev-watch.ts +59 -37
- package/src/doctor-offline.ts +122 -0
- package/src/error-catalog.ts +4 -5
- 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/index.ts +1 -1
- package/src/island-bundle.ts +2 -1
- package/src/island-states-load.ts +2 -1
- package/src/jobs-driver.ts +4 -1
- package/src/mcp-host.ts +18 -9
- package/src/parse.ts +17 -0
- package/src/path-segments.ts +14 -0
- package/src/prerender.ts +46 -20
- package/src/retry-memo.ts +37 -0
- package/src/serve.ts +5 -1
- package/src/source-files.ts +3 -1
- package/src/sw-artifacts.ts +71 -7
- package/src/templates/admin-page.ts +49 -1
- package/src/templates/scaffold-container.ts +12 -0
- package/src/templates/scaffold-repo.ts +7 -2
- package/src/test-passes.ts +79 -0
- package/src/test-shards.ts +110 -36
- package/src/verify-checks.ts +6 -6
- package/src/verify-step.ts +4 -4
- package/src/verify-tests.ts +14 -2
package/src/dev-watch.ts
CHANGED
|
@@ -1,53 +1,75 @@
|
|
|
1
|
-
// Which
|
|
2
|
-
// `
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// Which paths under the app root `x dev` must not watch, and the rule is REGISTRATION rather than
|
|
2
|
+
// filtering: `watch(root, { recursive: true })` takes one inotify descriptor per directory before
|
|
3
|
+
// any filter runs, so an answer given after the event has already cost the kernel queue, a JS
|
|
4
|
+
// callback and a slot out of `max_user_watches`. Measured on a monorepo root: 1901 descriptors,
|
|
5
|
+
// 1490 of them under `.git/` and `node_modules/`, and one `git status` delivering 5 events.
|
|
5
6
|
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
|
|
13
|
-
//
|
|
14
|
-
|
|
7
|
+
// The ignore set is the app's own `.gitignore`, read with git's own anchoring (`gitignore.ts`),
|
|
8
|
+
// plus a floor of directory names an ignore file need not name. It was seven hand-listed names,
|
|
9
|
+
// and both halves of that were wrong: nothing read `.gitignore`, so `tsconfig.tsbuildinfo` —
|
|
10
|
+
// rewritten by every `bun run typecheck` — ran a full `appManifest()` plus `buildIslands()`; and
|
|
11
|
+
// `dist` and `coverage` were matched at ANY depth, so an app's own `/dist` or `/coverage` route
|
|
12
|
+
// never reloaded at all, silently.
|
|
13
|
+
|
|
14
|
+
// why: Bun exposes no path-join primitive. The same necessity `fix-path.ts` already records.
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import type { IgnoreScope } from './gitignore';
|
|
17
|
+
import { ignoreScopes, isGitIgnored } from './gitignore';
|
|
18
|
+
import { pathSegments } from './path-segments';
|
|
15
19
|
|
|
16
20
|
/**
|
|
17
|
-
*
|
|
21
|
+
* Directory names no `.gitignore` can be relied on to carry, matched as a path SEGMENT at any
|
|
22
|
+
* depth. Every entry earns its line, and every one is either dotted or `node_modules` — which is
|
|
23
|
+
* what makes the any-depth match safe: a `site/` subtree is a URL tree, and neither a dot-directory
|
|
24
|
+
* nor an install is ever a route.
|
|
18
25
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* - `node_modules` — an install, not an edit. `x dev`
|
|
24
|
-
*
|
|
25
|
-
* - `.
|
|
26
|
-
* and ref files continuously, and none of them is a source edit; a checkout of a branch IS one,
|
|
27
|
-
* but it also writes the source files themselves, which this list does not touch.
|
|
28
|
-
* - `.personal` — an app's uncommitted local state (ai-maxxing's fleet file, its inventory, its
|
|
29
|
-
* credentials). Written by scripts while the dev server runs.
|
|
26
|
+
* - `.git` — git never names its own directory in an ignore file, and `git status`, `git fetch` and
|
|
27
|
+
* every commit rewrite index and ref files continuously. The reason this list exists.
|
|
28
|
+
* - `.x` — the framework's own state: PGlite's data directory (which THIS process writes
|
|
29
|
+
* continuously), the dev lock, the static export, `build-stats.json`.
|
|
30
|
+
* - `node_modules` — an install, not an edit. `x dev` cannot reload for a dependency change: the
|
|
31
|
+
* modules are already in this process's cache.
|
|
32
|
+
* - `.personal` — an app's uncommitted local state, written by scripts while the dev server runs.
|
|
30
33
|
* - `.claude` — agent scratch, session logs and worktrees. ai-maxxing keeps two entire copies of
|
|
31
34
|
* the app under `.claude/worktrees/`, so a second agent's edit rebuilt the first agent's islands.
|
|
32
|
-
*
|
|
33
|
-
*
|
|
35
|
+
*
|
|
36
|
+
* `dist` and `coverage` were here and are deliberately not: both are ordinary build output that
|
|
37
|
+
* every ignore file already names, and hand-listing them cost an app its own routes.
|
|
34
38
|
*/
|
|
35
|
-
export const
|
|
39
|
+
export const ALWAYS_IGNORED_DIRECTORIES: readonly string[] = [
|
|
40
|
+
'.git',
|
|
36
41
|
'.x',
|
|
37
42
|
'node_modules',
|
|
38
|
-
'.git',
|
|
39
43
|
'.personal',
|
|
40
44
|
'.claude',
|
|
41
|
-
'dist',
|
|
42
|
-
'coverage',
|
|
43
45
|
];
|
|
44
46
|
|
|
47
|
+
/** The ignore set as one question, so the walk and the event filter cannot disagree. */
|
|
48
|
+
export interface DevIgnore {
|
|
49
|
+
/** `path` is app-root-relative; `isDirectory` decides every trailing-slash rule git holds. */
|
|
50
|
+
ignores(path: string, isDirectory: boolean): boolean;
|
|
51
|
+
/** The `.gitignore` files this set was built from, outermost first — what `--json` can report. */
|
|
52
|
+
readonly scopes: readonly IgnoreScope[];
|
|
53
|
+
}
|
|
54
|
+
|
|
45
55
|
/**
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
56
|
+
* Read once, at boot and again on every write that names `.gitignore`. A snapshot rather than a
|
|
57
|
+
* live reader: the watcher rebuilds the whole set and re-walks, so a directory the author just
|
|
58
|
+
* ignored drops its descriptor instead of keeping one nothing will ever read an event from.
|
|
59
|
+
*
|
|
60
|
+
* Two limits, both deliberate. An ANCESTOR ignore file is read at boot and is not watched — it sits
|
|
61
|
+
* outside the app root, so `x dev` has no descriptor there and a restart is the way to pick up an
|
|
62
|
+
* edit to it. A NESTED one (`apps/web/.gitignore`) is not read at all: `ignoreScopes` walks upward
|
|
63
|
+
* only, and watching for one would mean a scope per directory in the tree.
|
|
49
64
|
*/
|
|
50
|
-
export function
|
|
51
|
-
const
|
|
52
|
-
return
|
|
65
|
+
export function devIgnore(root: string): DevIgnore {
|
|
66
|
+
const scopes = ignoreScopes(root);
|
|
67
|
+
return {
|
|
68
|
+
scopes,
|
|
69
|
+
ignores(path: string, isDirectory: boolean): boolean {
|
|
70
|
+
const segments = pathSegments(path);
|
|
71
|
+
if (segments.some((segment) => ALWAYS_IGNORED_DIRECTORIES.includes(segment))) return true;
|
|
72
|
+
return isGitIgnored(scopes, join(root, ...segments), isDirectory);
|
|
73
|
+
},
|
|
74
|
+
};
|
|
53
75
|
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// `x doctor`'s offline-fallback check: the path the app DECLARED, against the routes it really
|
|
2
|
+
// serves. Its own file because answering it needs the app's config and its route table, and
|
|
3
|
+
// `cmd-doctor.ts`'s job is the probe rather than the loading — the `db-backfill.ts` split, one
|
|
4
|
+
// diagnostic over.
|
|
5
|
+
|
|
6
|
+
import { ERROR_DOCS_URL } from '@ultimat3/core';
|
|
7
|
+
import { describeRoutes } from '@ultimat3/render';
|
|
8
|
+
import { loadApp } from './app-load';
|
|
9
|
+
import { APP_CONFIG_FILE } from './app-root';
|
|
10
|
+
import type { Finding } from './output';
|
|
11
|
+
import { loadPwaArtifacts } from './pwa-artifacts';
|
|
12
|
+
|
|
13
|
+
/** A route as this check reads one: the URL it answers, and the surface that answers it. */
|
|
14
|
+
export interface NavigableRoute {
|
|
15
|
+
readonly path: string;
|
|
16
|
+
readonly surface: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface OfflineFallbackFact {
|
|
20
|
+
/**
|
|
21
|
+
* `pwa.offline.fallback` as `loadPwaArtifacts` read it, or `null` — which is both "this app
|
|
22
|
+
* declares no PWA" and "it declares one with no fallback". Either way no service worker is
|
|
23
|
+
* emitted at all (`serviceWorkerArtifacts` answers `undefined`), so the two share one remedy.
|
|
24
|
+
*/
|
|
25
|
+
readonly fallback: string | null;
|
|
26
|
+
/**
|
|
27
|
+
* Every registered route, or `undefined` when the app would not load. `appEntities`' rule
|
|
28
|
+
* (`schema-drift.ts`) one registry over: a module that will not import leaves the registry
|
|
29
|
+
* short, and a short registry reads as "no route serves it" — a generator handed out for a route
|
|
30
|
+
* the app already has, over one file's syntax error.
|
|
31
|
+
*/
|
|
32
|
+
readonly routes: readonly NavigableRoute[] | undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The one surface an OFFLINE navigation can land on. `api/` answers a JSON document and `shared/`
|
|
37
|
+
* is not a URL at all — the pair `sw-artifacts.ts` excludes for the same reason — and `app/` is
|
|
38
|
+
* excluded for a third: `SURFACE_SPECS` allows it `stream | ssr` and nothing else, only a `static`
|
|
39
|
+
* route is prerendered, and the service worker precaches a rendered DOCUMENT
|
|
40
|
+
* (`serviceWorkerArtifacts` reads `documents.get(fallback)`). So an `app/` fallback has nothing
|
|
41
|
+
* to precache and the offline navigation it is supposed to answer reaches the network and fails —
|
|
42
|
+
* this check passing for it is the false green it exists to prevent.
|
|
43
|
+
*
|
|
44
|
+
* It accepted `app` until 2026-09, on the argument that both surfaces answer the same URL and it
|
|
45
|
+
* is the FIX that is opinionated. True about URLs and wrong about offline: the fix's own comment
|
|
46
|
+
* below already refuses `--surface app`, so the check and the remedy disagreed about one code.
|
|
47
|
+
*
|
|
48
|
+
* Residual, and NOT closed by this: a `site/` route declaring `render: 'ssr'` is not prerendered
|
|
49
|
+
* either. `NavigableRoute` carries no render mode, and `describeRoutes()` has one — the narrower
|
|
50
|
+
* check belongs with it.
|
|
51
|
+
*/
|
|
52
|
+
const NAVIGABLE: ReadonlySet<string> = new Set(['site']);
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A fallback `x g route <name> --surface site` can actually create: ONE path segment, which is
|
|
56
|
+
* what the generator turns into `apps/<app>/site/<name>/page.tsx` and therefore into `/<name>`. A
|
|
57
|
+
* nested or punctuated path would be slugified into a DIFFERENT url (`/support/offline` →
|
|
58
|
+
* `/support-offline`), so offering the command there is a fix that runs and leaves the finding
|
|
59
|
+
* exactly where it was.
|
|
60
|
+
*
|
|
61
|
+
* `--surface site`, and never `app`: the document that answers a lost network must render with no
|
|
62
|
+
* network, no session and no database, which `app/` (`ssr | stream`) cannot promise — the reason
|
|
63
|
+
* `x new` scaffolds it under `site/` (`wiki/Upgrading.md`) — and it is the line `@ultimat3/pwa`'s
|
|
64
|
+
* own `X_PWA_NO_OFFLINE_FALLBACK` hands out. Two fixes for one code are two answers.
|
|
65
|
+
*/
|
|
66
|
+
const GENERATABLE = /^\/([a-z][a-z0-9-]*)$/;
|
|
67
|
+
|
|
68
|
+
const finding = (cause: string, fix: string): Finding => ({
|
|
69
|
+
code: 'X_PWA_NO_OFFLINE_FALLBACK',
|
|
70
|
+
cause,
|
|
71
|
+
fix,
|
|
72
|
+
docs: ERROR_DOCS_URL,
|
|
73
|
+
at: APP_CONFIG_FILE,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The declared fallback, judged against the route table — never against a filename.
|
|
78
|
+
*
|
|
79
|
+
* It WAS a filename: the literal `apps/web/app/offline.tsx`, which `assertRouteFilename` refuses
|
|
80
|
+
* outright (the directory is the URL, so a page is `page.tsx`), while `x new` scaffolds
|
|
81
|
+
* `apps/web/site/offline/page.tsx` and this finding's own `fix:` writes
|
|
82
|
+
* `apps/web/app/offline/page.tsx`. Every one of the three is a different path, so the check was
|
|
83
|
+
* red for every app the framework has ever produced and no invocation could clear it — the shape
|
|
84
|
+
* `budgets.ts` calls a false green read backwards, and the reason a diagnostic is held to being
|
|
85
|
+
* closable by its own fix.
|
|
86
|
+
*/
|
|
87
|
+
export function offlineFallbackFinding(fact: OfflineFallbackFact): Finding | undefined {
|
|
88
|
+
if (fact.routes === undefined) return undefined;
|
|
89
|
+
const fallback = fact.fallback;
|
|
90
|
+
if (fallback === null) {
|
|
91
|
+
return finding(
|
|
92
|
+
'no pwa.offline.fallback is declared, so no service worker is emitted and an offline navigation falls back to the browser error page',
|
|
93
|
+
`set pwa: { offline: { fallback: '/offline' } } in ${APP_CONFIG_FILE}`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
if (fact.routes.some((route) => route.path === fallback && NAVIGABLE.has(route.surface))) {
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
const cause = `pwa.offline.fallback is "${fallback}" and no site/ route serves it, so an offline navigation falls back to the browser error page`;
|
|
100
|
+
const name = GENERATABLE.exec(fallback)?.[1];
|
|
101
|
+
return name === undefined
|
|
102
|
+
? finding(cause, `set pwa.offline.fallback in ${APP_CONFIG_FILE} to a path a route serves`)
|
|
103
|
+
: finding(cause, `x g route ${name} --surface site`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The fact, read off a real app root. Both halves come from the framework's own answers — the
|
|
108
|
+
* config through `loadPwaArtifacts` (the one reader of that file) and the routes through
|
|
109
|
+
* `describeRoutes()` (the projection `x.manifest.json`, `/_x`, the sitemap and `sw.js` are all
|
|
110
|
+
* built from), so this check and the service worker cannot disagree about which routes exist.
|
|
111
|
+
*/
|
|
112
|
+
export async function offlineFallbackProbe(root: string): Promise<OfflineFallbackFact> {
|
|
113
|
+
const pwa = await loadPwaArtifacts(root);
|
|
114
|
+
const app = await loadApp(root);
|
|
115
|
+
return {
|
|
116
|
+
fallback: pwa?.offline.fallback ?? null,
|
|
117
|
+
routes:
|
|
118
|
+
app.findings.length > 0
|
|
119
|
+
? undefined
|
|
120
|
+
: describeRoutes().map((route) => ({ path: route.path, surface: route.surface })),
|
|
121
|
+
};
|
|
122
|
+
}
|
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/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
|
/**
|