@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.
- package/CHANGELOG.md +11 -0
- package/README.md +10 -0
- package/app-shell.mjs +161 -0
- package/callback-shape.mjs +1 -13
- package/docs/app-shell.md +50 -0
- package/docs/grammar-boundary.md +25 -1
- package/docs/surface-rules.md +41 -0
- package/grammar-boundary.mjs +112 -0
- package/hooks-folder.mjs +9 -3
- package/icon.mjs +31 -1
- package/index.mjs +6 -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/bindings.mjs +15 -0
- package/lib/config.mjs +1 -0
- package/lib/fe-stack.mjs +8 -4
- package/lib/scope.mjs +2 -2
- package/lib/typed-class-values.mjs +72 -0
- package/lib/why.mjs +42 -2
- package/lint-escape-hatch.mjs +2 -2
- package/menu-entries.mjs +133 -0
- package/package.json +1 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +65 -9
- package/runtime/knowledge/hfs/fe-stack.yaml +23 -5
- package/runtime/knowledge/hfs/slots.yaml +113 -53
- package/runtime/knowledge/sonar-gate.yaml +1 -1
- package/runtime/modules/goal/source-phrases.yaml +95 -0
- package/runtime/modules/kernel/removed-vocabulary.yaml +62 -0
- package/runtime/modules/models/registry.yaml +3 -1
- package/runtime/modules/models/runtimes.yaml +2 -0
- package/runtime/scripts/api/git/ls-tree.mjs +5 -0
- package/runtime/scripts/api/git/show.mjs +6 -0
- package/runtime/scripts/hfs/architecture/dead-exports.mjs +10 -10
- package/runtime/scripts/hfs/architecture/function-spec-providers.mjs +1 -1
- package/runtime/scripts/hfs/architecture/operation-contract-roots.mjs +53 -0
- package/runtime/scripts/hfs/architecture/reachability.mjs +1 -1
- package/runtime/scripts/hfs/architecture/sql-owner.mjs +17 -6
- package/runtime/scripts/hfs/architecture/sql-token-forms.mjs +105 -0
- package/runtime/scripts/hfs/architecture/sql-tokens.mjs +38 -52
- package/runtime/scripts/hfs/architecture/supabase-keys.mjs +261 -0
- package/runtime/scripts/hfs/architecture/transport-owner.mjs +49 -3
- package/runtime/scripts/hfs/architecture/typescript.mjs +1 -1
- package/runtime/scripts/hfs/declaration-shape.mjs +4 -4
- package/runtime/scripts/hfs/declaration-slots.mjs +14 -3
- package/runtime/scripts/hfs/manifest-shape.mjs +10 -6
- package/runtime/scripts/hfs/rule-params-shape.mjs +18 -6
- package/runtime/scripts/hfs/rules/database-constants.mjs +26 -0
- package/runtime/scripts/hfs/rules/database-migrations.mjs +489 -0
- package/runtime/scripts/hfs/slot-side-problems.mjs +3 -3
- package/runtime/scripts/hfs/slots.mjs +2 -1
- package/runtime/scripts/hfs/view.mjs +2 -1
- package/runtime/scripts/lib/in-order.mjs +71 -0
- package/runtime/scripts/lib/language.mjs +94 -1
- package/runtime/scripts/lib/package-at.mjs +62 -0
- package/runtime/scripts/lib/same-text.mjs +5 -0
- package/runtime/scripts/lib/secret-patterns.mjs +58 -9
- package/runtime/scripts/lib/source-phrases.mjs +34 -0
- package/runtime/scripts/lib/ts-analysis.mjs +10 -0
- package/size-and-state-budget.mjs +3 -3
- package/surface-rules.mjs +230 -0
- package/swr-family.mjs +136 -7
- 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
|
+
}
|