@starci/eslint-canon-fe 9.9.6 → 9.9.8
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 +9 -0
- package/README.md +6 -0
- package/dist/drawing.d.ts +2 -2
- package/dist/drawing.js +7 -4
- package/dist/hooks-folder.d.ts +1 -1
- package/dist/lib/cross-file-inputs.d.ts +0 -1
- package/dist/lib/cross-file-inputs.js +7 -0
- package/dist/lib/eslint-boundary.d.ts +4 -2
- package/dist/lib/eslint-boundary.js +2 -0
- package/dist/lib/owner-source.d.ts +26 -0
- package/dist/lib/owner-source.js +187 -0
- package/dist/lib/page-body.d.ts +17 -0
- package/dist/lib/page-body.js +209 -0
- package/dist/lib/root-page.d.ts +8 -0
- package/dist/lib/root-page.js +423 -0
- package/dist/props-and-slots.d.ts +3 -3
- package/dist/props-and-slots.js +6 -5
- package/package.json +1 -1
- package/runtime/knowledge/grammars/index.yaml +2 -0
- package/runtime/knowledge/hfs/canon-pins.yaml +6 -6
- package/runtime/scripts/hfs/architecture/canon-jsx-clones.mjs +5 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 9.9.8
|
|
4
|
+
|
|
5
|
+
- 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.
|
|
6
|
+
- Fixed: Virtual RuleTester files refuse the RootPage children capability before disk parsing; unsaved content keeps the on-disk source identity guard.
|
|
7
|
+
|
|
8
|
+
## 9.9.7
|
|
9
|
+
|
|
10
|
+
- 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.
|
|
11
|
+
|
|
3
12
|
## 9.9.6
|
|
4
13
|
|
|
5
14
|
- Changed: Align bundled canon pins and exact sibling requirements with Grammar and its lint families at 0.21.6. Runtime behavior and rule severity are unchanged.
|
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
|
|
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" | "
|
|
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
|
|
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;
|
|
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({
|
package/dist/hooks-folder.d.ts
CHANGED
|
@@ -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" | "
|
|
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,209 @@
|
|
|
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, 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) => {
|
|
32
|
+
const family = hfsOf(owner.context).grammar;
|
|
33
|
+
if (!family)
|
|
34
|
+
return false;
|
|
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 false;
|
|
39
|
+
const imported = jsxImport(owner.context, opening);
|
|
40
|
+
return (imported !== null &&
|
|
41
|
+
page.bodyContainers.includes(imported.exported) &&
|
|
42
|
+
page.entries.some((entry) => typeof entry === "string" && entry.replace("<family>", family) === imported.source));
|
|
43
|
+
};
|
|
44
|
+
/** The exactly returned fragment/expression, with no unused or duplicated call hidden beside it. */
|
|
45
|
+
const returnedCall = (node) => {
|
|
46
|
+
const value = unwrap(node);
|
|
47
|
+
if (value.type === "CallExpression")
|
|
48
|
+
return value;
|
|
49
|
+
if (value.type !== "JSXFragment")
|
|
50
|
+
return null;
|
|
51
|
+
const children = value.children.filter((child) => child.type !== "JSXText" || child.value.trim() !== "");
|
|
52
|
+
const expression = children.length === 1 && children[0]?.type === "JSXExpressionContainer" ? children[0].expression : null;
|
|
53
|
+
return expression?.type === "CallExpression" ? expression : null;
|
|
54
|
+
};
|
|
55
|
+
/** Prove the selected SlotView implementation really calls render on loading/ready, and never on refusal. */
|
|
56
|
+
const canonicalRegion = (owner, readers) => {
|
|
57
|
+
const found = hfsOf(owner.context).classify(owner.context.filename);
|
|
58
|
+
if (found.status !== "owned" || found.slot !== "fe.components" || found.kind !== "composites" || found.role !== "entry")
|
|
59
|
+
return false;
|
|
60
|
+
if (!isFn(owner.value) || owner.value.body.type !== "BlockStatement")
|
|
61
|
+
return false;
|
|
62
|
+
const body = owner.value.body.body;
|
|
63
|
+
const active = body.filter((statement) => statement.type !== "VariableDeclaration");
|
|
64
|
+
if (active.length !== 5 || active.slice(0, 4).some((statement) => statement.type !== "IfStatement" || statement.alternate !== null))
|
|
65
|
+
return false;
|
|
66
|
+
const states = ["isLoading", "isForbidden", "isError", "isEmpty"];
|
|
67
|
+
let slot = null;
|
|
68
|
+
for (const [index, statement] of active.slice(0, 4).entries()) {
|
|
69
|
+
if (statement.type !== "IfStatement" ||
|
|
70
|
+
statement.test.type !== "BinaryExpression" ||
|
|
71
|
+
statement.test.operator !== "in" ||
|
|
72
|
+
statement.test.left.type !== "Literal" ||
|
|
73
|
+
statement.test.left.value !== states[index] ||
|
|
74
|
+
statement.test.right.type !== "Identifier")
|
|
75
|
+
return false;
|
|
76
|
+
if (slot && variableOf(owner.context, slot) !== variableOf(owner.context, statement.test.right))
|
|
77
|
+
return false;
|
|
78
|
+
slot = statement.test.right;
|
|
79
|
+
}
|
|
80
|
+
if (!slot)
|
|
81
|
+
return false;
|
|
82
|
+
const slotBinding = variableOf(owner.context, slot);
|
|
83
|
+
const definition = slotBinding?.defs[0];
|
|
84
|
+
const parameter = owner.value.params[0];
|
|
85
|
+
if (definition?.type !== "Variable" ||
|
|
86
|
+
definition.parent.kind !== "const" ||
|
|
87
|
+
definition.node.id.type !== "ObjectPattern" ||
|
|
88
|
+
definition.node.init?.type !== "Identifier" ||
|
|
89
|
+
parameter?.type !== "Identifier" ||
|
|
90
|
+
variableOf(owner.context, definition.node.init)?.defs.some((item) => item.type === "Parameter" && item.name === parameter) !== true)
|
|
91
|
+
return false;
|
|
92
|
+
const slotProperty = definition.node.id.properties.find((property) => property.type === "Property" && property.key.type === "Identifier" && property.key.name === "slot");
|
|
93
|
+
if (slotProperty?.type !== "Property" || slotProperty.value.type !== "Identifier" || variableOf(owner.context, slotProperty.value) !== slotBinding)
|
|
94
|
+
return false;
|
|
95
|
+
const loading = active[0];
|
|
96
|
+
const ready = active[4];
|
|
97
|
+
if (loading?.type !== "IfStatement" ||
|
|
98
|
+
loading.consequent.type !== "ReturnStatement" ||
|
|
99
|
+
!loading.consequent.argument ||
|
|
100
|
+
ready?.type !== "ReturnStatement" ||
|
|
101
|
+
!ready.argument)
|
|
102
|
+
return false;
|
|
103
|
+
const waitingCall = returnedCall(loading.consequent.argument);
|
|
104
|
+
const readyCall = returnedCall(ready.argument);
|
|
105
|
+
if (!waitingCall || !readyCall)
|
|
106
|
+
return false;
|
|
107
|
+
const renderField = { owner, field: "render" };
|
|
108
|
+
const validCall = (call, waiting) => {
|
|
109
|
+
if (!parameterMember(renderField, call.callee) ||
|
|
110
|
+
call.arguments.length !== 2 ||
|
|
111
|
+
call.arguments[1]?.type !== "Literal" ||
|
|
112
|
+
call.arguments[1].value !== waiting)
|
|
113
|
+
return false;
|
|
114
|
+
const data = call.arguments[0];
|
|
115
|
+
if (!data)
|
|
116
|
+
return false;
|
|
117
|
+
if (waiting)
|
|
118
|
+
return parameterMember({ owner, field: "placeholder" }, data);
|
|
119
|
+
return (data.type === "MemberExpression" &&
|
|
120
|
+
!data.computed &&
|
|
121
|
+
data.property.type === "Identifier" &&
|
|
122
|
+
data.property.name === "items" &&
|
|
123
|
+
data.object.type === "Identifier" &&
|
|
124
|
+
variableOf(owner.context, data.object) === slotBinding);
|
|
125
|
+
};
|
|
126
|
+
if (!validCall(waitingCall, true) || !validCall(readyCall, false))
|
|
127
|
+
return false;
|
|
128
|
+
let calls = 0;
|
|
129
|
+
walk(owner.context.sourceCode, owner.value.body, (node) => {
|
|
130
|
+
if (node.type === "CallExpression" && parameterMember(renderField, node.callee))
|
|
131
|
+
calls += 1;
|
|
132
|
+
});
|
|
133
|
+
const live = readers.returned(owner);
|
|
134
|
+
return calls === 2 && live !== null && live.includes(loading.consequent.argument) && live.includes(ready.argument);
|
|
135
|
+
};
|
|
136
|
+
/** A local pure helper is followed by its binding; imported helpers must be actual lower owners. */
|
|
137
|
+
const helperOwner = (content, opening, readers) => {
|
|
138
|
+
if (opening.name.type === "JSXIdentifier") {
|
|
139
|
+
const variable = variableOf(content.owner.context, opening.name);
|
|
140
|
+
const definition = variable?.defs[0];
|
|
141
|
+
const value = definition?.type === "Variable" && definition.parent.kind === "const"
|
|
142
|
+
? definition.node.init
|
|
143
|
+
: definition?.type === "FunctionName"
|
|
144
|
+
? definition.node
|
|
145
|
+
: null;
|
|
146
|
+
if (value)
|
|
147
|
+
return readers.resolve({ context: content.owner.context, value });
|
|
148
|
+
}
|
|
149
|
+
const imported = readers.resolve(importedHeader(content.owner.context, opening));
|
|
150
|
+
if (!imported)
|
|
151
|
+
return null;
|
|
152
|
+
const found = hfsOf(content.owner.context).classify(imported.context.filename);
|
|
153
|
+
return found.status === "owned" && found.slot === "fe.components" && ["frames", "composites", "branches", "leaves"].includes(found.kind ?? "")
|
|
154
|
+
? imported
|
|
155
|
+
: null;
|
|
156
|
+
};
|
|
157
|
+
/** A finite path must consume the content exactly once; unknown JSX/calls/branches fail closed. */
|
|
158
|
+
const rendered = (content, node, readers, seen) => {
|
|
159
|
+
if (parameterMember(content, node))
|
|
160
|
+
return true;
|
|
161
|
+
if (node.type === "JSXExpressionContainer")
|
|
162
|
+
return rendered(content, node.expression, readers, seen);
|
|
163
|
+
if (node.type === "JSXFragment")
|
|
164
|
+
return renderedChildren(content, node.children, readers, seen);
|
|
165
|
+
if (node.type !== "JSXElement" || node.openingElement.attributes.some((item) => item.type === "JSXSpreadAttribute"))
|
|
166
|
+
return false;
|
|
167
|
+
const imported = jsxImport(content.owner.context, node.openingElement);
|
|
168
|
+
if (imported && canonicalStatusOwnerBinding(imported.source, imported.exported)) {
|
|
169
|
+
const status = readers.resolve(importedHeader(content.owner.context, node.openingElement));
|
|
170
|
+
const callback = attributeValue(attribute(node.openingElement, "render"));
|
|
171
|
+
if (!status || !canonicalRegion(status, readers) || !callback || !isFn(callback))
|
|
172
|
+
return false;
|
|
173
|
+
if (references(content, node) !== 1 || references(content, callback) !== 1)
|
|
174
|
+
return false;
|
|
175
|
+
const values = readers.returned({ ...content.owner, value: callback });
|
|
176
|
+
return values !== null && values.length > 0 && values.every((value) => parameterMember(content, value));
|
|
177
|
+
}
|
|
178
|
+
if (bodyContainer(content.owner, node.openingElement)) {
|
|
179
|
+
if (node.openingElement.attributes.some((item) => references(content, item) > 0))
|
|
180
|
+
return false;
|
|
181
|
+
return renderedChildren(content, node.children, readers, seen);
|
|
182
|
+
}
|
|
183
|
+
const helper = helperOwner(content, node.openingElement, readers);
|
|
184
|
+
if (!helper || seen.has(helper.value))
|
|
185
|
+
return false;
|
|
186
|
+
const carrying = node.openingElement.attributes.filter((item) => references(content, item) > 0);
|
|
187
|
+
if (carrying.length !== 1 || references(content, node) !== 1)
|
|
188
|
+
return false;
|
|
189
|
+
const input = carrying[0];
|
|
190
|
+
if (input?.type !== "JSXAttribute" || input.name.type !== "JSXIdentifier" || !parameterMember(content, attributeValue(input)))
|
|
191
|
+
return false;
|
|
192
|
+
const target = { owner: helper, field: input.name.name };
|
|
193
|
+
const values = readers.returned(helper);
|
|
194
|
+
return (references(target, helper.value) === 1 &&
|
|
195
|
+
values !== null &&
|
|
196
|
+
values.length > 0 &&
|
|
197
|
+
values.every((value) => rendered(target, value, readers, new Set(seen).add(helper.value))));
|
|
198
|
+
};
|
|
199
|
+
const renderedChildren = (content, children, readers, seen) => {
|
|
200
|
+
const carrying = children.filter((child) => references(content, child) > 0);
|
|
201
|
+
return carrying.length === 1 && carrying[0] !== undefined && rendered(content, carrying[0], readers, seen);
|
|
202
|
+
};
|
|
203
|
+
/** Only Page's body carries owner content; a header/unknown attribute/dead callback cannot stand in for it. */
|
|
204
|
+
export const preservesPageBody = (owner, page, readers) => {
|
|
205
|
+
const content = { owner, field: "children" };
|
|
206
|
+
if (references(content, page) !== 1 || page.openingElement.attributes.some((item) => references(content, item) > 0))
|
|
207
|
+
return false;
|
|
208
|
+
return renderedChildren(content, page.children, readers, new Set([owner.value]));
|
|
209
|
+
};
|
|
@@ -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<"
|
|
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
|
-
*
|
|
20
|
-
*
|
|
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;
|
package/dist/props-and-slots.js
CHANGED
|
@@ -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
|
-
*
|
|
313
|
-
*
|
|
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: "
|
|
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
|
|
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
|
@@ -68,6 +68,8 @@ families:
|
|
|
68
68
|
package: '@starci/grammar'
|
|
69
69
|
entry: '@starci/grammar/common'
|
|
70
70
|
styleEntry: '@starci/grammar/common.css'
|
|
71
|
+
semanticRoles:
|
|
72
|
+
page: { export: Page, entries: ['@starci/grammar/common', '@starci/grammar/<family>'], segment: { attribute: header, member: tabs }, bodyContainers: [PaneGrid, Pane] }
|
|
71
73
|
peers: [react, '@heroui/react', '@xyflow/react', react-aria-components]
|
|
72
74
|
packageSnapshot: common/DNA.yaml
|
|
73
75
|
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.
|
|
34
|
+
version: 0.21.8
|
|
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.15
|
|
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.
|
|
48
|
+
version: 9.9.8
|
|
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.
|
|
55
|
+
version: 0.21.8
|
|
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.
|
|
62
|
+
version: 0.21.8
|
|
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.
|
|
109
|
+
version: 5.0.4
|
|
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
|
|
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) {
|