@starci/eslint-canon-fe 9.8.5 → 9.9.2
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.
- package/CHANGELOG.md +24 -3
- package/README.md +2 -4
- package/dist/button-intent.d.ts +0 -1
- package/dist/button-intent.js +1 -16
- package/dist/file-layout.js +2 -1
- package/dist/index.js +2 -0
- package/dist/lib/config.js +3 -2
- package/dist/lib/scope.d.ts +1 -1
- package/dist/lib/scope.js +1 -1
- package/dist/lib/why.d.ts +20 -5
- package/dist/lib/why.js +26 -11
- package/dist/loading.js +1 -1
- package/dist/props-and-slots.d.ts +6 -0
- package/dist/props-and-slots.js +41 -0
- package/dist/shape-slot.js +6 -6
- package/dist/skeleton-states.d.ts +16 -0
- package/dist/skeleton-states.js +230 -0
- package/dist/surface-rules.d.ts +2 -5
- package/dist/surface-rules.js +28 -121
- package/dist/ui-rulings.js +1 -1
- package/docs/button-intent.md +0 -1
- package/docs/size-and-state-budget.md +1 -1
- package/docs/surface-rules.md +17 -32
- package/package.json +1 -1
- package/runtime/bundle.json +4 -0
- package/runtime/knowledge/hfs/canon-pins.yaml +7 -7
- package/runtime/knowledge/hfs/slots.yaml +5 -5
- package/runtime/knowledge/patterns/fe/folder.yaml +1 -1
- package/runtime/scripts/hfs/architecture/clones.mjs +12 -2
- package/runtime/scripts/hfs/architecture/component-tiers.mjs +161 -0
- package/runtime/scripts/hfs/architecture/config.mjs +1 -1
- package/runtime/scripts/hfs/architecture/frontend-world.mjs +1 -1
- package/runtime/scripts/hfs/architecture/frontend.mjs +2 -2
- package/runtime/scripts/hfs/architecture/index.mjs +6 -0
- package/runtime/scripts/hfs/architecture/render-graph.mjs +449 -0
- package/runtime/scripts/hfs/architecture/render-prop-across-server.mjs +58 -0
- package/runtime/scripts/hfs/architecture/surface-nesting.mjs +127 -0
- package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +12 -5
- package/runtime/scripts/hfs/slot-manifest-shape.mjs +16 -1
package/runtime/bundle.json
CHANGED
|
@@ -44,6 +44,7 @@
|
|
|
44
44
|
"scripts/hfs/architecture/client-reaches-server.mjs",
|
|
45
45
|
"scripts/hfs/architecture/clones.mjs",
|
|
46
46
|
"scripts/hfs/architecture/config-unread.mjs",
|
|
47
|
+
"scripts/hfs/architecture/component-tiers.mjs",
|
|
47
48
|
"scripts/hfs/architecture/config.mjs",
|
|
48
49
|
"scripts/hfs/architecture/connection-map.mjs",
|
|
49
50
|
"scripts/hfs/architecture/constructor-deps.mjs",
|
|
@@ -96,6 +97,8 @@
|
|
|
96
97
|
"scripts/hfs/architecture/reachability.mjs",
|
|
97
98
|
"scripts/hfs/architecture/register-once.mjs",
|
|
98
99
|
"scripts/hfs/architecture/registration.mjs",
|
|
100
|
+
"scripts/hfs/architecture/render-graph.mjs",
|
|
101
|
+
"scripts/hfs/architecture/render-prop-across-server.mjs",
|
|
99
102
|
"scripts/hfs/architecture/required-files.mjs",
|
|
100
103
|
"scripts/hfs/architecture/route-files-thin.mjs",
|
|
101
104
|
"scripts/hfs/architecture/schema-owner.mjs",
|
|
@@ -109,6 +112,7 @@
|
|
|
109
112
|
"scripts/hfs/architecture/sql-token-forms.mjs",
|
|
110
113
|
"scripts/hfs/architecture/sql-tokens.mjs",
|
|
111
114
|
"scripts/hfs/architecture/staged-consumers.mjs",
|
|
115
|
+
"scripts/hfs/architecture/surface-nesting.mjs",
|
|
112
116
|
"scripts/hfs/architecture/surface.mjs",
|
|
113
117
|
"scripts/hfs/architecture/symbols.mjs",
|
|
114
118
|
"scripts/hfs/architecture/test-world-files.mjs",
|
|
@@ -31,42 +31,42 @@ pins:
|
|
|
31
31
|
source: packages/event-stream/package.json
|
|
32
32
|
why: 'the one server-sent-events client of every front end (fe-stack event-stream, owner:event-stream-package): the transport client layer (modules/api/sse/clients) creates one instance for the app with the bearer token source and the stream operations follow on it; it carries Last-Event-ID resume, the capped doubling backoff, the heartbeat timeout, the single 401 renewal and the useSWRSubscription adapter, so no app keeps a hand-written reader.'
|
|
33
33
|
'@starci/grammar':
|
|
34
|
-
version: 0.
|
|
34
|
+
version: 0.21.2
|
|
35
35
|
group: starci
|
|
36
36
|
install: registry
|
|
37
37
|
side: fe
|
|
38
38
|
source: packages/grammar/package.json
|
|
39
39
|
why: 'the grammar front ends read: the brand layer sets `--font-sans` and `--font-mono` and the grammar reads them; it carries the Input tel kind, the copyable and read-only Input, the IconButton disclosure props and count badge, ChatBubble, FillViewport, Textarea mentions and Enter-to-send, the leading PrimaryRailLayout rail, the plate-less Image, ListRow, the soft Alert, Progress steps, the floating primary IconButton, FlowGraph, the STATE-8 loading views and the bordered selectable TagGroup chips. The runtime source version wins for a @starci package.'
|
|
40
40
|
'@starci/eslint-canon-be':
|
|
41
|
-
version: 3.11.
|
|
41
|
+
version: 3.11.9
|
|
42
42
|
group: starci
|
|
43
43
|
install: registry
|
|
44
44
|
side: be
|
|
45
45
|
source: packages/eslint-canon-be/package.json
|
|
46
46
|
why: 'the back-end ESLint canon: `loadHfs(import.meta.url)` of be/eslint.config.mjs finds the app-root hfs.json (kind app) and gives every linted file the view of the be side, and the project graph is built per side; its bundled runtime copies (slots, canon-pins, failure codes, the architecture machine) put .starcistacks and .sops.yaml at the app root; `starciBeConfig({ hfs: loadHfs(import.meta.url) })` is the typed factory and the BE-CONVENTION laws are its rules.'
|
|
47
47
|
'@starci/eslint-canon-fe':
|
|
48
|
-
version: 9.
|
|
48
|
+
version: 9.9.2
|
|
49
49
|
group: starci
|
|
50
50
|
install: registry
|
|
51
51
|
side: fe
|
|
52
52
|
source: packages/eslint-canon-fe/package.json
|
|
53
53
|
why: 'the front-end ESLint canon: `loadHfs(import.meta.url)` of fe/eslint.config.mjs finds the app-root hfs.json (kind app) and gives every linted file the view of the fe side, and the project graph is built per side; its bundled runtime copies (slots, canon-pins, failure codes, the architecture machine) put .starcistacks and .sops.yaml at the app root; the front end has no tests (FE_NO_TESTS R97 in hfs) and no-vietnamese-in-source (R91) applies.'
|
|
54
54
|
'@starci/eslint-grammar-core':
|
|
55
|
-
version: 0.
|
|
55
|
+
version: 0.21.2
|
|
56
56
|
group: starci
|
|
57
57
|
install: registry
|
|
58
58
|
side: fe
|
|
59
59
|
source: packages/eslint-grammar-core/package.json
|
|
60
60
|
why: 'the StarCi Grammar component API lint, versioned with @starci/grammar; front ends compose this family package with the universal FE canon.'
|
|
61
61
|
'@starci/eslint-grammar-offset-pop':
|
|
62
|
-
version: 0.
|
|
62
|
+
version: 0.21.2
|
|
63
63
|
group: starci
|
|
64
64
|
install: registry
|
|
65
65
|
side: fe
|
|
66
66
|
source: packages/eslint-grammar-offset-pop/package.json
|
|
67
67
|
why: 'the Offset Pop Grammar component API lint, versioned with @starci/grammar; its shared component contract comes from the StarCi family lint without copying rules.'
|
|
68
68
|
'@starci/stylelint-canon':
|
|
69
|
-
version: 2.
|
|
69
|
+
version: 2.5.0
|
|
70
70
|
group: starci
|
|
71
71
|
install: registry
|
|
72
72
|
side: fe
|
|
@@ -106,7 +106,7 @@ pins:
|
|
|
106
106
|
source: packages/ai-router/package.json
|
|
107
107
|
why: 'the AI pool router of a back end: a NestJS module that routes OpenAI-compatible chat, stream and embeddings calls across keys, models and providers, with @starci/ai-router/testing for its specs; @starci/jest-preset compiles it as an ESM-only dependency.'
|
|
108
108
|
'@starci/hfs':
|
|
109
|
-
version: 4.
|
|
109
|
+
version: 4.17.2
|
|
110
110
|
group: starci
|
|
111
111
|
install: registry
|
|
112
112
|
side: both
|
|
@@ -196,7 +196,7 @@ tiers:
|
|
|
196
196
|
feature: {mayImport: [feature, components, hooks, foundation, modules, package], mayImportKinds: {feature: {from: [pages, layouts], to: [overlays]}}} # pages and layouts compose overlays and blocks; data goes through hooks/swr
|
|
197
197
|
# A component never reaches data: config, routes, i18n and types are foundation; every other module and the api
|
|
198
198
|
# transport are reached through a hook (owner decision 2026-09-29).
|
|
199
|
-
components: {mayImport: [components, hooks, foundation, package], lowerLayerOnly: true} # blocks, composites, branches, leaves
|
|
199
|
+
components: {mayImport: [components, hooks, foundation, package], lowerLayerOnly: true} # blocks, frames, composites, branches, leaves
|
|
200
200
|
# Across domains a hook is imported only through the other domain's public index (hooks/<domain>/index.ts), never deep.
|
|
201
201
|
hooks: {mayImport: [hooks, foundation, modules, transport, package], mayImportSlots: {transport: {from: [fe.hooks.swr]}}} # only hooks/swr imports an operation
|
|
202
202
|
foundation: {mayImport: [foundation, package], acyclic: true} # modules/{config,i18n,routes,types}
|
|
@@ -2227,12 +2227,12 @@ slots:
|
|
|
2227
2227
|
- id: fe.components
|
|
2228
2228
|
since: 3.1.0
|
|
2229
2229
|
profiles: [fe]
|
|
2230
|
-
path: "apps/<app>/src/components/{blocks,composites,branches,leaves}/<name>/"
|
|
2230
|
+
path: "apps/<app>/src/components/{blocks,frames,composites,branches,leaves}/<name>/"
|
|
2231
2231
|
presence: optional
|
|
2232
2232
|
tracked: tracked
|
|
2233
2233
|
tier: components
|
|
2234
2234
|
owner: true
|
|
2235
|
-
layers: [blocks, composites, branches, leaves] # a layer imports only the layers after it
|
|
2235
|
+
layers: [blocks, frames, composites, branches, leaves] # a layer imports only the layers after it
|
|
2236
2236
|
requires: [index.tsx]
|
|
2237
2237
|
allows: [index.tsx, component.tsx, classNames.ts]
|
|
2238
2238
|
roles: {entry: index.tsx, drawing: component.tsx, styles: classNames.ts} # the connected entry, the pure drawing half and the styling declarations of a component owner
|
|
@@ -2575,12 +2575,12 @@ slots:
|
|
|
2575
2575
|
tracked: tracked
|
|
2576
2576
|
tier: package
|
|
2577
2577
|
owner: true
|
|
2578
|
-
kinds: [composites, branches, leaves] # the layer folders under src/ (a package owns no blocks)
|
|
2578
|
+
kinds: [frames, composites, branches, leaves] # the layer folders under src/ (a package owns no blocks)
|
|
2579
2579
|
roles: {entry: index.tsx, drawing: component.tsx, styles: classNames.ts}
|
|
2580
2580
|
requires: [package.json, src/index.ts, tsconfig.json]
|
|
2581
2581
|
tests: none
|
|
2582
2582
|
budget: {component.tsx: 300, index.tsx: 200, dataHooks: 6, useState: 6}
|
|
2583
|
-
why: built to dist with explicit named exports; only composites, branches and leaves; no unused export; no colour literal
|
|
2583
|
+
why: built to dist with explicit named exports; only frames, composites, branches and leaves; no unused export; no colour literal
|
|
2584
2584
|
rules: [FE_PACKAGE_SHAPE, HFS_UNUSED_EXPORT, FE_STYLE_TOKEN_ONLY]
|
|
2585
2585
|
- id: fe.package.api
|
|
2586
2586
|
since: 3.1.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/<
|
|
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"
|
|
@@ -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
|
-
|
|
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,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 = [
|