@starci/eslint-canon-fe 8.2.18 → 8.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 (41) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +19 -1
  3. package/docs/size-and-state-budget.md +6 -6
  4. package/drawing.mjs +65 -28
  5. package/package.json +1 -1
  6. package/runtime/knowledge/hfs/canon-pins.yaml +4 -4
  7. package/runtime/knowledge/hfs/slots.yaml +99 -43
  8. package/runtime/knowledge/patterns/fe/folder.yaml +3 -0
  9. package/runtime/scripts/api/git/cat-file.mjs +5 -0
  10. package/runtime/scripts/api/git/diff.mjs +5 -0
  11. package/runtime/scripts/hfs/architecture/backend-app-files.mjs +110 -0
  12. package/runtime/scripts/hfs/architecture/backend.mjs +20 -3
  13. package/runtime/scripts/hfs/architecture/background-unowned.mjs +157 -41
  14. package/runtime/scripts/hfs/architecture/canon-clones.mjs +240 -0
  15. package/runtime/scripts/hfs/architecture/canon-jsx-clones.mjs +143 -0
  16. package/runtime/scripts/hfs/architecture/clones.mjs +17 -8
  17. package/runtime/scripts/hfs/architecture/connection-map.mjs +14 -12
  18. package/runtime/scripts/hfs/architecture/context-map.mjs +25 -14
  19. package/runtime/scripts/hfs/architecture/context-owner.mjs +1 -2
  20. package/runtime/scripts/hfs/architecture/dead-exports.mjs +14 -9
  21. package/runtime/scripts/hfs/architecture/frontend-block-render.mjs +104 -0
  22. package/runtime/scripts/hfs/architecture/frontend-world-render.mjs +30 -11
  23. package/runtime/scripts/hfs/architecture/i18n-keys.mjs +16 -3
  24. package/runtime/scripts/hfs/architecture/machine-ast.mjs +17 -0
  25. package/runtime/scripts/hfs/architecture/module-per-transport.mjs +21 -12
  26. package/runtime/scripts/hfs/architecture/register-once.mjs +13 -5
  27. package/runtime/scripts/hfs/architecture/required-files.mjs +2 -1
  28. package/runtime/scripts/hfs/architecture/staged-consumers.mjs +37 -0
  29. package/runtime/scripts/hfs/architecture/supabase-ast.mjs +3 -6
  30. package/runtime/scripts/hfs/architecture/supabase-be.mjs +1 -6
  31. package/runtime/scripts/hfs/architecture/tiers.mjs +8 -1
  32. package/runtime/scripts/hfs/architecture/typescript.mjs +1 -8
  33. package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +1 -11
  34. package/runtime/scripts/hfs/manifest-shape.mjs +27 -2
  35. package/runtime/scripts/hfs/path-findings.mjs +32 -2
  36. package/runtime/scripts/hfs/rules/secrets.mjs +13 -0
  37. package/runtime/scripts/hfs/slot-imports.mjs +3 -1
  38. package/runtime/scripts/hfs/slot-required.mjs +13 -0
  39. package/runtime/scripts/hfs/slot-semantic-problems.mjs +2 -1
  40. package/runtime/scripts/lib/ts-analysis.mjs +20 -0
  41. package/size-and-state-budget.mjs +11 -8
@@ -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
+ }
@@ -21,7 +21,7 @@
21
21
  import { functionOf, isFn } from "./lib/bindings.mjs"
22
22
  import { hfsOf } from "./lib/hfs.mjs"
23
23
  import { isPromiseValued } from "./lib/types.mjs"
24
- import { fileOf, roleOfFile, slotOfFile } from "./lib/scope.mjs"
24
+ import { baseName, fileOf, roleOfFile, slotOfFile } from "./lib/scope.mjs"
25
25
 
26
26
  /**
27
27
  * The budget the slot of the linted file states (`budget` in knowledge/hfs/slots.yaml), or an empty one when no slot owns the file
@@ -34,13 +34,17 @@ const budgetOf = (context) => {
34
34
 
35
35
  /**
36
36
  * The line limit of the linted file: the budget keyed by the file name its role has in the slot (`component.tsx` for the drawing,
37
- * `index.tsx` for the entry), else the slot's `file` budget (hooks 200, modules 400), else null.
37
+ * `index.tsx` for the entry), else the slot's `file` budget (hooks 200, modules 400), else null. A pure re-export `index.ts`
38
+ * uses the slot's separate `barrel` budget when stated: every statement must be an `export ... from` declaration.
38
39
  */
39
- const lineBudgetOf = (context) => {
40
+ const lineBudgetOf = (context, node) => {
40
41
  const role = roleOfFile(context)
41
42
  const id = slotOfFile(context)
42
43
  const fileName = role === null || id === null ? undefined : hfsOf(context).slot(id)?.roles?.[role]
43
44
  const budget = budgetOf(context)
45
+ const pureBarrel = baseName(fileOf(context)) === "index.ts" && node.body.length > 0 && node.body.every((statement) =>
46
+ ["ExportNamedDeclaration", "ExportAllDeclaration"].includes(statement.type) && Boolean(statement.source))
47
+ if (pureBarrel && typeof budget.barrel === "number") return budget.barrel
44
48
  const limit = fileName !== undefined && typeof budget[fileName] === "number" ? budget[fileName] : budget.file
45
49
  return typeof limit === "number" ? limit : null
46
50
  }
@@ -73,11 +77,11 @@ const isUnitName = (name) => Boolean(name) && (/^[A-Z]/.test(name) || /^use[A-Z0
73
77
 
74
78
  // -- BUDGET-1 --------------------------------------------------------------------------------------
75
79
 
76
- /** A file is within the line budget its slot states for its role (`component.tsx`, `index.tsx`) or for every file (`file`). */
80
+ /** A file is within the line budget its slot states for its role, pure re-export barrel (`barrel`) or every file (`file`). */
77
81
  export const componentLineBudget = {
78
82
  meta: {
79
83
  type: "suggestion",
80
- docs: { description: "A file stays within the line budget its slot states (`component.tsx`, `index.tsx`, `file`)." },
84
+ docs: { description: "A file stays within the line budget its slot states (`component.tsx`, `index.tsx`, `barrel`, `file`)." },
81
85
  schema: [],
82
86
  messages: {
83
87
  lines:
@@ -85,12 +89,11 @@ export const componentLineBudget = {
85
89
  },
86
90
  },
87
91
  create(context) {
88
- // A spec is not held to a budget; a file has one when its role or its slot states it.
89
- const max = lineBudgetOf(context)
90
- if (max === null) return {}
91
92
  const source = context.sourceCode || context.getSourceCode()
92
93
  return {
93
94
  Program(node) {
95
+ const max = lineBudgetOf(context, node)
96
+ if (max === null) return
94
97
  // A trailing newline is not a line of code.
95
98
  const count = source.lines.length - (source.lines[source.lines.length - 1] === "" ? 1 : 0)
96
99
  if (count > max) context.report({ node, messageId: "lines", data: { count, max } })