@starci/hfs 4.13.2 → 4.16.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/CHANGELOG.md +22 -0
- package/codemod/main.d.mts +16 -0
- package/codemod/main.mjs +60 -0
- package/codemod/spacing-roles.d.mts +60 -0
- package/codemod/spacing-roles.mjs +198 -0
- package/lint/run.mjs +15 -6
- package/package.json +1 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +8 -8
- package/runtime/knowledge/hfs/rules.yaml +141 -2
- package/runtime/knowledge/hfs/slots.yaml +5 -1
- package/runtime/modules/kernel/failure-codes.yaml +5 -5
- package/runtime/scripts/hfs/declaration-shape.mjs +4 -2
- package/runtime/scripts/hfs/manifest-shape.d.mts +6 -0
- package/runtime/scripts/hfs/rule-params-shape.d.mts +5 -0
- package/runtime/scripts/hfs/rule-params-shape.mjs +7 -2
- package/runtime/scripts/hfs/slots.d.mts +2 -1
- package/runtime/scripts/hfs/slots.mjs +15 -5
- package/src/main.mjs +2 -2
- package/templates/be/skeleton-lite/src/modules/platform/database/database.client.spec.ts +25 -6
- package/templates/be/skeleton-lite/src/modules/platform/database/database.client.ts +13 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 4.16.0 - 2026-10-31
|
|
4
|
+
|
|
5
|
+
- Added: `starci app codemod spacing-roles [--execute] [--json] [--format json|markdown] [--out <file>]`: a dry run by default; plain flex column and row `div`s whose only spacing is one gap become `Stack` and `Cluster` bound from the file's Grammar import (gap-1 identity, gap-2 inline, gap-3 row, gap-4 section, gap-6 and gap-8 region, gap-8 marked for review); every other spacing utility is reported with its suggested role, and the report counts the utilities by family.
|
|
6
|
+
- Changed: `starci app lint` reports an ESLint warning (a rule of the canon's counting cohort) with severity `warning`, counts them per rule (`counts.warning`, `counting`) and keeps them out of the verdict and the error threshold.
|
|
7
|
+
- Changed: the canon pins name @starci/grammar, eslint-grammar-core and eslint-grammar-offset-pop 0.18.0, eslint-canon-fe 9.8.0, stylelint-canon 2.4.0 and hfs 4.16.0; R350 joins the bundled rule catalog.
|
|
8
|
+
|
|
9
|
+
## 4.15.0 - 2026-10-31
|
|
10
|
+
|
|
11
|
+
- Added: R345 `FE_PANE_GRID_CHILD_NOT_PANE`, R346 `FE_PANE_CHILD_NOT_SECTION`, R347 `FE_PANE_SECTION_PADDING` and R348 `FE_PANE_GRID_FRAME` (eslint-grammar-core `pane-grid-children`, `pane-children`, `no-pane-section-padding`, `pane-grid-page`) and R349 `FE_HAND_BUILT_PANES` (eslint-canon-fe `no-hand-built-panes`) in the bundled rule catalog.
|
|
12
|
+
- Changed: the bundled canon pins name the cohort hfs 4.15.0, eslint-canon-be 3.11.0, eslint-canon-fe 9.7.0, @starci/grammar, eslint-grammar-core and eslint-grammar-offset-pop 0.17.0.
|
|
13
|
+
|
|
14
|
+
## 4.14.0 - 2026-10-31
|
|
15
|
+
|
|
16
|
+
- Added: `ruleParams.be.sqlPassThrough` and hfs.json `sides.be.sqlPassThrough` (a list of `{file, reason}`, side-relative forward-slash paths, unique): the files whose `.query(` passes through SQL text that is not the app's own (R334). The shape validators (`manifest-shape`, `rule-params-shape`, `declaration-shape`) accept it on the be side only, the resolver merges the app's list into the canon's like `dbFunctions`, and the canon lists the seed runner `src/modules/platform/database/seed-runner.service.ts`.
|
|
17
|
+
- Changed: R334 `BE_PERSISTENCE_NO_RAW_SQL` is one style: lite TypeScript holds no SQL text outside the test world (entities, or a listed function through `callDbFunction`; the world keeps raw SQL for probes only), the failure code text follows, R68 names the call and LISTEN statements, and the `be.tests.world` slot allows `<name>.sql.ts`.
|
|
18
|
+
- Changed: the lite skeleton's `platform/database/database.client.ts` writes no literal `.query(` but the call template: `advisoryXactLock` is `callDbFunction` of `hashtextextended(key, 0)` then `pg_advisory_xact_lock(hash)`, and `notify` is `callDbFunction` of `pg_notify`; its spec follows.
|
|
19
|
+
- Changed: the bundled canon pins name the cohort hfs 4.14.0, eslint-canon-be 3.11.0, eslint-canon-fe 9.6.7, @starci/grammar, eslint-grammar-core and eslint-grammar-offset-pop 0.16.6.
|
|
20
|
+
|
|
21
|
+
## 4.13.3 - 2026-10-31
|
|
22
|
+
|
|
23
|
+
- Changed: the canon pins name @starci/ai-router 0.5.0 (many providers behind one virtual key, the persisted key blacklist) and the cohort that carries them: hfs 4.13.3, eslint-canon-be 3.10.1, eslint-canon-fe 9.6.6, eslint-grammar-core and eslint-grammar-offset-pop 0.16.6; no rule changed.
|
|
24
|
+
|
|
3
25
|
## 4.13.2 - 2026-10-31
|
|
4
26
|
|
|
5
27
|
- Added: R344 `BE_AI_VIRTUAL_KEY` in the bundled rule catalog and on the `be.integrations` slot: an app's ai-router gateway has one client key, `STARCI_AI_KEY`, read by `integrations/ai`, and no admin token; `@starci/eslint-canon-be` `ai-virtual-key-name` enforces it (ruling be-ai-one-virtual-key).
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** The codemods `starci app codemod` runs, by name. */
|
|
2
|
+
export declare const CODEMODS: readonly string[];
|
|
3
|
+
/** The arguments of `starci app codemod spacing-roles`: --execute, --json, --format and --out; any other is refused. */
|
|
4
|
+
export declare function spacingRolesArguments(argv: readonly string[]): {
|
|
5
|
+
write: boolean;
|
|
6
|
+
format: 'json' | 'markdown';
|
|
7
|
+
out?: string;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* CLI boundary of `starci app codemod <name>`. Dry run by default; --execute writes only the safe rewrites. A codemod
|
|
11
|
+
* that leaves a case for a person exits 1 with the complete report.
|
|
12
|
+
*/
|
|
13
|
+
export declare function codemodMain(argv: string[], io: {
|
|
14
|
+
cwd: string;
|
|
15
|
+
stdout: (text: string) => unknown;
|
|
16
|
+
}): number;
|
package/codemod/main.mjs
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { codemodMain as buttonIntentMain } from './button-intent.mjs';
|
|
4
|
+
import { migrateSpacingRoles, spacingRolesMarkdown } from './spacing-roles.mjs';
|
|
5
|
+
/** The codemods `starci app codemod` runs, by name. */
|
|
6
|
+
export const CODEMODS = Object.freeze(['button-intent', 'spacing-roles']);
|
|
7
|
+
/** The arguments of `starci app codemod spacing-roles`: --execute, --json, --format and --out; any other is refused. */
|
|
8
|
+
export function spacingRolesArguments(argv) {
|
|
9
|
+
const options = { write: false, format: 'markdown' };
|
|
10
|
+
const args = argv.values();
|
|
11
|
+
for (const arg of args) {
|
|
12
|
+
const [flag, inline] = arg.split(/=(.*)/s);
|
|
13
|
+
if (flag === '--execute' || flag === '--json') {
|
|
14
|
+
if (inline !== undefined && !['true', 'false'].includes(inline))
|
|
15
|
+
throw new Error(`${flag} expects true or false`);
|
|
16
|
+
const value = inline === undefined || inline === 'true';
|
|
17
|
+
if (flag === '--execute')
|
|
18
|
+
options.write = value;
|
|
19
|
+
else if (value)
|
|
20
|
+
options.format = 'json';
|
|
21
|
+
}
|
|
22
|
+
else if (flag === '--format' || flag === '--out') {
|
|
23
|
+
const value = inline ?? args.next().value;
|
|
24
|
+
if (!value || value.startsWith('--'))
|
|
25
|
+
throw new Error(`${flag} needs a value`);
|
|
26
|
+
if (flag === '--out')
|
|
27
|
+
options.out = value;
|
|
28
|
+
else if (value === 'json' || value === 'markdown')
|
|
29
|
+
options.format = value;
|
|
30
|
+
else
|
|
31
|
+
throw new Error('--format accepts json or markdown');
|
|
32
|
+
}
|
|
33
|
+
else
|
|
34
|
+
throw new Error(`unknown codemod option ${arg}`);
|
|
35
|
+
}
|
|
36
|
+
return options;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* CLI boundary of `starci app codemod <name>`. Dry run by default; --execute writes only the safe rewrites. A codemod
|
|
40
|
+
* that leaves a case for a person exits 1 with the complete report.
|
|
41
|
+
*/
|
|
42
|
+
export function codemodMain(argv, io) {
|
|
43
|
+
const [name, ...rest] = argv;
|
|
44
|
+
if (name === 'button-intent')
|
|
45
|
+
return buttonIntentMain(argv, io);
|
|
46
|
+
if (name !== 'spacing-roles')
|
|
47
|
+
throw new Error(`starci app codemod takes ${CODEMODS.join(' or ')}`);
|
|
48
|
+
const options = spacingRolesArguments(rest);
|
|
49
|
+
if (options.out && !/\.(?:json|md)$/i.test(options.out))
|
|
50
|
+
throw new Error('--out needs a .json or .md report file');
|
|
51
|
+
const report = migrateSpacingRoles({ root: path.resolve(io.cwd), write: options.write });
|
|
52
|
+
const output = options.format === 'json' ? `${JSON.stringify(report, null, 2)}\n` : spacingRolesMarkdown(report);
|
|
53
|
+
if (options.out) {
|
|
54
|
+
const target = path.resolve(io.cwd, options.out);
|
|
55
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
56
|
+
fs.writeFileSync(target, output);
|
|
57
|
+
}
|
|
58
|
+
io.stdout(output);
|
|
59
|
+
return report.findings.length ? 1 : 0;
|
|
60
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/** The TypeScript compiler API of the app's own install (loadTypescript), typed by the version this package develops against. */
|
|
2
|
+
type Ts = typeof import('typescript');
|
|
3
|
+
/** One rewrite of the report: a layout element whose classes were only a flex column or row and its gap. */
|
|
4
|
+
export interface SpacingChange {
|
|
5
|
+
file: string;
|
|
6
|
+
line: number;
|
|
7
|
+
column: number;
|
|
8
|
+
from: string;
|
|
9
|
+
to: string;
|
|
10
|
+
review?: string;
|
|
11
|
+
}
|
|
12
|
+
/** One spacing utility the codemod leaves for a person, with the role it suggests when it has one. */
|
|
13
|
+
export interface SpacingFinding {
|
|
14
|
+
file: string;
|
|
15
|
+
line: number;
|
|
16
|
+
column: number;
|
|
17
|
+
code: string;
|
|
18
|
+
utility: string;
|
|
19
|
+
suggestion: string;
|
|
20
|
+
reason: string;
|
|
21
|
+
}
|
|
22
|
+
/** The report of a migration (`starci app codemod spacing-roles`). */
|
|
23
|
+
export interface SpacingReport {
|
|
24
|
+
schema: string;
|
|
25
|
+
root: string;
|
|
26
|
+
write: boolean;
|
|
27
|
+
filesScanned: number;
|
|
28
|
+
filesChanged: string[];
|
|
29
|
+
/** Every spacing utility of the scanned source by family (gap, padding, margin, space), before the rewrite. */
|
|
30
|
+
counts: Record<string, number>;
|
|
31
|
+
changes: SpacingChange[];
|
|
32
|
+
findings: SpacingFinding[];
|
|
33
|
+
}
|
|
34
|
+
/** The role each Tailwind gap step names (the doctrine's mapping); `review` marks a step whose role a person confirms. */
|
|
35
|
+
export declare const GAP_ROLES: Readonly<Record<string, {
|
|
36
|
+
role: string;
|
|
37
|
+
review?: string;
|
|
38
|
+
}>>;
|
|
39
|
+
/** The spacing utilities of one class string. */
|
|
40
|
+
export declare const spacingUtilities: (text: string) => string[];
|
|
41
|
+
/** Rewrite one source text: plain `div` flex columns and rows whose only spacing is one gap become Stack and Cluster. */
|
|
42
|
+
export declare function transformSpacingRoles({ ts, text, file }: {
|
|
43
|
+
ts: Ts;
|
|
44
|
+
text: string;
|
|
45
|
+
file?: string;
|
|
46
|
+
}): {
|
|
47
|
+
text: string;
|
|
48
|
+
changes: SpacingChange[];
|
|
49
|
+
findings: SpacingFinding[];
|
|
50
|
+
counts: Record<string, number>;
|
|
51
|
+
};
|
|
52
|
+
/** Preview or apply the safe rewrites in one app's FE tree; every utility left is in the report with its suggested role. */
|
|
53
|
+
export declare function migrateSpacingRoles({ root, write, ts }: {
|
|
54
|
+
root: string;
|
|
55
|
+
write?: boolean;
|
|
56
|
+
ts?: Ts | null;
|
|
57
|
+
}): SpacingReport;
|
|
58
|
+
/** Render the migration and the utilities left for a person as Markdown; JSON consumers read the same report object. */
|
|
59
|
+
export declare function spacingRolesMarkdown(report: SpacingReport): string;
|
|
60
|
+
export {};
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { loadTypescript } from '../runtime/scripts/lib/package-at.mjs';
|
|
5
|
+
import { sourceLocation } from '../runtime/scripts/lib/ts-ast.mjs';
|
|
6
|
+
const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
7
|
+
const GRAMMAR = /^@starci\/grammar(?:\/(?:core|common|offset-pop))?$/;
|
|
8
|
+
const GENERATED = new Set(['node_modules', '.next', '.turbo', 'dist', 'coverage', '.git']);
|
|
9
|
+
const SPACING_UTILITY = /^-?(p|px|py|pt|pb|ps|pe|pl|pr|m|mx|my|mt|mb|ms|me|ml|mr|gap|gap-x|gap-y|space-x|space-y)-(.+)$/;
|
|
10
|
+
/** The role each Tailwind gap step names (the doctrine's mapping); `review` marks a step whose role a person confirms. */
|
|
11
|
+
export const GAP_ROLES = Object.freeze({
|
|
12
|
+
'1': { role: 'identity' },
|
|
13
|
+
'2': { role: 'inline' },
|
|
14
|
+
'3': { role: 'row' },
|
|
15
|
+
'4': { role: 'section' },
|
|
16
|
+
'6': { role: 'region' },
|
|
17
|
+
'8': { role: 'region', review: 'gap-8 and gap-6 both become region; confirm the two blocks are page regions with their own heading' },
|
|
18
|
+
});
|
|
19
|
+
/** Classes a Stack or Cluster already draws, so an element holding only these and one gap becomes the primitive. */
|
|
20
|
+
const STACK_CLASSES = new Set(['flex', 'flex-col', 'min-w-0']);
|
|
21
|
+
const CLUSTER_CLASSES = new Set(['flex', 'flex-row', 'flex-wrap', 'items-center', 'min-w-0']);
|
|
22
|
+
const family = (utility) => {
|
|
23
|
+
const name = SPACING_UTILITY.exec(utility.replace(/^!|!$/g, ''))?.[1] ?? '';
|
|
24
|
+
return name.startsWith('gap') ? 'gap' : name.startsWith('space') ? 'space' : name.startsWith('p') ? 'padding' : 'margin';
|
|
25
|
+
};
|
|
26
|
+
/** The utility once its variants are dropped (`sm:gap-2` is `gap-2`); a variant marks a responsive or state choice. */
|
|
27
|
+
const baseOf = (cls) => cls.slice(cls.lastIndexOf(':') + 1);
|
|
28
|
+
/** The spacing utilities of one class string. */
|
|
29
|
+
export const spacingUtilities = (text) => text.split(/\s+/).filter((cls) => SPACING_UTILITY.test(baseOf(cls).replace(/^!|!$/g, '')));
|
|
30
|
+
/** What a person writes instead of one utility the codemod does not rewrite. */
|
|
31
|
+
const suggestionFor = (utility) => {
|
|
32
|
+
const base = baseOf(utility);
|
|
33
|
+
const kind = family(base);
|
|
34
|
+
if (kind === 'gap' || kind === 'space') {
|
|
35
|
+
const step = /-(\d+)$/.exec(base)?.[1] ?? '';
|
|
36
|
+
const mapped = GAP_ROLES[step];
|
|
37
|
+
return mapped ? `gap="${mapped.role}" on a Stack or Cluster` : 'the role of the relation on a Stack, Cluster or Grid';
|
|
38
|
+
}
|
|
39
|
+
return kind === 'margin' ? "the parent's gap role" : 'the inset of the Grammar box that holds the content';
|
|
40
|
+
};
|
|
41
|
+
/** The Grammar import that binds Stack and Cluster in a file: the family specifier it already imports. */
|
|
42
|
+
function grammarImport(ts, source) {
|
|
43
|
+
for (const node of source.statements) {
|
|
44
|
+
if (!ts.isImportDeclaration(node) || !ts.isStringLiteral(node.moduleSpecifier) || !GRAMMAR.test(node.moduleSpecifier.text))
|
|
45
|
+
continue;
|
|
46
|
+
const bindings = node.importClause?.namedBindings;
|
|
47
|
+
if (bindings === undefined || !ts.isNamedImports(bindings) || node.importClause?.isTypeOnly)
|
|
48
|
+
continue;
|
|
49
|
+
return { specifier: node.moduleSpecifier.text, node, names: new Set(bindings.elements.map((element) => element.name.text)) };
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
/** The static class string of an element, or null when it is computed. */
|
|
54
|
+
function classText(ts, attribute) {
|
|
55
|
+
const value = attribute?.initializer;
|
|
56
|
+
if (value === undefined)
|
|
57
|
+
return null;
|
|
58
|
+
if (ts.isStringLiteral(value))
|
|
59
|
+
return value.text;
|
|
60
|
+
if (ts.isJsxExpression(value) && value.expression !== undefined && ts.isStringLiteralLike(value.expression))
|
|
61
|
+
return value.expression.text;
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
/** The primitive and its props an element's classes ask for, or why it stays for a person. */
|
|
65
|
+
function primitiveFor(classes) {
|
|
66
|
+
const gaps = classes.filter((cls) => /^gap-\d+$/.test(cls));
|
|
67
|
+
if (gaps.length !== 1)
|
|
68
|
+
return { reason: 'the element holds no single plain gap utility' };
|
|
69
|
+
const step = (gaps[0] ?? '').slice(4);
|
|
70
|
+
const mapped = GAP_ROLES[step];
|
|
71
|
+
if (mapped === undefined)
|
|
72
|
+
return { reason: `gap-${step} names no role; choose the relation` };
|
|
73
|
+
const rest = classes.filter((cls) => cls !== gaps[0]);
|
|
74
|
+
const review = mapped.review === undefined ? {} : { review: mapped.review };
|
|
75
|
+
if (rest.includes('flex-col') && rest.every((cls) => STACK_CLASSES.has(cls)))
|
|
76
|
+
return { tag: 'Stack', props: `gap="${mapped.role}"`, ...review };
|
|
77
|
+
if (rest.includes('flex') && !rest.includes('flex-col') && rest.every((cls) => CLUSTER_CLASSES.has(cls))) {
|
|
78
|
+
if (mapped.role === 'region')
|
|
79
|
+
return { reason: 'a cluster never holds regions; a region gap belongs to a Stack or a Grid' };
|
|
80
|
+
return { tag: 'Cluster', props: `gap="${mapped.role}"${rest.includes('flex-wrap') ? '' : ' wrap={false}'}`, ...review };
|
|
81
|
+
}
|
|
82
|
+
return { reason: 'the element carries layout classes a Stack or Cluster does not draw; move them to a Grammar API first' };
|
|
83
|
+
}
|
|
84
|
+
/** Rewrite one source text: plain `div` flex columns and rows whose only spacing is one gap become Stack and Cluster. */
|
|
85
|
+
export function transformSpacingRoles({ ts, text, file = 'component.tsx' }) {
|
|
86
|
+
const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, /\.jsx$/i.test(file) ? ts.ScriptKind.JSX : ts.ScriptKind.TSX);
|
|
87
|
+
const changes = [];
|
|
88
|
+
const findings = [];
|
|
89
|
+
const counts = {};
|
|
90
|
+
const edits = [];
|
|
91
|
+
const needed = new Set();
|
|
92
|
+
const imported = grammarImport(ts, source);
|
|
93
|
+
const leave = (node, utility, code, reason) => {
|
|
94
|
+
findings.push({ file, ...sourceLocation(source, node), code, utility, suggestion: suggestionFor(utility), reason });
|
|
95
|
+
};
|
|
96
|
+
const visitString = (node) => {
|
|
97
|
+
for (const utility of spacingUtilities(node.text)) {
|
|
98
|
+
counts[family(baseOf(utility))] = (counts[family(baseOf(utility))] ?? 0) + 1;
|
|
99
|
+
leave(node, utility, 'class-string', 'a class string outside a plain layout element; name the relation where it is used');
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
const visitElement = (opening, element) => {
|
|
103
|
+
const attributes = opening.attributes.properties;
|
|
104
|
+
const classAttribute = attributes.find((entry) => ts.isJsxAttribute(entry) && entry.name.getText(source) === 'className');
|
|
105
|
+
const text = classText(ts, classAttribute);
|
|
106
|
+
if (text === null)
|
|
107
|
+
return;
|
|
108
|
+
const utilities = spacingUtilities(text);
|
|
109
|
+
for (const utility of utilities)
|
|
110
|
+
counts[family(baseOf(utility))] = (counts[family(baseOf(utility))] ?? 0) + 1;
|
|
111
|
+
if (utilities.length === 0)
|
|
112
|
+
return;
|
|
113
|
+
const classes = text.split(/\s+/).filter(Boolean);
|
|
114
|
+
const others = attributes.filter((entry) => entry !== classAttribute && !(ts.isJsxAttribute(entry) && entry.name.getText(source) === 'key'));
|
|
115
|
+
const isDiv = opening.tagName.getText(source) === 'div';
|
|
116
|
+
const target = primitiveFor(classes);
|
|
117
|
+
const reason = !isDiv ? 'only a plain div becomes a layout primitive' : others.length > 0 ? 'the element carries props a layout primitive does not take' : imported === null ? 'the file imports no Grammar family to bind the primitive from' : 'reason' in target ? target.reason : null;
|
|
118
|
+
if (reason !== null || 'reason' in target) {
|
|
119
|
+
for (const utility of utilities)
|
|
120
|
+
leave(opening, utility, 'layout-element', reason ?? 'the element stays for a person');
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
needed.add(target.tag);
|
|
124
|
+
const key = attributes.find((entry) => ts.isJsxAttribute(entry) && entry.name.getText(source) === 'key');
|
|
125
|
+
const props = `${target.tag} ${target.props}${key === undefined ? '' : ` ${key.getText(source)}`}`;
|
|
126
|
+
edits.push({ start: opening.getStart(source), end: opening.end, replacement: ts.isJsxSelfClosingElement(opening) ? `<${props} />` : `<${props}>` });
|
|
127
|
+
if (ts.isJsxElement(element))
|
|
128
|
+
edits.push({ start: element.closingElement.getStart(source), end: element.closingElement.end, replacement: `</${target.tag}>` });
|
|
129
|
+
changes.push({ file, ...sourceLocation(source, opening), from: text, to: `<${target.tag} ${target.props}>`, ...(target.review === undefined ? {} : { review: target.review }) });
|
|
130
|
+
};
|
|
131
|
+
const visit = (node) => {
|
|
132
|
+
if (ts.isJsxElement(node))
|
|
133
|
+
visitElement(node.openingElement, node);
|
|
134
|
+
else if (ts.isJsxSelfClosingElement(node))
|
|
135
|
+
visitElement(node, node);
|
|
136
|
+
else if ((ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) && !ts.isJsxAttribute(node.parent) && !ts.isImportDeclaration(node.parent) && !(ts.isJsxExpression(node.parent) && ts.isJsxAttribute(node.parent.parent)))
|
|
137
|
+
visitString(node);
|
|
138
|
+
ts.forEachChild(node, visit);
|
|
139
|
+
};
|
|
140
|
+
visit(source);
|
|
141
|
+
if (imported !== null) {
|
|
142
|
+
const missing = [...needed].filter((name) => !imported.names.has(name)).sort();
|
|
143
|
+
const bindings = imported.node.importClause?.namedBindings;
|
|
144
|
+
if (missing.length > 0 && bindings !== undefined && ts.isNamedImports(bindings)) {
|
|
145
|
+
const names = [...bindings.elements.map((element) => element.getText(source)), ...missing];
|
|
146
|
+
edits.push({ start: bindings.getStart(source), end: bindings.end, replacement: `{ ${names.join(', ')} }` });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
let output = text;
|
|
150
|
+
for (const edit of edits.sort((first, second) => second.start - first.start))
|
|
151
|
+
output = output.slice(0, edit.start) + edit.replacement + output.slice(edit.end);
|
|
152
|
+
return { text: output, changes, findings, counts };
|
|
153
|
+
}
|
|
154
|
+
function sourceFiles(directory) {
|
|
155
|
+
return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
|
156
|
+
const target = path.join(directory, entry.name);
|
|
157
|
+
if (entry.isDirectory())
|
|
158
|
+
return GENERATED.has(entry.name) ? [] : sourceFiles(target);
|
|
159
|
+
return entry.isFile() && /\.[jt]sx?$/i.test(entry.name) && !/\.(?:spec|test|d)\.[jt]sx?$/i.test(entry.name) ? [target] : [];
|
|
160
|
+
}).sort();
|
|
161
|
+
}
|
|
162
|
+
/** Preview or apply the safe rewrites in one app's FE tree; every utility left is in the report with its suggested role. */
|
|
163
|
+
export function migrateSpacingRoles({ root, write = false, ts = loadTypescript(root, PACKAGE_ROOT) }) {
|
|
164
|
+
if (!ts)
|
|
165
|
+
throw new Error('spacing-roles needs TypeScript installed in the app or @starci/hfs development checkout');
|
|
166
|
+
const frontend = path.join(root, 'fe');
|
|
167
|
+
if (!fs.statSync(frontend, { throwIfNoEntry: false })?.isDirectory())
|
|
168
|
+
throw new Error('spacing-roles runs at an app root with a fe/ directory');
|
|
169
|
+
const report = { schema: 'starci/spacing-roles-codemod@1', root, write, filesScanned: 0, filesChanged: [], counts: {}, changes: [], findings: [] };
|
|
170
|
+
for (const target of sourceFiles(frontend)) {
|
|
171
|
+
const file = path.relative(root, target).split(path.sep).join('/');
|
|
172
|
+
const original = fs.readFileSync(target, 'utf8');
|
|
173
|
+
const result = transformSpacingRoles({ ts, text: original, file });
|
|
174
|
+
report.filesScanned += 1;
|
|
175
|
+
report.changes.push(...result.changes);
|
|
176
|
+
report.findings.push(...result.findings);
|
|
177
|
+
for (const [name, count] of Object.entries(result.counts))
|
|
178
|
+
report.counts[name] = (report.counts[name] ?? 0) + count;
|
|
179
|
+
if (result.text !== original) {
|
|
180
|
+
report.filesChanged.push(file);
|
|
181
|
+
if (write)
|
|
182
|
+
fs.writeFileSync(target, result.text);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return report;
|
|
186
|
+
}
|
|
187
|
+
const cell = (value) => String(value).replaceAll('|', '\\|').replaceAll('\n', ' ');
|
|
188
|
+
/** Render the migration and the utilities left for a person as Markdown; JSON consumers read the same report object. */
|
|
189
|
+
export function spacingRolesMarkdown(report) {
|
|
190
|
+
const counts = Object.entries(report.counts).sort().map(([name, count]) => `${name} ${count}`).join(', ') || 'none';
|
|
191
|
+
return ['# Spacing roles migration', '',
|
|
192
|
+
`${report.write ? 'Applied' : 'Dry run'}: ${report.changes.length} rewrites in ${report.filesChanged.length} files of ${report.filesScanned}; ${report.findings.length} utilities left for a person.`, '',
|
|
193
|
+
`Spacing utilities before: ${counts}.`, '',
|
|
194
|
+
'## Rewrites', '', '| Location | Classes | Becomes | Review |', '| --- | --- | --- | --- |',
|
|
195
|
+
...report.changes.map((change) => `| ${cell(`${change.file}:${change.line}:${change.column}`)} | ${cell(change.from)} | ${cell(change.to)} | ${cell(change.review ?? '')} |`), '',
|
|
196
|
+
'## Left for a person', '', '| Location | Utility | Suggestion | Reason |', '| --- | --- | --- | --- |',
|
|
197
|
+
...report.findings.map((finding) => `| ${cell(`${finding.file}:${finding.line}:${finding.column}`)} | ${cell(finding.utility)} | ${cell(finding.suggestion)} | ${cell(finding.reason)} |`), ''].join('\n');
|
|
198
|
+
}
|
package/lint/run.mjs
CHANGED
|
@@ -168,7 +168,7 @@ export async function runLinter({ cwd, pkg, bin, args, bound = null }) {
|
|
|
168
168
|
return { results, ...(completed ? {} : { error: `${pkg} could not complete in ${path.basename(cwd)}/ (exit ${run.status}, signal ${run.signal ?? 'none'}): ${String(run.error?.message || run.stderr || run.stdout || '').trim().split('\n')[0]}` }) };
|
|
169
169
|
}
|
|
170
170
|
const eslintFindings = (results, appRoot) => results.flatMap((result) => (result.messages ?? []).map((message) => ({
|
|
171
|
-
engine: 'eslint', rule: message.ruleId ?? 'eslint-error', code: CODE_PREFIX.exec(message.message ?? '')?.[1] ?? null, severity: 'error',
|
|
171
|
+
engine: 'eslint', rule: message.ruleId ?? 'eslint-error', code: CODE_PREFIX.exec(message.message ?? '')?.[1] ?? null, severity: message.severity === 1 ? 'warning' : 'error',
|
|
172
172
|
path: posix(path.relative(appRoot, result.filePath)), line: message.line ?? null, column: message.column ?? null, message: message.message,
|
|
173
173
|
})));
|
|
174
174
|
const stylelintFindings = (results, appRoot) => results.flatMap((result) => (result.warnings ?? []).map((warning) => ({
|
|
@@ -676,18 +676,27 @@ export async function lintRepository({ repoRoot, opts, hfsCheck, trackedFiles =
|
|
|
676
676
|
linterReport('eslint', raw.eslint, { root: repoRoot, sourceRoots, tracked }),
|
|
677
677
|
linterReport('stylelint', raw.stylelint, { root: repoRoot, sourceRoots, tracked }),
|
|
678
678
|
]);
|
|
679
|
-
// Green under the threshold (gate-threshold.mjs): every finding is still reported, only the verdict changes.
|
|
680
|
-
|
|
681
|
-
const
|
|
679
|
+
// Green under the threshold (gate-threshold.mjs): every finding is still reported, only the verdict changes. A warning is a
|
|
680
|
+
// rule of the canon's counting cohort (eslint-canon-fe COUNTING): reported and counted per rule, never part of the verdict.
|
|
681
|
+
const failing = findings.filter((finding) => finding.severity !== 'warning');
|
|
682
|
+
const counting = {};
|
|
683
|
+
for (const finding of findings)
|
|
684
|
+
if (finding.severity === 'warning')
|
|
685
|
+
counting[finding.rule] = (counting[finding.rule] ?? 0) + 1;
|
|
686
|
+
const gate = gateVerdict(failing);
|
|
687
|
+
const report = { schema: LINT_SCHEMA, ok: gate.pass && errors.length === 0, repoRoot, changed: changed ? [...changed].sort(byCodeUnit) : null, workspace, ...(opts.side === undefined ? {} : { side: opts.side }), counts: { error: failing.length, warning: findings.length - failing.length }, counting, engines, errors, findings, gate };
|
|
682
688
|
return { report, sonar, exit: errors.length ? 2 : gate.pass ? 0 : 1 };
|
|
683
689
|
}
|
|
684
690
|
export function printLintText(report, out) {
|
|
685
691
|
for (const f of report.findings)
|
|
686
|
-
out(`${f.path ?? '-'}${f.line ? `:${f.line}${f.column ? `:${f.column}` : ''}` : ''} ${f.engine}/${f.rule} ${f.message}\n`);
|
|
692
|
+
out(`${f.path ?? '-'}${f.line ? `:${f.line}${f.column ? `:${f.column}` : ''}` : ''} ${f.severity === 'warning' ? 'warn ' : ''}${f.engine}/${f.rule} ${f.message}\n`);
|
|
687
693
|
for (const e of report.errors)
|
|
688
694
|
out(`ERROR ${e}\n`);
|
|
689
|
-
const per = Object.keys(report.engines).map((engine) => `${engine} ${report.findings.filter((f) => f.engine === engine).length}`).join(', ');
|
|
695
|
+
const per = Object.keys(report.engines).map((engine) => `${engine} ${report.findings.filter((f) => f.engine === engine && f.severity !== 'warning').length}`).join(', ');
|
|
690
696
|
out(`\nstarci app lint: ${report.counts.error} finding${report.counts.error === 1 ? '' : 's'} (${per})${report.errors.length ? `, ${report.errors.length} tool error${report.errors.length === 1 ? '' : 's'}` : ''}\n`);
|
|
697
|
+
const counting = Object.entries((report.counting ?? {})).sort(([a], [b]) => a.localeCompare(b));
|
|
698
|
+
if (counting.length)
|
|
699
|
+
out(`counting (warn, not in the verdict): ${counting.map(([rule, count]) => `${rule} ${count}`).join(', ')}\n`);
|
|
691
700
|
if (report.gate?.line)
|
|
692
701
|
out(`${report.gate.line}\n`);
|
|
693
702
|
}
|
package/package.json
CHANGED
|
@@ -31,42 +31,42 @@ pins:
|
|
|
31
31
|
source: packages/event-stream/package.json
|
|
32
32
|
why: 'the one server-sent-events client of every front end (fe-stack event-stream, owner:event-stream-package): the transport client layer (modules/api/sse/clients) creates one instance for the app with the bearer token source and the stream operations follow on it; it carries Last-Event-ID resume, the capped doubling backoff, the heartbeat timeout, the single 401 renewal and the useSWRSubscription adapter, so no app keeps a hand-written reader.'
|
|
33
33
|
'@starci/grammar':
|
|
34
|
-
version: 0.
|
|
34
|
+
version: 0.18.0
|
|
35
35
|
group: starci
|
|
36
36
|
install: registry
|
|
37
37
|
side: fe
|
|
38
38
|
source: packages/grammar/package.json
|
|
39
39
|
why: 'the grammar front ends read: the brand layer sets `--font-sans` and `--font-mono` and the grammar reads them; it carries the Input tel kind, the copyable and read-only Input, the IconButton disclosure props and count badge, ChatBubble, FillViewport, Textarea mentions and Enter-to-send, the leading PrimaryRailLayout rail, the plate-less Image, ListRow, the soft Alert, Progress steps, the floating primary IconButton, FlowGraph, the STATE-8 loading views and the bordered selectable TagGroup chips. The runtime source version wins for a @starci package.'
|
|
40
40
|
'@starci/eslint-canon-be':
|
|
41
|
-
version: 3.
|
|
41
|
+
version: 3.11.1
|
|
42
42
|
group: starci
|
|
43
43
|
install: registry
|
|
44
44
|
side: be
|
|
45
45
|
source: packages/eslint-canon-be/package.json
|
|
46
46
|
why: 'the back-end ESLint canon: `loadHfs(import.meta.url)` of be/eslint.config.mjs finds the app-root hfs.json (kind app) and gives every linted file the view of the be side, and the project graph is built per side; its bundled runtime copies (slots, canon-pins, failure codes, the architecture machine) put .starcistacks and .sops.yaml at the app root; `starciBeConfig({ hfs: loadHfs(import.meta.url) })` is the typed factory and the BE-CONVENTION laws are its rules.'
|
|
47
47
|
'@starci/eslint-canon-fe':
|
|
48
|
-
version: 9.
|
|
48
|
+
version: 9.8.0
|
|
49
49
|
group: starci
|
|
50
50
|
install: registry
|
|
51
51
|
side: fe
|
|
52
52
|
source: packages/eslint-canon-fe/package.json
|
|
53
53
|
why: 'the front-end ESLint canon: `loadHfs(import.meta.url)` of fe/eslint.config.mjs finds the app-root hfs.json (kind app) and gives every linted file the view of the fe side, and the project graph is built per side; its bundled runtime copies (slots, canon-pins, failure codes, the architecture machine) put .starcistacks and .sops.yaml at the app root; the front end has no tests (FE_NO_TESTS R97 in hfs) and no-vietnamese-in-source (R91) applies.'
|
|
54
54
|
'@starci/eslint-grammar-core':
|
|
55
|
-
version: 0.
|
|
55
|
+
version: 0.18.0
|
|
56
56
|
group: starci
|
|
57
57
|
install: registry
|
|
58
58
|
side: fe
|
|
59
59
|
source: packages/eslint-grammar-core/package.json
|
|
60
60
|
why: 'the StarCi Grammar component API lint, versioned with @starci/grammar; front ends compose this family package with the universal FE canon.'
|
|
61
61
|
'@starci/eslint-grammar-offset-pop':
|
|
62
|
-
version: 0.
|
|
62
|
+
version: 0.18.0
|
|
63
63
|
group: starci
|
|
64
64
|
install: registry
|
|
65
65
|
side: fe
|
|
66
66
|
source: packages/eslint-grammar-offset-pop/package.json
|
|
67
67
|
why: 'the Offset Pop Grammar component API lint, versioned with @starci/grammar; its shared component contract comes from the StarCi family lint without copying rules.'
|
|
68
68
|
'@starci/stylelint-canon':
|
|
69
|
-
version: 2.
|
|
69
|
+
version: 2.4.0
|
|
70
70
|
group: starci
|
|
71
71
|
install: registry
|
|
72
72
|
side: fe
|
|
@@ -99,14 +99,14 @@ pins:
|
|
|
99
99
|
source: packages/test-world/package.json
|
|
100
100
|
why: 'the shared e2e library of every back end (R47, R48), a devDependency of every back end: the warm stack behind toxiproxy, the network-edge fakes, the Nest boot, the typed useTestWorld handle, useSandbox for contract specs and the outage lock. A fake declared https (defineHttpFake) serves TLS with the run''s ephemeral in-memory fake CA and publishes its PEM as w.fake.<name>.ca for the app''s own client to trust; verification is never turned off. Kafka is real own infra (the one digest-pinned apache/kafka KRaft image, a broker listener and proxy per slot, slot-prefixed topics, consumer groups and client ids) and Postgres is schema-per-context (a schema and a login role per context in a shared database). Each worker has its own data slot (<ns>_w<k>) with its own databases, Keycloak realm, leased Redis DB, bucket/collection/topic prefixes, fakes host, toxiproxy proxies and outage lock; a run provisions min(jest workers, workers cap, default 2) slots, and state file protocol 2 pairs exactly with @starci/jest-preset (TEST_WORLD_PAIR_MISMATCH otherwise). A modules world boots real peer apps beside its modules, world.apps.<name>.during(fn) is the outage of a peer app, w.keycloak.clientSecret(client) answers the run-generated secret of a confidential realm client, world.keycloak.events/sessions read the realm user events and live sessions, world.infra.postgresql.connection(name) takes one database connection down while the others serve, and world.resolve and scope.resolve take any Nest token. Every path a declaration names (`stack`, seeds, the realm, Dockerfiles) resolves from the app root (the directory of hfs.json); the globalSetup registers the path aliases as TypeScript resolves them. Its CLI main is exported at ./cli for starci app stack.'
|
|
101
101
|
'@starci/ai-router':
|
|
102
|
-
version: 0.
|
|
102
|
+
version: 0.5.0
|
|
103
103
|
group: starci
|
|
104
104
|
install: registry
|
|
105
105
|
side: be
|
|
106
106
|
source: packages/ai-router/package.json
|
|
107
107
|
why: 'the AI pool router of a back end: a NestJS module that routes OpenAI-compatible chat, stream and embeddings calls across keys, models and providers, with @starci/ai-router/testing for its specs; @starci/jest-preset compiles it as an ESM-only dependency.'
|
|
108
108
|
'@starci/hfs':
|
|
109
|
-
version: 4.
|
|
109
|
+
version: 4.16.0
|
|
110
110
|
group: starci
|
|
111
111
|
install: registry
|
|
112
112
|
side: both
|
|
@@ -1407,7 +1407,7 @@ rules:
|
|
|
1407
1407
|
witness:
|
|
1408
1408
|
- {"path":"packages/eslint/be/legacy-sql-http-log-proof.spec.mjs","edition":"lite","codes":["BE_SQL_INTERPOLATED"]}
|
|
1409
1409
|
- {"path":"packages/eslint/be/legacy-sql-http-log-proof.spec.mjs","edition":"full","codes":["BE_SQL_INTERPOLATED"]}
|
|
1410
|
-
law: "SQL text carries no runtime substitution; values are numbered parameters. The one exception is R334's
|
|
1410
|
+
law: "SQL text carries no runtime substitution; values are numbered parameters. The one exception is R334's call and LISTEN statements in platform/database/database.client.ts, which splice only the name parts and parameter list its own validator checked."
|
|
1411
1411
|
kinds: [lint, codemod]
|
|
1412
1412
|
gates: [pre-commit, settle, land, ci]
|
|
1413
1413
|
failureCodes: ["BE_SQL_INTERPOLATED"]
|
|
@@ -5828,7 +5828,7 @@ rules:
|
|
|
5828
5828
|
ruling: "Lite entities only map tables the migrations create, so each entity column is checked against the migrated schema by starci app db drift, which CI runs in the build job and the release gate runs as its db-drift row."
|
|
5829
5829
|
- id: R334
|
|
5830
5830
|
code: "BE_PERSISTENCE_NO_RAW_SQL"
|
|
5831
|
-
law: "Lite
|
|
5831
|
+
law: "Lite TypeScript holds no SQL text, outside the test world. A table is read and written through its TypeORM entity on an EntityManager (domain code the injected one; specs, builders, fixtures and the test world the world's `world.db.<connection>`): find, save, update, delete, upsert, a pessimistic `lock` option, `createQueryBuilder(Entity, \"alias\")` with named parameters; never a table-name builder or a Repository. Every table a migration creates has an entity. Anything else is a database function, called through `callDbFunction` of `platform/database/database.client.ts`, which names only a function that `ruleParams.be.dbFunctions` or hfs.json `sides.be.dbFunctions` lists with a reason: a function of a migration, schema-qualified (`public.job_claim`); an extension function, schema-qualified; a pg built-in of the canon's list, bare (`pg_notify`, called in pg_catalog). `database.client.ts` alone composes SQL text (the call statement and LISTEN), from parts its validator checked; `advisoryXactLock` and `notify` are `callDbFunction` calls of built-ins there. The only other `.query(` outside the world is the pass-through of a file that `ruleParams.be.sqlPassThrough` or hfs.json `sides.be.sqlPassThrough` lists with a reason, whose text is not the app's own (the tracked seed, a customer data-source read, an outside session's setup), with a plain string first argument, never a template or a `+`. The test world `src/tests/world` may hold raw SQL, as `sql`-tagged text with numbered `$n` parameters (and `<name>.sql.ts` files), only for what neither an entity nor a listed function expresses: a privilege or row-level-security probe, catalog, cron or trigger introspection, a pgmq queue table, a lock or failure probe; in the world too plain CRUD on an application table goes through its entity, an application function through `callDbFunction`, and seeded rows come from builders (R98). No `sql` template, `*.sql.ts`, `sql`/`ident` import or other `.query(` exists anywhere else."
|
|
5832
5832
|
kinds: [lint]
|
|
5833
5833
|
gates: [pre-commit, settle, land, ci]
|
|
5834
5834
|
failureCodes: ["BE_PERSISTENCE_NO_RAW_SQL"]
|
|
@@ -6069,3 +6069,142 @@ rules:
|
|
|
6069
6069
|
source: owner
|
|
6070
6070
|
quote: "Xóa ai api key và openclaw ai key chỉnh thành starci ai key là key ảo"
|
|
6071
6071
|
ruling: "Per app and environment the ai member holds the upstream provider keys and one random virtual key STARCI_AI_KEY that the gateway mounts and every client uses; the gateway has no admin API (ruling be-ai-one-virtual-key)."
|
|
6072
|
+
- id: R345
|
|
6073
|
+
code: "FE_PANE_GRID_CHILD_NOT_PANE"
|
|
6074
|
+
law: "A child of a Grammar PaneGrid is a literal Pane element, written directly or returned by a conditional (`cond ? <Pane/> : null`, `cond && <Pane/>`) or a `.map`; a wrapper component, a raw element, a fragment or a variable is refused, because PaneGrid places each child by the area of a Pane and drops the rest, leaving its cell blank. A Freestyle subtree leaves the raw elements inside it alone."
|
|
6075
|
+
kinds: [lint]
|
|
6076
|
+
gates: [pre-commit, settle, land, ci]
|
|
6077
|
+
failureCodes: ["FE_PANE_GRID_CHILD_NOT_PANE"]
|
|
6078
|
+
enforcers:
|
|
6079
|
+
- {kind: eslint-grammar-core, id: pane-grid-children}
|
|
6080
|
+
editions: [lite, full]
|
|
6081
|
+
docs: [contexts/ui/components/pane-grid.md]
|
|
6082
|
+
templateNa: "A template draws no pane grid; an app adds one with its multi-pane screens."
|
|
6083
|
+
proves:
|
|
6084
|
+
- {path: packages/eslint-grammar-core/src/pane.spec.ts, editions: [lite, full], codes: [FE_PANE_GRID_CHILD_NOT_PANE]}
|
|
6085
|
+
exemplifiedBy:
|
|
6086
|
+
- {path: packages/eslint-grammar-core/src/pane.spec.ts, editions: [lite, full], codes: [FE_PANE_GRID_CHILD_NOT_PANE]}
|
|
6087
|
+
witness:
|
|
6088
|
+
- {path: packages/eslint-grammar-core/src/pane.spec.ts, editions: [lite, full], codes: [FE_PANE_GRID_CHILD_NOT_PANE]}
|
|
6089
|
+
cases:
|
|
6090
|
+
- id: case-1
|
|
6091
|
+
date: "2026-10-31"
|
|
6092
|
+
source: owner
|
|
6093
|
+
quote: "trò thiết kế hệ thống padding đồng nhất cho grammar chứ mỗi nơi 1 padding trò thấy k"
|
|
6094
|
+
ruling: "A Pane owns its content inset and every band in it is a PaneSection; a PaneGrid holds Panes only, so the lint refuses a wrapper component that the grid would drop."
|
|
6095
|
+
- id: R346
|
|
6096
|
+
code: "FE_PANE_CHILD_NOT_SECTION"
|
|
6097
|
+
law: "A Grammar Pane holds PaneSection elements or one nested PaneGrid; a raw element, or a Grammar component other than PaneSection, PaneGrid and Freestyle, placed directly in a Pane skips the inset the pane publishes to its bands. A project component is judged in its own file, and a Freestyle subtree leaves its raw elements alone."
|
|
6098
|
+
kinds: [lint]
|
|
6099
|
+
gates: [pre-commit, settle, land, ci]
|
|
6100
|
+
failureCodes: ["FE_PANE_CHILD_NOT_SECTION"]
|
|
6101
|
+
enforcers:
|
|
6102
|
+
- {kind: eslint-grammar-core, id: pane-children}
|
|
6103
|
+
editions: [lite, full]
|
|
6104
|
+
docs: [contexts/ui/components/pane-grid.md]
|
|
6105
|
+
templateNa: "A template draws no pane grid; an app adds one with its multi-pane screens."
|
|
6106
|
+
proves:
|
|
6107
|
+
- {path: packages/eslint-grammar-core/src/pane.spec.ts, editions: [lite, full], codes: [FE_PANE_CHILD_NOT_SECTION]}
|
|
6108
|
+
exemplifiedBy:
|
|
6109
|
+
- {path: packages/eslint-grammar-core/src/pane.spec.ts, editions: [lite, full], codes: [FE_PANE_CHILD_NOT_SECTION]}
|
|
6110
|
+
witness:
|
|
6111
|
+
- {path: packages/eslint-grammar-core/src/pane.spec.ts, editions: [lite, full], codes: [FE_PANE_CHILD_NOT_SECTION]}
|
|
6112
|
+
cases:
|
|
6113
|
+
- id: case-1
|
|
6114
|
+
date: "2026-10-31"
|
|
6115
|
+
source: owner
|
|
6116
|
+
quote: "trò thiết kế hệ thống padding đồng nhất cho grammar chứ mỗi nơi 1 padding trò thấy k"
|
|
6117
|
+
ruling: "A Pane owns its content inset and every band in it is a PaneSection whose boxes align to that edge, so the lint refuses a band hand-built in a pane."
|
|
6118
|
+
- id: R347
|
|
6119
|
+
code: "FE_PANE_SECTION_PADDING"
|
|
6120
|
+
law: "The outermost element a Grammar PaneSection renders, and the root of a same-file component returned as its child, carries no padding or margin utility (`p-*`, `px-*`, `py-*`, `ps/pe/pt/pb/pl/pr-*`, `m-*`, `mx/my/ms/me/mt/mb/ml/mr-*`, their negative forms, or an arbitrary padding or margin property; zero and auto values place or reset and are not one), because the Pane owns the content inset and the section applies it. A Freestyle subtree leaves its raw elements alone."
|
|
6121
|
+
kinds: [lint]
|
|
6122
|
+
gates: [pre-commit, settle, land, ci]
|
|
6123
|
+
failureCodes: ["FE_PANE_SECTION_PADDING"]
|
|
6124
|
+
enforcers:
|
|
6125
|
+
- {kind: eslint-grammar-core, id: no-pane-section-padding}
|
|
6126
|
+
editions: [lite, full]
|
|
6127
|
+
docs: [contexts/ui/components/pane-grid.md]
|
|
6128
|
+
templateNa: "A template draws no pane grid; an app adds one with its multi-pane screens."
|
|
6129
|
+
proves:
|
|
6130
|
+
- {path: packages/eslint-grammar-core/src/pane.spec.ts, editions: [lite, full], codes: [FE_PANE_SECTION_PADDING]}
|
|
6131
|
+
exemplifiedBy:
|
|
6132
|
+
- {path: packages/eslint-grammar-core/src/pane.spec.ts, editions: [lite, full], codes: [FE_PANE_SECTION_PADDING]}
|
|
6133
|
+
witness:
|
|
6134
|
+
- {path: packages/eslint-grammar-core/src/pane.spec.ts, editions: [lite, full], codes: [FE_PANE_SECTION_PADDING]}
|
|
6135
|
+
cases:
|
|
6136
|
+
- id: case-1
|
|
6137
|
+
date: "2026-10-31"
|
|
6138
|
+
source: owner
|
|
6139
|
+
quote: "trò thiết kế hệ thống padding đồng nhất cho grammar chứ mỗi nơi 1 padding trò thấy k"
|
|
6140
|
+
ruling: "One padding system for everything placed in a PaneGrid: the Pane publishes the inset and the PaneSection applies it, so no band adds its own."
|
|
6141
|
+
- id: R348
|
|
6142
|
+
code: "FE_PANE_GRID_FRAME"
|
|
6143
|
+
law: "A Grammar PaneGrid is the one child of `Page chrome=\"panes\"` or stands inside a Pane; a PaneGrid drawn in a FillViewport, a SurfaceCard, SurfaceListCard or SurfaceAccordionCard, a Page of another chrome, or wrapped inside `Page chrome=\"panes\"` re-decides the frame and the bottom edge that Page owns. A grid whose frame lives in another file is judged where the frame is, and a Freestyle subtree is not judged."
|
|
6144
|
+
kinds: [lint]
|
|
6145
|
+
gates: [pre-commit, settle, land, ci]
|
|
6146
|
+
failureCodes: ["FE_PANE_GRID_FRAME"]
|
|
6147
|
+
enforcers:
|
|
6148
|
+
- {kind: eslint-grammar-core, id: pane-grid-page}
|
|
6149
|
+
editions: [lite, full]
|
|
6150
|
+
docs: [contexts/ui/components/pane-grid.md]
|
|
6151
|
+
templateNa: "A template draws no pane grid; an app adds one with its multi-pane screens."
|
|
6152
|
+
proves:
|
|
6153
|
+
- {path: packages/eslint-grammar-core/src/pane.spec.ts, editions: [lite, full], codes: [FE_PANE_GRID_FRAME]}
|
|
6154
|
+
exemplifiedBy:
|
|
6155
|
+
- {path: packages/eslint-grammar-core/src/pane.spec.ts, editions: [lite, full], codes: [FE_PANE_GRID_FRAME]}
|
|
6156
|
+
witness:
|
|
6157
|
+
- {path: packages/eslint-grammar-core/src/pane.spec.ts, editions: [lite, full], codes: [FE_PANE_GRID_FRAME]}
|
|
6158
|
+
cases:
|
|
6159
|
+
- id: case-1
|
|
6160
|
+
date: "2026-10-31"
|
|
6161
|
+
source: owner
|
|
6162
|
+
quote: "sao trang lan agent có gap 1 khoảng trên vậy??? … phải dính vào chứ"
|
|
6163
|
+
ruling: "A tabbed app page's PaneGrid attaches flush under the tab line, full width and to the bottom, with no card, radius or gap: Page chrome=\"panes\"."
|
|
6164
|
+
- id: R349
|
|
6165
|
+
code: "FE_HAND_BUILT_PANES"
|
|
6166
|
+
law: "A multi-pane frame is a Grammar PaneGrid of Panes, which draws the dividers between panes; a vertical Divider used as a column seam (among the plain columns of a flex row, or anywhere inside a SurfaceCard composition=\"joined\") and a joined SurfaceCard that holds a flex row of two or more plain columns are a pane frame drawn by hand and are refused. A short vertical separator between controls and a row whose classes cannot be read in the file are not judged."
|
|
6167
|
+
kinds: [lint]
|
|
6168
|
+
gates: [pre-commit, settle, land, ci]
|
|
6169
|
+
failureCodes: ["FE_HAND_BUILT_PANES"]
|
|
6170
|
+
enforcers:
|
|
6171
|
+
- {kind: eslint-fe, id: no-hand-built-panes}
|
|
6172
|
+
editions: [lite, full]
|
|
6173
|
+
docs: [contexts/ui/components/pane-grid.md]
|
|
6174
|
+
templateNa: "A template draws no multi-pane frame; an app adds one with its multi-pane screens."
|
|
6175
|
+
proves:
|
|
6176
|
+
- {path: packages/eslint-canon-fe/src/surface-rules.spec.ts, editions: [lite, full], codes: [FE_HAND_BUILT_PANES]}
|
|
6177
|
+
exemplifiedBy:
|
|
6178
|
+
- {path: packages/eslint-canon-fe/src/surface-rules.spec.ts, editions: [lite, full], codes: [FE_HAND_BUILT_PANES]}
|
|
6179
|
+
witness:
|
|
6180
|
+
- {path: packages/eslint-canon-fe/src/surface-rules.spec.ts, editions: [lite, full], codes: [FE_HAND_BUILT_PANES]}
|
|
6181
|
+
cases:
|
|
6182
|
+
- id: case-1
|
|
6183
|
+
date: "2026-10-31"
|
|
6184
|
+
source: owner
|
|
6185
|
+
quote: "sao trang lan agent có gap 1 khoảng trên vậy??? … phải dính vào chứ"
|
|
6186
|
+
ruling: "A multi-pane frame is the Grammar's PaneGrid, never columns and a vertical Divider composed by hand inside a card."
|
|
6187
|
+
- id: R350
|
|
6188
|
+
code: "FE_SPACING_UTILITY"
|
|
6189
|
+
law: "Every spacing value is the Grammar's, on its one scale 0 4 8 12 24 32: an app names the relation between the blocks it places through a role prop (Stack and Cluster gap identity, inline, row, section or region; Grid kind cards, form or main-rail; PaneSection; Page) and writes no padding, margin, gap or space utility and no arbitrary spacing value in a class string; a Grammar stylesheet writes no spacing length off the scale and no fluid spacing value."
|
|
6190
|
+
kinds: [lint]
|
|
6191
|
+
gates: [pre-commit, settle, land, ci]
|
|
6192
|
+
failureCodes: ["FE_SPACING_UTILITY", "FE_SPACING_OFF_SCALE"]
|
|
6193
|
+
enforcers:
|
|
6194
|
+
- {kind: eslint-fe, id: no-spacing-utility}
|
|
6195
|
+
- {kind: stylelint, id: spacing-scale}
|
|
6196
|
+
editions: [lite, full]
|
|
6197
|
+
docs: [contexts/ui/presentation.md]
|
|
6198
|
+
templateNa: "A template's own layout is moved onto the role props by starci app codemod spacing-roles; the rule counts at warn for one cohort."
|
|
6199
|
+
proves:
|
|
6200
|
+
- {path: packages/eslint-canon-fe/src/spacing.spec.ts, editions: [lite, full], codes: [FE_SPACING_UTILITY]}
|
|
6201
|
+
exemplifiedBy:
|
|
6202
|
+
- {path: packages/eslint-canon-fe/src/spacing.spec.ts, editions: [lite, full], codes: [FE_SPACING_UTILITY]}
|
|
6203
|
+
witness:
|
|
6204
|
+
- {path: packages/eslint-canon-fe/src/spacing.spec.ts, editions: [lite, full], codes: [FE_SPACING_UTILITY]}
|
|
6205
|
+
cases:
|
|
6206
|
+
- id: case-1
|
|
6207
|
+
date: "2026-10-31"
|
|
6208
|
+
source: owner
|
|
6209
|
+
quote: "hay full 3 bỏ 4 đi!!! thầy nghĩ nên v"
|
|
6210
|
+
ruling: "One spacing scale with no 16; every container holds its content 12 in, and an app names relations through role props, never spacing utilities."
|
|
@@ -467,6 +467,10 @@ ruleParams:
|
|
|
467
467
|
- {name: public.event_bus_bury, reason: "platform/event-bus (add webhook, lite): the move of an exhausted or malformed message to its consumer's dead-letter queue"}
|
|
468
468
|
- {name: public.event_bus_dead_letters, reason: "platform/event-bus (add webhook, lite): the operator read of a consumer's dead-letter queue"}
|
|
469
469
|
- {name: public.event_bus_requeue, reason: "platform/event-bus (add webhook, lite): the operator requeue of one dead letter"}
|
|
470
|
+
# R334 (lite): the only `.query(` outside platform/database's composer and the test world is the pass-through of a file listed here
|
|
471
|
+
# (or in hfs.json sides.be.sqlPassThrough), whose SQL text is not the app's own. Paths are relative to the side folder.
|
|
472
|
+
sqlPassThrough:
|
|
473
|
+
- {file: src/modules/platform/database/seed-runner.service.ts, reason: "the tracked seed file's text, run as it is (db/seed.sql); the app writes no SQL there"}
|
|
470
474
|
|
|
471
475
|
lite:
|
|
472
476
|
schemaAuthority: sql
|
|
@@ -2070,7 +2074,7 @@ slots:
|
|
|
2070
2074
|
own; use-test-world.ts exports useTestWorld({ modules: [...] }) -> world.db.<connection> (the shared EntityManager) and
|
|
2071
2075
|
world.fake.<provider> (a network-edge fake of an external SaaS). There is no world.apps (no e2e) and no world.infra
|
|
2072
2076
|
(no self-hosted service to cut)
|
|
2073
|
-
allows: [global-setup.ts, global-teardown.ts, use-test-world.ts, "fakes/<provider>/server.ts", fakes
|
|
2077
|
+
allows: [global-setup.ts, global-teardown.ts, use-test-world.ts, "fakes/<provider>/server.ts", fakes/, "<name>.sql.ts"] # jest globalSetup/globalTeardown default-export (Jest API); fakes/<provider>/server.ts is the fake server of a provider, a literal file name BE_SOURCE_FORM reads as its role; plus root files with a role suffix (test-world-files, R47)
|
|
2074
2078
|
tests: none
|
|
2075
2079
|
coverage: none
|
|
2076
2080
|
why: >-
|
|
@@ -5634,12 +5634,12 @@ LITE_DB_MIGRATION:
|
|
|
5634
5634
|
kind: check-finding
|
|
5635
5635
|
|
|
5636
5636
|
BE_PERSISTENCE_NO_RAW_SQL:
|
|
5637
|
-
title: "Lite
|
|
5638
|
-
title_vi: "
|
|
5639
|
-
meaning_vi: "File `<path>` gọi `.query(
|
|
5637
|
+
title: "Lite TypeScript runs or holds SQL text outside the test world"
|
|
5638
|
+
title_vi: "TypeScript của app lite chạy hoặc chứa văn bản SQL ngoài test world"
|
|
5639
|
+
meaning_vi: "File `<path>` gọi `.query(`, dùng template `sql`, là file `*.sql.ts`, import `sql`/`ident`/`SqlText`/`SqlIdent` ngoài src/tests/world (trừ bộ soạn câu lệnh của platform/database/database.client.ts và file có tên trong ruleParams.be.sqlPassThrough hay hfs.json sides.be.sqlPassThrough), gọi callDbFunction với một hàm không có trong ruleParams.be.dbFunctions hay hfs.json sides.be.dbFunctions, hoặc trong test world viết SQL thô cho điều entity hoặc hàm đã khai báo diễn đạt được (gọi hàm, ghi bảng ứng dụng)."
|
|
5640
5640
|
causes_vi:
|
|
5641
|
-
- "Vi phạm R334: dữ liệu của app lite, kể cả spec, builder và
|
|
5642
|
-
nextStep_vi: "Đọc/ghi bằng entity trên EntityManager hoặc createQueryBuilder với tham số có tên (trong test là world.db.<connection>); hàm database
|
|
5641
|
+
- "Vi phạm R334: dữ liệu của app lite, kể cả spec, builder, fixture và test world, đi qua entity TypeORM trên EntityManager (find, save, update, upsert, createQueryBuilder); hàm database đi qua callDbFunction; SQL thô chỉ còn ở test world cho thăm dò (quyền, row-level security, catalog, cron, trigger, bảng pgmq, khóa/lỗi)."
|
|
5642
|
+
nextStep_vi: "Đọc/ghi bằng entity trên EntityManager hoặc createQueryBuilder với tham số có tên (trong test là world.db.<connection>); hàm database thì gọi qua callDbFunction với hàm đã khai kèm lý do; file chỉ chuyển tiếp văn bản SQL không phải của app thì khai trong hfs.json sides.be.sqlPassThrough kèm lý do; thăm dò chỉ test world mới được viết SQL thô."
|
|
5643
5643
|
owner: op-retry
|
|
5644
5644
|
kind: check-finding
|
|
5645
5645
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { isPlainObject } from '../../engine/plain-object.mjs';
|
|
2
2
|
import { APP_KIND, connectionShapeProblems, NAME, PACKAGE_NAME, RUNTIME_KIND, SLOT_ID } from './manifest-shape.mjs';
|
|
3
|
-
import { dbFunctionsOk } from './rule-params-shape.mjs';
|
|
3
|
+
import { dbFunctionsOk, sqlPassThroughOk } from './rule-params-shape.mjs';
|
|
4
4
|
import { kindShapeProblems, patternShapeProblems } from './declaration-slots.mjs';
|
|
5
5
|
import { byCodeUnit } from '../lib/list.mjs';
|
|
6
6
|
/** Shape problems of the optional hfs.json `db` block of a lite app: the environment variable naming its database, its image, forced RLS and its application role. */
|
|
@@ -91,10 +91,12 @@ function declarationSideProblems(side, value, names) {
|
|
|
91
91
|
if (!isPlainObject(value))
|
|
92
92
|
return [`${at} must be an object`];
|
|
93
93
|
for (const key of Object.keys(value))
|
|
94
|
-
if (!['apps', 'optionalSlots', 'patterns', 'kinds', 'connections', 'reads', 'extraPackages', 'grammar', 'backing', 'dbFunctions'].includes(key))
|
|
94
|
+
if (!['apps', 'optionalSlots', 'patterns', 'kinds', 'connections', 'reads', 'extraPackages', 'grammar', 'backing', 'dbFunctions', 'sqlPassThrough'].includes(key))
|
|
95
95
|
bad.push(`${at} has unknown key ${key}`);
|
|
96
96
|
if (value.dbFunctions !== undefined && (side !== 'be' || !dbFunctionsOk(value.dbFunctions, { bare: true })))
|
|
97
97
|
bad.push(`${at}.dbFunctions belongs to the be side and lists {name, reason} database functions, each schema-qualified (schema.name, never pg_catalog) or a bare pg built-in of the canon's list, with unique names`);
|
|
98
|
+
if (value.sqlPassThrough !== undefined && (side !== 'be' || !sqlPassThroughOk(value.sqlPassThrough)))
|
|
99
|
+
bad.push(`${at}.sqlPassThrough belongs to the be side and lists {file, reason} files relative to the side folder, forward slashes, unique, each with the reason its SQL text is not the app's own`);
|
|
98
100
|
bad.push(...appShapeProblems(value.apps, at, side, names));
|
|
99
101
|
if (isBadUniqueList(value.optionalSlots, (slot) => SLOT_ID.test(String(slot))))
|
|
100
102
|
bad.push(`${at}.optionalSlots must be a unique list of slot ids`);
|
|
@@ -92,6 +92,11 @@ export interface DbFunction {
|
|
|
92
92
|
name: string;
|
|
93
93
|
reason: string;
|
|
94
94
|
}
|
|
95
|
+
/** A file whose `.query(` passes through SQL text that is not the app's own (ruleParams.be.sqlPassThrough, hfs.json sides.be.sqlPassThrough). */
|
|
96
|
+
export interface SqlPassThrough {
|
|
97
|
+
file: string;
|
|
98
|
+
reason: string;
|
|
99
|
+
}
|
|
95
100
|
/** The rule parameters of one profile (ruleParams.common merged with ruleParams.<profile>); the keys not named here are parsed manifest data. */
|
|
96
101
|
export interface RuleParams {
|
|
97
102
|
[key: string]: Parsed;
|
|
@@ -107,6 +112,7 @@ export interface RuleParams {
|
|
|
107
112
|
suffixes?: string[];
|
|
108
113
|
bannedSuffixes?: string[];
|
|
109
114
|
dbFunctions?: DbFunction[];
|
|
115
|
+
sqlPassThrough?: SqlPassThrough[];
|
|
110
116
|
}
|
|
111
117
|
/** ruleParams.be: the back end's own parameters, which the validators of this folder require. */
|
|
112
118
|
export interface BeRuleParams extends RuleParams {
|
|
@@ -6,5 +6,10 @@ import type { Parsed } from './manifest-shape.mjs';
|
|
|
6
6
|
export declare const dbFunctionsOk: (list: Parsed, { bare }?: {
|
|
7
7
|
bare?: boolean;
|
|
8
8
|
}) => boolean;
|
|
9
|
+
/**
|
|
10
|
+
* True for an absent list or a list of {file, reason} SQL pass-through files (R334): `file` is a forward-slash path relative to the
|
|
11
|
+
* side folder (no leading slash, no `..`), unique, and the reason names whose text the file passes through.
|
|
12
|
+
*/
|
|
13
|
+
export declare const sqlPassThroughOk: (list: Parsed) => boolean;
|
|
9
14
|
/** The shape problems of `m.ruleParams`. */
|
|
10
15
|
export declare function ruleParamsProblems(m: Parsed): string[];
|
|
@@ -73,6 +73,11 @@ const dbFunctionNameOk = (name, bare) => typeof name === 'string' && (/^[a-z_][a
|
|
|
73
73
|
* a bare name, which only the canon's list (ruleParams.be.dbFunctions) uses, for the pg built-ins its helpers call.
|
|
74
74
|
*/
|
|
75
75
|
export const dbFunctionsOk = (list, { bare = false } = {}) => list === undefined || (Array.isArray(list) && list.every((entry) => isPlainObject(entry) && Object.keys(entry).sort().join() === 'name,reason' && dbFunctionNameOk(entry.name, bare) && typeof entry.reason === 'string' && entry.reason.trim().length > 0) && new Set(list.map((entry) => entry.name)).size === list.length);
|
|
76
|
+
/**
|
|
77
|
+
* True for an absent list or a list of {file, reason} SQL pass-through files (R334): `file` is a forward-slash path relative to the
|
|
78
|
+
* side folder (no leading slash, no `..`), unique, and the reason names whose text the file passes through.
|
|
79
|
+
*/
|
|
80
|
+
export const sqlPassThroughOk = (list) => list === undefined || (Array.isArray(list) && list.every((entry) => isPlainObject(entry) && Object.keys(entry).sort().join() === 'file,reason' && typeof entry.file === 'string' && /^[A-Za-z0-9_.-]+(\/[A-Za-z0-9_.-]+)*$/.test(entry.file) && !entry.file.split('/').includes('..') && typeof entry.reason === 'string' && entry.reason.trim().length > 0) && new Set(list.map((entry) => entry.file)).size === list.length);
|
|
76
81
|
/** The shape problems of `m.ruleParams`. */
|
|
77
82
|
export function ruleParamsProblems(m) {
|
|
78
83
|
const bad = [];
|
|
@@ -84,8 +89,8 @@ export function ruleParamsProblems(m) {
|
|
|
84
89
|
// Edition lite: a side's optional `lite` map overrides keys of the same side (never `lite` itself) when the app declares edition lite.
|
|
85
90
|
if (!sideOk(rp.common, []) || !fileLinesOk(rp.common.fileLines) || !blockOk(rp.common.duplicateBlock))
|
|
86
91
|
bad.push('ruleParams.common needs fileLines {soft, hardGrowth} and duplicateBlock {lines >= 2, tokens >= 1}; optional functionLines and featureFiles must be positive integers');
|
|
87
|
-
if (!sideOk(rp.be, BE_OWN, ['schemaAuthority', 'lite', 'dbFunctions']) || (rp.be.schemaAuthority !== undefined && !SCHEMA_AUTHORITIES.includes(rp.be.schemaAuthority)) || !dbFunctionsOk(rp.be.dbFunctions, { bare: true }) || !liteOk(rp.be.lite, [...BE_OWN, ...SHARED_KEYS, 'schemaAuthority', 'dbFunctions']))
|
|
88
|
-
bad.push('ruleParams.be needs infraOwners, eventBus, specDoubles, paramNames, suffixes, bannedSuffixes, contractShape {helper}, unitRoles, logicRoles, thinRoles, patternScenarios, kindPatterns, addKinds and backing (shared parameters live in ruleParams.common, a side may override them); optional: schemaAuthority (' + SCHEMA_AUTHORITIES.join(' | ') + '), dbFunctions ([{name, reason}]: a pg built-in bare, every other function schema.name) and lite (overrides of the same keys)');
|
|
92
|
+
if (!sideOk(rp.be, BE_OWN, ['schemaAuthority', 'lite', 'dbFunctions', 'sqlPassThrough']) || (rp.be.schemaAuthority !== undefined && !SCHEMA_AUTHORITIES.includes(rp.be.schemaAuthority)) || !dbFunctionsOk(rp.be.dbFunctions, { bare: true }) || !sqlPassThroughOk(rp.be.sqlPassThrough) || !liteOk(rp.be.lite, [...BE_OWN, ...SHARED_KEYS, 'schemaAuthority', 'dbFunctions', 'sqlPassThrough']))
|
|
93
|
+
bad.push('ruleParams.be needs infraOwners, eventBus, specDoubles, paramNames, suffixes, bannedSuffixes, contractShape {helper}, unitRoles, logicRoles, thinRoles, patternScenarios, kindPatterns, addKinds and backing (shared parameters live in ruleParams.common, a side may override them); optional: schemaAuthority (' + SCHEMA_AUTHORITIES.join(' | ') + '), dbFunctions ([{name, reason}]: a pg built-in bare, every other function schema.name), sqlPassThrough ([{file, reason}]: a file whose SQL text is not written by the app) and lite (overrides of the same keys)');
|
|
89
94
|
else
|
|
90
95
|
bad.push(...unitRolesProblems(rp.be), ...roleListProblems(rp.be));
|
|
91
96
|
bad.push(...beSpecificProblems(rp.be, m));
|
|
@@ -3,7 +3,7 @@ import type { Side } from './slot-manifest-shape.mjs';
|
|
|
3
3
|
import type { AppRepo } from './slot-app-view.mjs';
|
|
4
4
|
import { loadRuleCatalog } from './rule-catalog.mjs';
|
|
5
5
|
import type { RuleCatalog } from './rule-catalog.mjs';
|
|
6
|
-
import type { DbFunction, Parsed, RuleParams, Slot, SlotManifest, SlotTracking } from './manifest-shape.mjs';
|
|
6
|
+
import type { DbFunction, Parsed, RuleParams, Slot, SlotManifest, SlotTracking, SqlPassThrough } from './manifest-shape.mjs';
|
|
7
7
|
import type { ClassifiedPath, OwnerUnit } from './slot-classify.mjs';
|
|
8
8
|
export type { Bindings } from './slot-match.mjs';
|
|
9
9
|
export type { ClassifiedPath, OwnerUnit, PlacedPath, AmbiguousPath, UndeclaredPath } from './slot-classify.mjs';
|
|
@@ -41,6 +41,7 @@ export interface RepoView {
|
|
|
41
41
|
providers?: readonly string[];
|
|
42
42
|
reads: readonly string[];
|
|
43
43
|
dbFunctions?: readonly DbFunction[];
|
|
44
|
+
sqlPassThrough?: readonly SqlPassThrough[];
|
|
44
45
|
grammar?: string;
|
|
45
46
|
extraPackages?: readonly {
|
|
46
47
|
name: string;
|
|
@@ -125,6 +125,7 @@ function sideDeclaration(manifest, declaration, name, edition, providers) {
|
|
|
125
125
|
providers,
|
|
126
126
|
reads: Object.freeze([...(s.reads ?? [])]),
|
|
127
127
|
dbFunctions: Object.freeze((s.dbFunctions ?? []).map((entry) => Object.freeze({ ...entry }))),
|
|
128
|
+
sqlPassThrough: Object.freeze((s.sqlPassThrough ?? []).map((entry) => Object.freeze({ ...entry }))),
|
|
128
129
|
...(s.grammar === undefined ? {} : { grammar: s.grammar }),
|
|
129
130
|
...(s.extraPackages === undefined ? {} : { extraPackages: Object.freeze(s.extraPackages.map((entry) => Object.freeze({ ...entry }))) }),
|
|
130
131
|
manifestVersion: manifest.version,
|
|
@@ -251,8 +252,8 @@ function createScopeResolver(manifest, repo) {
|
|
|
251
252
|
trackingOf,
|
|
252
253
|
/** True only for a path a slot owns and requires to be committed. */
|
|
253
254
|
isTracked: (p) => trackingOf(p) === 'tracked',
|
|
254
|
-
// The app's own database functions (hfs.json sides.be.dbFunctions) join the canon's
|
|
255
|
-
ruleParams: () =>
|
|
255
|
+
// The app's own database functions and SQL pass-through files (hfs.json sides.be.dbFunctions, sides.be.sqlPassThrough) join the canon's lists (R334).
|
|
256
|
+
ruleParams: () => withDeclaredLists(ruleParams(manifest, profile, edition), repo),
|
|
256
257
|
allowedImports: (tier) => manifest.tiers[profile]?.[tier]?.mayImport ?? null,
|
|
257
258
|
});
|
|
258
259
|
}
|
|
@@ -274,9 +275,18 @@ export function createSlotResolver(manifest, repo) {
|
|
|
274
275
|
const sides = Object.fromEntries(PROFILES.map((side) => [side, createScopeResolver(manifest, app.sides[side])]));
|
|
275
276
|
return appResolver(app, root, sides);
|
|
276
277
|
}
|
|
277
|
-
/** The rule parameters with the app's declared database functions appended to `dbFunctions` (be only), frozen. */
|
|
278
|
-
const
|
|
279
|
-
|
|
278
|
+
/** The rule parameters with the app's declared database functions and SQL pass-through files appended to `dbFunctions` and `sqlPassThrough` (be only), the result frozen. */
|
|
279
|
+
const withDeclaredLists = (params, repo) => {
|
|
280
|
+
const functions = repo.dbFunctions ?? [];
|
|
281
|
+
const passThrough = repo.sqlPassThrough ?? [];
|
|
282
|
+
if ((params.dbFunctions === undefined && !functions.length) && (params.sqlPassThrough === undefined && !passThrough.length))
|
|
283
|
+
return params;
|
|
284
|
+
return Object.freeze({
|
|
285
|
+
...params,
|
|
286
|
+
...(params.dbFunctions === undefined && !functions.length ? {} : { dbFunctions: [...(params.dbFunctions ?? []), ...functions] }),
|
|
287
|
+
...(params.sqlPassThrough === undefined && !passThrough.length ? {} : { sqlPassThrough: [...(params.sqlPassThrough ?? []), ...passThrough] }),
|
|
288
|
+
});
|
|
289
|
+
};
|
|
280
290
|
/** The rule parameters of one profile (be: infraOwners, suffixes, bannedSuffixes and the rest over the shared ruleParams.common fileLines and duplicateBlock; fe: common alone or with its overrides; runtime: ruleParams.runtime of a runtime manifest), as a frozen deep copy. Under `edition` lite the `lite` overrides of ruleParams.<profile> merge over the base (the `lite` key itself is never returned). */
|
|
281
291
|
export function ruleParams(manifest, profile, edition = 'full') {
|
|
282
292
|
const profiles = manifestKind(manifest) === RUNTIME_KIND ? [RUNTIME_KIND] : PROFILES;
|
package/src/main.mjs
CHANGED
|
@@ -129,7 +129,7 @@ import { BOOLEAN as CI_BOOLEAN, COMMANDS as CI_COMMANDS } from '../ci/cli.mjs';
|
|
|
129
129
|
import { LOCAL_FLAGS as CI_LOCAL_FLAGS } from '../ci/local.mjs';
|
|
130
130
|
import { USAGE as COVERAGE_USAGE } from '../coverage/cli.mjs';
|
|
131
131
|
import { SYNC_USAGE } from '../sync/cli.mjs';
|
|
132
|
-
import { codemodMain } from '../codemod/
|
|
132
|
+
import { codemodMain } from '../codemod/main.mjs';
|
|
133
133
|
/** A value flag as parse stores it: the string given, else undefined. */
|
|
134
134
|
const valueFlag = (opts, flag) => { const given = opts[flag]; return typeof given === 'string' ? given : undefined; };
|
|
135
135
|
/** A boolean flag as parse stores it: true when given, else undefined. */
|
|
@@ -192,7 +192,7 @@ const DATABASE_USAGE = [['new', '<kebab-name>', DB_NEW_FLAGS], ['baseline', '',
|
|
|
192
192
|
const VERB_USAGE = {
|
|
193
193
|
...Object.fromEntries(Object.entries(PARSED).map(([verb, spec]) => [verb, spec.forms.map((form) => line(`starci app ${verb}`, form.operands, specUsage(form.only ?? (form.flags === undefined ? spec : { value: form.flags })), spec.edition && form.edition !== false ? flagUsage('--edition', { value: true }) : ''))])),
|
|
194
194
|
lint: ['starci app lint [--changed <file>...] [--workspace <dir>] [--side be|fe] [--fix] [--full] [--full] [--json] [--out <file>]'],
|
|
195
|
-
codemod: ['starci app codemod button-intent [--execute] [--json] [--format json|markdown] [--out <file.json|file.md>]'],
|
|
195
|
+
codemod: ['starci app codemod button-intent [--execute] [--json] [--format json|markdown] [--out <file.json|file.md>]', 'starci app codemod spacing-roles [--execute] [--json] [--format json|markdown] [--out <file.json|file.md>]'],
|
|
196
196
|
upgrade: ['starci app upgrade [--execute]'],
|
|
197
197
|
sync: [SYNC_USAGE.sync],
|
|
198
198
|
world: ['starci app world <up|down|status> [--stack <dir>] [--services <list>] [--k3d] [--force] [--json] [--edition full|lite]'],
|
|
@@ -74,23 +74,42 @@ describe("callDbFunction", () => {
|
|
|
74
74
|
})
|
|
75
75
|
|
|
76
76
|
describe("advisoryXactLock", () => {
|
|
77
|
-
it("
|
|
77
|
+
it("hashes the key, then locks the hash for the transaction, both as function calls", async () => {
|
|
78
78
|
const client = await setup()
|
|
79
|
-
const manager = mockEntityManager({
|
|
79
|
+
const manager = mockEntityManager({
|
|
80
|
+
query: [
|
|
81
|
+
['SELECT * FROM "pg_catalog"."hashtextextended"($1, $2)', [{ hashtextextended: "42" }]],
|
|
82
|
+
['SELECT * FROM "pg_catalog"."pg_advisory_xact_lock"($1)', [{}]],
|
|
83
|
+
],
|
|
84
|
+
})
|
|
80
85
|
|
|
81
86
|
await expect(client.advisoryXactLock(manager, "jobs:mail")).resolves.toBeUndefined()
|
|
82
87
|
|
|
83
|
-
expect(manager.query).
|
|
88
|
+
expect(manager.query).toHaveBeenNthCalledWith(1, 'SELECT * FROM "pg_catalog"."hashtextextended"($1, $2)', ["jobs:mail", 0])
|
|
89
|
+
expect(manager.query).toHaveBeenNthCalledWith(2, 'SELECT * FROM "pg_catalog"."pg_advisory_xact_lock"($1)', ["42"])
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it.each([
|
|
93
|
+
["no row", []],
|
|
94
|
+
["a row with no hash", [{}]],
|
|
95
|
+
["a hash that is not a number", [{ hashtextextended: null }]],
|
|
96
|
+
])("refuses %s before it locks", async (_case, rows) => {
|
|
97
|
+
const client = await setup()
|
|
98
|
+
const manager = mockEntityManager({ query: ['SELECT * FROM "pg_catalog"."hashtextextended"($1, $2)', rows] })
|
|
99
|
+
|
|
100
|
+
await expect(client.advisoryXactLock(manager, "jobs:mail")).rejects.toMatchObject({ code: DatabaseErrorCode.ReturningRowRejected })
|
|
101
|
+
|
|
102
|
+
expect(manager.query).toHaveBeenCalledTimes(1)
|
|
84
103
|
})
|
|
85
104
|
})
|
|
86
105
|
|
|
87
106
|
describe("notify", () => {
|
|
88
|
-
it("sends the payload on the channel", async () => {
|
|
107
|
+
it("sends the payload on the channel as a function call", async () => {
|
|
89
108
|
const client = await setup()
|
|
90
|
-
const manager = mockEntityManager({ query: [
|
|
109
|
+
const manager = mockEntityManager({ query: ['SELECT * FROM "pg_catalog"."pg_notify"($1, $2)', [{}]] })
|
|
91
110
|
|
|
92
111
|
await expect(client.notify(manager, "jobs", '{"id":"job-1"}')).resolves.toBeUndefined()
|
|
93
112
|
|
|
94
|
-
expect(manager.query).toHaveBeenCalledWith(
|
|
113
|
+
expect(manager.query).toHaveBeenCalledWith('SELECT * FROM "pg_catalog"."pg_notify"($1, $2)', ["jobs", '{"id":"job-1"}'])
|
|
95
114
|
})
|
|
96
115
|
})
|
|
@@ -82,12 +82,22 @@ export const callDbFunction = <T>(manager: EntityManager, name: string, args: Re
|
|
|
82
82
|
return manager.query<Array<T>>(`SELECT * FROM "${target.schema}"."${target.name}"(${parameters})`, [...args])
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
-
/**
|
|
85
|
+
/** The refusal of a hash answer that is not the number the lock takes. */
|
|
86
|
+
const hashRejected = (key: string): DatabaseError =>
|
|
87
|
+
new DatabaseError({ code: DatabaseErrorCode.ReturningRowRejected, params: { name: "hashtextextended", key } })
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Takes a transaction-scoped advisory lock on the 64-bit hash of `key`; it is released when the manager's transaction ends.
|
|
91
|
+
* Both steps are `callDbFunction` calls of listed pg built-ins, so no SQL text is written here (R334).
|
|
92
|
+
*/
|
|
86
93
|
export const advisoryXactLock = async (manager: EntityManager, key: string): Promise<void> => {
|
|
87
|
-
await
|
|
94
|
+
const rows = await callDbFunction<Record<string, unknown>>(manager, "hashtextextended", [key, 0])
|
|
95
|
+
const hash = rows[0]?.["hashtextextended"]
|
|
96
|
+
if (typeof hash !== "string" && typeof hash !== "number" && typeof hash !== "bigint") throw hashRejected(key)
|
|
97
|
+
await callDbFunction(manager, "pg_advisory_xact_lock", [hash])
|
|
88
98
|
}
|
|
89
99
|
|
|
90
100
|
/** Sends one NOTIFY on `channel` with `payload`, delivered when the manager's transaction commits. */
|
|
91
101
|
export const notify = async (manager: EntityManager, channel: string, payload: string): Promise<void> => {
|
|
92
|
-
await manager
|
|
102
|
+
await callDbFunction(manager, "pg_notify", [channel, payload])
|
|
93
103
|
}
|