@starci/eslint-canon-fe 6.0.2 → 7.1.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 +18 -0
- package/README.md +3 -5
- package/client-boundary.mjs +2 -5
- package/comments.mjs +38 -15
- package/docs/brand-values.md +2 -2
- package/docs/client-boundary.md +10 -10
- package/docs/env-owner.md +4 -4
- package/docs/formatting.md +2 -2
- package/docs/grammar-boundary.md +2 -2
- package/docs/hooks-folder.md +2 -2
- package/docs/hygiene.md +8 -8
- package/docs/lint-escape-hatch.md +2 -2
- package/docs/lists.md +6 -6
- package/docs/native-controls.md +6 -6
- package/docs/next-conventions.md +20 -20
- package/docs/size-and-state-budget.md +6 -6
- package/docs/size-growth.md +2 -2
- package/docs/status-colors.md +2 -2
- package/docs/translation.md +2 -2
- package/docs/transport.md +18 -18
- package/docs/type-safety.md +8 -8
- package/env-owner.mjs +2 -3
- package/file-layout.mjs +3 -43
- package/formatting.mjs +2 -2
- package/grammar-boundary.mjs +1 -2
- package/hooks-folder.mjs +6 -7
- package/index.mjs +5 -27
- package/lib/config.mjs +6 -22
- package/lib/scope.mjs +3 -12
- package/lib/why.mjs +3 -68
- package/lint-escape-hatch.mjs +3 -3
- package/lists.mjs +0 -4
- package/loading.mjs +2 -2
- package/native-controls.mjs +0 -4
- package/next-conventions.mjs +3 -7
- package/package.json +1 -1
- package/project-graph.mjs +23 -0
- package/props-and-slots.mjs +4 -5
- package/runtime/knowledge/hfs/slots.yaml +145 -107
- package/runtime/knowledge/patterns/fe/folder.yaml +309 -0
- package/runtime/scripts/checks/architecture/backend.mjs +350 -0
- package/runtime/scripts/checks/architecture/background-unowned.mjs +107 -0
- package/runtime/scripts/checks/architecture/client-reaches-server.mjs +94 -0
- package/runtime/scripts/checks/architecture/clones.mjs +200 -0
- package/runtime/scripts/checks/architecture/config-unread.mjs +35 -0
- package/runtime/scripts/checks/architecture/config.mjs +310 -0
- package/runtime/scripts/checks/architecture/connection-map.mjs +208 -0
- package/runtime/scripts/checks/architecture/constructor-deps.mjs +100 -0
- package/runtime/scripts/checks/architecture/contract-fixture-guard.mjs +128 -0
- package/runtime/scripts/checks/architecture/contracts.mjs +792 -0
- package/runtime/scripts/checks/architecture/cross-app-duplicate.mjs +73 -0
- package/runtime/scripts/checks/architecture/dead-exports.mjs +265 -0
- package/runtime/scripts/checks/architecture/default-deny.mjs +129 -0
- package/runtime/scripts/checks/architecture/doc-language.mjs +39 -0
- package/runtime/scripts/checks/architecture/entrypoint.mjs +57 -0
- package/runtime/scripts/checks/architecture/error-codes.mjs +45 -0
- package/runtime/scripts/checks/architecture/error-masked.mjs +60 -0
- package/runtime/scripts/checks/architecture/fe-slot-allows.mjs +38 -0
- package/runtime/scripts/checks/architecture/feature-shape.mjs +49 -0
- package/runtime/scripts/checks/architecture/framework-pinned.mjs +83 -0
- package/runtime/scripts/checks/architecture/frontend.mjs +995 -0
- package/runtime/scripts/checks/architecture/hfs-graph.mjs +61 -0
- package/runtime/scripts/checks/architecture/hfs.mjs +521 -0
- package/runtime/scripts/checks/architecture/hooks-are-hooks.mjs +88 -0
- package/runtime/scripts/checks/architecture/i18n-keys.mjs +146 -0
- package/runtime/scripts/checks/architecture/index.mjs +321 -0
- package/runtime/scripts/checks/architecture/injection-token-exported.mjs +62 -0
- package/runtime/scripts/checks/architecture/machine-ast.mjs +138 -0
- package/runtime/scripts/checks/architecture/module-per-transport.mjs +130 -0
- package/runtime/scripts/checks/architecture/next-data.mjs +775 -0
- package/runtime/scripts/checks/architecture/owners.mjs +95 -0
- package/runtime/scripts/checks/architecture/package-shape.mjs +63 -0
- package/runtime/scripts/checks/architecture/reachability.mjs +233 -0
- package/runtime/scripts/checks/architecture/register-once.mjs +141 -0
- package/runtime/scripts/checks/architecture/registration.mjs +319 -0
- package/runtime/scripts/checks/architecture/required-files.mjs +172 -0
- package/runtime/scripts/checks/architecture/route-files-thin.mjs +97 -0
- package/runtime/scripts/checks/architecture/schema-owner.mjs +261 -0
- package/runtime/scripts/checks/architecture/source-names.mjs +607 -0
- package/runtime/scripts/checks/architecture/sql-owner.mjs +142 -0
- package/runtime/scripts/checks/architecture/sql-tokens.mjs +327 -0
- package/runtime/scripts/checks/architecture/surface.mjs +91 -0
- package/runtime/scripts/checks/architecture/symbols.mjs +193 -0
- package/runtime/scripts/checks/architecture/test-world-files.mjs +163 -0
- package/runtime/scripts/checks/architecture/tiers.mjs +130 -0
- package/runtime/scripts/checks/architecture/transport-owner.mjs +112 -0
- package/runtime/scripts/checks/architecture/typescript.mjs +573 -0
- package/runtime/scripts/checks/architecture/unit-spec-providers.mjs +122 -0
- package/runtime/scripts/checks/common.mjs +44 -0
- package/runtime/scripts/checks/typescript-programs.mjs +82 -0
- package/runtime/scripts/lib/hfs-allows.mjs +15 -0
- package/runtime/scripts/lib/hfs-path-findings.mjs +84 -0
- package/runtime/scripts/lib/hfs-rules/fe-no-tests.mjs +47 -0
- package/runtime/scripts/lib/hfs-rules/read.mjs +28 -0
- package/runtime/scripts/lib/hfs-rules/secrets.mjs +54 -0
- package/runtime/scripts/lib/hfs-rules/spec-placement.mjs +29 -0
- package/runtime/scripts/lib/hfs-slots.mjs +13 -6
- package/runtime/scripts/lib/json.mjs +22 -0
- package/runtime/scripts/lib/language.mjs +107 -0
- package/runtime/scripts/lib/project-graph.mjs +0 -0
- package/runtime/scripts/lib/project-rule.mjs +39 -0
- package/runtime/scripts/lib/repo-identity.mjs +50 -0
- package/runtime/scripts/lib/secret-patterns.mjs +44 -0
- package/runtime/scripts/lib/stack-services.mjs +217 -0
- package/runtime/scripts/lib/test-secrets.mjs +120 -0
- package/served-locale.mjs +2 -2
- package/shape-slot.mjs +2 -3
- package/size-and-state-budget.mjs +1 -4
- package/size-growth.mjs +1 -2
- package/status-colors.mjs +0 -2
- package/tokens.mjs +1 -1
- package/translation.mjs +1 -29
- package/transport.mjs +13 -17
- package/docs/e2e-shape.md +0 -161
- package/docs/spec-quality.md +0 -147
- package/e2e-shape.mjs +0 -345
- package/spec-quality.mjs +0 -318
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { analyzeSql, HOLE } from './sql-tokens.mjs';
|
|
3
|
+
import { machineKit } from './machine-ast.mjs';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* R86 `sql-owner` (BE_SQL_TABLE_OWNER). Every `sql` tagged template (the tag declared in platform/database) of a
|
|
7
|
+
* `<name>.sql.ts` file in a capability's `persistence/` is read with the tokenizer of sql-tokens.mjs and judged against the
|
|
8
|
+
* entities of the whole program:
|
|
9
|
+
* - it writes (INSERT INTO, UPDATE, DELETE FROM, MERGE INTO, TRUNCATE) only tables the file's own capability declares
|
|
10
|
+
* with `@Entity("<table>")` in its persistence;
|
|
11
|
+
* - it reads (FROM, JOIN, USING) only tables of capabilities its owner may import (the tier matrix, through
|
|
12
|
+
* `resolver.importAllowed` to the table owner's public entry) or its own;
|
|
13
|
+
* - every table it names is declared by some entity;
|
|
14
|
+
* - a SELECT carries LIMIT, or constrains the primary key or a unique key of its first table with `=`, or selects only
|
|
15
|
+
* aggregates without GROUP BY.
|
|
16
|
+
* Table positions that hold a `${...}` substitution (SqlIdent) are dynamic; they are counted in the coverage and judged by
|
|
17
|
+
* nobody. Joined tables are not part of the bound: a to-many join under a key-constrained first table is a review point.
|
|
18
|
+
*/
|
|
19
|
+
export const SQL_OWNER_RULE_IDS = ['BE_SQL_TABLE_OWNER'];
|
|
20
|
+
|
|
21
|
+
const RULE = 'BE_SQL_TABLE_OWNER';
|
|
22
|
+
const TYPEORM = 'typeorm';
|
|
23
|
+
|
|
24
|
+
const literalStrings = (ts, node) => {
|
|
25
|
+
if (!node) return null;
|
|
26
|
+
if (ts.isStringLiteralLike(node)) return [node.text];
|
|
27
|
+
if (ts.isArrayLiteralExpression(node) && node.elements.every(item => ts.isStringLiteralLike(item))) return node.elements.map(item => item.text);
|
|
28
|
+
return null;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function entitiesOf(kit, graph) {
|
|
32
|
+
const { ts } = kit;
|
|
33
|
+
const tables = new Map();
|
|
34
|
+
let unreadable = 0;
|
|
35
|
+
for (const file of graph.files.values()) {
|
|
36
|
+
if (file.slot !== 'be.persistence' || !path.posix.basename(file.rel).endsWith('.entity.ts') || !file.owner) continue;
|
|
37
|
+
const checker = kit.checkerOf(file.sourceFile);
|
|
38
|
+
const typeorm = (node, ...names) => {
|
|
39
|
+
const call = ts.isCallExpression(node.expression) ? node.expression.expression : node.expression;
|
|
40
|
+
const binding = kit.importBinding(checker, call);
|
|
41
|
+
return binding?.module === TYPEORM && names.includes(binding.name);
|
|
42
|
+
};
|
|
43
|
+
for (const statement of file.sourceFile.statements) {
|
|
44
|
+
if (!ts.isClassDeclaration(statement)) continue;
|
|
45
|
+
const entity = kit.decorators(statement).find(decorator => typeorm(decorator, 'Entity'));
|
|
46
|
+
if (!entity) continue;
|
|
47
|
+
const argument = ts.isCallExpression(entity.expression) ? entity.expression.arguments[0] : null;
|
|
48
|
+
let table = argument && ts.isStringLiteralLike(argument) ? argument.text : null;
|
|
49
|
+
if (table === null && argument && ts.isObjectLiteralExpression(argument)) {
|
|
50
|
+
const property = kit.propertyOf(argument, 'name');
|
|
51
|
+
if (property && ts.isStringLiteralLike(kit.valueOfProperty(property))) table = kit.valueOfProperty(property).text;
|
|
52
|
+
}
|
|
53
|
+
if (table === null) { unreadable += 1; continue; }
|
|
54
|
+
const dbName = new Map();
|
|
55
|
+
const primary = [];
|
|
56
|
+
const sets = [];
|
|
57
|
+
for (const member of statement.members) {
|
|
58
|
+
if (!ts.isPropertyDeclaration(member) || !member.name || !ts.isIdentifier(member.name)) continue;
|
|
59
|
+
const property = member.name.text;
|
|
60
|
+
let column = property;
|
|
61
|
+
for (const decorator of kit.decorators(member)) {
|
|
62
|
+
const args = ts.isCallExpression(decorator.expression) ? decorator.expression.arguments : [];
|
|
63
|
+
for (const item of args) if (ts.isObjectLiteralExpression(item)) {
|
|
64
|
+
const named = kit.propertyOf(item, 'name');
|
|
65
|
+
if (named && ts.isStringLiteralLike(kit.valueOfProperty(named))) column = kit.valueOfProperty(named).text;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
dbName.set(property, column.toLowerCase());
|
|
69
|
+
for (const decorator of kit.decorators(member)) {
|
|
70
|
+
const args = ts.isCallExpression(decorator.expression) ? decorator.expression.arguments : [];
|
|
71
|
+
const uniqueOption = args.some(item => ts.isObjectLiteralExpression(item) && kit.propertyOf(item, 'unique')
|
|
72
|
+
&& kit.valueOfProperty(kit.propertyOf(item, 'unique')).kind === ts.SyntaxKind.TrueKeyword);
|
|
73
|
+
if (typeorm(decorator, 'PrimaryColumn', 'PrimaryGeneratedColumn')) primary.push(column.toLowerCase());
|
|
74
|
+
else if (typeorm(decorator, 'Column', 'Index') && uniqueOption) sets.push([column.toLowerCase()]);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (primary.length) sets.push(primary);
|
|
78
|
+
for (const decorator of kit.decorators(statement)) {
|
|
79
|
+
const args = ts.isCallExpression(decorator.expression) ? decorator.expression.arguments : [];
|
|
80
|
+
const list = args.find(item => ts.isArrayLiteralExpression(item) && literalStrings(ts, item));
|
|
81
|
+
const columns = list ? literalStrings(ts, list) : null;
|
|
82
|
+
const unique = typeorm(decorator, 'Unique') || (typeorm(decorator, 'Index') && args.some(item => ts.isObjectLiteralExpression(item) && kit.propertyOf(item, 'unique')
|
|
83
|
+
&& kit.valueOfProperty(kit.propertyOf(item, 'unique')).kind === ts.SyntaxKind.TrueKeyword));
|
|
84
|
+
if (unique && columns) sets.push(columns.map(column => (dbName.get(column) ?? column).toLowerCase()));
|
|
85
|
+
}
|
|
86
|
+
const key = table.toLowerCase();
|
|
87
|
+
if (!tables.has(key)) tables.set(key, { table, owner: file.owner.root, rel: file.rel, uniqueSets: sets });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return { tables, unreadable };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function checkSqlOwner(input) {
|
|
94
|
+
const { graph } = input;
|
|
95
|
+
const kit = machineKit(input);
|
|
96
|
+
const { ts, resolver } = kit;
|
|
97
|
+
const { tables, unreadable } = entitiesOf(kit, graph);
|
|
98
|
+
const violations = [];
|
|
99
|
+
let templates = 0;
|
|
100
|
+
let dynamic = 0;
|
|
101
|
+
let foreignTags = 0;
|
|
102
|
+
for (const file of graph.files.values()) {
|
|
103
|
+
if (file.slot !== 'be.persistence' || !path.posix.basename(file.rel).endsWith('.sql.ts') || !file.owner) continue;
|
|
104
|
+
const checker = kit.checkerOf(file.sourceFile);
|
|
105
|
+
kit.walk(file.sourceFile, node => {
|
|
106
|
+
if (!ts.isTaggedTemplateExpression(node)) return true;
|
|
107
|
+
const home = kit.declarationsOf(checker, node.tag).map(kit.ownerOfDeclaration).find(Boolean);
|
|
108
|
+
if (!home || home.tier !== 'platform' || home.name !== 'database') { foreignTags += 1; return true; }
|
|
109
|
+
templates += 1;
|
|
110
|
+
const template = node.template;
|
|
111
|
+
const text = ts.isNoSubstitutionTemplateLiteral(template) ? template.text
|
|
112
|
+
: template.head.text + template.templateSpans.map(span => HOLE + span.literal.text).join('');
|
|
113
|
+
const seen = new Set();
|
|
114
|
+
const report = (message, extra = {}) => {
|
|
115
|
+
const key = `${message}`;
|
|
116
|
+
if (seen.has(key)) return;
|
|
117
|
+
seen.add(key);
|
|
118
|
+
violations.push({ ruleId: RULE, path: file.rel, ...kit.at(file.rel, file.sourceFile, node), message, ...extra });
|
|
119
|
+
};
|
|
120
|
+
const result = analyzeSql(text, { uniqueSetsOf: table => tables.get(table.toLowerCase())?.uniqueSets ?? null });
|
|
121
|
+
dynamic += result.dynamic;
|
|
122
|
+
for (const write of result.writes) {
|
|
123
|
+
const entity = tables.get(write.table.toLowerCase());
|
|
124
|
+
if (!entity) report(`SQL writes table ${write.table}, which no @Entity declares; declare the entity in the owning capability's persistence/entities.`, { table: write.table });
|
|
125
|
+
else if (entity.owner !== file.owner.root) report(`SQL in ${file.owner.root} writes table ${write.table}, owned by ${entity.owner}; a capability writes only the tables of its own entities, call the owner's public API instead.`, { table: write.table, tableOwner: entity.owner });
|
|
126
|
+
}
|
|
127
|
+
for (const read of result.reads) {
|
|
128
|
+
const entity = tables.get(read.table.toLowerCase());
|
|
129
|
+
if (!entity) { report(`SQL reads table ${read.table}, which no @Entity declares; declare the entity in the owning capability's persistence/entities.`, { table: read.table }); continue; }
|
|
130
|
+
if (entity.owner === file.owner.root) continue;
|
|
131
|
+
const verdict = resolver.importAllowed(file.rel, `${entity.owner}/index.ts`);
|
|
132
|
+
if (!verdict.allowed) report(`SQL in ${file.owner.root} reads table ${read.table}, owned by ${entity.owner}, which ${file.owner.root} may not import (${verdict.reason}); read it through the owner's public API.`, { table: read.table, tableOwner: entity.owner, reason: verdict.reason });
|
|
133
|
+
}
|
|
134
|
+
for (const select of result.selects) {
|
|
135
|
+
if (select.bounded || (select.table && !tables.has(select.table.toLowerCase()))) continue;
|
|
136
|
+
report(`SELECT ${select.table ? `on ${select.table} ` : ''}has no LIMIT and does not constrain a primary key or unique column with =; bound it with LIMIT (PAGE_SIZE_MAX, LIST_ROWS_MAX or BATCH_ROWS from platform/database), filter by key, or select only aggregates.`, { table: select.table ?? undefined });
|
|
137
|
+
}
|
|
138
|
+
return true;
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
return { violations, coverage: { status: 'checked', entities: tables.size, unreadableEntities: unreadable, templates, dynamicIdentifiers: dynamic, foreignTags } };
|
|
142
|
+
}
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A small PostgreSQL text reader for the R86 machine check (sql-owner). No SQL parser is a dependency of the runtime, so
|
|
3
|
+
* this is a tokenizer plus a clause scanner, written for exactly what the check needs: which tables a statement
|
|
4
|
+
* writes, which it reads, and whether a SELECT is bounded. Comments and string literals never reach the scanner.
|
|
5
|
+
* A substitution of the `sql` tag (a `${...}` hole, the SqlIdent form) is an opaque token: a table position that holds
|
|
6
|
+
* one is counted as dynamic and judged by nobody.
|
|
7
|
+
*/
|
|
8
|
+
export const HOLE = '';
|
|
9
|
+
|
|
10
|
+
const isSpace = char => /\s/u.test(char);
|
|
11
|
+
const isWordStart = char => /[\p{L}_]/u.test(char);
|
|
12
|
+
const isWordPart = char => /[\p{L}\p{N}_$]/u.test(char);
|
|
13
|
+
|
|
14
|
+
/** Tokens: {t: 'word'|'ident'|'string'|'number'|'param'|'hole'|'punct', v, up?} (`up` is the uppercase word). */
|
|
15
|
+
export function tokenizeSql(text) {
|
|
16
|
+
const tokens = [];
|
|
17
|
+
let i = 0;
|
|
18
|
+
const push = (t, v) => tokens.push(t === 'word' ? { t, v, up: v.toUpperCase() } : { t, v });
|
|
19
|
+
while (i < text.length) {
|
|
20
|
+
const char = text[i];
|
|
21
|
+
if (isSpace(char)) { i += 1; continue; }
|
|
22
|
+
if (char === '-' && text[i + 1] === '-') { while (i < text.length && text[i] !== '\n') i += 1; continue; }
|
|
23
|
+
if (char === '/' && text[i + 1] === '*') {
|
|
24
|
+
const end = text.indexOf('*/', i + 2);
|
|
25
|
+
i = end === -1 ? text.length : end + 2;
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
if (char === HOLE) { push('hole', HOLE); i += 1; continue; }
|
|
29
|
+
if (char === "'") {
|
|
30
|
+
i += 1;
|
|
31
|
+
while (i < text.length) {
|
|
32
|
+
if (text[i] === "'" && text[i + 1] === "'") { i += 2; continue; }
|
|
33
|
+
if (text[i] === "'") break;
|
|
34
|
+
i += 1;
|
|
35
|
+
}
|
|
36
|
+
i += 1;
|
|
37
|
+
push('string', '');
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (char === '"') {
|
|
41
|
+
let value = '';
|
|
42
|
+
i += 1;
|
|
43
|
+
while (i < text.length) {
|
|
44
|
+
if (text[i] === '"' && text[i + 1] === '"') { value += '"'; i += 2; continue; }
|
|
45
|
+
if (text[i] === '"') break;
|
|
46
|
+
value += text[i];
|
|
47
|
+
i += 1;
|
|
48
|
+
}
|
|
49
|
+
i += 1;
|
|
50
|
+
push('ident', value);
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (char === '$') {
|
|
54
|
+
const param = /^\$(\d+)/u.exec(text.slice(i, i + 12));
|
|
55
|
+
if (param) { push('param', param[0]); i += param[0].length; continue; }
|
|
56
|
+
const tag = /^\$([\p{L}_][\p{L}\p{N}_]*)?\$/u.exec(text.slice(i));
|
|
57
|
+
if (tag) {
|
|
58
|
+
const end = text.indexOf(tag[0], i + tag[0].length);
|
|
59
|
+
i = end === -1 ? text.length : end + tag[0].length;
|
|
60
|
+
push('string', '');
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (/\d/u.test(char)) {
|
|
65
|
+
let j = i;
|
|
66
|
+
while (j < text.length && /[\d.]/u.test(text[j])) j += 1;
|
|
67
|
+
push('number', text.slice(i, j));
|
|
68
|
+
i = j;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (isWordStart(char)) {
|
|
72
|
+
let j = i;
|
|
73
|
+
while (j < text.length && isWordPart(text[j])) j += 1;
|
|
74
|
+
const word = text.slice(i, j);
|
|
75
|
+
if (text[j] === "'" && /^[EeBbXxNn]$/u.test(word)) {
|
|
76
|
+
const escaped = /^[Ee]$/u.test(word);
|
|
77
|
+
j += 1;
|
|
78
|
+
while (j < text.length) {
|
|
79
|
+
if (escaped && text[j] === '\\') { j += 2; continue; }
|
|
80
|
+
if (text[j] === "'" && text[j + 1] === "'") { j += 2; continue; }
|
|
81
|
+
if (text[j] === "'") break;
|
|
82
|
+
j += 1;
|
|
83
|
+
}
|
|
84
|
+
i = j + 1;
|
|
85
|
+
push('string', '');
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
push('word', word);
|
|
89
|
+
i = j;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
const two = text.slice(i, i + 2);
|
|
93
|
+
if (['::', '<=', '>=', '<>', '!=', '||', '->'].includes(two)) { push('punct', two); i += 2; continue; }
|
|
94
|
+
push('punct', char);
|
|
95
|
+
i += 1;
|
|
96
|
+
}
|
|
97
|
+
return tokens;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const CLAUSE_WORDS = new Set(['WHERE', 'JOIN', 'INNER', 'LEFT', 'RIGHT', 'FULL', 'CROSS', 'NATURAL', 'ON', 'USING', 'GROUP', 'ORDER', 'LIMIT', 'OFFSET',
|
|
101
|
+
'HAVING', 'UNION', 'INTERSECT', 'EXCEPT', 'WINDOW', 'FOR', 'RETURNING', 'SET', 'VALUES', 'SELECT', 'FETCH', 'OUTER', 'TABLESAMPLE', 'WITH', 'AS']);
|
|
102
|
+
// PostgreSQL aggregate function names, kept as one string so they read as words, not as failure codes.
|
|
103
|
+
const AGGREGATES = new Set('COUNT SUM AVG MIN MAX BOOL_AND BOOL_OR EVERY ARRAY_AGG STRING_AGG JSON_AGG JSONB_AGG JSON_OBJECT_AGG JSONB_OBJECT_AGG BIT_AND BIT_OR STDDEV VARIANCE'.split(' '));
|
|
104
|
+
const FROM_INSIDE = new Set(['EXTRACT', 'SUBSTRING', 'TRIM', 'OVERLAY', 'POSITION']);
|
|
105
|
+
const NOT_A_WRITE_BEFORE_UPDATE = new Set(['FOR', 'DO', 'ON', 'KEY', 'SHARE', 'OF']);
|
|
106
|
+
const WHERE_END = new Set(['GROUP', 'ORDER', 'HAVING', 'LIMIT', 'OFFSET', 'UNION', 'INTERSECT', 'EXCEPT', 'FOR', 'WINDOW', 'RETURNING', 'FETCH']);
|
|
107
|
+
const SYSTEM_SCHEMAS = new Set(['pg_catalog', 'information_schema']);
|
|
108
|
+
|
|
109
|
+
const isName = token => token && (token.t === 'word' || token.t === 'ident');
|
|
110
|
+
const nameOf = token => (token.t === 'word' ? token.v.toLowerCase() : token.v);
|
|
111
|
+
const isWord = (token, up) => token?.t === 'word' && token.up === up;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The table reference at `start`: {parts, table, alias, next}, {dynamic: true}, or null (a subquery, a function, nothing).
|
|
115
|
+
* `next` is the index after the reference and its alias.
|
|
116
|
+
*/
|
|
117
|
+
function tableRef(tokens, start, { columns = false } = {}) {
|
|
118
|
+
let i = start;
|
|
119
|
+
while (isWord(tokens[i], 'ONLY') || isWord(tokens[i], 'LATERAL')) i += 1;
|
|
120
|
+
const first = tokens[i];
|
|
121
|
+
if (first?.t === 'hole') return { dynamic: true, next: i + 1 };
|
|
122
|
+
if (!isName(first) || (first.t === 'word' && CLAUSE_WORDS.has(first.up) && first.up !== 'AS')) return null;
|
|
123
|
+
const parts = [nameOf(first)];
|
|
124
|
+
i += 1;
|
|
125
|
+
while (tokens[i]?.v === '.' && isName(tokens[i + 1])) { parts.push(nameOf(tokens[i + 1])); i += 2; }
|
|
126
|
+
if (tokens[i]?.v === '(' && !columns) return null;
|
|
127
|
+
let alias = null;
|
|
128
|
+
if (isWord(tokens[i], 'AS') && isName(tokens[i + 1])) { alias = nameOf(tokens[i + 1]); i += 2; }
|
|
129
|
+
else if (isName(tokens[i]) && !(tokens[i].t === 'word' && CLAUSE_WORDS.has(tokens[i].up))) { alias = nameOf(tokens[i]); i += 1; }
|
|
130
|
+
if (alias && tokens[i]?.v === '(') { let depth = 0; do { if (tokens[i].v === '(') depth += 1; else if (tokens[i].v === ')') depth -= 1; i += 1; } while (i < tokens.length && depth > 0); }
|
|
131
|
+
return { parts, table: parts.at(-1), schema: parts.length > 1 ? parts.at(-2) : null, alias, next: i };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function statementsOf(tokens) {
|
|
135
|
+
const statements = [];
|
|
136
|
+
let current = [];
|
|
137
|
+
let depth = 0;
|
|
138
|
+
for (const token of tokens) {
|
|
139
|
+
if (token.t === 'punct' && token.v === '(') depth += 1;
|
|
140
|
+
if (token.t === 'punct' && token.v === ')') depth -= 1;
|
|
141
|
+
if (token.t === 'punct' && token.v === ';' && depth <= 0) { if (current.length) statements.push(current); current = []; continue; }
|
|
142
|
+
current.push(token);
|
|
143
|
+
}
|
|
144
|
+
if (current.length) statements.push(current);
|
|
145
|
+
return statements;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The CTE names a statement declares: `name [(cols)] AS [NOT] [MATERIALIZED] (`. */
|
|
149
|
+
function cteNames(tokens) {
|
|
150
|
+
const names = new Set();
|
|
151
|
+
for (let i = 1; i < tokens.length; i += 1) {
|
|
152
|
+
if (!isWord(tokens[i], 'AS')) continue;
|
|
153
|
+
let j = i + 1;
|
|
154
|
+
if (isWord(tokens[j], 'NOT')) j += 1;
|
|
155
|
+
if (isWord(tokens[j], 'MATERIALIZED')) j += 1;
|
|
156
|
+
if (tokens[j]?.v !== '(') continue;
|
|
157
|
+
let k = i - 1;
|
|
158
|
+
if (tokens[k]?.v === ')') { let depth = 0; do { if (tokens[k].v === ')') depth += 1; else if (tokens[k].v === '(') depth -= 1; k -= 1; } while (k >= 0 && depth > 0); }
|
|
159
|
+
const before = tokens[k - 1];
|
|
160
|
+
if (isName(tokens[k]) && (isWord(before, 'WITH') || isWord(before, 'RECURSIVE') || before?.v === ',')) names.add(nameOf(tokens[k]));
|
|
161
|
+
}
|
|
162
|
+
return names;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Depth of each token (the depth before it when it is `(`, after it when it is `)`). */
|
|
166
|
+
function depths(tokens) {
|
|
167
|
+
let depth = 0;
|
|
168
|
+
return tokens.map(token => {
|
|
169
|
+
if (token.v === ')' && token.t === 'punct') depth -= 1;
|
|
170
|
+
const here = depth;
|
|
171
|
+
if (token.v === '(' && token.t === 'punct') depth += 1;
|
|
172
|
+
return here;
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function matching(tokens, open) {
|
|
177
|
+
let depth = 0;
|
|
178
|
+
for (let i = open; i < tokens.length; i += 1) {
|
|
179
|
+
if (tokens[i].t !== 'punct') continue;
|
|
180
|
+
if (tokens[i].v === '(') depth += 1;
|
|
181
|
+
else if (tokens[i].v === ')') { depth -= 1; if (depth === 0) return i; }
|
|
182
|
+
}
|
|
183
|
+
return tokens.length - 1;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function isAggregateItem(item) {
|
|
187
|
+
for (let i = 0; i < item.length; i += 1) {
|
|
188
|
+
if (item[i].t === 'word' && AGGREGATES.has(item[i].up) && item[i + 1]?.v === '(') {
|
|
189
|
+
const close = matching(item, i + 1);
|
|
190
|
+
if (!isWord(item[close + 1], 'OVER')) return true;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** The unique-key columns `columns` (lowercase Set of `=`-constrained column names) satisfy for one of `uniqueSets`. */
|
|
197
|
+
const covers = (uniqueSets, columns) => uniqueSets.some(set => set.length > 0 && set.every(column => columns.has(column)));
|
|
198
|
+
|
|
199
|
+
function equalityColumns(conjunct, qualifiers) {
|
|
200
|
+
const found = new Set();
|
|
201
|
+
const name = (from, to) => {
|
|
202
|
+
const part = conjunct.slice(from, to);
|
|
203
|
+
if (part.length === 1 && isName(part[0])) return nameOf(part[0]);
|
|
204
|
+
if (part.length === 3 && isName(part[0]) && part[1].v === '.' && isName(part[2]) && qualifiers.has(nameOf(part[0]))) return nameOf(part[2]);
|
|
205
|
+
return null;
|
|
206
|
+
};
|
|
207
|
+
const equals = conjunct.findIndex(token => token.t === 'punct' && token.v === '=');
|
|
208
|
+
if (equals < 0) return found;
|
|
209
|
+
const left = name(0, equals);
|
|
210
|
+
const rightStart = conjunct[equals + 1];
|
|
211
|
+
if (left && !(rightStart?.t === 'word' && ['ANY', 'ALL', 'SOME'].includes(rightStart.up))) found.add(left);
|
|
212
|
+
const right = name(equals + 1, conjunct.length);
|
|
213
|
+
if (right) found.add(right);
|
|
214
|
+
return found;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Judge one statement that reads rows (no INSERT/UPDATE/DELETE/MERGE at its own depth): `{bounded, basis}`. Bounded is
|
|
219
|
+
* a LIMIT/FETCH at the statement depth, no FROM at all (one row), only aggregates without GROUP BY, or an equality on
|
|
220
|
+
* every column of the primary key or of one unique key of the first table of FROM.
|
|
221
|
+
*/
|
|
222
|
+
function selectBound(tokens, level, uniqueSetsOf, cte) {
|
|
223
|
+
const at0 = index => level[index] === 0;
|
|
224
|
+
const find = (up, from = 0) => { for (let i = from; i < tokens.length; i += 1) if (at0(i) && isWord(tokens[i], up)) return i; return -1; };
|
|
225
|
+
if (find('LIMIT') >= 0 || find('FETCH') >= 0) return { bounded: true, basis: 'limit' };
|
|
226
|
+
const select = find('SELECT');
|
|
227
|
+
const from = find('FROM', select);
|
|
228
|
+
if (from < 0) return { bounded: true, basis: 'no from' };
|
|
229
|
+
const groupBy = tokens.findIndex((token, i) => at0(i) && isWord(token, 'GROUP') && isWord(tokens[i + 1], 'BY'));
|
|
230
|
+
if (groupBy < 0) {
|
|
231
|
+
const items = [];
|
|
232
|
+
let item = [];
|
|
233
|
+
for (let i = select + 1; i < from; i += 1) {
|
|
234
|
+
if (at0(i) && tokens[i].v === ',' && tokens[i].t === 'punct') { items.push(item); item = []; continue; }
|
|
235
|
+
if (!(items.length === 0 && item.length === 0 && (isWord(tokens[i], 'DISTINCT') || isWord(tokens[i], 'ALL')))) item.push(tokens[i]);
|
|
236
|
+
}
|
|
237
|
+
items.push(item);
|
|
238
|
+
if (items.length && items.every(isAggregateItem)) return { bounded: true, basis: 'aggregate' };
|
|
239
|
+
}
|
|
240
|
+
const primary = tableRef(tokens, from + 1);
|
|
241
|
+
if (!primary || primary.dynamic || cte.has(primary.table)) return { bounded: false, basis: 'unbounded', table: primary?.table ?? null };
|
|
242
|
+
const uniqueSets = uniqueSetsOf(primary.table);
|
|
243
|
+
const where = find('WHERE', from);
|
|
244
|
+
if (uniqueSets?.length && where >= 0) {
|
|
245
|
+
const region = [];
|
|
246
|
+
for (let i = where + 1; i < tokens.length; i += 1) {
|
|
247
|
+
if (at0(i) && tokens[i].t === 'word' && WHERE_END.has(tokens[i].up)) break;
|
|
248
|
+
region.push({ token: tokens[i], level: level[i] });
|
|
249
|
+
}
|
|
250
|
+
if (!region.some(entry => entry.level === 0 && isWord(entry.token, 'OR'))) {
|
|
251
|
+
const conjuncts = [[]];
|
|
252
|
+
let between = false;
|
|
253
|
+
for (const entry of region) {
|
|
254
|
+
if (entry.level === 0 && isWord(entry.token, 'BETWEEN')) between = true;
|
|
255
|
+
if (entry.level === 0 && isWord(entry.token, 'AND')) {
|
|
256
|
+
if (between) { between = false; } else { conjuncts.push([]); continue; }
|
|
257
|
+
}
|
|
258
|
+
conjuncts.at(-1).push(entry.token);
|
|
259
|
+
}
|
|
260
|
+
const qualifiers = new Set([primary.table, primary.alias].filter(Boolean));
|
|
261
|
+
const columns = new Set();
|
|
262
|
+
for (const conjunct of conjuncts) for (const column of equalityColumns(conjunct, qualifiers)) columns.add(column);
|
|
263
|
+
if (covers(uniqueSets, columns)) return { bounded: true, basis: 'unique' };
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return { bounded: false, basis: 'unbounded', table: primary.table };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Read one SQL text. `uniqueSetsOf(table)` returns the unique-key column sets (each a lowercase column list) of the entity
|
|
271
|
+
* of `table`, or null when no entity declares it.
|
|
272
|
+
* Returns {writes: [{table, schema}], reads: [{table, schema}], selects: [{bounded, basis, table}], dynamic}.
|
|
273
|
+
*/
|
|
274
|
+
export function analyzeSql(text, { uniqueSetsOf = () => null } = {}) {
|
|
275
|
+
const writes = [];
|
|
276
|
+
const reads = [];
|
|
277
|
+
const selects = [];
|
|
278
|
+
let dynamic = 0;
|
|
279
|
+
for (const tokens of statementsOf(tokenizeSql(text))) {
|
|
280
|
+
const cte = cteNames(tokens);
|
|
281
|
+
const level = depths(tokens);
|
|
282
|
+
const parens = [];
|
|
283
|
+
const record = (list, ref) => {
|
|
284
|
+
if (!ref) return;
|
|
285
|
+
if (ref.dynamic) { dynamic += 1; return; }
|
|
286
|
+
if (ref.schema && SYSTEM_SCHEMAS.has(ref.schema)) return;
|
|
287
|
+
if (ref.table.startsWith('pg_')) return;
|
|
288
|
+
if (list === reads && !ref.schema && cte.has(ref.table)) return;
|
|
289
|
+
list.push({ table: ref.table, schema: ref.schema });
|
|
290
|
+
};
|
|
291
|
+
const mergeOrDelete = tokens.some(token => isWord(token, 'MERGE')) || tokens.some(token => isWord(token, 'DELETE'));
|
|
292
|
+
let hasWriteVerb = false;
|
|
293
|
+
for (let i = 0; i < tokens.length; i += 1) {
|
|
294
|
+
const token = tokens[i];
|
|
295
|
+
if (token.t === 'punct' && token.v === '(') { parens.push(i > 0 && tokens[i - 1].t === 'word' && FROM_INSIDE.has(tokens[i - 1].up)); continue; }
|
|
296
|
+
if (token.t === 'punct' && token.v === ')') { parens.pop(); continue; }
|
|
297
|
+
if (token.t !== 'word') continue;
|
|
298
|
+
const previous = tokens[i - 1];
|
|
299
|
+
if (token.up === 'INSERT' && isWord(tokens[i + 1], 'INTO')) { if (level[i] === 0) hasWriteVerb = true; record(writes, tableRef(tokens, i + 2, { columns: true })); }
|
|
300
|
+
else if (token.up === 'UPDATE' && !(previous?.t === 'word' && NOT_A_WRITE_BEFORE_UPDATE.has(previous.up))) { if (level[i] === 0) hasWriteVerb = true; record(writes, tableRef(tokens, i + 1)); }
|
|
301
|
+
else if (token.up === 'DELETE' && isWord(tokens[i + 1], 'FROM')) { if (level[i] === 0) hasWriteVerb = true; record(writes, tableRef(tokens, i + 2)); i += 1; }
|
|
302
|
+
else if (token.up === 'MERGE' && isWord(tokens[i + 1], 'INTO')) { if (level[i] === 0) hasWriteVerb = true; record(writes, tableRef(tokens, i + 2)); }
|
|
303
|
+
else if (token.up === 'TRUNCATE') {
|
|
304
|
+
let j = i + 1;
|
|
305
|
+
if (isWord(tokens[j], 'TABLE')) j += 1;
|
|
306
|
+
for (;;) {
|
|
307
|
+
const ref = tableRef(tokens, j);
|
|
308
|
+
record(writes, ref);
|
|
309
|
+
if (ref && tokens[ref.next]?.v === ',') { j = ref.next + 1; continue; }
|
|
310
|
+
break;
|
|
311
|
+
}
|
|
312
|
+
} else if (token.up === 'FROM' && !isWord(previous, 'DISTINCT') && !isWord(previous, 'DELETE') && parens.at(-1) !== true) {
|
|
313
|
+
let j = i + 1;
|
|
314
|
+
for (;;) {
|
|
315
|
+
const ref = tableRef(tokens, j);
|
|
316
|
+
record(reads, ref);
|
|
317
|
+
if (ref && tokens[ref.next]?.v === ',' && tokens[ref.next].t === 'punct') { j = ref.next + 1; continue; }
|
|
318
|
+
break;
|
|
319
|
+
}
|
|
320
|
+
} else if (token.up === 'JOIN') record(reads, tableRef(tokens, i + 1));
|
|
321
|
+
else if (token.up === 'USING' && mergeOrDelete && tokens[i + 1]?.v !== '(') record(reads, tableRef(tokens, i + 1));
|
|
322
|
+
}
|
|
323
|
+
const hasSelect = tokens.some((candidate, i) => level[i] === 0 && isWord(candidate, 'SELECT'));
|
|
324
|
+
if (hasSelect && !hasWriteVerb) selects.push(selectBound(tokens, level, uniqueSetsOf, cte));
|
|
325
|
+
}
|
|
326
|
+
return { writes, reads, selects, dynamic };
|
|
327
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The lint surface: which architecture-machine findings an ESLint rule judges instead of `hfs check`.
|
|
3
|
+
* Single source of truth. Each enforcer id is the rules.yaml enforcer; `codes` are the exact violation.ruleId strings its
|
|
4
|
+
* check file emits on source files. plugins: 'be' = back-end profile repos, 'fe' = front-end profile, graph checks run for both.
|
|
5
|
+
*/
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
|
|
9
|
+
const BE = ['be'];
|
|
10
|
+
const FE = ['fe'];
|
|
11
|
+
const BOTH = ['be', 'fe'];
|
|
12
|
+
|
|
13
|
+
// `via` names the machine check (the key of its run in index.mjs) when two enforcers share a code; `origin: 'repo'` marks a finding of the
|
|
14
|
+
// slot manifest's per-path judgement (hfs-path-findings.mjs) instead of the machine. A finding reaches exactly one rule.
|
|
15
|
+
const enforcer = (id, plugins, codes, description, extra = {}) => Object.freeze({ id, plugins: Object.freeze(plugins), codes: Object.freeze(codes), description, ...extra });
|
|
16
|
+
|
|
17
|
+
export const LINT_ENFORCERS = Object.freeze([
|
|
18
|
+
enforcer('duplicate-code', BOTH, ['HFS_DUPLICATE_CODE'], 'A block of production code never repeats elsewhere in the owner graph.'),
|
|
19
|
+
enforcer('duplicate-symbol', BOTH, ['HFS_DUPLICATE_SYMBOL'], 'A symbol name is declared once across the production source.'),
|
|
20
|
+
enforcer('alias-reexport', BOTH, ['HFS_ALIAS_REEXPORT'], 'An owner entry re-exports a symbol under its own name, never through a const alias.'),
|
|
21
|
+
// cross-app-duplicate emits only FE_CROSS_APP_DUPLICATE today; HFS_DUPLICATE_CODE is named in its comment only.
|
|
22
|
+
enforcer('cross-app-duplicate', FE, ['FE_CROSS_APP_DUPLICATE'], 'A file is never copied between apps; shared code lives in a package.'),
|
|
23
|
+
enforcer('dead-exports', BOTH, ['HFS_UNUSED_EXPORT', 'HFS_UNUSED_FILE'], 'Every owner export and every production file is reached by a root.'),
|
|
24
|
+
enforcer('tier-direction', BOTH, ['BE_TIER_DIRECTION', 'FE_TIER_DIRECTION'], 'An import goes only to a tier the slot matrix allows.'),
|
|
25
|
+
enforcer('owner-cycle', BOTH, ['ARCH_OWNER_CYCLE'], 'Owners never import each other in a cycle.'),
|
|
26
|
+
enforcer('feature-imports-feature', BE, ['BE_FEATURE_IMPORTS_FEATURE'], 'A feature never imports another feature.'),
|
|
27
|
+
enforcer('app-isolation', FE, ['FE_APP_ISOLATION'], 'An app never imports another app.'),
|
|
28
|
+
enforcer('feature-layout', BE, ['BE_FEATURE_LAYOUT_INVALID'], 'A feature file sits in the folder its role names.'),
|
|
29
|
+
enforcer('application-never-imports-transport', BE, ['BE_APPLICATION_IMPORTS_TRANSPORT', 'BE_APPLICATION_TRANSPORT_FRAMEWORK'], 'Application code never imports transport code, directly or through a chain.'),
|
|
30
|
+
enforcer('owner-export-bypass', BOTH, ['ARCH_OWNER_EXPORT_BYPASS', 'ARCH_OWNER_EXPORT_STAR'], 'Another owner is imported only through its index entry, and an entry never uses export star.'),
|
|
31
|
+
enforcer('feature-not-composed', BE, ['BE_FEATURE_NOT_COMPOSED'], 'Every feature owner is composed into an app.'),
|
|
32
|
+
enforcer('owner-reachable', FE, ['FE_OWNER_REACHABLE', 'FE_HREF_RESOLVES'], 'Every owner is mounted by a route and every href resolves to a route.'),
|
|
33
|
+
enforcer('app-composition-only', BE, ['BE_APP_COMPOSITION_ONLY', 'BE_APP_BUSINESS_ROLE'], 'An app only composes modules and holds no business role.'),
|
|
34
|
+
enforcer('entrypoint-only-in-apps', BE, ['BE_ENTRYPOINT_ONLY_IN_APPS'], 'NestFactory and bootstrap live only in an app main file.'),
|
|
35
|
+
enforcer('schema-owner', BE, ['BE_SCHEMA_OWNER'], 'Each entity array is registered by one owner across all apps.'),
|
|
36
|
+
enforcer('error-code-unique', BE, ['BE_ERROR_HOME'], 'An error code is declared once in the repository.'),
|
|
37
|
+
enforcer('error-masked', BE, ['BE_ERROR_MASKED'], 'Every app wires the error filter that masks internal errors.'),
|
|
38
|
+
enforcer('default-deny-app-guard', BE, ['BE_DEFAULT_DENY'], 'Every app registers the default-deny guard before any other guard.'),
|
|
39
|
+
// BE_MODULE_SHAPE is emitted by both register-once and module-per-transport.
|
|
40
|
+
enforcer('register-once', BE, ['BE_MODULE_SHAPE'], 'A module is registered once and imported by one module.', { via: 'registerOnce' }),
|
|
41
|
+
enforcer('module-per-transport', BE, ['BE_MODULE_SHAPE'], 'An app imports transport modules only.', { via: 'modulePerTransport' }),
|
|
42
|
+
enforcer('module-registration', BE, ['BE_MODULE_HANDLER_REGISTRATION', 'BE_MODULE_PROVIDER_REREGISTRATION'], 'Every handler is registered by a module and no provider is registered twice.'),
|
|
43
|
+
enforcer('background-unowned', BE, ['BE_BACKGROUND_UNOWNED'], 'Every background job is reachable from a worker app.'),
|
|
44
|
+
enforcer('test-world-files', BE, ['BE_TEST_TOPOLOGY'], 'The test world files match the stack declaration.'),
|
|
45
|
+
enforcer('unit-spec-providers', BE, ['BE_SPEC_QUALITY'], 'A unit spec provides exactly the dependencies its service constructor takes.'),
|
|
46
|
+
enforcer('transport-owner', FE, ['FE_TRANSPORT_OWNER'], 'One client and one outcome type own the transport of the repository.'),
|
|
47
|
+
enforcer('swr-data-lifecycle', FE, ['FE_SWR_KEY_IDENTITY', 'FE_SWR_MUTATION_RESOURCE_IDENTITY'], 'SWR keys and mutations share one resource identity.'),
|
|
48
|
+
enforcer('route-files-thin', FE, ['FE_ROUTE_FILES_THIN'], 'A route file only mounts one feature owner.'),
|
|
49
|
+
enforcer('route-adapter', FE, ['FE_ROUTE_ONE_PAGE', 'FE_ROUTE_DRAWING_DECISION', 'FE_ROUTE_CLIENT_BOUNDARY', 'FE_ROUTE_CLIENT_HOOK', 'FE_ROUTE_DEFAULT_EXPORT'], 'A page route is a server adapter that mounts one pages-tier component.'),
|
|
50
|
+
enforcer('client-reaches-server', FE, ['FE_CLIENT_REACHES_SERVER'], 'Client code never reaches server-only code through any import chain.'),
|
|
51
|
+
enforcer('hooks-are-hooks', FE, ['FE_HOOKS_ARE_HOOKS'], 'A hooks folder holds hooks only, one shared file per domain.'),
|
|
52
|
+
enforcer('hook-location', FE, ['FE_CUSTOM_HOOK_LOCATION', 'FE_BLOCK_PRODUCT_HOOK_DEFINITION', 'FE_COMPONENT_DEEP_HOOK_IMPORT'], 'A custom hook is defined in the hooks folder and imported through its entry.'),
|
|
53
|
+
enforcer('connection-map', BE, ['BE_CONNECTION_DUPLICATE'], 'Each database connection is declared once and matches the connection map.'),
|
|
54
|
+
enforcer('injection-token-exported', BE, ['BE_RAW_INJECT'], 'An injection token is exported by its owner and never injected raw.'),
|
|
55
|
+
enforcer('sql-owner', BE, ['BE_SQL_TABLE_OWNER'], 'A SQL table is touched only by the owner of its entity.'),
|
|
56
|
+
enforcer('readonly-boundary', BE, ['BE_READONLY_BOUNDARY'], 'Messages and injected classes crossing a boundary are readonly.'),
|
|
57
|
+
enforcer('source-names', BE, ['BE_SOURCE_FORM'], 'Names and forms inside a source file follow its role.'),
|
|
58
|
+
enforcer('public-contract-form', BE, ['BE_PUBLIC_CONTRACT_FORM'], 'A public signature is typed by a named contract, never inline or untyped.'),
|
|
59
|
+
enforcer('frontend-source-layout', FE, ['FE_SOURCE_LAYOUT_INVALID'], 'A front-end source file sits in a tier folder.'),
|
|
60
|
+
enforcer('component-purity', FE, ['FE_COMPONENT_WORLD_OWNERSHIP', 'FE_WORLD_OWNER_RENDER_BOUNDARY', 'FE_CONNECTED_BLOCK_RENDER_PAIR', 'FE_PURE_REACHES_DATA', 'FE_PURE_WORLD_HOOK', 'FE_PURE_WORLD_IMPORT'], 'A pure component reaches no data or world state; its connected entry owns them.'),
|
|
61
|
+
enforcer('grammar-entry', FE, ['ARCH_GRAMMAR_CONTRACT_INVALID', 'ARCH_GRAMMAR_EXPORT_BYPASS'], 'Vendor grammar is reached only through its owner entry.'),
|
|
62
|
+
enforcer('i18n-keys', FE, ['FE_I18N_KEYS'], 'Translation keys read by source and keys held by catalogs agree both ways.'),
|
|
63
|
+
// The per-path judgements of the slot manifest (scripts/lib/hfs-path-findings.mjs), on a tracked TypeScript file.
|
|
64
|
+
enforcer('slot-undeclared', BOTH, ['HFS_SLOT_UNDECLARED', 'HFS_SLOT_AMBIGUOUS', 'HFS_SLOT_NOT_ENABLED'], 'A TypeScript file is owned by exactly one slot of the repository.', { origin: 'repo' }),
|
|
65
|
+
enforcer('source-suffix', BE, ['BE_SOURCE_FORM'], 'A source file name is index.ts, main.ts, a migration or <kebab-name>.<suffix>.ts with a suffix of the closed list.', { origin: 'repo' }),
|
|
66
|
+
enforcer('spec-placement', BE, ['BE_SPEC_PLACEMENT'], 'A spec file lives in one of the four test layers and nowhere else.', { origin: 'repo' }),
|
|
67
|
+
// The two below are emitted into context.errors today, not into violations.
|
|
68
|
+
enforcer('package-imports-app', BOTH, ['ARCH_PACKAGE_IMPORTS_APP'], 'A package never imports an app.'),
|
|
69
|
+
enforcer('package-export-bypass', BOTH, ['ARCH_PACKAGE_EXPORT_BYPASS'], 'A package is imported only through its declared exports.'),
|
|
70
|
+
]);
|
|
71
|
+
|
|
72
|
+
export const LINT_CODES = new Set(LINT_ENFORCERS.flatMap(e => e.codes));
|
|
73
|
+
|
|
74
|
+
export const TS_SOURCE = /\.(?:[cm]?tsx?)$/;
|
|
75
|
+
|
|
76
|
+
/** True when a finding sits on an existing TypeScript file of the repository: the only findings an editor can show on a line. */
|
|
77
|
+
export function attachesToSource(repositoryRoot, violation) {
|
|
78
|
+
const file = violation?.path;
|
|
79
|
+
if (typeof file !== 'string' || !TS_SOURCE.test(file)) return false;
|
|
80
|
+
return fs.existsSync(path.join(repositoryRoot, ...file.split('/')));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The finding's code: a machine violation spells it `ruleId`, a repository finding `code`. */
|
|
84
|
+
export const codeOf = (finding) => finding?.ruleId ?? finding?.code;
|
|
85
|
+
|
|
86
|
+
/** Whether `finding` (with its origin and check tags) belongs to `enforcer`. */
|
|
87
|
+
export const belongsTo = (enforcer, finding) => enforcer.codes.includes(codeOf(finding))
|
|
88
|
+
&& (finding.origin ?? 'machine') === (enforcer.origin ?? 'machine') && (enforcer.via === undefined || finding.check === enforcer.via);
|
|
89
|
+
|
|
90
|
+
/** True when some lint rule owns the finding and it sits on an existing TypeScript file: it is an ESLint report, not a `hfs check` finding. */
|
|
91
|
+
export const onLintSurface = (repositoryRoot, finding) => LINT_ENFORCERS.some(enforcer => belongsTo(enforcer, finding)) && attachesToSource(repositoryRoot, finding);
|