@ultimat3/cli 7.0.0 → 8.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 +15 -1
- package/README.md +8 -3
- package/package.json +25 -25
- package/src/app-boundaries.ts +55 -5
- 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 +37 -3
- 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-cache.ts +1 -1
- package/src/dev-lock.ts +124 -12
- package/src/dev-queue.ts +12 -7
- 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 +96 -4
- package/src/dev-sync.ts +9 -4
- package/src/dispatch.ts +35 -5
- 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/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/realtime-browser-probe-fixture.ts +9 -0
- package/src/runtime-overrides.ts +11 -3
- package/src/shot-settle.ts +57 -0
- package/src/shot-verdict.ts +27 -4
- 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 +24 -13
- package/src/templates/scaffold-entries.ts +131 -0
- package/src/templates/scaffold-guards.ts +26 -0
- package/src/templates/scaffold-repo.ts +37 -6
- 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 `raw-colour` guard `x new` ships: no stylesheet in this app names a colour.
|
|
2
|
+
// `AGENTS.md` has always stated the rule and NOTHING enforced it — `verify-checks.ts` said it rode
|
|
3
|
+
// on `packages/ui/src/tokens/tokens.test.ts`, which covers the framework's stylesheets and never
|
|
4
|
+
// the app's, so `color: #ff0000` in a scaffolded `page.module.scss` was green on `x verify`.
|
|
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 = 'raw-colour';
|
|
16
|
+
const CODE = guardCode(NAME);
|
|
17
|
+
|
|
18
|
+
const source =
|
|
19
|
+
(): string => `// raw-colour: every colour in this app is a semantic token, so dark theme is not a later project.
|
|
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. Delete it to drop the rule.
|
|
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
|
+
/** A hex literal. \`#{$x}\` is Sass interpolation, not a colour, and \`{\` is not a hex digit. */
|
|
29
|
+
const HEX = /#[0-9a-fA-F]{3,8}\\b/;
|
|
30
|
+
const CHANNEL_FUNCTION = /\\b(?:rgba?|hsla?|lab|lch|oklab|oklch|color)\\(/i;
|
|
31
|
+
/** The named colours a human actually types. The full CSS list would report \`.item\` selectors. */
|
|
32
|
+
const NAMED =
|
|
33
|
+
/\\b(?:white|black|red|green|blue|yellow|orange|purple|pink|brown|gray|grey|silver|navy|teal|olive|lime|aqua|maroon|fuchsia|gold|beige|coral|crimson|indigo|violet|khaki|salmon|tan|turquoise|wheat)\\b/i;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A DECLARATION, never a whole line: a selector carries no colon, so \`#hero { … }\` is not a value
|
|
37
|
+
* and is never reported. The value stops at the first \`;\`, \`{\` or \`}\`.
|
|
38
|
+
*/
|
|
39
|
+
const DECLARATION = /([\\w-]+)\\s*:\\s*([^;{}]+)/g;
|
|
40
|
+
|
|
41
|
+
export interface StyleFile {
|
|
42
|
+
/** App-root-relative POSIX path, so the finding names the file an author opens. */
|
|
43
|
+
readonly path: string;
|
|
44
|
+
readonly scss: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Comments blanked rather than removed, so the reported line number still points at the source
|
|
49
|
+
* line. \`//\` is skipped when a \`:\` precedes it — \`url(https://…)\` is a value, not a comment.
|
|
50
|
+
*/
|
|
51
|
+
const blankComments = (scss: string): string =>
|
|
52
|
+
scss
|
|
53
|
+
.replaceAll(/\\/\\*[\\s\\S]*?\\*\\//g, (match) => match.replaceAll(/[^\\n]/g, ' '))
|
|
54
|
+
.replaceAll(/(?<![:\\w])\\/\\/[^\\n]*/g, (match) => ' '.repeat(match.length));
|
|
55
|
+
|
|
56
|
+
/** Quoted text is a filename or a token name, never a colour: \`url('red.png')\`, \`role('bg')\`. */
|
|
57
|
+
const unquote = (value: string): string => value.replaceAll(/'[^']*'|"[^"]*"/g, ' ');
|
|
58
|
+
|
|
59
|
+
const lineOf = (text: string, index: number): number => text.slice(0, index).split('\\n').length;
|
|
60
|
+
|
|
61
|
+
/** Pure — the caller does the I/O — so the rule is testable without a filesystem. */
|
|
62
|
+
export function rawColours(files: readonly StyleFile[]): readonly Finding[] {
|
|
63
|
+
const findings: Finding[] = [];
|
|
64
|
+
for (const file of files) {
|
|
65
|
+
const scss = blankComments(file.scss);
|
|
66
|
+
for (const match of scss.matchAll(DECLARATION)) {
|
|
67
|
+
const property = match[1] ?? '';
|
|
68
|
+
const value = unquote(match[2] ?? '');
|
|
69
|
+
const literal = HEX.exec(value) ?? CHANNEL_FUNCTION.exec(value) ?? NAMED.exec(value);
|
|
70
|
+
if (literal === null) continue;
|
|
71
|
+
findings.push({
|
|
72
|
+
code: CODE,
|
|
73
|
+
cause: \`\${file.path}:\${lineOf(scss, match.index)} sets \${property} to the raw colour \${literal[0]} — a value no theme can restate, so dark theme renders it unchanged\`,
|
|
74
|
+
fix: \`replace \${literal[0]} in \${file.path} with tokens.role('fg'), tokens.role('bg') or the role this element means, then: x verify\`,
|
|
75
|
+
at: file.path,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return findings;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export const guard: Guard = {
|
|
83
|
+
summary: 'a stylesheet names a semantic token, never a colour',
|
|
84
|
+
async check(root) {
|
|
85
|
+
const files: StyleFile[] = [];
|
|
86
|
+
for await (const entry of new Bun.Glob('{apps,packages}/**/*.scss').scan({
|
|
87
|
+
cwd: root,
|
|
88
|
+
absolute: false,
|
|
89
|
+
})) {
|
|
90
|
+
const path = entry.split('\\\\').join('/');
|
|
91
|
+
if (path.includes('node_modules/')) continue;
|
|
92
|
+
files.push({ path, scss: await Bun.file(\`\${root}/\${path}\`).text() });
|
|
93
|
+
}
|
|
94
|
+
return rawColours(files);
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
`;
|
|
98
|
+
|
|
99
|
+
const test =
|
|
100
|
+
(): string => `// The rule, driven directly. Failure case first: a guard whose rule silently stopped matching is
|
|
101
|
+
// a green gate over the convention it was written to enforce.
|
|
102
|
+
|
|
103
|
+
import { expect, unitTest } from '@ultimat3/testing';
|
|
104
|
+
import { rawColours } from './raw-colour';
|
|
105
|
+
|
|
106
|
+
const sheet = (scss: string) => [{ path: 'apps/web/site/page.module.scss', scss }];
|
|
107
|
+
|
|
108
|
+
unitTest('a hex literal in a declaration is refused', () => {
|
|
109
|
+
const findings = rawColours(sheet('.hero {\\n color: #ff0000;\\n}\\n'));
|
|
110
|
+
expect(findings).toHaveLength(1);
|
|
111
|
+
expect(findings[0]?.code).toBe('${CODE}');
|
|
112
|
+
expect(findings[0]?.cause).toContain('#ff0000');
|
|
113
|
+
expect(findings[0]?.cause).toContain(':2');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
unitTest('rgb(), hsl() and a named colour are the same rule', () => {
|
|
117
|
+
expect(rawColours(sheet('.a { background: rgb(1 2 3); }'))).toHaveLength(1);
|
|
118
|
+
expect(rawColours(sheet('.a { background: hsl(1 2% 3%); }'))).toHaveLength(1);
|
|
119
|
+
expect(rawColours(sheet('.a { border-color: white; }'))).toHaveLength(1);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
unitTest('a token, a selector and a quoted filename are not colours', () => {
|
|
123
|
+
expect(rawColours(sheet(".a { background: tokens.role('bg'); }"))).toEqual([]);
|
|
124
|
+
expect(rawColours(sheet('#hero { padding: 0; }'))).toEqual([]);
|
|
125
|
+
expect(rawColours(sheet(".a { background: url('red-hero.png'); }"))).toEqual([]);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
unitTest('a commented-out colour is a note, not a declaration', () => {
|
|
129
|
+
expect(rawColours(sheet('// color: #ff0000;\\n.a { padding: 0; }'))).toEqual([]);
|
|
130
|
+
expect(rawColours(sheet('/* color: #ff0000; */\\n.a { padding: 0; }'))).toEqual([]);
|
|
131
|
+
});
|
|
132
|
+
`;
|
|
133
|
+
|
|
134
|
+
/** `guards/raw-colour.ts` and its test. No index, no registry — the directory is the registration. */
|
|
135
|
+
export const rawColourGuardFiles = (): readonly GeneratedFile[] => [
|
|
136
|
+
{ path: 'guards/raw-colour.ts', contents: source() },
|
|
137
|
+
{ path: 'guards/raw-colour.test.ts', contents: test() },
|
|
138
|
+
];
|
|
@@ -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
|
|