@starci/hfs 4.16.4 → 4.17.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 (74) hide show
  1. package/CHANGELOG.md +35 -3
  2. package/README.md +4 -4
  3. package/codemod/loading-states.d.mts +50 -0
  4. package/codemod/loading-states.mjs +384 -0
  5. package/codemod/main.d.mts +1 -1
  6. package/codemod/main.mjs +46 -5
  7. package/codemod/page-navigation.d.mts +50 -0
  8. package/codemod/page-navigation.mjs +278 -0
  9. package/codemod/section-frame.d.mts +50 -0
  10. package/codemod/section-frame.mjs +483 -0
  11. package/codemod/small-renames.d.mts +54 -0
  12. package/codemod/small-renames.mjs +614 -0
  13. package/codemod/surface-card-kinds.d.mts +49 -0
  14. package/codemod/surface-card-kinds.mjs +404 -0
  15. package/codemod/theme-tokens.d.mts +45 -0
  16. package/codemod/theme-tokens.mjs +114 -0
  17. package/codemod/tier-binding.d.mts +50 -0
  18. package/codemod/tier-binding.mjs +214 -0
  19. package/package.json +1 -1
  20. package/runtime/knowledge/grammars/core/playbook.yaml +41 -51
  21. package/runtime/knowledge/grammars/offset-pop/playbook.yaml +5 -5
  22. package/runtime/knowledge/hfs/canon-pins.yaml +7 -7
  23. package/runtime/knowledge/hfs/rules.yaml +343 -26
  24. package/runtime/knowledge/hfs/slots.yaml +12 -5
  25. package/runtime/knowledge/patterns/fe/folder.yaml +1 -1
  26. package/runtime/modules/kernel/failure-codes.yaml +23 -13
  27. package/runtime/scripts/hfs/architecture/clones.mjs +12 -2
  28. package/runtime/scripts/hfs/architecture/component-tiers.d.mts +46 -0
  29. package/runtime/scripts/hfs/architecture/component-tiers.mjs +161 -0
  30. package/runtime/scripts/hfs/architecture/config.mjs +1 -1
  31. package/runtime/scripts/hfs/architecture/frontend-world.mjs +1 -1
  32. package/runtime/scripts/hfs/architecture/frontend.mjs +2 -2
  33. package/runtime/scripts/hfs/architecture/index.mjs +6 -0
  34. package/runtime/scripts/hfs/architecture/render-graph.d.mts +125 -0
  35. package/runtime/scripts/hfs/architecture/render-graph.mjs +449 -0
  36. package/runtime/scripts/hfs/architecture/render-prop-across-server.d.mts +23 -0
  37. package/runtime/scripts/hfs/architecture/render-prop-across-server.mjs +58 -0
  38. package/runtime/scripts/hfs/architecture/surface-nesting.d.mts +44 -0
  39. package/runtime/scripts/hfs/architecture/surface-nesting.mjs +127 -0
  40. package/runtime/scripts/hfs/slot-manifest-shape.mjs +16 -1
  41. package/runtime/scripts/work/draw/draw-dna.mjs +1 -1
  42. package/scaffold/add-fe-layout.mjs +3 -3
  43. package/scaffold/add-fe-overlay.mjs +4 -4
  44. package/scaffold/add-fe-page.mjs +1 -1
  45. package/scaffold/add-fe-unit.d.mts +2 -1
  46. package/scaffold/add-fe-unit.mjs +14 -12
  47. package/scaffold/add.d.mts +1 -1
  48. package/scaffold/add.mjs +1 -1
  49. package/src/main.mjs +4 -4
  50. package/templates/fe/skeleton/apps/__app__/src/components/blocks/SignInForm/component.tsx +3 -4
  51. package/templates/fe/skeleton/apps/app/src/app/[locale]/layout.tsx +8 -5
  52. package/templates/fe/skeleton/apps/landing/src/app/[locale]/layout.tsx +1 -3
  53. package/templates/fe/skeleton/packages/__project__-ui/src/branches/Providers/index.tsx +3 -3
  54. package/templates/fe/skeleton/packages/__project__-ui/src/composites/LocaleShell/index.tsx +3 -5
  55. package/templates/fe/skeleton/packages/__project__-ui/src/index.ts +1 -1
  56. package/templates/fe/skeleton-lite/apps/web/src/features/layouts/AppLayout/index.tsx +1 -1
  57. package/templates/fe/skeleton-lite/apps/web/src/modules/brand/brand.css +1 -2
  58. package/templates/fe/units/block/component.tsx.tpl +2 -3
  59. package/templates/fe/units/branch/index.tsx.tpl +4 -3
  60. package/templates/fe/units/composite/index.tsx.tpl +3 -3
  61. package/templates/fe/units/frame/index.tsx.tpl +10 -0
  62. package/templates/fe/units/layout/index.tsx.tpl +2 -2
  63. package/templates/fe/units/leaf/index.tsx.tpl +3 -3
  64. package/templates/fe/units/page/component.tsx.tpl +2 -2
  65. package/templates/fe/units/page/index.tsx.tpl +2 -2
  66. package/templates/fe/units/page/stories.tsx.tpl +2 -2
  67. package/templates/fe/units/slot/SlotView.tsx.tpl +11 -10
  68. package/templates/fe/units/slot/data-slot.ts.tpl +20 -8
  69. package/templates/fe/units/slot/slot.shared.ts.tpl +52 -11
  70. package/templates/fe/units/slot/useSlotOfReads.ts.tpl +19 -0
  71. /package/templates/fe/skeleton/packages/__project__-ui/src/{composites → frames}/SiteShell/classNames.ts +0 -0
  72. /package/templates/fe/skeleton/packages/__project__-ui/src/{composites → frames}/SiteShell/index.tsx +0 -0
  73. /package/templates/fe/skeleton-lite/apps/web/src/components/{composites → frames}/SiteShell/classNames.ts +0 -0
  74. /package/templates/fe/skeleton-lite/apps/web/src/components/{composites → frames}/SiteShell/index.tsx +0 -0
@@ -364,7 +364,7 @@ rules:
364
364
  cases:
365
365
  - id: case-1
366
366
  when: "A Grammar component (runtime package)"
367
- write: "packages/grammar/src/core/<primitive|composite|branch|composition>/<Name>/index.tsx"
367
+ write: "packages/grammar/src/core/<leaf|branch|composite|frame>/<Name>/index.tsx"
368
368
  - id: case-2
369
369
  when: "A Grammar component's class strings and spec (runtime package)"
370
370
  write: "sibling classNames.ts and index.spec.tsx"
@@ -2113,16 +2113,6 @@ FE_BUTTON_INTENT_REQUIRED:
2113
2113
  owner: op-retry
2114
2114
  kind: check-finding
2115
2115
 
2116
- FE_BUTTON_NO_VARIANT:
2117
- title: "An app button chooses a visual variant"
2118
- title_vi: "Nút ứng dụng tự chọn variant"
2119
- meaning_vi: "Button hoặc IconButton tại <file> truyền variant thay vì để Grammar suy ra từ intent và vị trí."
2120
- causes_vi:
2121
- - "Ứng dụng truyền variant trực tiếp hoặc qua props spread."
2122
- nextStep_vi: "Thay variant bằng intent theo hệ quả của lệnh (CTA-6)."
2123
- owner: op-retry
2124
- kind: check-finding
2125
-
2126
2116
 
2127
2117
  FE_CLIENT_BOUNDARY:
2128
2118
  title: "Server first; `\"use client\"` only where allowed"
@@ -2377,6 +2367,26 @@ FE_HOOKS_ARE_HOOKS:
2377
2367
  owner: op-retry
2378
2368
  kind: check-finding
2379
2369
 
2370
+ FE_SURFACE_NESTING:
2371
+ title: "A surface stands inside a surface, or a table, grid or heading stands where the card laws refuse it"
2372
+ title_vi: "Bề mặt nằm trong bề mặt, hoặc bảng, lưới, tiêu đề nằm sai chỗ"
2373
+ meaning_vi: "Qua các tệp, `<file>` vẽ một card trong card, trong `Pane surface` hoặc trong lớp phủ; một `DataTable` trong card; một `PaneGrid` ngoài `Page chrome=\"panes\"`; một tiêu đề đứng ngay trên một card; hoặc một card có nhãn lặp trong `Grid`. Từng tệp đứng riêng thì hợp lệ, ghép lại thì sai."
2374
+ causes_vi:
2375
+ - "Vi phạm luật R356: bề mặt trong bề mặt bị cấm hoàn toàn (kể cả hộp thoại và ngăn kéo); card tự mang nhãn nên không cần tiêu đề phía trên; bảng đứng trên trang; lưới chia pane chỉ đứng trong `Page chrome=\"panes\"`; ô lặp lại không có nhãn."
2376
+ nextStep_vi: "Bỏ card bên trong (đưa nội dung lên card ngoài), chuyển bảng ra trang, đưa tiêu đề vào nhãn của card, hoặc chuyển ô lặp thành tile không nhãn. Bộ kiểm tra nêu chuỗi thành phần dẫn tới chỗ sai."
2377
+ owner: op-retry
2378
+ kind: check-finding
2379
+
2380
+ FE_RENDER_PROP_ACROSS_SERVER:
2381
+ title: "A server file hands a function to a client component"
2382
+ title_vi: "Tệp server truyền hàm vào component client"
2383
+ meaning_vi: "`<file>` không có 'use client' truyền một hàm (prop dạng arrow hoặc function) vào một component nằm trong tệp có 'use client'. Hàm không đi qua ranh giới server-client."
2384
+ causes_vi:
2385
+ - "Vi phạm luật R361: một block nhận dữ liệu và tự vẽ; callback thuộc nhánh client sở hữu tương tác."
2386
+ nextStep_vi: "Truyền dữ liệu thay vì hàm, hoặc chuyển callback vào nhánh client sở hữu tương tác."
2387
+ owner: op-retry
2388
+ kind: check-finding
2389
+
2380
2390
  FE_HREF_RESOLVES:
2381
2391
  title: "Link points to a route that does not exist"
2382
2392
  title_vi: "Liên kết trỏ tới đường dẫn không tồn tại"
@@ -2643,10 +2653,10 @@ FE_PACKAGE_SHAPE:
2643
2653
  FE_PAGE_HEADER_HAND_ROLLED:
2644
2654
  title: "A page head is composed by hand instead of PageHeader"
2645
2655
  title_vi: "Phần đầu trang được ghép tay thay vì dùng PageHeader"
2646
- meaning_vi: "Tại <file>, một Breadcrumbs theo sau là một SectionHeader trong cùng một phần tử cha: phần đầu trang ghép tay không có dải nền, không có khoảng đệm trên và không có đường phân cách, nên mỗi trang tự quyết khoảng cách riêng."
2656
+ meaning_vi: "Tại <file>, một PageHeader hoặc Breadcrumbs được nhập trực tiếp từ Grammar, vốn là bộ phận riêng của Page và không còn được export: phần đầu trang ghép tay không có dải nền, không có khoảng đệm trên và không có đường phân cách, nên mỗi trang tự quyết khoảng cách riêng."
2647
2657
  causes_vi:
2648
- - "Đặt Breadcrumbs rồi SectionHeader level={1} ở đầu trang hoặc layout."
2649
- nextStep_vi: "Dùng PageHeader của Grammar với breadcrumbs, title và các khe cần thiết (description, meta, status, mark, actions, tabs) (quy định page-header-band)."
2658
+ - "Nhập PageHeader hoặc Breadcrumbs từ Grammar để ghép đầu trang ở trang hoặc layout."
2659
+ nextStep_vi: "Dùng khe header của Page với breadcrumbs, title và các khe cần thiết (description, meta, status, mark, actions, tabs) (quy định page-header-band)."
2650
2660
  owner: op-retry
2651
2661
  kind: check-finding
2652
2662
 
@@ -255,9 +255,19 @@ const cloneCoverage = (entries, blocks, N, T) => {
255
255
  return { status: 'checked', minLines: N, minTokens: T, files: entries.length, cloneBlocks: blocks.length, ownersInvolved: owners.size, duplicatedLines,
256
256
  ...(blocks.length > MAX_VIOLATIONS ? { truncated: true } : {}) };
257
257
  };
258
+ /**
259
+ * The threshold of the run. The app root (profile app) answers `ruleParams()` with null: its rule parameters are its sides', and
260
+ * duplicateBlock is the one value they share (ruleParams.common), so the root reads it from a side. A run on the app root
261
+ * (the root has its own tsconfig) threw "Cannot destructure property 'lines'" here before.
262
+ */
263
+ function duplicateBlockOf(resolver) {
264
+ const own = resolver.ruleParams()?.duplicateBlock;
265
+ const block = own ?? Object.values(resolver.sides ?? {}).map(side => side.ruleParams().duplicateBlock).find(found => found !== undefined);
266
+ // reason: the rule-params validators require duplicateBlock on every profile, so a side that runs this check carries it
267
+ return block;
268
+ }
258
269
  export function checkClones({ context, graph }) {
259
- // reason: the rule-params validators require duplicateBlock on every profile that runs this check
260
- const { lines: N, tokens: T } = graph.resolver.ruleParams()?.duplicateBlock;
270
+ const { lines: N, tokens: T } = duplicateBlockOf(graph.resolver);
261
271
  const ts = context.ts ?? context.loaded.ts;
262
272
  const entries = [];
263
273
  for (const rel of [...graph.files.keys()].sort(byCodeUnit)) {
@@ -0,0 +1,46 @@
1
+ import type { ArchitectureViolation, CheckInput } from './hfs-graph.mjs';
2
+ /**
3
+ * The component tier ladder, checked on the source of a component library and of a component tree.
4
+ *
5
+ * FE_GRAMMAR_TIER_DIRECTION a grammar component imports a tier above it (the ladder leaf, branch, composite, frame: a tier imports
6
+ * nothing above itself), type-only imports included, through a shared untiered module as well;
7
+ * `tiers.grammar` of knowledge/hfs/slots.yaml is the matrix
8
+ * FE_GRAMMAR_TIER_STAMP a `data-tier` stamp names a tier other than the folder the file sits in (`atom`, `primitive` and
9
+ * `composition` are not tiers)
10
+ * FE_TIER_DIRECTION (layers) a component imports a layer at or above its own: blocks, frames, composites, branches, leaves
11
+ *
12
+ * A library is a folder holding `src/core/<tier>/<Name>/`; a tree is a folder holding `components/<layer>/<Unit>/` that no hfs
13
+ * front-end slot already judges (an app without hfs.json, such as the harness desktop app).
14
+ */
15
+ export declare const COMPONENT_TIER_RULE_IDS: string[];
16
+ /** One tier of the matrix: the tiers it may import. */
17
+ export interface TierMatrixEntry {
18
+ mayImport: readonly string[];
19
+ }
20
+ /** The result of a check: findings and the coverage line. */
21
+ export interface ComponentTierResult {
22
+ violations: ArchitectureViolation[];
23
+ coverage: {
24
+ status: string;
25
+ [field: string]: unknown;
26
+ };
27
+ }
28
+ /**
29
+ * Check the component libraries under `root` against `matrix` (`tiers.grammar`). Not applicable when no folder holds
30
+ * `src/core/<tier>/<Name>/`. A file outside the tier folders (a shared helper under `src/core`) is not a tier, but an import that
31
+ * reaches a higher tier through it is the importing tier's edge.
32
+ */
33
+ export declare function checkGrammarLibraries({ root, matrix }: {
34
+ root: string;
35
+ matrix: Readonly<Record<string, TierMatrixEntry>>;
36
+ }): ComponentTierResult;
37
+ /**
38
+ * Check a component tree under `root` that no hfs slot judges: a component imports only the layers after its own (`layers` in order,
39
+ * the first layer imports all below it). The layers are the folder names of slot fe.components.
40
+ */
41
+ export declare function checkComponentLayers({ root, layers }: {
42
+ root: string;
43
+ layers: readonly string[];
44
+ }): ComponentTierResult;
45
+ /** The machine check: the grammar libraries of the repository, judged by `tiers.grammar` of the slot manifest. */
46
+ export declare function checkGrammarTiers({ config }: CheckInput): ComponentTierResult;
@@ -0,0 +1,161 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { byCodeUnit } from '../../lib/list.mjs';
4
+ /**
5
+ * The component tier ladder, checked on the source of a component library and of a component tree.
6
+ *
7
+ * FE_GRAMMAR_TIER_DIRECTION a grammar component imports a tier above it (the ladder leaf, branch, composite, frame: a tier imports
8
+ * nothing above itself), type-only imports included, through a shared untiered module as well;
9
+ * `tiers.grammar` of knowledge/hfs/slots.yaml is the matrix
10
+ * FE_GRAMMAR_TIER_STAMP a `data-tier` stamp names a tier other than the folder the file sits in (`atom`, `primitive` and
11
+ * `composition` are not tiers)
12
+ * FE_TIER_DIRECTION (layers) a component imports a layer at or above its own: blocks, frames, composites, branches, leaves
13
+ *
14
+ * A library is a folder holding `src/core/<tier>/<Name>/`; a tree is a folder holding `components/<layer>/<Unit>/` that no hfs
15
+ * front-end slot already judges (an app without hfs.json, such as the harness desktop app).
16
+ */
17
+ export const COMPONENT_TIER_RULE_IDS = ['FE_GRAMMAR_TIER_DIRECTION', 'FE_GRAMMAR_TIER_STAMP'];
18
+ const SKIPPED = new Set(['node_modules', '.git', 'dist', '.next', 'storybook-static', 'proof']);
19
+ const SOURCE = /\.(?:ts|tsx)$/u;
20
+ const TEST_OR_STORY = /\.(?:stories|spec|test)\.[jt]sx?$/u;
21
+ const IMPORT = /(?:\bimport|\bexport)\b[^'"`;]*?\bfrom\s*['"]([^'"]+)['"]|\bimport\s*['"]([^'"]+)['"]|\bimport\(\s*['"]([^'"]+)['"]\s*\)/gu;
22
+ const STAMP = /["']?data-tier["']?\s*[=:]\s*\{?\s*["']([a-z-]+)["']/gu;
23
+ const LIBRARY_FILE = /^(.*?)src\/core\/(leaf|branch|composite|frame)\/([^/]+)\//u;
24
+ function walk(root, dir = '') {
25
+ const out = [];
26
+ for (const entry of fs.readdirSync(path.join(root, dir), { withFileTypes: true })) {
27
+ if (SKIPPED.has(entry.name))
28
+ continue;
29
+ const rel = dir === '' ? entry.name : `${dir}/${entry.name}`;
30
+ if (entry.isDirectory())
31
+ out.push(...walk(root, rel));
32
+ else if (SOURCE.test(entry.name) && !entry.name.endsWith('.d.ts') && !TEST_OR_STORY.test(entry.name))
33
+ out.push(rel);
34
+ }
35
+ return out;
36
+ }
37
+ const lineOf = (text, index) => text.slice(0, index).split('\n').length;
38
+ /** The file an import specifier names, as a repo-relative path in `files`, or null (a package, or a file outside the walk). */
39
+ function resolveImport(files, from, specifier) {
40
+ if (!specifier.startsWith('.'))
41
+ return null;
42
+ const base = path.posix.normalize(path.posix.join(path.posix.dirname(from), specifier)).replace(/\.(?:js|mjs|cjs)$/u, '');
43
+ for (const candidate of [`${base}.ts`, `${base}.tsx`, `${base}/index.ts`, `${base}/index.tsx`, base])
44
+ if (files.has(candidate))
45
+ return candidate;
46
+ return null;
47
+ }
48
+ function edgesOf(root, files, cache, file) {
49
+ const cached = cache.get(file);
50
+ if (cached)
51
+ return cached;
52
+ const text = fs.readFileSync(path.join(root, ...file.split('/')), 'utf8');
53
+ const edges = [];
54
+ for (const match of text.matchAll(IMPORT)) {
55
+ const specifier = match[1] ?? match[2] ?? match[3] ?? '';
56
+ const to = resolveImport(files, file, specifier);
57
+ if (to !== null)
58
+ edges.push({ to, line: lineOf(text, match.index ?? 0), specifier });
59
+ }
60
+ const entry = { text, edges };
61
+ cache.set(file, entry);
62
+ return entry;
63
+ }
64
+ /** The tier (the folder under `src/core`) and the component of a grammar source file, or null for a file outside the ladder. */
65
+ const gradeOf = (file) => {
66
+ const match = LIBRARY_FILE.exec(file);
67
+ return match === null ? null : { tier: match[2], unit: `${match[1]}${match[2]}/${match[3]}` };
68
+ };
69
+ /**
70
+ * Check the component libraries under `root` against `matrix` (`tiers.grammar`). Not applicable when no folder holds
71
+ * `src/core/<tier>/<Name>/`. A file outside the tier folders (a shared helper under `src/core`) is not a tier, but an import that
72
+ * reaches a higher tier through it is the importing tier's edge.
73
+ */
74
+ export function checkGrammarLibraries({ root, matrix }) {
75
+ const all = walk(root).filter(file => /(?:^|\/)src\//u.test(file));
76
+ const library = all.filter(file => gradeOf(file) !== null);
77
+ if (!library.length)
78
+ return { violations: [], coverage: { status: 'not-applicable' } };
79
+ const files = new Set(all);
80
+ const cache = new Map();
81
+ const violations = [];
82
+ const tiers = new Set(Object.keys(matrix));
83
+ let edgesChecked = 0;
84
+ let stamps = 0;
85
+ for (const file of library.sort(byCodeUnit)) {
86
+ const from = gradeOf(file);
87
+ const { text, edges } = edgesOf(root, files, cache, file);
88
+ for (const stamp of text.matchAll(STAMP)) {
89
+ stamps += 1;
90
+ if (stamp[1] !== from.tier) {
91
+ violations.push({ ruleId: 'FE_GRAMMAR_TIER_STAMP', path: file, line: lineOf(text, stamp.index ?? 0), column: 1,
92
+ message: `${file} stamps data-tier="${stamp[1]}" and sits in the ${from.tier} folder: the stamp is the folder (${[...tiers].join(', ')}).` });
93
+ }
94
+ }
95
+ // Follow shared untiered modules, so a leaf cannot reach a frame by one hop of indirection.
96
+ const queue = edges.map(edge => ({ edge, via: [] }));
97
+ const seen = new Set();
98
+ for (let next = queue.shift(); next !== undefined; next = queue.shift()) {
99
+ const { edge, via } = next;
100
+ const target = gradeOf(edge.to);
101
+ if (target === null) {
102
+ if (seen.has(edge.to) || !edge.to.includes('/src/') && !edge.to.startsWith('src/'))
103
+ continue;
104
+ seen.add(edge.to);
105
+ for (const onward of edgesOf(root, files, cache, edge.to).edges)
106
+ queue.push({ edge: { ...onward, line: edge.line }, via: [...via, edge.to] });
107
+ continue;
108
+ }
109
+ if (target.unit === from.unit)
110
+ continue;
111
+ edgesChecked += 1;
112
+ if ((matrix[from.tier]?.mayImport ?? []).includes(target.tier))
113
+ continue;
114
+ violations.push({ ruleId: 'FE_GRAMMAR_TIER_DIRECTION', path: file, line: edge.line, column: 1, specifier: edge.specifier, resolvedPath: edge.to,
115
+ message: `${file} (${from.tier}) imports ${edge.to} (${target.tier})${via.length ? ` through ${via.join(' -> ')}` : ''}: a ${from.tier} may import only ${(matrix[from.tier]?.mayImport ?? []).join(', ') || 'nothing'}. Type-only imports count.` });
116
+ }
117
+ }
118
+ const unique = new Map(violations.map(violation => [`${violation.ruleId}|${violation.path}|${violation.line}|${violation.resolvedPath ?? ''}`, violation]));
119
+ return { violations: [...unique.values()], coverage: { status: 'checked', files: library.length, edgesChecked, stamps } };
120
+ }
121
+ const LAYER_FILE = /^(.*?)components\/([^/]+)\/([^/]+)\//u;
122
+ /**
123
+ * Check a component tree under `root` that no hfs slot judges: a component imports only the layers after its own (`layers` in order,
124
+ * the first layer imports all below it). The layers are the folder names of slot fe.components.
125
+ */
126
+ export function checkComponentLayers({ root, layers }) {
127
+ const all = walk(root).filter(file => /(?:^|\/)src\//u.test(file));
128
+ const placed = (file) => {
129
+ const match = LAYER_FILE.exec(file);
130
+ const layer = match === null ? -1 : layers.indexOf(match[2]);
131
+ return match === null || layer < 0 ? null : { layer, unit: `${match[1]}components/${match[2]}/${match[3]}` };
132
+ };
133
+ const tree = all.filter(file => placed(file) !== null);
134
+ if (!tree.length)
135
+ return { violations: [], coverage: { status: 'not-applicable' } };
136
+ const files = new Set(all);
137
+ const cache = new Map();
138
+ const violations = [];
139
+ let edgesChecked = 0;
140
+ for (const file of tree.sort(byCodeUnit)) {
141
+ const from = placed(file);
142
+ for (const edge of edgesOf(root, files, cache, file).edges) {
143
+ const target = placed(edge.to);
144
+ if (target === null || target.unit === from.unit)
145
+ continue;
146
+ edgesChecked += 1;
147
+ if (target.layer > from.layer)
148
+ continue;
149
+ violations.push({ ruleId: 'FE_TIER_DIRECTION', path: file, line: edge.line, column: 1, specifier: edge.specifier, resolvedPath: edge.to,
150
+ message: `${file}: a ${layers[from.layer]} component may import only the layers after it (${layers.slice(from.layer + 1).join(', ') || 'none'}); it imports a ${layers[target.layer]}. Type-only imports count.` });
151
+ }
152
+ }
153
+ return { violations, coverage: { status: 'checked', files: tree.length, edgesChecked } };
154
+ }
155
+ /** The machine check: the grammar libraries of the repository, judged by `tiers.grammar` of the slot manifest. */
156
+ export function checkGrammarTiers({ config }) {
157
+ const matrix = config.hfs.manifest.tiers.grammar;
158
+ if (matrix === undefined)
159
+ return { violations: [], coverage: { status: 'not-applicable' } };
160
+ return checkGrammarLibraries({ root: config.root, matrix });
161
+ }
@@ -208,7 +208,7 @@ function inferredLayout(root, workspaces) {
208
208
  // A design-system workspace whose src carries tier dirs (leaves/branches/…) IS a component root;
209
209
  // collecting its src/components too would nest two roots in one role and fail the disjoint check.
210
210
  const tierSources = new Set(workspaces.map(prefix => `${prefix}/src`)
211
- .filter(source => ['leaves', 'branches', 'blocks', 'overlays', 'composites', 'layouts', 'product-shells', 'pages']
211
+ .filter(source => ['leaves', 'branches', 'blocks', 'frames', 'overlays', 'composites', 'layouts', 'product-shells', 'pages']
212
212
  .some(tier => existingDirectory(root, `${source}/${tier}`))));
213
213
  const components = collect('src/components')
214
214
  .filter(relative => !tierSources.has(path.posix.dirname(relative)));
@@ -5,7 +5,7 @@ import { WorldRenderAnalysis } from './frontend-world-render.mjs';
5
5
  const WORLD_NAVIGATION_CALLS = new Set(['useParams', 'usePathname', 'useRouter', 'useSearchParams', 'useSelectedLayoutSegment', 'useSelectedLayoutSegments']);
6
6
  const WORLD_INTL_CALLS = new Set(['useLocale', 'useMessages', 'useNow', 'useTimeZone', 'useTranslations']);
7
7
  const WORLD_SWR_CALLS = new Set(['default', 'useSWR', 'useSWRConfig', 'useSWRImmutable', 'useSWRMutation']);
8
- const LOWER_COMPONENT_TIERS = new Set(['composites', 'branches', 'leaves']);
8
+ const LOWER_COMPONENT_TIERS = new Set(['frames', 'composites', 'branches', 'leaves']);
9
9
  function selectedSymbol(ts, checker, expression) {
10
10
  const selected = unwrapExpression(ts, expression);
11
11
  if (!selected)
@@ -8,7 +8,7 @@ import { checkWorldRenderBoundaries } from './frontend-world.mjs';
8
8
  import { checkGrammar } from './frontend-grammar.mjs';
9
9
  import { checkRoute } from './frontend-routing.mjs';
10
10
  const FEATURE_TIERS = new Set(['pages', 'layouts', 'overlays']);
11
- const COMPONENT_TIERS = new Set(['blocks', 'composites', 'branches', 'leaves', 'providers']);
11
+ const COMPONENT_TIERS = new Set(['blocks', 'frames', 'composites', 'branches', 'leaves', 'providers']);
12
12
  function absolute(root, relative) {
13
13
  return path.resolve(root, ...relative.split('/'));
14
14
  }
@@ -82,7 +82,7 @@ function checkFrontendSourceLayout(config, sourceFile, roots) {
82
82
  valid = located.relative.includes('/') || roleEntry(located.relative);
83
83
  return valid ? [] : [{ ruleId: 'FE_SOURCE_LAYOUT_INVALID', path: relativePath(config.root, sourceFile.fileName), line: 1, column: 1,
84
84
  message: { features: 'Frontend features contain only pages, layouts and overlays plus an optional root public entry.',
85
- components: 'Frontend components contain only blocks, composites, branches, leaves and providers plus an optional root public entry.',
85
+ components: 'Frontend components contain only blocks, frames, composites, branches, leaves and providers plus an optional root public entry.',
86
86
  hooks: 'Every authored custom hook is grouped below a domain folder under hooks; only the root public entry may sit directly under hooks.' }[located.role] }];
87
87
  }
88
88
  function declarationName(ts, declaration) {
@@ -42,9 +42,12 @@ import { checkBackgroundUnowned, BACKGROUND_UNOWNED_RULE_IDS } from './backgroun
42
42
  import { checkTransportOwner, TRANSPORT_OWNER_RULE_IDS } from './transport-owner.mjs';
43
43
  import { checkRouteFilesThin, ROUTE_FILES_THIN_RULE_IDS } from './route-files-thin.mjs';
44
44
  import { checkHooksAreHooks, HOOKS_ARE_HOOKS_RULE_IDS } from './hooks-are-hooks.mjs';
45
+ import { checkRenderPropAcrossServer, RENDER_PROP_ACROSS_SERVER_RULE_IDS } from './render-prop-across-server.mjs';
46
+ import { checkSurfaceNesting, SURFACE_NESTING_RULE_IDS } from './surface-nesting.mjs';
45
47
  import { checkClientReachesServer, CLIENT_REACHES_SERVER_RULE_IDS } from './client-reaches-server.mjs';
46
48
  import { checkCrossAppDuplicate, CROSS_APP_DUPLICATE_RULE_IDS } from './cross-app-duplicate.mjs';
47
49
  import { checkPackageShape, PACKAGE_SHAPE_RULE_IDS } from './package-shape.mjs';
50
+ import { COMPONENT_TIER_RULE_IDS, checkGrammarTiers } from './component-tiers.mjs';
48
51
  import { checkFeSlotAllows, FE_SLOT_ALLOWS_RULE_IDS } from './fe-slot-allows.mjs';
49
52
  import { checkI18nKeys, I18N_KEYS_RULE_IDS } from './i18n-keys.mjs';
50
53
  import { checkDocLanguage, DOC_LANGUAGE_RULE_IDS } from './doc-language.mjs';
@@ -92,10 +95,13 @@ const FRONTEND_MACHINE = {
92
95
  hooksAreHooks: [checkHooksAreHooks, HOOKS_ARE_HOOKS_RULE_IDS],
93
96
  packageShape: [checkPackageShape, PACKAGE_SHAPE_RULE_IDS],
94
97
  clientReachesServer: [checkClientReachesServer, CLIENT_REACHES_SERVER_RULE_IDS],
98
+ surfaceNesting: [checkSurfaceNesting, SURFACE_NESTING_RULE_IDS],
99
+ renderPropAcrossServer: [checkRenderPropAcrossServer, RENDER_PROP_ACROSS_SERVER_RULE_IDS],
95
100
  crossAppDuplicate: [checkCrossAppDuplicate, CROSS_APP_DUPLICATE_RULE_IDS],
96
101
  feSlotAllows: [checkFeSlotAllows, FE_SLOT_ALLOWS_RULE_IDS],
97
102
  i18nKeys: [checkI18nKeys, I18N_KEYS_RULE_IDS],
98
103
  serverActions: [checkServerActions, SERVER_ACTION_RULE_IDS],
104
+ grammarTiers: [checkGrammarTiers, COMPONENT_TIER_RULE_IDS],
99
105
  };
100
106
  /** Errors the machine reports when a program cannot be built or an edge cannot be proven: it cannot judge, so it refuses. */
101
107
  const COMMON_REFUSAL_RULE_IDS = [
@@ -0,0 +1,125 @@
1
+ /**
2
+ * The render graph: which component draws which, and where each places the `children` and slot props it is given.
3
+ *
4
+ * One ESLint file sees one component. An app splits a screen across files, so a card inside a card, a table inside a card or a
5
+ * grid in the wrong page can stand in three files. This module reads the source of the program (no type checker: the parsed files
6
+ * of the HFS graph and its resolved import edges) and answers one question for the checks built on it: starting from a component,
7
+ * what is drawn inside what, once every app component is replaced by what it draws and every `{children}` or `{props.slot}` by what
8
+ * its caller handed it.
9
+ *
10
+ * Limits, stated because a check built here is only as honest as they are:
11
+ * - conditional JSX counts as drawn (both branches), a `.map` callback counts as drawn once and is marked `repeated`;
12
+ * - a render prop (a function passed as a prop) is not followed;
13
+ * - a component reached through a variable, a prop or a dynamic import is not followed;
14
+ * - a local render helper (`renderRow(x)`) is followed to three levels.
15
+ */
16
+ import type { Node } from 'typescript';
17
+ import type { TypeScriptApi } from './config.mjs';
18
+ import type { HfsGraph } from './hfs-graph.mjs';
19
+ /** What a JSX tag names. */
20
+ export type Tag = {
21
+ readonly kind: 'grammar';
22
+ readonly name: string;
23
+ } | {
24
+ readonly kind: 'component';
25
+ readonly file: string;
26
+ readonly name: string;
27
+ } | {
28
+ readonly kind: 'host';
29
+ readonly name: string;
30
+ } | {
31
+ readonly kind: 'unknown';
32
+ readonly name: string;
33
+ };
34
+ /** A JSX attribute as the graph keeps it: its literal text when it has one, and the elements it carries. */
35
+ export interface RenderAttribute {
36
+ readonly name: string;
37
+ /** The string of a string literal or `{"..."}`, `'true'` for a bare attribute or `{true}`, `'false'` for `{false}`, otherwise null. */
38
+ readonly text: string | null;
39
+ readonly parts: readonly RenderPart[];
40
+ }
41
+ /** One element of a render. */
42
+ export interface RenderElement {
43
+ readonly kind: 'element';
44
+ readonly tag: Tag;
45
+ readonly attributes: ReadonlyMap<string, RenderAttribute>;
46
+ readonly children: readonly RenderPart[];
47
+ /** True when the element is made by a `.map` callback: one of several. */
48
+ readonly repeated: boolean;
49
+ readonly file: string;
50
+ readonly node: Node;
51
+ }
52
+ /** Where a component places something its caller handed it: `children`, or the named slot prop. */
53
+ export interface RenderPlacement {
54
+ readonly kind: 'placement';
55
+ readonly prop: string;
56
+ readonly file: string;
57
+ readonly node: Node;
58
+ }
59
+ /** A piece of a render. */
60
+ export type RenderPart = RenderElement | RenderPlacement;
61
+ /** An app component: where it is declared and what it draws. */
62
+ export interface ComponentDefinition {
63
+ readonly file: string;
64
+ readonly name: string;
65
+ readonly returns: readonly RenderPart[];
66
+ }
67
+ /** What a caller handed a component, with the supplier of the caller's own render. */
68
+ export interface Supplier {
69
+ readonly children: readonly RenderPart[];
70
+ readonly slots: ReadonlyMap<string, readonly RenderPart[]>;
71
+ readonly outer: Supplier | null;
72
+ }
73
+ /** An element on the way down: a grammar or host element that encloses what is being walked. */
74
+ export interface Ancestor {
75
+ readonly element: RenderElement;
76
+ }
77
+ /** What a walk reports to a check. */
78
+ export interface RenderVisitor {
79
+ /** An element, with its grammar ancestors outermost first and the component stack that led to it. */
80
+ enter?(element: RenderElement, ancestors: readonly Ancestor[], stack: readonly string[]): void;
81
+ /** The elements one parent draws side by side, placements and fragments resolved, in order. */
82
+ siblings?(elements: readonly RenderElement[], ancestors: readonly Ancestor[], stack: readonly string[]): void;
83
+ /** A grammar or host element with the elements it draws directly, placements resolved. */
84
+ parent?(element: RenderElement, drawn: readonly RenderElement[], ancestors: readonly Ancestor[], stack: readonly string[]): void;
85
+ }
86
+ /** Everything the walk needs about the files of one program. */
87
+ export declare class RenderGraph {
88
+ readonly ts: TypeScriptApi;
89
+ readonly graph: HfsGraph;
90
+ private readonly components;
91
+ private readonly imports;
92
+ private readonly exportsCache;
93
+ private readonly fileEdges;
94
+ constructor(ts: TypeScriptApi, graph: HfsGraph);
95
+ /** Every app component of the program. */
96
+ allComponents(): ComponentDefinition[];
97
+ /** The component a file declares under a name. */
98
+ componentOf(file: string, name: string): ComponentDefinition | null;
99
+ /** The location of a node, 1-based. */
100
+ locate(element: {
101
+ readonly file: string;
102
+ readonly node: Node;
103
+ }): {
104
+ path: string;
105
+ line: number;
106
+ column: number;
107
+ };
108
+ private readComponents;
109
+ /** The function a declaration initializer is or wraps (`memo(fn)`, `forwardRef(fn)`). */
110
+ private functionOf;
111
+ private returnsOf;
112
+ private importsOf;
113
+ /** The file and local name that declare what `file` exports under `name`, following re-exports. */
114
+ private resolveExport;
115
+ private exportTable;
116
+ /** What the JSX tag `tagName` of a file names. */
117
+ tagOf(rel: string, tagName: Node): Tag;
118
+ /** Walks everything `root` draws with the empty context, reporting each element and each run of siblings to the visitor. */
119
+ walkFrom(root: ComponentDefinition, visitor: RenderVisitor): void;
120
+ private walk;
121
+ /** Parts with placements replaced by what the supplier handed over; each element keeps the supplier its own placements read. */
122
+ private flatten;
123
+ /** The one element a component element draws at its root, or null when it draws several things or none. */
124
+ rootOf(element: RenderElement): RenderElement | null;
125
+ }