@starci/eslint-grammar-core 0.13.0 → 0.14.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 +12 -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 +11 -10
  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
@@ -0,0 +1,143 @@
1
+ import { defineFamilyRules, defineRule } from "./lib/rule-contract.js";
2
+ /**
3
+ * The rule that holds `status-colors.md` (HFS R61 `FE_STYLE_TOKEN_ONLY`, the class-name half of the soft-pair contrast
4
+ * obligation; the brand layer's own contrast belongs to `@starci/stylelint-canon` `starci/status-contrast`).
5
+ *
6
+ * A STATUS TONE HAS TWO FACES. The SOLID tone (`--success`) is a fill: a button, a bar, a badge dot's disc, with
7
+ * `--success-foreground` ink on it. A status shown as TEXT or as an ICON uses the SOFT pair, the way a HeroUI flat Chip
8
+ * does: `--success-soft-foreground` ink, on `--success-soft` when it sits on a tint. The brand layer is checked to
9
+ * make that ink readable (3:1 on the tint and on the page); the solid tone is only the mid-luminance hue of the brand
10
+ * and reads 3.8:1 or worse on white, so as a text colour it fails the contrast the soft pair was built to give.
11
+ *
12
+ * WHAT IT READS. Every static class string a component writes: the `className` / `class` attribute of an element (through
13
+ * `cn()`, `clsx()`, conditionals, arrays, template text), and a string in a constant or in a `classes` entry. The tones
14
+ * are the grammar's `STATUS_TONES` (`lib/status-tones.generated.ts`, generated from the grammar with the stylelint
15
+ * vocabulary), never a list typed here. A class is a finding when it paints text, an icon or a text decoration with a solid
16
+ * tone (`text-success`, `fill-danger`, `stroke-warning`, `decoration-info`, with any variant and opacity).
17
+ *
18
+ * WHAT IT LEAVES ALONE: the soft pair (`text-success-soft-foreground`, `bg-success-soft`), a solid fill with its own ink
19
+ * (`bg-success text-success-foreground`), a solid border, ring or outline (`border-danger`, `ring-warning`: a
20
+ * boundary is not text), and a class that only shares a prefix (`text-successful`).
21
+ */
22
+ import { attributeValue } from "@starci/eslint-canon-fe/lib/ast.mjs";
23
+ import { STATUS_TONES } from "./lib/status-tones.generated.js";
24
+ /** The utility families that paint text, an icon or a text decoration. */
25
+ const TEXT_PAINT = ["text", "fill", "stroke", "decoration"];
26
+ /** `text-success`, `text-success/60`, `text-success-hover`, with an optional `!` and any variants before it. */
27
+ const SOLID_TEXT = new RegExp(`^!?(${TEXT_PAINT.join("|")})-(${STATUS_TONES.join("|")})(?:-hover)?(?:/(?:\\d+|\\[[^\\]]*\\]))?$`);
28
+ /** The class after its variants: `md:hover:text-success` and `[&>svg]:text-success` both end at `text-success`. */
29
+ const utilityOf = (token) => {
30
+ let depth = 0;
31
+ let cut = -1;
32
+ for (let i = 0; i < token.length; i += 1) {
33
+ if (token[i] === "[")
34
+ depth += 1;
35
+ else if (token[i] === "]")
36
+ depth -= 1;
37
+ else if (token[i] === ":" && depth === 0)
38
+ cut = i;
39
+ }
40
+ return token.slice(cut + 1);
41
+ };
42
+ /** Every solid-tone text class in one class string, as `{ token, family, tone }`. */
43
+ export const solidTextClasses = (text) => text
44
+ .split(/\s+/)
45
+ .filter(Boolean)
46
+ .flatMap((token) => {
47
+ const match = SOLID_TEXT.exec(utilityOf(token));
48
+ const family = match?.[1];
49
+ const tone = match?.[2];
50
+ return family !== undefined && tone !== undefined ? [{ token, family, tone }] : [];
51
+ });
52
+ /** The string nodes inside an expression that builds a class string: literals, template text, keys, and everything a call, array or condition carries. */
53
+ function* stringNodes(node) {
54
+ if (!node || typeof node.type !== "string")
55
+ return;
56
+ switch (node.type) {
57
+ case "Literal":
58
+ if (typeof node.value === "string")
59
+ yield { node, text: node.value };
60
+ return;
61
+ case "TemplateLiteral":
62
+ yield { node, text: node.quasis.map((quasi) => quasi.value.cooked ?? "").join(" ") };
63
+ return;
64
+ case "JSXExpressionContainer":
65
+ yield* stringNodes(node.expression);
66
+ return;
67
+ case "ConditionalExpression":
68
+ yield* stringNodes(node.consequent);
69
+ yield* stringNodes(node.alternate);
70
+ return;
71
+ case "LogicalExpression":
72
+ yield* stringNodes(node.left);
73
+ yield* stringNodes(node.right);
74
+ return;
75
+ case "TSAsExpression":
76
+ case "TSSatisfiesExpression":
77
+ case "TSNonNullExpression":
78
+ yield* stringNodes(node.expression);
79
+ return;
80
+ case "ArrayExpression":
81
+ for (const element of node.elements)
82
+ yield* stringNodes(element);
83
+ return;
84
+ case "CallExpression":
85
+ for (const argument of node.arguments)
86
+ yield* stringNodes(argument);
87
+ return;
88
+ case "ObjectExpression":
89
+ for (const property of node.properties) {
90
+ if (property.type !== "Property")
91
+ continue;
92
+ if (!property.computed && property.key.type === "Literal")
93
+ yield* stringNodes(property.key);
94
+ yield* stringNodes(property.value);
95
+ }
96
+ return;
97
+ default:
98
+ }
99
+ }
100
+ /** Text or an icon painted with a solid status tone is not readable at the contrast the soft pair was built for. */
101
+ export const statusTextUsesSoftForeground = defineRule({
102
+ meta: {
103
+ type: "problem",
104
+ docs: { description: "Status text and icons take the soft foreground of the tone, never the solid tone." },
105
+ schema: [],
106
+ messages: {
107
+ solid: "`{{token}}` paints text or an icon with the solid `{{tone}}` tone, which is a fill and reads under 4.5:1 on the page (3.8:1 on white for a mid-luminance brand). A status shown as text or an icon takes the soft pair: `{{family}}-{{tone}}-soft-foreground`, on `bg-{{tone}}-soft` when it sits on a tint. The solid tone is for a fill with `text-{{tone}}-foreground` on it.",
108
+ },
109
+ },
110
+ create(context) {
111
+ // A `classes` entry inside a constant is reached twice (as part of the constant, and as the entry): one finding per string.
112
+ const seen = new Set();
113
+ const check = (root) => {
114
+ for (const { node, text } of stringNodes(root)) {
115
+ if (seen.has(node))
116
+ continue;
117
+ seen.add(node);
118
+ for (const { token, family, tone } of solidTextClasses(text))
119
+ context.report({ node, messageId: "solid", data: { token, family, tone } });
120
+ }
121
+ };
122
+ return {
123
+ JSXAttribute(node) {
124
+ if (node.name.type === "JSXIdentifier" && (node.name.name === "className" || node.name.name === "class"))
125
+ check(attributeValue(node));
126
+ },
127
+ VariableDeclarator(node) {
128
+ check(node.init);
129
+ },
130
+ Property(node) {
131
+ if (!node.computed && node.key.type === "Identifier" && node.key.name === "classes")
132
+ check(node.value);
133
+ },
134
+ };
135
+ },
136
+ });
137
+ /** The rules this law contributes to the plugin. */
138
+ export const rules = {
139
+ "status-text-uses-soft-foreground": statusTextUsesSoftForeground,
140
+ };
141
+ defineFamilyRules(rules, "knowledge/hfs/rules.yaml#R61");
142
+ /** Every rule of this law is an error. */
143
+ export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-grammar/${name}`, "error"]));
@@ -0,0 +1,11 @@
1
+ import type { Rule } from "eslint";
2
+ /** A fractional step is off the ladder, not between two rungs. */
3
+ export declare const noFractionalStep: Rule.RuleModule;
4
+ /** Large text plus a heavy weight is a heading, and headings come from the component that owns both. */
5
+ export declare const noHandRolledHeading: Rule.RuleModule;
6
+ /** A class naming a token the theme never defines is a class that draws nothing. */
7
+ export declare const noUnresolvedTokenClass: Rule.RuleModule;
8
+ /** StarCi scale rules, bound to their knowledge owner. */
9
+ export declare const rules: Record<string, Rule.RuleModule>;
10
+ /** Every family scale rule is mandatory. */
11
+ export declare const recommended: Record<string, "error">;
package/dist/tokens.js ADDED
@@ -0,0 +1,166 @@
1
+ /** StarCi scale and theme-token choices; ownership follows FE-RULE-OWNERSHIP. */
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { classTextVisitors } from "@starci/eslint-canon-fe/lib/class-text.mjs";
5
+ import { hfsOf } from "@starci/eslint-canon-fe/lib/hfs.mjs";
6
+ import { classOf, isProductSource } from "@starci/eslint-canon-fe/lib/scope.mjs";
7
+ import { defineFamilyRules, defineRule } from "./lib/rule-contract.js";
8
+ /**
9
+ * A fractional step, in any family that measures.
10
+ *
11
+ * The rungs are whole and unevenly spaced, so half of one is not "between two rungs" - it is off
12
+ * the ladder, and it will match nothing else on any screen.
13
+ */
14
+ const FRACTIONAL = /\b(?:gap|gap-x|gap-y|p|px|py|pt|pb|pl|pr|m|mx|my|mt|mb|ml|mr|space-x|space-y|inset|top|bottom|left|right|size|w|h)-\d+\.\d+\b/;
15
+ /** Large text. Paired with a heavy weight it is a heading, whatever element carries it. */
16
+ const LARGE_TEXT = /\btext-(?:xl|2xl|3xl|4xl|5xl)\b/;
17
+ /** A heavy weight. */
18
+ const HEAVY_WEIGHT = /\bfont-(?:bold|extrabold|black)\b/;
19
+ /** A fractional step is off the ladder, not between two rungs. */
20
+ export const noFractionalStep = defineRule({
21
+ meta: {
22
+ type: "problem",
23
+ docs: { description: "A measurement takes a whole rung; a fractional step is off the scale." },
24
+ schema: [],
25
+ messages: {
26
+ fractional: "`{{cls}}` is half a rung. The scale is whole steps, unevenly spaced, so this is not between two rungs - it is off the ladder, and it will match nothing else on any screen. Use the nearest rung; there is no case where the right answer is half of one.",
27
+ },
28
+ },
29
+ create(context) {
30
+ return classTextVisitors(context, (node, text) => {
31
+ if (!text)
32
+ return;
33
+ const hit = text.match(FRACTIONAL);
34
+ if (hit)
35
+ context.report({ node, messageId: "fractional", data: { cls: hit[0] } });
36
+ });
37
+ },
38
+ });
39
+ /** Large text plus a heavy weight is a heading, and headings come from the component that owns both. */
40
+ export const noHandRolledHeading = defineRule({
41
+ meta: {
42
+ type: "problem",
43
+ docs: { description: "A heading comes from the heading component, not from raw type classes." },
44
+ schema: [],
45
+ messages: {
46
+ heading: "Large text and a heavy weight together are a heading, whatever element carries them - and assembled here it is a heading nothing else knows about: the outline a screen reader builds does not contain it, and it stays behind the day the type scale changes. Render the heading component, which owns the tag and the size as one decision.",
47
+ },
48
+ },
49
+ create(context) {
50
+ return classTextVisitors(context, (node, text) => {
51
+ if (!text)
52
+ return;
53
+ if (LARGE_TEXT.test(text) && HEAVY_WEIGHT.test(text)) {
54
+ context.report({ node, messageId: "heading" });
55
+ }
56
+ });
57
+ },
58
+ });
59
+ /**
60
+ * The class families whose names are PROMISES about a theme token, and the variable each needs.
61
+ *
62
+ * Tailwind resolves `max-w-app-lg` by looking for `--container-app-lg`. Nothing warns when that
63
+ * variable does not exist: the class is emitted, the element gets no width, and the type system
64
+ * is satisfied because the name IS in the union. That is the failure this rule exists for - a
65
+ * class can be typesafe and mean nothing at the same time, and the union is exactly what makes
66
+ * it look settled.
67
+ *
68
+ * Only families whose token name is DERIVABLE from the class name are listed. A colour or a
69
+ * spacing step resolves through Tailwind's own scale as well as the theme, so a rule guessing at
70
+ * those would report classes that work.
71
+ */
72
+ /**
73
+ * Names Tailwind resolves ITSELF, which therefore promise nothing about this theme.
74
+ *
75
+ * `min-h-screen` is the viewport, not a variable; so are `full`, `fit`, `auto` and the viewport
76
+ * units. Reported as unresolved tokens they would send an author to define `--min-height-screen`,
77
+ * which is a variable nothing reads - the rule would have invented work and broken a working class.
78
+ * Measured on both repositories the first time this ran: two findings, both wrong, both this list.
79
+ */
80
+ const TAILWIND_OWN_NAMES = new Set([
81
+ "screen", "full", "fit", "auto", "none", "min", "max", "prose",
82
+ "dvh", "svh", "lvh", "dvw", "svw", "lvw", "px",
83
+ ]);
84
+ const TOKEN_CLASS_FAMILIES = [
85
+ { pattern: /^max-w-app-([a-z0-9]+)$/, variable: (name) => `--container-app-${name}` },
86
+ { pattern: /^max-h-([a-z][a-z0-9-]*)$/, variable: (name) => `--max-height-${name}` },
87
+ { pattern: /^min-h-([a-z][a-z0-9-]*)$/, variable: (name) => `--min-height-${name}` },
88
+ ];
89
+ /**
90
+ * The stylesheets an app declares its theme in: the route slot's `globals.css` (`fe.route`, named by its `allows`) and the brand slot's
91
+ * `brand.css` (`fe.modules.brand`), both with `<app>` filled from the linted file's slot. Null when the file belongs to no app
92
+ * (a shared package has no theme of its own).
93
+ */
94
+ /** Every theme read once per lint run. */
95
+ const themeCache = new Map();
96
+ /** The path template of a slot the manifest must declare. */
97
+ const slotPath = (hfs, id) => {
98
+ const slot = hfs.slot(id);
99
+ if (slot === null)
100
+ throw new Error(`The slot manifest declares no slot ${id}`);
101
+ return slot.path;
102
+ };
103
+ const themeText = (context) => {
104
+ const hfs = hfsOf(context);
105
+ const app = classOf(context).bindings?.app;
106
+ if (app === undefined)
107
+ return null;
108
+ const candidates = [
109
+ join(slotPath(hfs, "fe.route").replace("<app>", app), "globals.css"),
110
+ slotPath(hfs, "fe.modules.brand").replace("<app>", app),
111
+ ].map((relative) => join(hfs.repoRoot, relative));
112
+ const key = candidates.join("|");
113
+ if (!themeCache.has(key)) {
114
+ const found = candidates.filter((candidate) => existsSync(candidate)).map((candidate) => readFileSync(candidate, "utf8"));
115
+ themeCache.set(key, found.length > 0 ? found.join("\n") : null);
116
+ }
117
+ return themeCache.get(key) ?? null;
118
+ };
119
+ /** A class naming a token the theme never defines is a class that draws nothing. */
120
+ export const noUnresolvedTokenClass = defineRule({
121
+ meta: {
122
+ type: "problem",
123
+ docs: { description: "A class naming a theme token resolves only when the theme defines it." },
124
+ schema: [],
125
+ messages: {
126
+ unresolved: "`{{value}}` reads as a token this theme defines, and `{{variable}}` is defined nowhere in it. Tailwind emits the class, the element gets nothing, and both the union and the compiler stay happy - which is why this is the one dead class no type can catch. Define `{{variable}}` in the stylesheet, or name a token that exists.",
127
+ },
128
+ },
129
+ create(context) {
130
+ if (!isProductSource(context))
131
+ return {};
132
+ const theme = themeText(context);
133
+ // A reader that cannot find the stylesheet stays quiet rather than calling every token dead.
134
+ if (theme === null)
135
+ return {};
136
+ // The visitor hands (node, text) in that order; reversing them here is how a rule reports the
137
+ // node as its message and the message as its node, and neither complains.
138
+ const check = (node, value) => {
139
+ for (const raw of String(value).split(/\s+/)) {
140
+ const bare = raw.replace(/^[a-z-]+:/, "").replace(/^!/, "");
141
+ for (const family of TOKEN_CLASS_FAMILIES) {
142
+ const match = family.pattern.exec(bare);
143
+ if (match === null)
144
+ continue;
145
+ const name = match[1] ?? "";
146
+ if (TAILWIND_OWN_NAMES.has(name))
147
+ continue;
148
+ const variable = family.variable(name);
149
+ if (theme.includes(variable))
150
+ continue;
151
+ context.report({ node, messageId: "unresolved", data: { value: bare, variable } });
152
+ }
153
+ }
154
+ };
155
+ return classTextVisitors(context, check);
156
+ },
157
+ });
158
+ /** StarCi scale rules, bound to their knowledge owner. */
159
+ export const rules = {
160
+ "no-fractional-step": noFractionalStep,
161
+ "no-hand-rolled-heading": noHandRolledHeading,
162
+ "no-unresolved-token-class": noUnresolvedTokenClass,
163
+ };
164
+ defineFamilyRules(rules, "knowledge/hfs/rules.yaml#R61");
165
+ /** Every family scale rule is mandatory. */
166
+ export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-grammar/${name}`, "error"]));
@@ -0,0 +1,13 @@
1
+ import type { Rule } from "eslint";
2
+ /** A heading comes from the component that owns its tag and its size as one decision. */
3
+ export declare const noHeadingTagOutsideHeadingComponent: Rule.RuleModule;
4
+ /** The rules this law contributes to the plugin. */
5
+ export declare const rules: Record<string, Rule.RuleModule>;
6
+ /**
7
+ * The level this law asks for, as the plugin's own opinion.
8
+ *
9
+ * Exact: a tag name in a file that is not the component that owns it. A repository adopting this
10
+ * with history should expect the deeper levels to report a structure problem rather than a styling
11
+ * one, and those take longer to fix than the message suggests.
12
+ */
13
+ export declare const recommended: Record<string, "error">;
@@ -0,0 +1,72 @@
1
+ import { defineFamilyRules, defineRule } from "./lib/rule-contract.js";
2
+ /**
3
+ * The rule that holds `typography.md`.
4
+ *
5
+ * MOST OF THIS LAW IS A UNION, and what is left is one shape a type cannot see: a heading TAG
6
+ * written by hand. The level union stops a fifth step being asked for; nothing in the type system
7
+ * notices an `<h2>` in a file that never mentions the heading component at all.
8
+ *
9
+ * The rule's own twin lives in `tokens.ts`: that one catches a heading assembled out of a large
10
+ * size and a heavy weight on some other element. Between them they cover both ways a heading gets
11
+ * hand-rolled - with the right tag and the wrong look, or the right look and no tag.
12
+ */
13
+ import { classOf, inSlot, isProductSource } from "@starci/eslint-canon-fe/lib/scope.mjs";
14
+ /** The one owner that writes the heading tag: the component named `Heading` in the leaves layer of an app (slot `fe.components`; the owner name is the one name this rule keeps). */
15
+ const isHeadingComponent = (context) => {
16
+ const owner = classOf(context);
17
+ return inSlot(context, "fe.components") && owner.kind === "leaves" && owner.bindings?.name === "Heading";
18
+ };
19
+ /** The tags a document outline is built from. */
20
+ const HEADING_TAGS = new Set(["h1", "h2", "h3", "h4", "h5", "h6"]);
21
+ /** The deepest level the component draws; below this a page has nested too far. */
22
+ const DEEPEST_LEVEL = 4;
23
+ /** Intrinsic (lowercase) tag name, or null for a component. */
24
+ const tagName = (opening) => {
25
+ const name = opening && opening.name;
26
+ if (!name || name.type !== "JSXIdentifier")
27
+ return null;
28
+ return name.name === name.name.toLowerCase() ? name.name : null;
29
+ };
30
+ // -- TYPESET-1 · TYPESET-2 -------------------------------------------------------------------------------
31
+ /** A heading comes from the component that owns its tag and its size as one decision. */
32
+ export const noHeadingTagOutsideHeadingComponent = defineRule({
33
+ meta: {
34
+ type: "problem",
35
+ docs: { description: "Heading tags are written by the heading component, from a level." },
36
+ schema: [],
37
+ messages: {
38
+ tag: "`<{{tag}}>` written here throws away the two facts the heading component exists to keep together: the tag a screen reader builds the document outline from, and the size a reader sees. Set separately they drift, and a screen's third-largest text becomes its first heading. Render the heading component with a level of {{level}}; one prop decides both, so they cannot disagree.",
39
+ tooDeep: "`<{{tag}}>` is deeper than the scale goes: levels stop at {{deepest}}, because a fifth means the page has nested further than a reader can hold. That is a structure problem wearing a styling problem's clothes - flatten the section, then render the title with a level the scale has.",
40
+ },
41
+ },
42
+ create(context) {
43
+ if (!isProductSource(context) || isHeadingComponent(context))
44
+ return {};
45
+ return {
46
+ JSXOpeningElement(node) {
47
+ const tag = tagName(node);
48
+ if (!tag || !HEADING_TAGS.has(tag))
49
+ return;
50
+ const level = Number(tag.slice(1));
51
+ if (level > DEEPEST_LEVEL) {
52
+ context.report({ node, messageId: "tooDeep", data: { tag, deepest: DEEPEST_LEVEL } });
53
+ return;
54
+ }
55
+ context.report({ node, messageId: "tag", data: { tag, level } });
56
+ },
57
+ };
58
+ },
59
+ });
60
+ /** The rules this law contributes to the plugin. */
61
+ export const rules = {
62
+ "no-heading-tag-outside-heading-component": noHeadingTagOutsideHeadingComponent,
63
+ };
64
+ defineFamilyRules(rules, "knowledge/ui/presentation/font.yaml");
65
+ /**
66
+ * The level this law asks for, as the plugin's own opinion.
67
+ *
68
+ * Exact: a tag name in a file that is not the component that owns it. A repository adopting this
69
+ * with history should expect the deeper levels to report a structure problem rather than a styling
70
+ * one, and those take longer to fix than the message suggests.
71
+ */
72
+ export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-grammar/${name}`, "error"]));
@@ -0,0 +1,5 @@
1
+ import type { Rule } from "eslint";
2
+ /** Keep HeroUI mechanics inside leaves, branches, and styling-owner modules (the `styles` file of a component). */
3
+ export declare const vendorPrimitiveHasNamedOwner: Rule.RuleModule;
4
+ export declare const rules: Record<string, Rule.RuleModule>;
5
+ export declare const recommended: Record<string, "error">;
@@ -0,0 +1,25 @@
1
+ import { defineFamilyRules, defineRule } from "./lib/rule-contract.js";
2
+ /**
3
+ * Vendor ownership rules for the traditional React component hierarchy.
4
+ *
5
+ * Vendor primitives stay behind named leaves or mechanics branches. This boundary is deliberately
6
+ * independent of product composition protocols; ordinary React children and props remain valid.
7
+ */
8
+ import { isComponentFile, kindOfFile, roleOfFile } from "@starci/eslint-canon-fe/lib/scope.mjs";
9
+ /** The layers that may hold vendor mechanics: leaves and branches. */
10
+ const MECHANICS_LAYERS = new Set(["leaves", "branches"]);
11
+ /** Keep HeroUI mechanics inside leaves, branches, and styling-owner modules (the `styles` file of a component). */
12
+ export const vendorPrimitiveHasNamedOwner = defineRule({
13
+ meta: { type: "problem", docs: { description: "HeroUI primitives have a named component or styling owner." }, schema: [], messages: { owner: "Import HeroUI primitives from a named leaf, mechanics branch, or colocated classNames module." } },
14
+ create(context) {
15
+ if (!isComponentFile(context) || MECHANICS_LAYERS.has(kindOfFile(context)) || roleOfFile(context) === "styles")
16
+ return {};
17
+ return { ImportDeclaration(node) { if (String(node.source.value) === "@heroui/react")
18
+ context.report({ node, messageId: "owner" }); } };
19
+ },
20
+ });
21
+ export const rules = {
22
+ "vendor-primitive-has-named-owner": vendorPrimitiveHasNamedOwner,
23
+ };
24
+ defineFamilyRules(rules, "knowledge/hfs/rules.yaml#R93");
25
+ export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-grammar/${name}`, "error"]));
@@ -0,0 +1,30 @@
1
+ # Chip labels start uppercase
2
+
3
+ Law module: `chip-label.ts`. Catalogue: R322 FE_CHIP_LABEL_CASE.
4
+
5
+ A status chip (`StatusDot` with text, a `Badge`, dot or not) is a label read at a glance beside the thing it qualifies, so it starts with an uppercase letter in every language, Vietnamese included. StatusDot capitalises the text it renders; the rule holds the literal where the source writes it.
6
+
7
+ Every rule below is an error in `coreGrammarConfig`; none can be switched off or suppressed inline.
8
+
9
+ ## `starci-grammar/chip-label-starts-uppercase`
10
+
11
+ The children of a `StatusDot` or `Badge` that the source states literally (JSX text, a string literal, a template literal with no expression, and every branch of a conditional or logical expression made of them) do not start with a lowercase letter. The fix uppercases the first letter in place. Text the source cannot show (a translation call, a variable, a template with an expression), a label that starts with a digit or a symbol, and an uncased script are not judged.
12
+
13
+ **Invalid** (`src/components/blocks/RunList/component.tsx`)
14
+
15
+ ```tsx
16
+ <StatusDot tone="success">running</StatusDot>
17
+ <Badge tone="warning" isDot>{"queued"}</Badge>
18
+ ```
19
+
20
+ **Valid** (`src/components/blocks/RunList/component.tsx`)
21
+
22
+ ```tsx
23
+ <StatusDot tone="success">Running</StatusDot>
24
+ <Badge tone="warning" isDot>{t("status.queued")}</Badge>
25
+ <Badge>3 failed</Badge>
26
+ ```
27
+
28
+ **Finding code:** `FE_CHIP_LABEL_CASE`
29
+
30
+ Rule ownership: [FE-RULE-OWNERSHIP](../../../contexts/code/fe/README.md#the-placement-test-fe-rule-ownership).
@@ -1,6 +1,6 @@
1
1
  # Grammar boundary
2
2
 
3
- Law module: `grammar-boundary.mjs`. Catalogue: R62 FE_NATIVE_FORM_CONTROL (the grammar owns the element).
3
+ Law module: `grammar-boundary.ts`. Catalogue: R62 FE_NATIVE_FORM_CONTROL (the grammar owns the element).
4
4
 
5
5
  Page structure and text are composed from `@starci/grammar` (or the repository's `<family>-ui` package), not drawn as raw HTML.
6
6
 
@@ -1,8 +1,8 @@
1
1
  # Status colours: text and icons take the soft pair
2
2
 
3
- Law module: `status-colors.mjs`. Catalogue: R61 FE_STYLE_TOKEN_ONLY (the class-name half of the soft-pair contrast obligation; the brand layer's own contrast is `starci/status-contrast` of @starci/stylelint-canon).
3
+ Law module: `status-colors.ts`. Catalogue: R61 FE_STYLE_TOKEN_ONLY (the class-name half of the soft-pair contrast obligation; the brand layer's own contrast is `starci/status-contrast` of @starci/stylelint-canon).
4
4
 
5
- A status tone has two faces. The solid tone (`--success`) is a fill, with `--success-foreground` ink on it. A status shown as text or as an icon takes the soft pair, the way a HeroUI flat Chip does: `--success-soft-foreground` ink, on `--success-soft` when it sits on a tint. The soft pair is what the brand layer is checked to make readable (3:1 on the tint and on the page); the solid tone reads 3.8:1 or worse on white, so as a text colour it fails. The tones are the grammar's status tones (`success`, `warning`, `danger`, `info`), read from `lib/status-tones.generated.mjs`, which is generated from the grammar with the stylelint vocabulary.
5
+ A status tone has two faces. The solid tone (`--success`) is a fill, with `--success-foreground` ink on it. A status shown as text or as an icon takes the soft pair, the way a HeroUI flat Chip does: `--success-soft-foreground` ink, on `--success-soft` when it sits on a tint. The soft pair is what the brand layer is checked to make readable (3:1 on the tint and on the page); the solid tone reads 3.8:1 or worse on white, so as a text colour it fails. The tones are the grammar's status tones (`success`, `warning`, `danger`, `info`), read from `lib/status-tones.generated.ts`, which is generated from the grammar with the stylelint vocabulary.
6
6
 
7
7
  Every rule below is an error in `coreGrammarConfig`; none can be switched off or suppressed inline.
8
8
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starci/eslint-grammar-core",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "ESLint rules for the core Grammar's component, token and composition contracts.",
5
5
  "keywords": [
6
6
  "eslint",
@@ -23,20 +23,17 @@
23
23
  "private": false,
24
24
  "exports": {
25
25
  ".": {
26
- "types": "./index.d.ts",
27
- "import": "./index.mjs"
26
+ "types": "./dist/index.d.ts",
27
+ "import": "./dist/index.js"
28
28
  }
29
29
  },
30
- "main": "./index.mjs",
31
- "types": "./index.d.ts",
30
+ "main": "./dist/index.js",
31
+ "types": "./dist/index.d.ts",
32
32
  "files": [
33
- "LICENSE",
34
- "*.mjs",
35
- "*.d.ts",
36
- "lib/*.mjs",
33
+ "dist",
37
34
  "runtime/**",
38
35
  "docs/*.md",
39
- "!*.spec.mjs",
36
+ "LICENSE",
40
37
  "CHANGELOG.md",
41
38
  "README.md"
42
39
  ],
@@ -44,9 +41,9 @@
44
41
  "node": ">=22"
45
42
  },
46
43
  "peerDependencies": {
44
+ "@starci/eslint-canon-fe": "9.3.0",
47
45
  "eslint": ">=9",
48
- "typescript": ">=5.9",
49
- "@starci/eslint-canon-fe": "9.2.0"
46
+ "typescript": ">=5.9"
50
47
  },
51
48
  "dependencies": {
52
49
  "@typescript-eslint/parser": "^8.70.0"
@@ -55,12 +52,18 @@
55
52
  "access": "public"
56
53
  },
57
54
  "scripts": {
58
- "test": "node --test \"*.spec.mjs\""
55
+ "build": "node -e \"require('node:fs').rmSync('dist', {recursive: true, force: true})\" && tsc -p tsconfig.build.json",
56
+ "typecheck": "tsc -p tsconfig.json",
57
+ "prepack": "npm run build",
58
+ "test": "npm run typecheck && node --test \"src/**/*.spec.ts\""
59
59
  },
60
60
  "devDependencies": {
61
+ "@starci/eslint-canon-fe": "file:../eslint-canon-fe",
62
+ "@starci/tsconfig": "file:../tsconfig",
63
+ "@types/node": "24.19.2",
64
+ "@typescript-eslint/types": "^8.70.0",
61
65
  "eslint": "^9.18.0",
62
66
  "eslint-plugin-react-hooks": "^7.1.1",
63
- "typescript": "^6.0.3",
64
- "@starci/eslint-canon-fe": "file:../eslint-canon-fe"
67
+ "typescript": "^6.0.3"
65
68
  }
66
69
  }
@@ -30,35 +30,35 @@ pins:
30
30
  source: packages/event-stream/package.json
31
31
  why: 'the one server-sent-events client of every front end (fe-stack event-stream, owner:event-stream-package): the transport client layer (modules/api/sse/clients) creates one instance for the app with the bearer token source and the stream operations follow on it; it carries Last-Event-ID resume, the capped doubling backoff, the heartbeat timeout, the single 401 renewal and the useSWRSubscription adapter, so no app keeps a hand-written reader.'
32
32
  '@starci/grammar':
33
- version: 0.13.0
33
+ version: 0.14.0
34
34
  group: starci
35
35
  install: registry
36
36
  side: fe
37
37
  source: packages/grammar/package.json
38
38
  why: 'the grammar front ends read: the brand layer sets `--font-sans` and `--font-mono` and the grammar reads them; it carries the Input tel kind, the copyable and read-only Input, the IconButton disclosure props and count badge, ChatBubble, FillViewport, Textarea mentions and Enter-to-send, the leading PrimaryRailLayout rail, the plate-less Image, ListRow, the soft Alert, Progress steps, the floating primary IconButton, FlowGraph, the STATE-8 loading views and the bordered selectable TagGroup chips. The runtime source version wins for a @starci package.'
39
39
  '@starci/eslint-canon-be':
40
- version: 3.4.57
40
+ version: 3.5.0
41
41
  group: starci
42
42
  install: registry
43
43
  side: be
44
44
  source: packages/eslint-canon-be/package.json
45
45
  why: 'the back-end ESLint canon: `loadHfs(import.meta.url)` of be/eslint.config.mjs finds the app-root hfs.json (kind app) and gives every linted file the view of the be side, and the project graph is built per side; its bundled runtime copies (slots, canon-pins, failure codes, the architecture machine) put .starcistacks and .sops.yaml at the app root; `starciBeConfig({ hfs: loadHfs(import.meta.url) })` is the typed factory and the BE-CONVENTION laws are its rules.'
46
46
  '@starci/eslint-canon-fe':
47
- version: 9.2.0
47
+ version: 9.3.0
48
48
  group: starci
49
49
  install: registry
50
50
  side: fe
51
51
  source: packages/eslint-canon-fe/package.json
52
52
  why: 'the front-end ESLint canon: `loadHfs(import.meta.url)` of fe/eslint.config.mjs finds the app-root hfs.json (kind app) and gives every linted file the view of the fe side, and the project graph is built per side; its bundled runtime copies (slots, canon-pins, failure codes, the architecture machine) put .starcistacks and .sops.yaml at the app root; the front end has no tests (FE_NO_TESTS R97 in hfs) and no-vietnamese-in-source (R91) applies.'
53
53
  '@starci/eslint-grammar-core':
54
- version: 0.13.0
54
+ version: 0.14.0
55
55
  group: starci
56
56
  install: registry
57
57
  side: fe
58
58
  source: packages/eslint-grammar-core/package.json
59
59
  why: 'the StarCi Grammar component API lint, versioned with @starci/grammar; front ends compose this family package with the universal FE canon.'
60
60
  '@starci/eslint-grammar-offset-pop':
61
- version: 0.13.0
61
+ version: 0.14.0
62
62
  group: starci
63
63
  install: registry
64
64
  side: fe
@@ -97,19 +97,20 @@ pins:
97
97
  side: be
98
98
  source: packages/test-world/package.json
99
99
  why: 'the shared e2e library of every back end (R47, R48), a devDependency of every back end: the warm stack behind toxiproxy, the network-edge fakes, the Nest boot, the typed useTestWorld handle, useSandbox for contract specs and the outage lock. Kafka is real own infra (the one digest-pinned apache/kafka KRaft image, a broker listener and proxy per slot, slot-prefixed topics, consumer groups and client ids) and Postgres is schema-per-context (a schema and a login role per context in a shared database). Each worker has its own data slot (<ns>_w<k>) with its own databases, Keycloak realm, leased Redis DB, bucket/collection/topic prefixes, fakes host, toxiproxy proxies and outage lock; a run provisions min(jest workers, workers cap, default 2) slots, and state file protocol 2 pairs exactly with @starci/jest-preset (TEST_WORLD_PAIR_MISMATCH otherwise). A modules world boots real peer apps beside its modules, world.apps.<name>.during(fn) is the outage of a peer app, w.keycloak.clientSecret(client) answers the run-generated secret of a confidential realm client, world.keycloak.events/sessions read the realm user events and live sessions, world.infra.postgresql.connection(name) takes one database connection down while the others serve, and world.resolve and scope.resolve take any Nest token. Every path a declaration names (`stack`, seeds, the realm, Dockerfiles) resolves from the app root (the directory of hfs.json); the globalSetup registers the path aliases as TypeScript resolves them. Its CLI main is exported at ./cli for starci app stack.'
100
- '@starci/cli':
101
- version: 1.7.47
100
+ 'starci-harness':
101
+ version: 1.1.0-alpha.1
102
102
  group: starci
103
103
  install: registry
104
104
  side: both
105
- why: 'the only StarCi command package; product repositories install it for starci app, while @starci/hfs remains its pinned transitive implementation.'
105
+ source: cli/package.json
106
+ why: 'the one StarCi command line (bins starci and starci-harness): a product repository installs it for starci app, which it runs in-process through its exact @starci/hfs dependency, and its codegen loads starci-harness/input-bounds.'
106
107
  '@starci/hfs':
107
- version: 4.8.0
108
+ version: 4.9.0
108
109
  group: starci
109
110
  install: registry
110
111
  side: both
111
112
  source: packages/hfs/package.json
112
- why: 'the transitive app implementation used by @starci/cli; it carries the generated runtime slice read by starci app commands.'
113
+ why: 'the app implementation starci-harness runs for starci app; it carries the generated runtime slice read by starci app commands.'
113
114
  # --- tooling
114
115
  typescript:
115
116
  version: 5.9.3