@starci/eslint-canon-fe 9.6.5 → 9.7.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 CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 9.7.0 - 2026-10-31
4
+
5
+ - 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.
6
+ - 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.
7
+
8
+ ## 9.6.7 - 2026-10-31
9
+
10
+ - 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.
11
+
12
+ ## 9.6.6 - 2026-10-31
13
+
14
+ - 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.
15
+
3
16
  ## 9.6.5 - 2026-10-31
4
17
 
5
18
  - Changed: the bundled runtime copies follow @starci/hfs 4.13.2 (R344 on the `be.integrations` slot) and the canon pins name hfs 4.13.2, eslint-canon-be 3.10.0, eslint-canon-fe 9.6.5, eslint-grammar-core and eslint-grammar-offset-pop 0.16.5; no rule changed.
package/README.md CHANGED
@@ -41,7 +41,9 @@ family that publishes the shared HeroUI surface and field API: `nested-control-v
41
41
  variants on layer-aware fields and primary Tabs inside painted Grammar surfaces (`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`). The R65 budget law's
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
 
47
49
  The [button intent law](docs/button-intent.md) (CTA-6) requires every app Button and IconButton to
@@ -96,7 +96,7 @@ export const SLOT_FREE_RULES = Object.freeze([
96
96
  "no-native-anchor", "no-inline-parameter-type", "no-hand-rolled-polling", "no-copy-in-call", "no-alert", "no-failure-collapse",
97
97
  "outcome-kinds-exhaustive", "no-await-in-loop", "prefer-code-point", "prefer-string-raw", "no-nested-conditional", "no-void-operator", "no-unused-import",
98
98
  "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",
99
+ "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
100
  "button-intent-required", "button-no-variant", "destructive-confirm-in-dialog", "single-commit-per-render", "no-commit-in-collection-item",
101
101
  ]);
102
102
  /** The source of a plain front-end tree: its `src/`. */
package/dist/lib/why.d.ts CHANGED
@@ -32,6 +32,11 @@ export declare const why: {
32
32
  en: string;
33
33
  fix: string;
34
34
  };
35
+ "no-hand-built-panes": {
36
+ code: string;
37
+ en: string;
38
+ fix: string;
39
+ };
35
40
  "button-intent-required": {
36
41
  code: string;
37
42
  en: string;
package/dist/lib/why.js CHANGED
@@ -32,6 +32,11 @@ 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-hand-built-panes": {
36
+ code: "FE_HAND_BUILT_PANES",
37
+ 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.",
38
+ 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.",
39
+ },
35
40
  "button-intent-required": {
36
41
  code: "FE_BUTTON_INTENT_REQUIRED",
37
42
  en: "`<file>` renders a Button or IconButton without the command's intent.",
@@ -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
  };
@@ -1,4 +1,4 @@
1
- /** Grammar owns surface nesting and the single content edge of a panel list (ANATOMY-2, PADDING-0). */
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",
@@ -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"]));
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starci/eslint-canon-fe",
3
- "version": "9.6.5",
3
+ "version": "9.7.0",
4
4
  "description": "Universal front-end lint laws for TypeScript and Next.js, independent of the selected Grammar family.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -31,35 +31,35 @@ 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.16.5
34
+ version: 0.17.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.10.0
41
+ version: 3.11.0
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.6.5
48
+ version: 9.7.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.16.5
55
+ version: 0.17.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.16.5
62
+ version: 0.17.0
63
63
  group: starci
64
64
  install: registry
65
65
  side: fe
@@ -99,14 +99,14 @@ pins:
99
99
  source: packages/test-world/package.json
100
100
  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. A fake declared https (defineHttpFake) serves TLS with the run''s ephemeral in-memory fake CA and publishes its PEM as w.fake.<name>.ca for the app''s own client to trust; verification is never turned off. 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.'
101
101
  '@starci/ai-router':
102
- version: 0.4.1
102
+ version: 0.5.0
103
103
  group: starci
104
104
  install: registry
105
105
  side: be
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.13.2
109
+ version: 4.15.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/] # 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)
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 list (R334).
255
- ruleParams: () => withDeclaredDbFunctions(ruleParams(manifest, profile, edition), repo.dbFunctions),
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 withDeclaredDbFunctions = (params, declared = []) => (params.dbFunctions === undefined && !declared.length ? params
279
- : Object.freeze({ ...params, dbFunctions: Object.freeze([...(params.dbFunctions ?? []), ...declared]) }));
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;