@starci/eslint-grammar-core 0.13.0 → 0.15.0

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 (73) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +4 -1
  3. package/dist/app-shell.d.ts +15 -0
  4. package/dist/app-shell.js +224 -0
  5. package/dist/chip-label.d.ts +14 -0
  6. package/dist/chip-label.js +117 -0
  7. package/dist/drawing.d.ts +8 -0
  8. package/dist/drawing.js +59 -0
  9. package/dist/grammar-boundary.d.ts +12 -0
  10. package/dist/grammar-boundary.js +88 -0
  11. package/dist/icon.d.ts +27 -0
  12. package/dist/icon.js +293 -0
  13. package/dist/index.d.ts +45 -0
  14. package/dist/index.js +73 -0
  15. package/dist/lib/app-shell-cluster.d.ts +6 -0
  16. package/dist/lib/app-shell-cluster.js +75 -0
  17. package/dist/lib/app-shell-create.d.ts +5 -0
  18. package/dist/lib/app-shell-create.js +37 -0
  19. package/dist/lib/app-shell-owner-source.d.ts +11 -0
  20. package/dist/lib/app-shell-owner-source.js +159 -0
  21. package/dist/lib/app-shell-owner.d.ts +7 -0
  22. package/dist/lib/app-shell-owner.js +97 -0
  23. package/dist/lib/app-shell-scope.d.ts +13 -0
  24. package/dist/lib/app-shell-scope.js +93 -0
  25. package/dist/lib/app-shell-visible.d.ts +13 -0
  26. package/dist/lib/app-shell-visible.js +117 -0
  27. package/dist/lib/app-shell.d.ts +27 -0
  28. package/dist/lib/app-shell.js +185 -0
  29. package/dist/lib/rule-contract.d.ts +12 -0
  30. package/dist/lib/rule-contract.js +23 -0
  31. package/dist/lib/status-tones.generated.d.ts +2 -0
  32. package/dist/lib/status-tones.generated.js +8 -0
  33. package/dist/lib/types.d.ts +113 -0
  34. package/dist/lib/types.js +2 -0
  35. package/dist/lib/why.d.ts +8 -0
  36. package/dist/lib/why.js +42 -0
  37. package/dist/menu-entries.d.ts +27 -0
  38. package/dist/menu-entries.js +142 -0
  39. package/dist/status-colors.d.ts +13 -0
  40. package/dist/status-colors.js +143 -0
  41. package/dist/tokens.d.ts +11 -0
  42. package/dist/tokens.js +166 -0
  43. package/dist/typography.d.ts +13 -0
  44. package/dist/typography.js +72 -0
  45. package/dist/vendor-boundary.d.ts +5 -0
  46. package/dist/vendor-boundary.js +25 -0
  47. package/docs/chip-label.md +30 -0
  48. package/docs/grammar-boundary.md +1 -1
  49. package/docs/status-colors.md +2 -2
  50. package/package.json +18 -15
  51. package/runtime/knowledge/hfs/canon-pins.yaml +22 -14
  52. package/runtime/knowledge/hfs/rules.yaml +23 -0
  53. package/app-shell.mjs +0 -208
  54. package/drawing.mjs +0 -60
  55. package/grammar-boundary.mjs +0 -94
  56. package/icon.mjs +0 -313
  57. package/index.d.ts +0 -28
  58. package/index.mjs +0 -78
  59. package/lib/app-shell-cluster.mjs +0 -66
  60. package/lib/app-shell-create.mjs +0 -33
  61. package/lib/app-shell-owner-source.mjs +0 -124
  62. package/lib/app-shell-owner.mjs +0 -83
  63. package/lib/app-shell-scope.mjs +0 -79
  64. package/lib/app-shell-visible.mjs +0 -92
  65. package/lib/app-shell.mjs +0 -148
  66. package/lib/rule-contract.mjs +0 -11
  67. package/lib/status-tones.generated.mjs +0 -9
  68. package/lib/why.mjs +0 -38
  69. package/menu-entries.mjs +0 -146
  70. package/status-colors.mjs +0 -140
  71. package/tokens.mjs +0 -164
  72. package/typography.mjs +0 -81
  73. package/vendor-boundary.mjs +0 -29
package/icon.mjs DELETED
@@ -1,313 +0,0 @@
1
- import { defineFamilyRules } from "./lib/rule-contract.mjs"
2
- /**
3
- * The rules that hold `icon.md`.
4
- *
5
- * Both exist because of the same escape, found by hand rather than by a gate: one leaf imported a
6
- * caret straight from the glyph library, at a size off both steps and a cut outside the two the
7
- * icon leaf offers. Nothing reported it. The vendor rule that guards the component library names
8
- * that library only, and the token rules that police the scale read SPACING utilities - gaps,
9
- * insets, offsets - so a glyph size is outside every pattern they match.
10
- *
11
- * The lesson is worth stating where the next rule gets written: a rule that names one vendor
12
- * protects one vendor, and a rule that scans one family of utilities protects that family. Neither
13
- * generalises on its own, and the gap between them is exactly where a third step gets invented.
14
- */
15
-
16
- import { attribute, isClassAttribute, staticText, tagName } from "@starci/eslint-canon-fe/lib/ast.mjs"
17
- import { canonicalPackages } from "@starci/eslint-canon-fe/lib/fe-stack.mjs"
18
- import { hfsOf } from "@starci/eslint-canon-fe/lib/hfs.mjs"
19
- import { fileOf, inSlot, isComponentFile, isProductSource, kindOfFile, roleOfFile } from "@starci/eslint-canon-fe/lib/scope.mjs"
20
- import { why } from "./lib/why.mjs"
21
-
22
- /** The one module allowed to name a glyph from a library: the entry of the leaf owner named `Icon`. */
23
- const ICON_LEAF = { kind: "leaves", owner: "Icon" }
24
-
25
- /** Repeated goal / metric cells are text-led in the reference: the entry of the composite owner named `LabelledProgressRow`. */
26
- const LABELLED_PROGRESS_ROW = { kind: "composites", owner: "LabelledProgressRow" }
27
-
28
- /**
29
- * Package roots that ship glyphs.
30
- *
31
- * Matched as a PREFIX so a subpath cannot walk around it - the escape that started this was
32
- * `@phosphor-icons/react/dist/ssr`, which an equality check on the package name does not see.
33
- */
34
- const GLYPH_PACKAGES = [
35
- "@phosphor-icons/",
36
- "lucide-react",
37
- "react-icons",
38
- "@heroicons/",
39
- "@tabler/icons",
40
- "@fortawesome/",
41
- "@mui/icons-material",
42
- "@fluentui/react-icons",
43
- "iconsax-react",
44
- "react-feather",
45
- ]
46
-
47
- /** Package-name signal for a glyph catalogue not yet listed explicitly. */
48
- const GLYPH_PACKAGE_NAME = /(?:icon|glyph|lucide|feather|tabler|fortawesome)/i
49
-
50
- /** The two optical families of the heroicons family: the 24px outline heading cut and the 16px solid micro cut. */
51
- const GLYPH_CUTS = ["24/outline", "16/solid"]
52
-
53
- /**
54
- * The closed glyph surface: each package of the one application icon set (fe-stack `icons`: upstream `@heroicons/react`,
55
- * used first, and StarCi's custom-only `@starci/heroicons` for the glyphs it lacks) on each of the two cuts. Read from the
56
- * catalog, so the set is named once.
57
- */
58
- const glyphSurface = () => canonicalPackages("icons").flatMap((name) => GLYPH_CUTS.map((cut) => `${name}/${cut}`))
59
-
60
- /**
61
- * True when the file is the entry (slot role `entry`) of the component owner named `owner` in the layer `kind`.
62
- * The slot says which layer and which role; the owner's own folder name is the one fact it cannot say, so it is compared.
63
- */
64
- const isEntryOf = (context, { kind, owner }) =>
65
- isComponentFile(context) && kindOfFile(context) === kind && roleOfFile(context) === "entry" &&
66
- hfsOf(context).relative(fileOf(context)).split("/").slice(-2)[0] === owner
67
-
68
- /**
69
- * True when this file owns glyphs: the icon leaf, which owns the meaning-to-glyph map, or the toast module (`fe.modules.toast`),
70
- * whose region draws the status glyph of each toast and sits in the modules tier, below every component it could ask.
71
- */
72
- const isIconLeafFile = (context) => isEntryOf(context, ICON_LEAF) || inSlot(context, "fe.modules.toast")
73
-
74
- /** True for the reusable repeated metric cell whose reference contains no decorative glyph. */
75
- const isLabelledProgressRowFile = (context) => isEntryOf(context, LABELLED_PROGRESS_ROW)
76
-
77
- /** True when an import source resolves into a glyph library, subpaths included. */
78
- const isGlyphImport = (source) => {
79
- const value = typeof source === "string" ? source : ""
80
- const isExternalPackage = value !== "" && !value.startsWith(".") && !value.startsWith("@/")
81
- return GLYPH_PACKAGES.some((pkg) => value === pkg || value.startsWith(pkg)) ||
82
- (isExternalPackage && GLYPH_PACKAGE_NAME.test(value))
83
- }
84
-
85
- /** A glyph size written as a fraction of a step, or as an arbitrary value. */
86
- const OFF_SCALE_GLYPH = /\bsize-(?:\d+\.\d+|\[[^\]]+\])/
87
-
88
- // -- ICON-6 ----------------------------------------------------------------------------------------
89
-
90
- /** A glyph library is named in one file, and a caller asks for a MEANING instead. */
91
- export const noVendorIconOutsideIconLeaf = {
92
- meta: {
93
- type: "problem",
94
- docs: { description: "Glyph libraries are imported only by the icon leaf." },
95
- schema: [],
96
- messages: {
97
- vendor:
98
- "`{{source}}` is a glyph library, imported outside `{{leaf}}`. Reaching for it here decides three things at the call site - which library, which glyph, how big - and the next screen answers all three differently. Ask for the MEANING instead; if no meaning fits, add one to the icon leaf, which is where the answer is kept once.",
99
- },
100
- },
101
- create(context) {
102
- if (!isProductSource(context) || isIconLeafFile(context)) return {}
103
- return {
104
- ImportDeclaration(node) {
105
- const source = node.source && node.source.value
106
- if (!isGlyphImport(source)) return
107
- context.report({ node, messageId: "vendor", data: { source, leaf: `${ICON_LEAF.kind}/${ICON_LEAF.owner}/index.tsx` } })
108
- },
109
- }
110
- },
111
- }
112
-
113
- // -- ICON-7 ----------------------------------------------------------------------------------------
114
-
115
- /**
116
- * The icon leaf owns the map, but that ownership does not license a second glyph vocabulary: every glyph comes from the
117
- * heroicons family on its two cuts. Phosphor, Iconify, Lucide and every other glyph catalogue are refused, including the
118
- * ones no list can name in advance.
119
- */
120
- export const heroiconsIsTheGlyphVendor = {
121
- meta: {
122
- type: "problem",
123
- docs: { description: "Only the Heroicons heading and micro families (`@heroicons/react`, then `@starci/heroicons`) may supply product glyphs." },
124
- schema: [],
125
- messages: {
126
- vendor:
127
- "`{{source}}` is outside StarCi's closed glyph surface ({{surface}}). Use upstream `@heroicons/react/24/outline` or `16/solid` when the icon exists; use the matching `@starci/heroicons` subpath only for a custom cut missing upstream, and add a missing glyph there. The StarCi package never re-exports upstream icons. Phosphor, Iconify and every other glyph catalogue are refused: two sets draw the same meaning with two strokes, two grids and two weights.",
128
- },
129
- },
130
- create(context) {
131
- if (!isProductSource(context)) return {}
132
- return {
133
- ImportDeclaration(node) {
134
- const source = node.source && node.source.value
135
- const surface = glyphSurface()
136
- if (!isGlyphImport(source) || surface.includes(String(source))) return
137
- context.report({ node, messageId: "vendor", data: { source, surface: surface.map((name) => `\`${name}\``).join(", ") } })
138
- },
139
- }
140
- },
141
- }
142
-
143
- // -- ICON-1 ----------------------------------------------------------------------------------------
144
-
145
- /** A glyph takes one of the two steps; a fraction or an arbitrary value is a third. */
146
- export const noOffScaleGlyphSize = {
147
- meta: {
148
- type: "problem",
149
- docs: { description: "A glyph is sized by one of the two steps, never by a fraction or an arbitrary value." },
150
- schema: [],
151
- messages: {
152
- offScale:
153
- "`{{cls}}` is a glyph size off both steps. A third step is one nobody applies consistently - the author picks it for a reason true on their screen, and everyone after copies the nearest of three. Use the step the icon leaf offers, and if the case genuinely needs a size neither step gives, that is a change to the two steps rather than an exception here.",
154
- },
155
- },
156
- create(context) {
157
- if (!isProductSource(context)) return {}
158
- const scan = (node, text) => {
159
- if (!text) return
160
- const hit = text.match(OFF_SCALE_GLYPH)
161
- if (hit) context.report({ node, messageId: "offScale", data: { cls: hit[0] } })
162
- }
163
- return {
164
- JSXAttribute(node) {
165
- if (!isClassAttribute(node)) return
166
- scan(node, staticText(node.value))
167
- },
168
- VariableDeclarator(node) {
169
- scan(node, staticText(node.init))
170
- },
171
- }
172
- },
173
- }
174
-
175
- // -- ICON-1 (call site) ----------------------------------------------------------------------------
176
-
177
- /** A size utility on the class of a glyph: `size-5`, `w-4`, `h-6`, `min-w-4`. */
178
- const SIZE_UTILITY = /(?:^|\s)(?:size|w|h|min-w|min-h|max-w|max-h)-\S+/
179
-
180
- /** The icon's size is its usage's: the call site passes the glyph and never a size class. */
181
- export const noIconSizeClass = {
182
- meta: {
183
- type: "problem",
184
- docs: { description: "A glyph component takes its size from its usage (the 20px leading step is size-5), never from a size class at the call site." },
185
- schema: [],
186
- messages: {
187
- size:
188
- "`{{cls}}` sizes a glyph at the call site. The size of a glyph is its usage's (`Icon` and the Grammar components that draw one: 24px heading, 20px leading = size-5, 16px chip), so the app passes the glyph and no size class; a size written here is a third step nobody applies the same way twice.",
189
- },
190
- },
191
- create(context) {
192
- if (!isProductSource(context) || isIconLeafFile(context)) return {}
193
- return {
194
- JSXOpeningElement(node) {
195
- const name = tagName(node)
196
- if (name === null || !/Icon$/.test(name)) return
197
- const hit = SIZE_UTILITY.exec(staticText(attribute(node, "className")?.value) ?? "")
198
- if (hit) context.report({ node, messageId: "size", data: { cls: hit[0].trim() } })
199
- },
200
- }
201
- },
202
- }
203
-
204
- // -- ICON-10 ---------------------------------------------------------------------------------------
205
-
206
- /** A repeated metric cell does not invent a feature glyph absent from the reference. */
207
- export const noDecorativeIconInMetricCell = {
208
- meta: {
209
- type: "problem",
210
- docs: { description: "Repeated metric cells stay text-led unless the reference explicitly contains an icon." },
211
- schema: [],
212
- messages: {
213
- decorative:
214
- "`LabelledProgressRow` is a repeated metric cell, not a feature entry. Its reference is text-led, so an `Icon` here invents emphasis and repeats glyph meanings across the grid. Keep the label and figure; reserve tiny glyphs for generic state or action semantics actually present in the reference.",
215
- },
216
- },
217
- create(context) {
218
- if (!isLabelledProgressRowFile(context)) return {}
219
- return {
220
- JSXOpeningElement(node) {
221
- if (node.name?.type === "JSXIdentifier" && node.name.name === "Icon") {
222
- context.report({ node, messageId: "decorative" })
223
- }
224
- },
225
- }
226
- },
227
- }
228
-
229
- /** The immediately preceding JSDoc declares this exported component as a brand mark. */
230
- const declaresBrandMark = (sourceCode, node) => {
231
- const comment = sourceCode.getCommentsBefore(node).at(-1)
232
- return comment?.type === "Block" && comment.value.startsWith("*") &&
233
- /(?:^|\n)\s*\*?\s*@brand-mark(?:\s|$)/.test(comment.value) &&
234
- sourceCode.text.slice(comment.range[1], node.range[0]).trim() === ""
235
- }
236
-
237
- /** A marker belongs to the exported component itself, never to a sibling or an enclosing file. */
238
- const isDeclaredBrandMarkFunction = (sourceCode, node) => {
239
- let declaration = node
240
- if (node.parent?.type === "VariableDeclarator") {
241
- if (node.parent.init !== node || node.parent.id.type !== "Identifier") return false
242
- declaration = node.parent.parent
243
- if (declaration.type !== "VariableDeclaration" || declaration.declarations.length !== 1) return false
244
- }
245
- const exported = declaration.parent
246
- if (!["ExportNamedDeclaration", "ExportDefaultDeclaration"].includes(exported?.type)) return false
247
- return declaresBrandMark(sourceCode, exported) || declaresBrandMark(sourceCode, declaration)
248
- }
249
-
250
- /** An SVG belongs to its nearest function; marking another component cannot license it. */
251
- const inDeclaredBrandMark = (sourceCode, node) => {
252
- for (let ancestor = node.parent; ancestor; ancestor = ancestor.parent) {
253
- if (["FunctionDeclaration", "FunctionExpression", "ArrowFunctionExpression"].includes(ancestor.type)) {
254
- return isDeclaredBrandMarkFunction(sourceCode, ancestor)
255
- }
256
- }
257
- return false
258
- }
259
-
260
- /** Inline SVG belongs only to an explicitly declared brand mark in a leaf entry. */
261
- export const noInlineSvgOutsideBrandMarkLeaf = {
262
- meta: {
263
- type: "problem",
264
- docs: { description: "FE_ICON_INLINE_SVG: inline SVG is drawn only by an exported leaf entry component with a @brand-mark JSDoc declaration.", url: "knowledge/hfs/rules.yaml#R286" },
265
- schema: [],
266
- messages: {
267
- inlineSvg:
268
- "FE_ICON_INLINE_SVG: inline `<svg>` draws a second glyph vocabulary. Use the Icon leaf for product glyphs. A brand mark may draw its own SVG only in a leaf entry whose exported component is immediately documented with `@brand-mark` in JSDoc.",
269
- },
270
- },
271
- create(context) {
272
- if (!isProductSource(context)) return {}
273
- const sourceCode = context.sourceCode ?? context.getSourceCode()
274
- const isLeafEntry = isComponentFile(context) && kindOfFile(context) === "leaves" && roleOfFile(context) === "entry"
275
- return {
276
- JSXOpeningElement(node) {
277
- if (node.name?.type !== "JSXIdentifier" || node.name.name !== "svg") return
278
- if (isLeafEntry && inDeclaredBrandMark(sourceCode, node)) return
279
- context.report({ node, messageId: "inlineSvg" })
280
- },
281
- }
282
- },
283
- }
284
-
285
- /** The finding code for the copy-and-glyph law's inline SVG enforcer. */
286
- Object.assign(why, {
287
- "no-inline-svg-outside-brand-mark-leaf": {
288
- code: "FE_ICON_INLINE_SVG",
289
- en: "`<file>` draws an inline SVG outside an exported leaf entry component declared as a brand mark.",
290
- fix: "Use the Icon leaf for product glyphs; declare an original brand mark with @brand-mark JSDoc on its exported leaf entry component.",
291
- },
292
- })
293
-
294
- /** The rules this law contributes to the plugin. */
295
- export const rules = {
296
- "no-vendor-icon-outside-icon-leaf": noVendorIconOutsideIconLeaf,
297
- "heroicons-is-the-glyph-vendor": heroiconsIsTheGlyphVendor,
298
- "no-off-scale-glyph-size": noOffScaleGlyphSize,
299
- "no-icon-size-class": noIconSizeClass,
300
- "no-decorative-icon-in-metric-cell": noDecorativeIconInMetricCell,
301
- "no-inline-svg-outside-brand-mark-leaf": noInlineSvgOutsideBrandMarkLeaf,
302
- }
303
-
304
- defineFamilyRules(rules, "knowledge/hfs/rules.yaml#R93")
305
-
306
- /**
307
- * The level this law asks for, as the plugin's own opinion.
308
- *
309
- * A consuming repository's `eslint.config.mjs` stays the authority on what is actually switched on.
310
- * Both rules are exact - a package prefix and a size pattern - so neither carries the false-positive
311
- * risk that would justify adopting it at `warn`.
312
- */
313
- export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-grammar/${name}`, "error"]))
package/index.d.ts DELETED
@@ -1,28 +0,0 @@
1
- import type { ESLint, Linter, Rule } from "eslint"
2
-
3
- export type GrammarFamilyId = "core" | "offset-pop"
4
-
5
- export type GrammarHfsView = {
6
- readonly repoRoot: string
7
- readonly profile: "fe"
8
- /** The selected family id: `core` or `offset-pop`. */
9
- readonly grammar: GrammarFamilyId
10
- readonly slotOf: (file: string) => string | null
11
- }
12
-
13
- export declare const lawOwners: readonly string[]
14
- export declare const ruleOwners: Readonly<Record<string, string>>
15
- export declare const ruleDeclarations: readonly { readonly law: string; readonly name: string }[]
16
- export declare const rules: Readonly<Record<string, Rule.RuleModule>>
17
- export declare const recommended: Readonly<Record<string, "error">>
18
- export declare const linterOptions: Readonly<Linter.LinterOptions>
19
- export declare const why: Readonly<Record<string, { readonly code: string; readonly en: string; readonly fix: string }>>
20
-
21
- /** Add every selected-family rule to the shared universal flat config. */
22
- export declare const coreGrammarConfig: (input: {
23
- readonly hfs: GrammarHfsView
24
- readonly family?: GrammarFamilyId
25
- }) => Linter.Config[]
26
-
27
- declare const plugin: ESLint.Plugin
28
- export default plugin
package/index.mjs DELETED
@@ -1,78 +0,0 @@
1
- /** Core Grammar API rules, versioned with the Grammar family that owns their concrete vocabulary. */
2
- import tsParser from "@typescript-eslint/parser"
3
- import { SOURCE_FILES, linterOptions } from "@starci/eslint-canon-fe/lib/config.mjs"
4
- import { rules as appShellRules } from "./app-shell.mjs"
5
- import { rules as drawingRules } from "./drawing.mjs"
6
- import { rules as grammarBoundaryRules } from "./grammar-boundary.mjs"
7
- import { rules as iconRules } from "./icon.mjs"
8
- import { rules as menuEntriesRules } from "./menu-entries.mjs"
9
- import { rules as statusColorsRules } from "./status-colors.mjs"
10
- import { rules as tokensRules } from "./tokens.mjs"
11
- import { rules as typographyRules } from "./typography.mjs"
12
- import { rules as vendorRules } from "./vendor-boundary.mjs"
13
-
14
- const CONTRIBUTIONS = [
15
- { law: "app-shell", rules: appShellRules },
16
- { law: "drawing", rules: drawingRules },
17
- { law: "grammar-boundary", rules: grammarBoundaryRules },
18
- { law: "icon", rules: iconRules },
19
- { law: "menu-entries", rules: menuEntriesRules },
20
- { law: "status-colors", rules: statusColorsRules },
21
- { law: "tokens", rules: tokensRules },
22
- { law: "typography", rules: typographyRules },
23
- { law: "vendor-boundary", rules: vendorRules },
24
- ]
25
-
26
- /** Each concrete Grammar law has one gathered owner. */
27
- export const lawOwners = CONTRIBUTIONS.map(({ law }) => law)
28
-
29
- /** Preserve declarations before gathering so duplicate rule names remain observable. */
30
- export const ruleDeclarations = CONTRIBUTIONS.flatMap(({ law, rules }) => Object.keys(rules).map((name) => ({ law, name })))
31
-
32
- /** The law that owns each published rule ID. */
33
- export const ruleOwners = Object.fromEntries(ruleDeclarations.map(({ law, name }) => [name, law]))
34
-
35
- /** Every rule of the selected family contract. */
36
- export const rules = Object.fromEntries(CONTRIBUTIONS.flatMap(({ rules }) => Object.entries(rules)))
37
-
38
- /** Every family rule runs at error, with no consumer override surface. */
39
- export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-grammar/${name}`, "error"]))
40
-
41
- export { why } from "./lib/why.mjs"
42
- export { linterOptions }
43
-
44
- const plugin = { meta: { name: "@starci/eslint-grammar-core" }, rules }
45
- export default plugin
46
-
47
- /** The families this factory serves: core, and offset-pop through its own package entry. */
48
- const SERVED_FAMILIES = Object.freeze(["core", "offset-pop"])
49
-
50
- /**
51
- * Add the complete selected-family contract to the universal front-end flat config.
52
- * Offset Pop uses the inherited Common renderer contract through its own package entry.
53
- * @param {{ hfs: object, family?: "core" | "offset-pop" }} input - The selected FE slot view and family.
54
- * @returns {object[]} A typed source block with every family rule at error.
55
- */
56
- export const coreGrammarConfig = ({ hfs, family = "core" } = {}) => {
57
- if (!hfs || typeof hfs.slotOf !== "function" || typeof hfs.repoRoot !== "string" || hfs.profile !== "fe") {
58
- throw new Error("coreGrammarConfig needs the FE view from loadHfs(import.meta.url)")
59
- }
60
- if (!SERVED_FAMILIES.includes(family) || hfs.grammar !== family) {
61
- throw new Error(`coreGrammarConfig family ${family} does not match the selected Grammar ${hfs.grammar}`)
62
- }
63
- const missing = Object.keys(rules).filter((name) => recommended[`starci-grammar/${name}`] !== "error")
64
- if (missing.length) throw new Error(`coreGrammarConfig requires every family rule at error: ${missing.join(", ")}`)
65
- return [{
66
- files: [...SOURCE_FILES],
67
- languageOptions: {
68
- parser: tsParser,
69
- ecmaVersion: "latest",
70
- sourceType: "module",
71
- parserOptions: { projectService: true, tsconfigRootDir: hfs.repoRoot, ecmaFeatures: { jsx: true } },
72
- },
73
- linterOptions: { ...linterOptions },
74
- plugins: { "starci-grammar": plugin },
75
- settings: { starci: { hfs } },
76
- rules: { ...recommended },
77
- }]
78
- }
@@ -1,66 +0,0 @@
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
- }
@@ -1,33 +0,0 @@
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
- }
@@ -1,124 +0,0 @@
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 "@starci/eslint-canon-fe/lib/bindings.mjs"
9
- import { resolveImport } from "@starci/eslint-canon-fe/lib/scope.mjs"
10
- import { hfsOf } from "@starci/eslint-canon-fe/lib/hfs.mjs"
11
- import { walk } from "@starci/eslint-canon-fe/lib/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