@ultimat3/cli 19.2.0 → 19.3.2
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 +135 -9
- package/README.md +1 -1
- package/package.json +29 -29
- package/src/app-agents-md.ts +14 -3
- package/src/app-boundaries.ts +11 -2
- package/src/app-load.ts +96 -25
- package/src/budgets.ts +17 -6
- package/src/cmd-dev-fixture.ts +25 -0
- package/src/cmd-dev.ts +48 -46
- package/src/cmd-doctor.ts +61 -23
- package/src/cmd-generate.ts +25 -3
- 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-render.ts +35 -9
- 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-files.ts +24 -2
- 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/scaffold-fixture.ts +17 -0
- package/src/serve.ts +40 -5
- package/src/source-files.ts +3 -1
- package/src/sw-artifacts.ts +71 -7
- package/src/templates/action.ts +47 -16
- package/src/templates/admin-page.ts +49 -1
- package/src/templates/island.ts +4 -2
- package/src/templates/scaffold-container.ts +12 -0
- package/src/templates/scaffold-docs.ts +7 -0
- package/src/templates/scaffold-entries.ts +4 -2
- package/src/templates/scaffold-repo.ts +7 -2
- package/src/templates/slice-foundation.ts +36 -0
- package/src/test-passes.ts +79 -0
- package/src/test-shards.ts +110 -36
- package/src/verify-checks.ts +11 -8
- package/src/verify-floor.ts +59 -3
- package/src/verify-step.ts +4 -4
- package/src/verify-tests.ts +14 -2
|
@@ -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-files.ts
CHANGED
|
@@ -48,6 +48,13 @@ export interface GenerateOptions {
|
|
|
48
48
|
* package, and only then does a generated file import `t` from `@ultimat3/i18n` instead.
|
|
49
49
|
*/
|
|
50
50
|
readonly catalogModule?: string;
|
|
51
|
+
/**
|
|
52
|
+
* `action` and `mutator`: the slice's `errors.ts` as it stands on disk, absent when there is
|
|
53
|
+
* none. Supplied by `run` for `catalogModule`'s reason — whether the slice declares
|
|
54
|
+
* `<Feature>NotFoundError` is a fact about THIS app, and a template that assumed it wrote an
|
|
55
|
+
* import of a class the app never declared. Read at `sliceDir(surface, feature)/errors.ts`.
|
|
56
|
+
*/
|
|
57
|
+
readonly sliceErrors?: string;
|
|
51
58
|
}
|
|
52
59
|
|
|
53
60
|
const DEFAULT_SURFACE_DIR: Record<Surface, string> = {
|
|
@@ -55,6 +62,10 @@ const DEFAULT_SURFACE_DIR: Record<Surface, string> = {
|
|
|
55
62
|
app: 'apps/web/app',
|
|
56
63
|
};
|
|
57
64
|
|
|
65
|
+
/** Where a feature slice lives, relative to the app root — the one derivation `run` reads from. */
|
|
66
|
+
export const sliceDir = (surface: Surface, feature: string): string =>
|
|
67
|
+
`${DEFAULT_SURFACE_DIR[surface]}/${feature}`;
|
|
68
|
+
|
|
58
69
|
/**
|
|
59
70
|
* Pure: returns the files a generator would write. `x g` writes them, the generator test asserts
|
|
60
71
|
* on them, and nothing has to run a filesystem to review what a generator produces.
|
|
@@ -76,9 +87,20 @@ export function generate(options: GenerateOptions): readonly GeneratedFile[] {
|
|
|
76
87
|
}),
|
|
77
88
|
);
|
|
78
89
|
case 'action':
|
|
79
|
-
return dedupe(
|
|
90
|
+
return dedupe(
|
|
91
|
+
actionFiles(options.name, {
|
|
92
|
+
...target,
|
|
93
|
+
...(options.sliceErrors === undefined ? {} : { sliceErrors: options.sliceErrors }),
|
|
94
|
+
}),
|
|
95
|
+
);
|
|
80
96
|
case 'mutator':
|
|
81
|
-
return dedupe(
|
|
97
|
+
return dedupe(
|
|
98
|
+
actionFiles(options.name, {
|
|
99
|
+
...target,
|
|
100
|
+
mutator: true,
|
|
101
|
+
...(options.sliceErrors === undefined ? {} : { sliceErrors: options.sliceErrors }),
|
|
102
|
+
}),
|
|
103
|
+
);
|
|
82
104
|
case 'backfill':
|
|
83
105
|
return dedupe(backfillFiles(options.name, target));
|
|
84
106
|
case 'entity':
|
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
|
/**
|
package/src/gitignore.ts
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// What git ignores, as data. The one reader of a `.gitignore` in this package: `dev-watch.ts` asks
|
|
2
|
+
// it which directories `x dev` must not watch, and `fix-path.ts` asks it which citations name a
|
|
3
|
+
// file this repository never commits. A second reader would be a second answer, and the two would
|
|
4
|
+
// disagree on the first pattern neither author anticipated.
|
|
5
|
+
|
|
6
|
+
// why: Bun exposes no synchronous file read and no synchronous existence primitive —
|
|
7
|
+
// `Bun.file(p).text()` is async, and the ignore set is rebuilt inside an `fs.watch` callback where
|
|
8
|
+
// an await opens a window for the next event. Delete when Bun ships sync equivalents.
|
|
9
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
10
|
+
// why: Bun exposes no path-join, dirname or relative primitive. The same necessity `fix-path.ts`
|
|
11
|
+
// already records for `join`.
|
|
12
|
+
import { dirname, join, relative, sep } from 'node:path';
|
|
13
|
+
|
|
14
|
+
/** One line of a `.gitignore`, read for what it states rather than for what a reader assumes. */
|
|
15
|
+
export interface IgnorePattern {
|
|
16
|
+
/** The glob, without its leading `/` or trailing `/`. */
|
|
17
|
+
readonly glob: string;
|
|
18
|
+
/** Held against the whole path relative to the file's own directory, never the basename. */
|
|
19
|
+
readonly anchored: boolean;
|
|
20
|
+
/** A trailing `/`: it matches a directory and never a file of that name. */
|
|
21
|
+
readonly directoryOnly: boolean;
|
|
22
|
+
/** A leading `!`: it re-includes what an earlier pattern ignored, at the same level. */
|
|
23
|
+
readonly negated: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** One `.gitignore` file and the directory every one of its patterns is relative to. */
|
|
27
|
+
export interface IgnoreScope {
|
|
28
|
+
readonly base: string;
|
|
29
|
+
readonly patterns: readonly IgnorePattern[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A pattern is ANCHORED when it holds a `/` anywhere but at its end — git's own rule, and the one
|
|
34
|
+
* a hand-rolled reader gets wrong in both directions: `coverage/` ignores a `coverage` directory at
|
|
35
|
+
* any depth, while `/coverage/` and a pattern holding an inner slash only ever match where they
|
|
36
|
+
* are written.
|
|
37
|
+
*/
|
|
38
|
+
export function parseGitignore(text: string): readonly IgnorePattern[] {
|
|
39
|
+
const patterns: IgnorePattern[] = [];
|
|
40
|
+
for (const raw of text.split('\n')) {
|
|
41
|
+
// Trailing whitespace carries no rule unless escaped; a leading `#` is a comment, and `\#` is
|
|
42
|
+
// a filename that starts with one.
|
|
43
|
+
const line = raw.replace(/\\?\s+$/, (match) => (match.startsWith('\\') ? match : ''));
|
|
44
|
+
if (line === '' || line.startsWith('#')) continue;
|
|
45
|
+
const negated = line.startsWith('!');
|
|
46
|
+
const body = (negated ? line.slice(1) : line).replace(/^\\(?=[#!])/, '');
|
|
47
|
+
if (body === '' || body === '/') continue;
|
|
48
|
+
const directoryOnly = body.endsWith('/');
|
|
49
|
+
const trimmed = directoryOnly ? body.slice(0, -1) : body;
|
|
50
|
+
const rooted = trimmed.startsWith('/');
|
|
51
|
+
const glob = rooted ? trimmed.slice(1) : trimmed;
|
|
52
|
+
if (glob === '') continue;
|
|
53
|
+
patterns.push({ glob, anchored: rooted || glob.includes('/'), directoryOnly, negated });
|
|
54
|
+
}
|
|
55
|
+
return patterns;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The patterns one directory's own `.gitignore` states, or none where it has no such file. */
|
|
59
|
+
export function readIgnoreFile(directory: string): readonly IgnorePattern[] {
|
|
60
|
+
const file = join(directory, '.gitignore');
|
|
61
|
+
return existsSync(file) ? parseGitignore(readFileSync(file, 'utf8')) : [];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The `.gitignore` files that govern `root`: its own last, then every ancestor up to and including
|
|
66
|
+
* the directory holding `.git`. Outermost first, so a nearer file's pattern is read last and wins —
|
|
67
|
+
* `examples/dummy` carries no ignore file of its own and every rule about it lives in the
|
|
68
|
+
* repository root's, which is why reading one file made `touch tsconfig.tsbuildinfo` a full reload.
|
|
69
|
+
*/
|
|
70
|
+
export function ignoreScopes(root: string): readonly IgnoreScope[] {
|
|
71
|
+
const scopes: IgnoreScope[] = [];
|
|
72
|
+
let directory = root;
|
|
73
|
+
for (;;) {
|
|
74
|
+
const patterns = readIgnoreFile(directory);
|
|
75
|
+
if (patterns.length > 0) scopes.unshift({ base: directory, patterns });
|
|
76
|
+
if (existsSync(join(directory, '.git'))) break;
|
|
77
|
+
const parent = dirname(directory);
|
|
78
|
+
if (parent === directory) break;
|
|
79
|
+
directory = parent;
|
|
80
|
+
}
|
|
81
|
+
return scopes;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Compiled once per pattern: one `x dev` walk asks the same glob of every directory it meets. */
|
|
85
|
+
const globs = new Map<string, Bun.Glob>();
|
|
86
|
+
|
|
87
|
+
function globFor(pattern: string): Bun.Glob {
|
|
88
|
+
const known = globs.get(pattern);
|
|
89
|
+
if (known !== undefined) return known;
|
|
90
|
+
const glob = new Bun.Glob(pattern);
|
|
91
|
+
globs.set(pattern, glob);
|
|
92
|
+
return glob;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The path relative to `base`, POSIX-separated, or `undefined` when it is not under it. */
|
|
96
|
+
function relativeUnder(base: string, path: string): string | undefined {
|
|
97
|
+
const rel = relative(base, path).split(sep).join('/');
|
|
98
|
+
return rel === '' || rel.startsWith('../') || rel === '..' ? undefined : rel;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function matches(pattern: IgnorePattern, path: string, isDirectory: boolean): boolean {
|
|
102
|
+
if (pattern.directoryOnly && !isDirectory) return false;
|
|
103
|
+
const subject = pattern.anchored ? path : (path.split('/').at(-1) ?? path);
|
|
104
|
+
return globFor(pattern.glob).match(subject);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Last match wins, across every scope in order — git's rule, and the reason `!` works at all. */
|
|
108
|
+
function verdictFor(
|
|
109
|
+
scopes: readonly IgnoreScope[],
|
|
110
|
+
path: string,
|
|
111
|
+
isDirectory: boolean,
|
|
112
|
+
): boolean | undefined {
|
|
113
|
+
let verdict: boolean | undefined;
|
|
114
|
+
for (const scope of scopes) {
|
|
115
|
+
const rel = relativeUnder(scope.base, path);
|
|
116
|
+
if (rel === undefined) continue;
|
|
117
|
+
for (const pattern of scope.patterns) {
|
|
118
|
+
if (matches(pattern, rel, isDirectory)) verdict = !pattern.negated;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return verdict;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Whether git would ignore this absolute path. Every ancestor between the outermost scope and the
|
|
126
|
+
* path is judged as a DIRECTORY first: an ignored directory takes everything under it, which is how
|
|
127
|
+
* `coverage/` reaches `coverage/lcov.info` and the only reason a walk may prune at all.
|
|
128
|
+
*/
|
|
129
|
+
export function isGitIgnored(
|
|
130
|
+
scopes: readonly IgnoreScope[],
|
|
131
|
+
path: string,
|
|
132
|
+
isDirectory: boolean,
|
|
133
|
+
): boolean {
|
|
134
|
+
const outermost = scopes[0];
|
|
135
|
+
if (outermost === undefined) return false;
|
|
136
|
+
const rel = relativeUnder(outermost.base, path);
|
|
137
|
+
if (rel === undefined) return false;
|
|
138
|
+
const segments = rel.split('/');
|
|
139
|
+
for (let depth = 0; depth < segments.length; depth += 1) {
|
|
140
|
+
const prefix = join(outermost.base, ...segments.slice(0, depth + 1));
|
|
141
|
+
const last = depth === segments.length - 1;
|
|
142
|
+
if (verdictFor(scopes, prefix, last ? isDirectory : true) === true) return true;
|
|
143
|
+
}
|
|
144
|
+
return false;
|
|
145
|
+
}
|