@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.
- package/CHANGELOG.md +12 -0
- package/README.md +4 -1
- package/dist/app-shell.d.ts +15 -0
- package/dist/app-shell.js +224 -0
- package/dist/chip-label.d.ts +14 -0
- package/dist/chip-label.js +117 -0
- package/dist/drawing.d.ts +8 -0
- package/dist/drawing.js +59 -0
- package/dist/grammar-boundary.d.ts +12 -0
- package/dist/grammar-boundary.js +88 -0
- package/dist/icon.d.ts +27 -0
- package/dist/icon.js +293 -0
- package/dist/index.d.ts +45 -0
- package/dist/index.js +73 -0
- package/dist/lib/app-shell-cluster.d.ts +6 -0
- package/dist/lib/app-shell-cluster.js +75 -0
- package/dist/lib/app-shell-create.d.ts +5 -0
- package/dist/lib/app-shell-create.js +37 -0
- package/dist/lib/app-shell-owner-source.d.ts +11 -0
- package/dist/lib/app-shell-owner-source.js +159 -0
- package/dist/lib/app-shell-owner.d.ts +7 -0
- package/dist/lib/app-shell-owner.js +97 -0
- package/dist/lib/app-shell-scope.d.ts +13 -0
- package/dist/lib/app-shell-scope.js +93 -0
- package/dist/lib/app-shell-visible.d.ts +13 -0
- package/dist/lib/app-shell-visible.js +117 -0
- package/dist/lib/app-shell.d.ts +27 -0
- package/dist/lib/app-shell.js +185 -0
- package/dist/lib/rule-contract.d.ts +12 -0
- package/dist/lib/rule-contract.js +23 -0
- package/dist/lib/status-tones.generated.d.ts +2 -0
- package/dist/lib/status-tones.generated.js +8 -0
- package/dist/lib/types.d.ts +113 -0
- package/dist/lib/types.js +2 -0
- package/dist/lib/why.d.ts +8 -0
- package/dist/lib/why.js +42 -0
- package/dist/menu-entries.d.ts +27 -0
- package/dist/menu-entries.js +142 -0
- package/dist/status-colors.d.ts +13 -0
- package/dist/status-colors.js +143 -0
- package/dist/tokens.d.ts +11 -0
- package/dist/tokens.js +166 -0
- package/dist/typography.d.ts +13 -0
- package/dist/typography.js +72 -0
- package/dist/vendor-boundary.d.ts +5 -0
- package/dist/vendor-boundary.js +25 -0
- package/docs/chip-label.md +30 -0
- package/docs/grammar-boundary.md +1 -1
- package/docs/status-colors.md +2 -2
- package/package.json +18 -15
- package/runtime/knowledge/hfs/canon-pins.yaml +11 -10
- package/runtime/knowledge/hfs/rules.yaml +23 -0
- package/app-shell.mjs +0 -208
- package/drawing.mjs +0 -60
- package/grammar-boundary.mjs +0 -94
- package/icon.mjs +0 -313
- package/index.d.ts +0 -28
- package/index.mjs +0 -78
- package/lib/app-shell-cluster.mjs +0 -66
- package/lib/app-shell-create.mjs +0 -33
- package/lib/app-shell-owner-source.mjs +0 -124
- package/lib/app-shell-owner.mjs +0 -83
- package/lib/app-shell-scope.mjs +0 -79
- package/lib/app-shell-visible.mjs +0 -92
- package/lib/app-shell.mjs +0 -148
- package/lib/rule-contract.mjs +0 -11
- package/lib/status-tones.generated.mjs +0 -9
- package/lib/why.mjs +0 -38
- package/menu-entries.mjs +0 -146
- package/status-colors.mjs +0 -140
- package/tokens.mjs +0 -164
- package/typography.mjs +0 -81
- 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"]));
|
package/dist/tokens.d.ts
ADDED
|
@@ -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).
|
package/docs/grammar-boundary.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Grammar boundary
|
|
2
2
|
|
|
3
|
-
Law module: `grammar-boundary.
|
|
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
|
|
package/docs/status-colors.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# Status colours: text and icons take the soft pair
|
|
2
2
|
|
|
3
|
-
Law module: `status-colors.
|
|
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.
|
|
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.
|
|
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.
|
|
26
|
+
"types": "./dist/index.d.ts",
|
|
27
|
+
"import": "./dist/index.js"
|
|
28
28
|
}
|
|
29
29
|
},
|
|
30
|
-
"main": "./index.
|
|
31
|
-
"types": "./index.d.ts",
|
|
30
|
+
"main": "./dist/index.js",
|
|
31
|
+
"types": "./dist/index.d.ts",
|
|
32
32
|
"files": [
|
|
33
|
-
"
|
|
34
|
-
"*.mjs",
|
|
35
|
-
"*.d.ts",
|
|
36
|
-
"lib/*.mjs",
|
|
33
|
+
"dist",
|
|
37
34
|
"runtime/**",
|
|
38
35
|
"docs/*.md",
|
|
39
|
-
"
|
|
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
|
-
"
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
'
|
|
101
|
-
version: 1.
|
|
100
|
+
'starci-harness':
|
|
101
|
+
version: 1.1.0-alpha.1
|
|
102
102
|
group: starci
|
|
103
103
|
install: registry
|
|
104
104
|
side: both
|
|
105
|
-
|
|
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.
|
|
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
|
|
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
|