@starci/eslint-canon-be 3.0.8 → 3.0.9

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 (147) hide show
  1. package/CHANGELOG.md +13 -1
  2. package/README.md +12 -6
  3. package/ambient-id.mjs +90 -0
  4. package/cli.mjs +181 -0
  5. package/comments.mjs +18 -21
  6. package/config-owner.mjs +20 -4
  7. package/connections.mjs +12 -12
  8. package/cookies.mjs +106 -0
  9. package/cqrs.mjs +22 -26
  10. package/data-access.mjs +9 -28
  11. package/default-deny.mjs +2 -1
  12. package/e2e-flow.mjs +4 -4
  13. package/event-bus.mjs +237 -0
  14. package/feature-thin.mjs +237 -0
  15. package/idempotency.mjs +3 -1
  16. package/index.mjs +22 -1
  17. package/infra-owner.mjs +1 -1
  18. package/injection.mjs +67 -22
  19. package/input-bounds.mjs +2 -1
  20. package/jobs.mjs +214 -0
  21. package/lib/ast.mjs +7 -0
  22. package/lib/config.mjs +1 -1
  23. package/lib/db-writes.mjs +1 -1
  24. package/lib/doors.mjs +81 -0
  25. package/lib/edition.mjs +45 -0
  26. package/lib/entity-writes.mjs +35 -0
  27. package/lib/path.mjs +0 -6
  28. package/lib/persistence.mjs +11 -18
  29. package/lib/ports.mjs +5 -2
  30. package/lib/specifier.mjs +27 -0
  31. package/lib/transactions.mjs +37 -0
  32. package/lib/transport-slots.mjs +8 -3
  33. package/lib/unit-spec.mjs +63 -2
  34. package/module-layering.mjs +3 -24
  35. package/module-shape.mjs +2 -2
  36. package/naming.mjs +2 -2
  37. package/package.json +3 -1
  38. package/project-graph.mjs +5 -2
  39. package/projections.mjs +117 -0
  40. package/query-safety.mjs +1 -1
  41. package/realtime.mjs +181 -0
  42. package/runtime/engine/config.mjs +494 -0
  43. package/runtime/engine/invalid-config.mjs +15 -0
  44. package/runtime/engine/orca-config.mjs +27 -0
  45. package/runtime/knowledge/hfs/canon-pins.yaml +160 -29
  46. package/runtime/knowledge/hfs/rules.yaml +2059 -0
  47. package/runtime/knowledge/hfs/slots.yaml +912 -120
  48. package/runtime/knowledge/patterns/fe/folder.yaml +131 -25
  49. package/runtime/knowledge/sonar-gate.yaml +52 -18
  50. package/runtime/modules/models/registry.yaml +591 -0
  51. package/runtime/modules/models/runtimes.yaml +701 -0
  52. package/runtime/packages/hfs/templates/app/package-scripts/package.json +3 -10
  53. package/runtime/scripts/api/git/config-get.mjs +10 -0
  54. package/runtime/scripts/api/git/lib.mjs +16 -26
  55. package/runtime/scripts/api/git/ls-files.mjs +6 -0
  56. package/runtime/scripts/api/git/remote.mjs +5 -0
  57. package/runtime/scripts/api/git/rev-parse-query.mjs +5 -0
  58. package/runtime/scripts/hfs/architecture/ast-walks.mjs +220 -0
  59. package/runtime/scripts/hfs/architecture/backend.mjs +16 -23
  60. package/runtime/scripts/hfs/architecture/background-unowned.mjs +16 -34
  61. package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +3 -1
  62. package/runtime/scripts/hfs/architecture/clones.mjs +4 -4
  63. package/runtime/scripts/hfs/architecture/config.mjs +9 -6
  64. package/runtime/scripts/hfs/architecture/connection-map.mjs +20 -8
  65. package/runtime/scripts/hfs/architecture/context-coupling.mjs +88 -0
  66. package/runtime/scripts/hfs/architecture/context-map.mjs +237 -0
  67. package/runtime/scripts/hfs/architecture/context-owner.mjs +82 -0
  68. package/runtime/scripts/hfs/architecture/context-platform-tables.mjs +47 -0
  69. package/runtime/scripts/hfs/architecture/context-transaction.mjs +60 -0
  70. package/runtime/scripts/hfs/architecture/contract-fixture-guard.mjs +4 -10
  71. package/runtime/scripts/hfs/architecture/contracts.mjs +33 -162
  72. package/runtime/scripts/hfs/architecture/dead-exports.mjs +58 -29
  73. package/runtime/scripts/hfs/architecture/doc-language.mjs +1 -1
  74. package/runtime/scripts/hfs/architecture/entrypoint.mjs +2 -1
  75. package/runtime/scripts/hfs/architecture/fe-slot-allows.mjs +1 -1
  76. package/runtime/scripts/hfs/architecture/feature-shape.mjs +5 -2
  77. package/runtime/scripts/hfs/architecture/framework-pinned.mjs +1 -1
  78. package/runtime/scripts/hfs/architecture/frontend.mjs +17 -43
  79. package/runtime/scripts/hfs/architecture/hfs.mjs +24 -39
  80. package/runtime/scripts/hfs/architecture/i18n-keys.mjs +1 -1
  81. package/runtime/scripts/hfs/architecture/index.mjs +17 -4
  82. package/runtime/scripts/hfs/architecture/machine-ast.mjs +44 -11
  83. package/runtime/scripts/hfs/architecture/managed-scripts.mjs +27 -0
  84. package/runtime/scripts/hfs/architecture/module-per-transport.mjs +30 -6
  85. package/runtime/scripts/hfs/architecture/next-data.mjs +22 -63
  86. package/runtime/scripts/hfs/architecture/owners.mjs +35 -15
  87. package/runtime/scripts/hfs/architecture/register-once.mjs +2 -1
  88. package/runtime/scripts/hfs/architecture/registration.mjs +23 -81
  89. package/runtime/scripts/hfs/architecture/required-files.mjs +20 -12
  90. package/runtime/scripts/hfs/architecture/schema-owner.mjs +17 -114
  91. package/runtime/scripts/hfs/architecture/server-action.mjs +24 -0
  92. package/runtime/scripts/hfs/architecture/side-boundary.mjs +16 -0
  93. package/runtime/scripts/hfs/architecture/source-names.mjs +27 -147
  94. package/runtime/scripts/hfs/architecture/sql-owner.mjs +59 -18
  95. package/runtime/scripts/hfs/architecture/sql-returning.mjs +64 -0
  96. package/runtime/scripts/hfs/architecture/supabase-ast.mjs +111 -0
  97. package/runtime/scripts/hfs/architecture/supabase-be.mjs +131 -0
  98. package/runtime/scripts/hfs/architecture/supabase-tables.mjs +42 -0
  99. package/runtime/scripts/hfs/architecture/supabase.mjs +434 -0
  100. package/runtime/scripts/hfs/architecture/surface.mjs +9 -3
  101. package/runtime/scripts/hfs/architecture/test-world-files.mjs +9 -19
  102. package/runtime/scripts/hfs/architecture/tiers.mjs +47 -5
  103. package/runtime/scripts/hfs/architecture/transport-owner.mjs +3 -3
  104. package/runtime/scripts/hfs/architecture/typescript.mjs +12 -26
  105. package/runtime/scripts/hfs/declaration-shape.mjs +62 -0
  106. package/runtime/scripts/hfs/declaration-slots.mjs +75 -0
  107. package/runtime/scripts/hfs/edition-slots.mjs +66 -0
  108. package/runtime/scripts/hfs/manifest-shape.mjs +109 -32
  109. package/runtime/scripts/hfs/param-names.mjs +18 -0
  110. package/runtime/scripts/hfs/path-findings.mjs +1 -1
  111. package/runtime/scripts/hfs/project-graph.mjs +3 -3
  112. package/runtime/scripts/hfs/repo-identity.mjs +9 -6
  113. package/runtime/scripts/hfs/rule-params-shape.mjs +50 -0
  114. package/runtime/scripts/hfs/rules/fe-no-tests.mjs +6 -5
  115. package/runtime/scripts/hfs/rules/read.mjs +1 -1
  116. package/runtime/scripts/hfs/rules/secrets.mjs +3 -3
  117. package/runtime/scripts/hfs/rules/spec-placement.mjs +1 -1
  118. package/runtime/scripts/hfs/slots.mjs +78 -108
  119. package/runtime/scripts/hfs/test-world-slot.mjs +6 -0
  120. package/runtime/scripts/hfs/typescript-programs.mjs +0 -5
  121. package/runtime/scripts/hfs/view.mjs +3 -2
  122. package/runtime/scripts/lib/dockerfile.mjs +124 -0
  123. package/runtime/scripts/lib/git.mjs +36 -0
  124. package/runtime/scripts/lib/i18n.mjs +86 -0
  125. package/runtime/scripts/lib/json.mjs +13 -3
  126. package/runtime/scripts/lib/language.mjs +24 -5
  127. package/runtime/scripts/lib/list.mjs +20 -0
  128. package/runtime/scripts/lib/path-key.mjs +57 -2
  129. package/runtime/scripts/lib/read-text.mjs +34 -0
  130. package/runtime/scripts/lib/regex.mjs +4 -0
  131. package/runtime/scripts/lib/secret-patterns.mjs +4 -3
  132. package/runtime/scripts/lib/sops-envelope.mjs +58 -0
  133. package/runtime/scripts/lib/stack-services.mjs +7 -31
  134. package/runtime/scripts/lib/ts-ast.mjs +35 -0
  135. package/runtime/scripts/lib/walk.mjs +4 -3
  136. package/schema-authority.mjs +14 -12
  137. package/service-deps.mjs +7 -13
  138. package/service-isolation.mjs +72 -0
  139. package/size-budget.mjs +1 -1
  140. package/test-world.mjs +5 -4
  141. package/testing.mjs +27 -24
  142. package/transport.mjs +12 -9
  143. package/unit-spec.mjs +1 -1
  144. package/user-copy.mjs +5 -4
  145. package/webhooks.mjs +173 -0
  146. package/runtime/scripts/api/sops/decrypt.mjs +0 -16
  147. package/runtime/scripts/lib/test-secrets.mjs +0 -116
@@ -2,21 +2,14 @@
2
2
  "scripts": {
3
3
  "prepare": "husky",
4
4
  {{appScripts}}
5
- "build:be": "cd be && tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json",
6
- "build:fe": "{{buildFe}}",
7
- "codegen": "node scripts/codegen.mjs",
8
- "typecheck": "{{typecheck}}",
5
+ {{> app/package-scripts/core.json}}
9
6
  "typecheck:tests": "tsc -p be/src/tests/tsconfig.json",
10
- "lint": "npm run codegen --silent && hfs lint",
11
- "lint:fix": "npm run codegen --silent && hfs lint --fix",
12
- "format": "prettier --write .",
13
- "format:check": "prettier --check .",
14
7
  "test": "cd be && jest --selectProjects unit --coverage",
15
8
  "test:affected": "cd be && jest --selectProjects unit --passWithNoTests",
16
9
  "test:integration": "npm run typecheck:tests && cd be && jest --selectProjects integration",
17
10
  "test:e2e": "npm run typecheck:tests && cd be && jest --selectProjects e2e",
18
11
  "test:contract": "npm run typecheck:tests && cd be && jest --selectProjects contract",
19
- "test:stack": "cd be && starci-test-stack",
20
- "contract:emit": "hfs emit-contracts"
12
+ "test:stack": "starci app stack",
13
+ "contract:emit": "starci app emit"
21
14
  }
22
15
  }
@@ -0,0 +1,10 @@
1
+ // config-get.mjs — `git config [--local] --get <key>`: one configuration value of a checkout (empty when unset).
2
+ import { gitRunner } from './lib.mjs';
3
+
4
+ /**
5
+ * {ok, stdout, stderr}; `local` reads the repository's own config only; `env` replaces the process env (a caller drops
6
+ * git's repository-local variables). git: the caller's runner or null.
7
+ */
8
+ export function configGet(cwd, key, { local = false, env = undefined, git = null } = {}) {
9
+ return gitRunner(git)(['config', ...(local ? ['--local'] : []), '--get', key], env ? { cwd, env } : { cwd });
10
+ }
@@ -1,12 +1,13 @@
1
1
  // scripts/api/git/lib.mjs — the one place the runtime spawns git (RT_EXTERNAL_OWNER of scripts/hfs/runtime-rules/external-owner.mjs enforces it).
2
2
  //
3
3
  // Every caller spelt the same options by hand - encoding:'utf8', windowsHide:true, sometimes a timeout - with two shapes:
4
- // `git args` in a cwd and `git -C dir args`. gitOutput is the throwing shape (stdout text, or an Error on a non-zero exit).
5
- // gitSpawn keeps the spawnSync(file, args, options) signature so an injected runner or a spec's fake takes the same three
6
- // arguments; runGit is the `-C` convenience; gitResult folds the result into the {ok, stdout, error} envelope; gitRunner
7
- // folds any caller's runner into {ok, stdout, stderr}. The call files beside this one (worktree-*.mjs, rev-parse.mjs, ...)
8
- // each name one git verb.
4
+ // `git args` in a cwd and `git -C dir args`. gitSpawn keeps the spawnSync(file, args, options) signature so an injected
5
+ // runner or a spec's fake takes the same three arguments; runGit is the `-C` / `-c` convenience; gitRunner folds any
6
+ // caller's runner into {ok, stdout, stderr}. Only the call files beside this one import it: each names one git verb
7
+ // (diff.mjs, ls-files.mjs, worktree-*.mjs, ...), and a caller folds the spawn result it gets back with the pure
8
+ // helpers of scripts/lib/git.mjs (gitResultOf, gitOutputOf).
9
9
  import { spawnSync } from 'node:child_process';
10
+ import { withoutGitLocalEnv } from '../../lib/git.mjs';
10
11
 
11
12
  /**
12
13
  * Spawn `file` (the git binary) once with `args`: utf8 text, a hidden window, never a shell.
@@ -20,27 +21,16 @@ export const gitSpawn = (file, args, options = {}) => {
20
21
  return spawnSync(file, args, spawn);
21
22
  };
22
23
 
23
- /** `git args` in `cwd`, or `git -C dir args` when `dir` is given. `git` overrides the binary. */
24
- export const runGit = (args, { cwd = null, dir = null, git = 'git', ...options } = {}) =>
25
- gitSpawn(git, [...(dir ? ['-C', dir] : []), ...args], cwd ? { cwd, ...options } : options);
26
-
27
- /** runGit's stdout as text; throws the spawn error, or an Error carrying git's stderr and exit status, unless git exits 0. */
28
- export function gitOutput(args, options = {}) {
29
- const r = runGit(args, options);
30
- if (r.error) throw r.error;
31
- if (r.status !== 0) throw Object.assign(new Error(`git ${args.join(' ')} exited ${r.status}: ${(r.stderr ?? '').trim()}`), { status: r.status, stderr: r.stderr });
32
- return r.stdout;
33
- }
34
-
35
- /** runGit's result as {ok, stdout, error}: ok only on a clean exit, error from stderr or the spawn. */
36
- export function gitResult(args, options = {}) {
37
- const r = runGit(args, options);
38
- return {
39
- ok: !r.error && r.status === 0,
40
- stdout: r.stdout ?? '',
41
- error: (r.stderr ?? '').trim() || String(r.error?.message ?? (r.status == null ? 'timed out' : `exit ${r.status}`)),
42
- };
43
- }
24
+ /**
25
+ * `git args` in `cwd`, or `git -C dir args` when `dir` is given. `git` overrides the binary; `config` ({key: value})
26
+ * prefixes one `-c key=value` per entry (core.quotepath=off, a committer identity). Scoped calls discard ambient
27
+ * hook repository variables; an explicit env remains authoritative, including an intentional temporary index.
28
+ */
29
+ export const runGit = (args, { cwd = null, dir = null, git = 'git', config = null, ...options } = {}) =>
30
+ gitSpawn(git, [...(dir ? ['-C', dir] : []), ...Object.entries(config ?? {}).flatMap(([key, value]) => ['-c', `${key}=${value}`]), ...args], {
31
+ ...(cwd ? { cwd } : {}), ...options,
32
+ ...((cwd || dir) && options.env === undefined ? { env: withoutGitLocalEnv(process.env) } : {}),
33
+ });
44
34
 
45
35
  /**
46
36
  * A caller's git runner folded to (args, opts) -> {ok, stdout, stderr}. `git`: the caller's runner (args, {cwd}) ->
@@ -0,0 +1,6 @@
1
+ // ls-files.mjs — `git ls-files <args>`: the paths the index tracks (--cached), or the untracked / deleted / ignored ones
2
+ // the flags ask for (-z keeps any file name intact).
3
+ import { runGit } from './lib.mjs';
4
+
5
+ /** The spawn result {status, stdout, stderr, error} of `git ls-files ...args`; options are runGit's (cwd or dir, config, timeout, maxBuffer, input, encoding, env, git). */
6
+ export const lsFiles = (args = [], options = {}) => runGit(['ls-files', ...args], options);
@@ -0,0 +1,5 @@
1
+ // remote.mjs — `git remote <args>`: the configured remotes (get-url <name>).
2
+ import { runGit } from './lib.mjs';
3
+
4
+ /** The spawn result {status, stdout, stderr, error} of `git remote ...args`; options are runGit's (cwd or dir, config, timeout, maxBuffer, input, encoding, env, git). */
5
+ export const remote = (args = [], options = {}) => runGit(['remote', ...args], options);
@@ -0,0 +1,5 @@
1
+ // rev-parse-query.mjs — `git rev-parse <args>`: one rev-parse query (--show-toplevel, --git-common-dir, --git-path, a ref): the text it prints.
2
+ import { runGit } from './lib.mjs';
3
+
4
+ /** The spawn result {status, stdout, stderr, error} of `git rev-parse ...args`; options are runGit's (cwd or dir, config, timeout, maxBuffer, input, encoding, env, git). */
5
+ export const revParseQuery = (args = [], options = {}) => runGit(['rev-parse', ...args], options);
@@ -0,0 +1,220 @@
1
+ // ast-walks.mjs - AST walks the architecture checkers share (moved out of typescript.mjs, which keeps the program loading and module resolution).
2
+ import { canonical, isInside } from './config.mjs';
3
+ import { sourceLocation } from '../../lib/ts-ast.mjs';
4
+ import { UNPROVEN_FRAMEWORK, isUnshadowedCommonJsRequire, relativePath, unwrapExpression } from './typescript.mjs';
5
+
6
+ /** `expression` unwrapped by the caller's own predicate set (a predicate absent on an older ts reads as never). */
7
+ export function unwrapEach(ts, expression, predicates) {
8
+ while (expression && predicates.some((predicate) => predicate?.(expression))) expression = expression.expression;
9
+ return expression;
10
+ }
11
+
12
+ /** `value` with every TS alias hop resolved (a re-exported import reads as its target symbol). */
13
+ export function normalizedSymbolValue(ts, checker, value) {
14
+ let symbol = value ?? null;
15
+ const seen = new Set();
16
+ while (symbol && (symbol.flags & ts.SymbolFlags.Alias) && !seen.has(symbol)) {
17
+ seen.add(symbol);
18
+ const target = checker.getAliasedSymbol(symbol);
19
+ if (!target || target === symbol) break;
20
+ symbol = target;
21
+ }
22
+ return symbol;
23
+ }
24
+
25
+ /** The symbol `node` names, aliases resolved. */
26
+ export function normalizedSymbol(ts, checker, node) {
27
+ return normalizedSymbolValue(ts, checker, checker?.getSymbolAtLocation(node) ?? null);
28
+ }
29
+
30
+ /** The identifier or member-name node an expression selects through unwrapExpression, or null. */
31
+ export function selectedNode(ts, expression) {
32
+ expression = unwrapExpression(ts, expression);
33
+ if (ts.isIdentifier(expression)) return expression;
34
+ if (ts.isPropertyAccessExpression(expression)) return expression.name;
35
+ if (ts.isElementAccessExpression(expression) && ts.isStringLiteralLike(expression.argumentExpression)) return expression.argumentExpression;
36
+ return null;
37
+ }
38
+
39
+ /**
40
+ * The symbol `node` ultimately evaluates to: its own symbol, followed through a single `const` variable
41
+ * initializer (and so on). `unselected: 'null'` answers null when `node` selects no name at all.
42
+ */
43
+ export function valueSymbol(ts, checker, node, seen = new Set(), { unselected = 'node' } = {}) {
44
+ const selected = selectedNode(ts, node);
45
+ if (!selected && unselected === 'null') return null;
46
+ const symbol = normalizedSymbol(ts, checker, selected ?? node);
47
+ if (!symbol || seen.has(symbol)) return symbol;
48
+ seen.add(symbol);
49
+ const declarations = symbol.getDeclarations?.() ?? [];
50
+ if (declarations.length === 1 && ts.isVariableDeclaration(declarations[0]) && declarations[0].initializer
51
+ && (ts.getCombinedNodeFlags(declarations[0].parent) & ts.NodeFlags.Const)) {
52
+ return valueSymbol(ts, checker, declarations[0].initializer, seen, { unselected });
53
+ }
54
+ return symbol;
55
+ }
56
+
57
+ /**
58
+ * The expressions `declaration` returns: its body when it is an expression-bodied arrow/function, else every
59
+ * `return`'s argument without descending into nested functions. `functionLike` overrides which node kinds bound
60
+ * that descent (default the four callable declaration kinds; pass `ts.isFunctionLike` for the wider set).
61
+ */
62
+ export function returnedExpressions(ts, declaration, { functionLike = null } = {}) {
63
+ const boundary = functionLike ?? ((node) => ts.isFunctionDeclaration(node) || ts.isMethodDeclaration(node)
64
+ || ts.isArrowFunction(node) || ts.isFunctionExpression(node));
65
+ if ((ts.isArrowFunction(declaration) || ts.isFunctionExpression(declaration)) && !ts.isBlock(declaration.body)) return [declaration.body];
66
+ const body = boundary(declaration) ? declaration.body : null;
67
+ if (!body || !ts.isBlock(body)) return [];
68
+ const returned = [];
69
+ const visit = (node) => {
70
+ if (node !== body && boundary(node)) return;
71
+ if (ts.isReturnStatement(node) && node.expression) returned.push(node.expression);
72
+ else ts.forEachChild(node, visit);
73
+ };
74
+ visit(body);
75
+ return returned;
76
+ }
77
+
78
+ /** A node's decorators across TS versions (ts.getDecorators, else the legacy `decorators` property). */
79
+ export function nodeDecorators(ts, node) {
80
+ return ts.canHaveDecorators(node) ? ts.getDecorators(node) ?? [] : node.decorators ?? [];
81
+ }
82
+
83
+ /** One finding row: rule id, root-relative path, node position and message, plus rule extras. */
84
+ export function violation(config, sourceFile, node, ruleId, message, extra = {}) {
85
+ return { ruleId, path: relativePath(config.root, sourceFile.fileName), ...sourceLocation(sourceFile, node), message, ...extra };
86
+ }
87
+
88
+ /** Whether `match` holds for `node` or a descendant (the walk stops at the first match). */
89
+ export function anyDescendant(ts, node, match) {
90
+ let found = false;
91
+ const visit = (current) => {
92
+ if (found) return;
93
+ if (match(current)) { found = true; return; }
94
+ ts.forEachChild(current, visit);
95
+ };
96
+ visit(node);
97
+ return found;
98
+ }
99
+
100
+ /**
101
+ * The framework kinds `expression` can produce: `directOf(ts, checker, {symbol, selected, targets})` maps a
102
+ * selected node's symbols to a kind (default `targets.get(symbol)`); `shorthandOf` (when set) also resolves
103
+ * shorthand-property declarations and descends property assignments; `newExpression` descends `new X()` callees.
104
+ */
105
+ export function tracedFrameworkKinds(ts, checker, expression, targets,
106
+ { seen = new Set(), depth = 0, directOf = null, shorthandOf = null, newExpression = false } = {}) {
107
+ if (!expression) return new Set();
108
+ if (depth > 10) return new Set([UNPROVEN_FRAMEWORK]);
109
+ expression = unwrapExpression(ts, expression);
110
+ const selected = selectedNode(ts, expression);
111
+ const symbol = selected ? normalizedSymbol(ts, checker, selected) : null;
112
+ const direct = symbol ? (directOf ?? ((ts2, c, { symbol: s, targets: t }) => t.get(s) ?? null))(ts, checker, { symbol, selected, targets }) : null;
113
+ if (direct) return new Set([direct]);
114
+ const opts = { depth: depth + 1, directOf, shorthandOf, newExpression };
115
+ if (ts.isArrowFunction(expression) || ts.isFunctionExpression(expression)) {
116
+ const kinds = new Set();
117
+ for (const returned of returnedExpressions(ts, expression)) {
118
+ for (const kind of tracedFrameworkKinds(ts, checker, returned, targets, { ...opts, seen })) kinds.add(kind);
119
+ }
120
+ return kinds;
121
+ }
122
+ if (ts.isCallExpression(expression)) {
123
+ const kinds = tracedFrameworkKinds(ts, checker, expression.expression, targets, { ...opts, seen });
124
+ if (kinds.size) return kinds;
125
+ }
126
+ if (newExpression && ts.isNewExpression(expression)) {
127
+ return tracedFrameworkKinds(ts, checker, expression.expression, targets, { ...opts, seen });
128
+ }
129
+ if (!symbol || seen.has(symbol)) return new Set();
130
+ const nextSeen = new Set(seen).add(symbol);
131
+ const kinds = new Set();
132
+ for (const declaration of symbol.getDeclarations?.() ?? []) {
133
+ if (ts.isVariableDeclaration(declaration) && declaration.initializer) {
134
+ for (const kind of tracedFrameworkKinds(ts, checker, declaration.initializer, targets, { ...opts, seen: nextSeen })) kinds.add(kind);
135
+ }
136
+ if (shorthandOf && ts.isShorthandPropertyAssignment(declaration)) {
137
+ const target = normalizedSymbolValue(ts, checker, checker.getShorthandAssignmentValueSymbol?.(declaration));
138
+ const kind = shorthandOf(ts, checker, target, targets);
139
+ if (kind) kinds.add(kind);
140
+ }
141
+ if (shorthandOf && ts.isPropertyAssignment(declaration)) {
142
+ for (const kind of tracedFrameworkKinds(ts, checker, declaration.initializer, targets, { ...opts, seen: nextSeen })) kinds.add(kind);
143
+ }
144
+ for (const returned of returnedExpressions(ts, declaration)) {
145
+ for (const kind of tracedFrameworkKinds(ts, checker, returned, targets, { ...opts, seen: nextSeen })) kinds.add(kind);
146
+ }
147
+ }
148
+ return kinds;
149
+ }
150
+
151
+ /** 'unproven framework' | 'multiple framework' | the one kind | null, for a decorator whose expression constructs. */
152
+ export function constructedDecoratorKind(ts, checker, decorator, targets, traceOpts = {}) {
153
+ const callee = ts.isCallExpression(decorator.expression) ? decorator.expression.expression : decorator.expression;
154
+ const kinds = tracedFrameworkKinds(ts, checker, callee, targets, traceOpts);
155
+ if (kinds.has(UNPROVEN_FRAMEWORK)) return 'unproven framework';
156
+ return kinds.size === 1 ? [...kinds][0] : kinds.size ? 'multiple framework' : null;
157
+ }
158
+
159
+ /** The target a decorator resolves to through a `let`/`var` alias's single initializer, or null. */
160
+ export function mutableDecoratorKind(ts, checker, decorator, targets) {
161
+ const callee = ts.isCallExpression(decorator.expression) ? decorator.expression.expression : decorator.expression;
162
+ const selected = selectedNode(ts, callee);
163
+ const symbol = selected ? normalizedSymbol(ts, checker, selected) : null;
164
+ const declarations = symbol?.getDeclarations?.() ?? [];
165
+ if (declarations.length !== 1 || !ts.isVariableDeclaration(declarations[0]) || !declarations[0].initializer
166
+ || (ts.getCombinedNodeFlags(declarations[0].parent) & ts.NodeFlags.Const)) return null;
167
+ return targets.get(valueSymbol(ts, checker, declarations[0].initializer)) ?? null;
168
+ }
169
+
170
+ /** The callee expression of a decorator (the called expression of `@x(...)`, else the expression itself). */
171
+ export function decoratorCallee(ts, decorator) {
172
+ return ts.isCallExpression(decorator.expression) ? decorator.expression.expression : decorator.expression;
173
+ }
174
+
175
+ /**
176
+ * The module `statement` (import, export-from or import-equals) statically binds: its specifier text, the
177
+ * module's symbol and its exported symbols by name (aliases resolved); `symbol` is null when the module does
178
+ * not resolve. Null when the statement names no static specifier.
179
+ */
180
+ export function moduleExportsOf(ts, checker, statement) {
181
+ const moduleSpecifier = (ts.isImportDeclaration(statement) || ts.isExportDeclaration(statement))
182
+ && statement.moduleSpecifier && ts.isStringLiteralLike(statement.moduleSpecifier) ? statement.moduleSpecifier : null;
183
+ const importEquals = ts.isImportEqualsDeclaration(statement) && ts.isExternalModuleReference(statement.moduleReference)
184
+ && statement.moduleReference.expression && ts.isStringLiteralLike(statement.moduleReference.expression)
185
+ ? statement.moduleReference.expression : null;
186
+ const specifierNode = moduleSpecifier ?? importEquals;
187
+ const specifier = specifierNode?.text;
188
+ if (specifier == null) return null;
189
+ const symbol = checker.getSymbolAtLocation(specifierNode) ?? null;
190
+ const exports = new Map((symbol ? checker.getExportsOfModule(symbol) : []).map(item => [item.getName(), normalizedSymbolValue(ts, checker, item)]));
191
+ return { specifierNode, specifier, symbol, exports };
192
+ }
193
+
194
+ /**
195
+ * The program source files a framework-target scan of `checker` judges: every file in `localFiles`, plus (when
196
+ * `root` is given) declaration files inside the root but outside node_modules. Null when no program of the
197
+ * context owns the checker.
198
+ */
199
+ export function programSourcesOf(context, checker, localFiles, { root = null } = {}) {
200
+ const program = context.programs.find(candidate => candidate.getTypeChecker() === checker) ?? null;
201
+ if (!program) return null;
202
+ return program.getSourceFiles().filter(sourceFile => localFiles.has(canonical(sourceFile.fileName))
203
+ || (root && sourceFile.isDeclarationFile && isInside(root, sourceFile.fileName)
204
+ && !sourceFile.fileName.replaceAll('\\', '/').includes('/node_modules/')));
205
+ }
206
+
207
+ /**
208
+ * The CommonJS require() caveat every framework scan reports: each `require('<specifier>')` `accepted` pushes
209
+ * `${filePath} uses a CommonJS <label ?? specifier> <detail>` (the import-shape reasoning cannot prove that binding).
210
+ */
211
+ export function commonJsRequireReasons(ts, checker, sourceFile, accepted, reasons, filePath, detail, label = null) {
212
+ const visit = node => {
213
+ if (ts.isCallExpression(node) && isUnshadowedCommonJsRequire(ts, checker, node.expression)
214
+ && node.arguments.length === 1 && ts.isStringLiteralLike(node.arguments[0]) && accepted(node.arguments[0].text)) {
215
+ reasons.push(`${filePath} uses a CommonJS ${label ?? node.arguments[0].text} ${detail}`);
216
+ }
217
+ ts.forEachChild(node, visit);
218
+ };
219
+ visit(sourceFile);
220
+ }
@@ -1,7 +1,8 @@
1
1
  import path from 'node:path';
2
2
  import { isInside } from './config.mjs';
3
3
  import { slotAdmitsFile } from '../allows.mjs';
4
- import { reachableViolation, relativePath, sourceLocation } from './typescript.mjs';
4
+ import { reachableViolation, relativePath } from './typescript.mjs';
5
+ import { sourceLocation } from '../../lib/ts-ast.mjs';
5
6
 
6
7
  const FORBIDDEN_APP_ROLE = /(?:^|\.)(?:service|provider|providers|resolver|controller|handler|repository|entity|use-case|command|query|listener|consumer|processor)\.[cm]?[jt]sx?$/i;
7
8
  const FORBIDDEN_DECLARATION = /(?:Service|Provider|Resolver|Controller|Handler|Repository|Entity|UseCase|Command|Query|Listener|Consumer|Processor)$/;
@@ -73,6 +74,18 @@ function roleEvidence(ts, sourceFile) {
73
74
 
74
75
  function transportFrameworkEvidence(ts, sourceFile, checker) {
75
76
  const found = [];
77
+ // The destructured binding elements naming a transport export (or a computed binding, which cannot be
78
+ // proved to exclude one): `const { Controller, [x]: y } = ...`. `rest` elements are always opaque.
79
+ const destructuredBindings = (elements, detail) => elements.flatMap(element => {
80
+ const selected = element.dotDotDotToken
81
+ ? null
82
+ : ts.isIdentifier(element.propertyName ?? element.name)
83
+ ? (element.propertyName ?? element.name).text
84
+ : ts.isStringLiteralLike(element.propertyName)
85
+ ? element.propertyName.text
86
+ : null;
87
+ return selected === null || NEST_COMMON_TRANSPORT.has(selected) ? [{ node: element, detail: selected ?? detail }] : [];
88
+ });
76
89
  const namespaceUsages = binding => {
77
90
  const usages = [];
78
91
  const alias = binding.text;
@@ -90,18 +103,7 @@ function transportFrameworkEvidence(ts, sourceFile, checker) {
90
103
  if (!ts.isObjectBindingPattern(node.name)) {
91
104
  usages.push({ node: node.name, detail: 'escaped @nestjs/common namespace access' });
92
105
  } else {
93
- for (const element of node.name.elements) {
94
- const selected = element.dotDotDotToken
95
- ? null
96
- : ts.isIdentifier(element.propertyName ?? element.name)
97
- ? (element.propertyName ?? element.name).text
98
- : ts.isStringLiteralLike(element.propertyName)
99
- ? element.propertyName.text
100
- : null;
101
- if (selected === null || NEST_COMMON_TRANSPORT.has(selected)) {
102
- usages.push({ node: element, detail: selected ?? 'computed @nestjs/common namespace destructuring' });
103
- }
104
- }
106
+ usages.push(...destructuredBindings(node.name.elements, 'computed @nestjs/common namespace destructuring'));
105
107
  }
106
108
  }
107
109
  ts.forEachChild(node, visit);
@@ -168,16 +170,7 @@ function transportFrameworkEvidence(ts, sourceFile, checker) {
168
170
  if (ts.isIdentifier(node.name)) {
169
171
  found.push(...namespaceUsages(node.name).map(item => ({ ...item, specifier })));
170
172
  } else if (ts.isObjectBindingPattern(node.name)) {
171
- for (const element of node.name.elements) {
172
- const selected = element.dotDotDotToken
173
- ? null
174
- : ts.isIdentifier(element.propertyName ?? element.name)
175
- ? (element.propertyName ?? element.name).text
176
- : ts.isStringLiteralLike(element.propertyName)
177
- ? element.propertyName.text
178
- : null;
179
- if (selected === null || NEST_COMMON_TRANSPORT.has(selected)) found.push({ node: element, specifier, detail: selected ?? 'computed @nestjs/common require destructuring' });
180
- }
173
+ found.push(...destructuredBindings(node.name.elements, 'computed @nestjs/common require destructuring').map(item => ({ ...item, specifier })));
181
174
  }
182
175
  }
183
176
  }
@@ -3,29 +3,26 @@ import { machineKit } from './machine-ast.mjs';
3
3
 
4
4
  /**
5
5
  * R46 `background-unowned` (BE_BACKGROUND_UNOWNED, BE-CONVENTION 1.6 and 1.13). Background work is a transport that only
6
- * a worker app runs:
6
+ * a worker or api app runs (an api app composes the message transport of its own service; a separate worker is opt-in):
7
7
  *
8
- * - every job (`*.job.ts`) and consumer (`*.consumer.ts`) is composed by an app of kind `worker`: the file is reachable,
9
- * through runtime imports, from the root module of some worker app declared in hfs.json; one no worker reaches never runs;
10
- * - no scheduler outside `platform/scheduling`: a `@Cron`, `@Interval` or `@Timeout` decorator of `@nestjs/schedule` and a
11
- * `setInterval` call are refused everywhere else (platform/scheduling owns the lease, the fencing and the overlap guard);
8
+ * - every job processor (`*.processor.ts`, the jobs kind) and consumer (`*.consumer.ts`) is composed by an app of kind `worker` or
9
+ * `api`: the file is reachable, through runtime imports, from the root module of some such app declared in hfs.json; one no app
10
+ * reaches never runs. A schedule is a BullMQ job scheduler of a queue, so no scheduler decorator or timer is judged here: `@nestjs/schedule`
11
+ * and the timers are owned by platform capabilities (R90 BE_INFRA_OWNER);
12
12
  * - a method whose name is one of the words the law names (`sweep`, `deliver`, `reconcile`, `retry`, alone or as the
13
- * first word of a camelCase name) in a domain, integrations or feature class is reachable from a composed job or
14
- * consumer: from the job or consumer files and everything they import, and from the files of the feature that holds them
15
- * (its handlers are registered with the command bus, not imported by the job). Mechanisms of platform are exempt.
13
+ * first word of a camelCase name) in a domain, integrations or feature class is reachable from a composed processor or
14
+ * consumer: from the processor or consumer files and everything they import, and from the files of the feature that holds them
15
+ * (its handlers are registered with the command bus, not imported by the processor). Mechanisms of platform are exempt.
16
16
  *
17
- * Reachability is static (imports, re-exports and the feature of the job); it proves a job or consumer could call the method,
17
+ * Reachability is static (imports, re-exports and the feature of the processor); it proves a processor or consumer could call the method,
18
18
  * not that it does.
19
19
  */
20
20
  export const BACKGROUND_UNOWNED_RULE_IDS = ['BE_BACKGROUND_UNOWNED'];
21
21
 
22
22
  const RULE = 'BE_BACKGROUND_UNOWNED';
23
- const BACKGROUND_ROLES = ['job', 'consumer'];
23
+ const BACKGROUND_ROLES = ['processor', 'consumer'];
24
24
  /** The words the law names for background methods (knowledge/hfs/README.md 5.7). */
25
25
  const BACKGROUND_WORDS = ['sweep', 'deliver', 'reconcile', 'retry'];
26
- const SCHEDULER_DECORATORS = ['Cron', 'Interval', 'Timeout'];
27
- const SCHEDULER_PACKAGE = '@nestjs/schedule';
28
- const SCHEDULING_CAPABILITY = 'scheduling';
29
26
 
30
27
  const roleOf = rel => {
31
28
  const parts = path.posix.basename(rel).split('.');
@@ -52,17 +49,18 @@ export function checkBackgroundUnowned(input) {
52
49
  return seen;
53
50
  };
54
51
 
55
- const workerRoots = resolver.repo.apps.filter(app => app.kind === 'worker').map(app => `apps/${app.name}/src/app.module.ts`).filter(rel => graph.files.has(rel));
56
- const composed = closure(workerRoots);
52
+ const rootOf = app => `apps/${app.name}/src/app.module.ts`;
53
+ const workerRoots = resolver.repo.apps.filter(app => app.kind === 'worker').map(rootOf).filter(rel => graph.files.has(rel));
54
+ const composed = closure([...workerRoots, ...resolver.repo.apps.filter(app => app.kind === 'api').map(rootOf).filter(rel => graph.files.has(rel))]);
57
55
  const background = [...graph.files.values()].filter(file => roleOf(file.rel) && file.tier === 'feature');
58
56
  const running = background.filter(file => composed.has(file.rel));
59
57
  for (const file of background) {
60
58
  if (composed.has(file.rel)) continue;
61
59
  const role = roleOf(file.rel);
62
- report(file, file.sourceFile, `${file.rel} is a ${role} that no worker app composes${workerRoots.length ? '' : ' (hfs.json declares no worker app)'}; a ${role} runs only when the ${role === 'job' ? 'schedule' : 'message'} module of its feature is imported by the root module of an app of kind worker.`, { role });
60
+ report(file, file.sourceFile, `${file.rel} is a ${role} that no service app composes${workerRoots.length ? '' : ' (hfs.json declares no worker app)'}; a ${role} runs only when the ${role === 'processor' ? 'job' : 'message'} module of its feature is imported by the root module of an app of kind worker or api.`, { role });
63
61
  }
64
62
 
65
- // Everything a composed job or consumer can reach: its imports, and the files of its own feature (command handlers).
63
+ // Everything a composed processor or consumer can reach: its imports, and the files of its own feature (command handlers).
66
64
  const roots = new Set();
67
65
  for (const file of running) {
68
66
  roots.add(file.rel);
@@ -73,31 +71,15 @@ export function checkBackgroundUnowned(input) {
73
71
 
74
72
  let methods = 0;
75
73
  for (const file of graph.files.values()) {
76
- const checker = kit.checkerOf(file.sourceFile);
77
- const inScheduling = file.owner?.slot === 'be.platform' && path.posix.basename(file.owner.root) === SCHEDULING_CAPABILITY;
78
74
  const mechanism = file.tier === 'platform';
79
75
  kit.walk(file.sourceFile, node => {
80
- if (!inScheduling && ts.isDecorator(node)) {
81
- const call = ts.isCallExpression(node.expression) ? node.expression.expression : node.expression;
82
- const binding = kit.importBinding(checker, call);
83
- if (binding && binding.module === SCHEDULER_PACKAGE && SCHEDULER_DECORATORS.includes(binding.name)) {
84
- report(file, node, `@${binding.name} schedules work inside a class outside platform/scheduling. Write a job in transport/schedule/<job>.job.ts that dispatches one command or query, and let platform/scheduling and a worker app run it.`, { scheduler: binding.name });
85
- }
86
- }
87
- if (!inScheduling && ts.isCallExpression(node)) {
88
- const callee = node.expression;
89
- const bare = ts.isIdentifier(callee) && callee.text === 'setInterval' && kit.declarationsOf(checker, callee).every(declaration => declaration.getSourceFile().isDeclarationFile);
90
- const member = ts.isPropertyAccessExpression(callee) && callee.name.text === 'setInterval' && ts.isIdentifier(callee.expression) && ['globalThis', 'window', 'global'].includes(callee.expression.text);
91
- const global = bare || member;
92
- if (global) report(file, node, 'setInterval starts a scheduler outside platform/scheduling. Write a job in transport/schedule/<job>.job.ts and let platform/scheduling and a worker app run it.', { scheduler: 'setInterval' });
93
- }
94
76
  if (!mechanism && ts.isClassDeclaration(node)) {
95
77
  for (const member of node.members) {
96
78
  if (!ts.isMethodDeclaration(member) || !member.name) continue;
97
79
  const name = kit.propertyNameText(member.name);
98
80
  if (!name || !isBackgroundName(name)) continue;
99
81
  methods += 1;
100
- if (!reachable.has(file.rel)) report(file, member.name, `${name} is background work (a ${BACKGROUND_WORDS.join(', ')} method) that no job or consumer composed by a worker app can reach. Add a job in transport/schedule or a consumer in transport/message of a feature that runs it, and compose that transport module in a worker app.`, { method: name });
82
+ if (!reachable.has(file.rel)) report(file, member.name, `${name} is background work (a ${BACKGROUND_WORDS.join(', ')} method) that no processor or consumer composed by a worker or api app can reach. Add a processor in features/jobs/<job> or a consumer in transport/message of a feature that runs it, and compose its module in a worker or api app.`, { method: name });
101
83
  }
102
84
  }
103
85
  return true;
@@ -1,5 +1,6 @@
1
1
  import { builtinModules } from 'node:module';
2
- import { sourceLocation } from './typescript.mjs';
2
+ import { sourceLocation } from '../../lib/ts-ast.mjs';
3
+ import { isServerActionModule } from './server-action.mjs';
3
4
 
4
5
  /**
5
6
  * R55 `client-reaches-server` (FE_CLIENT_REACHES_SERVER), the repository half of the eslint rule `client-no-server-import`,
@@ -75,6 +76,7 @@ export function checkClientReachesServer({ graph, context }) {
75
76
  const queue = [entry.rel];
76
77
  for (let head = 0; head < queue.length; head += 1) {
77
78
  const rel = queue[head];
79
+ if (isServerActionModule(ts, graph.files.get(rel).sourceFile)) continue;
78
80
  const state = seen.get(rel);
79
81
  for (const hit of serverOf(rel)) {
80
82
  const at = state.first ?? sourceLocation(graph.files.get(rel).sourceFile, hit.node);
@@ -16,7 +16,7 @@
16
16
  *
17
17
  * Two kinds of file are uniform by design and are not compared, both found through the slot resolver: the entry main.ts of
18
18
  * an app (slot appKind + its required main.ts) and the transport doors of a feature (a *.controller.ts, *.resolver.ts,
19
- * *.gateway.ts, *.consumer.ts, *.job.ts or *.cli.ts of a slot that a protocol app composes). This is not a loophole: logic
19
+ * *.gateway.ts, *.consumer.ts, *.processor.ts or *.cli.ts of a slot that a protocol app composes). This is not a loophole: logic
20
20
  * cannot live in either. A door body is dispatch only (eslint transport-is-thin, R88), and main.ts cannot share a
21
21
  * helper because NestFactory is allowed only in main.ts (BE_ENTRYPOINT_ONLY_IN_APPS), so what repeats there is the
22
22
  * canonical shape of section 5 of BE-CONVENTION. A door or main.ts that grows logic is refused by those enforcers, not
@@ -25,12 +25,12 @@
25
25
  export const CLONE_RULE_IDS = ['HFS_DUPLICATE_CODE'];
26
26
 
27
27
  const MAX_VIOLATIONS = 200;
28
- const DOOR_ROLES = new Set(['controller', 'resolver', 'gateway', 'consumer', 'job', 'cli']);
28
+ const DOOR_ROLES = new Set(['controller', 'resolver', 'gateway', 'consumer', 'processor', 'cli']);
29
29
  const APP_ENTRY = 'main.ts';
30
30
  const ID = -1;
31
31
  const LIT = -2;
32
32
 
33
- function tokenize(ts, sourceFile) {
33
+ export function tokenize(ts, sourceFile) {
34
34
  const { SyntaxKind } = ts;
35
35
  const literalKinds = new Set([SyntaxKind.StringLiteral, SyntaxKind.NumericLiteral, SyntaxKind.BigIntLiteral, SyntaxKind.RegularExpressionLiteral,
36
36
  SyntaxKind.NoSubstitutionTemplateLiteral, SyntaxKind.TemplateHead, SyntaxKind.TemplateMiddle, SyntaxKind.TemplateTail, SyntaxKind.JsxText]);
@@ -76,7 +76,7 @@ function uniformByDesign(resolver, rel) {
76
76
  const base = rel.slice(rel.lastIndexOf('/') + 1);
77
77
  if (slot.appKind !== undefined) return base === APP_ENTRY && (slot.requires ?? []).includes(APP_ENTRY);
78
78
  const role = base.split('.').at(-2);
79
- return (slot.composedBy?.length ?? 0) > 0 && DOOR_ROLES.has(role) && base.endsWith('.ts');
79
+ return !slot.owner && (slot.composedBy?.length ?? 0) > 0 && DOOR_ROLES.has(role) && base.endsWith('.ts');
80
80
  }
81
81
 
82
82
  function homeText(profile, sameOwner, crossApp) {
@@ -120,9 +120,10 @@ function workspaceDirectories(root, { packageRoot: appPackageRoot = root, side =
120
120
  }
121
121
  candidates = next;
122
122
  }
123
- if (!candidates.length) throw Error(`Workspace pattern matched no package directories: ${normalized}.`);
124
123
  // npm expands a `*` segment to the directories that hold a package.json and skips the rest (an empty or untracked folder is
125
- // HFS_EMPTY_DIR / HFS_SLOT_UNDECLARED, never a reason to analyse nothing); a literal workspace path must resolve.
124
+ // HFS_EMPTY_DIR / HFS_SLOT_UNDECLARED, never a reason to analyse nothing); a literal workspace path must resolve. A `*`
125
+ // pattern may match nothing: every app declares the same workspaces (fe/apps/*, fe/packages/*, HFS_MONO_WORKSPACES) whether
126
+ // or not it has a package yet, and the root patterns are held to that fixed list by the rule, not here.
126
127
  const wildcard = segments.includes('*');
127
128
  for (const candidate of candidates) admit(candidate, `workspace ${normalized}`, !wildcard);
128
129
  }
@@ -262,13 +263,13 @@ const GRAMMAR_PACKAGE = '@starci/grammar';
262
263
  function derivedGrammar(root, packageRoot, workspaces, apps = []) {
263
264
  const styleSources = apps.map(app => `apps/${app.name}/src/app/globals.css`).filter(relative => existingRegularFile(root, relative));
264
265
  if (!styleSources.length) return null;
265
- // The apps have no package.json of their own: the app root's one manifest declares their dependencies, and it is a consumer
266
- // when it declares the Grammar package; so is every workspace package that does.
266
+ // Every fe app and package is an npm workspace that declares what it imports (HFS_MONO_WORKSPACE_DEP): each workspace that
267
+ // declares the Grammar package is a consumer, and so is the app root's manifest when it does.
267
268
  const declaresGrammar = (pkg) => Boolean(pkg && [pkg.dependencies, pkg.peerDependencies, pkg.devDependencies].some(section => section && Object.hasOwn(section, GRAMMAR_PACKAGE)));
268
269
  const appManifest = slash(path.relative(root, path.join(packageRoot, 'package.json')));
269
270
  const consumerManifests = declaresGrammar(readJson(path.join(packageRoot, 'package.json'))) ? [appManifest] : [];
270
271
  for (const workspace of workspaces) {
271
- if (!workspace.startsWith('packages/')) continue;
272
+ if (!workspace.startsWith('packages/') && !workspace.startsWith('apps/')) continue;
272
273
  const manifest = `${workspace}/package.json`;
273
274
  const pkg = readJson(path.join(root, ...manifest.split('/')));
274
275
  if (declaresGrammar(pkg)) consumerManifests.push(manifest);
@@ -298,7 +299,9 @@ export function loadArchitectureConfig(repositoryRoot, { hfs } = {}) {
298
299
  if (!projects.length) throw Error('The repository has no tsconfig.json to derive a TypeScript project from.');
299
300
  const backend = {
300
301
  modules: ['src/modules'],
301
- features: ['src/features'],
302
+ // The layered feature root is the api kind (src/features/api/<feature>/: application/ and transport/<protocol>/); the cli
303
+ // feature root (src/features/cli/) has its own shape, held by its slot and the cli rules.
304
+ features: ['src/features/api'],
302
305
  apps: ['apps'],
303
306
  moduleRegistration: { providerIdentity: 'exported-class-token', handlerDecorators: [...DECLARED_HANDLER_DECORATORS] },
304
307
  };