@starci/eslint-canon-fe 8.4.39 → 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.
Files changed (67) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +10 -0
  3. package/app-shell.mjs +161 -0
  4. package/callback-shape.mjs +1 -13
  5. package/docs/app-shell.md +50 -0
  6. package/docs/grammar-boundary.md +25 -1
  7. package/docs/surface-rules.md +41 -0
  8. package/grammar-boundary.mjs +112 -0
  9. package/hooks-folder.mjs +9 -3
  10. package/icon.mjs +31 -1
  11. package/index.mjs +6 -0
  12. package/lib/app-shell-cluster.mjs +66 -0
  13. package/lib/app-shell-create.mjs +33 -0
  14. package/lib/app-shell-owner-source.mjs +124 -0
  15. package/lib/app-shell-owner.mjs +65 -0
  16. package/lib/app-shell-scope.mjs +79 -0
  17. package/lib/app-shell-visible.mjs +92 -0
  18. package/lib/app-shell.mjs +148 -0
  19. package/lib/bindings.mjs +15 -0
  20. package/lib/config.mjs +1 -0
  21. package/lib/fe-stack.mjs +8 -4
  22. package/lib/scope.mjs +2 -2
  23. package/lib/typed-class-values.mjs +72 -0
  24. package/lib/why.mjs +42 -2
  25. package/lint-escape-hatch.mjs +2 -2
  26. package/menu-entries.mjs +133 -0
  27. package/package.json +1 -1
  28. package/runtime/knowledge/hfs/canon-pins.yaml +65 -9
  29. package/runtime/knowledge/hfs/fe-stack.yaml +23 -5
  30. package/runtime/knowledge/hfs/slots.yaml +113 -53
  31. package/runtime/knowledge/sonar-gate.yaml +1 -1
  32. package/runtime/modules/goal/source-phrases.yaml +95 -0
  33. package/runtime/modules/kernel/removed-vocabulary.yaml +62 -0
  34. package/runtime/modules/models/registry.yaml +3 -1
  35. package/runtime/modules/models/runtimes.yaml +2 -0
  36. package/runtime/scripts/api/git/ls-tree.mjs +5 -0
  37. package/runtime/scripts/api/git/show.mjs +6 -0
  38. package/runtime/scripts/hfs/architecture/dead-exports.mjs +10 -10
  39. package/runtime/scripts/hfs/architecture/function-spec-providers.mjs +1 -1
  40. package/runtime/scripts/hfs/architecture/operation-contract-roots.mjs +53 -0
  41. package/runtime/scripts/hfs/architecture/reachability.mjs +1 -1
  42. package/runtime/scripts/hfs/architecture/sql-owner.mjs +17 -6
  43. package/runtime/scripts/hfs/architecture/sql-token-forms.mjs +105 -0
  44. package/runtime/scripts/hfs/architecture/sql-tokens.mjs +38 -52
  45. package/runtime/scripts/hfs/architecture/supabase-keys.mjs +261 -0
  46. package/runtime/scripts/hfs/architecture/transport-owner.mjs +49 -3
  47. package/runtime/scripts/hfs/architecture/typescript.mjs +1 -1
  48. package/runtime/scripts/hfs/declaration-shape.mjs +4 -4
  49. package/runtime/scripts/hfs/declaration-slots.mjs +14 -3
  50. package/runtime/scripts/hfs/manifest-shape.mjs +10 -6
  51. package/runtime/scripts/hfs/rule-params-shape.mjs +18 -6
  52. package/runtime/scripts/hfs/rules/database-constants.mjs +26 -0
  53. package/runtime/scripts/hfs/rules/database-migrations.mjs +489 -0
  54. package/runtime/scripts/hfs/slot-side-problems.mjs +3 -3
  55. package/runtime/scripts/hfs/slots.mjs +2 -1
  56. package/runtime/scripts/hfs/view.mjs +2 -1
  57. package/runtime/scripts/lib/in-order.mjs +71 -0
  58. package/runtime/scripts/lib/language.mjs +94 -1
  59. package/runtime/scripts/lib/package-at.mjs +62 -0
  60. package/runtime/scripts/lib/same-text.mjs +5 -0
  61. package/runtime/scripts/lib/secret-patterns.mjs +58 -9
  62. package/runtime/scripts/lib/source-phrases.mjs +34 -0
  63. package/runtime/scripts/lib/ts-analysis.mjs +10 -0
  64. package/size-and-state-budget.mjs +3 -3
  65. package/surface-rules.mjs +230 -0
  66. package/swr-family.mjs +136 -7
  67. package/transport.mjs +2 -1
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"
@@ -31,6 +32,7 @@ import { recommended as landmarkRecommended, rules as landmarkRules } from "./la
31
32
  import { recommended as listsRecommended, rules as listsRules } from "./lists.mjs"
32
33
  import { recommended as loadingRecommended, rules as loadingRules } from "./loading.mjs"
33
34
  import { recommended as lintEscapeRecommended, rules as lintEscapeRules } from "./lint-escape-hatch.mjs"
35
+ import { recommended as menuEntriesRecommended, rules as menuEntriesRules } from "./menu-entries.mjs"
34
36
  import { recommended as namingRecommended, rules as namingRules } from "./naming.mjs"
35
37
  import { recommended as nativeControlsRecommended, rules as nativeControlsRules } from "./native-controls.mjs"
36
38
  import { recommended as nextConventionsRecommended, rules as nextConventionsRules } from "./next-conventions.mjs"
@@ -40,6 +42,7 @@ import { recommended as drawingRecommended, rules as drawingRules } from "./draw
40
42
  import { recommended as sizeBudgetRecommended, rules as sizeBudgetRules } from "./size-and-state-budget.mjs"
41
43
  import { recommended as sizeGrowthRecommended, rules as sizeGrowthRules } from "./size-growth.mjs"
42
44
  import { recommended as statusColorsRecommended, rules as statusColorsRules } from "./status-colors.mjs"
45
+ import { recommended as surfaceRulesRecommended, rules as surfaceRules } from "./surface-rules.mjs"
43
46
  import { recommended as splitRecommended, rules as splitRules } from "./the-split.mjs"
44
47
  import { recommended as tokensRecommended, rules as tokensRules } from "./tokens.mjs"
45
48
  import { recommended as transportRecommended, rules as transportRules } from "./transport.mjs"
@@ -54,6 +57,7 @@ import { why } from "./lib/why.mjs"
54
57
 
55
58
  /** Each law's contribution, kept separate so a duplicate name is detectable rather than silent. */
56
59
  const CONTRIBUTIONS = [
60
+ { law: "app-shell", rules: appShellRules, recommended: appShellRecommended },
57
61
  { law: "project-graph", rules: projectGraphRules, recommended: projectGraphRecommended },
58
62
  { law: "brand-values", rules: brandValuesRules, recommended: brandValuesRecommended },
59
63
  { law: "client-boundary", rules: clientBoundaryRules, recommended: clientBoundaryRecommended },
@@ -75,6 +79,7 @@ const CONTRIBUTIONS = [
75
79
  { law: "lists", rules: listsRules, recommended: listsRecommended },
76
80
  { law: "loading", rules: loadingRules, recommended: loadingRecommended },
77
81
  { law: "lint-escape-hatch", rules: lintEscapeRules, recommended: lintEscapeRecommended },
82
+ { law: "menu-entries", rules: menuEntriesRules, recommended: menuEntriesRecommended },
78
83
  { law: "naming", rules: namingRules, recommended: namingRecommended },
79
84
  { law: "native-controls", rules: nativeControlsRules, recommended: nativeControlsRecommended },
80
85
  { law: "next-conventions", rules: nextConventionsRules, recommended: nextConventionsRecommended },
@@ -84,6 +89,7 @@ const CONTRIBUTIONS = [
84
89
  { law: "size-and-state-budget", rules: sizeBudgetRules, recommended: sizeBudgetRecommended },
85
90
  { law: "size-growth", rules: sizeGrowthRules, recommended: sizeGrowthRecommended },
86
91
  { law: "status-colors", rules: statusColorsRules, recommended: statusColorsRecommended },
92
+ { law: "surface-rules", rules: surfaceRules, recommended: surfaceRulesRecommended },
87
93
  { law: "the-split", rules: splitRules, recommended: splitRecommended },
88
94
  { law: "tokens", rules: tokensRules, recommended: tokensRecommended },
89
95
  { law: "translation", rules: translationRules, recommended: translationRecommended },
@@ -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
+ }
@@ -0,0 +1,92 @@
1
+ /** Traverse only the visible bar composition and its local React owners. */
2
+ import { functionOf, variableOf } from "./bindings.mjs"
3
+ import { grammarName, propOf, resolveLocal } from "./app-shell.mjs"
4
+
5
+ const BAR_SLOTS = {
6
+ WorkspaceShell: ["header", "compactHeader"],
7
+ TopBar: ["brand", "navigation", "actions"],
8
+ NavigationFeatureNav: ["identity", "navigation", "actions", "compactNavigationTrigger"],
9
+ DropdownMenu: ["trigger"],
10
+ Popover: ["trigger"],
11
+ Drawer: ["trigger"],
12
+ }
13
+
14
+ /** Visible JSX composition only: menu, popover and drawer bodies are outside the navbar band. */
15
+ export const visitVisible = (context, expression, visit, seen = new Set(), branch = () => "both", ancestors = []) => {
16
+ const node = resolveLocal(context, expression)
17
+ if (!node || seen.has(node)) return
18
+ const visited = new Set(seen).add(node)
19
+ const next = (child) => visitVisible(context, child, visit, visited, branch, ancestors)
20
+ switch (node.type) {
21
+ case "JSXExpressionContainer": next(node.expression); break
22
+ case "JSXFragment": for (const child of node.children) next(child); break
23
+ case "ConditionalExpression": visitConditional(node, next, branch); break
24
+ case "LogicalExpression": visitLogical(context, node, next, branch); break
25
+ case "ArrayExpression": for (const item of node.elements) next(item); break
26
+ case "JSXElement": visitElement(context, node, visit, visited, branch, ancestors); break
27
+ }
28
+ }
29
+
30
+ const visitConditional = (node, next, branch) => {
31
+ const choice = branch(node)
32
+ if (choice !== "alternate") next(node.consequent)
33
+ if (choice !== "consequent") next(node.alternate)
34
+ }
35
+
36
+ const visitLogical = (context, node, next, branch) => {
37
+ const left = resolveLocal(context, node.left)
38
+ const known = left?.type === "Literal"
39
+ let right = node.operator === "&&" ? Boolean(left?.value) : !left?.value
40
+ if (node.operator === "??") right = left?.value === null
41
+ if (!known) {
42
+ const choice = branch(node)
43
+ if (choice === "both") { next(node.left); next(node.right); return }
44
+ right = choice === "consequent"
45
+ }
46
+ next(right ? node.right : node.left)
47
+ }
48
+
49
+ const visitElement = (context, node, visit, seen, branch, ancestors) => {
50
+ const opening = node.openingElement
51
+ const name = grammarName(context, opening)
52
+ if (visit(opening, name, ancestors) === false) return
53
+ const inside = [...ancestors, opening]
54
+ const next = (child) => visitVisible(context, child, visit, seen, branch, inside)
55
+ if (name === null && opening.name.type === "JSXIdentifier") {
56
+ const fn = functionOf(context, resolveLocal(context, opening.name)) ?? variableOf(context, opening.name)?.defs.find((definition) => definition.type === "FunctionName")?.node
57
+ if (fn) visitFunction(context, fn, visit, seen, branch, inside)
58
+ }
59
+ const slots = BAR_SLOTS[name]
60
+ if (slots) for (const slot of slots) next(propOf(context, opening, slot).value)
61
+ if (["DropdownMenu", "Popover", "Drawer"].includes(name)) return
62
+ for (const child of node.children) next(child)
63
+ }
64
+
65
+ /** Visit a rendered owner's returns with the same branch, overlay and local-binding semantics as inline JSX. */
66
+ export const visitFunction = (context, fn, visit, seen, branch, ancestors) => {
67
+ if (fn.body.type !== "BlockStatement") return visitVisible(context, fn.body, visit, seen, branch, ancestors)
68
+ visitStatements(context, fn.body.body, visit, seen, branch, ancestors)
69
+ }
70
+
71
+ const visitStatements = (context, statements, visit, seen, branch, ancestors) => {
72
+ for (const statement of statements) {
73
+ if (statement.type === "ReturnStatement") {
74
+ visitVisible(context, statement.argument, visit, seen, branch, ancestors)
75
+ return true
76
+ }
77
+ if (statement.type === "BlockStatement" && visitStatements(context, statement.body, visit, seen, branch, ancestors)) return true
78
+ if (statement.type === "IfStatement") {
79
+ if (visitIf(context, statement, visit, seen, branch, ancestors)) return true
80
+ }
81
+ }
82
+ return false
83
+ }
84
+
85
+ const visitIf = (context, statement, visit, seen, branch, ancestors) => {
86
+ const condition = resolveLocal(context, statement.test)
87
+ let choice = branch(statement)
88
+ if (condition?.type === "Literal") choice = condition.value ? "consequent" : "alternate"
89
+ const consequent = choice !== "alternate" && visitStatements(context, [statement.consequent], visit, seen, branch, ancestors)
90
+ const alternate = choice !== "consequent" && statement.alternate && visitStatements(context, [statement.alternate], visit, seen, branch, ancestors)
91
+ return choice === "consequent" && consequent || choice === "alternate" && alternate || consequent && alternate
92
+ }
@@ -0,0 +1,148 @@
1
+ /** Static readers for the visible Grammar app-bar composition, including local JSX owners. */
2
+ import { attributeValue, stringOf } from "./ast.mjs"
3
+ import { functionOf, importOf, unwrap, variableOf } from "./bindings.mjs"
4
+
5
+ const GRAMMAR_SOURCE = /^@starci\/grammar(?:\/|$)/
6
+ /** Grammar controls whose visible hit target belongs to the navbar band's size contract. */
7
+ const BAR_CONTROLS = new Set(["Button", "IconButton", "Switch", "AvatarButton", "PlainPressable", "IdentityLockup", "SearchField", "Input", "TextField", "Select", "TextAction"])
8
+ const BAR_WRAPPERS = new Set(["GrammarRoot", "WorkspaceShell", "TopBar", "NavigationFeatureNav", "Region", "PageContainer", "NavLandmark", "Form", "Fieldset", "Field", "ButtonGroup", "DropdownMenu", "Popover", "Drawer", "Tooltip"])
9
+ const ACTION_PROPS = ["onPress", "onClick", "onAction", "onValueChange", "onSelectionChange", "onSelectedChange", "onChange", "onInputChange"]
10
+ const INTERACTION_PROPS = new Set(["label", "value", "defaultValue", "selectedKey", "selectedKeys", "selectionMode", "href", ...ACTION_PROPS])
11
+
12
+ /** Bar policy also counts labelled/value/selection/action field owners, so an extra control cannot vanish from the cluster. */
13
+ export const isBarControl = (context, opening, name) => {
14
+ if (BAR_CONTROLS.has(name)) return true
15
+ if (!name || !/^[A-Z]/.test(name) || BAR_WRAPPERS.has(name)) return false
16
+ if (opening.attributes.some((entry) => entry.type === "JSXAttribute" && INTERACTION_PROPS.has(entry.name.name))) return true
17
+ return [...INTERACTION_PROPS].some((prop) => propOf(context, opening, prop).value !== null)
18
+ }
19
+ /** A literal or constant's initializer, without type-only wrappers. */
20
+ export const resolveLocal = (context, expression, seen = new Set()) => {
21
+ const node = unwrap(expression)
22
+ if (node?.type === "MemberExpression") return resolveMember(context, node, seen)
23
+ if (node?.type === "CallExpression") return resolveCall(context, node, seen)
24
+ if (!["Identifier", "JSXIdentifier"].includes(node?.type) || seen.has(node)) return node
25
+ const definition = variableOf(context, node)?.defs.find((entry) => entry.type === "Variable")
26
+ if (!definition || definition.parent?.kind !== "const") return node
27
+ seen.add(node)
28
+ return resolveLocal(context, initializerOf(definition.node, node), seen)
29
+ }
30
+
31
+ const resolveCall = (context, node, seen) => {
32
+ if (seen.has(node)) return node
33
+ seen.add(node)
34
+ const fn = functionOf(context, resolveLocal(context, node.callee, seen))
35
+ if (!fn) return node
36
+ if (fn.body.type !== "BlockStatement") return resolveLocal(context, fn.body, seen)
37
+ if (fn.body.body.some((statement) => !["VariableDeclaration", "ReturnStatement"].includes(statement.type))) return node
38
+ const returned = fn.body.body.find((statement) => statement.type === "ReturnStatement")
39
+ return returned ? resolveLocal(context, returned.argument, seen) : node
40
+ }
41
+
42
+ const resolveMember = (context, node, seen) => {
43
+ const key = node.computed ? stringOf(node.property) : node.property.name
44
+ if (!key) return node
45
+ const property = objectValue(context, node.object, key, seen)
46
+ return property.known && property.value !== null ? resolveLocal(context, property.value, seen) : node
47
+ }
48
+
49
+ const initializerOf = (declaration, node) => {
50
+ if (declaration.id.type === "ObjectPattern") {
51
+ const property = declaration.id.properties.find((entry) => entry.type === "Property" && entry.value.name === node.name)
52
+ if (!property) return node
53
+ return { type: "MemberExpression", object: declaration.init, property: property.key, computed: property.computed }
54
+ }
55
+ return declaration.init
56
+ }
57
+
58
+ /** The imported Grammar component's name; aliases and namespace imports keep their identity. */
59
+ export const grammarName = (context, opening) => {
60
+ const name = opening.name
61
+ if (name.type === "JSXIdentifier") {
62
+ const canonical = expressionName(context, name)
63
+ if (canonical) return canonical
64
+ return variableOf(context, name)?.defs.length ? null : name.name
65
+ }
66
+ if (name.type !== "JSXMemberExpression" || name.object.type !== "JSXIdentifier") return null
67
+ return expressionName(context, { type: "MemberExpression", object: name.object, property: name.property, computed: false })
68
+ }
69
+
70
+ const expressionName = (context, expression) => {
71
+ const resolved = resolveLocal(context, expression)
72
+ if (resolved?.type === "MemberExpression") {
73
+ return namespaceName(context, resolved.object, resolved.computed ? stringOf(resolved.property) : resolved.property.name)
74
+ }
75
+ if (!["Identifier", "JSXIdentifier"].includes(resolved?.type)) return null
76
+ const imported = importOf(context, resolved)
77
+ return imported && GRAMMAR_SOURCE.test(imported.source) ? imported.imported : null
78
+ }
79
+
80
+ const namespaceName = (context, expression, property) => {
81
+ const object = resolveLocal(context, expression)
82
+ if (!["Identifier", "JSXIdentifier"].includes(object?.type)) return null
83
+ const imported = importOf(context, object)
84
+ return imported && GRAMMAR_SOURCE.test(imported.source) ? property : null
85
+ }
86
+
87
+ const objectValue = (context, expression, name, seen = new Set()) => {
88
+ const node = resolveLocal(context, expression)
89
+ if (node?.type === "ConditionalExpression") return conditionalObjectValue(context, node, name, seen)
90
+ if (node?.type !== "ObjectExpression" || seen.has(node)) return { known: false, value: null }
91
+ seen.add(node)
92
+ let result = { known: true, value: null }
93
+ for (const property of node.properties) {
94
+ if (property.type === "SpreadElement") {
95
+ const spread = objectValue(context, property.argument, name, seen)
96
+ if (!spread.known || spread.value !== null) result = spread
97
+ } else if (!property.computed && (property.key.name ?? property.key.value) === name) {
98
+ result = { known: true, value: property.value }
99
+ }
100
+ }
101
+ return result
102
+ }
103
+
104
+ const conditionalObjectValue = (context, node, name, seen) => {
105
+ if (seen.has(node)) return { known: false, value: null }
106
+ const visited = new Set(seen).add(node)
107
+ const values = [node.consequent, node.alternate].map((branch) => objectValue(context, branch, name, new Set(visited)))
108
+ return { known: values.every((value) => value.known && value.value === null), value: null }
109
+ }
110
+
111
+ /** The effective prop, respecting JSX spread order; unknown later spreads cannot prove a value. */
112
+ export const propOf = (context, opening, name) => {
113
+ let result = { known: true, value: null }
114
+ for (const entry of opening.attributes) {
115
+ if (entry.type === "JSXSpreadAttribute") {
116
+ const spread = objectValue(context, entry.argument, name)
117
+ if (!spread.known || spread.value !== null) result = spread
118
+ } else if (entry.name.name === name) result = { known: true, value: attributeValue(entry), present: true }
119
+ }
120
+ return result
121
+ }
122
+
123
+ /** A statically proven string prop, or null for missing/unknown values. */
124
+ export const textProp = (context, opening, name) => {
125
+ const prop = propOf(context, opening, name)
126
+ return prop.known ? stringOf(resolveLocal(context, prop.value)) : null
127
+ }
128
+
129
+ /** The source spelling is semantic evidence for an icon or action whose translated label is dynamic. */
130
+ export const semanticText = (context, opening) => {
131
+ const values = ["source", "icon", "label", "aria-label", "id", ...ACTION_PROPS].map((name) => {
132
+ const prop = propOf(context, opening, name)
133
+ return prop.value ? context.sourceCode.getText(prop.value) : ""
134
+ })
135
+ return values.join(" ")
136
+ }
137
+
138
+ /** The right-cluster role and the Grammar kind that must draw it. */
139
+ export const clusterRole = (context, opening, name) => {
140
+ if (name === "Switch") return "theme"
141
+ if (name === "AvatarButton") return "account"
142
+ const text = semanticText(context, opening)
143
+ if (/language|locale|translate/i.test(text)) return "language"
144
+ if (/notification|bell/i.test(text)) return "notifications"
145
+ if (/account|avatar|profile/i.test(text)) return "account"
146
+ if (/theme|darkmode|color.?mode/i.test(text)) return "theme"
147
+ return null
148
+ }