@ultimat3/cli 7.0.0 → 9.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +24 -4
- package/README.md +8 -3
- package/package.json +26 -25
- package/src/app-boundaries.ts +55 -5
- package/src/app-load.ts +7 -0
- package/src/bin.ts +6 -3
- package/src/ci-log.ts +0 -0
- package/src/cmd-db-backfill.ts +240 -0
- package/src/cmd-db-branch.ts +3 -2
- package/src/cmd-db.ts +35 -156
- package/src/cmd-deploy.ts +43 -6
- package/src/cmd-dev.ts +7 -1
- package/src/cmd-errors.ts +2 -3
- package/src/cmd-fix.ts +3 -3
- package/src/cmd-i18n.ts +67 -5
- package/src/cmd-jobs.ts +27 -4
- package/src/cmd-mcp.ts +18 -9
- package/src/cmd-new.ts +91 -4
- package/src/cmd-policy.ts +3 -2
- package/src/cmd-pr.ts +55 -4
- package/src/cmd-registries.ts +3 -2
- package/src/cmd-shot.ts +68 -6
- package/src/cmd-tasks.ts +9 -4
- package/src/cmd-verify.ts +47 -6
- package/src/dev-assets.ts +4 -7
- package/src/dev-cache.ts +130 -33
- package/src/dev-lock.ts +124 -12
- package/src/dev-purge.ts +120 -0
- package/src/dev-queue.ts +39 -9
- package/src/dev-render.ts +11 -14
- package/src/dev-replicator.ts +3 -7
- package/src/dev-roles-fixture.ts +1 -1
- package/src/dev-roles.ts +40 -8
- package/src/dev-runtime.ts +137 -6
- package/src/dev-sync.ts +9 -4
- package/src/dispatch.ts +35 -5
- package/src/document-styles.ts +2 -1
- package/src/drift.ts +52 -7
- package/src/error-codes.ts +5 -0
- package/src/framework-scope.ts +57 -5
- package/src/generate-kinds.ts +19 -1
- package/src/i18n-registration.ts +67 -4
- package/src/index.ts +1 -1
- package/src/island-bundle.ts +2 -6
- package/src/island-styles.ts +1 -1
- package/src/jobs-report.ts +10 -13
- package/src/mcp-errors.ts +3 -0
- package/src/messages.ts +12 -0
- package/src/output.ts +22 -2
- package/src/parse.ts +81 -37
- package/src/prerender.ts +2 -1
- package/src/realtime-browser-probe-fixture.ts +9 -0
- package/src/runtime-overrides.ts +12 -4
- package/src/serve.ts +1 -1
- package/src/shot-settle.ts +57 -0
- package/src/shot-verdict.ts +27 -4
- package/src/solid-loader.ts +1 -1
- package/src/style-csp.ts +2 -1
- package/src/sync-authenticator.ts +86 -14
- package/src/templates/guard-bare-error.ts +122 -0
- package/src/templates/guard-raw-colour.ts +138 -0
- package/src/templates/guard-untranslated-string.ts +138 -0
- package/src/templates/guard-unzoned-date.ts +142 -0
- package/src/templates/index.ts +3 -0
- package/src/templates/island.ts +2 -1
- package/src/templates/route.ts +1 -1
- package/src/templates/scaffold-app.ts +3 -82
- package/src/templates/scaffold-container.ts +30 -4
- package/src/templates/scaffold-db-package.ts +14 -6
- package/src/templates/scaffold-docs.ts +34 -16
- package/src/templates/scaffold-entries.ts +131 -0
- package/src/templates/scaffold-guards.ts +26 -0
- package/src/templates/scaffold-repo.ts +40 -7
- package/src/test-select.ts +4 -3
- package/src/verify-run.ts +25 -3
- package/src/verify-step.ts +11 -2
- package/src/verify-tests.ts +11 -3
- package/src/write-line.ts +23 -5
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// The `untranslated-string` guard `x new` ships: no user-facing string is typed into a page.
|
|
2
|
+
// `AGENTS.md` has always stated the rule and NOTHING enforced it — a hardcoded JSX string sitting
|
|
3
|
+
// beside a `t()` call in a scaffolded page was green on `x verify`, and `x i18n check` cannot see
|
|
4
|
+
// it either: a literal that is in no catalog is a literal the catalog audit has no key for.
|
|
5
|
+
|
|
6
|
+
import { guardCode } from './guard';
|
|
7
|
+
import type { GeneratedFile } from './naming';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Derived from the guard's name, never written as a literal — the same rule `x g guard` follows.
|
|
11
|
+
* An `X_*` literal in framework source is a FRAMEWORK code: `error-catalog.test.ts` refuses one the
|
|
12
|
+
* registry does not hold, and `wiki/Error-Codes.md` would owe it a row. The APP owns the codes its
|
|
13
|
+
* own conventions raise, so this one is spelled by the file it lands in and nowhere else.
|
|
14
|
+
*/
|
|
15
|
+
const NAME = 'untranslated-string';
|
|
16
|
+
const CODE = guardCode(NAME);
|
|
17
|
+
|
|
18
|
+
const source =
|
|
19
|
+
(): string => `// untranslated-string: every user-facing string on a rendered surface goes through \`t()\`.
|
|
20
|
+
// \`x verify\` discovers every file in \`guards/\` and runs its \`guard\` inside the \`boundaries\`
|
|
21
|
+
// step — nothing registers this file, so nothing can forget to.
|
|
22
|
+
|
|
23
|
+
import type { Finding, Guard } from '@ultimat3/cli';
|
|
24
|
+
|
|
25
|
+
/** The app owns the codes its own conventions raise — this one is named for the guard. */
|
|
26
|
+
const CODE = '${CODE}';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* \`<tag …>text</tag>\`, matched on the CLOSING tag rather than on the next \`<\`.
|
|
30
|
+
*
|
|
31
|
+
* That is the whole reason this rule can run over TypeScript at all: \`createSignal<State>('idle')\`
|
|
32
|
+
* is a \`>\` followed by prose-shaped source, and a pattern reading to the next \`<\` reports every
|
|
33
|
+
* generic in the file. A closing tag that names the same element cannot be a type argument.
|
|
34
|
+
*
|
|
35
|
+
* Only the INNERMOST element matches — the content class excludes \`<\` and \`>\` — which is what the
|
|
36
|
+
* rule wants: a parent whose children are elements has no text of its own.
|
|
37
|
+
*/
|
|
38
|
+
const ELEMENT = /<([A-Za-z][\\w.:-]*)(?:\\s[^<>]*)?>([^<>]*?)<\\/\\1>/g;
|
|
39
|
+
/** A \`{…}\` child is an expression — \`{t('key')}\`, \`{props.row.title}\` — never typed prose. */
|
|
40
|
+
const EXPRESSION = /\\{[^{}]*\\}/g;
|
|
41
|
+
/** Two word characters in a row. One is \`&\`, \`×\`, an initial — never a sentence. */
|
|
42
|
+
const PROSE = /[\\p{L}\\p{N}]{2,}/u;
|
|
43
|
+
|
|
44
|
+
export interface SourceFile {
|
|
45
|
+
/** App-root-relative POSIX path, so the finding names the file an author opens. */
|
|
46
|
+
readonly path: string;
|
|
47
|
+
readonly source: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Comments blanked IN PLACE — not deleted — so a reported line number still points at the source. */
|
|
51
|
+
const blank = (text: string): string =>
|
|
52
|
+
text
|
|
53
|
+
.replaceAll(/\\/\\*[\\s\\S]*?\\*\\//g, (match) => match.replaceAll(/[^\\n]/g, ' '))
|
|
54
|
+
.replaceAll(/(?<![:\\w])\\/\\/[^\\n]*/g, (match) => ' '.repeat(match.length));
|
|
55
|
+
|
|
56
|
+
const lineOf = (text: string, index: number): number => text.slice(0, index).split('\\n').length;
|
|
57
|
+
|
|
58
|
+
/** Pure — the caller does the I/O — so the rule is testable without a filesystem. */
|
|
59
|
+
export function untranslatedStrings(files: readonly SourceFile[]): readonly Finding[] {
|
|
60
|
+
const findings: Finding[] = [];
|
|
61
|
+
for (const file of files) {
|
|
62
|
+
const text = blank(file.source);
|
|
63
|
+
for (const match of text.matchAll(ELEMENT)) {
|
|
64
|
+
const typed = (match[2] ?? '').replaceAll(EXPRESSION, ' ').trim();
|
|
65
|
+
if (!PROSE.test(typed)) continue;
|
|
66
|
+
findings.push({
|
|
67
|
+
code: CODE,
|
|
68
|
+
cause: \`\${file.path}:\${lineOf(text, match.index)} renders the typed string "\${typed}" inside <\${match[1] ?? 'element'}> — it is in no catalog, so every locale but the one it was typed in reads it verbatim\`,
|
|
69
|
+
fix: \`add a key for "\${typed}" to packages/i18n/catalogs/en.json, render it as {t('…')} in \${file.path}, then: x i18n check\`,
|
|
70
|
+
at: file.path,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return findings;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const guard: Guard = {
|
|
78
|
+
summary: 'a rendered string comes from t(), never typed into the page',
|
|
79
|
+
async check(root) {
|
|
80
|
+
const files: SourceFile[] = [];
|
|
81
|
+
// Every rendered surface: an app's \`site/\` and \`app/\`, and the shared components under
|
|
82
|
+
// \`packages/*/src\` — \`x new\` scaffolds a \`packages/ui\` whose components render to a user, so
|
|
83
|
+
// a hardcoded string there used to be green. \`api/\` renders nothing and \`shared/\` is a leaf of
|
|
84
|
+
// helpers; \`packages/*/dist\` is a build output, not source.
|
|
85
|
+
//
|
|
86
|
+
// TWO globs, never one with a leading \`{a,b}\` group: \`Bun.Glob.scan()\` matches nothing at all
|
|
87
|
+
// for a pattern that starts with a brace group — measured — so folding these into one line
|
|
88
|
+
// silently turns the guard off, which is worse than the hole it closes.
|
|
89
|
+
for (const pattern of ['apps/*/{site,app}/**/*.tsx', 'packages/*/src/**/*.tsx']) {
|
|
90
|
+
for await (const entry of new Bun.Glob(pattern).scan({ cwd: root, absolute: false })) {
|
|
91
|
+
const path = entry.split('\\\\').join('/');
|
|
92
|
+
if (path.includes('node_modules/') || /\\.test\\.tsx?$/.test(path)) continue;
|
|
93
|
+
files.push({ path, source: await Bun.file(\`\${root}/\${path}\`).text() });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return untranslatedStrings(files);
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
`;
|
|
100
|
+
|
|
101
|
+
const test =
|
|
102
|
+
(): string => `// The rule, driven directly. Failure case first: a guard whose rule silently stopped matching is
|
|
103
|
+
// a green gate over the convention it was written to enforce.
|
|
104
|
+
|
|
105
|
+
import { expect, unitTest } from '@ultimat3/testing';
|
|
106
|
+
import { untranslatedStrings } from './untranslated-string';
|
|
107
|
+
|
|
108
|
+
const file = (source: string) => [{ path: 'apps/web/site/page.tsx', source }];
|
|
109
|
+
|
|
110
|
+
unitTest('a typed JSX string is refused, and the finding quotes it', () => {
|
|
111
|
+
const findings = untranslatedStrings(file('<h1>Welcome back</h1>'));
|
|
112
|
+
expect(findings).toHaveLength(1);
|
|
113
|
+
expect(findings[0]?.code).toBe('${CODE}');
|
|
114
|
+
expect(findings[0]?.cause).toContain('Welcome back');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
unitTest('a t() child satisfies it, and so does any other expression', () => {
|
|
118
|
+
expect(untranslatedStrings(file("<h1>{t('site.home.title')}</h1>"))).toEqual([]);
|
|
119
|
+
expect(untranslatedStrings(file('<li class={styles.item}>{row.title}</li>'))).toEqual([]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// The reason the rule reads a CLOSING tag: a generic type argument is a > followed by source that
|
|
123
|
+
// looks exactly like prose, and a pattern reading to the next < reports every one of them.
|
|
124
|
+
unitTest('a generic type argument is not a JSX text node', () => {
|
|
125
|
+
const generic = "const [state, setState] = createSignal<SaveState>('idle');\\nconst n = 1;";
|
|
126
|
+
expect(untranslatedStrings(file(generic))).toEqual([]);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
unitTest('one word character is a symbol, not a sentence', () => {
|
|
130
|
+
expect(untranslatedStrings(file('<span>&</span>'))).toEqual([]);
|
|
131
|
+
});
|
|
132
|
+
`;
|
|
133
|
+
|
|
134
|
+
/** `guards/untranslated-string.ts` and its test. The directory is the registration. */
|
|
135
|
+
export const untranslatedStringGuardFiles = (): readonly GeneratedFile[] => [
|
|
136
|
+
{ path: 'guards/untranslated-string.ts', contents: source() },
|
|
137
|
+
{ path: 'guards/untranslated-string.test.ts', contents: test() },
|
|
138
|
+
];
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// The `unzoned-date` guard `x new` ships: no date is formatted without an explicit IANA zone.
|
|
2
|
+
// `AGENTS.md` has always stated the rule and NOTHING enforced it — `toLocaleDateString('en-US')`
|
|
3
|
+
// in a scaffolded page was green on `x verify`, and it renders the SERVER's zone, so the same row
|
|
4
|
+
// reads as two different days depending on which container answered.
|
|
5
|
+
|
|
6
|
+
import { guardCode } from './guard';
|
|
7
|
+
import type { GeneratedFile } from './naming';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Derived from the guard's name, never written as a literal — the same rule `x g guard` follows.
|
|
11
|
+
* An `X_*` literal in framework source is a FRAMEWORK code: `error-catalog.test.ts` refuses one the
|
|
12
|
+
* registry does not hold, and `wiki/Error-Codes.md` would owe it a row. The APP owns the codes its
|
|
13
|
+
* own conventions raise, so this one is spelled by the file it lands in and nowhere else.
|
|
14
|
+
*/
|
|
15
|
+
const NAME = 'unzoned-date';
|
|
16
|
+
const CODE = guardCode(NAME);
|
|
17
|
+
|
|
18
|
+
const source =
|
|
19
|
+
(): string => `// unzoned-date: a date is stored in UTC and formatted with an explicit IANA zone, never with the
|
|
20
|
+
// host's ambient one. \`x verify\` discovers every file in \`guards/\` and runs its \`guard\` inside
|
|
21
|
+
// the \`boundaries\` step — nothing registers this file, so nothing can forget to.
|
|
22
|
+
|
|
23
|
+
import type { Finding, Guard } from '@ultimat3/cli';
|
|
24
|
+
|
|
25
|
+
/** The app owns the codes its own conventions raise — this one is named for the guard. */
|
|
26
|
+
const CODE = '${CODE}';
|
|
27
|
+
|
|
28
|
+
/** Every call whose output depends on a zone. The \`(\` is the start of the argument list. */
|
|
29
|
+
const FORMATTER = /(?:\\.toLocale(?:Date|Time)?String|Intl\\.DateTimeFormat)\\s*\\(/g;
|
|
30
|
+
|
|
31
|
+
export interface SourceFile {
|
|
32
|
+
/** App-root-relative POSIX path, so the finding names the file an author opens. */
|
|
33
|
+
readonly path: string;
|
|
34
|
+
readonly source: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Comments blanked IN PLACE — not deleted — so the line number a finding reports still points at
|
|
39
|
+
* the source line. What it does not blank is a string body: a call spelled inside a quoted string
|
|
40
|
+
* is reported, which is the one false positive this rule can produce and the reason its own test
|
|
41
|
+
* lives in \`guards/\`, which nothing here scans.
|
|
42
|
+
*/
|
|
43
|
+
const blank = (text: string): string =>
|
|
44
|
+
text
|
|
45
|
+
.replaceAll(/\\/\\*[\\s\\S]*?\\*\\//g, (match) => match.replaceAll(/[^\\n]/g, ' '))
|
|
46
|
+
.replaceAll(/(?<![:\\w])\\/\\/[^\\n]*/g, (match) => ' '.repeat(match.length));
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The call's own argument list, from its \`(\` to the \`)\` that closes it. Depth-counted rather
|
|
50
|
+
* than "up to the next \`)\`": \`toLocaleDateString(locale, { timeZone: zoneFor(x) })\` closes twice
|
|
51
|
+
* before the one that ends the call, and reading only the first would report a zoned call.
|
|
52
|
+
*/
|
|
53
|
+
function argumentsOf(text: string, open: number): string {
|
|
54
|
+
let depth = 0;
|
|
55
|
+
for (let index = open; index < text.length; index += 1) {
|
|
56
|
+
const char = text[index];
|
|
57
|
+
if (char === '(') depth += 1;
|
|
58
|
+
else if (char === ')') {
|
|
59
|
+
depth -= 1;
|
|
60
|
+
if (depth === 0) return text.slice(open + 1, index);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return text.slice(open + 1);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const lineOf = (text: string, index: number): number => text.slice(0, index).split('\\n').length;
|
|
67
|
+
|
|
68
|
+
/** Pure — the caller does the I/O — so the rule is testable without a filesystem. */
|
|
69
|
+
export function unzonedDates(files: readonly SourceFile[]): readonly Finding[] {
|
|
70
|
+
const findings: Finding[] = [];
|
|
71
|
+
for (const file of files) {
|
|
72
|
+
const text = blank(file.source);
|
|
73
|
+
for (const match of text.matchAll(FORMATTER)) {
|
|
74
|
+
const open = match.index + match[0].length - 1;
|
|
75
|
+
if (argumentsOf(text, open).includes('timeZone')) continue;
|
|
76
|
+
const line = lineOf(text, match.index);
|
|
77
|
+
findings.push({
|
|
78
|
+
code: CODE,
|
|
79
|
+
cause: \`\${file.path}:\${line} calls \${match[0].trim()}) with no timeZone — it formats in whatever zone the process happens to run in, so one row reads as two different days across two containers\`,
|
|
80
|
+
fix: \`pass an explicit IANA zone in \${file.path} — toLocaleDateString(locale, { timeZone: 'UTC' }) — then: x verify\`,
|
|
81
|
+
at: file.path,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return findings;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export const guard: Guard = {
|
|
89
|
+
summary: 'a date is never formatted without an explicit IANA time zone',
|
|
90
|
+
async check(root) {
|
|
91
|
+
const files: SourceFile[] = [];
|
|
92
|
+
for await (const entry of new Bun.Glob('{apps,packages}/**/*.{ts,tsx}').scan({
|
|
93
|
+
cwd: root,
|
|
94
|
+
absolute: false,
|
|
95
|
+
})) {
|
|
96
|
+
const path = entry.split('\\\\').join('/');
|
|
97
|
+
// A test's subject is often the wrong shape on purpose, and \`node_modules\` is not this
|
|
98
|
+
// app's source. Neither exclusion hides a rendered date from a user.
|
|
99
|
+
if (path.includes('node_modules/') || /\\.(?:test|d)\\.tsx?$/.test(path)) continue;
|
|
100
|
+
files.push({ path, source: await Bun.file(\`\${root}/\${path}\`).text() });
|
|
101
|
+
}
|
|
102
|
+
return unzonedDates(files);
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
`;
|
|
106
|
+
|
|
107
|
+
const test =
|
|
108
|
+
(): string => `// The rule, driven directly. Failure case first: a guard whose rule silently stopped matching is
|
|
109
|
+
// a green gate over the convention it was written to enforce.
|
|
110
|
+
|
|
111
|
+
import { expect, unitTest } from '@ultimat3/testing';
|
|
112
|
+
import { unzonedDates } from './unzoned-date';
|
|
113
|
+
|
|
114
|
+
const file = (source: string) => [{ path: 'apps/web/app/dashboard/page.tsx', source }];
|
|
115
|
+
|
|
116
|
+
unitTest('toLocaleDateString with no timeZone is refused', () => {
|
|
117
|
+
const findings = unzonedDates(file("const shown = at.toLocaleDateString('en-US');"));
|
|
118
|
+
expect(findings).toHaveLength(1);
|
|
119
|
+
expect(findings[0]?.code).toBe('${CODE}');
|
|
120
|
+
expect(findings[0]?.fix).toContain('timeZone');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
unitTest('an explicit zone satisfies it, even nested behind another call', () => {
|
|
124
|
+
const zoned = "at.toLocaleDateString('en-US', { timeZone: zoneFor(actor) });";
|
|
125
|
+
expect(unzonedDates(file(zoned))).toEqual([]);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
unitTest('Intl.DateTimeFormat and toLocaleTimeString are the same rule', () => {
|
|
129
|
+
expect(unzonedDates(file("new Intl.DateTimeFormat('en-US').format(at);"))).toHaveLength(1);
|
|
130
|
+
expect(unzonedDates(file("at.toLocaleTimeString('en-US');"))).toHaveLength(1);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
unitTest('a commented-out call is a note, not a call', () => {
|
|
134
|
+
expect(unzonedDates(file("// at.toLocaleDateString('en-US');"))).toEqual([]);
|
|
135
|
+
});
|
|
136
|
+
`;
|
|
137
|
+
|
|
138
|
+
/** `guards/unzoned-date.ts` and its test. No index, no registry — the directory registers it. */
|
|
139
|
+
export const unzonedDateGuardFiles = (): readonly GeneratedFile[] => [
|
|
140
|
+
{ path: 'guards/unzoned-date.ts', contents: source() },
|
|
141
|
+
{ path: 'guards/unzoned-date.test.ts', contents: test() },
|
|
142
|
+
];
|
package/src/templates/index.ts
CHANGED
|
@@ -39,6 +39,9 @@ export { claudeAgentFiles } from './scaffold-claude-agents';
|
|
|
39
39
|
export { claudeCommandFiles } from './scaffold-claude-commands';
|
|
40
40
|
export { containerFiles } from './scaffold-container';
|
|
41
41
|
export { docsFiles, EXECUTABLE_FILES } from './scaffold-docs';
|
|
42
|
+
export { entryFiles } from './scaffold-entries';
|
|
43
|
+
// The four guards `x new` ships, distinct from `guardFiles` above, which is `x g guard <name>`.
|
|
44
|
+
export { scaffoldGuardFiles } from './scaffold-guards';
|
|
42
45
|
export { i18nIndex } from './scaffold-i18n';
|
|
43
46
|
export { repoFiles } from './scaffold-repo';
|
|
44
47
|
export type { SliceModule } from './slice-foundation';
|
package/src/templates/island.ts
CHANGED
|
@@ -78,7 +78,8 @@ export function mount(el: HTMLElement, props: ${Name}Props): void {
|
|
|
78
78
|
`;
|
|
79
79
|
};
|
|
80
80
|
|
|
81
|
-
const islandStyle =
|
|
81
|
+
const islandStyle =
|
|
82
|
+
(): string => `// Semantic tokens only — a raw hex here is refused by the boundaries step, not by lint — a dark-theme bug and
|
|
82
83
|
// a lint failure. Scoped by the island build, with the class names the server hashed.
|
|
83
84
|
@use '@ultimat3/ui/tokens' as tokens;
|
|
84
85
|
|
package/src/templates/route.ts
CHANGED
|
@@ -138,7 +138,7 @@ export function ${name}Page() {${translatorBinding(module)}
|
|
|
138
138
|
};
|
|
139
139
|
|
|
140
140
|
const styleSource =
|
|
141
|
-
(): string => `// Semantic tokens only — a raw hex here is a dark-theme bug
|
|
141
|
+
(): string => `// Semantic tokens only — a raw hex here is refused by the boundaries step (guards/raw-colour.ts), not by lint — a dark-theme bug in every scheme but the one it was written in.
|
|
142
142
|
@use '@ultimat3/ui/tokens' as tokens;
|
|
143
143
|
|
|
144
144
|
.page {
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import type { GeneratedFile, NameSet } from './naming';
|
|
6
6
|
import { apiFiles } from './scaffold-api';
|
|
7
|
+
import { entryFiles } from './scaffold-entries';
|
|
7
8
|
import { icon } from './scaffold-icon';
|
|
8
9
|
import { rolesFiles } from './scaffold-roles';
|
|
9
10
|
|
|
@@ -349,86 +350,6 @@ export function AdminHome() {
|
|
|
349
350
|
}
|
|
350
351
|
`;
|
|
351
352
|
|
|
352
|
-
// The two entry files a deploy needs. Both are deliberately thin: which role a container is, which
|
|
353
|
-
// port it binds, how it drains and what a static build enumerates are the framework's answers, so
|
|
354
|
-
// an upgrade moves them without a codemod in every app that ever shipped.
|
|
355
|
-
|
|
356
|
-
const server =
|
|
357
|
-
(): string => `// The production entry. \`docker/Dockerfile\` starts this, and \`x build --target binary\` compiles it.
|
|
358
|
-
// ROLE selects what this process is — web, sync, worker, scheduler, replicator, or migrate, which
|
|
359
|
-
// applies the migrations and exits. PORT is bound on every interface, because a container bound to
|
|
360
|
-
// localhost is unreachable through its own port mapping.
|
|
361
|
-
|
|
362
|
-
import { join } from 'node:path';
|
|
363
|
-
import { runRole } from '@ultimat3/cli';
|
|
364
|
-
|
|
365
|
-
// MORE THAN ONE REPLICA? Add these two lines, above \`runRole\`:
|
|
366
|
-
//
|
|
367
|
-
// import { configureIdempotency } from '@ultimat3/action';
|
|
368
|
-
// configureIdempotency({ scope: 'shared' });
|
|
369
|
-
//
|
|
370
|
-
// \`idempotent: true\` on an action promises that a retry does not repeat the work. Under the
|
|
371
|
-
// process-scoped default that promise holds inside ONE process — a client retrying
|
|
372
|
-
// \`POST /api/payments/charge\` after a timeout lands on another replica, which has never seen the
|
|
373
|
-
// key, and charges the card twice, silently, with \`x verify\` green. Declaring \`'shared'\` is what
|
|
374
|
-
// makes that a boot error (\`X_IDEMPOTENCY_NOT_SHARED\`) unless a shared store is installed.
|
|
375
|
-
// \`runRole\` installs the Postgres one for you, on the connection it resolved from \`DATABASE_URL\`,
|
|
376
|
-
// so the declaration is all this app owes. It must run before \`runRole\` imports the actions.
|
|
377
|
-
|
|
378
|
-
/**
|
|
379
|
-
* Where the app is. From this file normally — the image's WORKDIR is not the app root's business.
|
|
380
|
-
* A \`--compile\` binary is the exception: its \`import.meta.dir\` is Bun's virtual filesystem, which
|
|
381
|
-
* holds this module's bundled imports and none of the app's source, and the framework's registries
|
|
382
|
-
* are filled by scanning that source at boot. So a binary reads its root from the directory it is
|
|
383
|
-
* started in — it is a launcher for an app tree, not a self-contained copy of one.
|
|
384
|
-
*/
|
|
385
|
-
const root = import.meta.dir.startsWith('/$bunfs')
|
|
386
|
-
? process.cwd()
|
|
387
|
-
: join(import.meta.dir, '..', '..');
|
|
388
|
-
|
|
389
|
-
// Guarded, because the framework's module scan imports every file under apps/*/ to fill its
|
|
390
|
-
// registries — an unguarded boot would start a server inside \`x verify\`.
|
|
391
|
-
if (import.meta.main) {
|
|
392
|
-
await runRole({ root, env: Bun.env });
|
|
393
|
-
}
|
|
394
|
-
`;
|
|
395
|
-
|
|
396
|
-
const prerender =
|
|
397
|
-
(): string => `// The static entry. \`x build --target static\` runs this with \`--out <dir>\` and it writes one HTML
|
|
398
|
-
// file per \`render: 'static'\` route — a CDN or an object store then serves site/ with no process
|
|
399
|
-
// behind it. Every other render mode needs a running app and is reported as skipped, never emitted.
|
|
400
|
-
//
|
|
401
|
-
// Skipped is not unweighed: a route that declares a \`budget:\` is rendered in memory and measured
|
|
402
|
-
// whatever its mode, so \`x verify\`'s \`budgets\` step has a number for it. \`unmeasured\` is the list
|
|
403
|
-
// this build could not render — each one is an X_BUDGET_UNMEASURED at the gate, and this is where
|
|
404
|
-
// the reason is.
|
|
405
|
-
//
|
|
406
|
-
// It writes the whole of \`pages\` and \`skipped\`, never a COUNT of either. A count is what let a
|
|
407
|
-
// partial artifact read as a complete one: someone pointed a screenshot tool at \`.x/static\` and
|
|
408
|
-
// filed "the island did not mount" against a route that had never been emitted (issue #242). Each
|
|
409
|
-
// skipped route carries its own \`reason\` and \`why\`, and \`report\` is where the same inventory
|
|
410
|
-
// landed on disk — which is what \`x build --target static --json\` reads back.
|
|
411
|
-
|
|
412
|
-
import { join } from 'node:path';
|
|
413
|
-
import { prerenderSite } from '@ultimat3/cli';
|
|
414
|
-
|
|
415
|
-
const root = join(import.meta.dir, '..', '..');
|
|
416
|
-
const flag = Bun.argv.indexOf('--out');
|
|
417
|
-
const out = (flag === -1 ? undefined : Bun.argv[flag + 1]) ?? join(root, '.x', 'static');
|
|
418
|
-
// SITE_ORIGIN is what canonical and og:url are built from; the default is only ever a local build.
|
|
419
|
-
// Property access, not \`Bun.env['SITE_ORIGIN']\`: the scaffolded tsconfig does not set
|
|
420
|
-
// \`noPropertyAccessFromIndexSignature\`, so the bracket form is the one biome's useLiteralKeys
|
|
421
|
-
// reports — a diagnostic in an app's first lint run over a file the app never wrote.
|
|
422
|
-
const origin = Bun.env.SITE_ORIGIN;
|
|
423
|
-
|
|
424
|
-
if (import.meta.main) {
|
|
425
|
-
const report = await prerenderSite({ root, out, ...(origin === undefined ? {} : { origin }) });
|
|
426
|
-
await Bun.stdout.write(
|
|
427
|
-
\`\${JSON.stringify({ ok: true, out: report.out, emitted: report.pages, skipped: report.skipped, unmeasured: report.unmeasured, report: report.report })}\\n\`,
|
|
428
|
-
);
|
|
429
|
-
}
|
|
430
|
-
`;
|
|
431
|
-
|
|
432
353
|
const placeholder = (surface: string, app: NameSet): string => `# ${surface}
|
|
433
354
|
|
|
434
355
|
Placeholder. The monorepo shape exists now so adding ${surface} later is a new directory, not a
|
|
@@ -447,8 +368,8 @@ export function appFiles(app: NameSet, example: boolean): readonly GeneratedFile
|
|
|
447
368
|
return [
|
|
448
369
|
{ path: 'apps/web/package.json', contents: webPackage(app) },
|
|
449
370
|
{ path: 'apps/web/tsconfig.json', contents: tsconfig() },
|
|
450
|
-
|
|
451
|
-
|
|
371
|
+
// The process a container starts and the artifact a CDN is handed — `scaffold-entries.ts`.
|
|
372
|
+
...entryFiles(),
|
|
452
373
|
{ path: 'apps/web/site/icon.png', contents: icon() },
|
|
453
374
|
{ path: 'apps/web/site/page.tsx', contents: sitePage(app) },
|
|
454
375
|
{ path: 'apps/web/site/page.module.scss', contents: siteStyle() },
|
|
@@ -52,8 +52,10 @@ ENV NODE_ENV=production \\
|
|
|
52
52
|
# own port mapping, its load balancer and every health probe alike.
|
|
53
53
|
EXPOSE 3000
|
|
54
54
|
|
|
55
|
-
#
|
|
56
|
-
# closes, so a rolling restart drains in-flight work instead of dropping it.
|
|
55
|
+
# The probe for the roles that SERVE HTTP — \`web\` and \`sync\`. /readyz flips to 503 on SIGTERM
|
|
56
|
+
# *before* the socket closes, so a rolling restart drains in-flight work instead of dropping it.
|
|
57
|
+
# Every other role opens the scrape listener alone and never binds $PORT, so each one overrides
|
|
58
|
+
# this in docker/docker-compose.prod.yml rather than reporting \`unhealthy\` for its whole life.
|
|
57
59
|
HEALTHCHECK --interval=10s --timeout=3s --start-period=30s --retries=3 CMD \\
|
|
58
60
|
bun --eval "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/readyz').then(r=>process.exit(r.ok?0:1),()=>process.exit(1))"
|
|
59
61
|
|
|
@@ -120,6 +122,22 @@ x-image: &image
|
|
|
120
122
|
depends_on:
|
|
121
123
|
db: { condition: service_healthy }
|
|
122
124
|
|
|
125
|
+
# The image's own HEALTHCHECK fetches \`/readyz\` on $PORT, and only \`web\` and \`sync\` open an HTTP
|
|
126
|
+
# socket — every other role gets the scrape listener and nothing else. A service that inherits that
|
|
127
|
+
# probe is fetching a port it never binds: it reports \`unhealthy\` for its whole life and anything
|
|
128
|
+
# gated on it never starts. Probes follow the role here, exactly as they do in \`docker/helm\`.
|
|
129
|
+
x-metrics-probe: &metrics-probe
|
|
130
|
+
test: ['CMD', 'bun', '--eval', "fetch('http://127.0.0.1:'+(process.env.METRICS_PORT||9090)+'/metrics').then(r=>process.exit(r.ok?0:1),()=>process.exit(1))"]
|
|
131
|
+
interval: 10s
|
|
132
|
+
timeout: 3s
|
|
133
|
+
start_period: 30s
|
|
134
|
+
retries: 3
|
|
135
|
+
|
|
136
|
+
# Run-once services exit. A probe against an exited container reports \`unhealthy\` forever, and
|
|
137
|
+
# nothing waits on their health — \`service_completed_successfully\` is what the others gate on.
|
|
138
|
+
x-run-once-probe: &run-once-probe
|
|
139
|
+
disable: true
|
|
140
|
+
|
|
123
141
|
services:
|
|
124
142
|
db:
|
|
125
143
|
image: postgres:17-alpine
|
|
@@ -138,6 +156,7 @@ services:
|
|
|
138
156
|
<<: *image
|
|
139
157
|
environment: [ROLE=migrate]
|
|
140
158
|
restart: 'no'
|
|
159
|
+
healthcheck: *run-once-probe
|
|
141
160
|
|
|
142
161
|
# Run-once, AFTER the new version serves. Deliberately NOT part of the release gate: a slow
|
|
143
162
|
# UPDATE there holds the deploy open against a database still serving the previous version.
|
|
@@ -158,6 +177,7 @@ services:
|
|
|
158
177
|
# listing this last would only look like "after". The image's HEALTHCHECK is what makes it true.
|
|
159
178
|
web: { condition: service_healthy }
|
|
160
179
|
restart: 'no'
|
|
180
|
+
healthcheck: *run-once-probe
|
|
161
181
|
|
|
162
182
|
web:
|
|
163
183
|
<<: *image
|
|
@@ -184,6 +204,7 @@ services:
|
|
|
184
204
|
depends_on:
|
|
185
205
|
db: { condition: service_healthy }
|
|
186
206
|
migrate: { condition: service_completed_successfully }
|
|
207
|
+
healthcheck: *metrics-probe
|
|
187
208
|
deploy: { replicas: 1 } # scales on queue depth
|
|
188
209
|
|
|
189
210
|
scheduler:
|
|
@@ -192,7 +213,12 @@ services:
|
|
|
192
213
|
depends_on:
|
|
193
214
|
db: { condition: service_healthy }
|
|
194
215
|
migrate: { condition: service_completed_successfully }
|
|
195
|
-
|
|
216
|
+
# Fixed 1. Leadership is an EXPIRING LEASE ROW in \`x_scheduler_leader\` (dev-roles.ts,
|
|
217
|
+
# driver-pg-ddl.ts), NOT an advisory lock: that grant belongs to the session, not to the
|
|
218
|
+
# process — it outlives every transaction and no pooled node can renew it or prove it still
|
|
219
|
+
# holds one. A second instance is harmless but idle.
|
|
220
|
+
healthcheck: *metrics-probe
|
|
221
|
+
deploy: { replicas: 1 }
|
|
196
222
|
|
|
197
223
|
volumes:
|
|
198
224
|
pgdata:
|
|
@@ -208,7 +234,7 @@ nothing to rebuild between staging and production.
|
|
|
208
234
|
| \`web\` | HTTP: pages, actions, assets | \`$PORT\` (default 3000) |
|
|
209
235
|
| \`sync\` | websockets for live queries | \`$PORT + 1\` |
|
|
210
236
|
| \`worker\` | the job queue | — |
|
|
211
|
-
| \`scheduler\` | cron tasks; leadership is
|
|
237
|
+
| \`scheduler\` | cron tasks; leadership is an expiring lease row in \`x_scheduler_leader\` | — |
|
|
212
238
|
| \`replicator\` | the logical replication slot, exactly one per database | — |
|
|
213
239
|
| \`migrate\` | applies pending migrations and **exits** | — |
|
|
214
240
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
// The generated app's `packages/db`: the entity re-export list the
|
|
2
|
-
// the deterministic seed. No business logic — that is the package's own stated boundary, and it
|
|
3
|
-
// why `example` reaches only the two files describing the slice's table.
|
|
1
|
+
// The generated app's `packages/db`: the entity re-export list the app's own modules import from
|
|
2
|
+
// and the deterministic seed. No business logic — that is the package's own stated boundary, and it
|
|
3
|
+
// is why `example` reaches only the two files describing the slice's table.
|
|
4
4
|
//
|
|
5
5
|
// No migration. `x db gen` is the ONE writer of `packages/db/migrations`, and a scaffold that hand-
|
|
6
6
|
// wrote `0000_initial.sql` was a second one: it declared a `posts` table the generator had never
|
|
@@ -56,8 +56,16 @@ export * as schema from './schema';
|
|
|
56
56
|
// never written, so each one ships its empty counterpart instead of a reference to a file that is
|
|
57
57
|
// not there — `export { post } from …` alone made `x new --no-example` an app that cannot compile.
|
|
58
58
|
|
|
59
|
-
|
|
60
|
-
//
|
|
59
|
+
// Not "what the migration generator reads" — that claim shipped into every generated app and was
|
|
60
|
+
// false. `x db gen` and `x verify`'s `drift` step both project the ENTITY REGISTRY that `loadApp`
|
|
61
|
+
// fills (`describeEntities()`, `packages/cli/src/app-entities.ts`), so an entity declared anywhere
|
|
62
|
+
// `loadApp` reaches is already in the migration whether or not this file names it. Re-exporting an
|
|
63
|
+
// entity here does exactly one thing, and it is worth doing: it gives `seed.ts` and every other
|
|
64
|
+
// consumer ONE import to reach the app's tables through.
|
|
65
|
+
const SCHEMA_HEADER = `// Every entity the app declares, re-exported here so the seed and anything else that needs a table
|
|
66
|
+
// reach them through one import. It is not what the migration generator reads: \`x db gen\` and the
|
|
67
|
+
// \`drift\` step project the entity registry, so an entity is in the migration because it was
|
|
68
|
+
// declared, never because it was listed here.`;
|
|
61
69
|
|
|
62
70
|
const dbSchema = (app: NameSet, example: boolean): string =>
|
|
63
71
|
example
|
|
@@ -65,7 +73,7 @@ const dbSchema = (app: NameSet, example: boolean): string =>
|
|
|
65
73
|
export { post } from '@${app.kebab}/web/app/post/entity';
|
|
66
74
|
`
|
|
67
75
|
: `${SCHEMA_HEADER}
|
|
68
|
-
// \`x g entity <name>\` writes the entity; add its export here
|
|
76
|
+
// \`x g entity <name>\` writes the entity; add its export here to reach it through \`@${app.kebab}/db\`.
|
|
69
77
|
export {};
|
|
70
78
|
`;
|
|
71
79
|
|
|
@@ -13,19 +13,30 @@ const agents = (app: NameSet): string => `# AGENTS.md
|
|
|
13
13
|
Human-authored, short, stable. Facts live in \`x.manifest.json\`; this file holds only what an
|
|
14
14
|
agent cannot infer from the code.
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
|
20
|
-
|
|
21
|
-
|
|
|
22
|
-
|
|
|
23
|
-
|
|
|
24
|
-
|
|
|
25
|
-
|
|
|
26
|
-
|
|
|
27
|
-
|
|
28
|
-
|
|
16
|
+
Every row below names what refuses it. A rule with nothing in the last column is a rule that does
|
|
17
|
+
not exist — five of these had an empty column and were each measured green on \`x verify\`.
|
|
18
|
+
|
|
19
|
+
| Rule | Detail | Refused by |
|
|
20
|
+
|---|---|---|
|
|
21
|
+
| One gate | \`x verify\` — green means shippable. Never merge red. | the gate itself |
|
|
22
|
+
| One way | generators, not hand-rolled files: \`x g resource\`, \`x g action\`, \`x g route\` | review |
|
|
23
|
+
| Surfaces | \`site/\` is 0kb JS and may not import \`app/\`; \`shared/\` is a leaf | \`X_BOUNDARY_SITE_TO_APP\` |
|
|
24
|
+
| Data | routes call actions and queries; only \`repo.ts\` touches the database | \`X_BOUNDARY_ROUTE_TO_DB\` |
|
|
25
|
+
| Errors | never \`throw new Error\` — subclass \`UltimateError\` with a code, a cause and a fix | \`guards/bare-error.ts\` |
|
|
26
|
+
| Money | \`{ minor, currency }\`, never a float — \`money()\` on the column | the type: \`price: 19.99\` is TS2322, \`number\` is not \`MoneyInput\` |
|
|
27
|
+
| Time | store UTC, format with an explicit IANA time zone | \`guards/unzoned-date.ts\` |
|
|
28
|
+
| Strings | every user-facing string goes through \`t()\` | \`guards/untranslated-string.ts\` |
|
|
29
|
+
| Colour | semantic tokens only, never a raw hex | \`guards/raw-colour.ts\` |
|
|
30
|
+
|
|
31
|
+
\`guards/\` is yours: each file is one rule, discovered by \`x verify\` and run inside its
|
|
32
|
+
\`boundaries\` step. Delete one to drop the rule, and \`x g guard <name>\` writes the next.
|
|
33
|
+
|
|
34
|
+
Money is the one row with no guard, deliberately: a float has no static signature a text rule can
|
|
35
|
+
see, and the type already fires — measured, \`price: 19.99\` in a seed is
|
|
36
|
+
\`TS2322: Type 'number' is not assignable to type 'MoneyInput'\`. A guard that pretended to check
|
|
37
|
+
it would be worse than the type that really does.
|
|
38
|
+
|
|
39
|
+
Commands: \`x dev\`, \`x verify\`, \`x g <primitive>\`, \`x g guard <name>\`, \`x db branch create <name>\`, \`x doctor\`.
|
|
29
40
|
|
|
30
41
|
Project notes for ${app.kebab}: replace this line with the conventions a newcomer could not guess.
|
|
31
42
|
`;
|
|
@@ -126,27 +137,34 @@ exec bunx x verify "$@"
|
|
|
126
137
|
const composeDev = (
|
|
127
138
|
app: NameSet,
|
|
128
139
|
): string => `# Optional: x dev needs none of this. Use it when you want the real Postgres/NATS/MinIO locally.
|
|
140
|
+
#
|
|
141
|
+
# Every published port binds 127.0.0.1, not 0.0.0.0. This stack ships its credentials in the file,
|
|
142
|
+
# as a dev stack reasonably does — so the short form \`'5432:5432'\` would put an authenticated
|
|
143
|
+
# database and an open object store on every interface this machine has, including the café wifi.
|
|
144
|
+
# Docker publishes a port by writing DNAT rules, so a host firewall does not stop it. To reach this
|
|
145
|
+
# stack from another machine, put a tunnel in front of it (\`ssh -L\`) rather than widening the bind;
|
|
146
|
+
# production topology is docker-compose.prod.yml, and it is a different file for a reason.
|
|
129
147
|
services:
|
|
130
148
|
db:
|
|
131
149
|
image: postgres:17-alpine
|
|
132
150
|
environment:
|
|
133
151
|
POSTGRES_PASSWORD: ${app.kebab}
|
|
134
152
|
POSTGRES_DB: ${app.kebab}
|
|
135
|
-
ports: ['5432:5432']
|
|
153
|
+
ports: ['127.0.0.1:5432:5432']
|
|
136
154
|
healthcheck:
|
|
137
155
|
test: ['CMD-SHELL', 'pg_isready -U postgres']
|
|
138
156
|
interval: 5s
|
|
139
157
|
nats:
|
|
140
158
|
image: nats:2-alpine
|
|
141
159
|
command: ['-js']
|
|
142
|
-
ports: ['4222:4222']
|
|
160
|
+
ports: ['127.0.0.1:4222:4222']
|
|
143
161
|
s3:
|
|
144
162
|
image: minio/minio
|
|
145
163
|
command: ['server', '/data']
|
|
146
164
|
environment:
|
|
147
165
|
MINIO_ROOT_USER: ${app.kebab}
|
|
148
166
|
MINIO_ROOT_PASSWORD: ${app.kebab}-dev
|
|
149
|
-
ports: ['9000:9000']
|
|
167
|
+
ports: ['127.0.0.1:9000:9000']
|
|
150
168
|
`;
|
|
151
169
|
|
|
152
170
|
/** Docs, shims and container files for a new app, in the order a reader meets them. */
|