@ultimat3/cli 22.15.0 → 24.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 +36 -13
- package/README.md +31 -8
- package/package.json +29 -28
- package/src/admin-catalog-keys.ts +16 -0
- package/src/admin-registration.ts +192 -0
- package/src/affected.ts +27 -35
- package/src/api-registration.ts +125 -11
- package/src/app-artifacts.ts +1 -1
- package/src/app-auth.ts +4 -27
- package/src/app-boundaries.ts +6 -20
- package/src/app-config-load.ts +51 -0
- package/src/app-env.ts +5 -11
- package/src/app-load.ts +55 -11
- package/src/app-manifest.ts +16 -8
- package/src/app-mcp.ts +10 -23
- package/src/app-permissions-borrowed.ts +129 -0
- package/src/app-permissions-site.ts +24 -0
- package/src/app-permissions.ts +0 -0
- package/src/app-reload-graph.ts +7 -30
- package/src/app-transport.ts +219 -0
- package/src/boundary-findings.ts +16 -4
- package/src/browser-transport.ts +160 -0
- package/src/budgets.ts +27 -34
- package/src/cdp-shot-driver.ts +3 -1
- package/src/cmd-auth-spec.ts +16 -0
- package/src/cmd-auth.ts +65 -0
- package/src/cmd-build-spec.ts +10 -3
- package/src/cmd-build.ts +107 -11
- package/src/cmd-db-backfill.ts +25 -12
- package/src/cmd-db.ts +66 -16
- package/src/cmd-deploy-helm.ts +7 -6
- package/src/cmd-deploy.ts +4 -4
- package/src/cmd-dev.ts +9 -8
- package/src/cmd-doctor-spec.ts +2 -9
- package/src/cmd-doctor.ts +75 -24
- package/src/cmd-env.ts +26 -2
- package/src/cmd-generate.ts +195 -47
- package/src/cmd-help.ts +19 -6
- package/src/cmd-i18n.ts +10 -9
- package/src/cmd-jobs-spec.ts +19 -3
- package/src/cmd-jobs.ts +136 -7
- package/src/cmd-manifest-spec.ts +1 -1
- package/src/cmd-manifest.ts +6 -25
- package/src/cmd-mcp.ts +46 -15
- package/src/cmd-new.ts +15 -1
- package/src/cmd-planned.ts +15 -8
- package/src/cmd-routes.ts +27 -3
- package/src/cmd-secrets-ring.ts +76 -0
- package/src/cmd-secrets-spec.ts +10 -2
- package/src/cmd-secrets.ts +95 -18
- package/src/cmd-shot-matrix.ts +1 -3
- package/src/cmd-shot.ts +12 -3
- package/src/cmd-tasks.ts +84 -15
- package/src/cmd-test-spec.ts +1 -7
- package/src/cmd-test.ts +13 -29
- package/src/cmd-verify.ts +13 -5
- package/src/comment-safe.ts +16 -0
- package/src/coverage-floor.ts +334 -0
- package/src/coverage-lcov.ts +193 -0
- package/src/coverage-source.ts +136 -0
- package/src/db-object-drift.ts +30 -0
- package/src/db-replay-engine.ts +115 -0
- package/src/db-schema-dump.ts +150 -0
- package/src/db-schema-refresh.ts +122 -0
- package/src/db-subscribes.ts +12 -4
- package/src/dev-dashboard-guard.ts +76 -0
- package/src/dev-dashboard.ts +51 -29
- package/src/dev-lock.ts +4 -7
- package/src/dev-route-table.ts +9 -0
- package/src/dispatch.ts +3 -6
- package/src/doctor-app-url.ts +39 -0
- package/src/doctor-auth.ts +95 -0
- package/src/doctor-coverage.ts +39 -0
- package/src/doctor-guards.ts +54 -0
- package/src/doctor-sealed.ts +132 -0
- package/src/document-graph.ts +82 -0
- package/src/drift-replica-identity.ts +42 -0
- package/src/error-catalog.ts +6 -9
- package/src/error-codes.ts +55 -3
- package/src/error-unthrown.ts +29 -11
- package/src/errors.ts +54 -83
- package/src/framework-schema-apply.ts +78 -0
- package/src/framework-schema-stamp.ts +96 -0
- package/src/framework-schema.ts +28 -2
- package/src/generate-files.ts +79 -12
- package/src/generate-grant-findings.ts +76 -0
- package/src/generate-grants.ts +77 -3
- package/src/generate-kinds.ts +52 -1
- package/src/generate-shadow.ts +46 -0
- package/src/generate-write.ts +62 -40
- package/src/generated-imports.ts +59 -0
- package/src/gh-target.ts +12 -7
- package/src/guard-sources.ts +104 -0
- package/src/guards.ts +23 -7
- package/src/handle-registration.ts +217 -0
- package/src/i18n-audit.ts +4 -1
- package/src/i18n-registration.ts +36 -0
- package/src/image-prepare.ts +51 -8
- package/src/import-closure.ts +165 -0
- package/src/index.ts +12 -5
- package/src/island-bundle.ts +3 -15
- package/src/island-store.ts +24 -16
- package/src/jobs-drain.ts +95 -21
- package/src/jobs-json.ts +50 -0
- package/src/jobs-report.ts +7 -4
- package/src/json-merge.ts +46 -0
- package/src/loopback-host.ts +13 -0
- package/src/manifest-staleness.ts +62 -0
- package/src/mcp-errors.ts +41 -4
- package/src/mcp-host.ts +16 -9
- package/src/mcp-test-run.ts +38 -0
- package/src/messages.ts +35 -0
- package/src/metrics-endpoint.ts +95 -24
- package/src/migration-extensions.ts +29 -0
- package/src/module-imports.ts +47 -0
- package/src/output.ts +24 -9
- package/src/page-navigation.ts +7 -23
- package/src/page-speculation.ts +5 -20
- package/src/permission-grants.ts +41 -9
- package/src/prerender-out.ts +34 -7
- package/src/prerender.ts +8 -5
- package/src/publish-closure.ts +101 -0
- package/src/pwa-artifacts.ts +26 -94
- package/src/registry.ts +2 -0
- package/src/revalidated-response.ts +4 -3
- package/src/role-load.ts +143 -0
- package/src/role-replicator.ts +16 -0
- package/src/role-start.ts +27 -5
- package/src/role-sync.ts +55 -20
- package/src/role-wake.ts +45 -0
- package/src/runtime-admin.ts +141 -0
- package/src/runtime-assets.ts +11 -7
- package/src/runtime-bindings.ts +4 -4
- package/src/runtime-cache.ts +38 -47
- package/src/runtime-hooks.ts +5 -2
- package/src/runtime-idempotency-scope.ts +23 -0
- package/src/runtime-isr.ts +36 -0
- package/src/runtime-jobs.ts +8 -33
- package/src/runtime-mfa-warning.ts +29 -0
- package/src/runtime-notify-retention.ts +10 -46
- package/src/runtime-overrides.ts +1 -1
- package/src/runtime-purge.ts +38 -16
- package/src/runtime-queue.ts +39 -11
- package/src/runtime-realtime.ts +9 -45
- package/src/runtime-render.ts +68 -35
- package/src/runtime-services.ts +22 -18
- package/src/runtime-storage.ts +10 -5
- package/src/scaffold-typecheck.ts +1 -0
- package/src/schema-diff.ts +16 -0
- package/src/schema-drift.ts +45 -15
- package/src/schema-dump-drift.ts +64 -0
- package/src/seo-routes.ts +80 -9
- package/src/serve-boot.ts +114 -158
- package/src/serve-drain.ts +9 -33
- package/src/serve-env.ts +9 -6
- package/src/serve-prebuilt-paths.ts +19 -0
- package/src/serve-prebuilt.ts +84 -0
- package/src/serve-web.ts +169 -0
- package/src/serve.ts +6 -9
- package/src/server-barrels.ts +54 -0
- package/src/shot-locale.ts +5 -23
- package/src/site-assets.ts +3 -5
- package/src/site-config.ts +9 -48
- package/src/site-seo.ts +1 -1
- package/src/sitemap-lastmod.ts +5 -0
- package/src/solid-loader.ts +12 -3
- package/src/source-edit.ts +49 -0
- package/src/source-files.ts +6 -0
- package/src/stored-object-headers.ts +43 -0
- package/src/sync-url.ts +2 -2
- package/src/tasks-facts.ts +3 -26
- package/src/templates/action.ts +147 -11
- package/src/templates/admin-catalog.ts +58 -0
- package/src/templates/admin-page.ts +36 -8
- package/src/templates/admin.ts +57 -17
- package/src/templates/backfill.ts +121 -87
- package/src/templates/entity.ts +140 -44
- package/src/templates/guard-animated-layout-property.ts +4 -11
- package/src/templates/guard-bare-error.ts +9 -13
- package/src/templates/guard-focus-visible.ts +4 -11
- package/src/templates/guard-image-dimensions.ts +6 -6
- package/src/templates/guard-island-without-states.ts +2 -4
- package/src/templates/guard-raw-breakpoint.ts +210 -0
- package/src/templates/guard-raw-colour.ts +4 -11
- package/src/templates/guard-raw-length.ts +375 -0
- package/src/templates/guard-raw-motion.ts +213 -0
- package/src/templates/guard-raw-shadow.ts +187 -0
- package/src/templates/guard-raw-z-index.ts +166 -0
- package/src/templates/guard-repo-raw-sql.ts +345 -0
- package/src/templates/guard-semantic-interactive.ts +6 -6
- package/src/templates/guard-undeclared-custom-property.ts +217 -0
- package/src/templates/guard-undefined-style-class.ts +215 -0
- package/src/templates/guard-untranslated-string.ts +6 -6
- package/src/templates/guard-unzoned-date.ts +9 -13
- package/src/templates/index.ts +3 -2
- package/src/templates/island.ts +34 -51
- package/src/templates/job-body-test.ts +121 -0
- package/src/templates/job.ts +23 -8
- package/src/templates/naming.ts +28 -0
- package/src/templates/policy.ts +16 -9
- package/src/templates/query.ts +242 -26
- package/src/templates/resource-create.ts +62 -18
- package/src/templates/resource-form-island.ts +58 -80
- package/src/templates/resource-page.ts +269 -0
- package/src/templates/resource-service.ts +96 -0
- package/src/templates/resource.ts +86 -61
- package/src/templates/route.ts +41 -35
- package/src/templates/scaffold-api.ts +55 -1
- package/src/templates/scaffold-app.ts +177 -53
- package/src/templates/scaffold-auth.ts +83 -19
- package/src/templates/scaffold-browser-client.ts +90 -0
- package/src/templates/scaffold-container-compose.ts +10 -1
- package/src/templates/scaffold-container.ts +20 -2
- package/src/templates/scaffold-dashboard-bare.ts +64 -2
- package/src/templates/scaffold-dashboard-example.ts +124 -31
- package/src/templates/scaffold-dashboard-shared.ts +1 -20
- package/src/templates/scaffold-dashboard.ts +1 -2
- package/src/templates/scaffold-db-client.ts +144 -0
- package/src/templates/scaffold-db-package.ts +90 -8
- package/src/templates/scaffold-docs.ts +50 -5
- package/src/templates/scaffold-guards.ts +54 -11
- package/src/templates/scaffold-helm-fences.ts +76 -0
- package/src/templates/scaffold-helm-templates.ts +32 -7
- package/src/templates/scaffold-helm.ts +31 -1
- package/src/templates/scaffold-http.ts +9 -7
- package/src/templates/scaffold-repo.ts +61 -3
- package/src/templates/scaffold-roles.ts +56 -9
- package/src/templates/scaffold-shell.ts +74 -105
- package/src/templates/scaffold-site.ts +45 -19
- package/src/templates/scaffold-ui-package.ts +18 -0
- package/src/templates/slice-foundation.ts +46 -8
- package/src/test-counts.ts +14 -2
- package/src/test-ignores.ts +25 -0
- package/src/test-passes.ts +0 -15
- package/src/test-select.ts +5 -14
- package/src/test-shards.ts +41 -93
- package/src/test-workers.ts +26 -4
- package/src/theme-boot.ts +6 -31
- package/src/transport-calls.ts +105 -0
- package/src/trusted-hops-error.ts +17 -0
- package/src/unscanned-admin.ts +54 -0
- package/src/unthrown-codes.ts +26 -0
- package/src/verify-checks.ts +43 -53
- package/src/verify-coverage-run.ts +190 -0
- package/src/verify-deadline.ts +353 -0
- package/src/verify-floor.ts +36 -9
- package/src/verify-merge.ts +171 -45
- package/src/verify-part-step.ts +90 -0
- package/src/verify-progress.ts +25 -0
- package/src/verify-role-load-probe.ts +54 -0
- package/src/verify-role-load.ts +148 -0
- package/src/verify-run.ts +116 -37
- package/src/verify-shard.ts +33 -11
- package/src/verify-stalled.ts +152 -0
- package/src/verify-step.ts +42 -1
- package/src/verify-tests.ts +83 -8
- package/src/workspace-checks.ts +50 -8
- package/src/workspace-dep-edit.ts +127 -0
- package/src/build-errors.ts +0 -54
- package/src/cmd-dev-fixture.ts +0 -204
- package/src/dev-live-fixture.ts +0 -122
- package/src/policy-fixture.ts +0 -78
- package/src/realtime-browser-probe-fixture.ts +0 -9
- package/src/role-start-fixture.ts +0 -78
- package/src/scaffold-fixture.ts +0 -160
- package/src/templates/island-fixture.ts +0 -76
- package/src/verify-errors.ts +0 -49
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
// The `repo-raw-sql` guard `x new` ships: a feature's `repo.ts` reads through the typed handle.
|
|
2
|
+
// The generator used to emit string SQL and an agent-written app copied it 1,115 times — every one
|
|
3
|
+
// a statement with no tenant guard and no codec. This is the rule that keeps a repo from drifting
|
|
4
|
+
// back, and it reports only what the handle can say: the call it names is the repair.
|
|
5
|
+
|
|
6
|
+
import { guardCode } from './guard';
|
|
7
|
+
import type { GeneratedFile } from './naming';
|
|
8
|
+
|
|
9
|
+
/** Derived from the guard's name, never a literal: an `X_*` literal here is a FRAMEWORK code. */
|
|
10
|
+
const NAME = 'repo-raw-sql';
|
|
11
|
+
const CODE = guardCode(NAME);
|
|
12
|
+
|
|
13
|
+
const source =
|
|
14
|
+
(): string => `// repo-raw-sql: a repo.ts reads through the typed handle, so tenancy and the codecs ride on every statement.
|
|
15
|
+
// \`x verify\` discovers every file in \`guards/\` and runs its \`guard\` inside the \`boundaries\`
|
|
16
|
+
// step — nothing registers this file, so nothing can forget to. Delete it to drop the rule.
|
|
17
|
+
//
|
|
18
|
+
// Only a statement the handle can express is reported: \`select *\`, \`insert\`, \`update\` or \`delete\`
|
|
19
|
+
// on ONE table, with plain comparisons. A join, an aggregate, a CTE, a window, an \`or\`, a function
|
|
20
|
+
// call and a statement composed from another fragment are past what the handle says, and silent.
|
|
21
|
+
|
|
22
|
+
import type { Finding, Guard } from '@ultimat3/cli';
|
|
23
|
+
|
|
24
|
+
/** The app owns the codes its own conventions raise — this one is named for the guard. */
|
|
25
|
+
const CODE = '${CODE}';
|
|
26
|
+
|
|
27
|
+
export interface RepoFile {
|
|
28
|
+
/** App-root-relative POSIX path, so the finding names the file an author opens. */
|
|
29
|
+
readonly path: string;
|
|
30
|
+
readonly source: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** One tagged template: its text with every interpolation reduced to \`?\`, and where it starts. */
|
|
34
|
+
export interface Statement {
|
|
35
|
+
readonly text: string;
|
|
36
|
+
readonly line: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const lineOf = (text: string, index: number): number => text.slice(0, index).split('\\n').length;
|
|
40
|
+
|
|
41
|
+
/** Past the closing quote of the string opening at \`open\`. */
|
|
42
|
+
const endOfString = (source: string, open: number): number => {
|
|
43
|
+
let index = open + 1;
|
|
44
|
+
while (index < source.length && source[index] !== source[open]) {
|
|
45
|
+
index += source[index] === '\\\\' ? 2 : 1;
|
|
46
|
+
}
|
|
47
|
+
return index + 1;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/** Past the \`}\` of the interpolation whose body starts at \`from\`; strings and templates skip whole. */
|
|
51
|
+
const endOfInterpolation = (source: string, from: number): number => {
|
|
52
|
+
let depth = 1;
|
|
53
|
+
let index = from;
|
|
54
|
+
while (index < source.length) {
|
|
55
|
+
const char = source[index];
|
|
56
|
+
if (char === '\`') index = readTemplate(source, index).end;
|
|
57
|
+
else if (char === "'" || char === '"') index = endOfString(source, index);
|
|
58
|
+
else {
|
|
59
|
+
if (char === '{') depth += 1;
|
|
60
|
+
if (char === '}') depth -= 1;
|
|
61
|
+
index += 1;
|
|
62
|
+
if (depth === 0) return index;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return index;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The template literal opening at \`open\`. An interpolation is a bound parameter, so it reads \`?\` —
|
|
70
|
+
* unless it holds another \`sql\` literal, which is a statement being COMPOSED: the handle has no
|
|
71
|
+
* word for that, and the marker makes sure no rule below matches it.
|
|
72
|
+
*/
|
|
73
|
+
function readTemplate(
|
|
74
|
+
source: string,
|
|
75
|
+
open: number,
|
|
76
|
+
): { readonly text: string; readonly end: number } {
|
|
77
|
+
let text = '';
|
|
78
|
+
let index = open + 1;
|
|
79
|
+
while (index < source.length) {
|
|
80
|
+
const char = source[index];
|
|
81
|
+
if (char === '\`') return { text, end: index + 1 };
|
|
82
|
+
if (char === '\\\\') {
|
|
83
|
+
text += source.slice(index, index + 2);
|
|
84
|
+
index += 2;
|
|
85
|
+
} else if (char === '$' && source[index + 1] === '{') {
|
|
86
|
+
const end = endOfInterpolation(source, index + 2);
|
|
87
|
+
text += /\\bsql\`/.test(source.slice(index, end)) ? ' <fragment> ' : '?';
|
|
88
|
+
index = end;
|
|
89
|
+
} else {
|
|
90
|
+
text += char;
|
|
91
|
+
index += 1;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return { text, end: index };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Every \`sql\` tagged template in real code. A scanner rather than a regex, because the lookalikes
|
|
99
|
+
* are the common case: this file's own header says "a query's \`sql\`" in a COMMENT, and a string
|
|
100
|
+
* may hold a backtick. Both are walked past, never read.
|
|
101
|
+
*/
|
|
102
|
+
export function sqlStatements(source: string): readonly Statement[] {
|
|
103
|
+
const found: Statement[] = [];
|
|
104
|
+
let index = 0;
|
|
105
|
+
while (index < source.length) {
|
|
106
|
+
const char = source[index];
|
|
107
|
+
const pair = source.slice(index, index + 2);
|
|
108
|
+
if (pair === '//') {
|
|
109
|
+
const eol = source.indexOf('\\n', index);
|
|
110
|
+
index = eol === -1 ? source.length : eol;
|
|
111
|
+
} else if (pair === '/*') {
|
|
112
|
+
const close = source.indexOf('*/', index + 2);
|
|
113
|
+
index = close === -1 ? source.length : close + 2;
|
|
114
|
+
} else if (char === "'" || char === '"') {
|
|
115
|
+
index = endOfString(source, index);
|
|
116
|
+
} else if (char === '\`') {
|
|
117
|
+
const template = readTemplate(source, index);
|
|
118
|
+
if (/(?:^|[^\\w$])sql$/.test(source.slice(Math.max(0, index - 4), index))) {
|
|
119
|
+
found.push({ text: template.text, line: lineOf(source, index) });
|
|
120
|
+
}
|
|
121
|
+
index = template.end;
|
|
122
|
+
} else index += 1;
|
|
123
|
+
}
|
|
124
|
+
return found;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const NAME = '[a-z_][a-z0-9_]*';
|
|
128
|
+
const TABLE = \`"?(\${NAME})"?\`;
|
|
129
|
+
const VALUE = "(?:\\\\?|-?\\\\d+(?:\\\\.\\\\d+)?|'[^']*'|null|true|false)";
|
|
130
|
+
const COMPARISON = \`"?\${NAME}"? (?:(?:=|<>|!=|<=|>=|<|>|like|in) \${VALUE}|is (?:not )?null)\`;
|
|
131
|
+
const WHERE = \`(\${COMPARISON}(?: and \${COMPARISON})*)\`;
|
|
132
|
+
const ORDER = \`"?(\${NAME})"?(?: (asc|desc))?(?:, ?"?\${NAME}"?(?: (?:asc|desc))?)*\`;
|
|
133
|
+
const ASSIGNMENT = \`"?\${NAME}"? = \${VALUE}\`;
|
|
134
|
+
|
|
135
|
+
const SELECT = new RegExp(
|
|
136
|
+
\`^select \\\\* from \${TABLE}(?: where \${WHERE})?(?: order by \${ORDER})?(?: limit (\${VALUE}))?$\`,
|
|
137
|
+
);
|
|
138
|
+
const INSERT = new RegExp(
|
|
139
|
+
\`^insert into \${TABLE} \\\\([^()]*\\\\) values \\\\(\${VALUE}(?:, ?\${VALUE})*\\\\)(?: returning \\\\*)?$\`,
|
|
140
|
+
);
|
|
141
|
+
const UPDATE = new RegExp(
|
|
142
|
+
\`^update \${TABLE} set \${ASSIGNMENT}(?:, ?\${ASSIGNMENT})* where \${WHERE}(?: returning \\\\*)?$\`,
|
|
143
|
+
);
|
|
144
|
+
const DELETE = new RegExp(\`^delete from \${TABLE} where \${WHERE}(?: returning \\\\*)?$\`);
|
|
145
|
+
|
|
146
|
+
/** \`short_links\` → \`shortLinks\`: the handle is keyed by the camelCase the entity declares. */
|
|
147
|
+
const camel = (name: string): string =>
|
|
148
|
+
name.replaceAll(/_([a-z0-9])/g, (_match, letter: string) => letter.toUpperCase());
|
|
149
|
+
|
|
150
|
+
const OPERATORS: Readonly<Record<string, string>> = {
|
|
151
|
+
'<>': 'neq',
|
|
152
|
+
'!=': 'neq',
|
|
153
|
+
'<': 'lt',
|
|
154
|
+
'<=': 'lte',
|
|
155
|
+
'>': 'gt',
|
|
156
|
+
'>=': 'gte',
|
|
157
|
+
like: 'like',
|
|
158
|
+
in: 'in',
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
/** The predicate as the handle spells it: equalities in one \`where({ … })\`, the rest chained. */
|
|
162
|
+
const filterCalls = (where: string | undefined): string => {
|
|
163
|
+
if (where === undefined) return '';
|
|
164
|
+
const equal: string[] = [];
|
|
165
|
+
const others: string[] = [];
|
|
166
|
+
for (const comparison of where.split(' and ')) {
|
|
167
|
+
const [column = '', operator = '', ...rest] = comparison.replaceAll('"', '').split(' ');
|
|
168
|
+
const name = camel(column);
|
|
169
|
+
if (operator === '=') equal.push(name);
|
|
170
|
+
else if (operator !== 'is') {
|
|
171
|
+
others.push(\`.andWhere('\${name}', '\${OPERATORS[operator] ?? operator}', value)\`);
|
|
172
|
+
} else others.push(\`.andWhere('\${name}', '\${rest.length === 2 ? 'is-not-null' : 'is-null'}')\`);
|
|
173
|
+
}
|
|
174
|
+
return \`\${equal.length === 0 ? '' : \`.where({ \${equal.join(', ')} })\`}\${others.join('')}\`;
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
/** Whether the predicate is exactly "this id" — the shape \`update(id, …)\` and \`delete(id)\` take. */
|
|
178
|
+
const byIdOnly = (where: string): boolean => /^"?id"? = \\?$/.test(where);
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The handle call that says what \`sql\` says, or \`undefined\` when the handle cannot say it. \`sql\`
|
|
182
|
+
* is the literal's text as written; whitespace, case and a trailing \`;\` are not part of the rule.
|
|
183
|
+
*/
|
|
184
|
+
export function handleCallFor(sql: string): string | undefined {
|
|
185
|
+
const text = sql
|
|
186
|
+
.toLowerCase()
|
|
187
|
+
.replaceAll(/\\s+/g, ' ')
|
|
188
|
+
.replaceAll(/\\( /g, '(')
|
|
189
|
+
.replaceAll(/ \\)/g, ')')
|
|
190
|
+
.trim()
|
|
191
|
+
.replace(/;$/, '')
|
|
192
|
+
.trim();
|
|
193
|
+
const select = SELECT.exec(text);
|
|
194
|
+
if (select !== null) {
|
|
195
|
+
const [, table = '', where, column, direction, limit] = select;
|
|
196
|
+
const order =
|
|
197
|
+
column === undefined
|
|
198
|
+
? ''
|
|
199
|
+
: \`.orderBy('\${camel(column)}'\${direction === 'desc' ? ", 'desc'" : ''})\`;
|
|
200
|
+
const one = column === undefined && limit === undefined && /(^| )"?id"? = /.test(where ?? '');
|
|
201
|
+
const bound = limit === undefined ? '' : \`.limit(\${limit === '?' ? 'limit' : limit})\`;
|
|
202
|
+
return \`db.\${camel(table)}\${filterCalls(where)}\${order}\${bound}.\${one ? 'one' : 'all'}()\`;
|
|
203
|
+
}
|
|
204
|
+
const insert = INSERT.exec(text);
|
|
205
|
+
if (insert !== null) return \`db.\${camel(insert[1] ?? '')}.insert({ … })\`;
|
|
206
|
+
const update = UPDATE.exec(text);
|
|
207
|
+
if (update !== null) {
|
|
208
|
+
const [, table = '', where = ''] = update;
|
|
209
|
+
return byIdOnly(where)
|
|
210
|
+
? \`db.\${camel(table)}.update(id, { … })\`
|
|
211
|
+
: \`db.\${camel(table)}.updateWhere({ … }, { … })\`;
|
|
212
|
+
}
|
|
213
|
+
const remove = DELETE.exec(text);
|
|
214
|
+
if (remove !== null) {
|
|
215
|
+
const [, table = '', where = ''] = remove;
|
|
216
|
+
return byIdOnly(where)
|
|
217
|
+
? \`db.\${camel(table)}.delete(id)\`
|
|
218
|
+
: \`db.\${camel(table)}.deleteWhere({ … })\`;
|
|
219
|
+
}
|
|
220
|
+
return undefined;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Pure — the caller does the I/O — so the rule is testable without a filesystem. */
|
|
224
|
+
export function rawRepoSql(files: readonly RepoFile[]): readonly Finding[] {
|
|
225
|
+
const findings: Finding[] = [];
|
|
226
|
+
for (const file of files) {
|
|
227
|
+
for (const statement of sqlStatements(file.source)) {
|
|
228
|
+
const call = handleCallFor(statement.text);
|
|
229
|
+
if (call === undefined) continue;
|
|
230
|
+
const at = \`\${file.path}:\${statement.line}\`;
|
|
231
|
+
findings.push({
|
|
232
|
+
code: CODE,
|
|
233
|
+
cause: \`\${at} sends a statement the typed handle expresses as hand-written SQL — no tenant guard on it, no codec for money or a sealed column, and no compile error when a column is renamed\`,
|
|
234
|
+
fix: \`\${call} — in place of the sql literal at \${at}, with db imported from this app's db package, then: x verify\`,
|
|
235
|
+
at,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return findings;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export const guard: Guard = {
|
|
243
|
+
summary: 'a repo.ts reads through the typed handle, never a sql literal the handle can express',
|
|
244
|
+
async check(_root, sources) {
|
|
245
|
+
// The run's ONE read of these files: every guard asking for this glob shares the walk.
|
|
246
|
+
const repos = await sources.files('{apps,packages}/**/repo.ts');
|
|
247
|
+
return rawRepoSql(repos.map((file) => ({ path: file.path, source: file.text })));
|
|
248
|
+
},
|
|
249
|
+
};
|
|
250
|
+
`;
|
|
251
|
+
|
|
252
|
+
const test =
|
|
253
|
+
(): string => `// The rule, driven directly. Failure case first: a guard whose rule silently stopped matching is
|
|
254
|
+
// a green gate over the convention it was written to enforce.
|
|
255
|
+
|
|
256
|
+
import { expect, unitTest } from '@ultimat3/testing';
|
|
257
|
+
import { handleCallFor, rawRepoSql, sqlStatements } from './repo-raw-sql';
|
|
258
|
+
|
|
259
|
+
const REPO = 'apps/web/app/post/repo.ts';
|
|
260
|
+
const repo = (source: string) => [{ path: REPO, source }];
|
|
261
|
+
|
|
262
|
+
/** An interpolation as SOURCE TEXT, spelled apart so this file holds no placeholder of its own. */
|
|
263
|
+
const hole = (name: string): string => ['$', '{', name, '}'].join('');
|
|
264
|
+
|
|
265
|
+
unitTest('a select the handle can express is refused, naming the call', () => {
|
|
266
|
+
const source = [
|
|
267
|
+
"import { db, sql } from '@ultimat3/db';",
|
|
268
|
+
'export const byId = (id: string) =>',
|
|
269
|
+
\` db().one(sql\\\`select * from posts where id = \${hole('id')}\\\`);\`,
|
|
270
|
+
'',
|
|
271
|
+
].join('\\n');
|
|
272
|
+
const findings = rawRepoSql(repo(source));
|
|
273
|
+
expect(findings).toHaveLength(1);
|
|
274
|
+
expect(findings[0]?.code).toBe('${CODE}');
|
|
275
|
+
expect(findings[0]?.at).toBe(\`\${REPO}:3\`);
|
|
276
|
+
expect(findings[0]?.fix).toStartWith('db.posts.where({ id }).one() — ');
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
unitTest('a page reads as the chain that pages it', () => {
|
|
280
|
+
const page = 'select * from posts where org_id = ? order by created_at desc limit ?';
|
|
281
|
+
expect(handleCallFor(page)).toBe(
|
|
282
|
+
"db.posts.where({ orgId }).orderBy('createdAt', 'desc').limit(limit).all()",
|
|
283
|
+
);
|
|
284
|
+
const open = 'SELECT * FROM posts WHERE deleted_at IS NULL AND created_at >= ? LIMIT 10;';
|
|
285
|
+
expect(handleCallFor(open)).toBe(
|
|
286
|
+
"db.posts.andWhere('deletedAt', 'is-null').andWhere('createdAt', 'gte', value).limit(10).all()",
|
|
287
|
+
);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
unitTest('insert, update and delete on one table are the same rule', () => {
|
|
291
|
+
const insert = 'insert into short_links (id, url)\\n values (?, ?)\\n returning *';
|
|
292
|
+
expect(handleCallFor(insert)).toBe('db.shortLinks.insert({ … })');
|
|
293
|
+
expect(handleCallFor('update posts set title = ? where id = ?')).toBe(
|
|
294
|
+
'db.posts.update(id, { … })',
|
|
295
|
+
);
|
|
296
|
+
expect(handleCallFor('update posts set title = ? where org_id = ? and slug = ?')).toBe(
|
|
297
|
+
'db.posts.updateWhere({ … }, { … })',
|
|
298
|
+
);
|
|
299
|
+
expect(handleCallFor('delete from posts where id = ?')).toBe('db.posts.delete(id)');
|
|
300
|
+
expect(handleCallFor('delete from likes where post_id = ? and user_id = ?')).toBe(
|
|
301
|
+
'db.likes.deleteWhere({ … })',
|
|
302
|
+
);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
// The legitimate lookalikes — the reason the rule can stay switched on. Each is a statement the
|
|
306
|
+
// handle has no word for, and raw SQL in a repo is the right answer for it.
|
|
307
|
+
unitTest('a statement past what the handle says is silent', () => {
|
|
308
|
+
const silent = [
|
|
309
|
+
'select p.*, m.name from posts p join members m on m.id = p.author_id',
|
|
310
|
+
'select count(*) from posts where org_id = ?',
|
|
311
|
+
'select id, title from posts where org_id = ?',
|
|
312
|
+
'select * from posts where lower(title) = ?',
|
|
313
|
+
'select * from posts where slug = ? or id = ?',
|
|
314
|
+
'with recent as (select * from posts) select * from recent',
|
|
315
|
+
'update posts set like_count = like_count + 1 where id = ?',
|
|
316
|
+
'insert into likes (post_id, user_id) values (?, ?) on conflict do nothing',
|
|
317
|
+
];
|
|
318
|
+
for (const sql of silent) expect(handleCallFor(sql)).toBeUndefined();
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
unitTest('a statement composed from another fragment is silent', () => {
|
|
322
|
+
const inner = \`sql\\\`and a = \${hole('a')}\\\`\`;
|
|
323
|
+
const source = \`const rows = sql\\\`select * from posts where id = \${hole('id')} \${hole(inner)}\\\`;\`;
|
|
324
|
+
expect(sqlStatements(source)).toHaveLength(1);
|
|
325
|
+
expect(rawRepoSql(repo(source))).toEqual([]);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
unitTest('a comment, a string and an untagged template are not statements', () => {
|
|
329
|
+
const source = [
|
|
330
|
+
"// Only this file and a query's \`sql\` may touch \`db\`.",
|
|
331
|
+
\`/* sql\\\`delete from posts where id = \${hole('id')}\\\` */\`,
|
|
332
|
+
"const note = 'sql\`select * from posts\`';",
|
|
333
|
+
'const text = \`select * from posts\`;',
|
|
334
|
+
'',
|
|
335
|
+
].join('\\n');
|
|
336
|
+
expect(sqlStatements(source)).toEqual([]);
|
|
337
|
+
expect(rawRepoSql(repo(source))).toEqual([]);
|
|
338
|
+
});
|
|
339
|
+
`;
|
|
340
|
+
|
|
341
|
+
/** `guards/repo-raw-sql.ts` and its test. No index, no registry — the directory is the registration. */
|
|
342
|
+
export const repoRawSqlGuardFiles = (): readonly GeneratedFile[] => [
|
|
343
|
+
{ path: `guards/${NAME}.ts`, contents: source() },
|
|
344
|
+
{ path: `guards/${NAME}.test.ts`, contents: test() },
|
|
345
|
+
];
|
|
@@ -163,19 +163,19 @@ export function semanticInteractive(files: readonly SourceFile[]): readonly Find
|
|
|
163
163
|
|
|
164
164
|
export const guard: Guard = {
|
|
165
165
|
summary: 'a click is answered by a control, never by a div with a handler',
|
|
166
|
-
async check(
|
|
166
|
+
async check(_root, sources) {
|
|
167
167
|
const files: SourceFile[] = [];
|
|
168
168
|
// TWO globs, and the rule is that a brace ALTERNATIVE may not contain a \`/\`. Measured on Bun
|
|
169
169
|
// 1.4.0 against \`examples/dummy\`: \`{apps/*/{site,app},packages/*/src}/**/*.tsx\` and
|
|
170
170
|
// \`{apps/web,packages/ui}/**/*.tsx\` each match ZERO files, where \`apps/*/{site,app}/**/*.tsx\`
|
|
171
171
|
// matches 17 — so folding these into one line silently turns the guard off, which is worse than
|
|
172
|
-
// the hole it closes. A LEADING group is fine and
|
|
172
|
+
// the hole it closes. A LEADING group is fine and the stylesheet guards here rely on it:
|
|
173
173
|
// \`{apps,packages}/**/*.scss\` matches all 15.
|
|
174
174
|
for (const pattern of ['apps/*/{site,app}/**/*.tsx', 'packages/*/src/**/*.tsx']) {
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
if (
|
|
178
|
-
files.push({ path, source:
|
|
175
|
+
// The run's ONE read: every guard asking for these two globs shares the walk.
|
|
176
|
+
for (const file of await sources.files(pattern)) {
|
|
177
|
+
if (/\\.test\\.tsx?$/.test(file.path)) continue;
|
|
178
|
+
files.push({ path: file.path, source: file.text });
|
|
179
179
|
}
|
|
180
180
|
}
|
|
181
181
|
return semanticInteractive(files);
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// The `undeclared-custom-property` guard `x new` ships: a `var(--x)` a stylesheet reads is a
|
|
2
|
+
// property the app declares. Read off the COMPILED sheets, which is what makes `tokens.space(7)`
|
|
3
|
+
// — a step the scale never had — a finding: the reference app shipped `var(--z-header)`, a layer
|
|
4
|
+
// that does not exist, so its sticky header had no z-index at all.
|
|
5
|
+
|
|
6
|
+
import { guardCode } from './guard';
|
|
7
|
+
import type { GeneratedFile } from './naming';
|
|
8
|
+
|
|
9
|
+
/** Derived from the guard's name, never a literal: an `X_*` literal here is a FRAMEWORK code. */
|
|
10
|
+
const NAME = 'undeclared-custom-property';
|
|
11
|
+
const CODE = guardCode(NAME);
|
|
12
|
+
|
|
13
|
+
const source =
|
|
14
|
+
(): string => `// undeclared-custom-property: a \`var(--x)\` this app reads is a property something in it declares.
|
|
15
|
+
// \`x verify\` discovers every file in \`guards/\` and runs its \`guard\` inside the \`boundaries\`
|
|
16
|
+
// step — nothing registers this file, so nothing can forget to. Delete it to drop the rule.
|
|
17
|
+
//
|
|
18
|
+
// Read from the COMPILED sheets, so \`tokens.space(7)\` — a step the scale does not have — is the
|
|
19
|
+
// \`var(--space-7)\` it compiles to. Declared means: set by any stylesheet in the app (the global
|
|
20
|
+
// one emits every token), or named in a \`.ts\`/\`.tsx\` file, where an inline \`style\` sets it. A
|
|
21
|
+
// \`var(--x, fallback)\` declares its own answer and is never reported. While ANY sheet fails to
|
|
22
|
+
// compile the rule is silent: that sheet's declarations are unknown, and the build names it.
|
|
23
|
+
|
|
24
|
+
import type { Finding, Guard } from '@ultimat3/cli';
|
|
25
|
+
|
|
26
|
+
/** The app owns the codes its own conventions raise — this one is named for the guard. */
|
|
27
|
+
const CODE = '${CODE}';
|
|
28
|
+
|
|
29
|
+
export interface CompiledSheet {
|
|
30
|
+
/** App-root-relative POSIX path, so the finding names the file an author opens. */
|
|
31
|
+
readonly path: string;
|
|
32
|
+
/** The source, to find the line a compiled \`var()\` came from. */
|
|
33
|
+
readonly scss: string;
|
|
34
|
+
readonly css: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const DECLARED = /(--[A-Za-z_][\\w-]*)\\s*:/g;
|
|
38
|
+
const NAMED = /--[A-Za-z_][\\w-]*/g;
|
|
39
|
+
/** \`var(--x)\` with no fallback: the \`)\` straight after the name is what makes it a bare read. */
|
|
40
|
+
const READ = /var\\(\\s*(--[A-Za-z_][\\w-]*)\\s*\\)/g;
|
|
41
|
+
|
|
42
|
+
const lineOf = (text: string, index: number): number => text.slice(0, index).split('\\n').length;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The source line a compiled read came from. Written literally it is found as written; read
|
|
46
|
+
* through a token function it is found as that call — \`--space-7\` is \`space(7)\`, \`--color-bg\` is
|
|
47
|
+
* \`role('bg')\`. Line 1 when neither is there: a mixin three files away emitted it.
|
|
48
|
+
*/
|
|
49
|
+
const lineFor = (scss: string, name: string): number => {
|
|
50
|
+
const literal = scss.indexOf(name);
|
|
51
|
+
if (literal !== -1) return lineOf(scss, literal);
|
|
52
|
+
const parts = name.slice(2).split('-');
|
|
53
|
+
for (let split = 1; split < parts.length; split += 1) {
|
|
54
|
+
const fn = parts.slice(0, split).join('-');
|
|
55
|
+
const arg = parts.slice(split).join('-');
|
|
56
|
+
const call = new RegExp(\`(?:\${fn}|role)\\\\(\\\\s*['"]?\${arg}['"]?\\\\s*[,)]\`).exec(scss);
|
|
57
|
+
if (call !== null) return lineOf(scss, call.index);
|
|
58
|
+
}
|
|
59
|
+
return 1;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** The steps a scale that IS declared offers, for a read one step off it: \`--space-7\` → \`1 2 4\`. */
|
|
63
|
+
const siblings = (
|
|
64
|
+
name: string,
|
|
65
|
+
declared: ReadonlySet<string>,
|
|
66
|
+
): { readonly stem: string; readonly offered: readonly string[] } => {
|
|
67
|
+
const parts = name.slice(2).split('-');
|
|
68
|
+
for (let split = parts.length - 1; split >= 1; split -= 1) {
|
|
69
|
+
const stem = \`--\${parts.slice(0, split).join('-')}-\`;
|
|
70
|
+
const found = [...declared].filter((candidate) => candidate.startsWith(stem));
|
|
71
|
+
if (found.length > 0) {
|
|
72
|
+
return { stem, offered: found.map((candidate) => candidate.slice(stem.length)) };
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return { stem: '', offered: [] };
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Pure — the caller reads and compiles — so the rule is testable without a filesystem or Sass.
|
|
80
|
+
* \`sources\` is the text of every \`.ts\`/\`.tsx\` file: a property named there is one an inline
|
|
81
|
+
* \`style\` can set.
|
|
82
|
+
*/
|
|
83
|
+
export function undeclaredCustomProperties(
|
|
84
|
+
sheets: readonly CompiledSheet[],
|
|
85
|
+
sources: readonly string[],
|
|
86
|
+
): readonly Finding[] {
|
|
87
|
+
const declared = new Set<string>();
|
|
88
|
+
for (const sheet of sheets) {
|
|
89
|
+
for (const match of sheet.css.matchAll(DECLARED)) declared.add(match[1] ?? '');
|
|
90
|
+
}
|
|
91
|
+
for (const source of sources) {
|
|
92
|
+
for (const match of source.matchAll(NAMED)) declared.add(match[0]);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const findings: Finding[] = [];
|
|
96
|
+
for (const sheet of sheets) {
|
|
97
|
+
const reported = new Set<string>();
|
|
98
|
+
for (const match of sheet.css.matchAll(READ)) {
|
|
99
|
+
const name = match[1] ?? '';
|
|
100
|
+
if (declared.has(name) || reported.has(name)) continue;
|
|
101
|
+
reported.add(name);
|
|
102
|
+
const line = lineFor(sheet.scss, name);
|
|
103
|
+
const { stem, offered } = siblings(name, declared);
|
|
104
|
+
findings.push({
|
|
105
|
+
code: CODE,
|
|
106
|
+
cause: \`\${sheet.path}:\${line} reads var(\${name}), and nothing in this app declares \${name} — the declaration it sits in computes to nothing, silently\`,
|
|
107
|
+
fix:
|
|
108
|
+
offered.length > 0
|
|
109
|
+
? \`\${offered.join(' | ')} — the steps \${stem}* has; write one in place of \\\`\${name.slice(stem.length)}\\\` at \${sheet.path}:\${line}, then: x verify\`
|
|
110
|
+
: \`declare \${name} where it is set — on the element, in the app's global.scss, or for a colour, radius or font through defineTheme() — or correct the name at \${sheet.path}:\${line}, then: x verify\`,
|
|
111
|
+
at: \`\${sheet.path}:\${line}\`,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return findings;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export const guard: Guard = {
|
|
119
|
+
summary: 'a custom property a stylesheet reads is one the app declares',
|
|
120
|
+
async check(_root, sources) {
|
|
121
|
+
const sheets: CompiledSheet[] = [];
|
|
122
|
+
for (const file of await sources.files('{apps,packages}/**/*.scss')) {
|
|
123
|
+
// A partial is \`@use\`d into the sheets that are compiled here; alone it is not a stylesheet.
|
|
124
|
+
if ((file.path.split('/').pop() ?? '').startsWith('_')) continue;
|
|
125
|
+
const compiled = await sources.compiled(file.path);
|
|
126
|
+
// One sheet that does not compile and the declared set is incomplete: its declarations are
|
|
127
|
+
// unknown, so every read of one would be reported as undeclared. That sheet is the build's
|
|
128
|
+
// finding (X_PRERENDER_FAILED, with the Sass error); this rule waits for it to be repaired.
|
|
129
|
+
if (compiled === undefined) return [];
|
|
130
|
+
sheets.push({ path: file.path, scss: file.text, css: compiled.css });
|
|
131
|
+
}
|
|
132
|
+
const code = await sources.files('{apps,packages}/**/*.{ts,tsx}');
|
|
133
|
+
return undeclaredCustomProperties(
|
|
134
|
+
sheets,
|
|
135
|
+
code.map((file) => file.text),
|
|
136
|
+
);
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
`;
|
|
140
|
+
|
|
141
|
+
const test =
|
|
142
|
+
(): string => `// The rule, driven directly. Failure case first: a guard whose rule silently stopped matching is
|
|
143
|
+
// a green gate over the convention it was written to enforce.
|
|
144
|
+
|
|
145
|
+
import { expect, unitTest } from '@ultimat3/testing';
|
|
146
|
+
import { undeclaredCustomProperties } from './undeclared-custom-property';
|
|
147
|
+
|
|
148
|
+
/** What the app's global stylesheet compiles to: the tokens, declared once. */
|
|
149
|
+
const GLOBAL = {
|
|
150
|
+
path: 'apps/web/shared/global.scss',
|
|
151
|
+
scss: "@use '@ultimat3/ui/global.scss';\\n",
|
|
152
|
+
css: ':root{--space-1:.25rem;--space-2:.5rem;--space-4:1rem;--color-bg:253 246 240;--z-dialog:400}',
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const sheet = (scss: string, css: string) => ({
|
|
156
|
+
path: 'apps/web/site/page.module.scss',
|
|
157
|
+
scss,
|
|
158
|
+
css,
|
|
159
|
+
});
|
|
160
|
+
const check = (scss: string, css: string, sources: readonly string[] = []) =>
|
|
161
|
+
undeclaredCustomProperties([GLOBAL, sheet(scss, css)], sources);
|
|
162
|
+
|
|
163
|
+
unitTest('a step the scale does not have is refused where the token call is written', () => {
|
|
164
|
+
const findings = check(
|
|
165
|
+
"@use '@ultimat3/ui/tokens' as tokens;\\n.hero {\\n padding: tokens.space(7);\\n}\\n",
|
|
166
|
+
'.hero{padding:var(--space-7)}',
|
|
167
|
+
);
|
|
168
|
+
expect(findings).toHaveLength(1);
|
|
169
|
+
expect(findings[0]?.code).toBe('${CODE}');
|
|
170
|
+
expect(findings[0]?.at).toBe('apps/web/site/page.module.scss:3');
|
|
171
|
+
expect(findings[0]?.cause).toContain('nothing in this app declares --space-7');
|
|
172
|
+
expect(findings[0]?.fix).toStartWith(
|
|
173
|
+
'1 | 2 | 4 — the steps --space-* has; write one in place of \`7\` at ',
|
|
174
|
+
);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
unitTest('a property nobody declares is refused where it is written', () => {
|
|
178
|
+
const findings = check('.a {\\n color: var(--brand-ink);\\n}\\n', '.a{color:var(--brand-ink)}');
|
|
179
|
+
expect(findings).toHaveLength(1);
|
|
180
|
+
expect(findings[0]?.at).toBe('apps/web/site/page.module.scss:2');
|
|
181
|
+
expect(findings[0]?.fix).toStartWith('declare --brand-ink where it is set');
|
|
182
|
+
expect(findings[0]?.fix).toContain('defineTheme()');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
unitTest('one undeclared property read three times in a sheet is one finding', () => {
|
|
186
|
+
const css = '.a{color:var(--nope)}.b{color:var(--nope)}.c{color:var(--nope)}';
|
|
187
|
+
expect(check('.a { color: var(--nope); }', css)).toHaveLength(1);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// The legitimate lookalikes — the reason the rule can stay switched on.
|
|
191
|
+
unitTest('a token the global sheet declares is silent, however it is read', () => {
|
|
192
|
+
expect(check('.a { padding: tokens.space(4); }', '.a{padding:var(--space-4)}')).toEqual([]);
|
|
193
|
+
expect(check('.a { color: tokens.role(bg); }', '.a{color:rgb(var(--color-bg)/1)}')).toEqual([]);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
unitTest('a property the same or another module sets is declared', () => {
|
|
197
|
+
const css = '.tone{--tone:rgb(var(--color-bg)/1)}.a{color:var(--tone)}';
|
|
198
|
+
expect(check('.tone { --tone: x; } .a { color: var(--tone); }', css)).toEqual([]);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
unitTest('a property an inline style can set is declared', () => {
|
|
202
|
+
const tsx = "<div style={{ '--fill': percent }} />";
|
|
203
|
+
expect(check('.bar { width: var(--fill); }', '.bar{width:var(--fill)}', [tsx])).toEqual([]);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
unitTest('a read with a fallback declares its own answer', () => {
|
|
207
|
+
expect(check('.a { color: var(--maybe, inherit); }', '.a{color:var(--maybe, inherit)}')).toEqual(
|
|
208
|
+
[],
|
|
209
|
+
);
|
|
210
|
+
});
|
|
211
|
+
`;
|
|
212
|
+
|
|
213
|
+
/** `guards/undeclared-custom-property.ts` and its test. No index, no registry — the directory is the registration. */
|
|
214
|
+
export const undeclaredCustomPropertyGuardFiles = (): readonly GeneratedFile[] => [
|
|
215
|
+
{ path: `guards/${NAME}.ts`, contents: source() },
|
|
216
|
+
{ path: `guards/${NAME}.test.ts`, contents: test() },
|
|
217
|
+
];
|