@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/dist/icon.js ADDED
@@ -0,0 +1,293 @@
1
+ import { defineFamilyRules, defineRule } from "./lib/rule-contract.js";
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
+ import { attribute, isClassAttribute, staticText, tagName } from "@starci/eslint-canon-fe/lib/ast.mjs";
16
+ import { canonicalPackages } from "@starci/eslint-canon-fe/lib/fe-stack.mjs";
17
+ import { hfsOf } from "@starci/eslint-canon-fe/lib/hfs.mjs";
18
+ import { fileOf, inSlot, isComponentFile, isProductSource, kindOfFile, roleOfFile } from "@starci/eslint-canon-fe/lib/scope.mjs";
19
+ import { why } from "./lib/why.js";
20
+ /** The one module allowed to name a glyph from a library: the entry of the leaf owner named `Icon`. */
21
+ const ICON_LEAF = { kind: "leaves", owner: "Icon" };
22
+ /** Repeated goal / metric cells are text-led in the reference: the entry of the composite owner named `LabelledProgressRow`. */
23
+ const LABELLED_PROGRESS_ROW = { kind: "composites", owner: "LabelledProgressRow" };
24
+ /**
25
+ * Package roots that ship glyphs.
26
+ *
27
+ * Matched as a PREFIX so a subpath cannot walk around it - the escape that started this was
28
+ * `@phosphor-icons/react/dist/ssr`, which an equality check on the package name does not see.
29
+ */
30
+ const GLYPH_PACKAGES = [
31
+ "@phosphor-icons/",
32
+ "lucide-react",
33
+ "react-icons",
34
+ "@heroicons/",
35
+ "@tabler/icons",
36
+ "@fortawesome/",
37
+ "@mui/icons-material",
38
+ "@fluentui/react-icons",
39
+ "iconsax-react",
40
+ "react-feather",
41
+ ];
42
+ /** Package-name signal for a glyph catalogue not yet listed explicitly. */
43
+ const GLYPH_PACKAGE_NAME = /(?:icon|glyph|lucide|feather|tabler|fortawesome)/i;
44
+ /** The two optical families of the heroicons family: the 24px outline heading cut and the 16px solid micro cut. */
45
+ const GLYPH_CUTS = ["24/outline", "16/solid"];
46
+ /**
47
+ * The closed glyph surface: each package of the one application icon set (fe-stack `icons`: upstream `@heroicons/react`,
48
+ * used first, and StarCi's custom-only `@starci/heroicons` for the glyphs it lacks) on each of the two cuts. Read from the
49
+ * catalog, so the set is named once.
50
+ */
51
+ const glyphSurface = () => canonicalPackages("icons").flatMap((name) => GLYPH_CUTS.map((cut) => `${name}/${cut}`));
52
+ /**
53
+ * True when the file is the entry (slot role `entry`) of the component owner named `owner` in the layer `kind`.
54
+ * 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.
55
+ */
56
+ const isEntryOf = (context, { kind, owner }) => isComponentFile(context) && kindOfFile(context) === kind && roleOfFile(context) === "entry" &&
57
+ hfsOf(context).relative(fileOf(context)).split("/").slice(-2)[0] === owner;
58
+ /**
59
+ * True when this file owns glyphs: the icon leaf, which owns the meaning-to-glyph map, or the toast module (`fe.modules.toast`),
60
+ * whose region draws the status glyph of each toast and sits in the modules tier, below every component it could ask.
61
+ */
62
+ const isIconLeafFile = (context) => isEntryOf(context, ICON_LEAF) || inSlot(context, "fe.modules.toast");
63
+ /** True for the reusable repeated metric cell whose reference contains no decorative glyph. */
64
+ const isLabelledProgressRowFile = (context) => isEntryOf(context, LABELLED_PROGRESS_ROW);
65
+ /** True when an import source resolves into a glyph library, subpaths included. */
66
+ const isGlyphImport = (source) => {
67
+ const value = typeof source === "string" ? source : "";
68
+ const isExternalPackage = value !== "" && !value.startsWith(".") && !value.startsWith("@/");
69
+ return GLYPH_PACKAGES.some((pkg) => value === pkg || value.startsWith(pkg)) ||
70
+ (isExternalPackage && GLYPH_PACKAGE_NAME.test(value));
71
+ };
72
+ /** A glyph size written as a fraction of a step, or as an arbitrary value. */
73
+ const OFF_SCALE_GLYPH = /\bsize-(?:\d+\.\d+|\[[^\]]+\])/;
74
+ // -- ICON-6 ----------------------------------------------------------------------------------------
75
+ /** A glyph library is named in one file, and a caller asks for a MEANING instead. */
76
+ export const noVendorIconOutsideIconLeaf = defineRule({
77
+ meta: {
78
+ type: "problem",
79
+ docs: { description: "Glyph libraries are imported only by the icon leaf." },
80
+ schema: [],
81
+ messages: {
82
+ vendor: "`{{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.",
83
+ },
84
+ },
85
+ create(context) {
86
+ if (!isProductSource(context) || isIconLeafFile(context))
87
+ return {};
88
+ return {
89
+ ImportDeclaration(node) {
90
+ const source = node.source && node.source.value;
91
+ if (!isGlyphImport(source))
92
+ return;
93
+ context.report({ node, messageId: "vendor", data: { source, leaf: `${ICON_LEAF.kind}/${ICON_LEAF.owner}/index.tsx` } });
94
+ },
95
+ };
96
+ },
97
+ });
98
+ // -- ICON-7 ----------------------------------------------------------------------------------------
99
+ /**
100
+ * The icon leaf owns the map, but that ownership does not license a second glyph vocabulary: every glyph comes from the
101
+ * heroicons family on its two cuts. Phosphor, Iconify, Lucide and every other glyph catalogue are refused, including the
102
+ * ones no list can name in advance.
103
+ */
104
+ export const heroiconsIsTheGlyphVendor = defineRule({
105
+ meta: {
106
+ type: "problem",
107
+ docs: { description: "Only the Heroicons heading and micro families (`@heroicons/react`, then `@starci/heroicons`) may supply product glyphs." },
108
+ schema: [],
109
+ messages: {
110
+ vendor: "`{{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.",
111
+ },
112
+ },
113
+ create(context) {
114
+ if (!isProductSource(context))
115
+ return {};
116
+ return {
117
+ ImportDeclaration(node) {
118
+ const source = node.source && node.source.value;
119
+ const surface = glyphSurface();
120
+ if (!isGlyphImport(source) || surface.includes(String(source)))
121
+ return;
122
+ context.report({ node, messageId: "vendor", data: { source, surface: surface.map((name) => `\`${name}\``).join(", ") } });
123
+ },
124
+ };
125
+ },
126
+ });
127
+ // -- ICON-1 ----------------------------------------------------------------------------------------
128
+ /** A glyph takes one of the two steps; a fraction or an arbitrary value is a third. */
129
+ export const noOffScaleGlyphSize = defineRule({
130
+ meta: {
131
+ type: "problem",
132
+ docs: { description: "A glyph is sized by one of the two steps, never by a fraction or an arbitrary value." },
133
+ schema: [],
134
+ messages: {
135
+ offScale: "`{{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.",
136
+ },
137
+ },
138
+ create(context) {
139
+ if (!isProductSource(context))
140
+ return {};
141
+ const scan = (node, text) => {
142
+ if (!text)
143
+ return;
144
+ const hit = text.match(OFF_SCALE_GLYPH);
145
+ if (hit)
146
+ context.report({ node, messageId: "offScale", data: { cls: hit[0] } });
147
+ };
148
+ return {
149
+ JSXAttribute(node) {
150
+ if (!isClassAttribute(node))
151
+ return;
152
+ scan(node, staticText(node.value));
153
+ },
154
+ VariableDeclarator(node) {
155
+ scan(node, staticText(node.init));
156
+ },
157
+ };
158
+ },
159
+ });
160
+ // -- ICON-1 (call site) ----------------------------------------------------------------------------
161
+ /** A size utility on the class of a glyph: `size-5`, `w-4`, `h-6`, `min-w-4`. */
162
+ const SIZE_UTILITY = /(?:^|\s)(?:size|w|h|min-w|min-h|max-w|max-h)-\S+/;
163
+ /** The icon's size is its usage's: the call site passes the glyph and never a size class. */
164
+ export const noIconSizeClass = defineRule({
165
+ meta: {
166
+ type: "problem",
167
+ 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." },
168
+ schema: [],
169
+ messages: {
170
+ size: "`{{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.",
171
+ },
172
+ },
173
+ create(context) {
174
+ if (!isProductSource(context) || isIconLeafFile(context))
175
+ return {};
176
+ return {
177
+ JSXOpeningElement(node) {
178
+ const name = tagName(node);
179
+ if (name === null || !/Icon$/.test(name))
180
+ return;
181
+ const hit = SIZE_UTILITY.exec(staticText(attribute(node, "className")?.value) ?? "");
182
+ if (hit)
183
+ context.report({ node, messageId: "size", data: { cls: hit[0].trim() } });
184
+ },
185
+ };
186
+ },
187
+ });
188
+ // -- ICON-10 ---------------------------------------------------------------------------------------
189
+ /** A repeated metric cell does not invent a feature glyph absent from the reference. */
190
+ export const noDecorativeIconInMetricCell = defineRule({
191
+ meta: {
192
+ type: "problem",
193
+ docs: { description: "Repeated metric cells stay text-led unless the reference explicitly contains an icon." },
194
+ schema: [],
195
+ messages: {
196
+ decorative: "`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.",
197
+ },
198
+ },
199
+ create(context) {
200
+ if (!isLabelledProgressRowFile(context))
201
+ return {};
202
+ return {
203
+ JSXOpeningElement(node) {
204
+ if (node.name?.type === "JSXIdentifier" && node.name.name === "Icon") {
205
+ context.report({ node, messageId: "decorative" });
206
+ }
207
+ },
208
+ };
209
+ },
210
+ });
211
+ /** The immediately preceding JSDoc declares this exported component as a brand mark. */
212
+ const declaresBrandMark = (sourceCode, node) => {
213
+ const comment = sourceCode.getCommentsBefore(node).at(-1);
214
+ return comment?.type === "Block" && comment.value.startsWith("*") &&
215
+ /(?:^|\n)\s*\*?\s*@brand-mark(?:\s|$)/.test(comment.value) &&
216
+ sourceCode.text.slice(comment.range[1], node.range[0]).trim() === "";
217
+ };
218
+ /** A marker belongs to the exported component itself, never to a sibling or an enclosing file. */
219
+ const isDeclaredBrandMarkFunction = (sourceCode, node) => {
220
+ let declaration = node;
221
+ if (node.parent?.type === "VariableDeclarator") {
222
+ if (node.parent.init !== node || node.parent.id.type !== "Identifier")
223
+ return false;
224
+ declaration = node.parent.parent;
225
+ if (declaration.type !== "VariableDeclaration" || declaration.declarations.length !== 1)
226
+ return false;
227
+ }
228
+ const exported = declaration.parent;
229
+ if (exported?.type !== "ExportNamedDeclaration" && exported?.type !== "ExportDefaultDeclaration")
230
+ return false;
231
+ return declaresBrandMark(sourceCode, exported) || declaresBrandMark(sourceCode, declaration);
232
+ };
233
+ /** An SVG belongs to its nearest function; marking another component cannot license it. */
234
+ const inDeclaredBrandMark = (sourceCode, node) => {
235
+ for (let ancestor = node.parent; ancestor; ancestor = ancestor.parent) {
236
+ if (ancestor.type === "FunctionDeclaration" || ancestor.type === "FunctionExpression" || ancestor.type === "ArrowFunctionExpression") {
237
+ return isDeclaredBrandMarkFunction(sourceCode, ancestor);
238
+ }
239
+ }
240
+ return false;
241
+ };
242
+ /** Inline SVG belongs only to an explicitly declared brand mark in a leaf entry. */
243
+ export const noInlineSvgOutsideBrandMarkLeaf = defineRule({
244
+ meta: {
245
+ type: "problem",
246
+ 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" },
247
+ schema: [],
248
+ messages: {
249
+ inlineSvg: "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.",
250
+ },
251
+ },
252
+ create(context) {
253
+ if (!isProductSource(context))
254
+ return {};
255
+ const sourceCode = context.sourceCode;
256
+ const isLeafEntry = isComponentFile(context) && kindOfFile(context) === "leaves" && roleOfFile(context) === "entry";
257
+ return {
258
+ JSXOpeningElement(node) {
259
+ if (node.name?.type !== "JSXIdentifier" || node.name.name !== "svg")
260
+ return;
261
+ if (isLeafEntry && inDeclaredBrandMark(sourceCode, node))
262
+ return;
263
+ context.report({ node, messageId: "inlineSvg" });
264
+ },
265
+ };
266
+ },
267
+ });
268
+ /** The finding code for the copy-and-glyph law's inline SVG enforcer. */
269
+ Object.assign(why, {
270
+ "no-inline-svg-outside-brand-mark-leaf": {
271
+ code: "FE_ICON_INLINE_SVG",
272
+ en: "`<file>` draws an inline SVG outside an exported leaf entry component declared as a brand mark.",
273
+ fix: "Use the Icon leaf for product glyphs; declare an original brand mark with @brand-mark JSDoc on its exported leaf entry component.",
274
+ },
275
+ });
276
+ /** The rules this law contributes to the plugin. */
277
+ export const rules = {
278
+ "no-vendor-icon-outside-icon-leaf": noVendorIconOutsideIconLeaf,
279
+ "heroicons-is-the-glyph-vendor": heroiconsIsTheGlyphVendor,
280
+ "no-off-scale-glyph-size": noOffScaleGlyphSize,
281
+ "no-icon-size-class": noIconSizeClass,
282
+ "no-decorative-icon-in-metric-cell": noDecorativeIconInMetricCell,
283
+ "no-inline-svg-outside-brand-mark-leaf": noInlineSvgOutsideBrandMarkLeaf,
284
+ };
285
+ defineFamilyRules(rules, "knowledge/hfs/rules.yaml#R93");
286
+ /**
287
+ * The level this law asks for, as the plugin's own opinion.
288
+ *
289
+ * A consuming repository's `eslint.config.mjs` stays the authority on what is actually switched on.
290
+ * Both rules are exact - a package prefix and a size pattern - so neither carries the false-positive
291
+ * risk that would justify adopting it at `warn`.
292
+ */
293
+ export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-grammar/${name}`, "error"]));
@@ -0,0 +1,45 @@
1
+ /** Core Grammar API rules, versioned with the Grammar family that owns their concrete vocabulary. */
2
+ import type { ESLint, Linter, Rule } from "eslint";
3
+ /** The Grammar families: `core`, and `offset-pop` through its own package entry. */
4
+ export type GrammarFamilyId = "core" | "offset-pop";
5
+ /** The FE slot view `loadHfs(import.meta.url)` gives, as the factory reads it. */
6
+ export type GrammarHfsView = {
7
+ readonly repoRoot: string;
8
+ readonly profile: "fe";
9
+ /** The selected family id: `core` or `offset-pop`. */
10
+ readonly grammar: GrammarFamilyId;
11
+ readonly slotOf: (file: string) => string | null;
12
+ };
13
+ /** Each concrete Grammar law has one gathered owner. */
14
+ export declare const lawOwners: readonly string[];
15
+ /** Preserve declarations before gathering so duplicate rule names remain observable. */
16
+ export declare const ruleDeclarations: readonly {
17
+ readonly law: string;
18
+ readonly name: string;
19
+ }[];
20
+ /** The law that owns each published rule ID. */
21
+ export declare const ruleOwners: Readonly<Record<string, string>>;
22
+ /** Every rule of the selected family contract. */
23
+ export declare const rules: Readonly<Record<string, Rule.RuleModule>>;
24
+ /** Every family rule runs at error, with no consumer override surface. */
25
+ export declare const recommended: Readonly<Record<string, "error">>;
26
+ /** The failure-code guidance of every rule. */
27
+ export declare const why: Readonly<Record<string, {
28
+ readonly code: string;
29
+ readonly en: string;
30
+ readonly fix: string;
31
+ }>>;
32
+ /** The linter options of the universal canon: no inline configuration, no unused disable directive. */
33
+ export declare const linterOptions: Readonly<Linter.LinterOptions>;
34
+ declare const plugin: ESLint.Plugin;
35
+ export default plugin;
36
+ /**
37
+ * Add the complete selected-family contract to the universal front-end flat config.
38
+ * Offset Pop uses the inherited Common renderer contract through its own package entry.
39
+ * @param input - The selected FE slot view and family.
40
+ * @returns A typed source block with every family rule at error.
41
+ */
42
+ export declare const coreGrammarConfig: (input: {
43
+ readonly hfs: GrammarHfsView;
44
+ readonly family?: GrammarFamilyId;
45
+ }) => Linter.Config[];
package/dist/index.js ADDED
@@ -0,0 +1,73 @@
1
+ import tsParser from "@typescript-eslint/parser";
2
+ import { SOURCE_FILES, linterOptions as canonLinterOptions } from "@starci/eslint-canon-fe/lib/config.mjs";
3
+ import { rules as appShellRules } from "./app-shell.js";
4
+ import { rules as chipLabelRules } from "./chip-label.js";
5
+ import { rules as drawingRules } from "./drawing.js";
6
+ import { rules as grammarBoundaryRules } from "./grammar-boundary.js";
7
+ import { rules as iconRules } from "./icon.js";
8
+ import { rules as menuEntriesRules } from "./menu-entries.js";
9
+ import { rules as statusColorsRules } from "./status-colors.js";
10
+ import { rules as tokensRules } from "./tokens.js";
11
+ import { rules as typographyRules } from "./typography.js";
12
+ import { rules as vendorRules } from "./vendor-boundary.js";
13
+ import { why as familyWhy } from "./lib/why.js";
14
+ const CONTRIBUTIONS = [
15
+ { law: "app-shell", rules: appShellRules },
16
+ { law: "chip-label", rules: chipLabelRules },
17
+ { law: "drawing", rules: drawingRules },
18
+ { law: "grammar-boundary", rules: grammarBoundaryRules },
19
+ { law: "icon", rules: iconRules },
20
+ { law: "menu-entries", rules: menuEntriesRules },
21
+ { law: "status-colors", rules: statusColorsRules },
22
+ { law: "tokens", rules: tokensRules },
23
+ { law: "typography", rules: typographyRules },
24
+ { law: "vendor-boundary", rules: vendorRules },
25
+ ];
26
+ /** Each concrete Grammar law has one gathered owner. */
27
+ export const lawOwners = CONTRIBUTIONS.map(({ law }) => law);
28
+ /** Preserve declarations before gathering so duplicate rule names remain observable. */
29
+ export const ruleDeclarations = CONTRIBUTIONS.flatMap(({ law, rules }) => Object.keys(rules).map((name) => ({ law, name })));
30
+ /** The law that owns each published rule ID. */
31
+ export const ruleOwners = Object.fromEntries(ruleDeclarations.map(({ law, name }) => [name, law]));
32
+ /** Every rule of the selected family contract. */
33
+ export const rules = Object.fromEntries(CONTRIBUTIONS.flatMap(({ rules }) => Object.entries(rules)));
34
+ /** Every family rule runs at error, with no consumer override surface. */
35
+ export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-grammar/${name}`, "error"]));
36
+ /** The failure-code guidance of every rule. */
37
+ export const why = familyWhy;
38
+ /** The linter options of the universal canon: no inline configuration, no unused disable directive. */
39
+ export const linterOptions = canonLinterOptions;
40
+ const plugin = { meta: { name: "@starci/eslint-grammar-core" }, rules };
41
+ export default plugin;
42
+ /** The families this factory serves: core, and offset-pop through its own package entry. */
43
+ const SERVED_FAMILIES = Object.freeze(["core", "offset-pop"]);
44
+ /**
45
+ * Add the complete selected-family contract to the universal front-end flat config.
46
+ * Offset Pop uses the inherited Common renderer contract through its own package entry.
47
+ * @param input - The selected FE slot view and family.
48
+ * @returns A typed source block with every family rule at error.
49
+ */
50
+ export const coreGrammarConfig = ({ hfs, family = "core" } = {}) => {
51
+ if (!hfs || typeof hfs.slotOf !== "function" || typeof hfs.repoRoot !== "string" || hfs.profile !== "fe") {
52
+ throw new Error("coreGrammarConfig needs the FE view from loadHfs(import.meta.url)");
53
+ }
54
+ if (!SERVED_FAMILIES.includes(family) || hfs.grammar !== family) {
55
+ throw new Error(`coreGrammarConfig family ${family} does not match the selected Grammar ${hfs.grammar}`);
56
+ }
57
+ const missing = Object.keys(rules).filter((name) => recommended[`starci-grammar/${name}`] !== "error");
58
+ if (missing.length)
59
+ throw new Error(`coreGrammarConfig requires every family rule at error: ${missing.join(", ")}`);
60
+ return [{
61
+ files: [...SOURCE_FILES],
62
+ languageOptions: {
63
+ parser: tsParser,
64
+ ecmaVersion: "latest",
65
+ sourceType: "module",
66
+ parserOptions: { projectService: true, tsconfigRootDir: hfs.repoRoot, ecmaFeatures: { jsx: true } },
67
+ },
68
+ linterOptions: { ...linterOptions },
69
+ plugins: { "starci-grammar": plugin },
70
+ settings: { starci: { hfs } },
71
+ rules: { ...recommended },
72
+ }];
73
+ };
@@ -0,0 +1,6 @@
1
+ import type { ReaderContext, Resolved } from "./types.js";
2
+ /** Every reachable branch yields its own sequence; shell headers select the narrowest right-hand group. */
3
+ export declare const clusterAlternatives: (context: ReaderContext, expression: Resolved | null | undefined, shellHeader: boolean) => {
4
+ alternatives: string[][];
5
+ ownsBar: boolean;
6
+ };
@@ -0,0 +1,75 @@
1
+ import { clusterRole, grammarName, isBarControl, propOf, textProp } from "./app-shell.js";
2
+ import { visitVisible } from "./app-shell-visible.js";
3
+ /** The disclosure component each cluster role's trigger must sit in. */
4
+ const EXPECTED_DISCLOSURE = { language: "DropdownMenu", notifications: "Popover", account: "DropdownMenu" };
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 && name !== null && ["TopBar", "NavigationFeatureNav"].includes(name)) {
11
+ ownsBar = true;
12
+ return false;
13
+ }
14
+ if (!isBarControl(context, opening, name))
15
+ return;
16
+ const role = clusterRole(context, opening, name);
17
+ controls.push(controlSignature(context, opening, name, role, ancestors));
18
+ return false;
19
+ }, new Set(), (node) => {
20
+ if (!choices.has(node))
21
+ branches.push(node);
22
+ return choices.get(node) ?? "consequent";
23
+ });
24
+ return { controls, branches, ownsBar };
25
+ };
26
+ const controlSignature = (context, opening, name, role, ancestors) => {
27
+ const signature = `${role ?? "unknown"}:${name}`;
28
+ const expected = role === null ? undefined : EXPECTED_DISCLOSURE[role];
29
+ const disclosure = [...ancestors].reverse().map((ancestor) => grammarName(context, ancestor)).find((parent) => parent === "DropdownMenu" || parent === "Popover");
30
+ if (expected && disclosure !== expected)
31
+ return `${signature}:wrong-disclosure`;
32
+ if (role === "language") {
33
+ const variant = propOf(context, opening, "variant");
34
+ if (!variant.known || variant.value !== null && textProp(context, opening, "variant") !== "tertiary")
35
+ return `${signature}:wrong-variant`;
36
+ }
37
+ return signature;
38
+ };
39
+ const rightGroup = (context, expression, choices) => {
40
+ let group = null;
41
+ let count = Number.POSITIVE_INFINITY;
42
+ const roles = controlsOf(context, expression, choices).controls.filter((role) => !role.startsWith("unknown:"));
43
+ visitVisible(context, expression, (opening, name) => {
44
+ if (isBarControl(context, opening, name))
45
+ return false;
46
+ const result = controlsOf(context, opening.parent, choices);
47
+ const known = result.controls.filter((role) => !role.startsWith("unknown:"));
48
+ if (roles.length > 0 && known.length === roles.length && roles.every((role) => known.includes(role)) && result.controls.length < count) {
49
+ group = opening.parent;
50
+ count = result.controls.length;
51
+ }
52
+ return undefined;
53
+ }, new Set(), (node) => choices.get(node) ?? "consequent");
54
+ return group ?? expression;
55
+ };
56
+ /** Every reachable branch yields its own sequence; shell headers select the narrowest right-hand group. */
57
+ export const clusterAlternatives = (context, expression, shellHeader) => {
58
+ const pending = [new Map()];
59
+ const alternatives = [];
60
+ for (let choices = pending.pop(); choices; choices = pending.pop()) {
61
+ const initial = controlsOf(context, expression, choices, shellHeader);
62
+ if (initial.ownsBar)
63
+ return { alternatives: [], ownsBar: true };
64
+ const group = shellHeader ? rightGroup(context, expression, choices) : expression;
65
+ const result = controlsOf(context, group, choices);
66
+ const current = choices;
67
+ const unknown = [...initial.branches, ...result.branches].find((node) => !current.has(node));
68
+ if (unknown) {
69
+ pending.push(new Map([...choices, [unknown, "alternate"]]), new Map([...choices, [unknown, "consequent"]]));
70
+ }
71
+ else
72
+ alternatives.push(result.controls);
73
+ }
74
+ return { alternatives, ownsBar: false };
75
+ };
@@ -0,0 +1,5 @@
1
+ import type { ReaderContext, Resolved } from "./types.js";
2
+ /** Literal create labels and conventional create handlers are executable static evidence. */
3
+ export declare const isCreateAction: (text: string) => boolean;
4
+ /** Follow static item/section arrays; inspect labels and handlers where a menu action is declared. */
5
+ export declare const menuHasCreate: (context: ReaderContext, expression: Resolved | null | undefined, seen?: Set<Resolved>) => boolean;
@@ -0,0 +1,37 @@
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 { propertyKeyText, resolveLocal } from "./app-shell.js";
6
+ import { isNode } from "./types.js";
7
+ const runtimeRoot = fileURLToPath(new URL("../../runtime/", import.meta.url));
8
+ const localizedLabels = phrasesOf("appShell.product", runtimeRoot);
9
+ if (localizedLabels.length === 0)
10
+ throw new Error("The FE canon's app-shell create-action phrase catalog is missing: regenerate the runtime bundle.");
11
+ const localizedCreate = localizedLabels.map((label) => escapeRegExp(label).replace(/\s+/g, String.raw `\s*`)).join("|");
12
+ const CREATE_WORDS = new RegExp(String.raw `\bcreate\b|\bnew\s+(?:item|record|project|task)|${localizedCreate}`, "i");
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
+ const ACTION_FIELDS = new Set(["id", "key", "label", "title", "textValue", "onAction", "onPress", "onClick", "action"]);
16
+ /** Follow static item/section arrays; inspect labels and handlers where a menu action is declared. */
17
+ export const menuHasCreate = (context, expression, seen = new Set()) => {
18
+ const node = resolveLocal(context, expression);
19
+ if (!node || seen.has(node))
20
+ return false;
21
+ seen.add(node);
22
+ if (node.type === "SpreadElement")
23
+ return menuHasCreate(context, node.argument, seen);
24
+ if (node.type === "ArrayExpression")
25
+ return node.elements.some((item) => menuHasCreate(context, item, seen));
26
+ if (node.type !== "ObjectExpression")
27
+ return false;
28
+ return node.properties.some((property) => {
29
+ if (property.type === "SpreadElement")
30
+ return menuHasCreate(context, property.argument, seen);
31
+ const name = propertyKeyText(property.key);
32
+ const value = resolveLocal(context, property.value);
33
+ if (name !== null && ACTION_FIELDS.has(name) && value && isNode(value) && isCreateAction(context.sourceCode.getText(value)))
34
+ return true;
35
+ return menuHasCreate(context, property.value, seen);
36
+ });
37
+ };
@@ -0,0 +1,11 @@
1
+ import type { TSESTree } from "@typescript-eslint/types";
2
+ import type { Node, ParsedContext, ReaderContext } from "./types.js";
3
+ /** An exported value together with the parsed file that holds it. */
4
+ export interface Owner {
5
+ readonly context: ParsedContext;
6
+ readonly value: Node;
7
+ }
8
+ /** An imported JSX owner, or null when its implementation cannot be proved from owned source. */
9
+ export declare const importedHeader: (context: ReaderContext, opening: TSESTree.JSXOpeningElement) => Owner | null;
10
+ /** The per-repository proof cache shares the same lifetime as parsed header source. */
11
+ export declare const headerProofs: (context: ReaderContext) => Map<string, boolean>;