@starci/eslint-canon-fe 8.4.40 → 8.4.42
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 +6 -1
- package/README.md +4 -0
- package/app-shell.mjs +161 -0
- package/docs/app-shell.md +50 -0
- package/index.mjs +2 -0
- package/lib/app-shell-cluster.mjs +66 -0
- package/lib/app-shell-create.mjs +33 -0
- package/lib/app-shell-owner-source.mjs +124 -0
- package/lib/app-shell-owner.mjs +65 -0
- package/lib/app-shell-scope.mjs +79 -0
- package/lib/app-shell-visible.mjs +92 -0
- package/lib/app-shell.mjs +148 -0
- package/lib/why.mjs +25 -0
- package/package.json +1 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +3 -3
- package/runtime/knowledge/hfs/slots.yaml +8 -6
- package/runtime/modules/goal/source-phrases.yaml +95 -0
- package/runtime/scripts/hfs/architecture/typescript.mjs +1 -1
- package/runtime/scripts/hfs/declaration-slots.mjs +6 -4
- package/runtime/scripts/hfs/rule-params-shape.mjs +5 -3
- package/runtime/scripts/hfs/slot-side-problems.mjs +2 -2
- package/runtime/scripts/hfs/slots.mjs +1 -1
- package/runtime/scripts/lib/secret-patterns.mjs +58 -9
- package/runtime/scripts/lib/source-phrases.mjs +34 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## 8.4.42 - 2026-10-26
|
|
4
|
+
- Changed: the bundled canon pins follow back-end canon 3.4.42.
|
|
5
|
+
|
|
6
|
+
## 8.4.41 - 2026-10-26
|
|
7
|
+
|
|
8
|
+
- Enforce the app-shell canon through fixed WorkspaceShell frames, drawer navigation, ordered navbar controls, control sizing and page-owned creation, with full and lite regression coverage.
|
|
4
9
|
- Fixed: `operations-only-in-swr-hooks` (FE_OP_OUTSIDE_SWR_HOOK) no longer reports named imports that all resolve, through relative re-exports, to a generated `__generated__/input-bounds` module (`INPUT_BOUNDS`, `inputBoundsIssues`): they are contract data, not API operations. Mixed, default and namespace imports of the barrel still report.
|
|
5
10
|
- Added: the stream reader of server-sent events (`fe.transport.stream`, `modules/api/sse/clients/`) is part of the API layer, so it may call `fetch` (FE_TRANSPORT_OWNER); a stream operation (`sse/<area>/stream-<channel>.ts`) must import it.
|
|
6
11
|
- Added: `useSWRSubscription` (`swr/subscription`) is an SWR request hook of the family (FE_SWR_OUTSIDE_FAMILY, FE_OP_OUTSIDE_SWR_HOOK, FE_HOOKS_ARE_HOOKS): a stream operation is consumed only by `hooks/swr/api/sse/subscriptions/useSubscribe<X>Swr.ts`.
|
package/README.md
CHANGED
|
@@ -32,6 +32,10 @@ exported; `why[rule]` is `{ code, vi, fixVi }`, the finding code and the Vietnam
|
|
|
32
32
|
|
|
33
33
|
## Rules
|
|
34
34
|
|
|
35
|
+
The [app-shell](docs/app-shell.md) law holds the fixed `WorkspaceShell` frame, mobile drawer navigation,
|
|
36
|
+
navbar action kinds and order, shared bar control size, and page ownership of create actions. Its
|
|
37
|
+
rules are gathered into the normal FE lint and application gate.
|
|
38
|
+
|
|
35
39
|
Each law has a page under [`docs/`](docs) with, for every rule, an invalid and a valid example and
|
|
36
40
|
its Vietnamese why. New in 5.0: [env-owner](docs/env-owner.md), [transport](docs/transport.md),
|
|
37
41
|
[client-boundary](docs/client-boundary.md), [hooks-folder](docs/hooks-folder.md),
|
package/app-shell.mjs
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/** FE-APP-SHELL-1..5: static app-frame and visible navbar composition from the UI canon. */
|
|
2
|
+
import { isProductSource } from "./lib/scope.mjs"
|
|
3
|
+
import { grammarName, isBarControl, propOf, resolveLocal, semanticText, textProp } from "./lib/app-shell.mjs"
|
|
4
|
+
import { appBarOpenings, isAppBar, isAppShell, isNavigationDrawer, ownsAppFrame } from "./lib/app-shell-scope.mjs"
|
|
5
|
+
import { visitVisible } from "./lib/app-shell-visible.mjs"
|
|
6
|
+
import { clusterAlternatives } from "./lib/app-shell-cluster.mjs"
|
|
7
|
+
import { isCreateAction, menuHasCreate } from "./lib/app-shell-create.mjs"
|
|
8
|
+
import { provesImportedBar } from "./lib/app-shell-owner.mjs"
|
|
9
|
+
import { variableOf } from "./lib/bindings.mjs"
|
|
10
|
+
|
|
11
|
+
const CANON = "knowledge/ui/composition/app-shell.yaml"
|
|
12
|
+
const BOTTOM_TABS = /^(?:BottomNav|BottomNavigation|BottomTabBar|MobileBottomNav|MobileTabBar)$/
|
|
13
|
+
const EXPECTED_CLUSTER = ["language:IconButton", "theme:Switch", "notifications:IconButton", "account:AvatarButton"]
|
|
14
|
+
|
|
15
|
+
const meta = (code, description, messages) => ({
|
|
16
|
+
type: "problem",
|
|
17
|
+
docs: { description: `${code}: ${description}`, url: CANON },
|
|
18
|
+
schema: [],
|
|
19
|
+
messages,
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
/** The app frame is the fixed-viewport Grammar shell; hand-written frame landmarks are rejected. */
|
|
23
|
+
export const appShellFrame = {
|
|
24
|
+
meta: meta("FE_APP_SHELL_FRAME", "FE-APP-SHELL-1 uses WorkspaceShell viewport=fixed.", {
|
|
25
|
+
frame: "FE_APP_SHELL_FRAME: compose the app frame with WorkspaceShell viewport=\"fixed\"; Grammar owns its header, navigation and independent scroll regions.",
|
|
26
|
+
}),
|
|
27
|
+
create(context) {
|
|
28
|
+
if (!isProductSource(context)) return {}
|
|
29
|
+
return { JSXOpeningElement(node) {
|
|
30
|
+
const wrongViewport = isAppShell(context, node) && textProp(context, node, "viewport") !== "fixed"
|
|
31
|
+
const handFrame = ownsAppFrame(context, node) && (node.name.type === "JSXIdentifier" && ["header", "nav", "main"].includes(node.name.name) || isAppBar(context, node) && !appBarOpenings(context).has(node))
|
|
32
|
+
if (wrongViewport || handFrame) {
|
|
33
|
+
context.report({ node, messageId: "frame" })
|
|
34
|
+
}
|
|
35
|
+
} }
|
|
36
|
+
},
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Bottom navigation is not an alternative compact app-shell frame. */
|
|
40
|
+
export const noShellBottomTabs = {
|
|
41
|
+
meta: meta("FE_APP_SHELL_BOTTOM_TABS", "FE-APP-SHELL-4 keeps mobile navigation in the sidebar drawer.", {
|
|
42
|
+
bottom: "FE_APP_SHELL_BOTTOM_TABS: the app shell uses a mobile subnav opener and Sidebar drawer. Remove bottom tab navigation and WorkspaceShell compactNavigation.",
|
|
43
|
+
}),
|
|
44
|
+
create(context) {
|
|
45
|
+
if (!isProductSource(context)) return {}
|
|
46
|
+
return { JSXOpeningElement(node) {
|
|
47
|
+
const name = grammarName(context, node)
|
|
48
|
+
const compact = name === "WorkspaceShell" && propOf(context, node, "compactNavigation")
|
|
49
|
+
const value = compact && resolveLocal(context, compact.value)
|
|
50
|
+
const absent = value?.type === "Identifier" && value.name === "undefined" && !variableOf(context, value)?.defs.length
|
|
51
|
+
if (BOTTOM_TABS.test(name ?? "") || compact && !absent && (!compact.known || compact.value !== null || compact.present)) context.report({ node, messageId: "bottom" })
|
|
52
|
+
} }
|
|
53
|
+
},
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const visitBar = (context, node, visit) => {
|
|
57
|
+
const name = grammarName(context, node)
|
|
58
|
+
if (name === "WorkspaceShell" && isAppShell(context, node)) {
|
|
59
|
+
for (const slot of ["header", "compactHeader"]) visitVisible(context, propOf(context, node, slot).value, visit)
|
|
60
|
+
} else if (isAppBar(context, node)) {
|
|
61
|
+
visitVisible(context, node.parent, visit)
|
|
62
|
+
} else if (isNavigationDrawer(context, node)) {
|
|
63
|
+
visitVisible(context, propOf(context, node, "trigger").value, visit)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const delegatesImportedBar = (context, expression) => {
|
|
68
|
+
let imported = false
|
|
69
|
+
let controls = false
|
|
70
|
+
let unknown = false
|
|
71
|
+
visitVisible(context, expression, (opening, name) => {
|
|
72
|
+
if (isBarControl(context, opening, name)) controls = true
|
|
73
|
+
if (name !== null) return
|
|
74
|
+
const proof = provesImportedBar(context, opening)
|
|
75
|
+
if (proof === true) imported = true
|
|
76
|
+
if (proof === false) unknown = true
|
|
77
|
+
})
|
|
78
|
+
return imported && !controls && !unknown
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Every visible control in a navbar band uses the shared control size. */
|
|
82
|
+
export const navbarControlSize = {
|
|
83
|
+
meta: meta("FE_NAVBAR_CONTROL_SIZE", "FE-APP-SHELL-2 requires size=control on bar controls.", {
|
|
84
|
+
control: "FE_NAVBAR_CONTROL_SIZE: {{name}} in the navbar band needs size=\"control\" so every bar control shares Grammar's control height.",
|
|
85
|
+
}),
|
|
86
|
+
create(context) {
|
|
87
|
+
if (!isProductSource(context)) return {}
|
|
88
|
+
const reported = new Set()
|
|
89
|
+
return { JSXOpeningElement(node) {
|
|
90
|
+
visitBar(context, node, (control, name) => {
|
|
91
|
+
if (!isBarControl(context, control, name) || textProp(context, control, "size") === "control" || reported.has(control)) return
|
|
92
|
+
reported.add(control)
|
|
93
|
+
context.report({ node: control, messageId: "control", data: { name } })
|
|
94
|
+
})
|
|
95
|
+
} }
|
|
96
|
+
},
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The right cluster is checked at the bar's actions composition, in visual source order. */
|
|
100
|
+
export const navbarCluster = {
|
|
101
|
+
meta: meta("FE_NAVBAR_CLUSTER", "FE-APP-SHELL-3 fixes navbar action kinds and order.", {
|
|
102
|
+
cluster: "FE_NAVBAR_CLUSTER: navbar actions must be a tertiary Language IconButton in DropdownMenu, theme Switch, notification IconButton in Popover, account AvatarButton in DropdownMenu, in that order. Mobile retains notifications then account and moves language/theme into the drawer.",
|
|
103
|
+
}),
|
|
104
|
+
create(context) {
|
|
105
|
+
if (!isProductSource(context)) return {}
|
|
106
|
+
return { JSXOpeningElement(node) {
|
|
107
|
+
const name = grammarName(context, node)
|
|
108
|
+
if (!isAppShell(context, node) && !isAppBar(context, node)) return
|
|
109
|
+
const shellHeader = name === "WorkspaceShell"
|
|
110
|
+
const actions = propOf(context, node, shellHeader ? "header" : "actions")
|
|
111
|
+
const { alternatives, ownsBar } = clusterAlternatives(context, actions.value, shellHeader)
|
|
112
|
+
if (ownsBar) return
|
|
113
|
+
if (shellHeader && delegatesImportedBar(context, actions.value)) return
|
|
114
|
+
const desktop = EXPECTED_CLUSTER.join("|")
|
|
115
|
+
const mobile = EXPECTED_CLUSTER.slice(2).join("|")
|
|
116
|
+
const sequences = alternatives.map((sequence) => sequence.join("|"))
|
|
117
|
+
if (!sequences.includes(desktop) || sequences.some((sequence) => sequence !== desktop && sequence !== mobile)) context.report({ node, messageId: "cluster" })
|
|
118
|
+
} }
|
|
119
|
+
},
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Creation belongs to its page, never to the global app bar. */
|
|
123
|
+
export const noNavbarCreate = {
|
|
124
|
+
meta: meta("FE_NAVBAR_CREATE", "FE-APP-SHELL-5 keeps create actions on pages.", {
|
|
125
|
+
create: "FE_NAVBAR_CREATE: move this create action from the navbar to its page.",
|
|
126
|
+
}),
|
|
127
|
+
create(context) {
|
|
128
|
+
if (!isProductSource(context)) return {}
|
|
129
|
+
const reported = new Set()
|
|
130
|
+
const report = (node) => {
|
|
131
|
+
if (reported.has(node)) return
|
|
132
|
+
reported.add(node)
|
|
133
|
+
context.report({ node, messageId: "create" })
|
|
134
|
+
}
|
|
135
|
+
const inspect = (control, name) => {
|
|
136
|
+
if (name === "DropdownMenu" || name === "Popover") {
|
|
137
|
+
if (["entries", "items", "sections"].some((slot) => menuHasCreate(context, propOf(context, control, slot).value))) report(control)
|
|
138
|
+
visitVisible(context, propOf(context, control, "content").value, inspect)
|
|
139
|
+
for (const child of control.parent.children) visitVisible(context, child, inspect)
|
|
140
|
+
}
|
|
141
|
+
if (!isBarControl(context, control, name) || reported.has(control)) return
|
|
142
|
+
const text = `${semanticText(context, control)} ${control.parent.children.map((child) => context.sourceCode.getText(child)).join(" ")}`
|
|
143
|
+
if (isCreateAction(text)) report(control)
|
|
144
|
+
}
|
|
145
|
+
return { JSXOpeningElement(node) {
|
|
146
|
+
visitBar(context, node, inspect)
|
|
147
|
+
} }
|
|
148
|
+
},
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Every app-shell enforcer is gathered into the ordinary FE lint and gate path. */
|
|
152
|
+
export const rules = {
|
|
153
|
+
"app-shell-frame": appShellFrame,
|
|
154
|
+
"no-shell-bottom-tabs": noShellBottomTabs,
|
|
155
|
+
"navbar-control-size": navbarControlSize,
|
|
156
|
+
"navbar-cluster": navbarCluster,
|
|
157
|
+
"no-navbar-create": noNavbarCreate,
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Every rule is mandatory. */
|
|
161
|
+
export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# App shell
|
|
2
|
+
|
|
3
|
+
Owner: knowledge/ui/composition/app-shell.yaml
|
|
4
|
+
|
|
5
|
+
The application composes `WorkspaceShell viewport="fixed"`. The `app-shell-frame` rule identifies
|
|
6
|
+
workspace chrome from a fixed shell, sidebar/navigation slot, workspace identity, HFS layout owner,
|
|
7
|
+
or a shell owner combining a bar and sidebar. It rejects hand-written app-frame landmarks and
|
|
8
|
+
workspace shells without the fixed viewport. Generic flow-based site chrome stays in its own scope.
|
|
9
|
+
`no-shell-bottom-tabs` rejects bottom navigation components and the shell's `compactNavigation` slot.
|
|
10
|
+
|
|
11
|
+
The `navbar-cluster` rule reads the visible `TopBar` or `NavigationFeatureNav` actions in source order:
|
|
12
|
+
tertiary Language `IconButton` triggering `DropdownMenu`, theme `Switch`, notification `IconButton`
|
|
13
|
+
triggering `Popover`, account `AvatarButton` triggering `DropdownMenu`. It follows
|
|
14
|
+
local JSX constants and components, constant object members, imported Grammar aliases, and namespace imports. Translated
|
|
15
|
+
labels may be dynamic; icon sources identify language and notifications. Menu contents and popover
|
|
16
|
+
content belong to their overlays and are outside the bar's control sequence. Conditional render
|
|
17
|
+
branches are checked separately. Grammar `TopBar` and `NavigationFeatureNav` compositions are app
|
|
18
|
+
bars by default. A proven `WorkspaceShell viewport="flow"` with no workspace/sidebar evidence owns
|
|
19
|
+
generic site chrome. A shell hosting an imported navbar delegates only after its owned public
|
|
20
|
+
export and rendered Base chain prove a `TopBar` or `NavigationFeatureNav` in every alternative.
|
|
21
|
+
Workspace exports resolve back to source. Raw or unknown imported headers are red; the actual
|
|
22
|
+
bar's own actions are checked where composed. Each lint source snapshot has fresh dependency proofs.
|
|
23
|
+
|
|
24
|
+
`navbar-control-size` requires `size="control"` on each bar control. Unknown trailing prop spreads
|
|
25
|
+
cannot prove a size; put the explicit size after a spread. A left `Drawer` containing
|
|
26
|
+
`Sidebar presentation="drawer"` also identifies a mobile bar opener when composed in a separate
|
|
27
|
+
client owner. Its trigger is checked; drawer bodies and footers retain their normal field sizes.
|
|
28
|
+
`no-navbar-create` places creation on its
|
|
29
|
+
own page and rejects create controls in the navbar and its attached menus or popovers. Static
|
|
30
|
+
evidence includes literal labels, translated keys with create semantics, and conventional create
|
|
31
|
+
handlers. Runtime-generated labels and arbitrary handler behavior need semantic review; the rule
|
|
32
|
+
does not execute application code.
|
|
33
|
+
|
|
34
|
+
| Rule | Invalid composition | Valid composition |
|
|
35
|
+
| --- | --- | --- |
|
|
36
|
+
| `app-shell-frame` | An app `Shell` returns a hand-written `<header>` and `<nav>`. | `WorkspaceShell viewport="fixed"` owns the app frame. |
|
|
37
|
+
| `no-shell-bottom-tabs` | The shell supplies `compactNavigation={<BottomNav />}`. | The shell supplies `navigationDrawer` with a drawer `Sidebar`. |
|
|
38
|
+
| `navbar-control-size` | A navbar `IconButton` uses `size="md"`. | The same control uses `size="control"`. |
|
|
39
|
+
| `navbar-cluster` | Account precedes notifications, or a language trigger uses a regular `Button`. | Language menu, theme switch, notification popover, account menu use their stated Grammar trigger kinds in that order. |
|
|
40
|
+
| `no-navbar-create` | A navbar button or attached menu item creates a record. | The page owns its create action. |
|
|
41
|
+
|
|
42
|
+
```tsx
|
|
43
|
+
// Invalid: the navbar action creates a page record and has a different height.
|
|
44
|
+
<TopBar actions={<Button onPress={on.create}>Create</Button>} />
|
|
45
|
+
|
|
46
|
+
// Valid: a page action belongs to page content.
|
|
47
|
+
<SurfaceCard><Button onPress={on.create}>Create</Button></SurfaceCard>
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
All five rules are errors in the gathered FE canon and run through the ordinary application gate.
|
package/index.mjs
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* would leave two names for one rule and no way to tell which a message came from.
|
|
14
14
|
*/
|
|
15
15
|
import { createRequire } from "node:module"
|
|
16
|
+
import { recommended as appShellRecommended, rules as appShellRules } from "./app-shell.mjs"
|
|
16
17
|
import { join } from "node:path"
|
|
17
18
|
import { pathToFileURL } from "node:url"
|
|
18
19
|
import { recommended as brandValuesRecommended, rules as brandValuesRules } from "./brand-values.mjs"
|
|
@@ -56,6 +57,7 @@ import { why } from "./lib/why.mjs"
|
|
|
56
57
|
|
|
57
58
|
/** Each law's contribution, kept separate so a duplicate name is detectable rather than silent. */
|
|
58
59
|
const CONTRIBUTIONS = [
|
|
60
|
+
{ law: "app-shell", rules: appShellRules, recommended: appShellRecommended },
|
|
59
61
|
{ law: "project-graph", rules: projectGraphRules, recommended: projectGraphRecommended },
|
|
60
62
|
{ law: "brand-values", rules: brandValuesRules, recommended: brandValuesRecommended },
|
|
61
63
|
{ law: "client-boundary", rules: clientBoundaryRules, recommended: clientBoundaryRecommended },
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/** The static alternatives of the app bar's right cluster, preserving conditional branches. */
|
|
2
|
+
import { clusterRole, grammarName, isBarControl, propOf, textProp } from "./app-shell.mjs"
|
|
3
|
+
import { visitVisible } from "./app-shell-visible.mjs"
|
|
4
|
+
|
|
5
|
+
const controlsOf = (context, expression, choices, shellHeader = false) => {
|
|
6
|
+
const controls = []
|
|
7
|
+
const branches = []
|
|
8
|
+
let ownsBar = false
|
|
9
|
+
visitVisible(context, expression, (opening, name, ancestors) => {
|
|
10
|
+
if (shellHeader && ["TopBar", "NavigationFeatureNav"].includes(name)) { ownsBar = true; return false }
|
|
11
|
+
if (!isBarControl(context, opening, name)) return
|
|
12
|
+
const role = clusterRole(context, opening, name)
|
|
13
|
+
controls.push(controlSignature(context, opening, name, role, ancestors))
|
|
14
|
+
return false
|
|
15
|
+
}, new Set(), (node) => {
|
|
16
|
+
if (!choices.has(node)) branches.push(node)
|
|
17
|
+
return choices.get(node) ?? "consequent"
|
|
18
|
+
})
|
|
19
|
+
return { controls, branches, ownsBar }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const controlSignature = (context, opening, name, role, ancestors) => {
|
|
23
|
+
const signature = `${role ?? "unknown"}:${name}`
|
|
24
|
+
const expected = { language: "DropdownMenu", notifications: "Popover", account: "DropdownMenu" }[role]
|
|
25
|
+
const disclosure = [...ancestors].reverse().map((ancestor) => grammarName(context, ancestor)).find((parent) => parent === "DropdownMenu" || parent === "Popover")
|
|
26
|
+
if (expected && disclosure !== expected) return `${signature}:wrong-disclosure`
|
|
27
|
+
if (role === "language") {
|
|
28
|
+
const variant = propOf(context, opening, "variant")
|
|
29
|
+
if (!variant.known || variant.value !== null && textProp(context, opening, "variant") !== "tertiary") return `${signature}:wrong-variant`
|
|
30
|
+
}
|
|
31
|
+
return signature
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const rightGroup = (context, expression, choices) => {
|
|
35
|
+
let group = null
|
|
36
|
+
let count = Number.POSITIVE_INFINITY
|
|
37
|
+
const roles = controlsOf(context, expression, choices).controls.filter((role) => !role.startsWith("unknown:"))
|
|
38
|
+
visitVisible(context, expression, (opening, name) => {
|
|
39
|
+
if (isBarControl(context, opening, name)) return false
|
|
40
|
+
const result = controlsOf(context, opening.parent, choices)
|
|
41
|
+
const known = result.controls.filter((role) => !role.startsWith("unknown:"))
|
|
42
|
+
if (roles.length > 0 && known.length === roles.length && roles.every((role) => known.includes(role)) && result.controls.length < count) {
|
|
43
|
+
group = opening.parent
|
|
44
|
+
count = result.controls.length
|
|
45
|
+
}
|
|
46
|
+
}, new Set(), (node) => choices.get(node) ?? "consequent")
|
|
47
|
+
return group ?? expression
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Every reachable branch yields its own sequence; shell headers select the narrowest right-hand group. */
|
|
51
|
+
export const clusterAlternatives = (context, expression, shellHeader) => {
|
|
52
|
+
const pending = [new Map()]
|
|
53
|
+
const alternatives = []
|
|
54
|
+
while (pending.length) {
|
|
55
|
+
const choices = pending.pop()
|
|
56
|
+
const initial = controlsOf(context, expression, choices, shellHeader)
|
|
57
|
+
if (initial.ownsBar) return { alternatives: [], ownsBar: true }
|
|
58
|
+
const group = shellHeader ? rightGroup(context, expression, choices) : expression
|
|
59
|
+
const result = controlsOf(context, group, choices)
|
|
60
|
+
const unknown = [...initial.branches, ...result.branches].find((node) => !choices.has(node))
|
|
61
|
+
if (unknown) {
|
|
62
|
+
pending.push(new Map([...choices, [unknown, "alternate"]]), new Map([...choices, [unknown, "consequent"]]))
|
|
63
|
+
} else alternatives.push(result.controls)
|
|
64
|
+
}
|
|
65
|
+
return { alternatives, ownsBar: false }
|
|
66
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** Static create-action semantics in a bar's controls and its attached menu declarations. */
|
|
2
|
+
import { fileURLToPath } from "node:url"
|
|
3
|
+
import { phrasesOf } from "../runtime/scripts/lib/source-phrases.mjs"
|
|
4
|
+
import { escapeRegExp } from "../runtime/scripts/lib/regex.mjs"
|
|
5
|
+
import { resolveLocal } from "./app-shell.mjs"
|
|
6
|
+
|
|
7
|
+
const runtimeRoot = fileURLToPath(new URL("../runtime/", import.meta.url))
|
|
8
|
+
const localizedLabels = phrasesOf("appShell.product", runtimeRoot)
|
|
9
|
+
if (localizedLabels.length === 0) throw new Error("The FE canon's app-shell create-action phrase catalog is missing: regenerate the runtime bundle.")
|
|
10
|
+
const localizedCreate = localizedLabels.map((label) => escapeRegExp(label).replace(/\s+/g, String.raw`\s*`)).join("|")
|
|
11
|
+
const CREATE_WORDS = new RegExp(String.raw`\bcreate\b|\bnew\s+(?:item|record|project|task)|${localizedCreate}`, "i")
|
|
12
|
+
|
|
13
|
+
/** Literal create labels and conventional create handlers are executable static evidence. */
|
|
14
|
+
export const isCreateAction = (text) => CREATE_WORDS.test(text.normalize("NFC")) || /\bcreate[A-Z]|\bnew[A-Z]|\bonCreate|\bhandleCreate/.test(text)
|
|
15
|
+
|
|
16
|
+
const ACTION_FIELDS = new Set(["id", "key", "label", "title", "textValue", "onAction", "onPress", "onClick", "action"])
|
|
17
|
+
|
|
18
|
+
/** Follow static item/section arrays; inspect labels and handlers where a menu action is declared. */
|
|
19
|
+
export const menuHasCreate = (context, expression, seen = new Set()) => {
|
|
20
|
+
const node = resolveLocal(context, expression)
|
|
21
|
+
if (!node || seen.has(node)) return false
|
|
22
|
+
seen.add(node)
|
|
23
|
+
if (node.type === "SpreadElement") return menuHasCreate(context, node.argument, seen)
|
|
24
|
+
if (node.type === "ArrayExpression") return node.elements.some((item) => menuHasCreate(context, item, seen))
|
|
25
|
+
if (node.type !== "ObjectExpression") return false
|
|
26
|
+
return node.properties.some((property) => {
|
|
27
|
+
if (property.type === "SpreadElement") return menuHasCreate(context, property.argument, seen)
|
|
28
|
+
const name = property.key.name ?? property.key.value
|
|
29
|
+
const value = resolveLocal(context, property.value)
|
|
30
|
+
if (ACTION_FIELDS.has(name) && value?.range && isCreateAction(context.sourceCode.getText(value))) return true
|
|
31
|
+
return menuHasCreate(context, property.value, seen)
|
|
32
|
+
})
|
|
33
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
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 { fileURLToPath } from "node:url"
|
|
5
|
+
import ts from "typescript"
|
|
6
|
+
import parser from "@typescript-eslint/parser"
|
|
7
|
+
import { SourceCode } from "eslint"
|
|
8
|
+
import { importOf, variableOf } from "./bindings.mjs"
|
|
9
|
+
import { resolveImport } from "./scope.mjs"
|
|
10
|
+
import { hfsOf } from "./hfs.mjs"
|
|
11
|
+
import { walk } from "./ast.mjs"
|
|
12
|
+
import { loadArchitectureConfig } from "../runtime/scripts/hfs/architecture/config.mjs"
|
|
13
|
+
import { workspaceSourceEntry } from "../runtime/scripts/hfs/architecture/typescript.mjs"
|
|
14
|
+
import { openHfs } from "../runtime/scripts/hfs/slots.mjs"
|
|
15
|
+
|
|
16
|
+
const RUNTIME = fileURLToPath(new URL("../runtime/", import.meta.url))
|
|
17
|
+
const roots = new WeakMap()
|
|
18
|
+
const origins = new WeakMap()
|
|
19
|
+
|
|
20
|
+
const stateOf = (context) => {
|
|
21
|
+
const root = hfsOf(context).repoRoot
|
|
22
|
+
const snapshot = origins.get(context.sourceCode) ?? context.sourceCode
|
|
23
|
+
if (!roots.has(snapshot)) roots.set(snapshot, { root, files: new Map(), proofs: new Map(), workspaces: null })
|
|
24
|
+
return roots.get(snapshot)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const workspaceFile = (context, source) => {
|
|
28
|
+
const state = stateOf(context)
|
|
29
|
+
if (!state.workspaces) {
|
|
30
|
+
const hfs = openHfs({ root: RUNTIME, repoRoot: state.root })
|
|
31
|
+
const config = loadArchitectureConfig(state.root, { hfs })
|
|
32
|
+
state.workspaces = config.workspaces.map((relative) => {
|
|
33
|
+
const root = path.join(state.root, relative)
|
|
34
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"))
|
|
35
|
+
return { root, workspace: true, name: manifest.name, manifest, exports: manifest.exports }
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
const workspace = state.workspaces.find((entry) => entry.name && (source === entry.name || source.startsWith(`${entry.name}/`)))
|
|
39
|
+
return workspace ? workspaceSourceEntry(ts, workspace, source) : null
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const sourceFile = (context, source) => {
|
|
43
|
+
const target = resolveImport(context, source) ?? workspaceFile(context, source)
|
|
44
|
+
if (!target) return null
|
|
45
|
+
const hfs = hfsOf(context)
|
|
46
|
+
return [target, `${target}.tsx`, `${target}.ts`, `${target}/index.tsx`, `${target}/index.ts`]
|
|
47
|
+
.find((file) => /\.[cm]?[jt]sx?$/i.test(file) && !/\.d\.[cm]?ts$/i.test(file) && ts.sys.fileExists(file) && hfs.classify(file).status === "owned") ?? null
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const sourceContext = (context, filename) => {
|
|
51
|
+
const state = stateOf(context)
|
|
52
|
+
if (state.files.has(filename)) return state.files.get(filename)
|
|
53
|
+
const text = fs.readFileSync(filename, "utf8")
|
|
54
|
+
const parsed = parser.parseForESLint(text, { filePath: filename, sourceType: "module", ecmaFeatures: { jsx: true }, range: true, loc: true, tokens: true, comment: true })
|
|
55
|
+
const sourceCode = new SourceCode({ text, ast: parsed.ast, scopeManager: parsed.scopeManager, visitorKeys: parsed.visitorKeys, parserServices: parsed.services })
|
|
56
|
+
walk(sourceCode, parsed.ast, (node) => {
|
|
57
|
+
for (const key of sourceCode.visitorKeys[node.type] ?? []) {
|
|
58
|
+
const values = Array.isArray(node[key]) ? node[key] : [node[key]]
|
|
59
|
+
for (const child of values) if (child?.type) child.parent = node
|
|
60
|
+
}
|
|
61
|
+
})
|
|
62
|
+
const owned = { filename, sourceCode, settings: context.settings }
|
|
63
|
+
origins.set(sourceCode, origins.get(context.sourceCode) ?? context.sourceCode)
|
|
64
|
+
state.files.set(filename, owned)
|
|
65
|
+
return owned
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const moduleContext = (context, source) => {
|
|
69
|
+
const filename = sourceFile(context, source)
|
|
70
|
+
return filename ? sourceContext(context, filename) : null
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const namedDeclaration = (declaration, name) => {
|
|
74
|
+
if (declaration?.type === "FunctionDeclaration") return declaration.id?.name === name ? declaration : null
|
|
75
|
+
if (declaration?.type !== "VariableDeclaration") return null
|
|
76
|
+
return declaration.declarations.find((entry) => entry.id.type === "Identifier" && entry.id.name === name)?.init ?? null
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const namedExport = (context, statement, name, visited) => {
|
|
80
|
+
const value = namedDeclaration(statement.declaration, name)
|
|
81
|
+
if (value) return { matched: true, owner: { context, value } }
|
|
82
|
+
const specifier = statement.specifiers.find((entry) => (entry.exported.name ?? entry.exported.value) === name)
|
|
83
|
+
if (!specifier) return { matched: false }
|
|
84
|
+
if (!statement.source) return { matched: true, owner: { context, value: specifier.local } }
|
|
85
|
+
const target = moduleContext(context, String(statement.source.value))
|
|
86
|
+
return { matched: true, owner: target ? headerExport(target, specifier.local.name ?? specifier.local.value, visited) : null }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The selected exported value, following public barrels without scanning unrelated imported implementations. */
|
|
90
|
+
const headerExport = (context, name, seen = new Set()) => {
|
|
91
|
+
const key = `${context.filename}:${name}`
|
|
92
|
+
if (seen.has(key)) return null
|
|
93
|
+
const visited = new Set(seen).add(key)
|
|
94
|
+
for (const statement of context.sourceCode.ast.body) {
|
|
95
|
+
if (statement.type === "ExportDefaultDeclaration" && name === "default") return { context, value: statement.declaration }
|
|
96
|
+
if (statement.type === "ExportNamedDeclaration") {
|
|
97
|
+
const found = namedExport(context, statement, name, visited)
|
|
98
|
+
if (found.matched) return found.owner
|
|
99
|
+
}
|
|
100
|
+
if (statement.type === "ExportAllDeclaration") {
|
|
101
|
+
const target = moduleContext(context, String(statement.source.value))
|
|
102
|
+
const found = target && headerExport(target, name, visited)
|
|
103
|
+
if (found) return found
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return null
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** An imported JSX owner, or null when its implementation cannot be proved from owned source. */
|
|
110
|
+
export const importedHeader = (context, opening) => {
|
|
111
|
+
const name = opening.name
|
|
112
|
+
const identifier = name.type === "JSXIdentifier" ? name : name.object
|
|
113
|
+
if (identifier?.type !== "JSXIdentifier") return null
|
|
114
|
+
const imported = importOf(context, identifier)
|
|
115
|
+
if (!imported) return null
|
|
116
|
+
const definition = variableOf(context, identifier)?.defs.find((entry) => entry.type === "ImportBinding")
|
|
117
|
+
if (name.type === "JSXMemberExpression" && definition?.node.type !== "ImportNamespaceSpecifier") return null
|
|
118
|
+
const target = moduleContext(context, imported.source)
|
|
119
|
+
const exported = name.type === "JSXMemberExpression" ? name.property.name : imported.imported ?? "default"
|
|
120
|
+
return target ? headerExport(target, exported) : null
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The per-repository proof cache shares the same lifetime as parsed header source. */
|
|
124
|
+
export const headerProofs = (context) => stateOf(context).proofs
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/** Imported header delegation requires an actual canonical bar in every visible source alternative. */
|
|
2
|
+
import { functionOf, importOf } from "./bindings.mjs"
|
|
3
|
+
import { isBarControl, resolveLocal } from "./app-shell.mjs"
|
|
4
|
+
import { visitFunction } from "./app-shell-visible.mjs"
|
|
5
|
+
import { headerProofs, importedHeader } from "./app-shell-owner-source.mjs"
|
|
6
|
+
|
|
7
|
+
const OVERLAYS = new Set(["Dialog", "AlertDialog", "Drawer", "Popover", "DropdownMenu", "Tooltip"])
|
|
8
|
+
|
|
9
|
+
const importBinding = (context, opening) => {
|
|
10
|
+
const name = opening.name.type === "JSXMemberExpression" ? opening.name.object : opening.name
|
|
11
|
+
return name.type === "JSXIdentifier" ? importOf(context, name) : null
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const reactFragment = (context, opening) => {
|
|
15
|
+
const imported = importBinding(context, opening)
|
|
16
|
+
return imported?.source === "react" && (imported.imported === "Fragment" || opening.name.type === "JSXMemberExpression" && opening.name.property.name === "Fragment")
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const proveOwner = (owner, seen) => {
|
|
20
|
+
if (!owner) return false
|
|
21
|
+
const { context, value } = owner
|
|
22
|
+
const key = `${context.filename}:${value.range?.join(":")}`
|
|
23
|
+
const proofs = headerProofs(context)
|
|
24
|
+
if (proofs.has(key)) return proofs.get(key)
|
|
25
|
+
if (seen.has(key)) return false
|
|
26
|
+
const fn = functionOf(context, resolveLocal(context, value))
|
|
27
|
+
const valid = Boolean(fn) && proveReturns(context, fn, new Set(seen).add(key))
|
|
28
|
+
proofs.set(key, valid)
|
|
29
|
+
return valid
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const proveReturns = (context, fn, seen) => {
|
|
33
|
+
const pending = [new Map()]
|
|
34
|
+
while (pending.length) {
|
|
35
|
+
const choices = pending.pop()
|
|
36
|
+
const branch = []
|
|
37
|
+
let bar = false
|
|
38
|
+
let unknown = false
|
|
39
|
+
visitFunction(context, fn, (opening, name) => {
|
|
40
|
+
if (reactFragment(context, opening)) return
|
|
41
|
+
if (["TopBar", "NavigationFeatureNav"].includes(name)) { bar = true; return false }
|
|
42
|
+
if (OVERLAYS.has(name)) return false
|
|
43
|
+
if (["header", "nav", "main"].includes(name) || isBarControl(context, opening, name)) { unknown = true; return false }
|
|
44
|
+
if (name === null && importBinding(context, opening)) {
|
|
45
|
+
if (proveOwner(importedHeader(context, opening), seen)) bar = true
|
|
46
|
+
else unknown = true
|
|
47
|
+
return false
|
|
48
|
+
}
|
|
49
|
+
}, new Set(), (node) => {
|
|
50
|
+
if (!choices.has(node)) branch.push(node)
|
|
51
|
+
return choices.get(node) ?? "consequent"
|
|
52
|
+
}, [])
|
|
53
|
+
const missing = branch.find((node) => !choices.has(node))
|
|
54
|
+
if (missing) pending.push(new Map([...choices, [missing, "alternate"]]), new Map([...choices, [missing, "consequent"]]))
|
|
55
|
+
else if (!bar || unknown) return false
|
|
56
|
+
}
|
|
57
|
+
return true
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** True only for an imported JSX header whose owned implementation reaches a canonical Grammar bar. */
|
|
61
|
+
export const provesImportedBar = (context, opening) => {
|
|
62
|
+
if (reactFragment(context, opening)) return null
|
|
63
|
+
if (!importBinding(context, opening)) return null
|
|
64
|
+
return proveOwner(importedHeader(context, opening), new Set())
|
|
65
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/** Structural app-shell ownership, distinct from generic flow-based site chrome. */
|
|
2
|
+
import { walk } from "./ast.mjs"
|
|
3
|
+
import { kindOfFile, roleOfFile } from "./scope.mjs"
|
|
4
|
+
import { grammarName, propOf, textProp } from "./app-shell.mjs"
|
|
5
|
+
import { visitVisible } from "./app-shell-visible.mjs"
|
|
6
|
+
|
|
7
|
+
/** A workspace identity or a sidebar/navigation slot identifies an application shell structurally. */
|
|
8
|
+
export const isAppShell = (context, opening) => {
|
|
9
|
+
if (grammarName(context, opening) !== "WorkspaceShell") return false
|
|
10
|
+
const viewport = textProp(context, opening, "viewport")
|
|
11
|
+
if (viewport === "fixed" || viewport !== "flow" && ownsAppFrame(context, opening)) return true
|
|
12
|
+
const navigation = propOf(context, opening, "navigation")
|
|
13
|
+
if (navigation.value !== null || !navigation.known) return true
|
|
14
|
+
let identity = false
|
|
15
|
+
visitVisible(context, propOf(context, opening, "header").value, (_node, name) => {
|
|
16
|
+
if (name === "IdentityLockup") identity = true
|
|
17
|
+
})
|
|
18
|
+
return identity
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A left Drawer containing a drawer Sidebar is the mobile navigation opener, including an extracted client owner. */
|
|
22
|
+
export const isNavigationDrawer = (context, opening) => {
|
|
23
|
+
if (grammarName(context, opening) !== "Drawer" || textProp(context, opening, "placement") !== "left") return false
|
|
24
|
+
let sidebar = false
|
|
25
|
+
const inspect = (node, name) => {
|
|
26
|
+
if (name === "Sidebar" && textProp(context, node, "presentation") === "drawer") sidebar = true
|
|
27
|
+
}
|
|
28
|
+
for (const child of opening.parent.children) visitVisible(context, child, inspect)
|
|
29
|
+
visitVisible(context, propOf(context, opening, "children").value, inspect)
|
|
30
|
+
return sidebar
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Shell-named React owners and the HFS layout kind compose an app frame, rather than page-local navigation. */
|
|
34
|
+
export const ownsAppFrame = (context, node) => {
|
|
35
|
+
if (kindOfFile(context) === "layouts" || roleOfFile(context) === "layout") return true
|
|
36
|
+
let rendered = null
|
|
37
|
+
let shellName = false
|
|
38
|
+
for (let current = node.parent; current; current = current.parent) {
|
|
39
|
+
if (current.type === "ReturnStatement") rendered = current.argument
|
|
40
|
+
if (current.type === "FunctionDeclaration") { shellName = /Shell(?:Base)?$/.test(current.id?.name ?? ""); break }
|
|
41
|
+
if (current.type === "ArrowFunctionExpression" || current.type === "FunctionExpression") {
|
|
42
|
+
rendered ??= current.body
|
|
43
|
+
shellName = current.parent?.type === "VariableDeclarator" && /Shell(?:Base)?$/.test(current.parent.id.name ?? "")
|
|
44
|
+
break
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (shellName) return true
|
|
48
|
+
let sidebar = false
|
|
49
|
+
visitVisible(context, rendered, (_opening, name) => { if (name === "Sidebar") sidebar = true })
|
|
50
|
+
return sidebar
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const APP_BAR_CACHE = new WeakMap()
|
|
54
|
+
|
|
55
|
+
/** Every visible opening hosted by a structurally identified app shell in this source file. */
|
|
56
|
+
export const appBarOpenings = (context) => {
|
|
57
|
+
const source = context.sourceCode
|
|
58
|
+
if (APP_BAR_CACHE.has(source)) return APP_BAR_CACHE.get(source)
|
|
59
|
+
const openings = new Set()
|
|
60
|
+
walk(source, source.ast, (node) => {
|
|
61
|
+
if (node.type !== "JSXOpeningElement" || !isAppShell(context, node)) return
|
|
62
|
+
for (const slot of ["header", "compactHeader"]) {
|
|
63
|
+
visitVisible(context, propOf(context, node, slot).value, (opening) => { openings.add(opening) })
|
|
64
|
+
}
|
|
65
|
+
})
|
|
66
|
+
APP_BAR_CACHE.set(source, openings)
|
|
67
|
+
return openings
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Grammar bars are app bars unless an explicit generic flow shell establishes site-chrome ownership. */
|
|
71
|
+
export const isAppBar = (context, opening) => {
|
|
72
|
+
if (!["TopBar", "NavigationFeatureNav"].includes(grammarName(context, opening))) return false
|
|
73
|
+
for (let current = opening.parent; current; current = current.parent) {
|
|
74
|
+
if (current.type !== "JSXElement") continue
|
|
75
|
+
const shell = current.openingElement
|
|
76
|
+
if (grammarName(context, shell) === "WorkspaceShell" && textProp(context, shell, "viewport") === "flow" && !isAppShell(context, shell)) return false
|
|
77
|
+
}
|
|
78
|
+
return true
|
|
79
|
+
}
|