@starci/eslint-canon-be 3.2.13 → 3.2.15

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.
Files changed (29) hide show
  1. package/CHANGELOG.md +13 -1
  2. package/package.json +1 -1
  3. package/runtime/knowledge/hfs/canon-pins.yaml +207 -15
  4. package/runtime/knowledge/hfs/fe-stack.yaml +218 -0
  5. package/runtime/knowledge/hfs/rules.yaml +51 -48
  6. package/runtime/knowledge/hfs/slots.yaml +319 -179
  7. package/runtime/knowledge/patterns/fe/folder.yaml +24 -18
  8. package/runtime/knowledge/sonar-gate.yaml +10 -2
  9. package/runtime/modules/kernel/removed-vocabulary.yaml +48 -0
  10. package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +0 -2
  11. package/runtime/scripts/hfs/architecture/config.mjs +3 -2
  12. package/runtime/scripts/hfs/architecture/dead-exports.mjs +6 -2
  13. package/runtime/scripts/hfs/architecture/error-masked.mjs +10 -1
  14. package/runtime/scripts/hfs/architecture/frontend.mjs +10 -3
  15. package/runtime/scripts/hfs/architecture/hfs.mjs +6 -3
  16. package/runtime/scripts/hfs/architecture/index.mjs +2 -0
  17. package/runtime/scripts/hfs/architecture/owners.mjs +19 -16
  18. package/runtime/scripts/hfs/architecture/route-files-thin.mjs +4 -1
  19. package/runtime/scripts/hfs/architecture/server-action.mjs +75 -20
  20. package/runtime/scripts/hfs/architecture/supabase-ast.mjs +2 -2
  21. package/runtime/scripts/hfs/architecture/supabase.mjs +36 -210
  22. package/runtime/scripts/hfs/architecture/tiers.mjs +1 -33
  23. package/runtime/scripts/hfs/architecture/transport-owner.mjs +110 -54
  24. package/runtime/scripts/hfs/architecture/type-context.mjs +4 -1
  25. package/runtime/scripts/hfs/edition-slots.mjs +13 -4
  26. package/runtime/scripts/hfs/manifest-shape.mjs +3 -3
  27. package/runtime/scripts/hfs/rule-catalog.mjs +21 -1
  28. package/runtime/scripts/hfs/rules/fe-no-tests.mjs +4 -5
  29. package/runtime/scripts/hfs/architecture/supabase-results.mjs +0 -182
@@ -86,9 +86,9 @@ export const MANIFEST_KINDS = [APP_KIND, RUNTIME_KIND];
86
86
  export const manifestKind = (m) => (isPlainObject(m) && m.kind !== undefined ? m.kind : APP_KIND);
87
87
  const strList = (v) => stringList(v);
88
88
 
89
- const APP_SLOT_KEYS = ['id', 'profiles', 'path', 'presence', 'tracked', 'tier', 'tests', 'owner', 'appKind', 'minInstances', 'requiredWhen', 'requiredInstances', 'requires', 'pattern', 'trigger', 'allows', 'forbids', 'layers', 'kinds', 'roles', 'composedBy', 'budget', 'managedBy', 'liteManagedBy', 'rules', 'goesTo', 'why', 'since', 'perConnection', 'parent', 'editions', 'litePresence', 'lite', 'provider', 'coverage', 'entries', 'outcomeHome', 'anonymousActions'];
89
+ const APP_SLOT_KEYS = ['id', 'profiles', 'path', 'presence', 'tracked', 'tier', 'tests', 'owner', 'appKind', 'minInstances', 'requiredWhen', 'requiredInstances', 'requires', 'pattern', 'trigger', 'allows', 'forbids', 'layers', 'kinds', 'roles', 'composedBy', 'budget', 'managedBy', 'liteManagedBy', 'rules', 'goesTo', 'why', 'since', 'perConnection', 'parent', 'editions', 'litePresence', 'lite', 'provider', 'coverage', 'entries', 'outcomeHome'];
90
90
  /** The fields a slot's `lite` overlay may hold: the same keys it would carry in the slot body, resolved under edition lite. */
91
- const LITE_OVERLAY_KEYS = ['path', 'requires', 'allows', 'forbids', 'minInstances', 'requiredInstances'];
91
+ const LITE_OVERLAY_KEYS = ['path', 'requires', 'allows', 'forbids', 'minInstances', 'requiredInstances', 'why'];
92
92
  /** A runtime slot has no app kind, side composition, layer or managed template; it may name the generator of a generated copy. */
93
93
  const RUNTIME_SLOT_KEYS = ['id', 'profiles', 'path', 'presence', 'tracked', 'tier', 'tests', 'owner', 'minInstances', 'requires', 'allows', 'forbids', 'budget', 'rules', 'goesTo', 'why', 'since', 'generatedBy', 'parent', 'coverage'];
94
94
 
@@ -197,6 +197,7 @@ function slotLiteOverlayProblems(slot, at) {
197
197
  if (slot.lite === undefined) return [];
198
198
  const ok = isPlainObject(slot.lite) && Object.keys(slot.lite).length && Object.keys(slot.lite).every((key) => LITE_OVERLAY_KEYS.includes(key))
199
199
  && (slot.lite.path === undefined || (typeof slot.lite.path === 'string' && slot.lite.path))
200
+ && (slot.lite.why === undefined || (typeof slot.lite.why === 'string' && slot.lite.why.trim()))
200
201
  && ['requires', 'allows', 'forbids'].every((key) => slot.lite[key] === undefined || strList(slot.lite[key]))
201
202
  && (slot.lite.minInstances === undefined || (Number.isInteger(slot.lite.minInstances) && slot.lite.minInstances >= 1))
202
203
  && (slot.lite.requiredInstances === undefined || (isPlainObject(slot.lite.requiredInstances) && Object.values(slot.lite.requiredInstances).every((v) => strList(v) && v.length)));
@@ -211,7 +212,6 @@ function slotEntryProblems(slot, at) {
211
212
  else if (typeof slot.path !== 'string' || !slot.path.endsWith('/') || slot.owner === true) bad.push(`${at}: entries belong to a directory slot that is not an owner (they name extra public entries of the owner that holds it)`);
212
213
  else if (Array.isArray(slot.allows) && slot.entries.some((entry) => !slot.allows.includes(entry))) bad.push(`${at}: every entry must be one of the names the slot allows`);
213
214
  }
214
- if (slot.anonymousActions !== undefined && !(strList(slot.anonymousActions) && slot.anonymousActions.length > 0 && new Set(slot.anonymousActions).size === slot.anonymousActions.length && slot.anonymousActions.every(relPath) && typeof slot.path === 'string' && slot.path.endsWith('/'))) bad.push(`${at}: anonymousActions must be a non-empty list of unique relative file paths of a directory slot`);
215
215
  if (slot.outcomeHome !== undefined && !(slot.outcomeHome === true && typeof slot.path === 'string' && slot.path && !slot.path.endsWith('/'))) bad.push(`${at}: outcomeHome is true and belongs to a file slot (the one file that declares the repository's Outcome union)`);
216
216
  return bad;
217
217
  }
@@ -15,7 +15,9 @@ const RULE_KINDS = Object.freeze(['codemod', 'lint', 'check', 'design']);
15
15
  const FINDING_CODE = /^[A-Z][A-Z0-9]*(_[A-Z0-9]+)+$/;
16
16
  const ENFORCER_ID = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
17
17
  const FILE_ENFORCERS = new Set(['machine', 'hfs', 'work-validate', 'sonar', 'runtime']);
18
- const RULE_KEYS = new Set(['id', 'code', 'law', 'scope', 'kinds', 'gates', 'failureCodes', 'editions', 'enforcers']);
18
+ const RULE_KEYS = new Set(['id', 'code', 'law', 'scope', 'kinds', 'gates', 'failureCodes', 'editions', 'enforcers', 'cases']);
19
+ const CASE_KEYS = new Set(['id', 'date', 'source', 'ruling', 'quote']);
20
+ const CASE_DATE = /^\d{4}-\d{2}-\d{2}$/;
19
21
  const ENFORCER_KEYS = new Set(['kind', 'id', 'status', 'at', 'editions']);
20
22
 
21
23
  function vocabularyProblems(catalog, key, names, bad) {
@@ -59,6 +61,23 @@ function checkRuleText(rule, label, bad) {
59
61
  if (rule.scope !== undefined && rule.scope !== 'runtime') bad.push(`${label}.scope is absent or runtime`);
60
62
  }
61
63
 
64
+ /** The dated owner cases of a rule: each {id case-N, date, source owner, ruling, quote?}, ids unique (an owner ruling recorded where the law lives). */
65
+ function checkRuleCases(rule, label, bad) {
66
+ if (rule.cases === undefined) return;
67
+ if (!Array.isArray(rule.cases) || !rule.cases.length) { bad.push(`${label}.cases must be a non-empty list`); return; }
68
+ const ids = new Set();
69
+ rule.cases.forEach((item, index) => {
70
+ const at = `${label}.cases[${index}]`;
71
+ if (!isPlainObject(item)) { bad.push(`${at} is not a map`); return; }
72
+ for (const key of Object.keys(item)) if (!CASE_KEYS.has(key)) bad.push(`${at} has unknown key ${key}`);
73
+ if (typeof item.id !== 'string' || !/^case-[1-9][0-9]*$/.test(item.id) || ids.has(item.id)) bad.push(`${at}.id must be a unique case-N`);
74
+ ids.add(item.id);
75
+ if (typeof item.date !== 'string' || !CASE_DATE.test(item.date)) bad.push(`${at}.date must be YYYY-MM-DD`);
76
+ if (item.source !== 'owner') bad.push(`${at}.source must be owner`);
77
+ for (const key of ['ruling', ...(item.quote === undefined ? [] : ['quote'])]) if (typeof item[key] !== 'string' || !item[key].trim()) bad.push(`${at}.${key} must be text`);
78
+ });
79
+ }
80
+
62
81
  function checkRuleGates(label, gates, bad) {
63
82
  if (!gates.length) return;
64
83
  if (!gates.includes('land')) bad.push(`${label} must run at the land gate (every rule does)`);
@@ -120,6 +139,7 @@ function checkRule(rule, index, catalog, codeOwner, bad) {
120
139
  }
121
140
  checkRuleIdentity(rule, index, catalog, at, label, bad);
122
141
  checkRuleText(rule, label, bad);
142
+ checkRuleCases(rule, label, bad);
123
143
  enumListProblems(rule, label, 'kinds', RULE_KINDS, bad);
124
144
  bad.push(...ruleEditionProblems(rule, label));
125
145
  const gates = enumListProblems(rule, label, 'gates', RULE_GATES, bad);
@@ -12,13 +12,12 @@ import { found, readJson } from './read.mjs';
12
12
  export const FE_NO_TESTS = 'FE_NO_TESTS';
13
13
  const SPEC_FILE = /(?:\.(?:spec|test)|-spec)\.[cm]?[jt]sx?$/;
14
14
  const TEST_DIRECTORY = /(?:^|\/)(?:e2e|__tests__|__mocks__|test-support)\//;
15
- export const TEST_TOOL_FILE = /(?:^|\/)(?:(?:vitest|jest|playwright)\.[^/]+|cypress\.config\.[^/]+|tsconfig\.e2e\.json|e2e\.ya?ml)$/;
16
- // The edition rule (rules/edition.mjs, L01) applies the same lists to the whole app tree in lite: they are the test vocabulary.
17
- export const TEST_SCRIPT_NAME = /^(?:pre|post)?test(?::|$)/;
15
+ const TEST_TOOL_FILE = /(?:^|\/)(?:(?:vitest|jest|playwright)\.[^/]+|cypress\.config\.[^/]+|tsconfig\.e2e\.json|e2e\.ya?ml)$/;
16
+ const TEST_SCRIPT_NAME = /^(?:pre|post)?test(?::|$)/;
18
17
  const TEST_RUNNER_COMMAND_SOURCE = String.raw`(?:^|[\s&|;(])(?:npx\s+)?(?:vitest|jest|playwright|cypress|mocha)(?=$|[\s&|;)])|\bnode\s+(?:--\S+\s+)*--test\b`;
19
- export const TEST_RUNNER_COMMAND = new RegExp(TEST_RUNNER_COMMAND_SOURCE);
18
+ const TEST_RUNNER_COMMAND = new RegExp(TEST_RUNNER_COMMAND_SOURCE);
20
19
  const TEST_DEPENDENCY_SOURCE = String.raw`^(?:vitest|@vitest\/.+|playwright|playwright-core|@playwright\/.+|jest|@jest\/.+|ts-jest|babel-jest|@types\/jest|jest-[\w-]+|mocha|@types\/mocha|cypress|@testing-library\/.+|jsdom|@types\/jsdom|happy-dom|axe-core|@axe-core\/.+|vitest-axe|@starci\/(?:vitest|jest|playwright)-preset)$`;
21
- export const TEST_DEPENDENCY = new RegExp(TEST_DEPENDENCY_SOURCE);
20
+ const TEST_DEPENDENCY = new RegExp(TEST_DEPENDENCY_SOURCE);
22
21
  export const DEPENDENCY_SECTIONS = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'];
23
22
 
24
23
  /** True for a path FE_NO_TESTS owns: a spec file, a test directory or a test-tool file. */
@@ -1,182 +0,0 @@
1
- import { chainParts, isSupabaseCall, isSupabaseValue, reportAt, unwrap } from './supabase-ast.mjs';
2
- import { machineKit } from './machine-ast.mjs';
3
-
4
- /**
5
- * The front-end Supabase result checks (FE_DB_RESULT_TYPED, FE_DB_ERROR_HANDLED): a query result keeps its inferred row type, a list read is
6
- * bounded, and the awaited result is either passed whole to `toOutcome` or has its error read.
7
- */
8
- const FE_RESULT_TYPED = 'FE_DB_RESULT_TYPED';
9
- const FE_ERROR_HANDLED = 'FE_DB_ERROR_HANDLED';
10
- export const FE_DB_SLOTS = new Set(['fe.modules.db', 'fe.package.db', 'fe.modules.db.outcome']);
11
- const QUERY_TERMINALS = new Set(['single', 'maybeSingle', 'returns']);
12
- const QUERY_WRITES = new Set(['insert', 'update', 'upsert', 'delete']);
13
- const LIST_BOUNDS = new Set(['gt', 'gte', 'lt', 'lte', 'range']);
14
-
15
- export const symbolOf = (kit, checker, node) => kit.aliased(checker, kit.symbolAt(checker, node)) ?? kit.symbolAt(checker, node);
16
-
17
- export const sameSymbol = (kit, checker, node, symbol) => Boolean(symbol && symbolOf(kit, checker, node) === symbol);
18
-
19
- const fromAwaitedSupabase = (kit, checker, node, seen = new Set()) => {
20
- const current = unwrap(kit.ts, node);
21
- if (!current || seen.has(current)) return false;
22
- seen.add(current);
23
- if (kit.ts.isCallExpression(current)) return isSupabaseCall(kit, checker, current);
24
- if (!kit.ts.isIdentifier(current)) return false;
25
- for (const declaration of kit.declarationsOf(checker, current)) {
26
- if (kit.ts.isVariableDeclaration(declaration) && declaration.initializer && fromAwaitedSupabase(kit, checker, declaration.initializer, seen)) return true;
27
- }
28
- return false;
29
- };
30
-
31
- const callIsToOutcome = (kit, checker, graph, call) => {
32
- if (!kit.ts.isCallExpression(call)) return false;
33
- const expression = call.expression;
34
- let name = null;
35
- if (kit.ts.isIdentifier(expression)) name = expression.text;
36
- else if (kit.ts.isPropertyAccessExpression(expression)) name = expression.name.text;
37
- if (name !== 'toOutcome') return false;
38
- return kit.declarationsOf(checker, kit.ts.isPropertyAccessExpression(expression) ? expression.name : expression)
39
- .some(declaration => {
40
- const rel = kit.graphPath(declaration);
41
- return rel !== null && FE_DB_SLOTS.has(graph.files.get(rel)?.slot);
42
- });
43
- };
44
-
45
- const parentCall = (ts, node) => {
46
- let current = node;
47
- while (current.parent && ts.isParenthesizedExpression(current.parent)) current = current.parent;
48
- return ts.isCallExpression(current.parent) ? current.parent : null;
49
- };
50
-
51
- /** True when `node` reads `.error` / `['error']` of the result symbol, or passes the result whole to toOutcome. */
52
- const readsResultError = (kit, checker, graph, node, result) => {
53
- const { ts } = kit;
54
- if (ts.isPropertyAccessExpression(node) && node.name.text === 'error' && sameSymbol(kit, checker, node.expression, result)) return true;
55
- if (ts.isElementAccessExpression(node) && ts.isStringLiteralLike(node.argumentExpression) && node.argumentExpression.text === 'error' && sameSymbol(kit, checker, node.expression, result)) return true;
56
- return ts.isCallExpression(node) && callIsToOutcome(kit, checker, graph, node)
57
- && node.arguments.some(argument => sameSymbol(kit, checker, unwrap(ts, argument), result));
58
- };
59
-
60
- /** A result bound to one identifier is handled when the file reads its error or hands it whole to toOutcome. */
61
- const identifierResultHandled = (kit, checker, graph, awaitNode, declaration) => {
62
- const result = symbolOf(kit, checker, declaration.name);
63
- let handled = false;
64
- kit.walk(awaitNode.getSourceFile(), node => {
65
- if (readsResultError(kit, checker, graph, node, result)) handled = true;
66
- return !handled;
67
- });
68
- return handled;
69
- };
70
-
71
- /** A result destructured into `{ error }` is handled when the bound error is referenced beyond its binding. */
72
- const destructuredResultHandled = (kit, checker, awaitNode, declaration) => {
73
- const errorBinding = declaration.name.elements.find(element => {
74
- const property = element.propertyName ?? element.name;
75
- return kit.ts.isIdentifier(property) && property.text === 'error';
76
- });
77
- if (!errorBinding || !kit.ts.isIdentifier(errorBinding.name)) return false;
78
- const error = symbolOf(kit, checker, errorBinding.name);
79
- let references = 0;
80
- kit.walk(awaitNode.getSourceFile(), node => {
81
- if (kit.ts.isIdentifier(node) && sameSymbol(kit, checker, node, error)) references += 1;
82
- return true;
83
- });
84
- return references > 1;
85
- };
86
-
87
- const handledAwait = (kit, checker, graph, awaitNode) => {
88
- const direct = parentCall(kit.ts, awaitNode);
89
- if (direct && callIsToOutcome(kit, checker, graph, direct) && direct.arguments.some(argument => argument === awaitNode || argument === awaitNode.parent)) return true;
90
- let cursor = awaitNode;
91
- while (cursor.parent && kit.ts.isParenthesizedExpression(cursor.parent)) cursor = cursor.parent;
92
- const declaration = kit.ts.isVariableDeclaration(cursor.parent) ? cursor.parent : null;
93
- if (!declaration) return false;
94
- if (kit.ts.isIdentifier(declaration.name)) return identifierResultHandled(kit, checker, graph, awaitNode, declaration);
95
- if (!kit.ts.isObjectBindingPattern(declaration.name)) return false;
96
- return destructuredResultHandled(kit, checker, awaitNode, declaration);
97
- };
98
-
99
- const dataSymbolFromAwait = (kit, checker, identifier) => {
100
- const symbol = symbolOf(kit, checker, identifier);
101
- for (const declaration of symbol?.declarations ?? []) {
102
- if (!kit.ts.isBindingElement(declaration) || !kit.ts.isObjectBindingPattern(declaration.parent)) continue;
103
- const property = declaration.propertyName ?? declaration.name;
104
- if (!kit.ts.isIdentifier(property) || property.text !== 'data') continue;
105
- const variable = declaration.parent.parent;
106
- if (kit.ts.isVariableDeclaration(variable) && variable.initializer && fromAwaitedSupabase(kit, checker, variable.initializer)) return symbol;
107
- }
108
- return null;
109
- };
110
-
111
- function inspectResultGeneric(scope, node) {
112
- const { ts, kit, checker, report } = scope;
113
- if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && QUERY_TERMINALS.has(node.expression.name.text)
114
- && node.typeArguments?.length && isSupabaseValue(kit, checker, node.expression.expression)) {
115
- report(FE_RESULT_TYPED, node, `${node.expression.name.text}<T>() supplies a result generic; derive the row type from Database and let the Supabase chain infer it.`);
116
- }
117
- }
118
-
119
- function inspectResultCast(scope, node) {
120
- const { ts, kit, checker, report } = scope;
121
- if ((ts.isAsExpression(node) || ts.isTypeAssertionExpression(node) || ts.isNonNullExpression(node)) && fromAwaitedSupabase(kit, checker, node.expression)) {
122
- report(FE_RESULT_TYPED, node, 'A Supabase query result is cast or non-null asserted; narrow its typed data/error outcome instead.');
123
- }
124
- }
125
-
126
- /** Counts a multi-row read and reports it when no limit, range or keyset bound applies. */
127
- function inspectListRead(scope, node, methods) {
128
- const isListRead = methods.includes('select') && !methods.some(method => QUERY_WRITES.has(method)) && !methods.includes('single') && !methods.includes('maybeSingle');
129
- if (!isListRead) return;
130
- scope.counters.listReads += 1;
131
- if (!methods.includes('limit') && !methods.some(method => LIST_BOUNDS.has(method))) {
132
- scope.report(FE_RESULT_TYPED, node, 'A Supabase list read has no .limit(), .range(), or keyset bound (.gt/.gte/.lt/.lte); bound every multi-row read.');
133
- }
134
- }
135
-
136
- function inspectAwaitedResult(scope, node) {
137
- const { ts, kit, checker, report, graph, counters } = scope;
138
- if (!ts.isAwaitExpression(node) || !isSupabaseValue(kit, checker, node.expression)) return;
139
- const expression = unwrap(ts, node.expression);
140
- if (!ts.isCallExpression(expression)) return;
141
- counters.awaited += 1;
142
- inspectListRead(scope, node, chainParts(ts, expression));
143
- if (!handledAwait(kit, checker, graph, node)) {
144
- report(FE_ERROR_HANDLED, node, 'The awaited Supabase result does not read its error and is not passed whole to toOutcome(result).');
145
- }
146
- }
147
-
148
- function inspectSwallowedData(scope, node) {
149
- const { ts, kit, checker, report } = scope;
150
- if (!(ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken && ts.isArrayLiteralExpression(node.right) && node.right.elements.length === 0)) return;
151
- const left = unwrap(ts, node.left);
152
- const swallowed = ts.isIdentifier(left) ? Boolean(dataSymbolFromAwait(kit, checker, left))
153
- : ts.isPropertyAccessExpression(left) && left.name.text === 'data' && fromAwaitedSupabase(kit, checker, left.expression);
154
- if (swallowed) report(FE_ERROR_HANDLED, node, 'Supabase data is collapsed with ?? []; refused or unavailable is not empty data. Pass the whole result to toOutcome.');
155
- }
156
-
157
- function inspectQueryResultNode(scope, node) {
158
- inspectResultGeneric(scope, node);
159
- inspectResultCast(scope, node);
160
- inspectAwaitedResult(scope, node);
161
- inspectSwallowedData(scope, node);
162
- return true;
163
- }
164
-
165
- export function checkFrontendQueryResults(input) {
166
- const kit = machineKit(input);
167
- const violations = [];
168
- const counters = { awaited: 0, listReads: 0 };
169
- for (const file of input.graph.files.values()) {
170
- const checker = kit.checkerOf(file.sourceFile);
171
- const seen = new Set();
172
- const report = (ruleId, node, message) => {
173
- const key = `${ruleId}:${node.pos}:${message}`;
174
- if (seen.has(key)) return;
175
- seen.add(key);
176
- reportAt(violations, kit, ruleId, file, node, message);
177
- };
178
- const scope = { ts: kit.ts, kit, checker, report, graph: input.graph, counters };
179
- kit.walk(file.sourceFile, node => inspectQueryResultNode(scope, node));
180
- }
181
- return { violations, coverage: { status: 'checked', awaited: counters.awaited, listReads: counters.listReads } };
182
- }