@starci/eslint-canon-be 3.2.18 → 3.3.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 (50) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +46 -4
  3. package/app-composition.mjs +176 -0
  4. package/config-owner.mjs +34 -19
  5. package/feature-thin.mjs +59 -17
  6. package/index.mjs +2 -0
  7. package/lib/app-roles.mjs +48 -0
  8. package/lib/webhook-ack.mjs +87 -0
  9. package/lib/webhook-handoff.mjs +138 -0
  10. package/lib/webhook-verifiers.mjs +147 -0
  11. package/module-layering.mjs +2 -2
  12. package/module-shape.mjs +6 -5
  13. package/package.json +1 -1
  14. package/runtime/knowledge/hfs/canon-pins.yaml +4 -4
  15. package/runtime/knowledge/hfs/rules.yaml +41 -13
  16. package/runtime/knowledge/hfs/slots.yaml +99 -43
  17. package/runtime/knowledge/patterns/fe/folder.yaml +3 -0
  18. package/runtime/scripts/api/git/cat-file.mjs +5 -0
  19. package/runtime/scripts/api/git/diff.mjs +5 -0
  20. package/runtime/scripts/hfs/architecture/backend-app-files.mjs +110 -0
  21. package/runtime/scripts/hfs/architecture/backend.mjs +20 -3
  22. package/runtime/scripts/hfs/architecture/background-unowned.mjs +157 -41
  23. package/runtime/scripts/hfs/architecture/canon-clones.mjs +240 -0
  24. package/runtime/scripts/hfs/architecture/canon-jsx-clones.mjs +143 -0
  25. package/runtime/scripts/hfs/architecture/clones.mjs +17 -8
  26. package/runtime/scripts/hfs/architecture/connection-map.mjs +14 -12
  27. package/runtime/scripts/hfs/architecture/context-map.mjs +25 -14
  28. package/runtime/scripts/hfs/architecture/context-owner.mjs +1 -2
  29. package/runtime/scripts/hfs/architecture/dead-exports.mjs +14 -9
  30. package/runtime/scripts/hfs/architecture/frontend-block-render.mjs +104 -0
  31. package/runtime/scripts/hfs/architecture/frontend-world-render.mjs +30 -11
  32. package/runtime/scripts/hfs/architecture/i18n-keys.mjs +16 -3
  33. package/runtime/scripts/hfs/architecture/machine-ast.mjs +17 -0
  34. package/runtime/scripts/hfs/architecture/module-per-transport.mjs +21 -12
  35. package/runtime/scripts/hfs/architecture/register-once.mjs +13 -5
  36. package/runtime/scripts/hfs/architecture/required-files.mjs +2 -1
  37. package/runtime/scripts/hfs/architecture/staged-consumers.mjs +37 -0
  38. package/runtime/scripts/hfs/architecture/supabase-ast.mjs +3 -6
  39. package/runtime/scripts/hfs/architecture/supabase-be.mjs +1 -6
  40. package/runtime/scripts/hfs/architecture/tiers.mjs +8 -1
  41. package/runtime/scripts/hfs/architecture/typescript.mjs +1 -8
  42. package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +1 -11
  43. package/runtime/scripts/hfs/manifest-shape.mjs +27 -2
  44. package/runtime/scripts/hfs/path-findings.mjs +32 -2
  45. package/runtime/scripts/hfs/rules/secrets.mjs +13 -0
  46. package/runtime/scripts/hfs/slot-imports.mjs +3 -1
  47. package/runtime/scripts/hfs/slot-required.mjs +13 -0
  48. package/runtime/scripts/hfs/slot-semantic-problems.mjs +2 -1
  49. package/runtime/scripts/lib/ts-analysis.mjs +20 -0
  50. package/webhooks.mjs +141 -69
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { byCodeUnit } from '../../lib/list.mjs';
4
+ import { escapeRegExp } from '../../lib/regex.mjs';
4
5
 
5
6
  /**
6
7
  * R106 `i18n-keys` (FE_I18N_KEYS). The catalogs of an app (`apps/<app>/src/modules/i18n/messages/<locale>.json`, slot fe.modules.i18n) and
@@ -12,7 +13,7 @@ import { byCodeUnit } from '../../lib/list.mjs';
12
13
  * own source (both branches of a conditional key are read). A key no locale's catalog holds is FE_I18N_KEYS, once per call. A computed
13
14
  * key (a template with an interpolation, a variable) is not judged here; it counts as a read.
14
15
  * - a key nobody reads is dead: a leaf of the app's catalogs that no string in the app's or the shared packages' source can be reading.
15
- * A leaf counts as read when a string literal or a template pattern (each interpolation a wildcard segment) of that source equals a
16
+ * A leaf counts as read when a string literal or a template pattern (each interpolation a wildcard within its dotted segment) equals a
16
17
  * tail of its dotted path (`"title"`, `"card.title"`, `"orders.card.title"` all read `orders.card.title`); the test is deliberately loose
17
18
  * so a key handed through props or a table of keys is never reported, and only a key that appears nowhere is.
18
19
  *
@@ -39,7 +40,10 @@ const held = (leaves, full) => leaves.has(full) || [...leaves].some(leaf => leaf
39
40
 
40
41
  // A pattern that is only wildcards (`${a}`, `${a}.${b}`) names nothing: it would read every key, so it reads none.
41
42
  const tailMatches = (pattern, leafSegments) => pattern.some(segment => segment !== '*' && segment !== '') && pattern.length <= leafSegments.length
42
- && pattern.every((segment, index) => segment === '*' || segment === leafSegments[leafSegments.length - pattern.length + index]);
43
+ && pattern.every((segment, index) => {
44
+ const leaf = leafSegments[leafSegments.length - pattern.length + index];
45
+ return segment instanceof RegExp ? segment.test(leaf) : segment === '*' || segment === leaf;
46
+ });
43
47
 
44
48
  function catalogsPerApp(config, graph) {
45
49
  const catalogs = new Map();
@@ -56,12 +60,21 @@ function catalogsPerApp(config, graph) {
56
60
  return catalogs;
57
61
  }
58
62
 
63
+ /** Each interpolation can fill part or all of a segment; the template's static fragments keep their literal spelling. */
64
+ function templatePatternOf(node) {
65
+ return [node.head.text, ...node.templateSpans.map(span => span.literal.text)].join('\u0000').split('.').map(segment => {
66
+ if (!segment.includes('\u0000')) return segment;
67
+ const parts = segment.split('\u0000');
68
+ return parts.every(part => part === '') ? '*' : new RegExp(`^${parts.map(escapeRegExp).join('.*')}$`, 'su');
69
+ });
70
+ }
71
+
59
72
  function patternsOf(ts, files) {
60
73
  const patterns = [];
61
74
  for (const file of files) {
62
75
  const visit = node => {
63
76
  if (ts.isStringLiteralLike(node)) patterns.push(node.text.split('.'));
64
- else if (ts.isTemplateExpression(node)) patterns.push([node.head.text, ...node.templateSpans.map(span => span.literal.text)].join('\u0000').replaceAll('\u0000', '*').split('.'));
77
+ else if (ts.isTemplateExpression(node)) patterns.push(templatePatternOf(node));
65
78
  ts.forEachChild(node, visit);
66
79
  };
67
80
  visit(file.sourceFile);
@@ -1,6 +1,7 @@
1
1
  import path from 'node:path';
2
2
  import { canonical } from './config.mjs';
3
3
  import { hasAnyFlag, sourceLocation } from '../../lib/ts-ast.mjs';
4
+ import { byCodeUnit } from '../../lib/list.mjs';
4
5
 
5
6
  const HELPER_DEPTH = 4;
6
7
 
@@ -110,6 +111,21 @@ const at = (env, rel, sourceFile, node, extra = {}) => ({ path: rel, ...sourceLo
110
111
  /** The app root file of an app (apps/<name>/src/app.module.ts), or null when the program does not hold it. */
111
112
  const appRoot = (env, name) => graphFile(env, `apps/${name}/src/app.module.ts`);
112
113
 
114
+ /**
115
+ * Whether a repository-relative file is a composition group of an app: `apps/<app>/src/compose/<area>.ts`, the module list of
116
+ * one area that the app root spreads into its imports (slot allows `compose/<area>.ts` of every be.app.* slot).
117
+ */
118
+ export const isComposeGroup = rel => /^apps\/[^/]+\/src\/compose\/[^/]+\.ts$/.test(rel) && !/\.(?:spec|d)\.ts$/.test(rel);
119
+
120
+ /** The app root and the composition groups of an app, in path order (the root first); [] when the program holds no root. */
121
+ const appCompositionFiles = (env, name) => {
122
+ const root = appRoot(env, name);
123
+ if (!root) return [];
124
+ const prefix = `apps/${name}/src/compose/`;
125
+ const groups = [...env.graph.files.values()].filter(file => file.rel.startsWith(prefix) && isComposeGroup(file.rel));
126
+ return [root, ...groups.sort((a, b) => byCodeUnit(a.rel, b.rel))];
127
+ };
128
+
113
129
  /** The body a call or a spread identifier resolves to when it is a function or a const declared in a file of the program (not a package), else null. */
114
130
  function helperBodyOf(env, checker, node) {
115
131
  const { ts } = env;
@@ -200,6 +216,7 @@ export function machineKit({ context, graph }) {
200
216
  valueOfProperty: bound(valueOfProperty),
201
217
  at: bound(at),
202
218
  appRoot: bound(appRoot),
219
+ appCompositionFiles: bound(appCompositionFiles),
203
220
  };
204
221
  }
205
222
 
@@ -1,5 +1,5 @@
1
1
  import path from 'node:path';
2
- import { machineKit } from './machine-ast.mjs';
2
+ import { isComposeGroup, machineKit } from './machine-ast.mjs';
3
3
  import { treeOf } from './required-files.mjs';
4
4
  import { byCodeUnit } from '../../lib/list.mjs';
5
5
  import { trimTrailingSlashes } from '../trailing-slashes.mjs';
@@ -13,8 +13,9 @@ import { trimTrailingSlashes } from '../trailing-slashes.mjs';
13
13
  * anywhere in a feature, and an `@Module` class (or a `ConfigurableModuleBuilder`/`ConfigurableModuleClass` use) in a
14
14
  * feature file that is neither the application module nor the transport module of its own folder are refused;
15
15
  * - apps import only transport modules, of the kinds their app kind allows: the `composedBy` list of each transport slot
16
- * in knowledge/hfs/slots.yaml (api: graphql, http, websocket; worker: schedule, message). An app root that
17
- * lists a feature's application module, or the transport module of another kind, is refused;
16
+ * in knowledge/hfs/slots.yaml (api: graphql, http, websocket; worker: schedule, message). An app root (app.module.ts
17
+ * or a composition group of its compose/ folder) that lists a feature's application module, or the transport module
18
+ * of another kind, is refused;
18
19
  * - a composed root (an owner slot with `composedBy`, be.cli) holds one static module per folder, named after the folder
19
20
  * (cli.module.ts, <group>/<group>.module.ts); an app of a composing kind lists the root module only, and an app of any
20
21
  * other kind lists none of its modules.
@@ -22,7 +23,6 @@ import { trimTrailingSlashes } from '../trailing-slashes.mjs';
22
23
  export const MODULE_PER_TRANSPORT_RULE_IDS = ['BE_MODULE_SHAPE'];
23
24
 
24
25
  const RULE = 'BE_MODULE_SHAPE';
25
- const APP_ROOT_FILE = 'app.module.ts';
26
26
 
27
27
  function featureOwnerOf(resolver, rel) {
28
28
  const owner = resolver.ownerOf(rel);
@@ -152,28 +152,37 @@ function inspectModuleDeclarations(checker, target, node, root, app, scan, seen)
152
152
  return references;
153
153
  }
154
154
 
155
- function inspectAppRoot(app, scan) {
156
- const { graph, kit, ts } = scan;
157
- const root = graph.files.get(`apps/${app.name}/src/${APP_ROOT_FILE}`);
158
- if (!root) return 0;
159
- const checker = kit.checkerOf(root.sourceFile);
160
- const seen = new Set();
155
+ /** The module references of one composition file: the root's `imports` lists, or every array of a composition group (its module list). */
156
+ function inspectCompositionFile(file, app, scan, seen) {
157
+ const { kit, ts } = scan;
158
+ const checker = kit.checkerOf(file.sourceFile);
159
+ const group = isComposeGroup(file.rel);
161
160
  let references = 0;
162
161
  const judge = node => {
163
162
  const call = ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === 'register';
164
163
  if (ts.isCallExpression(node) && !call) return;
165
164
  const target = call ? node.expression.expression : node;
166
- references += inspectModuleDeclarations(checker, target, node, root, app, scan, seen);
165
+ references += inspectModuleDeclarations(checker, target, node, file, app, scan, seen);
167
166
  };
168
- kit.walk(root.sourceFile, node => {
167
+ kit.walk(file.sourceFile, node => {
169
168
  if (ts.isPropertyAssignment(node) && kit.propertyNameText(node.name) === 'imports' && ts.isArrayLiteralExpression(node.initializer)) {
170
169
  for (const element of node.initializer.elements) judge(element);
170
+ } else if (group && ts.isArrayLiteralExpression(node)) {
171
+ for (const element of node.elements) judge(element);
171
172
  } else if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === 'register') judge(node);
172
173
  return true;
173
174
  });
174
175
  return references;
175
176
  }
176
177
 
178
+ /** The module references of an app: its root `apps/<app>/src/app.module.ts` and the composition groups of `compose/` it spreads. */
179
+ function inspectAppRoot(app, scan) {
180
+ const seen = new Set();
181
+ let references = 0;
182
+ for (const file of scan.kit.appCompositionFiles(app.name)) references += inspectCompositionFile(file, app, scan, seen);
183
+ return references;
184
+ }
185
+
177
186
  function appModuleReferences(apps, scan) {
178
187
  let references = 0;
179
188
  for (const app of apps) references += inspectAppRoot(app, scan);
@@ -1,5 +1,5 @@
1
1
  import path from 'node:path';
2
- import { machineKit } from './machine-ast.mjs';
2
+ import { isComposeGroup, machineKit } from './machine-ast.mjs';
3
3
  import { callName } from './supabase-ast.mjs';
4
4
  import { isTestWorldSlot } from '../test-world-slot.mjs';
5
5
  import { byCodeUnit } from '../../lib/list.mjs';
@@ -7,7 +7,8 @@ import { byCodeUnit } from '../../lib/list.mjs';
7
7
  /**
8
8
  * R45 `register-once` (BE_MODULE_SHAPE), the module graph read from the app roots (owner rule of 2026-09-30, the
9
9
  * reference's `CodingModule imports DeviceModule` shape). Each capability has one representative module: the only module
10
- * of the capability an app registers, once, in `apps/<app>/src/app.module.ts` as `X.register({ isGlobal: true, ... })`.
10
+ * of the capability an app registers, once, in `apps/<app>/src/app.module.ts` as `X.register({ isGlobal: true, ... })`, or in a
11
+ * composition group `apps/<app>/src/compose/<area>.ts` the root spreads (an app root here means the root and its groups).
11
12
  * It may split itself into sub-modules imported as plain `imports: [Sub]`; a sub-module is never in an app and has one
12
13
  * importer, its parent. Over every `@Module` class of the program:
13
14
  *
@@ -57,6 +58,7 @@ function enclosingModule(ts, modules, node) {
57
58
  /** Records the reference an element of an imports list, or a register call, makes to a module. */
58
59
  function noteReference(scan, element, viaImports) {
59
60
  const { ts, kit, modules, checker, file, seen, references, isAppRoot } = scan;
61
+ if (seen.has(element)) return;
60
62
  const call = callName(ts, element) === 'register';
61
63
  if (ts.isCallExpression(element) && !call) return;
62
64
  const target = targetOf(kit, modules, checker, call ? element.expression.expression : element);
@@ -72,14 +74,19 @@ const isImportsList = (scan, node) => scan.ts.isPropertyAssignment(node) && scan
72
74
  const isStrayGlobal = (scan, node) => scan.ts.isPropertyAssignment(node) && scan.kit.propertyNameText(node.name) === 'isGlobal'
73
75
  && node.initializer.kind === scan.ts.SyntaxKind.TrueKeyword && !scan.isAppRoot(scan.file) && !scan.isWorld(scan.file);
74
76
 
77
+ // A composition group's module list is any array of the group: the root spreads it into its imports.
78
+ const isGroupList = (scan, node) => scan.ts.isArrayLiteralExpression(node) && isComposeGroup(scan.file.rel);
79
+
75
80
  function visitReferenceNode(scan, node) {
76
81
  if (isImportsList(scan, node)) {
77
82
  for (const element of node.initializer.elements) noteReference(scan, element, true);
83
+ } else if (isGroupList(scan, node)) {
84
+ for (const element of node.elements) noteReference(scan, element, true);
78
85
  } else if (callName(scan.ts, node) === 'register' && !scan.seen.has(node)) {
79
86
  noteReference(scan, node, false);
80
87
  }
81
88
  if (isStrayGlobal(scan, node)) {
82
- scan.report(scan.file, node, '`isGlobal: true` appears only in the app root (apps/<app>/src/app.module.ts), where the representative module of a capability is registered; a module never decides its own globality.');
89
+ scan.report(scan.file, node, '`isGlobal: true` appears only in the app root (apps/<app>/src/app.module.ts or a group of apps/<app>/src/compose/), where the representative module of a capability is registered; a module never decides its own globality.');
83
90
  }
84
91
  return true;
85
92
  }
@@ -170,11 +177,12 @@ export function checkRegisterOnce(input) {
170
177
  const { ts } = kit;
171
178
  const violations = [];
172
179
  const report = (file, node, message, extra = {}) => violations.push({ ruleId: RULE, path: file.rel, ...kit.at(file.rel, file.sourceFile, node), message, ...extra });
173
- const isAppRoot = file => Boolean(file.slot?.startsWith('be.app.')) && path.posix.basename(file.rel) === 'app.module.ts';
180
+ const isAppRoot = file => Boolean(file.slot?.startsWith('be.app.')) && (path.posix.basename(file.rel) === 'app.module.ts' || isComposeGroup(file.rel));
174
181
  // The test world (slot be.tests.world) is the test composition root: useTestWorld registers capability modules with
175
182
  // `isGlobal: true` the way an app root does. A spec (integration, contract, e2e) is not a composition root.
176
183
  const isWorld = file => isTestWorldSlot(file.slot);
177
- const appOf = file => config.apps.find(app => file.rel === `apps/${app.name}/src/app.module.ts`)?.name ?? null;
184
+ const appOf = file => config.apps.find(app => file.rel === `apps/${app.name}/src/app.module.ts`
185
+ || (isComposeGroup(file.rel) && file.rel.startsWith(`apps/${app.name}/src/compose/`)))?.name ?? null;
178
186
  const modules = moduleDeclarations(graph, kit, ts);
179
187
  const references = moduleReferences(graph, kit, ts, modules, isWorld, isAppRoot, report);
180
188
  const rootReferences = references.filter(item => item.root);
@@ -4,6 +4,7 @@ import { lsFiles } from '../../api/git/ls-files.mjs';
4
4
  import { revParseQuery } from '../../api/git/rev-parse-query.mjs';
5
5
  import { gitOutputOf } from '../../lib/git.mjs';
6
6
  import { trimTrailingSlashes } from '../trailing-slashes.mjs';
7
+ import { requiredFileApplies } from '../slot-required.mjs';
7
8
 
8
9
  /**
9
10
  * HFS check 5: the files and directories the slot manifest requires (knowledge/hfs/slots.yaml `requires`,
@@ -173,7 +174,7 @@ function requirementExists(tree, target) {
173
174
  }
174
175
 
175
176
  function reportMissingRequirement(requirement, tree, report) {
176
- if (requirementExists(tree, requirement.target)) return;
177
+ if (requirementExists(tree, requirement.target) || !requiredFileApplies(requirement.slot, requirement.target, tree.files)) return;
177
178
  const rootExists = !requirement.root || tree.directories.has(requirement.root);
178
179
  if (!rootExists) {
179
180
  report(ruleFor(requirement.slot, requirement.target, false), requirement.root, requirement.slot, requirement.root, { kind: 'directory' },
@@ -0,0 +1,37 @@
1
+ import { catFile } from '../../api/git/cat-file.mjs';
2
+ import { diff } from '../../api/git/diff.mjs';
3
+ import { gitOutputOf } from '../../lib/git.mjs';
4
+
5
+ /** Eligible additions, copies, edits and rename destinations in the index, read in one blob batch. */
6
+ export function stagedConsumerSources(root, accept) {
7
+ try {
8
+ const options = { cwd: root, maxBuffer: 256 * 1024 * 1024 };
9
+ const files = gitOutputOf(diff(['--cached', '--relative', '--name-only', '--diff-filter=ACMR', '-z', '--', '.'], options))
10
+ .split('\0').filter(file => file && accept(file));
11
+ if (!files.length) return [];
12
+ // :./ keeps paths relative to this side's root even when git's top level is the parent app folder.
13
+ // -z terminates requests with NUL; output headers and blob trailers still use LF.
14
+ const bytes = gitOutputOf(catFile(['--batch', '-z'], {
15
+ ...options, encoding: null, input: files.map(file => `:./${file}\0`).join(''),
16
+ }));
17
+ if (!Buffer.isBuffer(bytes)) return [];
18
+ const sources = [];
19
+ let cursor = 0;
20
+ for (const file of files) {
21
+ const end = bytes.indexOf(10, cursor);
22
+ if (end < 0) return [];
23
+ const header = /^[0-9a-f]+ blob (\d+)$/u.exec(bytes.subarray(cursor, end).toString('utf8'));
24
+ if (!header) return []; // An index changed during the read: discard this supplement, keep disk consumers.
25
+ const size = Number(header[1]);
26
+ const start = end + 1;
27
+ const next = start + size;
28
+ if (!Number.isSafeInteger(size) || next >= bytes.length || bytes[next] !== 10) return [];
29
+ sources.push({ file, text: bytes.subarray(start, next).toString('utf8') });
30
+ cursor = next + 1;
31
+ }
32
+ return sources;
33
+ } catch {
34
+ // A non-git fixture (or an unavailable index) still uses the existing filesystem consumers.
35
+ return [];
36
+ }
37
+ }
@@ -1,5 +1,7 @@
1
1
  // supabase-ast.mjs - shared TypeScript-symbol and expression helpers for the front-end and back-end Supabase
2
2
  // architecture checks. Recognition follows imported declarations and inferred types, never local spelling alone.
3
+ import { unwrapExpression } from '../../lib/ts-analysis.mjs';
4
+
3
5
  export const SUPABASE_MODULES = new Set(['@supabase/ssr', '@supabase/supabase-js']);
4
6
 
5
7
  export const moduleNameOf = (ts, node) => {
@@ -38,12 +40,7 @@ const signatureFromSupabase = (checker, call) => {
38
40
  try { return declarationFromSupabase(checker.getResolvedSignature(call)?.declaration); } catch { return false; }
39
41
  };
40
42
 
41
- export const unwrap = (ts, node) => {
42
- let current = node;
43
- while (current && (ts.isParenthesizedExpression(current) || ts.isAwaitExpression(current) || ts.isAsExpression(current)
44
- || ts.isTypeAssertionExpression(current) || ts.isNonNullExpression(current))) current = current.expression;
45
- return current;
46
- };
43
+ export const unwrap = (ts, node) => unwrapExpression(ts, node, { awaitExpressions: true, satisfiesExpressions: false });
47
44
 
48
45
  const isSupabaseValue = (kit, checker, node) => {
49
46
  const current = unwrap(kit.ts, node);
@@ -3,6 +3,7 @@
3
3
  import path from 'node:path';
4
4
  import { machineKit } from './machine-ast.mjs';
5
5
  import { importedFrom, reportAt, unwrap } from './supabase-ast.mjs';
6
+ import { accessName } from './backend-app-files.mjs';
6
7
 
7
8
  const BE_SUPABASE_SLOT = 'be.integrations.supabase';
8
9
  const BE_JWT_VERIFIED = 'BE_SUPABASE_JWT_VERIFIED';
@@ -47,12 +48,6 @@ const importedCall = (kit, checker, call) => {
47
48
  return owner?.name === 'default' ? { module: owner.module, name: call.expression.name.text } : null;
48
49
  };
49
50
 
50
- const accessName = (ts, node) => {
51
- if (ts.isPropertyAccessExpression(node)) return node.name.text;
52
- if (ts.isElementAccessExpression(node) && ts.isStringLiteralLike(node.argumentExpression)) return node.argumentExpression.text;
53
- return null;
54
- };
55
-
56
51
  const requestLike = (ts, node) => {
57
52
  const current = unwrap(ts, node);
58
53
  if (ts.isIdentifier(current)) return /^(?:req|request)$/iu.test(current.text);
@@ -14,8 +14,15 @@ import { byCodeUnit } from '../../lib/list.mjs';
14
14
  */
15
15
  export const TIER_RULE_IDS = ['BE_TIER_DIRECTION', 'BE_FEATURE_IMPORTS_FEATURE', 'BE_KIND_ISOLATION', 'FE_TIER_DIRECTION', 'FE_APP_ISOLATION', 'ARCH_OWNER_CYCLE'];
16
16
 
17
+ function tierDirectionText({ fromTier, toTier, mayImport, kindRestriction, fromKind, toKind }) {
18
+ if (!kindRestriction) return `a ${fromTier} may import only ${mayImport.join(', ') || 'nothing'}; it imports a ${toTier}`;
19
+ const source = fromKind ?? 'no folder kind';
20
+ const target = toKind ?? 'no folder kind';
21
+ return `a ${fromTier} may import ${toTier} owners only from kinds ${kindRestriction.from.join(', ')} to kinds ${kindRestriction.to.join(', ')}; it imports ${source} -> ${target}`;
22
+ }
23
+
17
24
  const REASON_TEXT = {
18
- tierDirection: ({ fromTier, toTier, mayImport }) => `a ${fromTier} may import only ${mayImport.join(', ') || 'nothing'}; it imports a ${toTier}`,
25
+ tierDirection: tierDirectionText,
19
26
  layerOrder: ({ fromLayer, toLayer }) => `a ${fromLayer} component may import only the layers after it; it imports a ${toLayer}`,
20
27
  crossApp: ({ from, to }) => `app ${from} imports app ${to}; apps never import each other, shared code is a packages/<pkg> slot`,
21
28
  };
@@ -9,6 +9,7 @@ import { createTypeScriptProgram, readTypeScriptProject, resolveTypeScriptModule
9
9
  import { readJsonFile } from '../../lib/json.mjs';
10
10
  import { sourceLocation } from '../../lib/ts-ast.mjs';
11
11
  import { locateDeclaration } from '../slots.mjs';
12
+ export { unwrapExpression } from '../../lib/ts-analysis.mjs';
12
13
 
13
14
  const CODE_EXTENSIONS = /\.(?:[cm]?[jt]sx?)$/i;
14
15
  const SCRIPT_TAIL = '[cm]?[jt]sx?';
@@ -399,14 +400,6 @@ export function reachableViolation(edges, firstEdge, forbidden, { follow = () =>
399
400
  /** A framework identity the checkers cannot prove statically. */
400
401
  export const UNPROVEN_FRAMEWORK = '(unproven framework identity)';
401
402
 
402
- /** The expression under parentheses, type assertions, non-null and satisfies wrappers. */
403
- export function unwrapExpression(ts, expression) {
404
- while (expression && (ts.isParenthesizedExpression(expression) || ts.isAsExpression(expression)
405
- || ts.isTypeAssertionExpression(expression) || ts.isNonNullExpression(expression)
406
- || (ts.isSatisfiesExpression?.(expression) ?? false))) expression = expression.expression;
407
- return expression;
408
- }
409
-
410
403
  /* ---- the AST walks every architecture checker shares ---- */
411
404
 
412
405
  /** The names of `expected` an import or re-export statement binds (all of them for a namespace or star form). */
@@ -4,6 +4,7 @@ import { machineKit } from './machine-ast.mjs';
4
4
  import { constructorDependencies } from './constructor-deps.mjs';
5
5
  import { treeOf } from './required-files.mjs';
6
6
  import { byCodeUnit } from '../../lib/list.mjs';
7
+ import { importsOf } from '../../lib/ts-analysis.mjs';
7
8
 
8
9
  /**
9
10
  * R48 `unit-spec-providers` (BE_SPEC_QUALITY), the unit test standard's rule 3: the unit spec of a service builds its
@@ -25,17 +26,6 @@ export const UNIT_SPEC_PROVIDERS_RULE_IDS = ['BE_SPEC_QUALITY'];
25
26
  const RULE = 'BE_SPEC_QUALITY';
26
27
  const SPEC_ROOT = /^(?:src|apps)\//u;
27
28
 
28
- /** local name -> {name, module} for every named import of a source file. */
29
- function importsOf(ts, sourceFile) {
30
- const imports = new Map();
31
- for (const statement of sourceFile.statements) {
32
- const bindings = ts.isImportDeclaration(statement) ? statement.importClause?.namedBindings : null;
33
- if (!bindings || !ts.isNamedImports(bindings)) continue;
34
- for (const element of bindings.elements) imports.set(element.name.text, { name: (element.propertyName ?? element.name).text, module: statement.moduleSpecifier.text });
35
- }
36
- return imports;
37
- }
38
-
39
29
  /** The `Test.createTestingModule(...)` calls of a spec. */
40
30
  function testingModules(ts, sourceFile) {
41
31
  const calls = [];
@@ -323,6 +323,12 @@ function runtimeParamProblems(rp) {
323
323
  ];
324
324
  }
325
325
 
326
+ const folderKindsValid = list => strList(list) && list.length > 0 && new Set(list).size === list.length && list.every(kind => NAME.test(kind));
327
+ const kindPairValid = pair => isPlainObject(pair) && Object.keys(pair).sort(byCodeUnit).join() === 'from,to'
328
+ && ['from', 'to'].every(side => folderKindsValid(pair[side]));
329
+ const kindRestrictionValid = kinds => isPlainObject(kinds) && Object.keys(kinds).length > 0
330
+ && Object.entries(kinds).every(([target, pair]) => NAME.test(target) && kindPairValid(pair));
331
+
326
332
  /** Shape problems of the tier map of one profile. */
327
333
  export function tierMapProblems(profile, tiers) {
328
334
  const bad = [];
@@ -330,14 +336,33 @@ export function tierMapProblems(profile, tiers) {
330
336
  for (const [tier, def] of Object.entries(tiers)) {
331
337
  if (!NAME.test(tier)) bad.push(`tiers.${profile}.${tier} is not a tier name`);
332
338
  if (!isPlainObject(def) || !Array.isArray(def.mayImport) || !def.mayImport.every((t) => NAME.test(String(t)))) bad.push(`tiers.${profile}.${tier}.mayImport must be a list of tier names`);
333
- for (const key of Object.keys(def ?? {})) if (!['mayImport', 'acyclic', 'lowerLayerOnly'].includes(key)) bad.push(`tiers.${profile}.${tier}.${key} is not a tier field`);
339
+ if (def?.mayImportKinds !== undefined && !kindRestrictionValid(def.mayImportKinds)) bad.push(`tiers.${profile}.${tier}.mayImportKinds must map tier names to {from, to} non-empty lists of unique folder kinds`);
340
+ for (const key of Object.keys(def ?? {})) if (!['mayImport', 'mayImportKinds', 'acyclic', 'lowerLayerOnly'].includes(key)) bad.push(`tiers.${profile}.${tier}.${key} is not a tier field`);
341
+ }
342
+ return bad;
343
+ }
344
+
345
+ function tierFolderKindProblems(manifest, profile, tier, kinds, at) {
346
+ const known = new Set(manifest.slots.filter((slot) => slot.profiles.includes(profile) && slot.tier === tier).flatMap((slot) => [...(slot.kinds ?? []), ...(slot.layers ?? [])]));
347
+ return kinds.filter(kind => !known.has(kind)).map(kind => `${at} names ${kind}, which is not a folder kind of the ${tier} tier`);
348
+ }
349
+
350
+ /** Kind restrictions narrow an allowed tier edge and name kinds that its source and target slots actually declare. */
351
+ export function tierKindProblems(manifest, profile) {
352
+ const bad = [];
353
+ for (const [tier, def] of Object.entries(manifest.tiers[profile])) {
354
+ for (const [target, kinds] of Object.entries(def.mayImportKinds ?? {})) {
355
+ const at = `tiers.${profile}.${tier}.mayImportKinds.${target}`;
356
+ if (!def.mayImport.includes(target)) bad.push(`${at} restricts a tier that mayImport does not allow`);
357
+ bad.push(...tierFolderKindProblems(manifest, profile, tier, kinds.from, `${at}.from`), ...tierFolderKindProblems(manifest, profile, target, kinds.to, `${at}.to`));
358
+ }
334
359
  }
335
360
  return bad;
336
361
  }
337
362
 
338
363
  /** The semantic rules of a runtime manifest beyond the slot rules both kinds share: tier names and the schema major. */
339
364
  export function runtimeSemanticProblems(m) {
340
- const bad = [];
365
+ const bad = tierKindProblems(m, RUNTIME_KIND);
341
366
  const tiers = m.tiers.runtime;
342
367
  for (const [tier, def] of Object.entries(tiers)) {
343
368
  for (const target of def.mayImport) if (!(target in tiers)) bad.push(`tiers.runtime.${tier}.mayImport names ${target}, which is not a runtime tier`);
@@ -3,6 +3,7 @@
3
3
  // (scripts/hfs/project-graph.mjs) runs it over the same tree and serves the findings on a TypeScript file as ESLint reports.
4
4
  // HFS_SLOT_UNDECLARED / HFS_SLOT_AMBIGUOUS / HFS_SLOT_NOT_ENABLED / HFS_FORBIDDEN_PRESENT / HFS_TOOL_CONFIG_LOCAL / HFS_TRACKED_MUST_BE_IGNORED (R01, R16)
5
5
  // BE_SOURCE_FORM (R89, the file name), BE_SPEC_PLACEMENT (R102)
6
+ import fs from 'node:fs';
6
7
  import path from 'node:path';
7
8
  import { allowsFile } from './allows.mjs';
8
9
  import { isFeTestPath } from './rules/fe-no-tests.mjs';
@@ -30,8 +31,11 @@ function sourceFormContext(file, resolver, suffixes, bannedSuffixes) {
30
31
  const slot = resolver.slot(c.slot);
31
32
  if ([...(slot?.requires ?? []), ...(slot?.allows ?? [])].includes(base)) return null;
32
33
  const admitted = allowsFile(resolver, file);
34
+ // Environment parsing is an app slot role, not a source suffix admitted outside that matching app file.
35
+ if (c.slot.startsWith('be.app.') && admitted?.allowed && admitted.entry === '<app>.env.ts') return null;
33
36
  if (admitted?.allowed && admitted.entry?.includes('/') && path.posix.basename(admitted.entry) === base) return null;
34
- if (admitted?.allowed && PLAIN_ENTRY.test(admitted.entry ?? '') && KEBAB.test(base.slice(0, -'.ts'.length))) return null;
37
+ // A plain entry names its file by a variable only (`<x>.ts`, or `compose/<area>.ts` of an app slot): a kebab name is its form.
38
+ if (admitted?.allowed && PLAIN_ENTRY.test(path.posix.basename(admitted.entry ?? '')) && KEBAB.test(base.slice(0, -'.ts'.length))) return null;
35
39
  if (c.slot === 'be.persistence' && path.posix.basename(path.posix.dirname(file)) === 'migrations') return null;
36
40
  return { c, base, suffixes, bannedSuffixes };
37
41
  }
@@ -54,7 +58,7 @@ function sourceFormFinding(file, { c, base, suffixes, bannedSuffixes }, boundSuf
54
58
 
55
59
  /**
56
60
  * BE_SOURCE_FORM (R89): every tracked src/ or apps/ TypeScript file of a back end is index.ts, main.ts, a migration of
57
- * be.persistence, or <kebab-name>.<suffix>.ts with <suffix> in the closed vocabulary ruleParams.be.suffixes (a name such
61
+ * be.persistence, the matching <app>.env.ts explicitly admitted by its app slot, or <kebab-name>.<suffix>.ts with <suffix> in the closed vocabulary ruleParams.be.suffixes (a name such
58
62
  * as order.service.spec.ts keeps its inner words kebab-case). A suffix of ruleParams.be.bannedSuffixes anywhere in the
59
63
  * name is refused by name. Paths no slot owns are HFS_SLOT_UNDECLARED's, not this code's.
60
64
  */
@@ -86,6 +90,8 @@ function forbiddenPathFinding(file, c, resolver) {
86
90
  }
87
91
 
88
92
  function pathStatusFinding(file, resolver) {
93
+ const liteWork = liteStarciworkFinding(file, resolver);
94
+ if (liteWork) return liteWork;
89
95
  const c = resolver.classifyPath(file);
90
96
  if (c.status === 'no-slot') {
91
97
  const nearestNote = c.nearest ? `; nearest slot ${c.nearest.slot} (${c.nearest.pattern}), matched ${c.nearest.matchedPrefix || '.'} then expected ${c.nearest.expectedNext ?? 'nothing'}` : '';
@@ -98,6 +104,30 @@ function pathStatusFinding(file, resolver) {
98
104
  return null;
99
105
  }
100
106
 
107
+ /** The lite Work boundary, read from app.starciwork's edition-specific admission list, including paths a narrower slot owns. */
108
+ export function liteStarciworkFinding(file, resolver, { dir = false } = {}) {
109
+ if (resolver.repo?.edition !== 'lite' || !resolver.slot('app.starciwork')) return null;
110
+ const p = resolver.classifyPath(file).path;
111
+ if (!p.startsWith('.starciwork/')) return null;
112
+ const admitted = allowsFile(resolver, dir ? `${p}/.probe` : p);
113
+ if (admitted?.slot === 'app.starciwork' && admitted.allowed) return null;
114
+ return { code: 'HFS_STARCIWORK_LITE_FORBIDDEN', level: 'error', path: p, slot: 'app.starciwork', message: `${p}: lite .starciwork holds only workspace.yaml and brand/ (plus .gitignore); remove this path or use the full edition` };
115
+ }
116
+
117
+ /** Local lite Work leftovers, including metadata ignored by an old full-edition .gitignore. */
118
+ export function liteStarciworkTreeFindings(repoRoot, resolver, tracked) {
119
+ if (resolver.repo?.edition !== 'lite' || !resolver.slot('app.starciwork')) return [];
120
+ let entries = [];
121
+ try { entries = fs.readdirSync(path.join(repoRoot, '.starciwork'), { withFileTypes: true }); } catch { /* Work is optional. */ }
122
+ const trackedSet = new Set(tracked);
123
+ return entries.flatMap(entry => {
124
+ const file = `.starciwork/${entry.name}`;
125
+ if (trackedSet.has(file)) return [];
126
+ const finding = liteStarciworkFinding(file, resolver, { dir: entry.isDirectory() });
127
+ return finding ? [finding] : [];
128
+ });
129
+ }
130
+
101
131
  /** The findings of the slot manifest over `files` (repository-relative tracked paths) of a repository of `profile`. */
102
132
  export function pathFindings({ files, resolver, profile }) {
103
133
  const findings = [];
@@ -5,6 +5,8 @@
5
5
  // - a tracked `.enc` that is not a sops envelope (an encrypted-looking file that holds plaintext);
6
6
  // - a line of any other tracked text file that matches a secret pattern.
7
7
  // A finding names the file, the line and the pattern, never the value.
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
8
10
  import { FORBIDDEN_FILES, secretHits } from '../../lib/secret-patterns.mjs';
9
11
  import { isSopsEnvelope } from '../../lib/sops-envelope.mjs';
10
12
  import { found, readText } from './read.mjs';
@@ -16,6 +18,17 @@ const NOT_SCANNED = /(^|\/)package-lock\.json$/;
16
18
  /** True when the slot `id` of the manifest reports HFS_PLAINTEXT_SECRET for the paths it forbids. */
17
19
  export const slotOwnsSecrets = (slot) => slot?.rules?.includes(PLAINTEXT_SECRET) === true;
18
20
 
21
+ /** A legacy lite root secrets.env is reported even when ignored; its presence is enough, so no value is read. */
22
+ export function untrackedSecretFindings({ repoRoot, files, repo }) {
23
+ if (repo.profile !== 'app' || repo.edition !== 'lite' || files.includes('secrets.env')) return [];
24
+ try {
25
+ if (!fs.statSync(path.join(repoRoot, 'secrets.env')).isFile()) return [];
26
+ } catch {
27
+ return [];
28
+ }
29
+ return [found(PLAINTEXT_SECRET, 'secrets.env', 'secrets.env is an untracked plaintext secret at the lite app root; seal it with starci app secret set into .starcistacks/<env>/secrets/<slug>.enc and remove the plaintext file; read materialized values by *_FILE', { level: 'warning', pattern: 'untracked-secrets-env' })];
30
+ }
31
+
19
32
  /**
20
33
  * The findings of R06 for one tracked file that no forbidden slot claims: a secret by being (an env file, a key file), an `.enc` that is
21
34
  * no sops envelope, or a line that matches a secret pattern. `text` is the file's text, or null when it cannot be read (binary, too
@@ -65,8 +65,10 @@ export function importAllowedIn(scope, fromPath, toPath) {
65
65
  const fromOwner = ownerIn(scope, from.path);
66
66
  const toOwner = ownerIn(scope, to.path);
67
67
  if (sameOwnerUnit(from, fromOwner) === sameOwnerUnit(to, toOwner)) return { allowed: true, reason: 'sameOwner' };
68
- const { mayImport } = scope.manifest.tiers[scope.profile][fromTier] ?? {};
68
+ const { mayImport, mayImportKinds } = scope.manifest.tiers[scope.profile][fromTier] ?? {};
69
69
  if (!mayImport?.includes(toTier)) return { allowed: false, reason: 'tierDirection', fromTier, toTier, mayImport: mayImport ?? [] };
70
+ const kindRestriction = mayImportKinds?.[toTier];
71
+ if (kindRestriction && (!kindRestriction.from.includes(from.kind) || !kindRestriction.to.includes(to.kind))) return { allowed: false, reason: 'tierDirection', fromTier, toTier, mayImport, kindRestriction, fromKind: from.kind ?? null, toKind: to.kind ?? null };
70
72
  const layerProblem = lowerLayerProblem(scope, from, to, fromTier, toTier, fromOwner, toOwner);
71
73
  if (layerProblem) return layerProblem;
72
74
  // A runtime owner is imported file by file (crossOwner of knowledge/hfs/runtime-slots.yaml): no public entry.
@@ -5,6 +5,19 @@ import { braceVariants } from '../lib/glob.mjs';
5
5
  import { classifyIn, slotEnabled } from './slot-classify.mjs';
6
6
  import { fillVars, varsOf } from './slot-match.mjs';
7
7
 
8
+ /** A populated fe.modules instance needs its index.ts only when it contains TypeScript, including nested files. */
9
+ export function requiredFileApplies(slotId, target, files) {
10
+ if (!/^fe\.modules(?:\.|$)/u.test(slotId) || path.posix.basename(target) !== 'index.ts') return true;
11
+ const prefix = `${path.posix.dirname(target)}/`;
12
+ let populated = false;
13
+ for (const file of files) {
14
+ if (!file.startsWith(prefix)) continue;
15
+ populated = true;
16
+ if (/\.tsx?$/u.test(file)) return true;
17
+ }
18
+ return !populated;
19
+ }
20
+
8
21
  /** One `requires` entry of a slot placed under `root`: a leading / makes it repository-relative, the variables are filled from `bindings`. */
9
22
  function requiredEntry(entry, bindings, root) {
10
23
  const rooted = entry.startsWith('/');
@@ -2,7 +2,7 @@
2
2
  import { isPlainObject } from '../../engine/plain-object.mjs';
3
3
  import { triggerProblems } from './declaration-slots.mjs';
4
4
  import { litePresenceOf, slotInEdition } from './edition-slots.mjs';
5
- import { manifestKind, RUNTIME_KIND, runtimeSemanticProblems } from './manifest-shape.mjs';
5
+ import { manifestKind, RUNTIME_KIND, runtimeSemanticProblems, tierKindProblems } from './manifest-shape.mjs';
6
6
  import { APP_SCOPE, PROFILES } from './slot-manifest-shape.mjs';
7
7
 
8
8
  function checkAppRootSlot(slot, bad) {
@@ -113,6 +113,7 @@ function checkSideReads(manifest, bad, braceVariants) {
113
113
  }
114
114
 
115
115
  function checkProfileTierTargets(manifest, profile, bad) {
116
+ bad.push(...tierKindProblems(manifest, profile));
116
117
  for (const [tier, def] of Object.entries(manifest.tiers[profile])) {
117
118
  for (const target of def.mayImport) {
118
119
  if (!(target in manifest.tiers[profile])) bad.push(`tiers.${profile}.${tier}.mayImport names ${target}, which is not a ${profile} tier`);
@@ -0,0 +1,20 @@
1
+ /** The expression under the wrappers selected by an AST check's contract. */
2
+ export function unwrapExpression(ts, expression, { awaitExpressions = false, typeAssertions = true, satisfiesExpressions = true } = {}) {
3
+ while (expression && (ts.isParenthesizedExpression(expression) || ts.isAsExpression(expression)
4
+ || (typeAssertions && ts.isTypeAssertionExpression(expression)) || ts.isNonNullExpression(expression)
5
+ || (satisfiesExpressions && (ts.isSatisfiesExpression?.(expression) ?? false))
6
+ || (awaitExpressions && ts.isAwaitExpression(expression)))) expression = expression.expression;
7
+ return expression;
8
+ }
9
+
10
+ /** Local name -> imported name and module; callers select their existing module key and literal policy. */
11
+ export function importsOf(ts, sourceFile, { moduleKey = 'module', stringLiteralsOnly = false } = {}) {
12
+ const imports = new Map();
13
+ for (const statement of sourceFile.statements) {
14
+ if (!ts.isImportDeclaration(statement) || (stringLiteralsOnly && !ts.isStringLiteralLike(statement.moduleSpecifier))) continue;
15
+ const bindings = statement.importClause?.namedBindings;
16
+ if (!bindings || !ts.isNamedImports(bindings)) continue;
17
+ for (const binding of bindings.elements) imports.set(binding.name.text, { name: (binding.propertyName ?? binding.name).text, [moduleKey]: statement.moduleSpecifier.text });
18
+ }
19
+ return imports;
20
+ }