@starci/eslint-canon-be 3.0.9 → 3.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.
Files changed (151) hide show
  1. package/CHANGELOG.md +3 -1
  2. package/LICENSE +21 -0
  3. package/README.md +2 -1
  4. package/builders.mjs +1 -1
  5. package/comments.mjs +12 -21
  6. package/config-owner.mjs +1 -1
  7. package/idempotency.mjs +54 -5
  8. package/index.mjs +2 -0
  9. package/lib/config.mjs +4 -1
  10. package/lib/doors.mjs +3 -1
  11. package/module-shape.mjs +1 -2
  12. package/package.json +3 -2
  13. package/resilience.mjs +1 -1
  14. package/runtime/config.example.yaml +187 -0
  15. package/runtime/engine/by-code-unit.mjs +12 -0
  16. package/runtime/engine/config.mjs +247 -273
  17. package/runtime/engine/invalid-config.mjs +5 -5
  18. package/runtime/engine/model-config.mjs +90 -0
  19. package/runtime/engine/orca-config.mjs +3 -1
  20. package/runtime/engine/removed-vocabulary.mjs +28 -0
  21. package/runtime/engine/resources-config.mjs +52 -0
  22. package/runtime/engine/runtime-root.mjs +17 -0
  23. package/runtime/engine/secrets.mjs +134 -0
  24. package/runtime/engine/sonar-config.mjs +21 -0
  25. package/runtime/engine/temp-root.mjs +32 -0
  26. package/runtime/knowledge/hfs/canon-pins.yaml +18 -18
  27. package/runtime/knowledge/hfs/rules.yaml +102 -69
  28. package/runtime/knowledge/hfs/slots.yaml +11 -14
  29. package/runtime/modules/kernel/removed-vocabulary.yaml +168 -0
  30. package/runtime/modules/models/registry.yaml +25 -397
  31. package/runtime/modules/models/runtimes.yaml +92 -262
  32. package/runtime/modules/models/tiers.yaml +65 -0
  33. package/runtime/scripts/api/git/lib.mjs +36 -1
  34. package/runtime/scripts/hfs/allows.mjs +3 -3
  35. package/runtime/scripts/hfs/architecture/ast-walks.mjs +63 -50
  36. package/runtime/scripts/hfs/architecture/automatic-gates.mjs +96 -0
  37. package/runtime/scripts/hfs/architecture/backend.mjs +261 -175
  38. package/runtime/scripts/hfs/architecture/background-unowned.mjs +69 -34
  39. package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +55 -54
  40. package/runtime/scripts/hfs/architecture/clones.mjs +179 -106
  41. package/runtime/scripts/hfs/architecture/config-unread.mjs +5 -22
  42. package/runtime/scripts/hfs/architecture/config.mjs +95 -80
  43. package/runtime/scripts/hfs/architecture/connection-map.mjs +233 -141
  44. package/runtime/scripts/hfs/architecture/constructor-deps.mjs +15 -9
  45. package/runtime/scripts/hfs/architecture/context-coupling.mjs +76 -52
  46. package/runtime/scripts/hfs/architecture/context-map.mjs +215 -141
  47. package/runtime/scripts/hfs/architecture/context-owner.mjs +39 -30
  48. package/runtime/scripts/hfs/architecture/context-platform-tables.mjs +31 -17
  49. package/runtime/scripts/hfs/architecture/context-transaction.mjs +38 -25
  50. package/runtime/scripts/hfs/architecture/contract-fixture-guard.mjs +86 -40
  51. package/runtime/scripts/hfs/architecture/contracts-readonly.mjs +342 -0
  52. package/runtime/scripts/hfs/architecture/contracts.mjs +263 -455
  53. package/runtime/scripts/hfs/architecture/cross-app-duplicate.mjs +43 -34
  54. package/runtime/scripts/hfs/architecture/dead-exports.mjs +201 -154
  55. package/runtime/scripts/hfs/architecture/default-deny.mjs +118 -93
  56. package/runtime/scripts/hfs/architecture/doc-language.mjs +2 -1
  57. package/runtime/scripts/hfs/architecture/entrypoint.mjs +45 -30
  58. package/runtime/scripts/hfs/architecture/error-codes.mjs +19 -12
  59. package/runtime/scripts/hfs/architecture/error-masked.mjs +39 -26
  60. package/runtime/scripts/hfs/architecture/fe-slot-allows.mjs +3 -2
  61. package/runtime/scripts/hfs/architecture/feature-shape.mjs +26 -20
  62. package/runtime/scripts/hfs/architecture/framework-pinned.mjs +1 -1
  63. package/runtime/scripts/hfs/architecture/frontend-grammar.mjs +190 -0
  64. package/runtime/scripts/hfs/architecture/frontend-routing.mjs +186 -0
  65. package/runtime/scripts/hfs/architecture/frontend-world-render.mjs +311 -0
  66. package/runtime/scripts/hfs/architecture/frontend-world.mjs +328 -0
  67. package/runtime/scripts/hfs/architecture/frontend.mjs +109 -829
  68. package/runtime/scripts/hfs/architecture/hfs-graph.mjs +14 -2
  69. package/runtime/scripts/hfs/architecture/hfs.mjs +120 -288
  70. package/runtime/scripts/hfs/architecture/hooks-are-hooks.mjs +46 -26
  71. package/runtime/scripts/hfs/architecture/i18n-keys.mjs +114 -76
  72. package/runtime/scripts/hfs/architecture/index.mjs +158 -102
  73. package/runtime/scripts/hfs/architecture/injection-token-exported.mjs +33 -28
  74. package/runtime/scripts/hfs/architecture/machine-ast.mjs +180 -144
  75. package/runtime/scripts/hfs/architecture/managed-scripts.mjs +8 -2
  76. package/runtime/scripts/hfs/architecture/module-per-transport.mjs +150 -101
  77. package/runtime/scripts/hfs/architecture/next-data-contract.mjs +51 -43
  78. package/runtime/scripts/hfs/architecture/next-data-key.mjs +338 -0
  79. package/runtime/scripts/hfs/architecture/next-data.mjs +179 -427
  80. package/runtime/scripts/hfs/architecture/owners.mjs +27 -13
  81. package/runtime/scripts/hfs/architecture/package-shape.mjs +51 -23
  82. package/runtime/scripts/hfs/architecture/presentation.mjs +165 -0
  83. package/runtime/scripts/hfs/architecture/reachability.mjs +127 -61
  84. package/runtime/scripts/hfs/architecture/register-once.mjs +112 -63
  85. package/runtime/scripts/hfs/architecture/registration.mjs +187 -110
  86. package/runtime/scripts/hfs/architecture/required-files.mjs +100 -68
  87. package/runtime/scripts/hfs/architecture/route-files-thin.mjs +83 -58
  88. package/runtime/scripts/hfs/architecture/schema-owner.mjs +182 -108
  89. package/runtime/scripts/hfs/architecture/source-names-shape.mjs +318 -0
  90. package/runtime/scripts/hfs/architecture/source-names.mjs +83 -248
  91. package/runtime/scripts/hfs/architecture/sql-owner.mjs +175 -118
  92. package/runtime/scripts/hfs/architecture/sql-returning.mjs +39 -28
  93. package/runtime/scripts/hfs/architecture/sql-tokens.mjs +302 -172
  94. package/runtime/scripts/hfs/architecture/supabase-ast.mjs +1 -1
  95. package/runtime/scripts/hfs/architecture/supabase-be.mjs +55 -45
  96. package/runtime/scripts/hfs/architecture/supabase-results.mjs +182 -0
  97. package/runtime/scripts/hfs/architecture/supabase-tables.mjs +20 -12
  98. package/runtime/scripts/hfs/architecture/supabase.mjs +191 -263
  99. package/runtime/scripts/hfs/architecture/symbols.mjs +175 -99
  100. package/runtime/scripts/hfs/architecture/test-world-files.mjs +82 -41
  101. package/runtime/scripts/hfs/architecture/tiers.mjs +120 -69
  102. package/runtime/scripts/hfs/architecture/transport-owner.mjs +84 -57
  103. package/runtime/scripts/hfs/architecture/type-context.mjs +282 -0
  104. package/runtime/scripts/hfs/architecture/typescript.mjs +116 -276
  105. package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +84 -62
  106. package/runtime/scripts/hfs/declaration-shape.mjs +77 -36
  107. package/runtime/scripts/hfs/declaration-slots.mjs +1 -1
  108. package/runtime/scripts/hfs/edition-slots.mjs +5 -3
  109. package/runtime/scripts/hfs/linear-text.mjs +30 -0
  110. package/runtime/scripts/hfs/manifest-shape.mjs +164 -63
  111. package/runtime/scripts/hfs/path-findings.mjs +58 -38
  112. package/runtime/scripts/hfs/project-graph.mjs +4 -1
  113. package/runtime/scripts/hfs/repo-identity.mjs +5 -2
  114. package/runtime/scripts/hfs/rule-catalog.mjs +194 -0
  115. package/runtime/scripts/hfs/rule-params-shape.mjs +33 -27
  116. package/runtime/scripts/hfs/rules/fe-no-tests.mjs +36 -16
  117. package/runtime/scripts/hfs/rules/secrets.mjs +4 -3
  118. package/runtime/scripts/hfs/slot-app-view.mjs +102 -0
  119. package/runtime/scripts/hfs/slot-classify.mjs +65 -0
  120. package/runtime/scripts/hfs/slot-errors.mjs +11 -0
  121. package/runtime/scripts/hfs/slot-imports.mjs +76 -0
  122. package/runtime/scripts/hfs/slot-manifest-shape.mjs +73 -0
  123. package/runtime/scripts/hfs/slot-match.mjs +153 -0
  124. package/runtime/scripts/hfs/slot-path.mjs +8 -0
  125. package/runtime/scripts/hfs/slot-required.mjs +76 -0
  126. package/runtime/scripts/hfs/slot-semantic-problems.mjs +148 -0
  127. package/runtime/scripts/hfs/slot-side-problems.mjs +28 -0
  128. package/runtime/scripts/hfs/slots.mjs +72 -693
  129. package/runtime/scripts/hfs/trailing-slashes.mjs +8 -0
  130. package/runtime/scripts/hfs/typescript-programs.mjs +6 -4
  131. package/runtime/scripts/hfs/view.mjs +1 -1
  132. package/runtime/scripts/lib/dockerfile.mjs +56 -33
  133. package/runtime/scripts/lib/git.mjs +2 -2
  134. package/runtime/scripts/lib/i18n.mjs +3 -2
  135. package/runtime/scripts/lib/jsdoc.mjs +35 -0
  136. package/runtime/scripts/lib/language.mjs +3 -3
  137. package/runtime/scripts/lib/list.mjs +8 -1
  138. package/runtime/scripts/lib/mutation-fence.mjs +15 -0
  139. package/runtime/scripts/lib/path-key.mjs +42 -9
  140. package/runtime/scripts/lib/regex.mjs +1 -1
  141. package/runtime/scripts/lib/secret-patterns.mjs +6 -2
  142. package/runtime/scripts/lib/sonar-syntax-rules.mjs +225 -0
  143. package/runtime/scripts/lib/sops-envelope.mjs +103 -2
  144. package/runtime/scripts/lib/stack-services.mjs +1 -2
  145. package/runtime/scripts/lib/ts-ast.mjs +9 -0
  146. package/runtime/scripts/lib/walk.mjs +6 -1
  147. package/schema-authority.mjs +1 -1
  148. package/service-deps.mjs +1 -1
  149. package/sonar-parity.mjs +19 -0
  150. package/suppression.mjs +2 -6
  151. package/test-world.mjs +2 -10
@@ -0,0 +1,225 @@
1
+ // sonar-syntax-rules.mjs - the ESLint rules that keep a TypeScript app clear of the smells SonarCloud flagged in the example apps, judged on
2
+ // the syntax tree alone (no type information, no dependency). Both lint canons (@starci/eslint-canon-be and -fe) publish them from their
3
+ // bundled runtime copy under the names below, so a product inherits them and `starci app lint` refuses the finding before the scan sees it.
4
+ // Each message names the Sonar rule it stands for, and a rule judges the files the example scans judge: a unit or end-to-end spec is outside the
5
+ // scan (`sonar.exclusions`), so it is outside these rules. The rules that need the type checker (S1874, S6551, S7503) are borrowed from
6
+ // typescript-eslint by the canon factories; the one that needs a regex ambiguity analysis (S8786) runs in the runtime's own `sonar-rules` check.
7
+
8
+ const FUNCTIONS = new Set(['FunctionDeclaration', 'FunctionExpression', 'ArrowFunctionExpression']);
9
+ const REPEATED = Object.freeze({
10
+ ForStatement: ['body', 'test', 'update'],
11
+ ForInStatement: ['body'],
12
+ ForOfStatement: ['body'],
13
+ WhileStatement: ['body', 'test'],
14
+ DoWhileStatement: ['body', 'test'],
15
+ });
16
+
17
+ /** The first ancestor of `node` that is `type`-matching per `test`, stopping at a function or class boundary. */
18
+ function ancestorWithin(node, test) {
19
+ for (let child = node, parent = node.parent; parent; child = parent, parent = parent.parent) {
20
+ if (FUNCTIONS.has(parent.type) || parent.type === 'StaticBlock') return null;
21
+ if (test(parent, child)) return parent;
22
+ }
23
+ return null;
24
+ }
25
+
26
+ const repeatsChild = (loop, child) => (REPEATED[loop.type] ?? []).some((key) => loop[key] === child);
27
+
28
+ /** A loop with no condition (`for (;;)`, `while (true)`) walks pages or polls: each turn needs the one before, so it cannot be run together. */
29
+ const isEndless = (loop) => (loop.type === 'ForStatement' && loop.test === null) || (['WhileStatement', 'DoWhileStatement'].includes(loop.type) && loop.test.type === 'Literal' && loop.test.value === true);
30
+
31
+ /** S9382: an `await` inside a loop body runs one step after the other; say the sequence by name or run the independent steps together. */
32
+ const noAwaitInLoop = {
33
+ meta: {
34
+ type: 'suggestion',
35
+ schema: [],
36
+ messages: { loop: 'S9382: do not `await` inside a loop. Run independent steps together with `Promise.all`, or state the sequence by name with a helper that chains the promises (`eachInOrder` of the back end\'s `platform/primitives`).' },
37
+ },
38
+ create: (context) => ({
39
+ AwaitExpression: (node) => {
40
+ if (ancestorWithin(node, (loop, child) => repeatsChild(loop, child) && !isEndless(loop))) context.report({ node, messageId: 'loop' });
41
+ },
42
+ }),
43
+ };
44
+
45
+ /** S7758: `codePointAt` and `fromCodePoint` handle every code point; `charCodeAt` and `fromCharCode` stop at 16 bits. */
46
+ const preferCodePoint = {
47
+ meta: { type: 'suggestion', schema: [], messages: { codePoint: 'S7758: use `{{ better }}` instead of `{{ worse }}`; it reads whole code points, not 16-bit halves.' } },
48
+ create: (context) => ({
49
+ CallExpression: (node) => {
50
+ const callee = node.callee;
51
+ if (callee.type !== 'MemberExpression' || callee.computed || callee.property.type !== 'Identifier') return;
52
+ const name = callee.property.name;
53
+ if (name === 'charCodeAt') context.report({ node, messageId: 'codePoint', data: { better: 'codePointAt', worse: 'charCodeAt' } });
54
+ else if (name === 'fromCharCode' && callee.object.type === 'Identifier' && callee.object.name === 'String') context.report({ node, messageId: 'codePoint', data: { better: 'String.fromCodePoint', worse: 'String.fromCharCode' } });
55
+ },
56
+ }),
57
+ };
58
+
59
+ const BACKSLASH_PAIR = '\\\\';
60
+
61
+ /** Whether the string literal only escapes backslashes: its value is its raw text with each pair read as one. */
62
+ function onlyBackslashEscapes(node) {
63
+ const raw = node.raw;
64
+ if (typeof node.value !== 'string' || !raw.includes(BACKSLASH_PAIR) || raw.at(-2) === '\\') return false;
65
+ if (node.value.includes('`') || node.value.includes('${')) return false;
66
+ return raw.slice(1, -1).replaceAll(BACKSLASH_PAIR, '\\') === node.value;
67
+ }
68
+
69
+ const KEEPS_ITS_ESCAPES = new Set(['ImportDeclaration', 'ExportAllDeclaration', 'ExportNamedDeclaration', 'TSLiteralType', 'TSExternalModuleReference', 'JSXAttribute']);
70
+
71
+ /** A property named `matcher` is read statically by Next (a template literal there kills the build), so its text keeps the escape. */
72
+ const isMatcherValue = (node) => (node.parent?.type === 'Property' && node.parent.value === node && node.parent.key?.name === 'matcher')
73
+ || (node.parent?.type === 'ArrayExpression' && isMatcherValue(node.parent));
74
+
75
+ /** S7780: a string whose only escapes are backslashes reads better as `String.raw`. */
76
+ const preferStringRaw = {
77
+ meta: { type: 'suggestion', schema: [], messages: { raw: 'S7780: write this string with `String.raw` so its backslashes are not escaped.' } },
78
+ create: (context) => ({
79
+ Literal: (node) => {
80
+ if (KEEPS_ITS_ESCAPES.has(node.parent?.type) || node.parent?.type === 'ExpressionStatement' || isMatcherValue(node)) return;
81
+ if (onlyBackslashEscapes(node)) context.report({ node, messageId: 'raw' });
82
+ },
83
+ }),
84
+ };
85
+
86
+ /** S3358: a conditional expression inside another conditional expression is read inside out; name the inner one. */
87
+ const noNestedConditional = {
88
+ meta: { type: 'suggestion', schema: [], messages: { nested: 'S3358: extract this nested conditional expression into its own statement or a named function.' } },
89
+ create: (context) => ({
90
+ ConditionalExpression: (node) => {
91
+ if (node.parent?.type === 'ConditionalExpression') context.report({ node, messageId: 'nested' });
92
+ },
93
+ }),
94
+ };
95
+
96
+ /** Whether `void` is used on a call: the explicit discard of a promise that Sonar and no-floating-promises both accept. */
97
+ const discardsACall = (argument) => argument.type === 'CallExpression' || argument.type === 'AwaitExpression' || (argument.type === 'ChainExpression' && argument.expression.type === 'CallExpression');
98
+
99
+ /** S3735: `void` on anything but a call hides what the code does; `void principal` says nothing, `undefined` or no statement says it. */
100
+ const noVoidOperator = {
101
+ meta: { type: 'suggestion', schema: [], messages: { void: 'S3735: remove this `void`; it discards a value the code never needed. Only `void call()` (an explicit discard of a promise) is kept.' } },
102
+ create: (context) => ({
103
+ UnaryExpression: (node) => {
104
+ if (node.operator === 'void' && !discardsACall(node.argument)) context.report({ node, messageId: 'void' });
105
+ },
106
+ }),
107
+ };
108
+
109
+ /** S1128: an import no reference reads is removed. */
110
+ const noUnusedImport = {
111
+ meta: { type: 'suggestion', schema: [], messages: { unused: 'S1128: remove the unused import of `{{ name }}`.' } },
112
+ create: (context) => ({
113
+ ImportDeclaration: (node) => {
114
+ for (const variable of context.sourceCode.getDeclaredVariables(node)) {
115
+ if (variable.references.length === 0) context.report({ node: variable.identifiers[0] ?? node, messageId: 'unused', data: { name: variable.name } });
116
+ }
117
+ },
118
+ }),
119
+ };
120
+
121
+ /** Whether `identifier` names an import of the file that nothing else reads. */
122
+ function onlyReExported(sourceCode, identifier) {
123
+ let scope = sourceCode.getScope(identifier);
124
+ while (scope?.set.has(identifier.name) === false) scope = scope.upper;
125
+ const variable = scope?.set.get(identifier.name);
126
+ const definition = variable?.defs[0];
127
+ return definition?.type === 'ImportBinding' && variable.references.length === 1;
128
+ }
129
+
130
+ /** S7763: an import that is only handed on is re-exported with `export ... from`. */
131
+ const preferExportFrom = {
132
+ meta: { type: 'suggestion', schema: [], messages: { reexport: 'S7763: use `export ... from` to re-export `{{ name }}` instead of importing it first.' } },
133
+ create: (context) => {
134
+ const { sourceCode } = context;
135
+ const check = (identifier, node) => {
136
+ if (identifier?.type === 'Identifier' && onlyReExported(sourceCode, identifier)) context.report({ node, messageId: 'reexport', data: { name: identifier.name } });
137
+ };
138
+ return {
139
+ ExportNamedDeclaration: (node) => {
140
+ if (node.source) return;
141
+ for (const specifier of node.specifiers) check(specifier.local, specifier);
142
+ for (const declarator of node.declaration?.declarations ?? []) if (!declarator.id.typeAnnotation) check(declarator.init, declarator);
143
+ },
144
+ ExportDefaultDeclaration: (node) => check(node.declaration, node),
145
+ };
146
+ },
147
+ };
148
+
149
+ /** The members a props type declares: [{name, node}] from a type literal, an interface body, `Readonly<...>` or an intersection of those declared in the file. */
150
+ function declaredMembers(program, type) {
151
+ if (!type) return [];
152
+ if (type.type === 'TSTypeLiteral') return type.members.filter((member) => !member.computed && member.key).map((member) => ({ name: member.key.name ?? member.key.value, node: member.key }));
153
+ if (type.type === 'TSIntersectionType') return type.types.flatMap((part) => declaredMembers(program, part));
154
+ if (type.type !== 'TSTypeReference' || type.typeName.type !== 'Identifier') return [];
155
+ if (type.typeName.name === 'Readonly') return declaredMembers(program, type.typeArguments?.params?.[0]);
156
+ const declaration = program.body.map((statement) => statement.declaration ?? statement).find((statement) => (statement.type === 'TSTypeAliasDeclaration' || statement.type === 'TSInterfaceDeclaration') && statement.id.name === type.typeName.name);
157
+ if (declaration?.type === 'TSInterfaceDeclaration') return declaredMembers(program, { type: 'TSTypeLiteral', members: declaration.body.body });
158
+ return declaredMembers(program, declaration?.typeAnnotation);
159
+ }
160
+
161
+ const keyName = (key) => (key.type === 'Identifier' ? key.name : key.value);
162
+
163
+ /** The prop names a use of the parameter variable reads, or null when it escapes whole (passed on, spread, indexed dynamically). */
164
+ function readNames(variable) {
165
+ const names = new Set();
166
+ for (const reference of variable.references) {
167
+ const parent = reference.identifier.parent;
168
+ if (parent.type === 'MemberExpression' && parent.object === reference.identifier && (!parent.computed || parent.property.type === 'Literal')) names.add(keyName(parent.property));
169
+ else if (parent.type === 'VariableDeclarator' && parent.init === reference.identifier && parent.id.type === 'ObjectPattern' && parent.id.properties.every((property) => property.type === 'Property' && !property.computed)) parent.id.properties.forEach((property) => names.add(keyName(property.key)));
170
+ else return null;
171
+ }
172
+ return names;
173
+ }
174
+
175
+ /** The names a parameter reads: an object pattern's own keys, or what its variable's uses read; null means "all of them". */
176
+ function readByParameter(sourceCode, fn, parameter) {
177
+ if (parameter.type === 'ObjectPattern') {
178
+ if (parameter.properties.some((property) => property.type !== 'Property' || property.computed)) return null;
179
+ return new Set(parameter.properties.map((property) => keyName(property.key)));
180
+ }
181
+ if (parameter.type !== 'Identifier') return null;
182
+ const variable = sourceCode.getDeclaredVariables(fn).find((candidate) => candidate.name === parameter.name);
183
+ return variable ? readNames(variable) : null;
184
+ }
185
+
186
+ /** S6767: a member of a component's props type that the component never reads is removed from the type. */
187
+ const noUnusedPropTypes = {
188
+ meta: { type: 'suggestion', schema: [], messages: { unused: 'S6767: the prop `{{ name }}` is declared in the props type but the component never reads it; remove it.' } },
189
+ create: (context) => {
190
+ const { sourceCode } = context;
191
+ const frames = [];
192
+ const enter = (node) => frames.push({ node, jsx: false });
193
+ const markJsx = () => frames.forEach((frame) => { frame.jsx = true; });
194
+ const exit = () => {
195
+ const { node, jsx } = frames.pop();
196
+ const parameter = node.params[0];
197
+ const annotation = parameter?.typeAnnotation?.typeAnnotation;
198
+ if (!jsx || !annotation) return;
199
+ const read = readByParameter(sourceCode, node, parameter);
200
+ if (read === null) return;
201
+ for (const member of declaredMembers(sourceCode.ast, annotation)) {
202
+ if (!read.has(member.name)) context.report({ node: member.node, messageId: 'unused', data: { name: member.name } });
203
+ }
204
+ };
205
+ return { FunctionDeclaration: enter, FunctionExpression: enter, ArrowFunctionExpression: enter, 'FunctionDeclaration:exit': exit, 'FunctionExpression:exit': exit, 'ArrowFunctionExpression:exit': exit, JSXElement: markJsx, JSXFragment: markJsx };
206
+ },
207
+ };
208
+
209
+ /** The files the example scans leave out: a unit spec and an end-to-end spec. */
210
+ const OUTSIDE_THE_SCAN = /\.(?:e2e-)?spec\.[cm]?[jt]sx?$/;
211
+
212
+ /** The rule judging only the files the scan judges. */
213
+ const scanned = (rule) => ({ ...rule, create: (context) => (OUTSIDE_THE_SCAN.test(context.filename) ? {} : rule.create(context)) });
214
+
215
+ /** The rules both canons publish, by published name. */
216
+ export const SONAR_SYNTAX_RULES = Object.freeze({
217
+ 'no-await-in-loop': scanned(noAwaitInLoop),
218
+ 'prefer-code-point': scanned(preferCodePoint),
219
+ 'prefer-string-raw': scanned(preferStringRaw),
220
+ 'no-nested-conditional': scanned(noNestedConditional),
221
+ 'no-void-operator': scanned(noVoidOperator),
222
+ 'no-unused-import': scanned(noUnusedImport),
223
+ 'prefer-export-from': scanned(preferExportFrom),
224
+ 'no-unused-prop-types': scanned(noUnusedPropTypes),
225
+ });
@@ -25,7 +25,12 @@ export function testSecretPaths(name, { repo, stack = 'dev' } = {}) {
25
25
  export const setCommand = (name = '<name>', stack = 'dev') => `node scripts/stack-secret.mjs set ${stack}/secrets/test/${name}`;
26
26
 
27
27
  /** sops' format for a plaintext path, as the repository's stack-secret.mjs formatFor decides it. */
28
- export const sopsFormatFor = (file) => (file.endsWith('.env') ? 'dotenv' : /\.json$/.test(file) ? 'json' : /\.(ya?ml|kubeconfig)$/.test(file) ? 'yaml' : 'binary');
28
+ export const sopsFormatFor = (file) => {
29
+ if (file.endsWith('.env')) return 'dotenv';
30
+ if (file.endsWith('.json')) return 'json';
31
+ if (/\.(ya?ml|kubeconfig)$/.test(file)) return 'yaml';
32
+ return 'binary';
33
+ };
29
34
 
30
35
  /** Every leaf outside the `sops` block is an ENC[...] value (or null); the block itself carries a mac. */
31
36
  const allEncrypted = (node) => {
@@ -48,7 +53,7 @@ export function isSopsEnvelope(text) {
48
53
  if (!src.trim()) return false;
49
54
  try { return sopsTree(JSON.parse(src)); } catch { /* not JSON */ }
50
55
  const lines = src.split(/\r?\n/).filter((l) => l.trim() && !/^\s*#/.test(l));
51
- if (lines.length && lines.every((l) => /^[A-Za-z_][A-Za-z0-9_]*=/.test(l))) {
56
+ if (lines.length && lines.every((l) => /^[A-Za-z_]\w*=/.test(l))) {
52
57
  const pairs = lines.map((l) => [l.slice(0, l.indexOf('=')), l.slice(l.indexOf('=') + 1)]);
53
58
  const meta = pairs.filter(([k]) => k.startsWith('sops_'));
54
59
  const vals = pairs.filter(([k]) => !k.startsWith('sops_'));
@@ -56,3 +61,99 @@ export function isSopsEnvelope(text) {
56
61
  }
57
62
  try { return sopsTree(parseYaml(src)); } catch { return false; }
58
63
  }
64
+
65
+ /** Admit one flat age envelope for the native selected-identity invocation; this is shape admission, never cryptographic proof. */
66
+ const refusal = (reason) => ({ ok: false, reason });
67
+
68
+ function dotenvMetadataValue(meta, age, key, value) {
69
+ if (key === 'age__list_0__map_recipient') age.recipient = value;
70
+ else if (key === 'age__list_0__map_enc') age.enc = value;
71
+ else if (key === 'mac_only_encrypted') {
72
+ if (value !== 'false') return 'partial-mac';
73
+ meta[key] = false;
74
+ } else if (key.includes('__')) return 'unsupported-key-group';
75
+ else meta[key] = value;
76
+ return null;
77
+ }
78
+
79
+ function readDotenvMetadata(text) {
80
+ const pairs = new Map();
81
+ for (const line of String(text).split('\n')) {
82
+ if (!line || line.startsWith('#')) continue;
83
+ if (line.includes('\r')) return { refusal: 'unsupported-dotenv' };
84
+ const at = line.indexOf('=');
85
+ if (at < 1) return { refusal: 'unsupported-dotenv' };
86
+ const name = line.slice(0, at);
87
+ if (pairs.has(name)) return { refusal: 'duplicate-key' };
88
+ pairs.set(name, line.slice(at + 1).replaceAll(String.raw`\n`, '\n'));
89
+ }
90
+ const meta = {};
91
+ const age = {};
92
+ for (const [name, value] of pairs) {
93
+ if (!name.startsWith('sops_')) continue;
94
+ const reason = dotenvMetadataValue(meta, age, name.slice('sops_'.length), value);
95
+ if (reason) return { refusal: reason };
96
+ }
97
+ meta.age = [age];
98
+ return { meta };
99
+ }
100
+
101
+ function readStructuredMetadata(text, format) {
102
+ const doc = parseYaml(String(text));
103
+ if (format !== 'yaml') JSON.parse(String(text));
104
+ if (format === 'binary' && (!doc || Object.keys(doc).length !== 2 || !Object.hasOwn(doc, 'data') || !Object.hasOwn(doc, 'sops') || typeof doc.data !== 'string'))
105
+ return { refusal: 'unsupported-binary-shape' };
106
+ return { meta: doc?.sops };
107
+ }
108
+
109
+ function readAgeMetadata(text, format) {
110
+ try {
111
+ return format === 'dotenv' ? readDotenvMetadata(text) : readStructuredMetadata(text, format);
112
+ } catch { return { refusal: 'invalid-envelope' }; }
113
+ }
114
+
115
+ function hasNonAgeProvider(meta, providers) {
116
+ for (const name of providers) {
117
+ if (Object.hasOwn(meta, name) && (!Array.isArray(meta[name]) || meta[name].length)) return true;
118
+ }
119
+ return false;
120
+ }
121
+
122
+ function isAgeEntry(entry) {
123
+ return !!entry && typeof entry === 'object' && !Array.isArray(entry)
124
+ && !Object.keys(entry).some(name => !['recipient', 'enc'].includes(name));
125
+ }
126
+
127
+ function hasValidAgePayload(entry) {
128
+ return typeof entry.enc === 'string' && entry.enc.startsWith('-----BEGIN AGE ENCRYPTED FILE-----\n')
129
+ && entry.enc.includes('\n-----END AGE ENCRYPTED FILE-----');
130
+ }
131
+
132
+ function hasValidEnvelopeVersion(meta) {
133
+ return typeof meta.version === 'string' && /^\d+\.\d+\.\d+$/.test(meta.version)
134
+ && typeof meta.lastmodified === 'string' && Number.isFinite(Date.parse(meta.lastmodified));
135
+ }
136
+
137
+ function validateAgeMetadata(meta, text, recipient) {
138
+ if (!meta || typeof meta !== 'object' || Array.isArray(meta) || !isSopsEnvelope(text)) return refusal('invalid-envelope');
139
+ if (Object.hasOwn(meta, 'key_groups') || Object.hasOwn(meta, 'shamir_threshold')) return refusal('unsupported-key-group');
140
+ const providers = ['kms', 'gcp_kms', 'hckms', 'azure_kv', 'hc_vault', 'pgp'];
141
+ if (hasNonAgeProvider(meta, providers)) return refusal('non-age-provider');
142
+ const fields = new Set([...providers, 'age', 'lastmodified', 'mac', 'version', 'unencrypted_suffix', 'encrypted_suffix', 'unencrypted_regex', 'encrypted_regex', 'unencrypted_comment_regex', 'encrypted_comment_regex', 'mac_only_encrypted']);
143
+ if (Object.keys(meta).some(name => !fields.has(name))) return refusal('unknown-metadata');
144
+ if (Object.hasOwn(meta, 'mac_only_encrypted') && meta.mac_only_encrypted !== false) return refusal('partial-mac');
145
+ if (!Array.isArray(meta.age) || meta.age.length !== 1) return refusal('unsupported-age-set');
146
+ const entry = meta.age[0];
147
+ if (!isAgeEntry(entry)) return refusal('invalid-age-entry');
148
+ if (typeof entry.recipient !== 'string' || entry.recipient !== recipient) return refusal('recipient-mismatch');
149
+ if (!hasValidAgePayload(entry)) return refusal('invalid-age-entry');
150
+ if (!hasValidEnvelopeVersion(meta)) return refusal('invalid-envelope');
151
+ return { ok: true };
152
+ }
153
+
154
+ export function selectedAgeEnvelope(text, format, recipient) {
155
+ if (!['binary', 'json', 'yaml', 'dotenv'].includes(format)) return refusal('unsupported-format');
156
+ const parsed = readAgeMetadata(text, format);
157
+ if (parsed.refusal) return refusal(parsed.refusal);
158
+ return validateAgeMetadata(parsed.meta, text, recipient);
159
+ }
@@ -2,6 +2,7 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { parseYaml } from '../../engine/yaml.mjs';
4
4
  import { words } from './dockerfile.mjs';
5
+ import { asList } from './list.mjs';
5
6
 
6
7
  /**
7
8
  * The services of a repository's own stack definition, read from `.starcistacks/application-stacks.yaml` and the compose
@@ -74,8 +75,6 @@ const imagePathOf = image => {
74
75
  /** True when the image reference carries a tag or digest (an external image, not a placeholder of the repository's own build). */
75
76
  const isTagged = image => image.includes('@') || image.slice(image.lastIndexOf('/') + 1).includes(':');
76
77
 
77
- const asList = value => (Array.isArray(value) ? value : value === undefined || value === null ? [] : [value]);
78
-
79
78
  /** A compose `environment` (map or `KEY=VALUE` list) as a plain object; a `*_FILE` variable becomes the plain variable with the test value. */
80
79
  const environmentOf = value => {
81
80
  const entries = Array.isArray(value)
@@ -2,6 +2,15 @@
2
2
  // literal text, a node's 1-based position, a named property's initializer text. `ts` is the caller's
3
3
  // already-resolved compiler (see package-at.mjs loadTypescript); nothing here resolves a package.
4
4
 
5
+ /** Whether the single-bit `flag` (a ts.NodeFlags member) is set in `flags`: the bit read as arithmetic, no bitwise operator in the condition. */
6
+ export const hasFlag = (flags, flag) => Math.floor(flags / flag) % 2 === 1;
7
+
8
+ /** Whether any bit of `mask` (a ts flag or an OR of flags) is set in `flags` (absent flags read as none): an explicit comparison of the masked value. */
9
+ export const hasAnyFlag = (flags, mask) => ((flags ?? 0) & mask) !== 0;
10
+
11
+ /** Whether the variable `declaration` belongs to a `const` list: the TypeScript node-flag test, an explicit comparison of the masked flags. */
12
+ export const isConstVariable = (ts, declaration) => (ts.getCombinedNodeFlags(declaration.parent) & ts.NodeFlags.Const) !== 0;
13
+
5
14
  /** The text of an identifier or a string-literal-like `node`, or null (property names, literal specifiers). */
6
15
  export const nameText = (ts, node) => (node && (ts.isIdentifier(node) || ts.isStringLiteralLike(node)) ? node.text : null);
7
16
 
@@ -20,7 +20,12 @@ export function walkFiles(dir, {filter = () => true, sorted = false, exclude = (
20
20
  if (depth > maxDepth) return;
21
21
  let entries;
22
22
  try { entries = fs.readdirSync(current, {withFileTypes: true}); }
23
- catch (error) { if (ignoreReadErrors) return; throw error; }
23
+ catch (error) {
24
+ if (ignoreReadErrors) {
25
+ return;
26
+ }
27
+ throw error;
28
+ }
24
29
  if (sorted) entries = entries.sort((a, b) => a.name.localeCompare(b.name));
25
30
  for (const entry of entries) {
26
31
  const full = path.join(current, entry.name);
@@ -224,7 +224,7 @@ export const noRuntimeSchema = {
224
224
  const isEntityDecorator = (checker, decorator) => {
225
225
  const callee = ts.isCallExpression(decorator.expression) ? decorator.expression.expression : decorator.expression
226
226
  let symbol = checker.getSymbolAtLocation(callee)
227
- if (symbol && symbol.flags & ts.SymbolFlags.Alias) symbol = checker.getAliasedSymbol(symbol)
227
+ if (symbol && (symbol.flags & ts.SymbolFlags.Alias) !== 0) symbol = checker.getAliasedSymbol(symbol)
228
228
  return symbol?.name === "Entity" && (symbol.declarations ?? []).some((declaration) => packageOfFile(declaration.getSourceFile().fileName) === "typeorm")
229
229
  }
230
230
 
package/service-deps.mjs CHANGED
@@ -59,7 +59,7 @@ const isClassToken = (context, annotation) => {
59
59
  if (!tsNode) return false
60
60
  const type = checker.getTypeFromTypeNode(tsNode)
61
61
  const symbol = type.getSymbol()
62
- if (!symbol || !(symbol.flags & ts.SymbolFlags.Class)) return false
62
+ if (!symbol || (symbol.flags & ts.SymbolFlags.Class) === 0) return false
63
63
  const declaration = symbol.declarations?.find((entry) => ts.isClassDeclaration(entry) || ts.isClassExpression(entry))
64
64
  return Boolean(declaration)
65
65
  }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * The law that keeps the code clear of the smells SonarCloud measures (R235 `SCAN_SMELL`).
3
+ *
4
+ * Each rule is the syntax-tree form of one Sonar rule, shared with the front-end canon (scripts/lib/sonar-syntax-rules.mjs,
5
+ * bundled under `runtime/`): `no-await-in-loop` (S9382), `prefer-code-point` (S7758), `prefer-string-raw` (S7780),
6
+ * `no-nested-conditional` (S3358), `no-void-operator` (S3735), `no-unused-import` (S1128) and `prefer-export-from` (S7763).
7
+ * The three Sonar rules that need the type checker, S1874 (a deprecated API), S6551 (an object turned into text) and S7503 (an `async`
8
+ * function that never awaits and returns no promise), are the typescript-eslint rules `no-deprecated`, `no-base-to-string` and
9
+ * `require-await` the factory turns on.
10
+ */
11
+ import { SONAR_SYNTAX_RULES } from "./runtime/scripts/lib/sonar-syntax-rules.mjs"
12
+
13
+ const NAMES = ["no-await-in-loop", "prefer-code-point", "prefer-string-raw", "no-nested-conditional", "no-void-operator", "no-unused-import", "prefer-export-from"]
14
+
15
+ /** The rules this law contributes to the plugin. */
16
+ export const rules = Object.fromEntries(NAMES.map((name) => [name, SONAR_SYNTAX_RULES[name]]))
17
+
18
+ /** Starts at error: a repository's fix lane clears the debt before the rule ships. */
19
+ export const recommended = Object.fromEntries(NAMES.map((name) => [`starci-be/${name}`, "error"]))
package/suppression.mjs CHANGED
@@ -9,8 +9,7 @@
9
9
  * - TypeScript: `@ts-ignore`, `@ts-expect-error` and `@ts-nocheck`, wherever they sit in a comment;
10
10
  * - coverage: `istanbul ignore`, `c8 ignore`, `v8 ignore`;
11
11
  * - Sonar: `NOSONAR` and `sonar-disable`;
12
- * - Prettier: `prettier-ignore`;
13
- * - the `vn-ok` language marker.
12
+ * - Prettier: `prettier-ignore`.
14
13
  *
15
14
  * The factory also sets `noInlineConfig: true`, so ESLint itself ignores such a comment; this rule is what turns the
16
15
  * ignored comment into a failure a person reads, instead of a silent no-op that looks like an exemption. There is no
@@ -24,7 +23,6 @@ const TS_DIRECTIVE = /@ts-(?:ignore|expect-error|nocheck)\b/
24
23
  const COVERAGE_IGNORE = /(?:^|[\s*/])(?:istanbul|c8|v8)\s+ignore\b/
25
24
  const SONAR = /\bNOSONAR\b|\bsonar-(?:disable|enable)\b/i
26
25
  const PRETTIER_IGNORE = /(?:^|[\s*/])prettier-ignore\b/
27
- const LANGUAGE_MARKER = /\bvn-ok\b/
28
26
 
29
27
  /** The finding a comment is, or null when it is ordinary prose. `/* global x *\/` is a block-comment form only. */
30
28
  const kindOf = (comment) => {
@@ -35,7 +33,6 @@ const kindOf = (comment) => {
35
33
  if (COVERAGE_IGNORE.test(text)) return "coverage"
36
34
  if (SONAR.test(text)) return "sonar"
37
35
  if (PRETTIER_IGNORE.test(text)) return "prettier"
38
- if (LANGUAGE_MARKER.test(text)) return "vn-ok"
39
36
  return null
40
37
  }
41
38
 
@@ -43,7 +40,7 @@ const kindOf = (comment) => {
43
40
  export const noInlineSuppression = {
44
41
  meta: {
45
42
  type: "problem",
46
- docs: { description: "No eslint, TypeScript, coverage, Sonar or Prettier suppression comment, and no vn-ok marker." },
43
+ docs: { description: "No eslint, TypeScript, coverage, Sonar or Prettier suppression comment." },
47
44
  schema: [],
48
45
  messages: {
49
46
  eslint: "An `eslint-disable`/`eslint-enable`, `eslint-env`, `/* global */` or rule-configuration comment. HFS does not switch a rule off or declare a global in place: fix the code, or propose a change to the rule in the canon.",
@@ -51,7 +48,6 @@ export const noInlineSuppression = {
51
48
  coverage: "An `istanbul`/`c8`/`v8 ignore` comment hides code from coverage. Write the test that covers it, or delete the code that nothing reaches.",
52
49
  sonar: "A `NOSONAR` or `sonar-disable` comment hides a finding from Sonar. Fix the finding.",
53
50
  prettier: "A `prettier-ignore` comment exempts code from the formatter. Let the formatter write it.",
54
- "vn-ok": "A `vn-ok` marker exempts a line from the language rule. HFS keeps no exemption marker: write the text in English, or move the data to a locale or data file.",
55
51
  },
56
52
  },
57
53
  create(context) {
package/test-world.mjs CHANGED
@@ -145,8 +145,7 @@ const plainKey = (property) => {
145
145
 
146
146
  /**
147
147
  * The world an integration spec (`{ modules }`, with real peer apps beside them as `apps` when its client calls another app of
148
- * ours) or an e2e spec (`{ apps }`, never `modules`) asks for; every such spec asks for one; the retired name `useE2eWorld`
149
- * exists nowhere.
148
+ * ours) or an e2e spec (`{ apps }`, never `modules`) asks for; every such spec asks for one.
150
149
  */
151
150
  export const testWorldShape = {
152
151
  meta: {
@@ -158,21 +157,14 @@ export const testWorldShape = {
158
157
  apps: "An e2e spec boots applications: call `useTestWorld({ apps: {...} })`, not `modules`.",
159
158
  literal: "The options of `useTestWorld(...)` must be one object literal with plain keys: a variable, a spread or a computed key hides whether the spec asks for `modules` or `apps`.",
160
159
  missing: "This spec never calls `useTestWorld(...)`: integration and e2e specs run inside the one test world.",
161
- retired: "`useE2eWorld` is the retired name of `useTestWorld`; there is one name, no alias.",
162
160
  },
163
161
  },
164
162
  create(context) {
165
163
  const slot = hfsOf(context).slotOf(context.filename || context.getFilename())
166
164
  const wants = slot === "be.tests.integration" ? "modules" : slot === "be.tests.e2e" ? "apps" : null
167
- const retired = {
168
- Identifier(node) {
169
- if (node.name === "useE2eWorld") context.report({ node, messageId: "retired" })
170
- },
171
- }
172
- if (!wants) return retired
165
+ if (!wants) return {}
173
166
  let seen = false
174
167
  return {
175
- ...retired,
176
168
  CallExpression(node) {
177
169
  if (node.callee.type !== "Identifier" || node.callee.name !== "useTestWorld") return
178
170
  seen = true