@starci/eslint-canon-fe 8.4.40 → 8.4.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,6 +1,8 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 8.4.41 - 2026-10-26
4
+
5
+ - 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
6
  - 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
7
  - 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
8
  - 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
+ }