@starci/eslint-grammar-core 0.15.33 → 0.16.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.
- package/CHANGELOG.md +9 -0
- package/README.md +1 -1
- package/dist/index.js +2 -0
- package/dist/lib/why.js +10 -0
- package/dist/mascot.d.ts +28 -0
- package/dist/mascot.js +166 -0
- package/docs/mascot.md +60 -0
- package/package.json +2 -2
- package/runtime/bundle.json +74 -0
- package/runtime/engine/by-code-unit.mjs +6 -6
- package/runtime/engine/model-config.mjs +98 -76
- package/runtime/engine/orca-config.mjs +22 -17
- package/runtime/engine/release-config.mjs +22 -18
- package/runtime/engine/removed-vocabulary.mjs +21 -20
- package/runtime/engine/resources-config.mjs +42 -35
- package/runtime/engine/runtime-root.mjs +11 -13
- package/runtime/engine/secrets.mjs +170 -102
- package/runtime/engine/sonar-config.mjs +16 -13
- package/runtime/knowledge/hfs/canon-pins.yaml +10 -10
- package/runtime/knowledge/hfs/rules.yaml +72 -6
- package/runtime/knowledge/hfs/slots.yaml +2 -2
- package/runtime/knowledge/patterns/fe/ui.yaml +12 -1
- package/runtime/scripts/hfs/allows.mjs +37 -38
- package/runtime/scripts/hfs/architecture/config.mjs +302 -268
- package/runtime/scripts/hfs/architecture/side-boundary.mjs +2 -5
- package/runtime/scripts/hfs/architecture/type-context.mjs +255 -250
- package/runtime/scripts/hfs/architecture/typescript.mjs +358 -318
- package/runtime/scripts/hfs/declaration-shape.mjs +140 -103
- package/runtime/scripts/hfs/declaration-slots.mjs +64 -61
- package/runtime/scripts/hfs/edition-slots.mjs +43 -40
- package/runtime/scripts/hfs/param-names.mjs +6 -11
- package/runtime/scripts/hfs/rule-catalog.mjs +353 -257
- package/runtime/scripts/hfs/rule-params-shape.mjs +60 -47
- package/runtime/scripts/hfs/slot-app-view.mjs +69 -74
- package/runtime/scripts/hfs/slot-classify.mjs +42 -38
- package/runtime/scripts/hfs/slot-errors.mjs +8 -7
- package/runtime/scripts/hfs/slot-imports.mjs +70 -63
- package/runtime/scripts/hfs/slot-manifest-shape.mjs +74 -52
- package/runtime/scripts/hfs/slot-match.mjs +122 -111
- package/runtime/scripts/hfs/slot-path.mjs +0 -2
- package/runtime/scripts/hfs/slot-required.mjs +65 -59
- package/runtime/scripts/hfs/slot-semantic-problems.mjs +138 -112
- package/runtime/scripts/hfs/slot-side-problems.mjs +23 -19
- package/runtime/scripts/hfs/slots.mjs +175 -152
- package/runtime/scripts/hfs/typescript-programs.mjs +66 -69
- package/runtime/scripts/hfs/view.mjs +38 -40
- package/runtime/scripts/lib/glob.mjs +23 -14
- package/runtime/scripts/lib/grammar-lint.mjs +33 -37
- package/runtime/scripts/lib/i18n.mjs +32 -35
- package/runtime/scripts/lib/json-clone.mjs +45 -36
- package/runtime/scripts/lib/json.mjs +26 -14
- package/runtime/scripts/lib/list.mjs +9 -14
- package/runtime/scripts/lib/path-key.mjs +53 -38
- package/runtime/scripts/lib/read-text.mjs +30 -17
- package/runtime/scripts/lib/regex.mjs +1 -2
- package/runtime/scripts/lib/source-phrases.mjs +13 -13
- package/runtime/scripts/lib/ts-analysis.mjs +28 -26
- package/runtime/scripts/lib/ts-ast.mjs +36 -42
- package/runtime/scripts/lib/walk.mjs +33 -27
- package/runtime/scripts/lib/yaml-cached.mjs +4 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.16.0 - 2026-10-30
|
|
4
|
+
|
|
5
|
+
- Changed: the peer moves to eslint-canon-fe 9.6.0; the bundled runtime copies are now generated from the hfs build (TypeScript emitted as .mjs) per runtime/bundle.json, with a drift check in CI; the copies the package keeps as its own are listed there and stay hand-maintained; the bundled canon pins name hfs 4.12.0, eslint-canon-be 3.9.0, eslint-canon-fe 9.6.0, the Grammar family 0.16.0, jest-preset 2.5.3 and test-world 1.3.3. No rule of this package changed.
|
|
6
|
+
- Added: starci-grammar/generated-art-in-mascot-or-media-frame (R338, FE_GENERATED_ART_OUTSIDE_FRAME): a static /images/**.webp or .png src outside Mascot or MediaFrame is a finding; starci-grammar/mascot-asset-slot-required (R339, FE_MASCOT_ASSET_SLOT_MISSING): a Mascot names its assetSlot. Specs: src/mascot.spec.ts.
|
|
7
|
+
|
|
8
|
+
## 0.15.34 - 2026-10-30
|
|
9
|
+
|
|
10
|
+
- Changed: the peer moves to eslint-canon-fe 9.5.23; the bundled canon pins name hfs 4.11.22 (app check and lint pass under 20 findings), eslint-canon-be 3.8.21, eslint-canon-fe 9.5.23 and the Grammar family 0.15.34. No rule of this package changed.
|
|
11
|
+
|
|
3
12
|
## 0.15.33 - 2026-10-30
|
|
4
13
|
|
|
5
14
|
- Changed: the peer moves to eslint-canon-fe 9.5.22; the bundled canon pins name hfs 4.11.21 (the one-command urgent release), eslint-canon-be 3.8.20, eslint-canon-fe 9.5.22 and the Grammar family 0.15.33. No rule of this package changed.
|
package/README.md
CHANGED
|
@@ -19,7 +19,7 @@ Family helpers and the generated runtime closure belong to this package.
|
|
|
19
19
|
|
|
20
20
|
The rule IDs remain stable when their namespace moves. Laws cover the fixed app shell and navbar,
|
|
21
21
|
Grammar-owned structural elements, glyphs and icon sizes, destructive menu entries, status inks,
|
|
22
|
-
chip labels that start uppercase, the page head composed by `PageHeader`, the route page as one `Page` with its tabs in the header, heading and token scales, vendor ownership and slot-status composition. Each rule's metadata cites
|
|
22
|
+
chip labels that start uppercase, generated art rendered through `Mascot` or `MediaFrame` with every `Mascot` naming its asset slot, the page head composed by `PageHeader`, the route page as one `Page` with its tabs in the header, heading and token scales, vendor ownership and slot-status composition. Each rule's metadata cites
|
|
23
23
|
its behavioral knowledge source and the governing ownership test. The R318 surface composition
|
|
24
24
|
rules (nested control variants, panel-list alignment, layout-only CSS) stay in the universal canon.
|
|
25
25
|
|
package/dist/index.js
CHANGED
|
@@ -5,6 +5,7 @@ import { rules as chipLabelRules } from "./chip-label.js";
|
|
|
5
5
|
import { rules as drawingRules } from "./drawing.js";
|
|
6
6
|
import { rules as grammarBoundaryRules } from "./grammar-boundary.js";
|
|
7
7
|
import { rules as iconRules } from "./icon.js";
|
|
8
|
+
import { rules as mascotRules } from "./mascot.js";
|
|
8
9
|
import { rules as menuEntriesRules } from "./menu-entries.js";
|
|
9
10
|
import { rules as pageRules } from "./page.js";
|
|
10
11
|
import { rules as pageHeaderRules } from "./page-header.js";
|
|
@@ -19,6 +20,7 @@ const CONTRIBUTIONS = [
|
|
|
19
20
|
{ law: "drawing", rules: drawingRules },
|
|
20
21
|
{ law: "grammar-boundary", rules: grammarBoundaryRules },
|
|
21
22
|
{ law: "icon", rules: iconRules },
|
|
23
|
+
{ law: "mascot", rules: mascotRules },
|
|
22
24
|
{ law: "menu-entries", rules: menuEntriesRules },
|
|
23
25
|
{ law: "page", rules: pageRules },
|
|
24
26
|
{ law: "page-header", rules: pageHeaderRules },
|
package/dist/lib/why.js
CHANGED
|
@@ -9,6 +9,16 @@ export const why = {
|
|
|
9
9
|
en: "A StatusDot or Badge label in `<file>` starts with a lowercase letter.",
|
|
10
10
|
fix: "Start the label with an uppercase letter (`Running`, not `running`); the rule fixes a literal label in place.",
|
|
11
11
|
},
|
|
12
|
+
"generated-art-in-mascot-or-media-frame": {
|
|
13
|
+
code: "FE_GENERATED_ART_OUTSIDE_FRAME",
|
|
14
|
+
en: "Generated art (a `.webp` or `.png` under `/images/`) in `<file>` is drawn outside Grammar's `Mascot` and `MediaFrame`, so it takes whatever box the page gives it.",
|
|
15
|
+
fix: "Render a character with `<Mascot src size assetSlot />` (sm, md, lg or hero), or frame a scene or illustration in `<MediaFrame assetSlot>`.",
|
|
16
|
+
},
|
|
17
|
+
"mascot-asset-slot-required": {
|
|
18
|
+
code: "FE_MASCOT_ASSET_SLOT_MISSING",
|
|
19
|
+
en: "A `Mascot` in `<file>` names no `assetSlot`, so nothing ties the art it draws to the asset workflow's request.",
|
|
20
|
+
fix: "Pass `assetSlot` (the slot id of the request) and `assetSha256` once the file is delivered.",
|
|
21
|
+
},
|
|
12
22
|
"navbar-cluster": {
|
|
13
23
|
code: "FE_NAVBAR_CLUSTER",
|
|
14
24
|
en: "Navbar action component kinds or order differ from the app-shell canon.",
|
package/dist/mascot.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Rule } from "eslint";
|
|
2
|
+
/**
|
|
3
|
+
* The mascot law (HFS R338 `FE_GENERATED_ART_OUTSIDE_FRAME`, R339 `FE_MASCOT_ASSET_SLOT_MISSING`): generated art renders
|
|
4
|
+
* at the Grammar's size scale and stays tied to the asset workflow's request.
|
|
5
|
+
*
|
|
6
|
+
* Generated art is a raster the asset workflow delivers under the public `/images/` tree, as `.webp` or `.png`. Drawn
|
|
7
|
+
* through a bare `Image` or `<img>` it takes whatever box its page gives it, so one mascot comes out at a different size on
|
|
8
|
+
* every screen and nothing ties the bytes to the slot that requested them. Grammar's `Mascot` owns the character scale
|
|
9
|
+
* (sm, md, lg, hero), and `MediaFrame` frames a scene or illustration; those two are its renderers.
|
|
10
|
+
*
|
|
11
|
+
* WHAT IT READS. A JSX `src` attribute whose value is statically a string (a literal, an expression container holding one,
|
|
12
|
+
* a template literal without expressions, or a `const` of the file initialised with one). A source computed at run time is
|
|
13
|
+
* not judged: the rule never guesses. `assetSlot` is read on every `Mascot` element (`Mascot` or `<ns>.Mascot`); a spread
|
|
14
|
+
* attribute may carry it, so an element with a spread is not judged.
|
|
15
|
+
*
|
|
16
|
+
* WHERE IT APPLIES. Product source (`isProductSource`: the route, feature, component, hook, foundation, module and transport
|
|
17
|
+
* tiers and the workspace packages). A script or a file no slot owns is not judged.
|
|
18
|
+
*/
|
|
19
|
+
/** A generated raster under the public `/images/` tree: `.webp` or `.png`, any case, an optional query or fragment. */
|
|
20
|
+
export declare const GENERATED_ART: RegExp;
|
|
21
|
+
/** Generated art under `/images/` renders only through `Mascot` or inside a `MediaFrame`. */
|
|
22
|
+
export declare const generatedArtInMascotOrMediaFrame: Rule.RuleModule;
|
|
23
|
+
/** Every `Mascot` names the asset slot its art was requested under. */
|
|
24
|
+
export declare const mascotAssetSlotRequired: Rule.RuleModule;
|
|
25
|
+
/** The rules this law contributes to the plugin. */
|
|
26
|
+
export declare const rules: Record<string, Rule.RuleModule>;
|
|
27
|
+
/** Every rule of this law is an error. */
|
|
28
|
+
export declare const recommended: Record<string, "error">;
|
package/dist/mascot.js
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { isProductSource } from "@starci/eslint-canon-fe/lib/scope.mjs";
|
|
2
|
+
import { defineFamilyRules, defineRule } from "./lib/rule-contract.js";
|
|
3
|
+
/**
|
|
4
|
+
* The mascot law (HFS R338 `FE_GENERATED_ART_OUTSIDE_FRAME`, R339 `FE_MASCOT_ASSET_SLOT_MISSING`): generated art renders
|
|
5
|
+
* at the Grammar's size scale and stays tied to the asset workflow's request.
|
|
6
|
+
*
|
|
7
|
+
* Generated art is a raster the asset workflow delivers under the public `/images/` tree, as `.webp` or `.png`. Drawn
|
|
8
|
+
* through a bare `Image` or `<img>` it takes whatever box its page gives it, so one mascot comes out at a different size on
|
|
9
|
+
* every screen and nothing ties the bytes to the slot that requested them. Grammar's `Mascot` owns the character scale
|
|
10
|
+
* (sm, md, lg, hero), and `MediaFrame` frames a scene or illustration; those two are its renderers.
|
|
11
|
+
*
|
|
12
|
+
* WHAT IT READS. A JSX `src` attribute whose value is statically a string (a literal, an expression container holding one,
|
|
13
|
+
* a template literal without expressions, or a `const` of the file initialised with one). A source computed at run time is
|
|
14
|
+
* not judged: the rule never guesses. `assetSlot` is read on every `Mascot` element (`Mascot` or `<ns>.Mascot`); a spread
|
|
15
|
+
* attribute may carry it, so an element with a spread is not judged.
|
|
16
|
+
*
|
|
17
|
+
* WHERE IT APPLIES. Product source (`isProductSource`: the route, feature, component, hook, foundation, module and transport
|
|
18
|
+
* tiers and the workspace packages). A script or a file no slot owns is not judged.
|
|
19
|
+
*/
|
|
20
|
+
/** A generated raster under the public `/images/` tree: `.webp` or `.png`, any case, an optional query or fragment. */
|
|
21
|
+
export const GENERATED_ART = /^\/images\/.+\.(?:webp|png)(?:[?#].*)?$/i;
|
|
22
|
+
/** The components that render generated art: the element itself, or an ancestor that frames it. */
|
|
23
|
+
const RENDERER = "Mascot";
|
|
24
|
+
const FRAME = "MediaFrame";
|
|
25
|
+
/** The element name of a JSX name: `Mascot`, or `G.Mascot` as `Mascot`. */
|
|
26
|
+
const nameOf = (name) => {
|
|
27
|
+
if (name.type === "JSXIdentifier")
|
|
28
|
+
return name.name;
|
|
29
|
+
if (name.type === "JSXMemberExpression")
|
|
30
|
+
return name.property.name;
|
|
31
|
+
return null;
|
|
32
|
+
};
|
|
33
|
+
/** The variable a name resolves to, walking the scope chain outwards. */
|
|
34
|
+
const variableOf = (scope, name) => {
|
|
35
|
+
for (let current = scope; current !== null; current = current.upper) {
|
|
36
|
+
const found = current.set.get(name);
|
|
37
|
+
if (found !== undefined)
|
|
38
|
+
return found;
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
};
|
|
42
|
+
/** The string an expression statically is, or null. */
|
|
43
|
+
const staticString = (expression, scopeOf) => {
|
|
44
|
+
if (expression.type === "Literal")
|
|
45
|
+
return typeof expression.value === "string" ? expression.value : null;
|
|
46
|
+
if (expression.type === "TemplateLiteral")
|
|
47
|
+
return expression.expressions.length === 0 ? expression.quasis.map((quasi) => quasi.value.cooked ?? "").join("") : null;
|
|
48
|
+
if (expression.type === "Identifier") {
|
|
49
|
+
const variable = variableOf(scopeOf(expression), expression.name);
|
|
50
|
+
const definition = variable?.defs.length === 1 ? variable.defs[0] : undefined;
|
|
51
|
+
if (definition?.type !== "Variable")
|
|
52
|
+
return null;
|
|
53
|
+
const declarator = definition.node;
|
|
54
|
+
const declaration = definition.parent;
|
|
55
|
+
if (declaration?.kind !== "const" || declarator.init === null || declarator.init.type === "Identifier")
|
|
56
|
+
return null;
|
|
57
|
+
return staticString(declarator.init, scopeOf);
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
};
|
|
61
|
+
/** The string a JSX attribute value statically is, or null. */
|
|
62
|
+
const attributeString = (value, scopeOf) => {
|
|
63
|
+
if (value === null)
|
|
64
|
+
return null;
|
|
65
|
+
if (value.type === "Literal")
|
|
66
|
+
return typeof value.value === "string" ? value.value : null;
|
|
67
|
+
if (value.type === "JSXExpressionContainer" && value.expression.type !== "JSXEmptyExpression")
|
|
68
|
+
return staticString(value.expression, scopeOf);
|
|
69
|
+
return null;
|
|
70
|
+
};
|
|
71
|
+
/** True when a `MediaFrame` element encloses the node. */
|
|
72
|
+
const insideFrame = (node) => {
|
|
73
|
+
// The Program's parent is null at run time though the tree's types say a node always has one.
|
|
74
|
+
for (let current = node.parent; current !== undefined && current !== null; current = current.parent) {
|
|
75
|
+
if (current.type === "JSXElement" && nameOf(current.openingElement.name) === FRAME)
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
return false;
|
|
79
|
+
};
|
|
80
|
+
/** Generated art under `/images/` renders only through `Mascot` or inside a `MediaFrame`. */
|
|
81
|
+
export const generatedArtInMascotOrMediaFrame = defineRule({
|
|
82
|
+
meta: {
|
|
83
|
+
type: "problem",
|
|
84
|
+
docs: { description: "R338: generated art (a .webp or .png under /images/) renders only through Grammar's Mascot or inside a MediaFrame." },
|
|
85
|
+
schema: [],
|
|
86
|
+
messages: {
|
|
87
|
+
outside: "`{{src}}` is generated art drawn by `<{{element}}>`, outside Grammar's `Mascot` and `MediaFrame`: it takes whatever box this page gives it, so the same art comes out at a different size on every screen. Render a character with `<Mascot src size assetSlot />` (sm, md, lg or hero) or frame a scene or illustration in `<MediaFrame assetSlot>`.",
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
create(context) {
|
|
91
|
+
if (!isProductSource(context))
|
|
92
|
+
return {};
|
|
93
|
+
const scopeOf = (node) => context.sourceCode.getScope(node);
|
|
94
|
+
return {
|
|
95
|
+
JSXAttribute(attribute) {
|
|
96
|
+
if (attribute.name.type !== "JSXIdentifier" || attribute.name.name !== "src")
|
|
97
|
+
return;
|
|
98
|
+
const src = attributeString(attribute.value, scopeOf);
|
|
99
|
+
if (src === null || !GENERATED_ART.test(src))
|
|
100
|
+
return;
|
|
101
|
+
const opening = attribute.parent;
|
|
102
|
+
if (opening.type !== "JSXOpeningElement")
|
|
103
|
+
return;
|
|
104
|
+
const element = nameOf(opening.name);
|
|
105
|
+
if (element === RENDERER || insideFrame(opening))
|
|
106
|
+
return;
|
|
107
|
+
context.report({ node: attribute, messageId: "outside", data: { src, element: element ?? "element" } });
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
/** True when an attribute names nothing: no value, an empty string, or `undefined`. */
|
|
113
|
+
const namesNothing = (attribute) => {
|
|
114
|
+
const value = attribute.value;
|
|
115
|
+
if (value === null)
|
|
116
|
+
return true;
|
|
117
|
+
if (value.type === "Literal")
|
|
118
|
+
return value.value === "";
|
|
119
|
+
if (value.type !== "JSXExpressionContainer")
|
|
120
|
+
return false;
|
|
121
|
+
const expression = value.expression;
|
|
122
|
+
if (expression.type === "JSXEmptyExpression")
|
|
123
|
+
return true;
|
|
124
|
+
if (expression.type === "Identifier")
|
|
125
|
+
return expression.name === "undefined";
|
|
126
|
+
if (expression.type === "Literal")
|
|
127
|
+
return expression.value === "" || expression.value === null;
|
|
128
|
+
if (expression.type === "TemplateLiteral")
|
|
129
|
+
return expression.expressions.length === 0 && expression.quasis.every((quasi) => quasi.value.cooked === "");
|
|
130
|
+
return false;
|
|
131
|
+
};
|
|
132
|
+
/** Every `Mascot` names the asset slot its art was requested under. */
|
|
133
|
+
export const mascotAssetSlotRequired = defineRule({
|
|
134
|
+
meta: {
|
|
135
|
+
type: "problem",
|
|
136
|
+
docs: { description: "R339: every Grammar Mascot names its asset slot (assetSlot), tying the rendered art to the asset workflow's request." },
|
|
137
|
+
schema: [],
|
|
138
|
+
messages: {
|
|
139
|
+
missing: "This `Mascot` names no `assetSlot`: nothing ties the art it draws to the asset workflow's request, so a drawing, a test or an audit cannot tell which slot it fills. Pass `assetSlot` (and `assetSha256` once the file is delivered).",
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
create(context) {
|
|
143
|
+
if (!isProductSource(context))
|
|
144
|
+
return {};
|
|
145
|
+
return {
|
|
146
|
+
JSXOpeningElement(opening) {
|
|
147
|
+
if (nameOf(opening.name) !== RENDERER)
|
|
148
|
+
return;
|
|
149
|
+
if (opening.attributes.some((attribute) => attribute.type === "JSXSpreadAttribute"))
|
|
150
|
+
return;
|
|
151
|
+
const slot = opening.attributes.find((attribute) => attribute.type === "JSXAttribute" && attribute.name.type === "JSXIdentifier" && attribute.name.name === "assetSlot");
|
|
152
|
+
if (slot === undefined || namesNothing(slot))
|
|
153
|
+
context.report({ node: opening, messageId: "missing" });
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
/** The rules this law contributes to the plugin. */
|
|
159
|
+
export const rules = {
|
|
160
|
+
"generated-art-in-mascot-or-media-frame": generatedArtInMascotOrMediaFrame,
|
|
161
|
+
"mascot-asset-slot-required": mascotAssetSlotRequired,
|
|
162
|
+
};
|
|
163
|
+
defineFamilyRules({ "generated-art-in-mascot-or-media-frame": generatedArtInMascotOrMediaFrame }, "knowledge/hfs/rules.yaml#R338");
|
|
164
|
+
defineFamilyRules({ "mascot-asset-slot-required": mascotAssetSlotRequired }, "knowledge/hfs/rules.yaml#R339");
|
|
165
|
+
/** Every rule of this law is an error. */
|
|
166
|
+
export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-grammar/${name}`, "error"]));
|
package/docs/mascot.md
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# Generated art renders through Mascot
|
|
2
|
+
|
|
3
|
+
Law module: `mascot.ts`. Catalogue: R338 FE_GENERATED_ART_OUTSIDE_FRAME, R339 FE_MASCOT_ASSET_SLOT_MISSING.
|
|
4
|
+
|
|
5
|
+
Generated art is a raster the asset workflow delivers under the public `/images/` tree, as `.webp` or `.png`. Grammar's
|
|
6
|
+
`Mascot` renders a character at one size scale (`sm` 96px, `md` 160px, `lg` 240px square, `hero` filling its host's
|
|
7
|
+
height up to its cap), always contained on a transparent ground; `MediaFrame` frames a scene or illustration. Both carry
|
|
8
|
+
the asset-slot props (`assetSlot`, `assetSha256`, `assetPrompt`) that tie the render to the request. Drawn through a bare
|
|
9
|
+
`Image` or `<img>`, the same art takes whatever box each page gives it.
|
|
10
|
+
|
|
11
|
+
Every rule below is an error in `coreGrammarConfig`; none can be switched off or suppressed inline. Both apply to
|
|
12
|
+
product source (the route, feature, component, hook, foundation, module and transport tiers and the workspace packages);
|
|
13
|
+
a script or a file no slot owns is not judged.
|
|
14
|
+
|
|
15
|
+
## `starci-grammar/generated-art-in-mascot-or-media-frame`
|
|
16
|
+
|
|
17
|
+
A JSX `src` attribute whose value is statically a path under `/images/` ending in `.webp` or `.png` (any case, an
|
|
18
|
+
optional query or fragment) is a finding unless its element is a `Mascot` (`Mascot` or `<ns>.Mascot`) or a `MediaFrame`
|
|
19
|
+
element encloses it. The value is read from a string literal, an expression container holding one, a template literal
|
|
20
|
+
without expressions, or a `const` of the file initialised with one; a source computed at run time is not judged.
|
|
21
|
+
`.svg`, `.jpg` and paths outside `/images/` are not generated art here.
|
|
22
|
+
|
|
23
|
+
**Invalid** (`src/components/blocks/Onboarding/component.tsx`)
|
|
24
|
+
|
|
25
|
+
```tsx
|
|
26
|
+
<Image src="/images/mascot/welcome.webp" alt="" />
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
**Valid** (`src/components/blocks/Onboarding/component.tsx`)
|
|
30
|
+
|
|
31
|
+
```tsx
|
|
32
|
+
<Mascot src="/images/mascot/welcome.webp" size="lg" assetSlot="onboarding.welcome" />
|
|
33
|
+
|
|
34
|
+
<MediaFrame assetSlot="landing.scene">
|
|
35
|
+
<Image src="/images/scene/landing.webp" alt="" />
|
|
36
|
+
</MediaFrame>
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
**Finding code:** `FE_GENERATED_ART_OUTSIDE_FRAME`
|
|
40
|
+
|
|
41
|
+
## `starci-grammar/mascot-asset-slot-required`
|
|
42
|
+
|
|
43
|
+
A `Mascot` element with no `assetSlot`, or with one that names nothing (`""`, `{undefined}`), is a finding, reported on
|
|
44
|
+
the element. An element with a spread attribute is not judged: the spread may carry the slot.
|
|
45
|
+
|
|
46
|
+
**Invalid**
|
|
47
|
+
|
|
48
|
+
```tsx
|
|
49
|
+
<Mascot src="/images/mascot/welcome.webp" size="hero" />
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
**Valid**
|
|
53
|
+
|
|
54
|
+
```tsx
|
|
55
|
+
<Mascot src="/images/mascot/welcome.webp" size="hero" assetSlot="auth.sign-in" assetSha256={sha} />
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
**Finding code:** `FE_MASCOT_ASSET_SLOT_MISSING`
|
|
59
|
+
|
|
60
|
+
Rule ownership: [FE-RULE-OWNERSHIP](../../../contexts/code/fe/README.md#the-placement-test-fe-rule-ownership).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@starci/eslint-grammar-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "ESLint rules for the core Grammar's component, token and composition contracts.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"eslint",
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"node": ">=22"
|
|
42
42
|
},
|
|
43
43
|
"peerDependencies": {
|
|
44
|
-
"@starci/eslint-canon-fe": "9.
|
|
44
|
+
"@starci/eslint-canon-fe": "9.6.0",
|
|
45
45
|
"eslint": ">=9",
|
|
46
46
|
"typescript": ">=5.9"
|
|
47
47
|
},
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
{
|
|
2
|
+
"generated": [
|
|
3
|
+
"engine/by-code-unit.mjs",
|
|
4
|
+
"engine/model-config.mjs",
|
|
5
|
+
"engine/orca-config.mjs",
|
|
6
|
+
"engine/plain-object.mjs",
|
|
7
|
+
"engine/release-config.mjs",
|
|
8
|
+
"engine/removed-vocabulary.mjs",
|
|
9
|
+
"engine/resources-config.mjs",
|
|
10
|
+
"engine/runtime-root.mjs",
|
|
11
|
+
"engine/secrets.mjs",
|
|
12
|
+
"engine/sonar-config.mjs",
|
|
13
|
+
"engine/yaml.mjs",
|
|
14
|
+
"knowledge/grammars/core/playbook.yaml",
|
|
15
|
+
"knowledge/grammars/index.yaml",
|
|
16
|
+
"knowledge/hfs/canon-pins.yaml",
|
|
17
|
+
"knowledge/ui/presentation/font.yaml",
|
|
18
|
+
"knowledge/ui/presentation/padding.yaml",
|
|
19
|
+
"modules/kernel/removed-vocabulary.yaml",
|
|
20
|
+
"modules/models/registry.yaml",
|
|
21
|
+
"modules/models/runtimes.yaml",
|
|
22
|
+
"modules/models/tiers.yaml",
|
|
23
|
+
"scripts/hfs/allows.mjs",
|
|
24
|
+
"scripts/hfs/architecture/config.mjs",
|
|
25
|
+
"scripts/hfs/architecture/side-boundary.mjs",
|
|
26
|
+
"scripts/hfs/architecture/type-context.mjs",
|
|
27
|
+
"scripts/hfs/architecture/typescript.mjs",
|
|
28
|
+
"scripts/hfs/declaration-shape.mjs",
|
|
29
|
+
"scripts/hfs/declaration-slots.mjs",
|
|
30
|
+
"scripts/hfs/edition-slots.mjs",
|
|
31
|
+
"scripts/hfs/param-names.mjs",
|
|
32
|
+
"scripts/hfs/rule-catalog.mjs",
|
|
33
|
+
"scripts/hfs/rule-params-shape.mjs",
|
|
34
|
+
"scripts/hfs/slot-app-view.mjs",
|
|
35
|
+
"scripts/hfs/slot-classify.mjs",
|
|
36
|
+
"scripts/hfs/slot-errors.mjs",
|
|
37
|
+
"scripts/hfs/slot-imports.mjs",
|
|
38
|
+
"scripts/hfs/slot-manifest-shape.mjs",
|
|
39
|
+
"scripts/hfs/slot-match.mjs",
|
|
40
|
+
"scripts/hfs/slot-path.mjs",
|
|
41
|
+
"scripts/hfs/slot-required.mjs",
|
|
42
|
+
"scripts/hfs/slot-semantic-problems.mjs",
|
|
43
|
+
"scripts/hfs/slot-side-problems.mjs",
|
|
44
|
+
"scripts/hfs/slots.mjs",
|
|
45
|
+
"scripts/hfs/typescript-programs.mjs",
|
|
46
|
+
"scripts/hfs/view.mjs",
|
|
47
|
+
"scripts/lib/glob.mjs",
|
|
48
|
+
"scripts/lib/grammar-lint.mjs",
|
|
49
|
+
"scripts/lib/i18n.mjs",
|
|
50
|
+
"scripts/lib/json-clone.mjs",
|
|
51
|
+
"scripts/lib/json.mjs",
|
|
52
|
+
"scripts/lib/list.mjs",
|
|
53
|
+
"scripts/lib/path-key.mjs",
|
|
54
|
+
"scripts/lib/read-text.mjs",
|
|
55
|
+
"scripts/lib/regex.mjs",
|
|
56
|
+
"scripts/lib/source-phrases.mjs",
|
|
57
|
+
"scripts/lib/ts-analysis.mjs",
|
|
58
|
+
"scripts/lib/ts-ast.mjs",
|
|
59
|
+
"scripts/lib/walk.mjs",
|
|
60
|
+
"scripts/lib/yaml-cached.mjs"
|
|
61
|
+
],
|
|
62
|
+
"own": [
|
|
63
|
+
"engine/config.mjs",
|
|
64
|
+
"engine/invalid-config.mjs",
|
|
65
|
+
"knowledge/hfs/rules.yaml",
|
|
66
|
+
"knowledge/hfs/slots.yaml",
|
|
67
|
+
"knowledge/patterns/fe/index.yaml",
|
|
68
|
+
"knowledge/patterns/fe/ui.yaml",
|
|
69
|
+
"knowledge/ui/composition/app-shell.yaml",
|
|
70
|
+
"knowledge/ui/proof/anatomy-source.yaml",
|
|
71
|
+
"modules/goal/source-phrases.yaml",
|
|
72
|
+
"scripts/hfs/manifest-shape.mjs"
|
|
73
|
+
]
|
|
74
|
+
}
|
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
// by-code-unit.mjs -
|
|
2
|
-
|
|
1
|
+
// by-code-unit.mjs -the comparator of a bare `Array.prototype.sort()`, owned beside the engine files that digest and canonicalize (scripts/lib/list.mjs re-exports it).
|
|
3
2
|
/**
|
|
4
3
|
* The comparator of a bare `Array.prototype.sort()`: values compared as strings by UTF-16 code unit. Pass it to `sort` and
|
|
5
4
|
* `toSorted` so the order is stated, and stays byte-identical to the default order that digests and canonical output depend on.
|
|
6
5
|
*/
|
|
7
6
|
export const byCodeUnit = (a, b) => {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
7
|
+
const left = String(a);
|
|
8
|
+
const right = String(b);
|
|
9
|
+
if (left < right)
|
|
10
|
+
return -1;
|
|
11
|
+
return left > right ? 1 : 0;
|
|
12
12
|
};
|
|
@@ -1,90 +1,112 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
-
import {fileURLToPath} from 'node:url';
|
|
3
|
-
import {parseYaml} from './yaml.mjs';
|
|
4
|
-
import {isPlainObject as plain} from './plain-object.mjs';
|
|
5
|
-
import {invalid} from './invalid-config.mjs';
|
|
6
|
-
import {byCodeUnit} from './by-code-unit.mjs';
|
|
7
|
-
import {removedOfKind} from './removed-vocabulary.mjs';
|
|
8
|
-
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { parseYaml } from './yaml.mjs';
|
|
4
|
+
import { isPlainObject as plain } from './plain-object.mjs';
|
|
5
|
+
import { invalid } from './invalid-config.mjs';
|
|
6
|
+
import { byCodeUnit } from './by-code-unit.mjs';
|
|
7
|
+
import { removedOfKind } from './removed-vocabulary.mjs';
|
|
9
8
|
/** The effort vocabulary, ordered weakest to strongest — the only list of it. */
|
|
10
|
-
export const EFFORT_LEVELS=new Set(['none','minimal','low','medium','high','xhigh','max','ultra']);
|
|
11
|
-
const DIFFICULTIES=['easy','medium','hard','insane'];
|
|
12
|
-
export const MODELS_KEYS=['tiers','seats','balance','usage'];
|
|
13
|
-
const known=names=>[...names].sort(byCodeUnit).join(', ');
|
|
14
|
-
|
|
15
|
-
const hasPath=(root,dotted)=>dotted.split('.').reduce((node,key)=>(plain(node)&&Object.hasOwn(node,key)?node[key]:undefined),root)!==undefined;
|
|
16
|
-
|
|
9
|
+
export const EFFORT_LEVELS = new Set(['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra']);
|
|
10
|
+
const DIFFICULTIES = ['easy', 'medium', 'hard', 'insane'];
|
|
11
|
+
export const MODELS_KEYS = ['tiers', 'seats', 'balance', 'usage'];
|
|
12
|
+
const known = (names) => [...names].sort(byCodeUnit).join(', ');
|
|
13
|
+
const hasPath = (root, dotted) => dotted.split('.').reduce((node, key) => (plain(node) && Object.hasOwn(node, key) ? node[key] : undefined), root) !== undefined;
|
|
17
14
|
/** Refuse an old-shape config (the removed keys of modules/kernel/removed-vocabulary.yaml), naming every removed key and where its meaning lives now. */
|
|
18
|
-
export function refuseRemovedKeys(config){
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
15
|
+
export function refuseRemovedKeys(config) {
|
|
16
|
+
const found = removedOfKind('config-key').filter(({ name }) => hasPath(config, name));
|
|
17
|
+
if (!found.length)
|
|
18
|
+
return;
|
|
19
|
+
const removed = found.map(({ name, use }) => `${name} is removed (${use})`).join('; ');
|
|
20
|
+
throw new Error(`Invalid config.yaml: ${removed}.`);
|
|
23
21
|
}
|
|
24
|
-
|
|
25
|
-
let shippedCache=null;
|
|
22
|
+
let shippedCache = null;
|
|
26
23
|
/** modules/models/tiers.yaml as shipped, parsed once per file version. */
|
|
27
|
-
export function shippedTiers(){
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
24
|
+
export function shippedTiers() {
|
|
25
|
+
const file = fileURLToPath(new URL('../modules/models/tiers.yaml', import.meta.url));
|
|
26
|
+
let stat;
|
|
27
|
+
try {
|
|
28
|
+
stat = fs.statSync(file);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
throw new Error('Missing modules/models/tiers.yaml');
|
|
32
|
+
}
|
|
33
|
+
const version = `${stat.mtimeMs}:${stat.size}`;
|
|
34
|
+
if (shippedCache?.version !== version)
|
|
35
|
+
shippedCache = { version, doc: parseYaml(fs.readFileSync(file, 'utf8')) };
|
|
36
|
+
return structuredClone(shippedCache.doc);
|
|
33
37
|
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
38
|
+
function validateMember(bad, where, member, profile) {
|
|
39
|
+
if (!plain(member) || Object.keys(member).some(key => !['agent', 'model', 'effort'].includes(key)) || typeof member.agent !== 'string' || typeof member.model !== 'string')
|
|
40
|
+
return bad(`${where} must be {agent, model, effort?}.`);
|
|
41
|
+
if (profile.models?.[member.model]?.provider !== member.agent)
|
|
42
|
+
bad(`${where}: model ${member.model} is not declared by agent ${member.agent} in modules/models/registry.yaml models.`);
|
|
43
|
+
if (member.effort !== undefined && !(typeof member.effort === 'string' && EFFORT_LEVELS.has(member.effort)))
|
|
44
|
+
bad(`${where}.effort must use the effort vocabulary.`);
|
|
40
45
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
46
|
+
function validateChains(bad, tiers, profile) {
|
|
47
|
+
for (const [name, chain] of Object.entries(tiers)) {
|
|
48
|
+
if (!Array.isArray(chain) || !chain.length)
|
|
49
|
+
return bad(`.${name} must be a non-empty ordered list of {agent, model, effort?}.`);
|
|
50
|
+
chain.forEach((member, index) => validateMember(bad, `.${name}[${index}]`, member, profile));
|
|
51
|
+
// validateMember refused every member that is not a {agent, model, effort?} record above.
|
|
52
|
+
const members = chain;
|
|
53
|
+
if (new Set(members.map(member => `${member.agent}/${member.model}`)).size !== chain.length)
|
|
54
|
+
bad(`.${name} names each member once.`);
|
|
55
|
+
}
|
|
48
56
|
}
|
|
49
|
-
|
|
50
57
|
/** A call tier is taken by a headless call only: no seat, difficulty, kind, order or caller reference may name it, and every call names one. */
|
|
51
|
-
function validateUse(bad,{doc,names,refs}){
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
58
|
+
function validateUse(bad, { doc, names, refs }) {
|
|
59
|
+
const callTiers = new Set(Object.entries(doc.tierUse).filter(([, use]) => use === 'call').map(([tier]) => tier));
|
|
60
|
+
for (const tier of Object.keys(doc.tierUse))
|
|
61
|
+
if (!names.has(tier))
|
|
62
|
+
bad(`: tierUse names tier ${tier} (known: ${known(names)}).`);
|
|
63
|
+
for (const [where, tier] of refs)
|
|
64
|
+
if (callTiers.has(tier))
|
|
65
|
+
bad(`: ${where} names tier ${tier}, a call tier (tierUse): a headless call is made on it, no seat or op is seated on it.`);
|
|
66
|
+
for (const [call, spec] of Object.entries(doc.calls))
|
|
67
|
+
if (!callTiers.has(spec.tier))
|
|
68
|
+
bad(`: calls.${call}.tier ${spec.tier} must be a tier whose tierUse is call.`);
|
|
56
69
|
}
|
|
57
|
-
|
|
58
70
|
/** The tier document the picker reads: the shipped tiers.yaml with config.yaml `models` laid over it. Validated. */
|
|
59
|
-
export function effectiveTiers(config,profile){
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
71
|
+
export function effectiveTiers(config, profile) {
|
|
72
|
+
const doc = shippedTiers(), models = plain(config?.models) ? config.models : {};
|
|
73
|
+
const bad = invalid('models');
|
|
74
|
+
const merged = { ...doc.tiers, ...models.tiers };
|
|
75
|
+
validateChains(bad, merged, profile);
|
|
76
|
+
// validateChains refused every chain that is not a non-empty list of {agent, model, effort?} members.
|
|
77
|
+
const tiers = merged;
|
|
78
|
+
const seats = { ...doc.seats, ...models.seats };
|
|
79
|
+
const balance = { ...doc.balance, ...models.balance };
|
|
80
|
+
const usage = { ...doc.usage, ...models.usage };
|
|
81
|
+
const names = new Set(Object.keys(tiers));
|
|
82
|
+
const refs = [...Object.entries(seats).map(([seat, tier]) => [`.seats.${seat}`, tier]), ...DIFFICULTIES.map(level => [`difficulty.${level}`, doc.difficulty[level]]),
|
|
83
|
+
...Object.entries(doc.kindTiers).map(([kind, tier]) => [`kindTiers.${kind}`, tier]), ...doc.tierOrder.map(tier => ['tierOrder', tier])];
|
|
84
|
+
for (const [where, tier] of refs)
|
|
85
|
+
if (!names.has(tier))
|
|
86
|
+
bad(`: ${where} names tier ${tier} (known: ${known(names)}).`);
|
|
87
|
+
validateUse(bad, { doc, names, refs });
|
|
88
|
+
if (!(Number.isInteger(balance.maxStreak) && balance.maxStreak >= 1))
|
|
89
|
+
bad('.balance.maxStreak must be an integer >= 1.');
|
|
90
|
+
if (!(typeof balance.maxSharePercent === 'number' && balance.maxSharePercent > 0 && balance.maxSharePercent <= 100))
|
|
91
|
+
bad('.balance.maxSharePercent must be a number in (0, 100].');
|
|
92
|
+
const { reservePercent, biasPercent, exhaustedPercent } = usage;
|
|
93
|
+
if (![reservePercent, biasPercent, exhaustedPercent].every(value => typeof value === 'number' && value >= 0 && value <= 100) || !(reservePercent <= biasPercent && biasPercent < exhaustedPercent))
|
|
94
|
+
bad('.usage must hold numbers with reservePercent <= biasPercent < exhaustedPercent, each within 0..100.');
|
|
95
|
+
return { ...doc, tiers, seats, balance, usage };
|
|
78
96
|
}
|
|
79
|
-
|
|
80
97
|
/** config.yaml `models` — {tiers?, seats?, balance?, usage?}; the shipped defaults make every key optional. */
|
|
81
|
-
export function validateModelsBlock(config,profile){
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
98
|
+
export function validateModelsBlock(config, profile) {
|
|
99
|
+
refuseRemovedKeys(config);
|
|
100
|
+
const models = config?.models;
|
|
101
|
+
if (models === undefined || models === null)
|
|
102
|
+
return;
|
|
103
|
+
const bad = invalid('models');
|
|
104
|
+
if (!plain(models) || Object.keys(models).some(key => !MODELS_KEYS.includes(key)))
|
|
105
|
+
return bad(` must be {${MODELS_KEYS.map(key => key + '?').join(', ')}} or null (unknown keys are refused).`);
|
|
106
|
+
if (models.tiers !== undefined && !plain(models.tiers))
|
|
107
|
+
bad('.tiers must map tier names to ordered member lists.');
|
|
108
|
+
for (const key of ['seats', 'balance', 'usage'])
|
|
109
|
+
if (models[key] !== undefined && !plain(models[key]))
|
|
110
|
+
bad(`.${key} must be a mapping.`);
|
|
111
|
+
effectiveTiers(config, profile);
|
|
90
112
|
}
|