@starci/eslint-canon-fe 9.9.7 → 9.9.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 9.9.9
4
+
5
+ - Changed: Preserve mounted RootPage children through selected native body layouts only when their metadata constraints, immutable props and required context are proven. Retain exact body and import identity, and refresh the generated canon pins.
6
+
7
+ ## 9.9.8
8
+
9
+ - Changed: Enforce the documented one-level connected RootPage children composition with mounted selected-export and actual sibling-Base proof shared with R325. Keep ordinary block/atom restrictions and inspect shape records for rendered trees.
10
+ - Fixed: Virtual RuleTester files refuse the RootPage children capability before disk parsing; unsaved content keeps the on-disk source identity guard.
11
+
3
12
  ## 9.9.7
4
13
 
5
14
  - Changed: Bundled canon pins name the builder-provenance BE canon release and its coherent dependency cohort. This package changes no runtime behavior or rule severity.
package/README.md CHANGED
@@ -56,6 +56,12 @@ declare intent and leave variants to Grammar. It contains destructive confirms i
56
56
  dialog, counts mutually exclusive render paths separately and reserves commit for the view's
57
57
  decision rather than repeated collection items.
58
58
 
59
+ The R92 `tier-children` and `base-props-atom` checks share the existing R325 one-level RootPage proof: a mounted
60
+ page or segment layout composes one connected root, that root renders its own Base, and the Base owns the selected
61
+ canonical page renderer. Only those exact input members may take `children`; ordinary blocks, relays, fake or unused
62
+ renderers and shape records containing rendered trees remain refused. The selected canonical Grammar catalog owns
63
+ semantic renderer bindings; universal code carries no concrete renderer spelling.
64
+
59
65
  Regression specs belong to this package. Application front ends and Grammar libraries have no specs.
60
66
 
61
67
  ## The runtime copy
package/dist/drawing.d.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  * THE PURE HALF TAKES ATOMS. `component.tsx` receives `{ shape?, props, on? }`: `props` is data
10
10
  * (strings, numbers, booleans, arrays, plain objects), `on` is actions whose arguments are data,
11
11
  * and feature composition may also take named `render` callbacks inside `props` to render supplied
12
- * blocks. A layout alone also takes `children`, because the router puts the page there.
12
+ * blocks. A layout takes the router's `children`; the proven one-level connected root of a route Page and its Base take that owner's children into the Page body.
13
13
  *
14
14
  * ONLY ITS OWN INDEX MAY IMPORT IT. Other tiers use the connected export, so the pure half stays a
15
15
  * private drawing surface that audit can render from a fixture.
@@ -20,7 +20,7 @@
20
20
  */
21
21
  import { type Rules, type Recommended } from "./lib/rule.js";
22
22
  /** The pure half takes atom data and actions; feature composition may receive named render callbacks. */
23
- export declare const basePropsAtom: import("./lib/rule.js").Rule<"action" | "tree" | "onData" | "member" | "renamed", []>;
23
+ export declare const basePropsAtom: import("./lib/rule.js").Rule<"action" | "member" | "tree" | "onData" | "renamed", []>;
24
24
  /** Only the sibling `index.tsx` (and its specs) may reach `component.tsx`; only `X` leaves the folder. */
25
25
  export declare const baseImportPair: import("./lib/rule.js").Rule<"reach" | "reexport", []>;
26
26
  /** A surface's `XShape` names drawn shapes only: no data status, no open/closed, no status flag. */
package/dist/drawing.js CHANGED
@@ -9,7 +9,7 @@
9
9
  * THE PURE HALF TAKES ATOMS. `component.tsx` receives `{ shape?, props, on? }`: `props` is data
10
10
  * (strings, numbers, booleans, arrays, plain objects), `on` is actions whose arguments are data,
11
11
  * and feature composition may also take named `render` callbacks inside `props` to render supplied
12
- * blocks. A layout alone also takes `children`, because the router puts the page there.
12
+ * blocks. A layout takes the router's `children`; the proven one-level connected root of a route Page and its Base take that owner's children into the Page body.
13
13
  *
14
14
  * ONLY ITS OWN INDEX MAY IMPORT IT. Other tiers use the connected export, so the pure half stays a
15
15
  * private drawing surface that audit can render from a fixture.
@@ -20,6 +20,7 @@
20
20
  */
21
21
  import { defineRule } from "./lib/rule.js";
22
22
  import { classifyImport, roleOfFile } from "./lib/scope.js";
23
+ import { rootPageChildren } from "./lib/root-page.js";
23
24
  import { inSplitTier, pureHalf } from "./lib/drawing-scope.js";
24
25
  /** Type names that carry a rendered tree rather than data. */
25
26
  const NON_ATOM_TYPE = /^(?:ReactNode|ReactElement|ReactPortal|JSX\.Element|Element|ComponentType|FC|FunctionComponent|ElementType|ComponentProps|RenderFunction)$/;
@@ -85,7 +86,7 @@ const keyOf = (member) => {
85
86
  export const basePropsAtom = defineRule({
86
87
  meta: {
87
88
  type: "problem",
88
- docs: { description: "A pure `XBase` takes `shape`, atom `props` and `on` actions; feature composition may receive named render callbacks in `props`." },
89
+ docs: { description: "A pure `XBase` takes `shape`, atom `props` and `on` actions; a layout and a structurally proven RootPage may take children." },
89
90
  fixable: "code",
90
91
  schema: [],
91
92
  messages: {
@@ -213,8 +214,10 @@ export const basePropsAtom = defineRule({
213
214
  for (const member of membersOf(annotation, types)) {
214
215
  const key = keyOf(member);
215
216
  const type = memberType(member);
216
- if (key === "shape")
217
+ if (key === "shape") {
218
+ checkData(type, "shape", new Set());
217
219
  continue;
220
+ }
218
221
  if (key === "state") {
219
222
  const stateKey = "key" in member ? member.key : undefined;
220
223
  context.report({
@@ -228,7 +231,7 @@ export const basePropsAtom = defineRule({
228
231
  checkData(type, "props", new Set());
229
232
  else if (key === "on")
230
233
  checkActions(type);
231
- else if (key === "children" && tier === "layouts")
234
+ else if (key === "children" && (tier === "layouts" || tier === "blocks" && rootPageChildren(context, member)))
232
235
  continue;
233
236
  else {
234
237
  context.report({
@@ -27,7 +27,7 @@
27
27
  */
28
28
  import { type Rules, type Recommended } from "./lib/rule.js";
29
29
  /** `hooks/` holds React hooks, each under the name its family or domain gives it, and one shared helper file per domain. */
30
- export declare const hooksFolderHoldsHooksOnly: import("./lib/rule.js").Rule<"server" | "missing" | "defaultExport" | "export" | "many" | "fake" | "domain" | "notHook" | "family" | "subscription" | "sharedHook", []>;
30
+ export declare const hooksFolderHoldsHooksOnly: import("./lib/rule.js").Rule<"server" | "missing" | "export" | "defaultExport" | "many" | "fake" | "domain" | "notHook" | "family" | "subscription" | "sharedHook", []>;
31
31
  /** The rules this law contributes to the plugin. */
32
32
  export declare const rules: Rules;
33
33
  /** Every rule is an error. */
@@ -32,5 +32,4 @@ export interface CrossFileInput {
32
32
  }
33
33
  /** The line count above which `file-size-growth` reads history: the soft budget of the shipped manifest (500) is never lower than this. */
34
34
  export declare const HISTORY_MIN_LINES = 400;
35
- /** Every disk read of this canon's rules beyond the import graph. */
36
35
  export declare const crossFileInputs: readonly CrossFileInput[];
@@ -19,7 +19,14 @@
19
19
  /** The line count above which `file-size-growth` reads history: the soft budget of the shipped manifest (500) is never lower than this. */
20
20
  export const HISTORY_MIN_LINES = 400;
21
21
  /** Every disk read of this canon's rules beyond the import graph. */
22
+ const ROOT_PAGE_INPUTS = ["**/src/app/**/*.{ts,tsx}", "**/src/features/**/*.{ts,tsx}", "**/src/components/{blocks,frames,composites,branches,leaves}/**/*.{ts,tsx}", "packages/*/package.json", "packages/*/src/**/*.{ts,tsx}"];
23
+ const ROOT_PAGE_DEPENDANTS = ["**/src/components/blocks/**/*.{ts,tsx}"];
22
24
  export const crossFileInputs = [
25
+ ...["tier-children", "base-props-atom"].flatMap((rule) => [
26
+ { rule, kind: "content", relativeTo: "side", globs: ROOT_PAGE_INPUTS, appliesTo: ROOT_PAGE_DEPENDANTS, reader: "lib/owner-source.ts" },
27
+ { rule, kind: "content", relativeTo: "side", globs: ROOT_PAGE_INPUTS, appliesTo: ROOT_PAGE_DEPENDANTS, reader: "lib/root-page.ts" },
28
+ { rule, kind: "exists", relativeTo: "side", globs: ["**/src/app", "**/src/app/**"], appliesTo: ROOT_PAGE_DEPENDANTS, reader: "lib/root-page.ts" },
29
+ ]),
23
30
  // the manifest name of the package that owns the linted file (the nearest package.json upward)
24
31
  { rule: "no-grammar-shim", kind: "content", relativeTo: "side", globs: ["**/package.json", "../package.json"], appliesTo: ["**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}"], reader: "grammar-shim.ts" },
25
32
  // an operation import is told from a workspace package by that package's manifest and entry
@@ -8,8 +8,8 @@
8
8
  * run time, so the two views are joined here, in one place, and nowhere else.
9
9
  */
10
10
  import type { TSESLint } from "@typescript-eslint/utils";
11
- import type { ESLint, Rule as EslintRule } from "eslint";
12
- import type { Rule, Rules } from "./rule.js";
11
+ import type { SourceCode as EslintSourceCode, ESLint, Rule as EslintRule } from "eslint";
12
+ import type { SourceCode, Rule, Rules } from "./rule.js";
13
13
  /** A canon rule (or a rule already written at ESLint's type, as the Grammar families write theirs) as ESLint's rule type: the same object. */
14
14
  export declare const eslintRule: (rule: Rule<string, readonly unknown[]> | EslintRule.RuleModule) => EslintRule.RuleModule;
15
15
  /** A rule at typescript-eslint's rule type with mutable options, the type the shared back-end fixtures declare their testers with: the same object. */
@@ -21,3 +21,5 @@ export declare const eslintPlugin: (plugin: {
21
21
  };
22
22
  readonly rules: Rules;
23
23
  }) => ESLint.Plugin;
24
+ /** The independently parsed source object at the same ESLint/typescript-eslint boundary. */
25
+ export declare const sourceCodeView: (sourceCode: EslintSourceCode) => SourceCode;
@@ -4,3 +4,5 @@ export const eslintRule = (rule) => rule;
4
4
  export const sharedRule = (rule) => rule;
5
5
  /** The canon's rules as an ESLint plugin: the same objects, under ESLint's plugin type. */
6
6
  export const eslintPlugin = (plugin) => plugin;
7
+ /** The independently parsed source object at the same ESLint/typescript-eslint boundary. */
8
+ export const sourceCodeView = (sourceCode) => sourceCode;
@@ -0,0 +1,26 @@
1
+ import type { TSESTree } from "@typescript-eslint/types";
2
+ import type { Node, AnyContext } from "./rule.js";
3
+ /** The actual lint context with its independently parsed owner program. */
4
+ export interface ParsedContext extends AnyContext {
5
+ readonly program: TSESTree.Program;
6
+ }
7
+ export type ReaderContext = AnyContext;
8
+ /** An exported value together with the parsed file that holds it. */
9
+ export interface Owner {
10
+ readonly context: ParsedContext;
11
+ readonly value: Node;
12
+ }
13
+ export declare const sourceFile: (context: ReaderContext, source: string) => string | null;
14
+ /** The selected exported value, following public barrels without scanning unrelated imported implementations. */
15
+ export declare const headerExport: (context: ParsedContext, name: string, seen?: Set<string>) => Owner | null;
16
+ /** The actual value import a JSX tag selects, including import aliases and namespaces. */
17
+ export declare const jsxImport: (context: ReaderContext, opening: TSESTree.JSXOpeningElement) => {
18
+ readonly source: string;
19
+ readonly exported: string;
20
+ } | null;
21
+ /** An imported JSX owner, or null when its implementation cannot be proved from owned source. */
22
+ export declare const importedHeader: (context: ReaderContext, opening: TSESTree.JSXOpeningElement) => Owner | null;
23
+ /** A file of the app parsed on its own (a route `layout`), sharing the run's parse cache. */
24
+ export declare const parsedSource: (context: ReaderContext, filename: string) => ParsedContext;
25
+ /** The per-repository proof cache shares the same lifetime as parsed header source. */
26
+ export declare const headerProofs: (context: ReaderContext) => Map<string, boolean>;
@@ -0,0 +1,187 @@
1
+ /** Resolve only an imported header's owned source and selected public export. */
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { RUNTIME_ROOT } from "./package-root.js";
5
+ import ts from "typescript";
6
+ import parser from "@typescript-eslint/parser";
7
+ import { SourceCode } from "eslint";
8
+ import { importOf, variableOf } from "./bindings.js";
9
+ import { resolveImport } from "./scope.js";
10
+ import { hfsOf } from "./hfs.js";
11
+ import { walk } from "./ast.js";
12
+ import { sourceCodeView } from "./eslint-boundary.js";
13
+ import { loadArchitectureConfig } from "#runtime/scripts/hfs/architecture/config.mjs";
14
+ import { workspaceSourceEntry } from "#runtime/scripts/hfs/architecture/typescript.mjs";
15
+ import { openHfs } from "#runtime/scripts/hfs/slots.mjs";
16
+ const RUNTIME = RUNTIME_ROOT;
17
+ const roots = new WeakMap();
18
+ const origins = new WeakMap();
19
+ const stateOf = (context) => {
20
+ const root = hfsOf(context).repoRoot;
21
+ const snapshot = origins.get(context.sourceCode) ?? context.sourceCode;
22
+ let state = roots.get(snapshot);
23
+ if (state === undefined) {
24
+ state = { root, files: new Map(), proofs: new Map(), workspaces: null };
25
+ roots.set(snapshot, state);
26
+ }
27
+ return state;
28
+ };
29
+ /** A workspace package from the text of its `package.json`. */
30
+ const workspaceOf = (root, text) => {
31
+ const manifest = JSON.parse(text);
32
+ const record = typeof manifest === "object" && manifest !== null ? manifest : {};
33
+ return {
34
+ root,
35
+ workspace: true,
36
+ name: "name" in record && typeof record.name === "string" ? record.name : null,
37
+ manifest,
38
+ exports: "exports" in record ? record.exports : undefined
39
+ };
40
+ };
41
+ const workspaceFile = (context, source) => {
42
+ const state = stateOf(context);
43
+ if (!state.workspaces) {
44
+ const hfs = openHfs({ root: RUNTIME, repoRoot: state.root });
45
+ const config = loadArchitectureConfig(state.root, { hfs });
46
+ state.workspaces = config.workspaces.map((relative) => {
47
+ const root = path.join(state.root, relative);
48
+ return workspaceOf(root, fs.readFileSync(path.join(root, "package.json"), "utf8"));
49
+ });
50
+ }
51
+ const workspace = state.workspaces.find((entry) => entry.name && (source === entry.name || source.startsWith(`${entry.name}/`)));
52
+ return workspace ? workspaceSourceEntry(ts, workspace, source) : null;
53
+ };
54
+ export const sourceFile = (context, source) => {
55
+ const target = resolveImport(context, source) ?? workspaceFile(context, source);
56
+ if (!target)
57
+ return null;
58
+ const hfs = hfsOf(context);
59
+ return ([target, `${target}.tsx`, `${target}.ts`, `${target}/index.tsx`, `${target}/index.ts`].find((file) => /\.[cm]?[jt]sx?$/i.test(file) && !/\.d\.[cm]?ts$/i.test(file) && ts.sys.fileExists(file) && hfs.classify(file).status === "owned") ?? null);
60
+ };
61
+ /** Record the parent of a child node: the parser leaves it to ESLint's traversal, which this independent parse bypasses. */
62
+ const adopt = (child, parent) => {
63
+ if (typeof child === "object" && child !== null && "type" in child && child.type)
64
+ Reflect.set(child, "parent", parent);
65
+ };
66
+ const sourceContext = (context, filename) => {
67
+ const state = stateOf(context);
68
+ const known = state.files.get(filename);
69
+ if (known)
70
+ return known;
71
+ const text = fs.readFileSync(filename, "utf8");
72
+ const parsed = parser.parseForESLint(text, {
73
+ filePath: filename,
74
+ sourceType: "module",
75
+ ecmaFeatures: { jsx: true },
76
+ range: true,
77
+ loc: true,
78
+ tokens: true,
79
+ comment: true
80
+ });
81
+ const sourceCode = new SourceCode({
82
+ text,
83
+ ast: parsed.ast,
84
+ scopeManager: parsed.scopeManager,
85
+ visitorKeys: parsed.visitorKeys,
86
+ parserServices: parsed.services
87
+ });
88
+ const view = sourceCodeView(sourceCode);
89
+ walk(view, parsed.ast, (node) => {
90
+ for (const key of view.visitorKeys[node.type] ?? []) {
91
+ const value = Reflect.get(node, key);
92
+ const values = Array.isArray(value) ? value : [value];
93
+ for (const child of values)
94
+ adopt(child, node);
95
+ }
96
+ });
97
+ const owned = {
98
+ ...context,
99
+ filename,
100
+ getFilename: () => filename,
101
+ settings: context.settings,
102
+ sourceCode: view,
103
+ program: parsed.ast
104
+ };
105
+ origins.set(view, origins.get(context.sourceCode) ?? context.sourceCode);
106
+ state.files.set(filename, owned);
107
+ return owned;
108
+ };
109
+ const moduleContext = (context, source) => {
110
+ const filename = sourceFile(context, source);
111
+ return filename ? sourceContext(context, filename) : null;
112
+ };
113
+ const namedDeclaration = (declaration, name) => {
114
+ if (declaration?.type === "FunctionDeclaration")
115
+ return declaration.id?.name === name ? declaration : null;
116
+ if (declaration?.type !== "VariableDeclaration")
117
+ return null;
118
+ return declaration.declarations.find((entry) => entry.id.type === "Identifier" && entry.id.name === name)?.init ?? null;
119
+ };
120
+ /** The name an export specifier spells: an identifier's name or a string literal's value. */
121
+ const specifierName = (name) => (name.type === "Identifier" ? name.name : name.value);
122
+ const namedExport = (context, statement, name, visited) => {
123
+ const value = namedDeclaration(statement.declaration, name);
124
+ if (value)
125
+ return { matched: true, owner: { context, value } };
126
+ const specifier = statement.specifiers.find((entry) => specifierName(entry.exported) === name);
127
+ if (!specifier)
128
+ return { matched: false };
129
+ if (!statement.source)
130
+ return { matched: true, owner: { context, value: specifier.local } };
131
+ const target = moduleContext(context, String(statement.source.value));
132
+ return { matched: true, owner: target ? headerExport(target, specifierName(specifier.local), visited) : null };
133
+ };
134
+ /** The selected exported value, following public barrels without scanning unrelated imported implementations. */
135
+ export const headerExport = (context, name, seen = new Set()) => {
136
+ const key = `${context.filename}:${name}`;
137
+ if (seen.has(key))
138
+ return null;
139
+ const visited = new Set(seen).add(key);
140
+ for (const statement of context.program.body) {
141
+ if (statement.type === "ExportDefaultDeclaration" && name === "default")
142
+ return { context, value: statement.declaration };
143
+ if (statement.type === "ExportNamedDeclaration") {
144
+ const found = namedExport(context, statement, name, visited);
145
+ if (found.matched)
146
+ return found.owner;
147
+ }
148
+ if (statement.type === "ExportAllDeclaration") {
149
+ const target = moduleContext(context, String(statement.source.value));
150
+ const found = target && headerExport(target, name, visited);
151
+ if (found)
152
+ return found;
153
+ }
154
+ }
155
+ return null;
156
+ };
157
+ /** The actual value import a JSX tag selects, including import aliases and namespaces. */
158
+ export const jsxImport = (context, opening) => {
159
+ const name = opening.name;
160
+ const identifier = name.type === "JSXIdentifier" ? name : name.type === "JSXMemberExpression" ? name.object : null;
161
+ if (identifier?.type !== "JSXIdentifier")
162
+ return null;
163
+ const imported = importOf(context, identifier);
164
+ if (!imported)
165
+ return null;
166
+ const definition = variableOf(context, identifier)?.defs.find((entry) => entry.type === "ImportBinding");
167
+ if (definition?.type !== "ImportBinding" ||
168
+ definition.parent.importKind === "type" ||
169
+ (definition.node.type === "ImportSpecifier" && definition.node.importKind === "type"))
170
+ return null;
171
+ if (name.type === "JSXMemberExpression" && definition?.node.type !== "ImportNamespaceSpecifier")
172
+ return null;
173
+ const exported = name.type === "JSXMemberExpression" ? name.property.name : (imported.imported ?? "default");
174
+ return { source: imported.source, exported };
175
+ };
176
+ /** An imported JSX owner, or null when its implementation cannot be proved from owned source. */
177
+ export const importedHeader = (context, opening) => {
178
+ const imported = jsxImport(context, opening);
179
+ if (!imported)
180
+ return null;
181
+ const target = moduleContext(context, imported.source);
182
+ return target ? headerExport(target, imported.exported) : null;
183
+ };
184
+ /** A file of the app parsed on its own (a route `layout`), sharing the run's parse cache. */
185
+ export const parsedSource = (context, filename) => sourceContext(context, filename);
186
+ /** The per-repository proof cache shares the same lifetime as parsed header source. */
187
+ export const headerProofs = (context) => stateOf(context).proofs;
@@ -0,0 +1,17 @@
1
+ /** Finite content provenance inside a root Page: static grammar anatomy, local pure drawing, canonical region status. */
2
+ import type { TSESTree } from "@typescript-eslint/utils";
3
+ import { type Owner } from "./owner-source.js";
4
+ import type { Node } from "./rule.js";
5
+ export interface BodyReaders {
6
+ readonly resolve: (owner: Owner | null) => Owner | null;
7
+ readonly returned: (owner: Owner) => readonly Node[] | null;
8
+ }
9
+ interface Content {
10
+ readonly owner: Owner;
11
+ readonly field: string;
12
+ }
13
+ /** A field of the actual selected function parameter, never a matching identifier spelling. */
14
+ export declare const parameterMember: (content: Content, node: Node | null) => boolean;
15
+ /** Only Page's body carries owner content; a header/unknown attribute/dead callback cannot stand in for it. */
16
+ export declare const preservesPageBody: (owner: Owner, page: TSESTree.JSXElement, readers: BodyReaders) => boolean;
17
+ export {};
@@ -0,0 +1,244 @@
1
+ import { grammarContractFor } from "#runtime/scripts/lib/grammar-lint.mjs";
2
+ import { canonicalStatusOwnerBinding } from "#runtime/scripts/hfs/architecture/canon-jsx-clones.mjs";
3
+ import { attribute, attributeValue, stringOf, walk } from "./ast.js";
4
+ import { isFn, unwrap, variableOf } from "./bindings.js";
5
+ import { hfsOf } from "./hfs.js";
6
+ import { importedHeader, jsxImport } from "./owner-source.js";
7
+ const record = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
8
+ /** A field of the actual selected function parameter, never a matching identifier spelling. */
9
+ export const parameterMember = (content, node) => {
10
+ const { owner, field } = content;
11
+ if (!isFn(owner.value) ||
12
+ !node ||
13
+ node.type !== "MemberExpression" ||
14
+ node.computed ||
15
+ node.property.type !== "Identifier" ||
16
+ node.property.name !== field ||
17
+ node.object.type !== "Identifier")
18
+ return false;
19
+ const parameter = owner.value.params[0];
20
+ return (parameter?.type === "Identifier" &&
21
+ variableOf(owner.context, node.object)?.defs.some((definition) => definition.type === "Parameter" && definition.name === parameter) === true);
22
+ };
23
+ const references = (content, node) => {
24
+ let count = 0;
25
+ walk(content.owner.context.sourceCode, node, (child) => {
26
+ if (parameterMember(content, child))
27
+ count += 1;
28
+ });
29
+ return count;
30
+ };
31
+ const bodyContainer = (owner, opening, contexts) => {
32
+ const family = hfsOf(owner.context).grammar;
33
+ if (!family)
34
+ return null;
35
+ const roles = grammarContractFor(family).semanticRoles;
36
+ const page = record(roles) ? roles.page : null;
37
+ if (!record(page) || !Array.isArray(page.bodyContainers) || !Array.isArray(page.entries))
38
+ return null;
39
+ const imported = jsxImport(owner.context, opening);
40
+ if (!imported || !page.bodyContainers.includes(imported.exported) || !page.entries.some((entry) => typeof entry === "string" && entry.replace("<family>", family) === imported.source))
41
+ return null;
42
+ if (page.bodyContainerConstraints !== undefined && !record(page.bodyContainerConstraints))
43
+ return null;
44
+ const constraint = record(page.bodyContainerConstraints) ? page.bodyContainerConstraints[imported.exported] : undefined;
45
+ if (constraint === undefined)
46
+ return contexts;
47
+ if (!record(constraint) || Object.keys(constraint).some((key) => !["requiresContext", "providesContext", "absentProps", "literalProps"].includes(key)))
48
+ return null;
49
+ if (constraint.requiresContext !== undefined && (typeof constraint.requiresContext !== "string" || !contexts.has(constraint.requiresContext)))
50
+ return null;
51
+ if (constraint.absentProps !== undefined) {
52
+ const absent = constraint.absentProps;
53
+ if (!Array.isArray(absent) || !absent.every((name) => typeof name === "string"))
54
+ return null;
55
+ if (opening.attributes.some((item) => item.type === "JSXAttribute" && item.name.type === "JSXIdentifier" && absent.includes(item.name.name)))
56
+ return null;
57
+ }
58
+ if (constraint.literalProps !== undefined) {
59
+ if (!record(constraint.literalProps))
60
+ return null;
61
+ for (const [name, choices] of Object.entries(constraint.literalProps)) {
62
+ if (!Array.isArray(choices) || !choices.every((choice) => typeof choice === "string"))
63
+ return null;
64
+ for (const item of opening.attributes) {
65
+ if (item.type !== "JSXAttribute" || item.name.type !== "JSXIdentifier" || item.name.name !== name)
66
+ continue;
67
+ const value = attributeValue(item);
68
+ const literal = value && stringOf(unwrap(value));
69
+ if (literal === null || !choices.includes(literal))
70
+ return null;
71
+ }
72
+ }
73
+ }
74
+ if (constraint.providesContext === undefined)
75
+ return contexts;
76
+ return typeof constraint.providesContext === "string" ? new Set(contexts).add(constraint.providesContext) : null;
77
+ };
78
+ /** The exactly returned fragment/expression, with no unused or duplicated call hidden beside it. */
79
+ const returnedCall = (node) => {
80
+ const value = unwrap(node);
81
+ if (value.type === "CallExpression")
82
+ return value;
83
+ if (value.type !== "JSXFragment")
84
+ return null;
85
+ const children = value.children.filter((child) => child.type !== "JSXText" || child.value.trim() !== "");
86
+ const expression = children.length === 1 && children[0]?.type === "JSXExpressionContainer" ? children[0].expression : null;
87
+ return expression?.type === "CallExpression" ? expression : null;
88
+ };
89
+ /** Prove the selected SlotView implementation really calls render on loading/ready, and never on refusal. */
90
+ const canonicalRegion = (owner, readers) => {
91
+ const found = hfsOf(owner.context).classify(owner.context.filename);
92
+ if (found.status !== "owned" || found.slot !== "fe.components" || found.kind !== "composites" || found.role !== "entry")
93
+ return false;
94
+ if (!isFn(owner.value) || owner.value.body.type !== "BlockStatement")
95
+ return false;
96
+ const body = owner.value.body.body;
97
+ const active = body.filter((statement) => statement.type !== "VariableDeclaration");
98
+ if (active.length !== 5 || active.slice(0, 4).some((statement) => statement.type !== "IfStatement" || statement.alternate !== null))
99
+ return false;
100
+ const states = ["isLoading", "isForbidden", "isError", "isEmpty"];
101
+ let slot = null;
102
+ for (const [index, statement] of active.slice(0, 4).entries()) {
103
+ if (statement.type !== "IfStatement" ||
104
+ statement.test.type !== "BinaryExpression" ||
105
+ statement.test.operator !== "in" ||
106
+ statement.test.left.type !== "Literal" ||
107
+ statement.test.left.value !== states[index] ||
108
+ statement.test.right.type !== "Identifier")
109
+ return false;
110
+ if (slot && variableOf(owner.context, slot) !== variableOf(owner.context, statement.test.right))
111
+ return false;
112
+ slot = statement.test.right;
113
+ }
114
+ if (!slot)
115
+ return false;
116
+ const slotBinding = variableOf(owner.context, slot);
117
+ const definition = slotBinding?.defs[0];
118
+ const parameter = owner.value.params[0];
119
+ if (definition?.type !== "Variable" ||
120
+ definition.parent.kind !== "const" ||
121
+ definition.node.id.type !== "ObjectPattern" ||
122
+ definition.node.init?.type !== "Identifier" ||
123
+ parameter?.type !== "Identifier" ||
124
+ variableOf(owner.context, definition.node.init)?.defs.some((item) => item.type === "Parameter" && item.name === parameter) !== true)
125
+ return false;
126
+ const slotProperty = definition.node.id.properties.find((property) => property.type === "Property" && property.key.type === "Identifier" && property.key.name === "slot");
127
+ if (slotProperty?.type !== "Property" || slotProperty.value.type !== "Identifier" || variableOf(owner.context, slotProperty.value) !== slotBinding)
128
+ return false;
129
+ const loading = active[0];
130
+ const ready = active[4];
131
+ if (loading?.type !== "IfStatement" ||
132
+ loading.consequent.type !== "ReturnStatement" ||
133
+ !loading.consequent.argument ||
134
+ ready?.type !== "ReturnStatement" ||
135
+ !ready.argument)
136
+ return false;
137
+ const waitingCall = returnedCall(loading.consequent.argument);
138
+ const readyCall = returnedCall(ready.argument);
139
+ if (!waitingCall || !readyCall)
140
+ return false;
141
+ const renderField = { owner, field: "render" };
142
+ const validCall = (call, waiting) => {
143
+ if (!parameterMember(renderField, call.callee) ||
144
+ call.arguments.length !== 2 ||
145
+ call.arguments[1]?.type !== "Literal" ||
146
+ call.arguments[1].value !== waiting)
147
+ return false;
148
+ const data = call.arguments[0];
149
+ if (!data)
150
+ return false;
151
+ if (waiting)
152
+ return parameterMember({ owner, field: "placeholder" }, data);
153
+ return (data.type === "MemberExpression" &&
154
+ !data.computed &&
155
+ data.property.type === "Identifier" &&
156
+ data.property.name === "items" &&
157
+ data.object.type === "Identifier" &&
158
+ variableOf(owner.context, data.object) === slotBinding);
159
+ };
160
+ if (!validCall(waitingCall, true) || !validCall(readyCall, false))
161
+ return false;
162
+ let calls = 0;
163
+ walk(owner.context.sourceCode, owner.value.body, (node) => {
164
+ if (node.type === "CallExpression" && parameterMember(renderField, node.callee))
165
+ calls += 1;
166
+ });
167
+ const live = readers.returned(owner);
168
+ return calls === 2 && live !== null && live.includes(loading.consequent.argument) && live.includes(ready.argument);
169
+ };
170
+ /** A local pure helper is followed by its binding; imported helpers must be actual lower owners. */
171
+ const helperOwner = (content, opening, readers) => {
172
+ if (opening.name.type === "JSXIdentifier") {
173
+ const variable = variableOf(content.owner.context, opening.name);
174
+ const definition = variable?.defs[0];
175
+ const value = definition?.type === "Variable" && definition.parent.kind === "const"
176
+ ? definition.node.init
177
+ : definition?.type === "FunctionName"
178
+ ? definition.node
179
+ : null;
180
+ if (value)
181
+ return readers.resolve({ context: content.owner.context, value });
182
+ }
183
+ const imported = readers.resolve(importedHeader(content.owner.context, opening));
184
+ if (!imported)
185
+ return null;
186
+ const found = hfsOf(content.owner.context).classify(imported.context.filename);
187
+ return found.status === "owned" && found.slot === "fe.components" && ["frames", "composites", "branches", "leaves"].includes(found.kind ?? "")
188
+ ? imported
189
+ : null;
190
+ };
191
+ /** A finite path must consume the content exactly once; unknown JSX/calls/branches fail closed. */
192
+ const rendered = (content, node, readers, seen, contexts) => {
193
+ if (parameterMember(content, node))
194
+ return true;
195
+ if (node.type === "JSXExpressionContainer")
196
+ return rendered(content, node.expression, readers, seen, contexts);
197
+ if (node.type === "JSXFragment")
198
+ return renderedChildren(content, node.children, readers, seen, contexts);
199
+ if (node.type !== "JSXElement" || node.openingElement.attributes.some((item) => item.type === "JSXSpreadAttribute"))
200
+ return false;
201
+ const imported = jsxImport(content.owner.context, node.openingElement);
202
+ if (imported && canonicalStatusOwnerBinding(imported.source, imported.exported)) {
203
+ const status = readers.resolve(importedHeader(content.owner.context, node.openingElement));
204
+ const callback = attributeValue(attribute(node.openingElement, "render"));
205
+ if (!status || !canonicalRegion(status, readers) || !callback || !isFn(callback))
206
+ return false;
207
+ if (references(content, node) !== 1 || references(content, callback) !== 1)
208
+ return false;
209
+ const values = readers.returned({ ...content.owner, value: callback });
210
+ return values !== null && values.length > 0 && values.every((value) => parameterMember(content, value));
211
+ }
212
+ const containerContexts = bodyContainer(content.owner, node.openingElement, contexts);
213
+ if (containerContexts !== null) {
214
+ if (node.openingElement.attributes.some((item) => references(content, item) > 0))
215
+ return false;
216
+ return renderedChildren(content, node.children, readers, seen, containerContexts);
217
+ }
218
+ const helper = helperOwner(content, node.openingElement, readers);
219
+ if (!helper || seen.has(helper.value))
220
+ return false;
221
+ const carrying = node.openingElement.attributes.filter((item) => references(content, item) > 0);
222
+ if (carrying.length !== 1 || references(content, node) !== 1)
223
+ return false;
224
+ const input = carrying[0];
225
+ if (input?.type !== "JSXAttribute" || input.name.type !== "JSXIdentifier" || !parameterMember(content, attributeValue(input)))
226
+ return false;
227
+ const target = { owner: helper, field: input.name.name };
228
+ const values = readers.returned(helper);
229
+ return (references(target, helper.value) === 1 &&
230
+ values !== null &&
231
+ values.length > 0 &&
232
+ values.every((value) => rendered(target, value, readers, new Set(seen).add(helper.value), contexts)));
233
+ };
234
+ const renderedChildren = (content, children, readers, seen, contexts = new Set()) => {
235
+ const carrying = children.filter((child) => references(content, child) > 0);
236
+ return carrying.length === 1 && carrying[0] !== undefined && rendered(content, carrying[0], readers, seen, contexts);
237
+ };
238
+ /** Only Page's body carries owner content; a header/unknown attribute/dead callback cannot stand in for it. */
239
+ export const preservesPageBody = (owner, page, readers) => {
240
+ const content = { owner, field: "children" };
241
+ if (references(content, page) !== 1 || page.openingElement.attributes.some((item) => references(content, item) > 0))
242
+ return false;
243
+ return renderedChildren(content, page.children, readers, new Set([owner.value]));
244
+ };
@@ -0,0 +1,8 @@
1
+ import type { TSESTree } from "@typescript-eslint/utils";
2
+ import { type Owner } from "./owner-source.js";
3
+ import type { AnyContext } from "./rule.js";
4
+ /** A selected public semantic role, never a lexical JSX name. Unknown roles refuse. */
5
+ export declare const isPageRenderer: (context: AnyContext, opening: TSESTree.JSXOpeningElement) => boolean;
6
+ /** Shared with R325: one connected entry, its actual sibling Base, its own Page. */
7
+ export declare const connectedPageDrawing: (context: AnyContext, opening: TSESTree.JSXOpeningElement) => Owner | null;
8
+ export declare const rootPageChildren: (context: AnyContext, member: TSESTree.TypeElement) => boolean;
@@ -0,0 +1,423 @@
1
+ /**
2
+ * The one-level connected page composition of FE-FUNCTION-2 / R325. The selected
3
+ * canonical catalog owns renderer spellings; this reader owns the source proof.
4
+ * No app setting, component name or unused JSX grants a block a content door.
5
+ */
6
+ import fs from "node:fs";
7
+ import path from "node:path";
8
+ import { grammarContractFor } from "#runtime/scripts/lib/grammar-lint.mjs";
9
+ import { attribute, attributeValue, walk } from "./ast.js";
10
+ import { importOf, isFn, unwrap, variableOf } from "./bindings.js";
11
+ import { hfsOf } from "./hfs.js";
12
+ import { preservesPageBody, parameterMember } from "./page-body.js";
13
+ import { headerExport, importedHeader, parsedSource } from "./owner-source.js";
14
+ const record = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
15
+ /** A selected public semantic role, never a lexical JSX name. Unknown roles refuse. */
16
+ export const isPageRenderer = (context, opening) => {
17
+ const family = hfsOf(context).grammar;
18
+ if (!family)
19
+ return false;
20
+ const contract = grammarContractFor(family);
21
+ const roles = contract.semanticRoles;
22
+ const role = record(roles) ? roles.page : null;
23
+ if (!record(role) || typeof role.export !== "string" || !Array.isArray(role.entries) || !role.entries.every((entry) => typeof entry === "string"))
24
+ return false;
25
+ const name = opening.name;
26
+ const identifier = name.type === "JSXIdentifier" ? name : name.type === "JSXMemberExpression" && name.object.type === "JSXIdentifier" ? name.object : null;
27
+ if (!identifier)
28
+ return false;
29
+ const origin = importOf(context, identifier);
30
+ const definition = variableOf(context, identifier)?.defs.find((item) => item.type === "ImportBinding");
31
+ if (!origin ||
32
+ definition?.type !== "ImportBinding" ||
33
+ definition.parent.importKind === "type" ||
34
+ (definition.node.type === "ImportSpecifier" && definition.node.importKind === "type"))
35
+ return false;
36
+ const exported = name.type === "JSXMemberExpression" && definition.node.type === "ImportNamespaceSpecifier" ? name.property.name : origin.imported;
37
+ return exported === role.export && role.entries.some((entry) => origin.source === entry.replace("<family>", family));
38
+ };
39
+ /** Resolve only actual immutable value aliases of the selected public export. */
40
+ const resolvedOwner = (owner, seen = new Set()) => {
41
+ if (!owner || seen.has(owner.value))
42
+ return null;
43
+ const value = unwrap(owner.value);
44
+ if (isFn(value))
45
+ return { ...owner, value };
46
+ if (value.type !== "Identifier")
47
+ return null;
48
+ const definition = variableOf(owner.context, value)?.defs[0];
49
+ if (definition?.type === "FunctionName" && isFn(definition.node))
50
+ return { ...owner, value: definition.node };
51
+ if (definition?.type !== "Variable" || definition.parent.kind !== "const" || !definition.node.init)
52
+ return null;
53
+ return resolvedOwner({ ...owner, value: definition.node.init }, new Set(seen).add(owner.value));
54
+ };
55
+ /** Constant branch conditions have their actual meaning; unknown expressions are never guessed. */
56
+ const constantBoolean = (context, node, seen = new Set()) => {
57
+ if (seen.has(node))
58
+ return null;
59
+ const visited = new Set(seen).add(node);
60
+ const value = unwrap(node);
61
+ if (value.type === "Literal" && typeof value.value === "boolean")
62
+ return value.value;
63
+ if (value.type === "Identifier") {
64
+ const definition = variableOf(context, value)?.defs[0];
65
+ return definition?.type === "Variable" && definition.parent.kind === "const" && definition.node.init
66
+ ? constantBoolean(context, definition.node.init, visited)
67
+ : null;
68
+ }
69
+ if (value.type === "UnaryExpression" && value.operator === "!") {
70
+ const inner = constantBoolean(context, value.argument, visited);
71
+ return inner === null ? null : !inner;
72
+ }
73
+ return null;
74
+ };
75
+ /** Walk control flow, stopping after a return/throw and refusing unsupported return-bearing control flow. */
76
+ const returnedStatements = (context, statements) => {
77
+ const values = [];
78
+ const branch = (statement) => returnedStatements(context, statement.type === "BlockStatement" ? statement.body : [statement]);
79
+ for (const statement of statements) {
80
+ if (statement.type === "ReturnStatement")
81
+ return statement.argument ? { values: [...values, unwrap(statement.argument)], fallsThrough: false } : null;
82
+ if (statement.type === "ThrowStatement")
83
+ return { values, fallsThrough: false };
84
+ if (statement.type === "IfStatement") {
85
+ const known = constantBoolean(context, statement.test);
86
+ const left = known === false ? { values: [], fallsThrough: true } : branch(statement.consequent);
87
+ const right = known === true || !statement.alternate ? { values: [], fallsThrough: true } : branch(statement.alternate);
88
+ if (!left || !right)
89
+ return null;
90
+ values.push(...left.values, ...right.values);
91
+ const fallsThrough = known === true ? left.fallsThrough : known === false ? right.fallsThrough : left.fallsThrough || right.fallsThrough;
92
+ if (!fallsThrough)
93
+ return { values, fallsThrough: false };
94
+ continue;
95
+ }
96
+ if (statement.type === "BlockStatement") {
97
+ const nested = returnedStatements(context, statement.body);
98
+ if (!nested)
99
+ return null;
100
+ values.push(...nested.values);
101
+ if (!nested.fallsThrough)
102
+ return { values, fallsThrough: false };
103
+ continue;
104
+ }
105
+ if (![
106
+ "VariableDeclaration",
107
+ "ExpressionStatement",
108
+ "FunctionDeclaration",
109
+ "EmptyStatement",
110
+ "DebuggerStatement",
111
+ "TSTypeAliasDeclaration",
112
+ "TSInterfaceDeclaration"
113
+ ].includes(statement.type))
114
+ return null;
115
+ }
116
+ return { values, fallsThrough: true };
117
+ };
118
+ /** Return expressions of this function only: nested helpers and dead JSX do not draw it. */
119
+ const returned = (owner) => {
120
+ const value = owner.value;
121
+ if (!isFn(value))
122
+ return null;
123
+ if (value.body.type !== "BlockStatement")
124
+ return [unwrap(value.body)];
125
+ const result = returnedStatements(owner.context, value.body.body);
126
+ return result && !result.fallsThrough ? result.values : null;
127
+ };
128
+ /** Empty non-rendering arms are allowed; every rendering arm has exactly one root. */
129
+ const roots = (owner) => {
130
+ const values = returned(owner);
131
+ if (!values)
132
+ return null;
133
+ const found = [];
134
+ const add = (node) => {
135
+ if (node.type === "Literal" && (node.value === null || node.value === false))
136
+ return true;
137
+ if (node.type === "ConditionalExpression") {
138
+ const known = constantBoolean(owner.context, node.test);
139
+ if (known !== null)
140
+ return add(unwrap(known ? node.consequent : node.alternate));
141
+ return add(unwrap(node.consequent)) && add(unwrap(node.alternate));
142
+ }
143
+ if (node.type === "JSXElement") {
144
+ found.push(node);
145
+ return true;
146
+ }
147
+ if (node.type !== "JSXFragment")
148
+ return false;
149
+ const children = node.children.filter((child) => child.type !== "JSXText" || child.value.trim() !== "");
150
+ return children.length === 1 && children[0]?.type === "JSXElement" && add(children[0]);
151
+ };
152
+ return values.every(add) && found.length > 0 ? found : null;
153
+ };
154
+ const sameOwner = (left, right) => path.resolve(left.context.filename) === path.resolve(right.context.filename) && left.value.range[0] === right.value.range[0];
155
+ /** Exactly the selected sibling drawing, not any import whose filename resembles it. */
156
+ const ownDrawing = (owner) => {
157
+ const entry = resolvedOwner(owner);
158
+ if (!entry)
159
+ return null;
160
+ const elements = roots(entry);
161
+ if (!elements)
162
+ return null;
163
+ let drawing = null;
164
+ for (const element of elements) {
165
+ const imported = resolvedOwner(importedHeader(entry.context, element.openingElement));
166
+ if (!imported ||
167
+ path.dirname(imported.context.filename) !== path.dirname(entry.context.filename) ||
168
+ hfsOf(entry.context).classify(imported.context.filename).role !== "drawing")
169
+ return null;
170
+ if (element.openingElement.attributes.some((item) => item.type === "JSXSpreadAttribute"))
171
+ return null;
172
+ if (drawing && !sameOwner(drawing, imported))
173
+ return null;
174
+ drawing = imported;
175
+ }
176
+ return drawing;
177
+ };
178
+ /** The Page is the returned root on every drawing path, not a nested or unused renderer. */
179
+ const pagesOf = (drawing) => {
180
+ const elements = roots(drawing);
181
+ if (!elements || !elements.every((element) => isPageRenderer(drawing.context, element.openingElement)))
182
+ return null;
183
+ let nested = false;
184
+ for (const page of elements)
185
+ walk(drawing.context.sourceCode, page, (node) => {
186
+ if (node !== page && node.type === "JSXElement" && isPageRenderer(drawing.context, node.openingElement))
187
+ nested = true;
188
+ });
189
+ return nested ? null : elements;
190
+ };
191
+ /** A segment's link tabs are a fact of its selected renderer API, owned by the catalog. */
192
+ const segmentDrawing = (drawing) => {
193
+ const family = hfsOf(drawing.context).grammar;
194
+ if (!family)
195
+ return false;
196
+ const roles = grammarContractFor(family).semanticRoles;
197
+ const role = record(roles) ? roles.page : null;
198
+ const segment = record(role) ? role.segment : null;
199
+ if (!record(segment) || typeof segment.attribute !== "string" || typeof segment.member !== "string")
200
+ return false;
201
+ const objectOf = (node, seen = new Set()) => {
202
+ if (!node || seen.has(node))
203
+ return null;
204
+ const value = unwrap(node);
205
+ if (value.type === "ObjectExpression")
206
+ return value;
207
+ if (value.type !== "Identifier")
208
+ return null;
209
+ const definition = variableOf(drawing.context, value)?.defs[0];
210
+ return definition?.type === "Variable" && definition.parent.kind === "const" ? objectOf(definition.node.init, new Set(seen).add(node)) : null;
211
+ };
212
+ return (pagesOf(drawing)?.every((page) => objectOf(attributeValue(attribute(page.openingElement, segment.attribute)))?.properties.some((property) => property.type === "Property" && !property.computed && property.key.type === "Identifier" && property.key.name === segment.member) === true) === true);
213
+ };
214
+ /** Shared with R325: one connected entry, its actual sibling Base, its own Page. */
215
+ export const connectedPageDrawing = (context, opening) => {
216
+ const owner = resolvedOwner(importedHeader(context, opening));
217
+ if (!owner)
218
+ return null;
219
+ const classification = hfsOf(context).classify(owner.context.filename);
220
+ if (classification.slot !== "fe.components" || classification.kind !== "blocks" || classification.role !== "entry")
221
+ return null;
222
+ const drawing = ownDrawing(owner) ?? owner;
223
+ return pagesOf(drawing) ? drawing : null;
224
+ };
225
+ /** An actual reference to the selected function parameter's children. */
226
+ const parameterChildren = (owner, node) => parameterMember({ owner, field: "children" }, node);
227
+ /** The connected half passes children once to its twin; the twin draws it in Page's body. */
228
+ const carriesChildren = (entry, drawing) => {
229
+ const entryRoots = roots(entry);
230
+ const pages = pagesOf(drawing);
231
+ if (!entryRoots || !pages)
232
+ return false;
233
+ const directContent = (owner, element) => {
234
+ const attributes = element.openingElement.attributes;
235
+ if (attributes.some((item) => item.type === "JSXSpreadAttribute"))
236
+ return false;
237
+ const direct = element.children.filter((child) => child.type === "JSXExpressionContainer" && parameterChildren(owner, child.expression)).length +
238
+ attributes.filter((item) => item.type === "JSXAttribute" && item.name.name === "children" && parameterChildren(owner, attributeValue(item)))
239
+ .length;
240
+ let all = 0;
241
+ walk(owner.context.sourceCode, element, (node) => {
242
+ if (parameterChildren(owner, node))
243
+ all += 1;
244
+ });
245
+ return direct === 1 && all === 1;
246
+ };
247
+ return (entryRoots.every((element) => directContent(entry, element)) &&
248
+ pages.every((page) => preservesPageBody(drawing, page, { resolve: resolvedOwner, returned })));
249
+ };
250
+ /** Feature composition may use its own pure half, never a second feature/block relay. */
251
+ const featureRoots = (owner) => {
252
+ const entry = resolvedOwner(owner);
253
+ if (!entry)
254
+ return null;
255
+ const drawing = ownDrawing(entry) ?? entry;
256
+ const elements = roots(drawing);
257
+ return elements ? { owner: drawing, roots: elements } : null;
258
+ };
259
+ /** Route files are selected by the HFS route role and imported selected export. */
260
+ const mountedFeatures = (context, directory) => {
261
+ if (!fs.existsSync(directory))
262
+ return [];
263
+ const features = [];
264
+ for (const item of fs.readdirSync(directory, { withFileTypes: true })) {
265
+ const filename = path.join(directory, item.name);
266
+ if (item.isDirectory()) {
267
+ features.push(...mountedFeatures(context, filename));
268
+ continue;
269
+ }
270
+ if (!item.isFile())
271
+ continue;
272
+ const classification = hfsOf(context).classify(filename);
273
+ if (classification.status !== "owned" || classification.slot !== "fe.route" || !["page", "layout"].includes(classification.role ?? ""))
274
+ continue;
275
+ const route = parsedSource(context, filename);
276
+ const owner = resolvedOwner(headerExport(route, "default"));
277
+ if (!owner)
278
+ continue;
279
+ const elements = roots(owner);
280
+ if (!elements)
281
+ continue;
282
+ for (const element of elements) {
283
+ const feature = resolvedOwner(importedHeader(route, element.openingElement));
284
+ if (!feature)
285
+ continue;
286
+ const found = hfsOf(context).classify(feature.context.filename);
287
+ const expected = classification.role === "layout" ? "layouts" : "pages";
288
+ if (found.status === "owned" && found.slot === "fe.feature" && found.kind === expected && found.role === "entry")
289
+ features.push(feature);
290
+ }
291
+ }
292
+ return features;
293
+ };
294
+ /**
295
+ * Grant only the actual input type member of a proven mounted root and its twin.
296
+ * A nearby unrelated type declaration never inherits the proof.
297
+ */
298
+ const inputMembers = (owner) => {
299
+ if (!isFn(owner.value))
300
+ return [];
301
+ const parameter = owner.value.params[0];
302
+ const annotation = parameter && "typeAnnotation" in parameter ? parameter.typeAnnotation?.typeAnnotation : null;
303
+ const seen = new Set();
304
+ const read = (node) => {
305
+ if (!node || seen.has(node))
306
+ return [];
307
+ seen.add(node);
308
+ if (node.type === "TSTypeLiteral")
309
+ return node.members;
310
+ if (node.type === "TSInterfaceBody")
311
+ return node.body;
312
+ if (node.type === "TSIntersectionType")
313
+ return node.types.flatMap(read);
314
+ if (node.type !== "TSTypeReference" || node.typeName.type !== "Identifier")
315
+ return [];
316
+ const definition = variableOf(owner.context, node.typeName)?.defs[0];
317
+ if (definition?.type !== "Type")
318
+ return [];
319
+ if (definition.node.type === "TSTypeAliasDeclaration")
320
+ return read(definition.node.typeAnnotation);
321
+ if (definition.node.type === "TSInterfaceDeclaration")
322
+ return read(definition.node.body);
323
+ return [];
324
+ };
325
+ return read(annotation);
326
+ };
327
+ /** Each source-code lifetime gets one mounted-owner proof; no process-wide stale app cache. */
328
+ const childrenProofs = new WeakMap();
329
+ export const rootPageChildren = (context, member) => {
330
+ let proven = childrenProofs.get(context.sourceCode);
331
+ if (!proven) {
332
+ const result = new Set();
333
+ childrenProofs.set(context.sourceCode, result);
334
+ const found = hfsOf(context).classify(context.filename);
335
+ if (found.status !== "owned" || found.slot !== "fe.components" || found.kind !== "blocks" || !["entry", "drawing"].includes(found.role ?? ""))
336
+ return false;
337
+ if (!fs.existsSync(context.filename))
338
+ return false;
339
+ const current = parsedSource(context, context.filename);
340
+ if (current.sourceCode.text !== context.sourceCode.text)
341
+ return false;
342
+ const entryFile = found.role === "entry" ? context.filename : path.join(path.dirname(context.filename), "index.tsx");
343
+ if (!fs.existsSync(entryFile))
344
+ return false;
345
+ const entryContext = found.role === "entry" ? current : parsedSource(context, entryFile);
346
+ const entries = entryContext.program.body
347
+ .filter((statement) => statement.type === "ExportNamedDeclaration")
348
+ .flatMap((statement) => {
349
+ const declaration = statement.declaration;
350
+ const names = declaration?.type === "VariableDeclaration"
351
+ ? declaration.declarations.flatMap((item) => (item.id.type === "Identifier" ? [item.id.name] : []))
352
+ : declaration?.type === "FunctionDeclaration" && declaration.id
353
+ ? [declaration.id.name]
354
+ : statement.specifiers.map((specifier) => specifier.exported.type === "Identifier" ? specifier.exported.name : specifier.exported.value);
355
+ return names.map((name) => resolvedOwner(headerExport(entryContext, name)));
356
+ })
357
+ .filter((owner) => owner !== null);
358
+ for (const entry of entries) {
359
+ const drawing = ownDrawing(entry);
360
+ if (!drawing || !carriesChildren(entry, drawing))
361
+ continue;
362
+ const app = found.bindings?.app;
363
+ const appSlot = hfsOf(context).slot("fe.app.next");
364
+ if (!app || !appSlot)
365
+ continue;
366
+ const prefix = appSlot.path.split("/{")[0]?.replace("<app>", app);
367
+ if (!prefix)
368
+ continue;
369
+ const mounted = mountedFeatures(context, path.join(hfsOf(context).repoRoot, prefix, "src/app"));
370
+ let selected = false;
371
+ const composed = mounted.every((feature) => {
372
+ const composition = featureRoots(feature);
373
+ if (!composition) {
374
+ const target = ownDrawing(feature) ?? feature;
375
+ let usesEntry = false;
376
+ walk(target.context.sourceCode, target.value, (node) => {
377
+ if (node.type !== "JSXElement")
378
+ return;
379
+ const block = resolvedOwner(importedHeader(target.context, node.openingElement));
380
+ if (block && sameOwner(block, entry))
381
+ usesEntry = true;
382
+ });
383
+ return !usesEntry;
384
+ }
385
+ let usesEntry = false;
386
+ let nestedEntry = false;
387
+ for (const root of composition.roots)
388
+ walk(composition.owner.context.sourceCode, root, (node) => {
389
+ if (node.type !== "JSXElement")
390
+ return;
391
+ const block = resolvedOwner(importedHeader(composition.owner.context, node.openingElement));
392
+ if (!block || !sameOwner(block, entry))
393
+ return;
394
+ usesEntry = true;
395
+ if (node !== root)
396
+ nestedEntry = true;
397
+ });
398
+ if (!usesEntry)
399
+ return true;
400
+ selected = true;
401
+ if (nestedEntry)
402
+ return false;
403
+ const valid = composition.roots.every((root) => {
404
+ const page = connectedPageDrawing(composition.owner.context, root.openingElement);
405
+ const ownsPage = isPageRenderer(composition.owner.context, root.openingElement) || page !== null;
406
+ const layout = hfsOf(context).classify(feature.context.filename).kind === "layouts";
407
+ return ownsPage && (!layout || (page !== null && segmentDrawing(page)));
408
+ });
409
+ return valid;
410
+ });
411
+ if (!composed || !selected)
412
+ continue;
413
+ const local = found.role === "entry" ? entry : drawing;
414
+ if (path.resolve(local.context.filename) !== path.resolve(context.filename))
415
+ continue;
416
+ for (const input of inputMembers(local))
417
+ if (input.type === "TSPropertySignature" && input.key.type === "Identifier" && input.key.name === "children")
418
+ result.add(input.range[0]);
419
+ }
420
+ proven = result;
421
+ }
422
+ return proven.has(member.range[0]);
423
+ };
@@ -13,11 +13,11 @@ export declare const noPublicFrameCssProps: import("./lib/rule.js").Rule<"css",
13
13
  /** Prevent utility types from reintroducing public styling doors. */
14
14
  export declare const noCssDoorTypeLaundering: import("./lib/rule.js").Rule<"utility", []>;
15
15
  /** Props are readonly all the way down: every field, index signature, nested object and collection (FE-TYPING-2). */
16
- export declare const propsFieldsReadonly: import("./lib/rule.js").Rule<"index" | "field" | "collection", []>;
16
+ export declare const propsFieldsReadonly: import("./lib/rule.js").Rule<"field" | "index" | "collection", []>;
17
17
  /**
18
18
  * Children belong to frames. A frame owns a region and draws what its caller places in it; a leaf's only child is its text label; a
19
- * block, a composite and a branch draw a fixed anatomy from fields and named slots, so nested JSX under them is a type error for every
20
- * caller (`children?: never`). The tier is the layer folder of the unit (FE-FUNCTION-2).
19
+ * composite and branch draw fixed anatomy from named fields. A block receives atoms and takes children only when its mounted
20
+ * one-level RootPage composition is proved; ordinary blocks declare `children?: never` (FE-FUNCTION-2).
21
21
  */
22
22
  export declare const tierChildren: import("./lib/rule.js").Rule<"leaf" | "none" | "withChildren", []>;
23
23
  export declare const rules: Rules;
@@ -1,5 +1,6 @@
1
1
  /** Rules for named React props, ordinary children, and local visual ownership. */
2
2
  import { defineRule } from "./lib/rule.js";
3
+ import { rootPageChildren } from "./lib/root-page.js";
3
4
  import { declarationsOf, fileOf as declarationFile } from "./lib/types.js";
4
5
  import { isComponentFile, isComponentPath, isProductSource, kindOfFile } from "./lib/scope.js";
5
6
  /** The key of a node that names a member (`key`), or the property of a member access. */
@@ -309,16 +310,16 @@ export const propsFieldsReadonly = defineRule({
309
310
  });
310
311
  /**
311
312
  * Children belong to frames. A frame owns a region and draws what its caller places in it; a leaf's only child is its text label; a
312
- * block, a composite and a branch draw a fixed anatomy from fields and named slots, so nested JSX under them is a type error for every
313
- * caller (`children?: never`). The tier is the layer folder of the unit (FE-FUNCTION-2).
313
+ * composite and branch draw fixed anatomy from named fields. A block receives atoms and takes children only when its mounted
314
+ * one-level RootPage composition is proved; ordinary blocks declare `children?: never` (FE-FUNCTION-2).
314
315
  */
315
316
  export const tierChildren = defineRule({
316
317
  meta: {
317
318
  type: "problem",
318
- docs: { description: "Only a frame takes children; a leaf takes its text label; blocks, composites and branches take none." },
319
+ docs: { description: "A frame takes children, a leaf its text label; a block takes children only as the proven one-level connected root of the route Page." },
319
320
  schema: [],
320
321
  messages: {
321
- none: "A {{layer}} unit takes no children: name the slot for its role, or move the unit to components/frames when its job is to host content.",
322
+ none: "A {{layer}} unit takes no children. A block receives atoms; only the proven one-level connected root of the route Page receives owner content. A pure region host belongs in components/frames.",
322
323
  leaf: "A leaf takes its text label as children: string, never a node: name the slot for the node, or move the unit to components/frames.",
323
324
  withChildren: "PropsWithChildren gives a {{layer}} unit nested JSX: declare children?: never, or children: string for a leaf.",
324
325
  },
@@ -335,7 +336,7 @@ export const tierChildren = defineRule({
335
336
  if (propertyName(node) !== "children")
336
337
  return;
337
338
  const type = node.typeAnnotation?.typeAnnotation;
338
- if (type?.type === "TSNeverKeyword" || (text && type?.type === "TSStringKeyword"))
339
+ if (type?.type === "TSNeverKeyword" || (text && type?.type === "TSStringKeyword") || (layer === "blocks" && rootPageChildren(context, node)))
339
340
  return;
340
341
  context.report({ node, messageId: text ? "leaf" : "none", data: { layer: layer.replace(/s$/, "") } });
341
342
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starci/eslint-canon-fe",
3
- "version": "9.9.7",
3
+ "version": "9.9.9",
4
4
  "description": "Universal front-end lint laws for TypeScript and Next.js, independent of the selected Grammar family.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -68,6 +68,18 @@ families:
68
68
  package: '@starci/grammar'
69
69
  entry: '@starci/grammar/common'
70
70
  styleEntry: '@starci/grammar/common.css'
71
+ semanticRoles:
72
+ page:
73
+ export: Page
74
+ entries: ['@starci/grammar/common', '@starci/grammar/<family>']
75
+ segment: { attribute: header, member: tabs }
76
+ bodyContainers: [PaneGrid, Pane, Stack, PaneSection, Section]
77
+ # Native body transparency follows the renderer's exact context, status and intrinsic-element API.
78
+ bodyContainerConstraints:
79
+ Pane: { providesContext: pane }
80
+ Stack: { literalProps: { as: [div, header, footer] } }
81
+ PaneSection: { requiresContext: pane, absentProps: [error], literalProps: { as: [div, header, footer] } }
82
+ Section: { absentProps: [error], literalProps: { kind: [region] } }
71
83
  peers: [react, '@heroui/react', '@xyflow/react', react-aria-components]
72
84
  packageSnapshot: common/DNA.yaml
73
85
  scope: |
@@ -31,35 +31,35 @@ 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.21.7
34
+ version: 0.21.9
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.14
41
+ version: 3.11.16
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.9.7
48
+ version: 9.9.9
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.21.7
55
+ version: 0.21.9
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.21.7
62
+ version: 0.21.9
63
63
  group: starci
64
64
  install: registry
65
65
  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: 5.0.3
109
+ version: 5.0.5
110
110
  group: starci
111
111
  install: registry
112
112
  side: both
@@ -152,12 +152,16 @@ function nestedDataObjects(ts, expression, bindingOf, excluded) {
152
152
  nestedDataObjects(ts, element, bindingOf, excluded);
153
153
  }
154
154
  }
155
+ /** The one app status-owner binding of FE-ERROR-1, shared by clone and body-flow proofs. */
156
+ export function canonicalStatusOwnerBinding(module, name) {
157
+ return name === 'SlotView' && module !== null && /(?:^|\/)components\/composites\/SlotView(?:\/index)?(?:\.[cm]?tsx?)?$/.test(module);
158
+ }
155
159
  /** Resolve the canonical status owner through its import binding, including aliases and namespaces. */
156
160
  function statusOwner(ts, tag, bindingOf) {
157
161
  const binding = ts.isIdentifier(tag) ? bindingOf(tag) : ts.isPropertyAccessExpression(tag) && ts.isIdentifier(tag.expression) ? bindingOf(tag.expression) : null;
158
162
  const module = binding?.kind === 'component' || binding?.kind === 'namespace' ? binding.module : null;
159
163
  const name = binding?.kind === 'component' ? binding.name : ts.isPropertyAccessExpression(tag) ? tag.name.text : null;
160
- return name === 'SlotView' && module !== null && /(?:^|\/)components\/composites\/SlotView(?:\/index)?(?:\.[cm]?tsx?)?$/.test(module);
164
+ return canonicalStatusOwnerBinding(module, name);
161
165
  }
162
166
  /** The mandated status wiring breaks clone windows; its render body remains ordinary compared code. */
163
167
  function statusRecipe(ts, opening, bindingOf, excluded) {