@starci/eslint-canon-fe 9.6.6 → 9.8.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 +14 -0
- package/README.md +10 -2
- package/dist/index.d.ts +3 -2
- package/dist/index.js +6 -3
- package/dist/lib/config.d.ts +9 -0
- package/dist/lib/config.js +11 -3
- package/dist/lib/rule.d.ts +2 -0
- package/dist/lib/why.d.ts +10 -0
- package/dist/lib/why.js +10 -0
- package/dist/spacing.d.ts +18 -0
- package/dist/spacing.js +83 -0
- package/dist/surface-rules.d.ts +3 -0
- package/dist/surface-rules.js +117 -8
- package/docs/surface-rules.md +35 -2
- package/package.json +1 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +7 -7
- package/runtime/knowledge/hfs/slots.yaml +5 -1
- package/runtime/scripts/hfs/declaration-shape.mjs +4 -2
- package/runtime/scripts/hfs/rule-params-shape.mjs +7 -2
- package/runtime/scripts/hfs/slots.mjs +15 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 9.8.0 - 2026-10-31
|
|
4
|
+
|
|
5
|
+
- Added: `no-spacing-utility` (R350, `FE_SPACING_UTILITY`): no padding, margin, gap or space utility (variants and arbitrary values included) in an app class string; the message names the role prop to use. It ships in the closed counting cohort (`COUNTING` of `src/lib/config.ts`) at warn for this release.
|
|
6
|
+
- Changed: `nested-control-variant` refuses the pill (`variant="secondary"`) on `Tabs` wherever it is set, and no longer refuses `variant="primary"` inside a surface: tabs are the underline bar everywhere.
|
|
7
|
+
|
|
8
|
+
## 9.7.0 - 2026-10-31
|
|
9
|
+
|
|
10
|
+
- Added: R349 `no-hand-built-panes` (`FE_HAND_BUILT_PANES`, surface rules, slot-free): a multi-pane frame is a `PaneGrid`, so a vertical Grammar `Divider` used as a column seam (among the plain columns of a flex row, or anywhere inside a `SurfaceCard composition="joined"`) and a joined card that holds a flex row of two or more plain columns are refused.
|
|
11
|
+
- Changed: `Pane` and `PaneSection` are surfaces for `no-list-panel-padding` (R318), so wrapper padding around a `ListBox` in a pane is caught in the file. The bundled runtime copies follow @starci/hfs 4.15.0 and the canon pins name the cohort hfs 4.15.0, eslint-canon-be 3.11.0, eslint-canon-fe 9.7.0, @starci/grammar, eslint-grammar-core and eslint-grammar-offset-pop 0.17.0.
|
|
12
|
+
|
|
13
|
+
## 9.6.7 - 2026-10-31
|
|
14
|
+
|
|
15
|
+
- Changed: the bundled runtime copies follow @starci/hfs 4.14.0 (R334 one style, `ruleParams.be.sqlPassThrough`) and the canon pins name the cohort hfs 4.14.0, eslint-canon-be 3.11.0, eslint-canon-fe 9.6.7, @starci/grammar, eslint-grammar-core and eslint-grammar-offset-pop 0.16.6, with @starci/ai-router 0.5.0; no rule changed.
|
|
16
|
+
|
|
3
17
|
## 9.6.6 - 2026-10-31
|
|
4
18
|
|
|
5
19
|
- Changed: the bundled canon pins name @starci/ai-router 0.5.0 (many providers behind one virtual key, the persisted key blacklist) and the cohort that carries them: hfs 4.13.3, eslint-canon-be 3.10.1, eslint-canon-fe 9.6.6, eslint-grammar-core and eslint-grammar-offset-pop 0.16.6; no rule changed.
|
package/README.md
CHANGED
|
@@ -38,12 +38,20 @@ configuration for source that has not adopted the product composition laws.
|
|
|
38
38
|
|
|
39
39
|
The [surface composition law](docs/surface-rules.md) (R318) holds ANATOMY-2 and PADDING-0 for every
|
|
40
40
|
family that publishes the shared HeroUI surface and field API: `nested-control-variant` refuses static
|
|
41
|
-
variants on layer-aware fields and
|
|
41
|
+
variants on layer-aware fields and the pill (`variant="secondary"`) on Tabs, which are the underline bar (`FE_NESTED_VARIANT`),
|
|
42
42
|
`no-app-surface-paint` keeps surface paint in Grammar (`FE_GRAMMAR_LAYOUT_ONLY`) and
|
|
43
43
|
`no-list-panel-padding` refuses horizontal padding wrappers around Grammar lists and section captions
|
|
44
|
-
within the nearest painted surface (`FE_LIST_PANEL_PADDING`)
|
|
44
|
+
within the nearest painted surface, a `Pane` or a `PaneSection` included (`FE_LIST_PANEL_PADDING`), and
|
|
45
|
+
`no-hand-built-panes` refuses a multi-pane frame drawn by hand, a vertical divider as a column seam or
|
|
46
|
+
columns inside a joined card, where `PaneGrid` owns both (`FE_HAND_BUILT_PANES`). The R65 budget law's
|
|
45
47
|
`no-app-relative-clock` leaves live relative time and countdowns to Grammar's shared clock.
|
|
46
48
|
|
|
49
|
+
The spacing law (R350) leaves every distance to Grammar: `no-spacing-utility` refuses a padding, margin, gap
|
|
50
|
+
or space utility (`p-*`, `px-*`, `m*-*`, `gap-*`, `space-*`, arbitrary values and variants included) in an app class
|
|
51
|
+
string, where a role prop names the relation instead (`Stack`/`Cluster` `gap`, `Grid` `kind`, `PaneSection`, `Page`;
|
|
52
|
+
`FE_SPACING_UTILITY`). It runs in the counting cohort (`COUNTING` of `src/lib/config.ts`): `starci app lint` reports
|
|
53
|
+
and counts every finding at warn and fails on none, while `starci app codemod spacing-roles` migrates the app.
|
|
54
|
+
|
|
47
55
|
The [button intent law](docs/button-intent.md) (CTA-6) requires every app Button and IconButton to
|
|
48
56
|
declare intent and leave variants to Grammar. It contains destructive confirms in their same-file
|
|
49
57
|
dialog, counts mutually exclusive render paths separately and reserves commit for the view's
|
package/dist/index.d.ts
CHANGED
|
@@ -37,9 +37,10 @@ export declare const rules: Record<string, Rule>;
|
|
|
37
37
|
* The levels this canon asks for, as the plugin's own opinion.
|
|
38
38
|
*
|
|
39
39
|
* Every FE canon rule is an error. Existing debt is fixed before adoption; lowering architecture
|
|
40
|
-
* to warn teaches every later author that the boundary is optional and is not a supported rollout.
|
|
40
|
+
* to warn teaches every later author that the boundary is optional and is not a supported rollout. The one
|
|
41
|
+
* exception is the closed counting cohort of lib/config.ts (`COUNTING`), an owner-ruled warn for one release.
|
|
41
42
|
*/
|
|
42
|
-
export declare const recommended: Record<string, "error">;
|
|
43
|
+
export declare const recommended: Record<string, "error" | "warn">;
|
|
43
44
|
/** Flat-config options that make inline directives ineffective rather than merely forbidden. */
|
|
44
45
|
export { linterOptions } from "./lib/config.js";
|
|
45
46
|
export { loadHfs } from "./lib/hfs.js";
|
package/dist/index.js
CHANGED
|
@@ -45,7 +45,8 @@ import { recommended as translationRecommended, rules as translationRules } from
|
|
|
45
45
|
import { recommended as typeSafetyRecommended, rules as typeSafetyRules } from "./type-safety.js";
|
|
46
46
|
import { recommended as projectGraphRecommended, rules as projectGraphRules } from "./project-graph.js";
|
|
47
47
|
import { recommended as sonarParityRecommended, rules as sonarParityRules } from "./sonar-parity.js";
|
|
48
|
-
import {
|
|
48
|
+
import { recommended as spacingRecommended, rules as spacingRules } from "./spacing.js";
|
|
49
|
+
import { buildFeConfig, buildPlainSourceConfig, COUNTING } from "./lib/config.js";
|
|
49
50
|
import { why } from "./lib/why.js";
|
|
50
51
|
import { recommended as uiRulingsRecommended, rules as uiRulingsRules } from "./ui-rulings.js";
|
|
51
52
|
/** Each law's contribution, kept separate so a duplicate name is detectable rather than silent. */
|
|
@@ -89,6 +90,7 @@ const CONTRIBUTIONS = [
|
|
|
89
90
|
{ law: "transport", rules: transportRules, recommended: transportRecommended },
|
|
90
91
|
{ law: "type-safety", rules: typeSafetyRules, recommended: typeSafetyRecommended },
|
|
91
92
|
{ law: "sonar-parity", rules: sonarParityRules, recommended: sonarParityRecommended },
|
|
93
|
+
{ law: "spacing", rules: spacingRules, recommended: spacingRecommended },
|
|
92
94
|
];
|
|
93
95
|
/** Every gathered law. */
|
|
94
96
|
export const lawOwners = CONTRIBUTIONS.map((entry) => entry.law);
|
|
@@ -120,7 +122,8 @@ export const rules = Object.fromEntries(CONTRIBUTIONS.flatMap((entry) => Object.
|
|
|
120
122
|
* The levels this canon asks for, as the plugin's own opinion.
|
|
121
123
|
*
|
|
122
124
|
* Every FE canon rule is an error. Existing debt is fixed before adoption; lowering architecture
|
|
123
|
-
* to warn teaches every later author that the boundary is optional and is not a supported rollout.
|
|
125
|
+
* to warn teaches every later author that the boundary is optional and is not a supported rollout. The one
|
|
126
|
+
* exception is the closed counting cohort of lib/config.ts (`COUNTING`), an owner-ruled warn for one release.
|
|
124
127
|
*/
|
|
125
128
|
export const recommended = Object.fromEntries(CONTRIBUTIONS.flatMap((entry) => Object.entries(entry.recommended)));
|
|
126
129
|
/** Flat-config options that make inline directives ineffective rather than merely forbidden. */
|
|
@@ -223,7 +226,7 @@ export const reactHooksRules = (reactHooks) => {
|
|
|
223
226
|
*/
|
|
224
227
|
export const starciFeConfig = ({ hfs } = {}) => {
|
|
225
228
|
const published = Object.keys(rules).map((name) => `starci-fe/${name}`);
|
|
226
|
-
const notRequested = published.filter((name) => recommended[name] !== "error");
|
|
229
|
+
const notRequested = published.filter((name) => recommended[name] !== (name in COUNTING ? "warn" : "error"));
|
|
227
230
|
if (notRequested.length > 0) {
|
|
228
231
|
throw new Error(`starciFeConfig: these published rules are not requested at error: ${notRequested.join(", ")}`);
|
|
229
232
|
}
|
package/dist/lib/config.d.ts
CHANGED
|
@@ -27,6 +27,15 @@ export declare const IGNORED: readonly string[];
|
|
|
27
27
|
* and the Sonar rules that need the checker: S1874 (a deprecated API), S6551 (an object turned into text) and S7503 (an `async` function that never awaits).
|
|
28
28
|
*/
|
|
29
29
|
export declare const BORROWED: Readonly<Record<string, "error">>;
|
|
30
|
+
/**
|
|
31
|
+
* The counting cohort: a rule the owner ruled to ship at `warn` for one release while a codemod moves apps onto the API
|
|
32
|
+
* it asks for, each with the HFS rule id it enforces. `starci app lint` reports and counts its findings and never fails
|
|
33
|
+
* on them; the next cohort moves the rule to error and deletes its entry. No other rule may be below error.
|
|
34
|
+
*/
|
|
35
|
+
export declare const COUNTING: Readonly<Record<string, {
|
|
36
|
+
readonly rule: string;
|
|
37
|
+
readonly ruling: string;
|
|
38
|
+
}>>;
|
|
30
39
|
/**
|
|
31
40
|
* The flat config of one front-end repository.
|
|
32
41
|
*
|
package/dist/lib/config.js
CHANGED
|
@@ -40,6 +40,14 @@ export const BORROWED = Object.freeze({
|
|
|
40
40
|
"@typescript-eslint/require-await": "error",
|
|
41
41
|
});
|
|
42
42
|
const levelOf = (setting) => (Array.isArray(setting) ? setting[0] : setting);
|
|
43
|
+
/**
|
|
44
|
+
* The counting cohort: a rule the owner ruled to ship at `warn` for one release while a codemod moves apps onto the API
|
|
45
|
+
* it asks for, each with the HFS rule id it enforces. `starci app lint` reports and counts its findings and never fails
|
|
46
|
+
* on them; the next cohort moves the rule to error and deletes its entry. No other rule may be below error.
|
|
47
|
+
*/
|
|
48
|
+
export const COUNTING = Object.freeze({
|
|
49
|
+
"starci-fe/no-spacing-utility": { rule: "R350", ruling: "spacing-roles" },
|
|
50
|
+
});
|
|
43
51
|
/**
|
|
44
52
|
* The flat config of one front-end repository.
|
|
45
53
|
*
|
|
@@ -60,9 +68,9 @@ export const buildFeConfig = ({ hfs, plugin, source, reactHooks, tsPlugin, sonar
|
|
|
60
68
|
throw new Error(`starciFeConfig lints the fe side of an app (its eslint.config.mjs lives in fe/); this view is ${hfs.side ? `the ${hfs.side} side` : "the app root"}`);
|
|
61
69
|
if (Object.keys(source).length === 0)
|
|
62
70
|
throw new Error("starciFeConfig received an empty source recommendation - a config with no rules is not adoption");
|
|
63
|
-
const weak = Object.entries(source).filter(([, setting]) => levelOf(setting) !== "error").map(([name]) => name);
|
|
71
|
+
const weak = Object.entries(source).filter(([name, setting]) => levelOf(setting) !== (name in COUNTING ? "warn" : "error")).map(([name]) => name);
|
|
64
72
|
if (weak.length > 0)
|
|
65
|
-
throw new Error(`starciFeConfig refuses source rules not at error: ${weak.join(", ")}`);
|
|
73
|
+
throw new Error(`starciFeConfig refuses source rules not at error (or not at warn in the counting cohort): ${weak.join(", ")}`);
|
|
66
74
|
const settings = { starci: { hfs } };
|
|
67
75
|
return [
|
|
68
76
|
{ ignores: [...IGNORED] },
|
|
@@ -96,7 +104,7 @@ export const SLOT_FREE_RULES = Object.freeze([
|
|
|
96
104
|
"no-native-anchor", "no-inline-parameter-type", "no-hand-rolled-polling", "no-copy-in-call", "no-alert", "no-failure-collapse",
|
|
97
105
|
"outcome-kinds-exhaustive", "no-await-in-loop", "prefer-code-point", "prefer-string-raw", "no-nested-conditional", "no-void-operator", "no-unused-import",
|
|
98
106
|
"prefer-export-from", "no-unused-prop-types", "media-has-caption", "max-params",
|
|
99
|
-
"nested-control-variant", "no-list-panel-padding", "no-app-surface-paint", "divider-full-bleed", "control-height", "selection-fill-no-check", "no-grammar-shim",
|
|
107
|
+
"nested-control-variant", "no-list-panel-padding", "no-hand-built-panes", "no-app-surface-paint", "divider-full-bleed", "control-height", "selection-fill-no-check", "no-grammar-shim",
|
|
100
108
|
"button-intent-required", "button-no-variant", "destructive-confirm-in-dialog", "single-commit-per-render", "no-commit-in-collection-item",
|
|
101
109
|
]);
|
|
102
110
|
/** The source of a plain front-end tree: its `src/`. */
|
package/dist/lib/rule.d.ts
CHANGED
|
@@ -21,6 +21,8 @@ export type Comment = TSESTree.Comment;
|
|
|
21
21
|
export type Rules = Readonly<Record<string, Rule<string, readonly unknown[]>>>;
|
|
22
22
|
/** The level a law asks for each of its rules, by `starci-fe/<name>`. */
|
|
23
23
|
export type Recommended = Readonly<Record<string, "error">>;
|
|
24
|
+
/** The level of a rule in its counting cohort (lib/config.ts `COUNTING`): reported and counted, never failing. */
|
|
25
|
+
export type CountingRecommended = Readonly<Record<string, "warn">>;
|
|
24
26
|
/**
|
|
25
27
|
* A rule with its message ids and options inferred from the literal: `defineRule({ meta: { messages: { a: "..." } }, ... })`
|
|
26
28
|
* types `context.report({ messageId })` against `a`. It returns the object it is given.
|
package/dist/lib/why.d.ts
CHANGED
|
@@ -32,6 +32,16 @@ export declare const why: {
|
|
|
32
32
|
en: string;
|
|
33
33
|
fix: string;
|
|
34
34
|
};
|
|
35
|
+
"no-spacing-utility": {
|
|
36
|
+
code: string;
|
|
37
|
+
en: string;
|
|
38
|
+
fix: string;
|
|
39
|
+
};
|
|
40
|
+
"no-hand-built-panes": {
|
|
41
|
+
code: string;
|
|
42
|
+
en: string;
|
|
43
|
+
fix: string;
|
|
44
|
+
};
|
|
35
45
|
"button-intent-required": {
|
|
36
46
|
code: string;
|
|
37
47
|
en: string;
|
package/dist/lib/why.js
CHANGED
|
@@ -32,6 +32,16 @@ export const why = {
|
|
|
32
32
|
en: "`<file>` insets a horizontal Grammar divider with wrapper padding.",
|
|
33
33
|
fix: "Move horizontal padding to the sibling content bands so the divider reaches both surface edges.",
|
|
34
34
|
},
|
|
35
|
+
"no-spacing-utility": {
|
|
36
|
+
code: "FE_SPACING_UTILITY",
|
|
37
|
+
en: "`<file>` writes a padding, margin, gap or space utility (`<what>`): a distance the Grammar owns.",
|
|
38
|
+
fix: "Name the relation instead: a Stack or Cluster gap role, a Grid kind, or the inset of the Grammar box that holds the content.",
|
|
39
|
+
},
|
|
40
|
+
"no-hand-built-panes": {
|
|
41
|
+
code: "FE_HAND_BUILT_PANES",
|
|
42
|
+
en: "`<file>` draws a multi-pane frame by hand: a vertical Divider between columns of a flex row, or a flex row of columns inside a joined SurfaceCard.",
|
|
43
|
+
fix: "Compose the columns as Panes of a PaneGrid (areas per breakpoint, collapse=\"drawer\" for a side pane), mounted as Page chrome=\"panes\" on a tabbed app page.",
|
|
44
|
+
},
|
|
35
45
|
"button-intent-required": {
|
|
36
46
|
code: "FE_BUTTON_INTENT_REQUIRED",
|
|
37
47
|
en: "`<file>` renders a Button or IconButton without the command's intent.",
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type CountingRecommended } from "./lib/rule.js";
|
|
2
|
+
/** The role a Tailwind gap step names, as the spacing-roles codemod maps it; a step with no role is a judgement. */
|
|
3
|
+
export declare const GAP_STEP_ROLE: Readonly<Record<string, string>>;
|
|
4
|
+
/** The utility a class names once its variants (`sm:`, `hover:`, `[&>*]:`) and importance (`!`) are dropped. */
|
|
5
|
+
export declare const baseUtility: (cls: string) => string;
|
|
6
|
+
/** Every spacing utility of a class string, in the order written. */
|
|
7
|
+
export declare const spacingUtilities: (text: string) => string[];
|
|
8
|
+
/** No padding, margin, gap or space utility in an app class string: the relation is named, the step is the Grammar's. */
|
|
9
|
+
export declare const noSpacingUtility: import("./lib/rule.js").Rule<"utility", []>;
|
|
10
|
+
/** The rules of the spacing law. */
|
|
11
|
+
export declare const rules: {
|
|
12
|
+
"no-spacing-utility": import("./lib/rule.js").Rule<"utility", []>;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* The rule runs in its counting cohort (lib/config.ts `COUNTING`): every finding is reported and counted by `starci app
|
|
16
|
+
* lint`, none fails the gate, while `starci app codemod spacing-roles` moves the app onto role props.
|
|
17
|
+
*/
|
|
18
|
+
export declare const recommended: CountingRecommended;
|
package/dist/spacing.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spacing is the Grammar's (HFS R350): an app names the relation between the blocks it places through a role prop
|
|
3
|
+
* (`<Stack gap="section">`, `<Cluster gap="inline">`, `<Grid kind="cards">`, `PaneSection`, `Page`), and the Grammar
|
|
4
|
+
* resolves the role to its step. A padding, margin, gap or space utility in an app class string is a second owner of
|
|
5
|
+
* a distance the Grammar already owns.
|
|
6
|
+
*/
|
|
7
|
+
import { classTextVisitors } from "./lib/class-text.js";
|
|
8
|
+
import { exemptFreestyle } from "./lib/freestyle.js";
|
|
9
|
+
import { defineRule } from "./lib/rule.js";
|
|
10
|
+
/** The spacing utility families: padding, margin, gap and the space-between utilities, each side and axis. */
|
|
11
|
+
const SPACING_UTILITY = /^-?(?:p|px|py|pt|pb|ps|pe|pl|pr|m|mx|my|mt|mb|ms|me|ml|mr|gap|gap-x|gap-y|space-x|space-y)-(.+)$/;
|
|
12
|
+
/** The role a Tailwind gap step names, as the spacing-roles codemod maps it; a step with no role is a judgement. */
|
|
13
|
+
export const GAP_STEP_ROLE = Object.freeze({
|
|
14
|
+
"0": "seam (a joined face or a divider)",
|
|
15
|
+
"0.5": "identity",
|
|
16
|
+
"1": "identity",
|
|
17
|
+
"1.5": "identity",
|
|
18
|
+
"2": "inline",
|
|
19
|
+
"2.5": "inline",
|
|
20
|
+
"3": "row",
|
|
21
|
+
"4": "section",
|
|
22
|
+
"5": "section",
|
|
23
|
+
"6": "region",
|
|
24
|
+
"8": "region",
|
|
25
|
+
});
|
|
26
|
+
/** The utility a class names once its variants (`sm:`, `hover:`, `[&>*]:`) and importance (`!`) are dropped. */
|
|
27
|
+
export const baseUtility = (cls) => {
|
|
28
|
+
let depth = 0;
|
|
29
|
+
let start = 0;
|
|
30
|
+
for (let index = 0; index < cls.length; index += 1) {
|
|
31
|
+
const char = cls[index];
|
|
32
|
+
if (char === "[" || char === "(")
|
|
33
|
+
depth += 1;
|
|
34
|
+
else if (char === "]" || char === ")")
|
|
35
|
+
depth -= 1;
|
|
36
|
+
else if (char === ":" && depth === 0)
|
|
37
|
+
start = index + 1;
|
|
38
|
+
}
|
|
39
|
+
return cls.slice(start).replace(/^!|!$/g, "");
|
|
40
|
+
};
|
|
41
|
+
/** Every spacing utility of a class string, in the order written. */
|
|
42
|
+
export const spacingUtilities = (text) => text.split(/\s+/).filter((cls) => SPACING_UTILITY.test(baseUtility(cls)));
|
|
43
|
+
/** What to write instead of one spacing utility. */
|
|
44
|
+
const instead = (cls) => {
|
|
45
|
+
const base = baseUtility(cls);
|
|
46
|
+
const match = SPACING_UTILITY.exec(base);
|
|
47
|
+
const family = base.replace(/^-/, "").split("-")[0] ?? "";
|
|
48
|
+
if (family === "gap" || family === "space") {
|
|
49
|
+
const role = GAP_STEP_ROLE[match?.[1] ?? ""];
|
|
50
|
+
return role === undefined
|
|
51
|
+
? "the role of the relation on a Stack, Cluster or Grid gap"
|
|
52
|
+
: `gap="${role}" on the Stack or Cluster that holds these siblings`;
|
|
53
|
+
}
|
|
54
|
+
if (family.startsWith("m"))
|
|
55
|
+
return "the parent's gap (a Stack or Cluster role); the Grammar alone places a margin";
|
|
56
|
+
return "the inset of the Grammar box that holds this content (SurfaceCard, PaneSection, Page, Dialog); a box of your own takes none";
|
|
57
|
+
};
|
|
58
|
+
/** No padding, margin, gap or space utility in an app class string: the relation is named, the step is the Grammar's. */
|
|
59
|
+
export const noSpacingUtility = exemptFreestyle(defineRule({
|
|
60
|
+
meta: {
|
|
61
|
+
type: "suggestion",
|
|
62
|
+
docs: { description: "Spacing is the Grammar's: an app names the relation through a role prop, never a spacing utility." },
|
|
63
|
+
schema: [],
|
|
64
|
+
messages: {
|
|
65
|
+
utility: "`{{cls}}` writes a distance the Grammar owns. Use {{instead}} (R350 `FE_SPACING_UTILITY`).",
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
create(context) {
|
|
69
|
+
return classTextVisitors(context, (node, text) => {
|
|
70
|
+
if (!text)
|
|
71
|
+
return;
|
|
72
|
+
for (const cls of spacingUtilities(text))
|
|
73
|
+
context.report({ node, messageId: "utility", data: { cls, instead: instead(cls) } });
|
|
74
|
+
});
|
|
75
|
+
},
|
|
76
|
+
}));
|
|
77
|
+
/** The rules of the spacing law. */
|
|
78
|
+
export const rules = { "no-spacing-utility": noSpacingUtility };
|
|
79
|
+
/**
|
|
80
|
+
* The rule runs in its counting cohort (lib/config.ts `COUNTING`): every finding is reported and counted by `starci app
|
|
81
|
+
* lint`, none fails the gate, while `starci app codemod spacing-roles` moves the app onto role props.
|
|
82
|
+
*/
|
|
83
|
+
export const recommended = { "starci-fe/no-spacing-utility": "warn" };
|
package/dist/surface-rules.d.ts
CHANGED
|
@@ -7,10 +7,13 @@ export declare const noAppSurfacePaint: import("./lib/rule.js").Rule<"surface",
|
|
|
7
7
|
export declare const noListPanelPadding: import("./lib/rule.js").Rule<"padding", []>;
|
|
8
8
|
/** BOUNDARY-1 case-5: only content bands carry inset; horizontal separators reach the surface edges. */
|
|
9
9
|
export declare const dividerFullBleed: import("./lib/rule.js").Rule<"padding", []>;
|
|
10
|
+
/** A multi-pane frame is a PaneGrid: a vertical divider between columns, or columns inside a joined card, is a pane frame drawn by hand. */
|
|
11
|
+
export declare const noHandBuiltPanes: import("./lib/rule.js").Rule<"seam" | "joined", []>;
|
|
10
12
|
/** The surface laws ship at error in every composing source tree. */
|
|
11
13
|
export declare const rules: {
|
|
12
14
|
"divider-full-bleed": import("./lib/rule.js").Rule<"padding", []>;
|
|
13
15
|
"nested-control-variant": import("./lib/rule.js").Rule<"variant" | "tabs", []>;
|
|
16
|
+
"no-hand-built-panes": import("./lib/rule.js").Rule<"seam" | "joined", []>;
|
|
14
17
|
"no-list-panel-padding": import("./lib/rule.js").Rule<"padding", []>;
|
|
15
18
|
"no-app-surface-paint": import("./lib/rule.js").Rule<"surface", []>;
|
|
16
19
|
};
|
package/dist/surface-rules.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/** Grammar owns surface nesting
|
|
1
|
+
/** Grammar owns surface nesting, the single content edge of a panel list (ANATOMY-2, PADDING-0) and the column seams of a pane frame (PaneGrid). */
|
|
2
2
|
import { attributeValue, stringOf, walk } from "./lib/ast.js";
|
|
3
3
|
import { functionOf, importOf, isFn, patternPath, unwrap, variableOf } from "./lib/bindings.js";
|
|
4
4
|
import { typedClassStrings, typedPropertyStrings } from "./lib/typed-class-values.js";
|
|
@@ -15,7 +15,7 @@ const GRAMMAR_ENTRY = /^@starci\/grammar(?:\/(?:common|core|offset-pop))?$/;
|
|
|
15
15
|
const SURFACES = new Set([
|
|
16
16
|
"SurfaceCard", "SurfaceListCard", "SurfaceAccordionCard", "Sidebar",
|
|
17
17
|
"Dialog", "Drawer", "AlertDialog", "Popover",
|
|
18
|
-
"WorkspaceShell", "TopBar", "NavigationFeatureNav",
|
|
18
|
+
"WorkspaceShell", "TopBar", "NavigationFeatureNav", "Pane", "PaneSection",
|
|
19
19
|
]);
|
|
20
20
|
const FIELDS = new Set([
|
|
21
21
|
"Input", "TextField", "Textarea", "TextArea", "SearchField", "NumberField", "DateField", "TimeField",
|
|
@@ -289,11 +289,11 @@ const inSectionCaption = (context, node) => {
|
|
|
289
289
|
export const nestedControlVariant = defineRule({
|
|
290
290
|
meta: {
|
|
291
291
|
type: "problem",
|
|
292
|
-
docs: { description: "ANATOMY-2: Grammar context supplies field variants;
|
|
292
|
+
docs: { description: "ANATOMY-2: Grammar context supplies field variants; Tabs are the underline bar, never the pill." },
|
|
293
293
|
schema: [],
|
|
294
294
|
messages: {
|
|
295
295
|
variant: "FE_NESTED_VARIANT: app fields cannot set a static variant. Omit variant so Grammar's surface context supplies the appropriate default.",
|
|
296
|
-
tabs: "FE_NESTED_VARIANT: Tabs
|
|
296
|
+
tabs: "FE_NESTED_VARIANT: Tabs are the underline bar, their labels text on the content edge; variant=\"secondary\" is the pill, a filter's shape. Omit variant, or filter a collection with a selectable TagGroup.",
|
|
297
297
|
},
|
|
298
298
|
},
|
|
299
299
|
create(context) {
|
|
@@ -308,9 +308,9 @@ export const nestedControlVariant = defineRule({
|
|
|
308
308
|
context.report({ node: variant, messageId: "variant" });
|
|
309
309
|
}
|
|
310
310
|
else if (name[0] === "Tabs") {
|
|
311
|
-
const
|
|
312
|
-
if (
|
|
313
|
-
context.report({ node:
|
|
311
|
+
const pill = node.attributes.find((entry) => propStrings(context, entry, "variant", "variant").some((value) => value.text === "secondary"));
|
|
312
|
+
if (pill)
|
|
313
|
+
context.report({ node: pill, messageId: "tabs" });
|
|
314
314
|
}
|
|
315
315
|
},
|
|
316
316
|
};
|
|
@@ -486,7 +486,116 @@ export const dividerFullBleed = defineRule({
|
|
|
486
486
|
};
|
|
487
487
|
},
|
|
488
488
|
});
|
|
489
|
+
/** True for a plain element (`div`), as opposed to a component. */
|
|
490
|
+
const rawElement = (opening) => opening.name.type === "JSXIdentifier" && /^[a-z]/.test(opening.name.name);
|
|
491
|
+
/** The elements one JSX child can render, through fragments, conditionals and `&&`; anything else renders none this rule can name. */
|
|
492
|
+
const elementsOfChild = (child) => {
|
|
493
|
+
if (!child)
|
|
494
|
+
return [];
|
|
495
|
+
if (child.type === "JSXElement")
|
|
496
|
+
return [child];
|
|
497
|
+
if (child.type === "JSXFragment")
|
|
498
|
+
return child.children.flatMap((entry) => elementsOfChild(entry));
|
|
499
|
+
if (child.type === "JSXExpressionContainer")
|
|
500
|
+
return elementsOfChild(child.expression);
|
|
501
|
+
if (child.type === "ConditionalExpression")
|
|
502
|
+
return [...elementsOfChild(child.consequent), ...elementsOfChild(child.alternate)];
|
|
503
|
+
if (child.type === "LogicalExpression")
|
|
504
|
+
return child.operator === "&&" ? elementsOfChild(child.right) : [...elementsOfChild(child.left), ...elementsOfChild(child.right)];
|
|
505
|
+
return [];
|
|
506
|
+
};
|
|
507
|
+
/** The children that render: whitespace-only text and empty expressions are left out. */
|
|
508
|
+
const renderedChildren = (element) => element.children.filter((child) => child.type === "JSXText" ? child.value.trim() !== "" : child.type !== "JSXExpressionContainer" || child.expression.type !== "JSXEmptyExpression");
|
|
509
|
+
/** A column of a hand-built split: a plain block element (`div`, `aside`) or a Grammar `Region`. */
|
|
510
|
+
const isColumn = (context, element) => rawElement(element.openingElement) || grammarName(context, element.openingElement.name)?.[0] === "Region";
|
|
511
|
+
/** How a plain element lays out its children, read from its authored classes: `row`, `stack` (a column), `none` (not flex or grid), or `unknown` (no static class). */
|
|
512
|
+
const flowOf = (context, opening) => {
|
|
513
|
+
const texts = opening.attributes.flatMap((entry) => ["className", "class"].flatMap((name) => propStrings(context, entry, name).map((value) => value.text)));
|
|
514
|
+
if (texts.length === 0)
|
|
515
|
+
return "unknown";
|
|
516
|
+
const tokens = texts.flatMap((text) => text.split(/\s+/)).filter((token) => token !== "");
|
|
517
|
+
if (!tokens.some((token) => /^(?:flex|inline-flex|grid|inline-grid)$/.test(token.slice(token.lastIndexOf(":") + 1))))
|
|
518
|
+
return "none";
|
|
519
|
+
const column = tokens.some((token) => token === "flex-col" || token === "flex-col-reverse");
|
|
520
|
+
const row = tokens.some((token) => /(?:^|:)flex-row(?:-reverse)?$/.test(token));
|
|
521
|
+
return column && !row ? "stack" : "row";
|
|
522
|
+
};
|
|
523
|
+
/** True when the opening element is a vertical Grammar Divider or Separator. */
|
|
524
|
+
const verticalDivider = (context, opening) => {
|
|
525
|
+
const name = grammarName(context, opening.name);
|
|
526
|
+
if (name?.length !== 1 || !isOneOf(["Divider", "Separator"], name[0]))
|
|
527
|
+
return false;
|
|
528
|
+
return opening.attributes.some((entry) => propStrings(context, entry, "orientation", "variant").some((value) => value.text === "vertical"));
|
|
529
|
+
};
|
|
530
|
+
/** True when the opening element is a Grammar SurfaceCard composed `joined`. */
|
|
531
|
+
const joinedCard = (context, opening) => {
|
|
532
|
+
const name = grammarName(context, opening.name);
|
|
533
|
+
return name?.length === 1 && name[0] === "SurfaceCard" && opening.attributes.some((entry) => propStrings(context, entry, "composition", "variant").some((value) => value.text === "joined"));
|
|
534
|
+
};
|
|
535
|
+
/** The columns a plain flex row holds side by side, or none when the element is not a row of two or more. */
|
|
536
|
+
const rowColumns = (context, element) => {
|
|
537
|
+
if (!rawElement(element.openingElement) || flowOf(context, element.openingElement) !== "row")
|
|
538
|
+
return [];
|
|
539
|
+
const columns = renderedChildren(element).flatMap((child) => elementsOfChild(child)).filter((child) => isColumn(context, child));
|
|
540
|
+
return columns.length >= 2 ? columns : [];
|
|
541
|
+
};
|
|
542
|
+
/** The joined card element a node stands in, in this file, or null. */
|
|
543
|
+
const enclosingJoinedCard = (context, node) => {
|
|
544
|
+
for (let current = node.parent; current; current = current.parent) {
|
|
545
|
+
if (current.type === "JSXElement" && joinedCard(context, current.openingElement))
|
|
546
|
+
return current;
|
|
547
|
+
}
|
|
548
|
+
return null;
|
|
549
|
+
};
|
|
550
|
+
/** True when a joined card holds a plain row of columns as one of its own children: a pane frame drawn by hand. */
|
|
551
|
+
const holdsColumnRow = (context, card) => renderedChildren(card).flatMap((child) => elementsOfChild(child)).some((child) => rowColumns(context, child).length > 0);
|
|
552
|
+
/** True when a vertical divider stands between plain columns of one flex row: directly, or inside a wrapper element that holds only it. */
|
|
553
|
+
const isColumnSeam = (context, divider) => {
|
|
554
|
+
let unit = divider;
|
|
555
|
+
const wrapper = divider.parent;
|
|
556
|
+
if (wrapper.type === "JSXElement" && rawElement(wrapper.openingElement) && renderedChildren(wrapper).length === 1)
|
|
557
|
+
unit = wrapper;
|
|
558
|
+
const container = unit.parent;
|
|
559
|
+
if (container.type !== "JSXElement" || !rawElement(container.openingElement))
|
|
560
|
+
return false;
|
|
561
|
+
if (!["row", "unknown"].includes(flowOf(context, container.openingElement)))
|
|
562
|
+
return false;
|
|
563
|
+
return renderedChildren(container).flatMap((child) => elementsOfChild(child)).some((sibling) => sibling !== unit && isColumn(context, sibling));
|
|
564
|
+
};
|
|
565
|
+
/** A multi-pane frame is a PaneGrid: a vertical divider between columns, or columns inside a joined card, is a pane frame drawn by hand. */
|
|
566
|
+
export const noHandBuiltPanes = defineRule({
|
|
567
|
+
meta: {
|
|
568
|
+
type: "problem",
|
|
569
|
+
docs: { description: "PaneGrid owns the columns of a multi-pane frame and the dividers between them." },
|
|
570
|
+
schema: [],
|
|
571
|
+
messages: {
|
|
572
|
+
seam: "FE_HAND_BUILT_PANES: a vertical Divider between columns draws a pane seam by hand, so the divider, the column widths and the collapse below medium are this file's to keep in step. Compose the columns as `Pane`s of a `PaneGrid` (areas per breakpoint, `collapse=\"drawer\"` for a side pane), which draws the dividers itself.",
|
|
573
|
+
joined: "FE_HAND_BUILT_PANES: this joined SurfaceCard holds a flex row of columns, a pane frame drawn by hand inside a card. `joined` stacks bands divided by horizontal separators; side-by-side panes are a `PaneGrid` of `Pane`s, which a tabbed app page mounts as `Page chrome=\"panes\"`.",
|
|
574
|
+
},
|
|
575
|
+
},
|
|
576
|
+
create(context) {
|
|
577
|
+
return {
|
|
578
|
+
JSXOpeningElement(node) {
|
|
579
|
+
const element = node.parent;
|
|
580
|
+
if (element.type !== "JSXElement")
|
|
581
|
+
return;
|
|
582
|
+
if (joinedCard(context, node)) {
|
|
583
|
+
if (holdsColumnRow(context, element))
|
|
584
|
+
context.report({ node, messageId: "joined" });
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
if (!verticalDivider(context, node))
|
|
588
|
+
return;
|
|
589
|
+
const card = enclosingJoinedCard(context, node);
|
|
590
|
+
if (card !== null && holdsColumnRow(context, card))
|
|
591
|
+
return;
|
|
592
|
+
if (card !== null || isColumnSeam(context, element))
|
|
593
|
+
context.report({ node, messageId: "seam" });
|
|
594
|
+
},
|
|
595
|
+
};
|
|
596
|
+
},
|
|
597
|
+
});
|
|
489
598
|
/** The surface laws ship at error in every composing source tree. */
|
|
490
|
-
export const rules = { "divider-full-bleed": dividerFullBleed, "nested-control-variant": nestedControlVariant, "no-list-panel-padding": noListPanelPadding, "no-app-surface-paint": noAppSurfacePaint };
|
|
599
|
+
export const rules = { "divider-full-bleed": dividerFullBleed, "nested-control-variant": nestedControlVariant, "no-hand-built-panes": noHandBuiltPanes, "no-list-panel-padding": noListPanelPadding, "no-app-surface-paint": noAppSurfacePaint };
|
|
491
600
|
/** No app can lower or omit a surface law. */
|
|
492
601
|
export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]));
|
package/docs/surface-rules.md
CHANGED
|
@@ -4,7 +4,7 @@ Law module: `surface-rules.mjs`. HFS rule: R318. Canon: ANATOMY-2 and PADDING-0.
|
|
|
4
4
|
|
|
5
5
|
## `starci-fe/nested-control-variant`
|
|
6
6
|
|
|
7
|
-
Finding code: `FE_NESTED_VARIANT`. Grammar context supplies field variants on the page background and inside painted surfaces. App code omits every static `variant` on layer-aware fields, including fields rendered by imported or helper components. Named import aliases, namespace imports, static constants and props spreads preserve Grammar ownership.
|
|
7
|
+
Finding code: `FE_NESTED_VARIANT`. Grammar context supplies field variants on the page background and inside painted surfaces. App code omits every static `variant` on layer-aware fields, including fields rendered by imported or helper components. Named import aliases, namespace imports, static constants and props spreads preserve Grammar ownership. `Tabs` are the underline bar everywhere, their labels text on the content edge, so the pill (`variant="secondary"`) is refused wherever it is set: a pill is a filter's shape, and a filter over a collection is a selectable `TagGroup`.
|
|
8
8
|
|
|
9
9
|
Invalid:
|
|
10
10
|
|
|
@@ -26,7 +26,7 @@ Finding code: `FE_GRAMMAR_LAYOUT_ONLY`. App classes carry layout. A raw `bg-surf
|
|
|
26
26
|
|
|
27
27
|
## `starci-fe/no-list-panel-padding`
|
|
28
28
|
|
|
29
|
-
Finding code: `FE_LIST_PANEL_PADDING`. Within the nearest painted Grammar surface, a wrapper around `ListBox` or its section caption cannot add horizontal padding through `p-*`, `px-*`, `ps-*`, `pe-*`, `pl-*`, `pr-*` or arbitrary `padding-inline` properties. These insets add to the list's own padding. Page gutters outside that surface and lists outside any surface retain their layout padding. Unpainted containers such as `Rail` pass the parent context through.
|
|
29
|
+
Finding code: `FE_LIST_PANEL_PADDING`. Within the nearest painted Grammar surface (`Pane` and `PaneSection` included), a wrapper around `ListBox` or its section caption cannot add horizontal padding through `p-*`, `px-*`, `ps-*`, `pe-*`, `pl-*`, `pr-*` or arbitrary `padding-inline` properties. These insets add to the list's own padding. Page gutters outside that surface and lists outside any surface retain their layout padding. Unpainted containers such as `Rail` pass the parent context through.
|
|
30
30
|
|
|
31
31
|
Invalid:
|
|
32
32
|
|
|
@@ -41,3 +41,36 @@ Valid:
|
|
|
41
41
|
import { SurfaceCard, ListBox } from "@starci/grammar/core"
|
|
42
42
|
const Panel = () => <div className="px-4"><SurfaceCard><ListBox inset="panel" label="Members" items={members} /></SurfaceCard></div>
|
|
43
43
|
```
|
|
44
|
+
|
|
45
|
+
## `starci-fe/no-hand-built-panes`
|
|
46
|
+
|
|
47
|
+
Finding code: `FE_HAND_BUILT_PANES`. A multi-pane frame is a `PaneGrid` of `Pane`s, which draws the dividers between panes, sizes the columns and collapses a side pane below medium. A vertical `Divider` used as a column seam is a pane frame drawn by hand: a vertical `Divider` (or the plain wrapper element that holds only it) standing among plain column elements (`div`, `aside`, `Region`) of a flex row, or a vertical `Divider` anywhere inside a `SurfaceCard composition="joined"`; and a joined card that holds a plain flex row of two or more columns as one of its own children. `joined` stacks bands divided by horizontal separators. A short vertical separator between controls of a toolbar, a `flex-col` stack, and a row whose classes cannot be read in the file are not judged. Typed app lint follows imported colocated class declarations.
|
|
48
|
+
|
|
49
|
+
Invalid:
|
|
50
|
+
|
|
51
|
+
```tsx
|
|
52
|
+
import { Divider, SurfaceCard } from "@starci/grammar/core"
|
|
53
|
+
const Frame = () => (
|
|
54
|
+
<SurfaceCard composition="joined" height="fill">
|
|
55
|
+
<div className="flex h-full">
|
|
56
|
+
<div className="w-72">{rail}</div>
|
|
57
|
+
<div className="flex"><Divider orientation="vertical" /></div>
|
|
58
|
+
<div className="flex-1">{body}</div>
|
|
59
|
+
</div>
|
|
60
|
+
</SurfaceCard>
|
|
61
|
+
)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Valid:
|
|
65
|
+
|
|
66
|
+
```tsx
|
|
67
|
+
import { Page, Pane, PaneGrid, PaneSection } from "@starci/grammar/core"
|
|
68
|
+
const Frame = () => (
|
|
69
|
+
<Page chrome="panes" header={header}>
|
|
70
|
+
<PaneGrid areas={areas}>
|
|
71
|
+
<Pane area="rail"><PaneSection>{rail}</PaneSection></Pane>
|
|
72
|
+
<Pane area="main"><PaneSection>{body}</PaneSection></Pane>
|
|
73
|
+
</PaneGrid>
|
|
74
|
+
</Page>
|
|
75
|
+
)
|
|
76
|
+
```
|
package/package.json
CHANGED
|
@@ -31,42 +31,42 @@ pins:
|
|
|
31
31
|
source: packages/event-stream/package.json
|
|
32
32
|
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.'
|
|
33
33
|
'@starci/grammar':
|
|
34
|
-
version: 0.
|
|
34
|
+
version: 0.18.0
|
|
35
35
|
group: starci
|
|
36
36
|
install: registry
|
|
37
37
|
side: fe
|
|
38
38
|
source: packages/grammar/package.json
|
|
39
39
|
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.'
|
|
40
40
|
'@starci/eslint-canon-be':
|
|
41
|
-
version: 3.
|
|
41
|
+
version: 3.11.1
|
|
42
42
|
group: starci
|
|
43
43
|
install: registry
|
|
44
44
|
side: be
|
|
45
45
|
source: packages/eslint-canon-be/package.json
|
|
46
46
|
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.'
|
|
47
47
|
'@starci/eslint-canon-fe':
|
|
48
|
-
version: 9.
|
|
48
|
+
version: 9.8.0
|
|
49
49
|
group: starci
|
|
50
50
|
install: registry
|
|
51
51
|
side: fe
|
|
52
52
|
source: packages/eslint-canon-fe/package.json
|
|
53
53
|
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.'
|
|
54
54
|
'@starci/eslint-grammar-core':
|
|
55
|
-
version: 0.
|
|
55
|
+
version: 0.18.0
|
|
56
56
|
group: starci
|
|
57
57
|
install: registry
|
|
58
58
|
side: fe
|
|
59
59
|
source: packages/eslint-grammar-core/package.json
|
|
60
60
|
why: 'the StarCi Grammar component API lint, versioned with @starci/grammar; front ends compose this family package with the universal FE canon.'
|
|
61
61
|
'@starci/eslint-grammar-offset-pop':
|
|
62
|
-
version: 0.
|
|
62
|
+
version: 0.18.0
|
|
63
63
|
group: starci
|
|
64
64
|
install: registry
|
|
65
65
|
side: fe
|
|
66
66
|
source: packages/eslint-grammar-offset-pop/package.json
|
|
67
67
|
why: 'the Offset Pop Grammar component API lint, versioned with @starci/grammar; its shared component contract comes from the StarCi family lint without copying rules.'
|
|
68
68
|
'@starci/stylelint-canon':
|
|
69
|
-
version: 2.
|
|
69
|
+
version: 2.4.0
|
|
70
70
|
group: starci
|
|
71
71
|
install: registry
|
|
72
72
|
side: fe
|
|
@@ -106,7 +106,7 @@ pins:
|
|
|
106
106
|
source: packages/ai-router/package.json
|
|
107
107
|
why: 'the AI pool router of a back end: a NestJS module that routes OpenAI-compatible chat, stream and embeddings calls across keys, models and providers, with @starci/ai-router/testing for its specs; @starci/jest-preset compiles it as an ESM-only dependency.'
|
|
108
108
|
'@starci/hfs':
|
|
109
|
-
version: 4.
|
|
109
|
+
version: 4.16.0
|
|
110
110
|
group: starci
|
|
111
111
|
install: registry
|
|
112
112
|
side: both
|
|
@@ -460,6 +460,10 @@ ruleParams:
|
|
|
460
460
|
- {name: public.event_bus_bury, reason: "platform/event-bus (add webhook, lite): the move of an exhausted or malformed message to its consumer's dead-letter queue"}
|
|
461
461
|
- {name: public.event_bus_dead_letters, reason: "platform/event-bus (add webhook, lite): the operator read of a consumer's dead-letter queue"}
|
|
462
462
|
- {name: public.event_bus_requeue, reason: "platform/event-bus (add webhook, lite): the operator requeue of one dead letter"}
|
|
463
|
+
# R334 (lite): the only `.query(` outside platform/database's composer and the test world is the pass-through of a file listed here
|
|
464
|
+
# (or in hfs.json sides.be.sqlPassThrough), whose SQL text is not the app's own. Paths are relative to the side folder.
|
|
465
|
+
sqlPassThrough:
|
|
466
|
+
- {file: src/modules/platform/database/seed-runner.service.ts, reason: "the tracked seed file's text, run as it is (db/seed.sql); the app writes no SQL there"}
|
|
463
467
|
|
|
464
468
|
lite:
|
|
465
469
|
schemaAuthority: sql
|
|
@@ -2027,7 +2031,7 @@ slots:
|
|
|
2027
2031
|
own; use-test-world.ts exports useTestWorld({ modules: [...] }) -> world.db.<connection> (the shared EntityManager) and
|
|
2028
2032
|
world.fake.<provider> (a network-edge fake of an external SaaS). There is no world.apps (no e2e) and no world.infra
|
|
2029
2033
|
(no self-hosted service to cut)
|
|
2030
|
-
allows: [global-setup.ts, global-teardown.ts, use-test-world.ts, "fakes/<provider>/server.ts", fakes
|
|
2034
|
+
allows: [global-setup.ts, global-teardown.ts, use-test-world.ts, "fakes/<provider>/server.ts", fakes/, "<name>.sql.ts"] # jest globalSetup/globalTeardown default-export (Jest API); fakes/<provider>/server.ts is the fake server of a provider, a literal file name BE_SOURCE_FORM reads as its role; plus root files with a role suffix (test-world-files, R47)
|
|
2031
2035
|
tests: none
|
|
2032
2036
|
coverage: none
|
|
2033
2037
|
why: >-
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { isPlainObject } from '../../engine/plain-object.mjs';
|
|
2
2
|
import { APP_KIND, connectionShapeProblems, NAME, PACKAGE_NAME, RUNTIME_KIND, SLOT_ID } from './manifest-shape.mjs';
|
|
3
|
-
import { dbFunctionsOk } from './rule-params-shape.mjs';
|
|
3
|
+
import { dbFunctionsOk, sqlPassThroughOk } from './rule-params-shape.mjs';
|
|
4
4
|
import { kindShapeProblems, patternShapeProblems } from './declaration-slots.mjs';
|
|
5
5
|
import { byCodeUnit } from '../lib/list.mjs';
|
|
6
6
|
/** Shape problems of the optional hfs.json `db` block of a lite app: the environment variable naming its database, its image, forced RLS and its application role. */
|
|
@@ -91,10 +91,12 @@ function declarationSideProblems(side, value, names) {
|
|
|
91
91
|
if (!isPlainObject(value))
|
|
92
92
|
return [`${at} must be an object`];
|
|
93
93
|
for (const key of Object.keys(value))
|
|
94
|
-
if (!['apps', 'optionalSlots', 'patterns', 'kinds', 'connections', 'reads', 'extraPackages', 'grammar', 'backing', 'dbFunctions'].includes(key))
|
|
94
|
+
if (!['apps', 'optionalSlots', 'patterns', 'kinds', 'connections', 'reads', 'extraPackages', 'grammar', 'backing', 'dbFunctions', 'sqlPassThrough'].includes(key))
|
|
95
95
|
bad.push(`${at} has unknown key ${key}`);
|
|
96
96
|
if (value.dbFunctions !== undefined && (side !== 'be' || !dbFunctionsOk(value.dbFunctions, { bare: true })))
|
|
97
97
|
bad.push(`${at}.dbFunctions belongs to the be side and lists {name, reason} database functions, each schema-qualified (schema.name, never pg_catalog) or a bare pg built-in of the canon's list, with unique names`);
|
|
98
|
+
if (value.sqlPassThrough !== undefined && (side !== 'be' || !sqlPassThroughOk(value.sqlPassThrough)))
|
|
99
|
+
bad.push(`${at}.sqlPassThrough belongs to the be side and lists {file, reason} files relative to the side folder, forward slashes, unique, each with the reason its SQL text is not the app's own`);
|
|
98
100
|
bad.push(...appShapeProblems(value.apps, at, side, names));
|
|
99
101
|
if (isBadUniqueList(value.optionalSlots, (slot) => SLOT_ID.test(String(slot))))
|
|
100
102
|
bad.push(`${at}.optionalSlots must be a unique list of slot ids`);
|
|
@@ -73,6 +73,11 @@ const dbFunctionNameOk = (name, bare) => typeof name === 'string' && (/^[a-z_][a
|
|
|
73
73
|
* a bare name, which only the canon's list (ruleParams.be.dbFunctions) uses, for the pg built-ins its helpers call.
|
|
74
74
|
*/
|
|
75
75
|
export const dbFunctionsOk = (list, { bare = false } = {}) => list === undefined || (Array.isArray(list) && list.every((entry) => isPlainObject(entry) && Object.keys(entry).sort().join() === 'name,reason' && dbFunctionNameOk(entry.name, bare) && typeof entry.reason === 'string' && entry.reason.trim().length > 0) && new Set(list.map((entry) => entry.name)).size === list.length);
|
|
76
|
+
/**
|
|
77
|
+
* True for an absent list or a list of {file, reason} SQL pass-through files (R334): `file` is a forward-slash path relative to the
|
|
78
|
+
* side folder (no leading slash, no `..`), unique, and the reason names whose text the file passes through.
|
|
79
|
+
*/
|
|
80
|
+
export const sqlPassThroughOk = (list) => list === undefined || (Array.isArray(list) && list.every((entry) => isPlainObject(entry) && Object.keys(entry).sort().join() === 'file,reason' && typeof entry.file === 'string' && /^[A-Za-z0-9_.-]+(\/[A-Za-z0-9_.-]+)*$/.test(entry.file) && !entry.file.split('/').includes('..') && typeof entry.reason === 'string' && entry.reason.trim().length > 0) && new Set(list.map((entry) => entry.file)).size === list.length);
|
|
76
81
|
/** The shape problems of `m.ruleParams`. */
|
|
77
82
|
export function ruleParamsProblems(m) {
|
|
78
83
|
const bad = [];
|
|
@@ -84,8 +89,8 @@ export function ruleParamsProblems(m) {
|
|
|
84
89
|
// Edition lite: a side's optional `lite` map overrides keys of the same side (never `lite` itself) when the app declares edition lite.
|
|
85
90
|
if (!sideOk(rp.common, []) || !fileLinesOk(rp.common.fileLines) || !blockOk(rp.common.duplicateBlock))
|
|
86
91
|
bad.push('ruleParams.common needs fileLines {soft, hardGrowth} and duplicateBlock {lines >= 2, tokens >= 1}; optional functionLines and featureFiles must be positive integers');
|
|
87
|
-
if (!sideOk(rp.be, BE_OWN, ['schemaAuthority', 'lite', 'dbFunctions']) || (rp.be.schemaAuthority !== undefined && !SCHEMA_AUTHORITIES.includes(rp.be.schemaAuthority)) || !dbFunctionsOk(rp.be.dbFunctions, { bare: true }) || !liteOk(rp.be.lite, [...BE_OWN, ...SHARED_KEYS, 'schemaAuthority', 'dbFunctions']))
|
|
88
|
-
bad.push('ruleParams.be needs infraOwners, eventBus, specDoubles, paramNames, suffixes, bannedSuffixes, contractShape {helper}, unitRoles, logicRoles, thinRoles, patternScenarios, kindPatterns, addKinds and backing (shared parameters live in ruleParams.common, a side may override them); optional: schemaAuthority (' + SCHEMA_AUTHORITIES.join(' | ') + '), dbFunctions ([{name, reason}]: a pg built-in bare, every other function schema.name) and lite (overrides of the same keys)');
|
|
92
|
+
if (!sideOk(rp.be, BE_OWN, ['schemaAuthority', 'lite', 'dbFunctions', 'sqlPassThrough']) || (rp.be.schemaAuthority !== undefined && !SCHEMA_AUTHORITIES.includes(rp.be.schemaAuthority)) || !dbFunctionsOk(rp.be.dbFunctions, { bare: true }) || !sqlPassThroughOk(rp.be.sqlPassThrough) || !liteOk(rp.be.lite, [...BE_OWN, ...SHARED_KEYS, 'schemaAuthority', 'dbFunctions', 'sqlPassThrough']))
|
|
93
|
+
bad.push('ruleParams.be needs infraOwners, eventBus, specDoubles, paramNames, suffixes, bannedSuffixes, contractShape {helper}, unitRoles, logicRoles, thinRoles, patternScenarios, kindPatterns, addKinds and backing (shared parameters live in ruleParams.common, a side may override them); optional: schemaAuthority (' + SCHEMA_AUTHORITIES.join(' | ') + '), dbFunctions ([{name, reason}]: a pg built-in bare, every other function schema.name), sqlPassThrough ([{file, reason}]: a file whose SQL text is not written by the app) and lite (overrides of the same keys)');
|
|
89
94
|
else
|
|
90
95
|
bad.push(...unitRolesProblems(rp.be), ...roleListProblems(rp.be));
|
|
91
96
|
bad.push(...beSpecificProblems(rp.be, m));
|
|
@@ -125,6 +125,7 @@ function sideDeclaration(manifest, declaration, name, edition, providers) {
|
|
|
125
125
|
providers,
|
|
126
126
|
reads: Object.freeze([...(s.reads ?? [])]),
|
|
127
127
|
dbFunctions: Object.freeze((s.dbFunctions ?? []).map((entry) => Object.freeze({ ...entry }))),
|
|
128
|
+
sqlPassThrough: Object.freeze((s.sqlPassThrough ?? []).map((entry) => Object.freeze({ ...entry }))),
|
|
128
129
|
...(s.grammar === undefined ? {} : { grammar: s.grammar }),
|
|
129
130
|
...(s.extraPackages === undefined ? {} : { extraPackages: Object.freeze(s.extraPackages.map((entry) => Object.freeze({ ...entry }))) }),
|
|
130
131
|
manifestVersion: manifest.version,
|
|
@@ -251,8 +252,8 @@ function createScopeResolver(manifest, repo) {
|
|
|
251
252
|
trackingOf,
|
|
252
253
|
/** True only for a path a slot owns and requires to be committed. */
|
|
253
254
|
isTracked: (p) => trackingOf(p) === 'tracked',
|
|
254
|
-
// The app's own database functions (hfs.json sides.be.dbFunctions) join the canon's
|
|
255
|
-
ruleParams: () =>
|
|
255
|
+
// The app's own database functions and SQL pass-through files (hfs.json sides.be.dbFunctions, sides.be.sqlPassThrough) join the canon's lists (R334).
|
|
256
|
+
ruleParams: () => withDeclaredLists(ruleParams(manifest, profile, edition), repo),
|
|
256
257
|
allowedImports: (tier) => manifest.tiers[profile]?.[tier]?.mayImport ?? null,
|
|
257
258
|
});
|
|
258
259
|
}
|
|
@@ -274,9 +275,18 @@ export function createSlotResolver(manifest, repo) {
|
|
|
274
275
|
const sides = Object.fromEntries(PROFILES.map((side) => [side, createScopeResolver(manifest, app.sides[side])]));
|
|
275
276
|
return appResolver(app, root, sides);
|
|
276
277
|
}
|
|
277
|
-
/** The rule parameters with the app's declared database functions appended to `dbFunctions` (be only), frozen. */
|
|
278
|
-
const
|
|
279
|
-
|
|
278
|
+
/** The rule parameters with the app's declared database functions and SQL pass-through files appended to `dbFunctions` and `sqlPassThrough` (be only), the result frozen. */
|
|
279
|
+
const withDeclaredLists = (params, repo) => {
|
|
280
|
+
const functions = repo.dbFunctions ?? [];
|
|
281
|
+
const passThrough = repo.sqlPassThrough ?? [];
|
|
282
|
+
if ((params.dbFunctions === undefined && !functions.length) && (params.sqlPassThrough === undefined && !passThrough.length))
|
|
283
|
+
return params;
|
|
284
|
+
return Object.freeze({
|
|
285
|
+
...params,
|
|
286
|
+
...(params.dbFunctions === undefined && !functions.length ? {} : { dbFunctions: [...(params.dbFunctions ?? []), ...functions] }),
|
|
287
|
+
...(params.sqlPassThrough === undefined && !passThrough.length ? {} : { sqlPassThrough: [...(params.sqlPassThrough ?? []), ...passThrough] }),
|
|
288
|
+
});
|
|
289
|
+
};
|
|
280
290
|
/** The rule parameters of one profile (be: infraOwners, suffixes, bannedSuffixes and the rest over the shared ruleParams.common fileLines and duplicateBlock; fe: common alone or with its overrides; runtime: ruleParams.runtime of a runtime manifest), as a frozen deep copy. Under `edition` lite the `lite` overrides of ruleParams.<profile> merge over the base (the `lite` key itself is never returned). */
|
|
281
291
|
export function ruleParams(manifest, profile, edition = 'full') {
|
|
282
292
|
const profiles = manifestKind(manifest) === RUNTIME_KIND ? [RUNTIME_KIND] : PROFILES;
|