@starci/eslint-canon-fe 8.2.18 → 8.4.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 (92) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +19 -1
  3. package/callback-shape.mjs +344 -0
  4. package/client-boundary.mjs +1 -1
  5. package/docs/hooks-folder.md +1 -1
  6. package/docs/size-and-state-budget.md +6 -6
  7. package/drawing.mjs +65 -28
  8. package/env-owner.mjs +120 -9
  9. package/function-budget.mjs +49 -0
  10. package/hooks-folder.mjs +149 -18
  11. package/icon.mjs +67 -0
  12. package/index.mjs +47 -0
  13. package/lib/own-hook-origin.mjs +53 -0
  14. package/lib/program-for.mjs +22 -0
  15. package/lib/pure-owner-helper.mjs +148 -0
  16. package/lib/redux-hook-origin.mjs +44 -0
  17. package/next-conventions.mjs +38 -35
  18. package/package.json +1 -1
  19. package/runtime/knowledge/hfs/canon-pins.yaml +11 -11
  20. package/runtime/knowledge/hfs/fe-stack.yaml +22 -1
  21. package/runtime/knowledge/hfs/slots.yaml +430 -92
  22. package/runtime/knowledge/patterns/fe/folder.yaml +4 -0
  23. package/runtime/knowledge/sonar-gate.yaml +1 -1
  24. package/runtime/modules/models/runtimes.yaml +1 -1
  25. package/runtime/packages/hfs/templates/app/package-scripts/package.json +1 -0
  26. package/runtime/scripts/api/git/cat-file.mjs +5 -0
  27. package/runtime/scripts/api/git/diff.mjs +5 -0
  28. package/runtime/scripts/api/git/lib.mjs +3 -0
  29. package/runtime/scripts/api/git/merge-base.mjs +9 -0
  30. package/runtime/scripts/api/git/recorded-lines.mjs +54 -8
  31. package/runtime/scripts/hfs/architecture/backend-app-files.mjs +103 -0
  32. package/runtime/scripts/hfs/architecture/backend.mjs +22 -10
  33. package/runtime/scripts/hfs/architecture/background-unowned.mjs +157 -41
  34. package/runtime/scripts/hfs/architecture/canon-clones.mjs +240 -0
  35. package/runtime/scripts/hfs/architecture/canon-jsx-clones.mjs +143 -0
  36. package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +6 -3
  37. package/runtime/scripts/hfs/architecture/clones.mjs +17 -8
  38. package/runtime/scripts/hfs/architecture/connection-map.mjs +14 -12
  39. package/runtime/scripts/hfs/architecture/context-map.mjs +25 -14
  40. package/runtime/scripts/hfs/architecture/context-owner.mjs +1 -2
  41. package/runtime/scripts/hfs/architecture/dead-exports-bindings.mjs +149 -0
  42. package/runtime/scripts/hfs/architecture/dead-exports.mjs +23 -50
  43. package/runtime/scripts/hfs/architecture/default-deny.mjs +8 -6
  44. package/runtime/scripts/hfs/architecture/frontend-block-render.mjs +104 -0
  45. package/runtime/scripts/hfs/architecture/frontend-world-render.mjs +30 -11
  46. package/runtime/scripts/hfs/architecture/frontend-world.mjs +1 -1
  47. package/runtime/scripts/hfs/architecture/function-spec-providers.mjs +153 -0
  48. package/runtime/scripts/hfs/architecture/hfs.mjs +3 -3
  49. package/runtime/scripts/hfs/architecture/i18n-keys.mjs +16 -3
  50. package/runtime/scripts/hfs/architecture/machine-ast.mjs +17 -0
  51. package/runtime/scripts/hfs/architecture/module-per-transport.mjs +49 -25
  52. package/runtime/scripts/hfs/architecture/next-data-inference.mjs +176 -0
  53. package/runtime/scripts/hfs/architecture/next-data-symbols.mjs +26 -0
  54. package/runtime/scripts/hfs/architecture/next-data.mjs +31 -23
  55. package/runtime/scripts/hfs/architecture/reachability.mjs +14 -0
  56. package/runtime/scripts/hfs/architecture/register-once.mjs +15 -7
  57. package/runtime/scripts/hfs/architecture/required-files.mjs +2 -1
  58. package/runtime/scripts/hfs/architecture/source-names.mjs +48 -3
  59. package/runtime/scripts/hfs/architecture/staged-consumers.mjs +37 -0
  60. package/runtime/scripts/hfs/architecture/supabase-ast.mjs +214 -17
  61. package/runtime/scripts/hfs/architecture/supabase-be.mjs +1 -6
  62. package/runtime/scripts/hfs/architecture/supabase.mjs +59 -21
  63. package/runtime/scripts/hfs/architecture/surface.mjs +2 -1
  64. package/runtime/scripts/hfs/architecture/tiers.mjs +11 -4
  65. package/runtime/scripts/hfs/architecture/typescript.mjs +1 -8
  66. package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +9 -13
  67. package/runtime/scripts/hfs/declaration-shape.mjs +39 -3
  68. package/runtime/scripts/hfs/edition-slots.mjs +2 -2
  69. package/runtime/scripts/hfs/manifest-shape.mjs +91 -6
  70. package/runtime/scripts/hfs/path-findings.mjs +32 -2
  71. package/runtime/scripts/hfs/project-rule.mjs +3 -1
  72. package/runtime/scripts/hfs/rule-catalog.mjs +156 -11
  73. package/runtime/scripts/hfs/rule-params-shape.mjs +22 -10
  74. package/runtime/scripts/hfs/rules/secrets.mjs +26 -0
  75. package/runtime/scripts/hfs/slot-imports.mjs +18 -3
  76. package/runtime/scripts/hfs/slot-required.mjs +13 -0
  77. package/runtime/scripts/hfs/slot-semantic-problems.mjs +2 -1
  78. package/runtime/scripts/hfs/slots.mjs +1 -0
  79. package/runtime/scripts/lib/env.mjs +20 -0
  80. package/runtime/scripts/lib/event-contract.mjs +107 -0
  81. package/runtime/scripts/lib/json-clone.mjs +61 -0
  82. package/runtime/scripts/lib/list.mjs +4 -0
  83. package/runtime/scripts/lib/secret-patterns.mjs +60 -5
  84. package/runtime/scripts/lib/stack-services.mjs +1 -1
  85. package/runtime/scripts/lib/ts-analysis.mjs +23 -0
  86. package/runtime/scripts/lib/ts-ast.mjs +27 -2
  87. package/served-locale.mjs +80 -9
  88. package/size-and-state-budget.mjs +11 -8
  89. package/size-growth.mjs +10 -6
  90. package/swr-family.mjs +197 -0
  91. package/translation.mjs +135 -7
  92. package/transport.mjs +126 -59
@@ -0,0 +1,149 @@
1
+ // Import and namespace consumption analysis shared by dead-export edges.
2
+ import path from 'node:path';
3
+
4
+ /** Disk/staged test consumers have no production checker; bind their local names without resolving dependencies. */
5
+ const namespaceChecker = (context, sourceFile, namespace) => {
6
+ if (context.namespaceCheckers.has(sourceFile)) return context.namespaceCheckers.get(sourceFile);
7
+ const { ts } = context;
8
+ let checker = context.checkerFor?.(sourceFile.fileName);
9
+ if (!checker?.getSymbolAtLocation(namespace.name)) {
10
+ const options = { noResolve: true, noLib: true, types: [], allowJs: true, target: ts.ScriptTarget.Latest };
11
+ const host = ts.createCompilerHost(options);
12
+ const filename = path.resolve(sourceFile.fileName);
13
+ host.getSourceFile = file => path.resolve(file) === filename ? sourceFile : undefined;
14
+ host.fileExists = file => path.resolve(file) === filename;
15
+ host.readFile = file => path.resolve(file) === filename ? sourceFile.text : undefined;
16
+ checker = ts.createProgram([filename], options, host).getTypeChecker();
17
+ }
18
+ context.namespaceCheckers.set(sourceFile, checker);
19
+ return checker;
20
+ };
21
+
22
+ const transparentExpression = (ts, node) => node && (ts.isParenthesizedExpression(node) || ts.isAsExpression(node)
23
+ || ts.isTypeAssertionExpression(node) || ts.isNonNullExpression(node) || ts.isSatisfiesExpression(node));
24
+
25
+ const literalMember = (ts, node) => {
26
+ while (transparentExpression(ts, node)) node = node.expression;
27
+ return node && (ts.isStringLiteralLike(node) || ts.isNumericLiteral(node)) ? node.text : null;
28
+ };
29
+
30
+ const destructuredMember = (ts, name) => {
31
+ if (ts.isIdentifier(name)) return name.text;
32
+ if (ts.isComputedPropertyName(name)) return literalMember(ts, name.expression);
33
+ return literalMember(ts, name);
34
+ };
35
+
36
+ /** A destructuring alias consumes the property's name, while a rest/computed binding may consume every export. */
37
+ const destructuredBindings = (ts, pattern) => {
38
+ const names = [];
39
+ const elements = ts.isObjectBindingPattern(pattern) ? pattern.elements : pattern.properties;
40
+ for (const element of elements) {
41
+ if (element.dotDotDotToken || ts.isSpreadAssignment(element)) return { all: true, pairs: [] };
42
+ const name = destructuredMember(ts, element.propertyName ?? element.name);
43
+ if (name === null) return { all: true, pairs: [] };
44
+ names.push(name);
45
+ }
46
+ return { all: false, pairs: names.map(imported => ({ imported, exported: null })) };
47
+ };
48
+
49
+ const namespaceReference = (ts, checker, symbol, node) => {
50
+ if (checker.getSymbolAtLocation(node) === symbol) return true;
51
+ const parent = node.parent;
52
+ if (ts.isShorthandPropertyAssignment(parent) && parent.name === node) return checker.getShorthandAssignmentValueSymbol(parent) === symbol;
53
+ return ts.isExportSpecifier(parent) && node === (parent.propertyName ?? parent.name)
54
+ && checker.getExportSpecifierLocalTargetSymbol(parent) === symbol;
55
+ };
56
+
57
+ const unwrappedNamespace = (ts, node) => {
58
+ while (transparentExpression(ts, node.parent)) node = node.parent;
59
+ return node;
60
+ };
61
+
62
+ const indexedNamespaceMember = (ts, node) => {
63
+ if (!ts.isTypeQueryNode(node.parent) || node.parent.exprName !== node) return null;
64
+ let query = node.parent;
65
+ while (ts.isParenthesizedTypeNode(query.parent)) query = query.parent;
66
+ const indexed = query.parent;
67
+ return ts.isIndexedAccessTypeNode(indexed) && indexed.objectType === query && ts.isLiteralTypeNode(indexed.indexType)
68
+ ? literalMember(ts, indexed.indexType.literal) : null;
69
+ };
70
+
71
+ /** The first member is the entry export even in a chain such as namespace.Enum.Member or namespace.Type.Inner. */
72
+ const namespaceUse = (ts, reference) => {
73
+ const node = unwrappedNamespace(ts, reference);
74
+ const parent = node.parent;
75
+ let name = null;
76
+ if (ts.isPropertyAccessExpression(parent) && parent.expression === node) name = parent.name.text;
77
+ else if (ts.isQualifiedName(parent) && parent.left === node) name = parent.right.text;
78
+ else if (ts.isElementAccessExpression(parent) && parent.expression === node) name = literalMember(ts, parent.argumentExpression);
79
+ else if (ts.isVariableDeclaration(parent) && parent.initializer === node && ts.isObjectBindingPattern(parent.name)) return destructuredBindings(ts, parent.name);
80
+ else if (ts.isBinaryExpression(parent) && parent.right === node && parent.operatorToken.kind === ts.SyntaxKind.EqualsToken
81
+ && ts.isObjectLiteralExpression(parent.left)) return destructuredBindings(ts, parent.left);
82
+ else name = indexedNamespaceMember(ts, node);
83
+ return name === null ? { all: true, pairs: [] } : { all: false, pairs: [{ imported: name, exported: null }] };
84
+ };
85
+
86
+ const namespaceBindings = (context, declaration, namespace) => {
87
+ if (context.namespaceUses.has(namespace)) return context.namespaceUses.get(namespace);
88
+ const { ts } = context;
89
+ const sourceFile = declaration.getSourceFile();
90
+ const checker = namespaceChecker(context, sourceFile, namespace);
91
+ const symbol = checker.getSymbolAtLocation(namespace.name);
92
+ const result = { all: false, pairs: [] };
93
+ const visit = node => {
94
+ if (ts.isJSDocSeeTag(node) || ts.isJSDocLink(node) || ts.isJSDocLinkCode(node) || ts.isJSDocLinkPlain(node)) return;
95
+ if (ts.isIdentifier(node) && node !== namespace.name && namespaceReference(ts, checker, symbol, node)) {
96
+ const used = namespaceUse(ts, node);
97
+ result.all ||= used.all;
98
+ result.pairs.push(...used.pairs);
99
+ }
100
+ if (!result.all) {
101
+ ts.forEachChild(node, visit);
102
+ for (const doc of node.jsDoc ?? []) visit(doc);
103
+ }
104
+ };
105
+ // An unavailable binding must never turn an unknown consumer into a false unused-export finding.
106
+ if (symbol) visit(sourceFile);
107
+ else result.all = true;
108
+ context.namespaceUses.set(namespace, result);
109
+ return result;
110
+ };
111
+
112
+ const importBindings = (context, declaration) => {
113
+ const { ts } = context;
114
+ const clause = declaration.importClause;
115
+ if (!clause) return { all: false, pairs: [] }; // side-effect import: uses nothing
116
+ const pairs = [];
117
+ if (clause.name) pairs.push({ imported: 'default', exported: null });
118
+ const named = clause.namedBindings;
119
+ if (named && named.kind === ts.SyntaxKind.NamespaceImport) {
120
+ const namespace = namespaceBindings(context, declaration, named);
121
+ return { all: namespace.all, pairs: [...pairs, ...namespace.pairs] };
122
+ }
123
+ if (named) for (const element of named.elements) pairs.push({ imported: (element.propertyName ?? element.name).text, exported: null });
124
+ return { all: false, pairs };
125
+ };
126
+
127
+ const exportBindings = (ts, declaration) => {
128
+ const clause = declaration.exportClause;
129
+ if (!clause || clause.kind === ts.SyntaxKind.NamespaceExport) return { all: true, pairs: [] };
130
+ return { all: false, pairs: clause.elements.map(element => ({ imported: (element.propertyName ?? element.name).text, exported: element.name.text })) };
131
+ };
132
+
133
+ const importTypeBindings = (ts, declaration) => {
134
+ let qualifier = declaration.qualifier;
135
+ if (!qualifier) return { all: true, pairs: [] };
136
+ while (qualifier.kind === ts.SyntaxKind.QualifiedName) qualifier = qualifier.left;
137
+ return { all: false, pairs: [{ imported: qualifier.text, exported: null }] };
138
+ };
139
+
140
+ /** Names an import/export declaration takes from its target: {all} or {pairs: [{imported, exported}]}. */
141
+ export function bindingsOf(context, declaration) {
142
+ const { ts } = context;
143
+ if (!declaration) return { all: true, pairs: [] };
144
+ if (declaration.kind === ts.SyntaxKind.ImportDeclaration) return importBindings(context, declaration);
145
+ if (declaration.kind === ts.SyntaxKind.ExportDeclaration) return exportBindings(ts, declaration);
146
+ if (declaration.kind === ts.SyntaxKind.ImportTypeNode) return importTypeBindings(ts, declaration);
147
+ // ImportEqualsDeclaration, dynamic import(), require(): the whole module is taken.
148
+ return { all: true, pairs: [] };
149
+ }
@@ -17,15 +17,16 @@
17
17
  * and contributes no name here.
18
18
  *
19
19
  * A name is used when a production file outside the owner imports it from the entry (type-only imports count). A
20
- * namespace import, `import x = require`, a dynamic import()/require(), an `export *` or `export * as` of the entry make
21
- * every name used. A file outside the owner that re-exports the name (`export { n as m } from`) uses it when `m` is itself
20
+ * namespace import uses its statically accessed members; a computed read or an escape of the namespace uses every name.
21
+ * `import x = require`, a dynamic import()/require(), an `export *` or `export * as` of the entry make every name used.
22
+ * A file outside the owner that re-exports the name (`export { n as m } from`) uses it when `m` is itself
22
23
  * used by the importers of that file; a re-exporting file nobody imports is a framework entry (a route file) and uses it.
23
24
  * Specs and tests are not in the graph, so an export used only by a spec is dead, on purpose, with these exceptions (unit test
24
25
  * standard): the unit spec of every unit role (ruleParams.be.unitRoles: `<name>.service.spec.ts`, `<name>.cli.spec.ts`) and of every logic role
25
26
  * (ruleParams.be.logicRoles: `<name>.policy.spec.ts`, `<name>.client.spec.ts`, ...), a
26
27
  * unit spec of a webhook, gateway or subscription door (`.webhook.spec.ts`, `.gateway.spec.ts`, `.subscription.spec.ts`: a door has no service of its own), a
27
28
  * `*.builder.ts` under src/tests/fixtures/builders, and a file of the test world (slots be.tests.world and be.tests.world.kit, which
28
- * open and migrate the real databases of the e2e run) read from disk count as consumers, because a spec can only provide an
29
+ * open and migrate the real databases of the e2e run) read from disk or staged in the git index count as consumers, because a spec can only provide an
29
30
  * Inject*() token or a param type the entry exports, and the world wires the same capabilities the apps do. An integration
30
31
  * spec (slot be.tests.integration, `src/tests/integration/<capability>/*.integration-spec.ts`) counts as a consumer of exactly one
31
32
  * owner: the integration (slot be.integrations, `src/modules/integrations/<provider>/`) whose provider folder is its capability
@@ -38,6 +39,8 @@ import { canonical } from './config.mjs';
38
39
  import path from 'node:path';
39
40
  import { treeOf } from './required-files.mjs';
40
41
  import { globExpression } from '../../lib/glob.mjs';
42
+ import { stagedConsumerSources } from './staged-consumers.mjs';
43
+ import { bindingsOf } from './dead-exports-bindings.mjs';
41
44
 
42
45
  export const DEAD_EXPORT_RULE_IDS = ['HFS_UNUSED_EXPORT', 'HFS_UNUSED_FILE'];
43
46
 
@@ -69,40 +72,6 @@ function extraEntries(graph, entry) {
69
72
  return names.flatMap(name => (name.includes('*') ? matches(name) : [`${found.root}/${name}`].filter(rel => graph.files.has(rel))));
70
73
  }
71
74
 
72
- const importBindings = (ts, declaration) => {
73
- const clause = declaration.importClause;
74
- if (!clause) return { all: false, pairs: [] }; // side-effect import: uses nothing
75
- const pairs = [];
76
- if (clause.name) pairs.push({ imported: 'default', exported: null });
77
- const named = clause.namedBindings;
78
- if (named && named.kind === ts.SyntaxKind.NamespaceImport) return { all: true, pairs: [] };
79
- if (named) for (const element of named.elements) pairs.push({ imported: (element.propertyName ?? element.name).text, exported: null });
80
- return { all: false, pairs };
81
- };
82
-
83
- const exportBindings = (ts, declaration) => {
84
- const clause = declaration.exportClause;
85
- if (!clause || clause.kind === ts.SyntaxKind.NamespaceExport) return { all: true, pairs: [] };
86
- return { all: false, pairs: clause.elements.map(element => ({ imported: (element.propertyName ?? element.name).text, exported: element.name.text })) };
87
- };
88
-
89
- const importTypeBindings = (ts, declaration) => {
90
- let qualifier = declaration.qualifier;
91
- if (!qualifier) return { all: true, pairs: [] };
92
- while (qualifier.kind === ts.SyntaxKind.QualifiedName) qualifier = qualifier.left;
93
- return { all: false, pairs: [{ imported: qualifier.text, exported: null }] };
94
- };
95
-
96
- /** Names an import/export declaration takes from its target: {all} or {pairs: [{imported, exported}]}. */
97
- function bindingsOf(ts, declaration) {
98
- if (!declaration) return { all: true, pairs: [] };
99
- if (declaration.kind === ts.SyntaxKind.ImportDeclaration) return importBindings(ts, declaration);
100
- if (declaration.kind === ts.SyntaxKind.ExportDeclaration) return exportBindings(ts, declaration);
101
- if (declaration.kind === ts.SyntaxKind.ImportTypeNode) return importTypeBindings(ts, declaration);
102
- // ImportEqualsDeclaration, dynamic import(), require(): the whole module is taken.
103
- return { all: true, pairs: [] };
104
- }
105
-
106
75
  const addExportName = (found, sourceFile, name, node) => {
107
76
  if (found.has(name)) return;
108
77
  found.set(name, sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1);
@@ -241,10 +210,12 @@ function inIntegrationOf(graph, to, provider) {
241
210
  }
242
211
 
243
212
  /** The pseudo edges of one test consumer file: its import/export declarations that resolve to graph files it may reach. */
244
- function consumerFileEdges({ ts, options, graph, config }, rel, provider) {
213
+ function consumerFileEdges({ ts, options, graph, config }, rel, provider, indexedText) {
245
214
  const abs = path.join(config.root, ...rel.split('/'));
246
- let text;
247
- try { text = fs.readFileSync(abs, 'utf8'); } catch { return []; }
215
+ let text = indexedText;
216
+ if (text === undefined) {
217
+ try { text = fs.readFileSync(abs, 'utf8'); } catch { return []; }
218
+ }
248
219
  const sourceFile = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true);
249
220
  const edges = [];
250
221
  for (const statement of sourceFile.statements) {
@@ -257,17 +228,19 @@ function consumerFileEdges({ ts, options, graph, config }, rel, provider) {
257
228
  }
258
229
 
259
230
  /**
260
- * Pseudo edges (read from disk, outside the production program) to graph files: from the unit role specs, the fixture builders and
231
+ * Pseudo edges (read from disk and staged index sources, outside the production program) to graph files: from the unit role specs, the fixture builders and
261
232
  * the test world files to anything, and from an integration spec only to the integration of its own provider folder.
262
233
  */
263
234
  function testConsumerEdges({ context, graph, config }) {
264
235
  const resolution = { ts: context.ts, options: context.projects?.[0]?.options ?? {}, graph, config };
265
- const consumers = [...treeOf(config.root).files]
266
- .map(file => ({ file, any: testConsumer(graph, file) }))
267
- .map(({ file, any }) => ({ file, provider: any ? null : integrationSpecProvider(graph, file), any }))
236
+ // Live lint keeps tracked disk consumers; staged snapshots add the consumers present in the same commit.
237
+ const staged = stagedConsumerSources(config.root, file => testConsumer(graph, file) || integrationSpecProvider(graph, file) !== null);
238
+ const consumers = [...[...treeOf(config.root).files].map(file => ({ file })), ...staged]
239
+ .map(source => ({ ...source, any: testConsumer(graph, source.file) }))
240
+ .map(({ file, text, any }) => ({ file, text, provider: any ? null : integrationSpecProvider(graph, file), any }))
268
241
  .filter(({ any, provider }) => any || provider !== null)
269
242
  .sort((a, b) => a.file.localeCompare(b.file));
270
- return consumers.flatMap(({ file: rel, provider }) => consumerFileEdges(resolution, rel, provider));
243
+ return consumers.flatMap(({ file: rel, text, provider }) => consumerFileEdges(resolution, rel, provider, text));
271
244
  }
272
245
 
273
246
  const edgeIndex = edges => {
@@ -307,7 +280,7 @@ const consumedNames = (context, file, ownerKey, depth, visiting) => {
307
280
  const result = { all: false, names: new Set() };
308
281
  for (const edge of context.edgesTo.get(file) ?? []) {
309
282
  if (ownerKey && unitOfFile(context.graph, edge.from) === ownerKey) continue;
310
- const bindings = bindingsOf(context.ts, edge.edge.declaration);
283
+ const bindings = bindingsOf(context, edge.edge.declaration);
311
284
  if (bindings.all) { result.all = true; continue; }
312
285
  for (const pair of bindings.pairs) consumeReexportedName(context, result, edge, pair, depth, visiting);
313
286
  }
@@ -316,10 +289,10 @@ const consumedNames = (context, file, ownerKey, depth, visiting) => {
316
289
 
317
290
  /** Adds to `used` the entry exports reached by a consumer outside the owner that imports a file the entry re-exports by name. */
318
291
  const addReexportedUses = (context, key, publicEntry, used) => {
319
- const { ts, graph } = context;
292
+ const { graph } = context;
320
293
  for (const edge of graph.edges) {
321
294
  if (edge.from !== publicEntry || !edge.reexport || graph.unit(edge.to) !== key) continue;
322
- const reexport = bindingsOf(ts, edge.edge.declaration);
295
+ const reexport = bindingsOf(context, edge.edge.declaration);
323
296
  const inner = consumedNames(context, edge.to, key, 0, new Set([publicEntry, edge.to]));
324
297
  if (reexport.all) continue;
325
298
  for (const pair of reexport.pairs) if (inner.all || inner.names.has(pair.imported)) used.names.add(pair.exported);
@@ -337,7 +310,7 @@ const unusedExportViolation = (publicEntry, name, line, owner) => ({
337
310
  const addSiblingEntryUses = (context, publicEntry, declared, used) => {
338
311
  for (const edge of context.edgesTo.get(publicEntry) ?? []) {
339
312
  if (edge.reexport || edge.from === publicEntry || !declared.has(edge.from)) continue;
340
- const bindings = bindingsOf(context.ts, edge.edge.declaration);
313
+ const bindings = bindingsOf(context, edge.edge.declaration);
341
314
  if (bindings.all) used.all = true;
342
315
  for (const pair of bindings.pairs) used.names.add(pair.imported);
343
316
  }
@@ -377,7 +350,7 @@ const ownerExportFindings = (context, config) => {
377
350
 
378
351
  export function checkDeadExports({ context, graph, config }) {
379
352
  const edgesTo = edgeIndex([...graph.edges, ...testConsumerEdges({ context, graph, config })]);
380
- const analysis = { ...context, graph, edgesTo };
353
+ const analysis = { ...context, graph, edgesTo, namespaceCheckers: new WeakMap(), namespaceUses: new WeakMap() };
381
354
  const exports = ownerExportFindings(analysis, config);
382
355
  const files = deadFiles(graph, config);
383
356
  const dead = exports.violations.length;
@@ -2,22 +2,23 @@ import { machineKit } from './machine-ast.mjs';
2
2
 
3
3
  /**
4
4
  * R41 `default-deny-app-guard` (BE_DEFAULT_DENY). Every api app root provides its `APP_GUARD` entries in the order
5
- * throttler -> CSRF origin guard -> AuthGuard, each exactly once, so no request reaches a handler before it was
6
- * rate-limited, origin-checked and authenticated. A guard is classified by structure, never by name or folder alone
5
+ * throttler -> CSRF origin guard -> CredentialGuard -> AuthGuard, each exactly once, so no request reaches a handler before it was
6
+ * rate-limited, origin-checked, credential-authenticated and admitted. A guard is classified by structure, never by name or folder alone
7
7
  * (BE-CONVENTION 1.7 names the roles, not a library):
8
8
  * - throttler: a class of @nestjs/throttler or one that extends it, or a guard that reads through a Reflector the
9
9
  * per-door metadata (the `@RateLimit(tier)` decorator) that a SetMetadata call of its own capability writes;
10
10
  * - CSRF origin guard: a guard declared in platform/http-security that reads the request origin or referer header;
11
+ * - CredentialGuard: the `CredentialGuard` class declared in domain/identity;
11
12
  * - AuthGuard: the `AuthGuard` class declared in domain/identity.
12
13
  * Other guards are permitted between them; a missing, repeated or reordered one of the three is a finding.
13
14
  */
14
15
  export const DEFAULT_DENY_RULE_IDS = ['BE_DEFAULT_DENY'];
15
16
 
16
17
  const RULE = 'BE_DEFAULT_DENY';
17
- const ORDER = ['throttler', 'csrf', 'auth'];
18
+ const ORDER = ['throttler', 'csrf', 'credential', 'auth'];
18
19
  const ORIGIN_HEADERS = new Set(['origin', 'referer', 'referrer']);
19
20
  const METADATA_READERS = new Set(['get', 'getAll', 'getAllAndMerge', 'getAllAndOverride']);
20
- const LABEL = { throttler: 'the throttler guard', csrf: 'the CSRF origin guard of platform/http-security', auth: 'AuthGuard of domain/identity' };
21
+ const LABEL = { throttler: 'the throttler guard', csrf: 'the CSRF origin guard of platform/http-security', credential: 'CredentialGuard of domain/identity', auth: 'AuthGuard of domain/identity' };
21
22
 
22
23
  const keySymbol = (kit, checker, node) => (node ? kit.aliased(checker, kit.symbolAt(checker, node)) ?? null : null);
23
24
 
@@ -98,6 +99,7 @@ const classifyDeclaration = (kit, graph, keyCache, declaration) => {
98
99
  if (readsDoorMetadata(kit, graph, keyCache, declaration, owner)) return 'throttler';
99
100
  }
100
101
  if (owner?.tier === 'domain' && owner.name === 'identity' && ts.isClassDeclaration(declaration) && declaration.name?.text === 'AuthGuard') return 'auth';
102
+ if (owner?.tier === 'domain' && owner.name === 'identity' && ts.isClassDeclaration(declaration) && declaration.name?.text === 'CredentialGuard') return 'credential';
101
103
  return null;
102
104
  };
103
105
 
@@ -117,7 +119,7 @@ const roleMessages = (app, roles, anchor) => {
117
119
  const out = [];
118
120
  for (const role of ORDER) {
119
121
  const entries = roles.filter(item => item.role === role);
120
- if (entries.length === 0) out.push({ node: anchor, message: `App ${app.name} does not provide ${LABEL[role]} as an APP_GUARD; an api app is denied by default: throttler, then CSRF origin guard, then AuthGuard.` });
122
+ if (entries.length === 0) out.push({ node: anchor, message: `App ${app.name} does not provide ${LABEL[role]} as an APP_GUARD; an api app is denied by default: throttler, then CSRF origin guard, then CredentialGuard, then AuthGuard.` });
121
123
  else if (entries.length > 1) out.push({ node: entries[1].guard.node, message: `App ${app.name} provides ${LABEL[role]} more than once as an APP_GUARD.` });
122
124
  }
123
125
  return out;
@@ -128,7 +130,7 @@ const orderMessages = (app, roles) => {
128
130
  if (!indexes.every(index => index >= 0)) return [];
129
131
  const out = [];
130
132
  for (let i = 1; i < indexes.length; i += 1) {
131
- if (indexes[i] < indexes[i - 1]) out.push({ node: roles[indexes[i]].guard.node, message: `App ${app.name} provides ${LABEL[ORDER[i]]} before ${LABEL[ORDER[i - 1]]}; the APP_GUARD order is throttler, CSRF origin guard, AuthGuard.` });
133
+ if (indexes[i] < indexes[i - 1]) out.push({ node: roles[indexes[i]].guard.node, message: `App ${app.name} provides ${LABEL[ORDER[i]]} before ${LABEL[ORDER[i - 1]]}; the APP_GUARD order is throttler, CSRF origin guard, CredentialGuard, AuthGuard.` });
132
134
  }
133
135
  return out;
134
136
  };
@@ -0,0 +1,104 @@
1
+ import path from 'node:path';
2
+ import { normalizedSymbolValue } from './ast-walks.mjs';
3
+ import { unwrapExpression } from './typescript.mjs';
4
+
5
+ /** Feature composition may inject block renders into a pure view without making the view a world owner. */
6
+ export class BlockRenderAnalysis {
7
+ constructor(renderer) {
8
+ this.renderer = renderer;
9
+ this.world = renderer.world;
10
+ }
11
+
12
+ featureComposition(expression, expectedFile) {
13
+ const file = expression?.getSourceFile().fileName;
14
+ return Boolean(file && !expectedFile && this.world.insideAny(this.world.roots.features, file)
15
+ && path.basename(file) === 'index.tsx');
16
+ }
17
+
18
+ renderProp(expression, checker, expectedFile) {
19
+ return this.featureComposition(expression, expectedFile) && this.renderValue(expression, checker);
20
+ }
21
+
22
+ blockTarget(expression, checker) {
23
+ const functions = this.world.expressionFunctions(expression, checker);
24
+ return functions.length > 0 && functions.every(fn => {
25
+ const file = fn.getSourceFile().fileName;
26
+ return this.world.sourceSet.has(path.resolve(file)) && path.basename(file) === 'index.tsx'
27
+ && this.world.tierOf(this.world.roots.components, file)?.tier === 'blocks'
28
+ && this.renderer.functionHasRender(fn, checker);
29
+ });
30
+ }
31
+
32
+ callbacksRender(functions, checker, seen) {
33
+ return functions.length > 0 && functions.every(fn => {
34
+ const values = this.renderer.helpers.returnedExpressions(this.world.ts, fn);
35
+ return !this.world.functionUsesWorld(fn, checker) && values.length > 0
36
+ && values.every(value => this.renderValue(value, checker, seen));
37
+ });
38
+ }
39
+
40
+ renderValue(input, checker, seen = new Set()) {
41
+ const ts = this.world.ts;
42
+ const value = unwrapExpression(ts, input);
43
+ if (!value) return false;
44
+ if (value.kind === ts.SyntaxKind.NullKeyword || value.kind === ts.SyntaxKind.FalseKeyword) return true;
45
+ if (ts.isConditionalExpression(value)) return this.renderValue(value.whenTrue, checker, seen)
46
+ && this.renderValue(value.whenFalse, checker, seen);
47
+ if (ts.isBinaryExpression(value) && value.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken)
48
+ return this.renderValue(value.right, checker, seen);
49
+ if (ts.isArrayLiteralExpression(value)) return value.elements.every(item => this.renderValue(item, checker, seen));
50
+ if (ts.isJsxFragment(value)) return this.fragmentBlocks(value, checker, seen);
51
+ if (ts.isJsxElement(value) || ts.isJsxSelfClosingElement(value)) return this.jsxBlock(value, checker, seen);
52
+ if (ts.isCallExpression(value)) return this.createdBlock(value, checker, seen) || this.mappedBlocks(value, checker, seen);
53
+ if (ts.isArrowFunction(value) || ts.isFunctionExpression(value)) return this.callbacksRender([value], checker, seen);
54
+ return this.referencedBlocks(value, checker, seen);
55
+ }
56
+
57
+ fragmentBlocks(value, checker, seen) {
58
+ const ts = this.world.ts;
59
+ return value.children.every(child => {
60
+ if (ts.isJsxText(child)) return !child.text.trim();
61
+ return this.renderValue(ts.isJsxExpression(child) ? child.expression : child, checker, seen);
62
+ });
63
+ }
64
+
65
+ jsxBlock(value, checker, seen) {
66
+ const ts = this.world.ts;
67
+ const opening = ts.isJsxElement(value) ? value.openingElement : value;
68
+ if (!this.blockTarget(opening.tagName, checker)) return false;
69
+ const boundary = { hasBoundary: true };
70
+ return this.renderer.jsxChildrenBoundary(value, opening, checker, seen, null, false, boundary)
71
+ && this.renderer.jsxAttributesBoundary(opening, checker, seen, null, boundary);
72
+ }
73
+
74
+ mappedBlocks(value, checker, seen) {
75
+ const ts = this.world.ts;
76
+ const callee = unwrapExpression(ts, value.expression);
77
+ if (!ts.isPropertyAccessExpression(callee) && !ts.isElementAccessExpression(callee)) return false;
78
+ let name = ts.isPropertyAccessExpression(callee) ? callee.name.text : null;
79
+ if (name === null && ts.isStringLiteralLike(callee.argumentExpression)) name = callee.argumentExpression.text;
80
+ return name === 'map' && value.arguments.length > 0
81
+ && this.callbacksRender(this.world.expressionFunctions(value.arguments[0], checker), checker, seen);
82
+ }
83
+
84
+ createdBlock(value, checker, seen) {
85
+ const ts = this.world.ts;
86
+ if (!this.renderer.isCreateElement(value, checker) || !value.arguments[0]
87
+ || !this.blockTarget(value.arguments[0], checker)) return false;
88
+ const boundary = { hasBoundary: true };
89
+ const props = value.arguments[1] ? unwrapExpression(ts, value.arguments[1]) : null;
90
+ return this.renderer.createElementPropsBoundary(props, checker, seen, null, boundary, false)
91
+ && value.arguments.slice(2).every(child => this.renderer.renderBoundary(child, checker, seen));
92
+ }
93
+
94
+ referencedBlocks(value, checker, seen) {
95
+ const ts = this.world.ts;
96
+ const symbol = normalizedSymbolValue(ts, checker, this.renderer.helpers.selectedSymbol(ts, checker, value));
97
+ if (!symbol || seen.has(symbol)) return false;
98
+ const nextSeen = new Set(seen).add(symbol);
99
+ const values = (symbol.getDeclarations?.() ?? []).flatMap(declaration => ts.isVariableDeclaration(declaration)
100
+ && declaration.initializer ? [declaration.initializer] : []);
101
+ if (values.length) return values.every(item => this.renderValue(item, checker, nextSeen));
102
+ return this.callbacksRender(this.world.expressionFunctions(value, checker), checker, nextSeen);
103
+ }
104
+ }
@@ -1,6 +1,7 @@
1
1
  import path from 'node:path';
2
2
  import { normalizedSymbolValue } from './ast-walks.mjs';
3
3
  import { relativePath, unwrapExpression } from './typescript.mjs';
4
+ import { BlockRenderAnalysis } from './frontend-block-render.mjs';
4
5
  function importDeclarationFor(ts, node) {
5
6
  for (let current = node; current; current = current.parent) if (ts.isImportDeclaration(current)) return current;
6
7
  return null;
@@ -41,12 +42,14 @@ export class WorldRenderAnalysis {
41
42
  constructor(world, helpers) {
42
43
  this.world = world;
43
44
  this.helpers = helpers;
45
+ this.blocks = new BlockRenderAnalysis(this);
44
46
  }
45
47
 
46
48
  expressionHasRender(expression, checker, seen = new Set()) {
47
49
  expression = unwrapExpression(this.world.ts, expression);
48
50
  if (!expression) return false;
49
51
  if (this.world.ts.isJsxElement(expression) || this.world.ts.isJsxSelfClosingElement(expression) || this.world.ts.isJsxFragment(expression)) return true;
52
+ if (this.world.ts.isArrayLiteralExpression(expression)) return expression.elements.some(item => this.expressionHasRender(item, checker, seen));
50
53
  if (this.world.ts.isCallExpression(expression) && isReactCreateElement(this.world.ts, checker, expression)) return true;
51
54
  if (this.world.ts.isConditionalExpression(expression)) return this.conditionalExpressionHasRender(expression, checker, seen);
52
55
  const logicalRender = this.logicalExpressionHasRender(expression, checker, seen);
@@ -148,6 +151,22 @@ export class WorldRenderAnalysis {
148
151
  || this.world.expressionFunctions(expression, checker).some(fn => this.functionHasRender(fn, checker));
149
152
  }
150
153
 
154
+ isCreateElement(expression, checker) {
155
+ return isReactCreateElement(this.world.ts, checker, expression);
156
+ }
157
+
158
+ propValueBoundary(input, checker, seen, expectedFile, pureTarget) {
159
+ const ts = this.world.ts;
160
+ const value = unwrapExpression(ts, input);
161
+ if (pureTarget && this.blocks.renderProp(value, checker, expectedFile)) return true;
162
+ if (pureTarget && this.blocks.featureComposition(value, expectedFile) && ts.isObjectLiteralExpression(value)) {
163
+ return value.properties.every(property => !ts.isPropertyAssignment(property)
164
+ || this.propValueBoundary(property.initializer, checker, seen, expectedFile, pureTarget));
165
+ }
166
+ return !this.expressionSuppliesRender(value, checker) || this.pureRenderTarget(value, checker, expectedFile)
167
+ || this.renderBoundary(value, checker, seen, true, expectedFile);
168
+ }
169
+
151
170
  routedLayoutChild(opening, child, checker) {
152
171
  const ts = this.world.ts;
153
172
  if (!ts.isJsxExpression(child) || !child.expression) return false;
@@ -216,7 +235,7 @@ export class WorldRenderAnalysis {
216
235
  if (!pureTarget && !wrapper) return false;
217
236
  const boundary = { hasBoundary: pureTarget };
218
237
  const props = expression.arguments[1] ? unwrapExpression(ts, expression.arguments[1]) : null;
219
- if (!this.createElementPropsBoundary(props, checker, seen, expectedFile, boundary)) return false;
238
+ if (!this.createElementPropsBoundary(props, checker, seen, expectedFile, boundary, pureTarget)) return false;
220
239
  const children = expression.arguments.slice(2);
221
240
  if (children.length) {
222
241
  if (!children.every(child => this.renderBoundary(child, checker, seen, false, expectedFile))) return false;
@@ -225,19 +244,19 @@ export class WorldRenderAnalysis {
225
244
  return boundary.hasBoundary;
226
245
  }
227
246
 
228
- createElementPropsBoundary(props, checker, seen, expectedFile, boundary) {
247
+ createElementPropsBoundary(props, checker, seen, expectedFile, boundary, pureTarget) {
229
248
  const ts = this.world.ts;
230
249
  if (!props || props.kind === ts.SyntaxKind.NullKeyword) return true;
231
250
  if (!ts.isObjectLiteralExpression(props) || props.properties.some(property => ts.isSpreadAssignment(property))) return false;
232
- return props.properties.every(property => this.propsPropertyBoundary(property, checker, seen, expectedFile, boundary));
251
+ return props.properties.every(property => this.propsPropertyBoundary(property, checker, seen, expectedFile, boundary, pureTarget));
233
252
  }
234
253
 
235
- propsPropertyBoundary(property, checker, seen, expectedFile, boundary) {
254
+ propsPropertyBoundary(property, checker, seen, expectedFile, boundary, pureTarget) {
236
255
  const ts = this.world.ts;
237
256
  if (!ts.isPropertyAssignment(property)) return true;
238
257
  const value = unwrapExpression(ts, property.initializer);
258
+ if (!this.propValueBoundary(value, checker, seen, expectedFile, pureTarget)) return false;
239
259
  if (!this.expressionSuppliesRender(value, checker)) return true;
240
- if (!this.pureRenderTarget(value, checker, expectedFile) && !this.renderBoundary(value, checker, seen, true, expectedFile)) return false;
241
260
  const name = property.name && (ts.isIdentifier(property.name) || ts.isStringLiteralLike(property.name)) ? property.name.text : null;
242
261
  if (['children', 'component', 'content', 'render', 'view'].includes(name)) boundary.hasBoundary = true;
243
262
  return true;
@@ -270,7 +289,7 @@ export class WorldRenderAnalysis {
270
289
  if (!pureTarget && !wrapper) return false;
271
290
  const boundary = { hasBoundary: pureTarget };
272
291
  if (!this.jsxChildrenBoundary(expression, opening, checker, seen, expectedFile, pureTarget, boundary)) return false;
273
- if (!this.jsxAttributesBoundary(opening, checker, seen, expectedFile, boundary)) return false;
292
+ if (!this.jsxAttributesBoundary(opening, checker, seen, expectedFile, boundary, pureTarget)) return false;
274
293
  return boundary.hasBoundary;
275
294
  }
276
295
 
@@ -286,13 +305,13 @@ export class WorldRenderAnalysis {
286
305
  return true;
287
306
  }
288
307
 
289
- jsxAttributesBoundary(opening, checker, seen, expectedFile, boundary) {
308
+ jsxAttributesBoundary(opening, checker, seen, expectedFile, boundary, pureTarget = false) {
290
309
  const ts = this.world.ts;
291
310
  return opening.attributes.properties.every(attribute => !ts.isJsxAttribute(attribute) || !attribute.initializer
292
- || this.jsxAttributeBoundary(attribute, checker, seen, expectedFile, boundary));
311
+ || this.jsxAttributeBoundary(attribute, checker, seen, expectedFile, boundary, pureTarget));
293
312
  }
294
313
 
295
- jsxAttributeBoundary(attribute, checker, seen, expectedFile, boundary) {
314
+ jsxAttributeBoundary(attribute, checker, seen, expectedFile, boundary, pureTarget) {
296
315
  const ts = this.world.ts;
297
316
  const name = attribute.name.text;
298
317
  if (['fallback', 'errorElement'].includes(name)) {
@@ -300,10 +319,10 @@ export class WorldRenderAnalysis {
300
319
  if (ts.isJsxExpression(attribute.initializer)
301
320
  && !this.renderBoundary(attribute.initializer.expression, checker, seen, true, expectedFile)) return false;
302
321
  }
322
+ if (ts.isJsxExpression(attribute.initializer) && attribute.initializer.expression
323
+ && !this.propValueBoundary(attribute.initializer.expression, checker, seen, expectedFile, pureTarget)) return false;
303
324
  if (ts.isJsxExpression(attribute.initializer) && attribute.initializer.expression
304
325
  && this.expressionSuppliesRender(attribute.initializer.expression, checker)) {
305
- if (!this.pureRenderTarget(attribute.initializer.expression, checker, expectedFile)
306
- && !this.renderBoundary(attribute.initializer.expression, checker, seen, true, expectedFile)) return false;
307
326
  if (['component', 'content', 'render', 'view'].includes(name)) boundary.hasBoundary = true;
308
327
  }
309
328
  return true;
@@ -74,7 +74,7 @@ class WorldAnalysis {
74
74
  let found = false;
75
75
  const visit = node => {
76
76
  if (!found && this.ts.isCallExpression(node) && this.ts.isIdentifier(node.expression) && node.expression.text === 'fetch'
77
- && !checker.getSymbolAtLocation(node.expression)) found = true;
77
+ && (checker.getSymbolAtLocation(node.expression)?.getDeclarations() ?? []).every(declaration => declaration.getSourceFile().isDeclarationFile)) found = true;
78
78
  if (!found) this.ts.forEachChild(node, visit);
79
79
  };
80
80
  visit(sourceFile);