@starci/eslint-canon-fe 9.11.0 → 9.12.1

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,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 9.12.1 - 2026-11-03
4
+
5
+ - Changed: the runtime copies carry the canon pins of `@starci/auth` 0.2.1, `@starci/auth-oidc` 0.1.1, the Grammar family 0.21.16 and `@starci/hfs` 5.4.0.
6
+
7
+ ## 9.12.0 - 2026-11-03
8
+
9
+ - Changed: the runtime copies carry R368's acceptance of the module-definition helpers, the module-per-transport confinement of the helpers and the `@starci/nest-kit` and `@starci/next-i18n` pins; `i18n-stack-in-one-module` refuses every product-source call of a next-intl layer builder (the stack is `@starci/next-i18n`'s).
10
+ - Added: `starciFePackagesConfig`, `loadPackagesHfs` and `scopeToPackages`: the rules of the front end over `packages/<pkg>/src/workbench/**` and `src/public/**` of the root workspace packages, over the package view of the app root, for the managed root `eslint.config.mjs`; the workbench contract stays with the back-end canon and the project-graph rules with the side folders.
11
+ - Changed: the runtime copy of the slot manifest carries the slots `app.packages`, `app.module-registry` and `app.packages-lint`.
12
+
3
13
  ## 9.11.0 - 2026-11-03
4
14
 
5
15
  - Changed: `modules/auth` holds the front-end transport rules like `modules/api`: no auth door file takes axios or `fetch`, and no file keeps module-level mutable state (the access token lives in the `@starci/auth` client); the stack concern `auth` names `@starci/auth`.
package/dist/index.d.ts CHANGED
@@ -43,7 +43,8 @@ export declare const rules: Record<string, Rule>;
43
43
  export declare const recommended: Record<string, "error" | "warn">;
44
44
  /** Flat-config options that make inline directives ineffective rather than merely forbidden. */
45
45
  export { linterOptions } from "./lib/config.js";
46
- export { loadHfs } from "./lib/hfs.js";
46
+ export { loadHfs, loadPackagesHfs } from "./lib/hfs.js";
47
+ export { scopeToPackages } from "./lib/config.js";
47
48
  /**
48
49
  * The Vietnamese why of each rule that carries a catalogue code: `{ code, vi, fixVi }`.
49
50
  *
@@ -81,6 +82,18 @@ export declare const reactHooksRules: (reactHooks: unknown) => Record<string, "e
81
82
  export declare const starciFeConfig: ({ hfs }?: {
82
83
  readonly hfs?: unknown;
83
84
  }) => Linter.Config[];
85
+ /**
86
+ * The flat config of the front-end subpaths of an app's root workspace packages (`packages/<pkg>/src/workbench/**` and `src/public/**`), for the
87
+ * managed root `eslint.config.mjs`: `starciFePackagesConfig({ hfs: loadPackagesHfs(import.meta.url) })`, composed with the Grammar family's
88
+ * `...PackagesConfig`. The very rules of `starciFeConfig` over the package view, but the project-graph rules, which judge the import graph of a
89
+ * side folder that a package is not part of.
90
+ *
91
+ * @param input - The package view of the app root.
92
+ * @returns The flat config.
93
+ */
94
+ export declare const starciFePackagesConfig: ({ hfs }?: {
95
+ readonly hfs?: unknown;
96
+ }) => Linter.Config[];
84
97
  /**
85
98
  * The flat config of a TypeScript front-end tree that is no HFS app (a runtime package, the harness UI):
86
99
  * `export default starciPlainSourceConfig({ root: import.meta.dirname })`. It lints the tree's `src/` with types, with the inline-directive fence, the
package/dist/index.js CHANGED
@@ -49,7 +49,7 @@ import { recommended as typeSafetyRecommended, rules as typeSafetyRules } from "
49
49
  import { recommended as projectGraphRecommended, rules as projectGraphRules } from "./project-graph.js";
50
50
  import { recommended as sonarParityRecommended, rules as sonarParityRules } from "./sonar-parity.js";
51
51
  import { recommended as spacingRecommended, rules as spacingRules } from "./spacing.js";
52
- import { buildFeConfig, buildPlainSourceConfig, COUNTING } from "./lib/config.js";
52
+ import { buildFeConfig, buildPlainSourceConfig, COUNTING, scopeToPackages } from "./lib/config.js";
53
53
  import { why } from "./lib/why.js";
54
54
  import { recommended as uiRulingsRecommended, rules as uiRulingsRules } from "./ui-rulings.js";
55
55
  /** Each law's contribution, kept separate so a duplicate name is detectable rather than silent. */
@@ -134,7 +134,8 @@ export const rules = Object.fromEntries(CONTRIBUTIONS.flatMap((entry) => Object.
134
134
  export const recommended = Object.fromEntries(CONTRIBUTIONS.flatMap((entry) => Object.entries(entry.recommended)));
135
135
  /** Flat-config options that make inline directives ineffective rather than merely forbidden. */
136
136
  export { linterOptions } from "./lib/config.js";
137
- export { loadHfs } from "./lib/hfs.js";
137
+ export { loadHfs, loadPackagesHfs } from "./lib/hfs.js";
138
+ export { scopeToPackages } from "./lib/config.js";
138
139
  /**
139
140
  * The Vietnamese why of each rule that carries a catalogue code: `{ code, vi, fixVi }`.
140
141
  *
@@ -246,6 +247,21 @@ export const starciFeConfig = ({ hfs } = {}) => {
246
247
  source: { ...recommended, ...reactHooksRules(reactHooks) },
247
248
  });
248
249
  };
250
+ /**
251
+ * The flat config of the front-end subpaths of an app's root workspace packages (`packages/<pkg>/src/workbench/**` and `src/public/**`), for the
252
+ * managed root `eslint.config.mjs`: `starciFePackagesConfig({ hfs: loadPackagesHfs(import.meta.url) })`, composed with the Grammar family's
253
+ * `...PackagesConfig`. The very rules of `starciFeConfig` over the package view, but the project-graph rules, which judge the import graph of a
254
+ * side folder that a package is not part of.
255
+ *
256
+ * @param input - The package view of the app root.
257
+ * @returns The flat config.
258
+ */
259
+ export const starciFePackagesConfig = ({ hfs } = {}) => {
260
+ const reactHooks = loadReactHooks();
261
+ const graph = new Set(Object.keys(projectGraphRecommended));
262
+ const source = { ...Object.fromEntries(Object.entries(recommended).filter(([name]) => !graph.has(name))), ...reactHooksRules(reactHooks) };
263
+ return scopeToPackages(buildFeConfig({ hfs, plugin, reactHooks, tsPlugin: loadTsPlugin(), sonar: loadSonarPlugins(), source }));
264
+ };
249
265
  /**
250
266
  * The flat config of a TypeScript front-end tree that is no HFS app (a runtime package, the harness UI):
251
267
  * `export default starciPlainSourceConfig({ root: import.meta.dirname })`. It lints the tree's `src/` with types, with the inline-directive fence, the
@@ -88,3 +88,16 @@ export declare const buildPlainSourceConfig: ({ root, plugin, hooks, reactHooks,
88
88
  readonly sonar: SonarPlugins;
89
89
  readonly canon?: boolean;
90
90
  }) => Linter.Config[];
91
+ /** The front-end subpaths of a root workspace package: its workbench (drawn with Grammar) and its public face; the React-free workbench contract stays back end. */
92
+ export declare const PACKAGE_SOURCE_FILES: readonly string[];
93
+ /** What a package folder never lints: installed packages, build output, generated wire types. */
94
+ export declare const PACKAGE_IGNORED: readonly string[];
95
+ /**
96
+ * A front-end flat config (the canon's or a Grammar family's) narrowed to the front-end subpaths of the root workspace packages of an app:
97
+ * the very rules of the fe side, over `packages/<pkg>/src/workbench/**` and `src/public/**` of the app root, the workbench contract left to the
98
+ * back-end canon. The global ignore block becomes the package one. The config is read, never changed.
99
+ *
100
+ * @param config - The flat config of a front end built over the package view.
101
+ * @returns The same blocks with `files` narrowed to the package subpaths.
102
+ */
103
+ export declare const scopeToPackages: (config: readonly Linter.Config[]) => Linter.Config[];
@@ -148,3 +148,21 @@ export const buildPlainSourceConfig = ({ root, plugin, hooks, reactHooks, tsPlug
148
148
  { files: [...PLAIN_SOURCE_FILES], ignores: [...OUTSIDE_THE_SCAN_FILES], plugins: { sonarjs: sonar.sonarjs, unicorn: sonar.unicorn }, rules: sonarWayRules(sonar.sonarjs, "fe").rules },
149
149
  ];
150
150
  };
151
+ /** The front-end subpaths of a root workspace package: its workbench (drawn with Grammar) and its public face; the React-free workbench contract stays back end. */
152
+ export const PACKAGE_SOURCE_FILES = Object.freeze(["packages/*/src/workbench/**/*.{ts,tsx}", "packages/*/src/public/**/*.{ts,tsx}"]);
153
+ const PACKAGE_CONTRACT = Object.freeze(["packages/*/src/workbench/contract/**"]);
154
+ /** What a package folder never lints: installed packages, build output, generated wire types. */
155
+ export const PACKAGE_IGNORED = Object.freeze(["**/node_modules/**", "**/.next/**", "**/dist/**", "**/coverage/**", "**/__generated__/**"]);
156
+ /**
157
+ * A front-end flat config (the canon's or a Grammar family's) narrowed to the front-end subpaths of the root workspace packages of an app:
158
+ * the very rules of the fe side, over `packages/<pkg>/src/workbench/**` and `src/public/**` of the app root, the workbench contract left to the
159
+ * back-end canon. The global ignore block becomes the package one. The config is read, never changed.
160
+ *
161
+ * @param config - The flat config of a front end built over the package view.
162
+ * @returns The same blocks with `files` narrowed to the package subpaths.
163
+ */
164
+ export const scopeToPackages = (config) => config.map((block) => {
165
+ if (block.files === undefined)
166
+ return { ...block, ignores: [...PACKAGE_IGNORED] };
167
+ return { ...block, files: [...PACKAGE_SOURCE_FILES], ignores: [...(block.ignores ?? []), ...PACKAGE_CONTRACT] };
168
+ });
package/dist/lib/hfs.d.ts CHANGED
@@ -16,6 +16,22 @@ export declare const loadHfs: (configUrl: string | URL) => HfsView;
16
16
  * @returns The frozen HFS view.
17
17
  */
18
18
  export declare const hfsFromDeclaration: (declaration: unknown, repoRoot: string) => HfsView;
19
+ /**
20
+ * The HFS view of the root workspace packages of an app, judged by the laws of the fe side: the managed root `eslint.config.mjs`
21
+ * (`packages/*` of the app root) calls this. Paths are the app's own; a package file is of the tier `package`.
22
+ *
23
+ * @param configUrl - `import.meta.url` of the app root's `eslint.config.mjs`.
24
+ * @returns The frozen HFS view (profile fe over the paths of the app root).
25
+ */
26
+ export declare const loadPackagesHfs: (configUrl: string | URL) => HfsView;
27
+ /**
28
+ * The HFS view of the root packages of an in-memory app declaration, for rule tests.
29
+ *
30
+ * @param declaration - An app `hfs.json` object.
31
+ * @param repoRoot - The absolute app root the linted filenames live under.
32
+ * @returns The frozen HFS view.
33
+ */
34
+ export declare const packagesHfsFromDeclaration: (declaration: unknown, repoRoot: string) => HfsView;
19
35
  /** True for a value that carries the view's resolver (`slotOf`), the test the canon has always made of `settings.starci.hfs`. */
20
36
  export declare const isHfsView: (value: unknown) => value is HfsView;
21
37
  /**
package/dist/lib/hfs.js CHANGED
@@ -13,7 +13,7 @@
13
13
  */
14
14
  import { dirname } from "node:path";
15
15
  import { fileURLToPath } from "node:url";
16
- import { declaredHfsView, openHfsView } from "#runtime/scripts/hfs/view.mjs";
16
+ import { declaredHfsView, declaredPackagesHfsView, openHfsView, openPackagesHfsView } from "#runtime/scripts/hfs/view.mjs";
17
17
  import { RUNTIME_ROOT } from "./package-root.js";
18
18
  const RUNTIME = RUNTIME_ROOT;
19
19
  /**
@@ -31,6 +31,22 @@ export const loadHfs = (configUrl) => openHfsView({ runtimeRoot: RUNTIME, repoRo
31
31
  * @returns The frozen HFS view.
32
32
  */
33
33
  export const hfsFromDeclaration = (declaration, repoRoot) => declaredHfsView({ runtimeRoot: RUNTIME, declaration, repoRoot, side: "fe" });
34
+ /**
35
+ * The HFS view of the root workspace packages of an app, judged by the laws of the fe side: the managed root `eslint.config.mjs`
36
+ * (`packages/*` of the app root) calls this. Paths are the app's own; a package file is of the tier `package`.
37
+ *
38
+ * @param configUrl - `import.meta.url` of the app root's `eslint.config.mjs`.
39
+ * @returns The frozen HFS view (profile fe over the paths of the app root).
40
+ */
41
+ export const loadPackagesHfs = (configUrl) => openPackagesHfsView({ runtimeRoot: RUNTIME, repoRoot: dirname(fileURLToPath(configUrl)), side: "fe" });
42
+ /**
43
+ * The HFS view of the root packages of an in-memory app declaration, for rule tests.
44
+ *
45
+ * @param declaration - An app `hfs.json` object.
46
+ * @param repoRoot - The absolute app root the linted filenames live under.
47
+ * @returns The frozen HFS view.
48
+ */
49
+ export const packagesHfsFromDeclaration = (declaration, repoRoot) => declaredPackagesHfsView({ runtimeRoot: RUNTIME, declaration, repoRoot, side: "fe" });
34
50
  /** True for a value that carries the view's resolver (`slotOf`), the test the canon has always made of `settings.starci.hfs`. */
35
51
  export const isHfsView = (value) => typeof value === "object" && value !== null && "slotOf" in value && typeof value.slotOf === "function";
36
52
  /** The `starci.hfs` entry of a settings object, read without trusting its shape. */
package/dist/lib/why.js CHANGED
@@ -384,8 +384,8 @@ export const why = {
384
384
  },
385
385
  "i18n-stack-in-one-module": {
386
386
  code: "FE_I18N_PLACEMENT",
387
- en: "`<file>` builds a layer of the next-intl stack itself (`defineRouting`, `createNavigation`, `getRequestConfig`, `createMiddleware`). The stack is written once for the whole repo.",
388
- fix: "Use the shared i18n factory; a single app keeps routing/request config in modules/i18n and navigation in modules/routes. The proxy imports routing without navigation.",
387
+ en: "`<file>` builds a layer of the next-intl stack itself (`defineRouting`, `createNavigation`, `getRequestConfig`, `createMiddleware`). The stack is written once, in `@starci/next-i18n`.",
388
+ fix: "Describe the languages with `defineI18n` and call `createProxy`, `createI18nNavigation`, `createRequestConfig` and `createReadLocaleSegment` of `@starci/next-i18n`; the proxy imports no navigation.",
389
389
  },
390
390
  "no-app-relative-clock": {
391
391
  code: "FE_SIZE_AND_STATE_BUDGET",
@@ -30,13 +30,13 @@ export declare const noNativeAnchor: import("./lib/rule.js").Rule<"internal" | "
30
30
  /** No route written by hand outside `modules/routes`. */
31
31
  export declare const noHardcodedRoute: import("./lib/rule.js").Rule<"route", []>;
32
32
  /**
33
- * The next-intl stack is written once per repository.
33
+ * The next-intl stack is written once, in `@starci/next-i18n`.
34
34
  *
35
35
  * `defineRouting` (`next-intl/routing`), `createNavigation` (`next-intl/navigation`), `getRequestConfig` (`next-intl/server`) and
36
36
  * `createMiddleware` (`next-intl/middleware`), resolved by the import that binds the called name (a renamed import or a namespace
37
- * member counts), have one owner. A shared i18n package owns its stack. In a one-app repository, routing and request
38
- * config belong to modules/i18n while createNavigation belongs to modules/routes. The proxy imports routing without
39
- * evaluating navigation: next-intl's navigation module selects React runtime exports unavailable in the proxy.
37
+ * member counts), are called by that package and by no product source: an app describes its languages with `defineI18n` and builds
38
+ * the proxy, the navigation, the request config and the layout's locale read with the package's factories. The proxy imports the
39
+ * routing without evaluating navigation: next-intl's navigation module selects React runtime exports unavailable in the proxy.
40
40
  *
41
41
  * It does not replace `no-second-i18n-stack`: that rule refuses another i18n LIBRARY at its import, this one refuses a second copy of
42
42
  * next-intl's own stack. Different evidence, different remedy.
@@ -374,13 +374,13 @@ const stackFactoryOf = (context, callee) => {
374
374
  return stackFactory(definition, importedName(definition.node));
375
375
  };
376
376
  /**
377
- * The next-intl stack is written once per repository.
377
+ * The next-intl stack is written once, in `@starci/next-i18n`.
378
378
  *
379
379
  * `defineRouting` (`next-intl/routing`), `createNavigation` (`next-intl/navigation`), `getRequestConfig` (`next-intl/server`) and
380
380
  * `createMiddleware` (`next-intl/middleware`), resolved by the import that binds the called name (a renamed import or a namespace
381
- * member counts), have one owner. A shared i18n package owns its stack. In a one-app repository, routing and request
382
- * config belong to modules/i18n while createNavigation belongs to modules/routes. The proxy imports routing without
383
- * evaluating navigation: next-intl's navigation module selects React runtime exports unavailable in the proxy.
381
+ * member counts), are called by that package and by no product source: an app describes its languages with `defineI18n` and builds
382
+ * the proxy, the navigation, the request config and the layout's locale read with the package's factories. The proxy imports the
383
+ * routing without evaluating navigation: next-intl's navigation module selects React runtime exports unavailable in the proxy.
384
384
  *
385
385
  * It does not replace `no-second-i18n-stack`: that rule refuses another i18n LIBRARY at its import, this one refuses a second copy of
386
386
  * next-intl's own stack. Different evidence, different remedy.
@@ -388,20 +388,15 @@ const stackFactoryOf = (context, callee) => {
388
388
  export const i18nStackInOneModule = defineRule({
389
389
  meta: {
390
390
  type: "problem",
391
- docs: { description: "A shared i18n package owns the stack; one app keeps routing/request config in i18n and navigation in routes, with a routing-only proxy entry." },
391
+ docs: { description: "The next-intl stack is built by @starci/next-i18n; an app calls its factories and never next-intl's own, and its proxy never evaluates the navigation." },
392
392
  schema: [],
393
393
  messages: {
394
- stack: "`{{name}}` builds the next-intl stack outside its owner. Keep {{where}} and import the owned result through its public entry.",
395
- proxyNavigation: "The proxy imports navigation runtime through {{source}}. Import routing from the i18n public entry, or use the shared package's routing-only proxy export; the proxy must not evaluate the routes/navigation barrel.",
394
+ stack: "`{{name}}` builds the next-intl stack in the app. Describe the languages with `defineI18n` and build the proxy, the navigation, the request config and the locale read of the layout with `createProxy`, `createI18nNavigation`, `createRequestConfig` and `createReadLocaleSegment` of `@starci/next-i18n` (FE-I18N-1).",
395
+ proxyNavigation: "The proxy imports navigation runtime through {{source}}. Build the proxy with `createProxy` of `@starci/next-i18n/proxy` over the i18n description; the proxy must not evaluate the routes/navigation barrel.",
396
396
  },
397
397
  },
398
398
  create(context) {
399
- const hfs = hfsOf(context);
400
- const slot = slotOfFile(context);
401
- const single = hfs.apps.length === 1;
402
- const proxy = single && slot === "fe.source-root-pinned" && roleOfFile(context) === "proxy";
403
- const proxyFile = slot === "fe.source-root-pinned" && roleOfFile(context) === "proxy";
404
- const where = single ? "routing/request config in modules/i18n and navigation in modules/routes" : "the stack in the shared i18n package that every app calls";
399
+ const proxyFile = slotOfFile(context) === "fe.source-root-pinned" && roleOfFile(context) === "proxy";
405
400
  const safeProxyImport = (node) => {
406
401
  if (!proxyFile || !node.source || (node.type === "ImportDeclaration" ? node.importKind === "type" : node.exportKind === "type"))
407
402
  return;
@@ -419,18 +414,8 @@ export const i18nStackInOneModule = defineRule({
419
414
  ExportAllDeclaration: safeProxyImport,
420
415
  CallExpression(node) {
421
416
  const factory = stackFactoryOf(context, node.callee);
422
- // In a one-app repository the proxy (role `proxy`) mounts the locale middleware built from the one routing; a shared repository
423
- // builds it in the i18n package and the proxy re-exports it.
424
- if (factory?.name === "createMiddleware" && proxy)
425
- return;
426
- if (slot === "fe.package.i18n")
427
- return;
428
- if (single && factory?.name === "createNavigation" && slot === "fe.modules.routes")
429
- return;
430
- if (single && factory?.name !== "createNavigation" && slot === "fe.modules.i18n")
431
- return;
432
417
  if (factory)
433
- context.report({ node, messageId: "stack", data: { name: factory.name, where } });
418
+ context.report({ node, messageId: "stack", data: { name: factory.name } });
434
419
  },
435
420
  };
436
421
  },
@@ -207,9 +207,7 @@ router.push(routes.newWorkspace())
207
207
 
208
208
  ## `starci-fe/i18n-stack-in-one-module`
209
209
 
210
- The shared i18n package owns a multi-app stack. A single app keeps routing/request config in `modules/i18n` and navigation in the `modules/routes` public entry.
211
-
212
- `defineRouting` and `getRequestConfig` remain in the i18n owner. `createNavigation` belongs to a shared `fe.package.i18n` owner or the single app's `fe.modules.routes` owner. A single app's proxy mounts locale middleware using the routing-only public i18n entry. A shared package exposes its routing-only proxy entry as a declared package export. The proxy does not import `next-intl/navigation` or the app's routes/navigation barrel. Named import aliases and namespace imports keep their factory identity.
210
+ The next-intl stack is built by `@starci/next-i18n`, never by a product source. `defineRouting`, `createNavigation`, `getRequestConfig` and `createMiddleware` are called by no file of an app or of a shared package, wherever it sits. An app states its languages with `defineI18n` and builds the proxy, the navigation, the request config and the layout's locale read with `createProxy`, `createI18nNavigation`, `createRequestConfig` and `createReadLocaleSegment`. The proxy does not import `next-intl/navigation` or the app's routes/navigation barrel. Named import aliases and namespace imports keep their factory identity.
213
211
 
214
212
  **Invalid** (`apps/web/src/modules/i18n/request.ts`, a repository with two apps)
215
213
 
@@ -218,15 +216,17 @@ import { getRequestConfig } from "next-intl/server"
218
216
  export default getRequestConfig(async () => ({ locale: "vi", messages: {} }))
219
217
  ```
220
218
 
221
- **Valid** (`apps/web/src/modules/i18n/index.ts`)
219
+ **Valid** (`apps/web/src/modules/i18n/request.ts`)
222
220
 
223
221
  ```ts
224
- import { createAppI18n } from "@acme/i18n"
225
- export const i18n = createAppI18n({ locales: ["vi"] })
222
+ import { createRequestConfig } from "@starci/next-i18n/request"
223
+ import { i18n } from "./routing"
224
+
225
+ export default createRequestConfig(i18n, { app: "web", loadMessages: (locale) => import(`./messages/${locale}.json`) })
226
226
  ```
227
227
 
228
228
  **Finding code:** `FE_I18N_PLACEMENT`
229
229
 
230
- **Why:** `<file>` builds a layer of the next-intl stack itself (`defineRouting`, `createNavigation`, `getRequestConfig`, `createMiddleware`). The stack may be written only once per repo.
230
+ **Why:** `<file>` builds a layer of the next-intl stack itself (`defineRouting`, `createNavigation`, `getRequestConfig`, `createMiddleware`). The stack is written once, in `@starci/next-i18n`.
231
231
 
232
- **Fix:** Use the shared i18n factory, or keep routing/request configuration in the single app's i18n module and navigation in its public routes entry. Keep navigation imports out of the proxy.
232
+ **Fix:** Describe the languages with `defineI18n` and call `createProxy`, `createI18nNavigation`, `createRequestConfig` and `createReadLocaleSegment` of `@starci/next-i18n`. Keep navigation imports out of the proxy.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starci/eslint-canon-fe",
3
- "version": "9.11.0",
3
+ "version": "9.12.1",
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",
@@ -101,6 +101,7 @@
101
101
  "scripts/hfs/architecture/render-graph.mjs",
102
102
  "scripts/hfs/architecture/render-prop-across-server.mjs",
103
103
  "scripts/hfs/architecture/required-files.mjs",
104
+ "scripts/hfs/architecture/root-packages.mjs",
104
105
  "scripts/hfs/architecture/route-files-thin.mjs",
105
106
  "scripts/hfs/architecture/schema-owner.mjs",
106
107
  "scripts/hfs/architecture/server-action.mjs",
@@ -38,42 +38,49 @@ pins:
38
38
  source: packages/next-data/package.json
39
39
  why: 'the SWR data helpers of every front end (fe-stack data, owner:next-data-package): pollingRestart, the viewer and tenant key helpers, viewerSwrConfig and the viewer cache; each operation hook stays in the app and the package holds no component.'
40
40
  '@starci/grammar':
41
- version: 0.21.14
41
+ version: 0.21.16
42
42
  group: starci
43
43
  install: registry
44
44
  side: fe
45
45
  source: packages/grammar/package.json
46
46
  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.'
47
+ '@starci/next-i18n':
48
+ version: 0.1.0
49
+ group: starci
50
+ install: registry
51
+ side: fe
52
+ source: packages/next-i18n/package.json
53
+ why: 'the next-intl stack of every front end (fe-stack i18n, owner:next-i18n-package): defineI18n over the locales of the app, the locale-negotiating proxy, the locale-aware navigation, the request config that loads the catalogs of the app and the locale read of the [locale] layout; the app keeps its message catalogs and writes none of the stack.'
47
54
  '@starci/eslint-canon-be':
48
- version: 3.13.0
55
+ version: 3.14.1
49
56
  group: starci
50
57
  install: registry
51
58
  side: be
52
59
  source: packages/eslint-canon-be/package.json
53
60
  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.'
54
61
  '@starci/eslint-canon-fe':
55
- version: 9.11.0
62
+ version: 9.12.1
56
63
  group: starci
57
64
  install: registry
58
65
  side: fe
59
66
  source: packages/eslint-canon-fe/package.json
60
67
  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.'
61
68
  '@starci/eslint-grammar-core':
62
- version: 0.21.14
69
+ version: 0.21.16
63
70
  group: starci
64
71
  install: registry
65
72
  side: fe
66
73
  source: packages/eslint-grammar-core/package.json
67
74
  why: 'the StarCi Grammar component API lint, versioned with @starci/grammar; front ends compose this family package with the universal FE canon.'
68
75
  '@starci/eslint-grammar-offset-pop':
69
- version: 0.21.14
76
+ version: 0.21.16
70
77
  group: starci
71
78
  install: registry
72
79
  side: fe
73
80
  source: packages/eslint-grammar-offset-pop/package.json
74
81
  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.'
75
82
  '@starci/stylelint-canon':
76
- version: 2.5.1
83
+ version: 2.6.0
77
84
  group: starci
78
85
  install: registry
79
86
  side: fe
@@ -92,7 +99,7 @@ pins:
92
99
  side: both
93
100
  source: packages/prettier-config/package.json
94
101
  '@starci/jest-preset':
95
- version: 2.6.1
102
+ version: 2.6.2
96
103
  group: starci
97
104
  install: registry
98
105
  side: be
@@ -112,13 +119,27 @@ pins:
112
119
  side: be
113
120
  source: packages/ai-router/package.json
114
121
  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.'
115
- '@starci/auth':
122
+ '@starci/nest-kit':
116
123
  version: 0.1.0
117
124
  group: starci
118
125
  install: registry
126
+ side: be
127
+ source: packages/nest-kit/package.json
128
+ why: 'the module definition of every back-end module: defineConfigurableModule and defineCapabilityModule return the module base class, the options token and the options types with their types stated, so a <x>.module-definition.ts is one call that the lint on stated export types (R368) and the duplicate-code check (R21) both accept; @starci/jest-preset compiles it as an ESM-only dependency.'
129
+ '@starci/auth':
130
+ version: 0.2.1
131
+ group: starci
132
+ install: registry
119
133
  side: both
120
134
  source: packages/auth/package.json
121
135
  why: 'the lite sign-in: the NestJS module (EdDSA access tokens with a multi-key JWKS, passwords, Google OAuth with state and PKCE, rotating refresh sessions, the sign-in budget, the refresh cookie), the front-end client and its React bindings, the door contracts and test doubles; the one importer of the identity libraries, reached by a back end only through integrations/passport; @starci/jest-preset compiles it as an ESM-only dependency.'
136
+ '@starci/auth-oidc':
137
+ version: 0.1.1
138
+ group: starci
139
+ install: registry
140
+ side: both
141
+ source: packages/auth-oidc/package.json
142
+ why: 'the full sign-in: the OIDC client of the identity provider (Keycloak) behind the PrincipalVerifier of @starci/auth, so guards do not change between editions: the NestJS module (issuer discovery, the JWKS with caching and rotation, algorithm-pinned token checks, role mapping, the authorization code flow with PKCE and nonce), the front-end client (PKCE redirect, one shared refresh, tokens in memory) and the fake issuer for tests; the one importer of jose in a full app, reached by a back end only through integrations/keycloak; @starci/jest-preset compiles it as an ESM-only dependency.'
122
143
  '@starci/object-storage':
123
144
  version: 0.1.0
124
145
  group: starci
@@ -126,8 +147,15 @@ pins:
126
147
  side: be
127
148
  source: packages/object-storage/package.json
128
149
  why: 'object storage of a back end: a NestJS module and the ObjectStorage port over S3-compatible storage (presigned PUT and GET that sign the content type and size, head, get, put, list, delete, bucket setup, size and content-type limits, coded errors), with @starci/object-storage/testing for its specs; the only importer of @aws-sdk/client-s3 and @aws-sdk/s3-request-presigner (R331).'
150
+ '@starci/mail':
151
+ version: 0.1.0
152
+ group: starci
153
+ install: registry
154
+ side: be
155
+ source: packages/mail/package.json
156
+ why: 'outgoing mail of a back end: a NestJS module and the Mailer port over SMTP (connect, greeting, socket and total timeouts, STARTTLS required by default, recipient, message and attachment checks, a shared SMTP target policy, coded errors, typed escaping render helpers returning { subject, text, html }), with @starci/mail/testing for its specs; the only importer of nodemailer (R90).'
129
157
  '@starci/hfs':
130
- version: 5.2.0
158
+ version: 5.4.0
131
159
  group: starci
132
160
  install: registry
133
161
  side: both
@@ -570,6 +598,11 @@ pins:
570
598
  group: framework
571
599
  side: be
572
600
  why: 'signs the presigned PUT and GET inside @starci/object-storage locally, the same release as client-s3; no app file imports it.'
601
+ nodemailer:
602
+ version: 10.1.7
603
+ group: framework
604
+ side: be
605
+ why: 'the SMTP client inside @starci/mail, which depends on it at exactly this version and which no app file imports; 10.1.7 is the release of 2026-10-18, chosen over newer ones that had not aged two weeks.'
573
606
  '@apollo/server':
574
607
  version: 5.5.1
575
608
  group: framework
@@ -670,7 +703,7 @@ pins:
670
703
  version: 6.2.12
671
704
  group: framework
672
705
  side: be
673
- why: signs the app's own ES256 access tokens and verifies them against its own key set (createLocalJWKSet) in integrations/passport.
706
+ why: signs the app's own access tokens and verifies them against its own key set inside @starci/auth, and verifies the identity provider's tokens against its JWKS inside @starci/auth-oidc; no app file imports it.
674
707
  class-transformer:
675
708
  version: 0.5.1
676
709
  group: framework
@@ -96,18 +96,18 @@ concerns:
96
96
  hand-written reader (read-event-stream.ts) and no second SSE library is installed.
97
97
  - id: auth
98
98
  tier: core
99
- packages: ['@starci/auth']
99
+ packages: ['@starci/auth', '@starci/auth-oidc']
100
100
  home: [fe.modules.auth]
101
101
  pattern: FE-AUTH
102
102
  forbids: ['@supabase/supabase-js', '@supabase/ssr', '@supabase/auth-js', jose, jwt-decode, next-auth, '@auth/core', keycloak-js, oidc-client-ts]
103
103
  why: modules/auth is the session issuer's client (one @starci/auth client instance over the app's own back-end auth doors, the access token in its memory; full - the app's IdP client); it yields the session and the access token for the Apollo link and never reads data.
104
104
  - id: i18n
105
105
  tier: core
106
- packages: [next-intl]
106
+ packages: [next-intl, '@starci/next-i18n']
107
107
  home: [fe.modules.i18n]
108
108
  pattern: FE-I18N
109
109
  forbids: [i18next, react-i18next, next-i18next, react-intl, '@lingui/react']
110
- why: useTranslations for copy and the next-intl formatter for numbers, fixed dates and currency; FE-DATE owns live time display.
110
+ why: useTranslations for copy and the next-intl formatter for numbers, fixed dates and currency; FE-DATE owns live time display. The routing, the proxy, the navigation, the request config and the locale read of the layout are @starci/next-i18n's (owner next-i18n-package); an app calls no defineRouting, createNavigation, getRequestConfig or createMiddleware.
111
111
  - id: ui
112
112
  tier: core
113
113
  packages: ['@heroui/react', '@heroui/styles', tailwindcss, '@starci/grammar', react-aria-components]
@@ -240,6 +240,8 @@ ruleParams:
240
240
  "@aws-sdk/s3-request-presigner": []
241
241
  "@starci/object-storage": [integrations/object-storage] # the ObjectStorage port: one S3 adapter for MinIO, S3 and Spaces (R331)
242
242
  minio: [] # storage goes through @starci/object-storage
243
+ nodemailer: [] # the SMTP client is a dependency of @starci/mail alone; no app file imports it
244
+ "@starci/mail": [integrations/mail] # outgoing mail: the Mailer port, parsed and registered by its one glue
243
245
  "@supabase": [] # no Supabase client in a back end
244
246
  "@nestjs/passport": [] # the identity libraries are @starci/auth's dependencies, never an app's (R328)
245
247
  passport: []
@@ -248,8 +250,15 @@ ruleParams:
248
250
  "@nestjs/jwt": []
249
251
  jose: []
250
252
  bcryptjs: []
253
+ jsonwebtoken: [] # no other JWT or OIDC client library in a back end: the tokens of an identity provider are verified by @starci/auth-oidc (R328)
254
+ jwks-rsa: []
255
+ jwt-decode: []
256
+ passport-jwt: []
257
+ openid-client: []
251
258
  "@starci/auth": [integrations/passport] # the lite sign-in package: its server entry is reached only through integrations/passport (R328)
252
259
  "@starci/auth/testing": [integrations/passport] # its test doubles, for the glue's specs
260
+ "@starci/auth-oidc": [integrations/keycloak] # the full sign-in package (jose is its dependency, imported by no app file): its server entry is reached only through integrations/keycloak (R328)
261
+ "@starci/auth-oidc/testing": [integrations/keycloak] # its fake issuer, for the glue's specs
253
262
  bcrypt: []
254
263
  cache-manager: [integrations/cache, integrations/redis]
255
264
  "@nestjs/cache-manager": [integrations/cache, integrations/redis] # the CACHE_MANAGER token: caching goes through the cache capability
@@ -463,6 +472,14 @@ ruleParams:
463
472
  - {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"}
464
473
  - {name: public.event_bus_dead_letters, reason: "platform/event-bus (add webhook, lite): the operator read of a consumer's dead-letter queue"}
465
474
  - {name: public.event_bus_requeue, reason: "platform/event-bus (add webhook, lite): the operator requeue of one dead letter"}
475
+ - {name: public.be_auth_user_by_email, reason: "domain/identity (lite sign-in): the account of an exact sign-in address with its bcrypt hash, through the account-doors definer of the baseline"}
476
+ - {name: public.be_auth_user_by_id, reason: "domain/identity (lite sign-in): the standing of one account by id (a refresh), through the account-doors definer"}
477
+ - {name: public.be_auth_user_by_google, reason: "domain/identity (lite sign-in): the account a Google subject is linked to, through the account-doors definer"}
478
+ - {name: public.be_auth_create_user, reason: "domain/identity (lite sign-in): the account a first Google sign-in creates, through the account-doors definer"}
479
+ - {name: public.be_auth_link_google, reason: "domain/identity (lite sign-in): the link of a Google subject to an account, through the account-doors definer"}
480
+ - {name: public.be_auth_touch_user, reason: "domain/identity (lite sign-in): the sign-in record of an account and its profile keys, through the account-doors definer"}
481
+ - {name: public.be_auth_confirm_user, reason: "domain/identity (lite sign-in): the confirmation stamp of a mailbox whose link was spent, through the account-doors definer"}
482
+ - {name: public.be_auth_set_password, reason: "domain/identity (lite sign-in): the new password hash of a spent reset link (and the mailbox it proved), through the account-doors definer"}
466
483
  # R334 (lite): the only `.query(` outside platform/database's composer and the test world is the pass-through of a file listed here
467
484
  # (or in hfs.json sides.be.sqlPassThrough), whose SQL text is not the app's own. Paths are relative to the side folder.
468
485
  sqlPassThrough:
@@ -590,13 +607,24 @@ slots:
590
607
  - id: app.tool-config-local
591
608
  since: 3.1.0
592
609
  profiles: [app]
593
- path: "{tsconfig.json,.eslintrc,.eslintrc.*,.eslintignore,eslint.config.*,.stylelintrc,.stylelintrc.*,.stylelintignore,stylelint.config.*,.prettierrc.*,prettier.config.*,jest.config.*,lint-staged.config.*,.lintstagedrc*}"
610
+ path: "{tsconfig.json,.eslintrc,.eslintrc.*,.eslintignore,eslint.config.js,eslint.config.cjs,eslint.config.ts,eslint.config.mts,eslint.config.cts,.stylelintrc,.stylelintrc.*,.stylelintignore,stylelint.config.*,.prettierrc.*,prettier.config.*,jest.config.*,lint-staged.config.*,.lintstagedrc*}"
594
611
  presence: forbidden
595
612
  tracked: external
596
613
  tier: none
597
614
  tests: none
598
- goesTo: "nowhere at the app root: each side holds exactly its managed tool configuration (be.tool-config, fe.tool-config) and the root only the formatter (app.format-config)"
615
+ goesTo: "nowhere at the app root: each side holds exactly its managed tool configuration (be.tool-config, fe.tool-config) and the root only the formatter (app.format-config) and the eslint config of its root packages (app.packages-lint)"
599
616
  rules: [HFS_TOOL_CONFIG_LOCAL]
617
+ - id: app.packages-lint
618
+ since: 3.17.0
619
+ profiles: [app]
620
+ # The one eslint config at the app root: `starci app sync` renders it when the app has root workspace packages (app.packages), and it lints their
621
+ # source by subpath (the back-end canon, the front-end canon and the Grammar family each over their own subpaths). No other tool config lives here.
622
+ path: eslint.config.mjs
623
+ presence: optional
624
+ tracked: tracked
625
+ tier: none
626
+ tests: none
627
+ rules: [HFS_MANAGED_FILE_DRIFT]
600
628
  - id: app.quality-config
601
629
  since: 3.1.0
602
630
  profiles: [app]
@@ -828,6 +856,31 @@ slots:
828
856
  rules: [HFS_PLAINTEXT_SECRET, HFS_SCRIPT_ONE_OFF, BE_SPEC_PLACEMENT, HFS_REPO_LOCAL_CHECK]
829
857
  exemplifiedBy:
830
858
  - {path: packages/hfs/src/hfs-slot-ownership.spec.mjs, editions: [lite, full]}
859
+ - id: app.packages
860
+ since: 3.17.0
861
+ profiles: [app]
862
+ editionWhy: Lite and full share the slot; a product's agent SDK and its agent modules are workspace packages in both.
863
+ # A workspace package of the app ROOT (packages/agent-sdk, packages/module-<name>): first-party source that both sides import through its
864
+ # package entry points and never past them (ARCH_INTERNAL_IMPORT_OUTSIDE), linted by the root eslint config by subpath.
865
+ path: "packages/{agent-sdk,module-<name>}/"
866
+ presence: optional
867
+ tracked: tracked
868
+ tier: none
869
+ requires: [package.json, tsconfig.json, src/index.ts]
870
+ tests: unit-beside
871
+ rules: [HFS_UNUSED_EXPORT]
872
+ - id: app.module-registry
873
+ since: 3.17.0
874
+ profiles: [app]
875
+ editionWhy: Lite and full share the slot; the registry lists the module packages of the app.
876
+ path: packages/modules/
877
+ presence: optional
878
+ tracked: tracked
879
+ tier: none
880
+ requires: [package.json, src/index.ts]
881
+ allows: [package.json, tsconfig.json, tsconfig.build.json, src/index.ts, src/template/index.ts, src/manifest/index.ts]
882
+ tests: none
883
+ rules: [HFS_MANAGED_FILE_DRIFT]
831
884
  - id: app.sides
832
885
  since: 3.1.0
833
886
  profiles: [app]
@@ -2003,8 +2056,8 @@ slots:
2003
2056
  profiles: [be]
2004
2057
  editions: [lite]
2005
2058
  editionWhy: "lite signs people in with the package @starci/auth, so its sign-in glue is rendered, not written (owner ruling auth-package); full uses its identity provider."
2006
- # The identity capability's sign-in glue over @starci/auth: the entities of the session, attempt and user tables, the stores the package asks for (sessions, accounts, rate counter), the authenticator and the service the doors call, with their specs. `starci app sync` renders them; a hand edit is drift.
2007
- path: "{src/modules/domain/identity/account-directory.service.spec.ts,src/modules/domain/identity/account-directory.service.ts,src/modules/domain/identity/authenticator.service.spec.ts,src/modules/domain/identity/authenticator.service.ts,src/modules/domain/identity/persistence/entities/auth-identity.entity.ts,src/modules/domain/identity/persistence/entities/auth-user.entity.ts,src/modules/domain/identity/persistence/entities/identity-attempt.entity.ts,src/modules/domain/identity/persistence/entities/identity-refresh-token.entity.ts,src/modules/domain/identity/persistence/entities/identity-session.entity.ts,src/modules/domain/identity/persistence/entities/index.ts,src/modules/domain/identity/rate-counter.service.spec.ts,src/modules/domain/identity/rate-counter.service.ts,src/modules/domain/identity/session-store.service.spec.ts,src/modules/domain/identity/session-store.service.ts,src/modules/domain/identity/sign-in.service.spec.ts,src/modules/domain/identity/sign-in.service.ts,src/tests/fixtures/builders/identity.builder.ts}"
2059
+ # The identity capability's sign-in glue over @starci/auth: the entities of the session, attempt, refresh-token and email-token tables, the account doors over auth.users and auth.identities, the stores the package asks for (sessions, accounts, rate counter), the authenticator and the service the doors call, with their specs. `starci app sync` renders them; a hand edit is drift.
2060
+ path: "{src/modules/domain/identity/account-directory.service.spec.ts,src/modules/domain/identity/account-directory.service.ts,src/modules/domain/identity/authenticator.service.spec.ts,src/modules/domain/identity/authenticator.service.ts,src/modules/domain/identity/identity-email.log-events.ts,src/modules/domain/identity/identity-email.service.spec.ts,src/modules/domain/identity/identity-email.service.ts,src/modules/domain/identity/identity-sign-in.log-events.ts,src/modules/domain/identity/persistence/account-doors.port.ts,src/modules/domain/identity/persistence/entities/identity-attempt.entity.ts,src/modules/domain/identity/persistence/entities/identity-email-token.entity.ts,src/modules/domain/identity/persistence/entities/identity-refresh-token.entity.ts,src/modules/domain/identity/persistence/entities/identity-session.entity.ts,src/modules/domain/identity/persistence/entities/index.ts,src/modules/domain/identity/rate-counter.service.spec.ts,src/modules/domain/identity/rate-counter.service.ts,src/modules/domain/identity/session-store.service.spec.ts,src/modules/domain/identity/session-store.service.ts,src/modules/domain/identity/sign-in.service.spec.ts,src/modules/domain/identity/sign-in.service.ts,src/tests/fixtures/builders/identity.builder.ts}"
2008
2061
  presence: optional
2009
2062
  tracked: tracked
2010
2063
  tier: inherit
@@ -2019,7 +2072,7 @@ slots:
2019
2072
  editions: [lite]
2020
2073
  editionWhy: "lite signs people in with the package @starci/auth, so its sign-in glue is rendered, not written (owner ruling auth-package); full uses its identity provider."
2021
2074
  # The auth feature: the REST doors under /auth (login, refresh, session, logout, Google start and callback, jwks) over IdentitySignInService, one command or query and handler per door.
2022
- path: "{src/features/api/auth/application/auth.contracts.ts,src/features/api/auth/application/get-jwks.handler.ts,src/features/api/auth/application/get-jwks.query.ts,src/features/api/auth/application/google-begin.command.ts,src/features/api/auth/application/google-begin.handler.ts,src/features/api/auth/application/google-complete.command.ts,src/features/api/auth/application/google-complete.handler.ts,src/features/api/auth/application/login.command.ts,src/features/api/auth/application/login.handler.ts,src/features/api/auth/application/logout.command.ts,src/features/api/auth/application/logout.handler.ts,src/features/api/auth/application/refresh.command.ts,src/features/api/auth/application/refresh.handler.ts,src/features/api/auth/application/session.command.ts,src/features/api/auth/application/session.handler.ts,src/features/api/auth/auth.module-definition.ts,src/features/api/auth/auth.module.ts,src/features/api/auth/auth.options.ts,src/features/api/auth/index.ts,src/features/api/auth/transport/http/auth-http.module-definition.ts,src/features/api/auth/transport/http/auth-http.module.ts,src/features/api/auth/transport/http/auth-http.options.ts,src/features/api/auth/transport/http/auth.controller.ts,src/features/api/auth/transport/http/dto/google-start.request.ts,src/features/api/auth/transport/http/dto/login.request.ts}"
2075
+ path: "{src/features/api/auth/application/auth.contracts.ts,src/features/api/auth/application/confirm.command.ts,src/features/api/auth/application/confirm.handler.ts,src/features/api/auth/application/forgot.command.ts,src/features/api/auth/application/forgot.handler.ts,src/features/api/auth/application/get-jwks.handler.ts,src/features/api/auth/application/get-jwks.query.ts,src/features/api/auth/application/google-begin.command.ts,src/features/api/auth/application/google-begin.handler.ts,src/features/api/auth/application/google-complete.command.ts,src/features/api/auth/application/google-complete.handler.ts,src/features/api/auth/application/login.command.ts,src/features/api/auth/application/login.handler.ts,src/features/api/auth/application/logout.command.ts,src/features/api/auth/application/logout.handler.ts,src/features/api/auth/application/refresh.command.ts,src/features/api/auth/application/refresh.handler.ts,src/features/api/auth/application/resend.command.ts,src/features/api/auth/application/resend.handler.ts,src/features/api/auth/application/reset.command.ts,src/features/api/auth/application/reset.handler.ts,src/features/api/auth/application/session.command.ts,src/features/api/auth/application/session.handler.ts,src/features/api/auth/application/signup.command.ts,src/features/api/auth/application/signup.handler.ts,src/features/api/auth/auth.module-definition.ts,src/features/api/auth/auth.module.ts,src/features/api/auth/auth.options.ts,src/features/api/auth/index.ts,src/features/api/auth/transport/http/auth-http.module-definition.ts,src/features/api/auth/transport/http/auth-http.module.ts,src/features/api/auth/transport/http/auth-http.options.ts,src/features/api/auth/transport/http/auth.controller.ts,src/features/api/auth/transport/http/dto/forgot.request.ts,src/features/api/auth/transport/http/dto/google-start.request.ts,src/features/api/auth/transport/http/dto/login.request.ts,src/features/api/auth/transport/http/dto/resend.request.ts,src/features/api/auth/transport/http/dto/reset.request.ts,src/features/api/auth/transport/http/dto/signup.request.ts,src/features/api/auth/transport/http/dto/token.request.ts}"
2023
2076
  presence: optional
2024
2077
  tracked: tracked
2025
2078
  tier: inherit
@@ -2028,6 +2081,25 @@ slots:
2028
2081
  managedBy: auth-lite
2029
2082
  managedOnly: true # lists files sync renders; the slot owning the path judges them
2030
2083
  rules: [HFS_MANAGED_FILE_DRIFT]
2084
+ - id: be.integrations.mail
2085
+ profiles: [be]
2086
+ editions: [lite]
2087
+ exemplifiedBy:
2088
+ - packages/hfs/templates/be/skeleton-lite/src/modules/integrations/mail/
2089
+ # The glue of @starci/mail, the owner of the SMTP client (nodemailer): this folder parses the settings (the server, the sender and the
2090
+ # link lifetimes of the confirmation and reset mails), registers the module and names the injectors; no app file imports nodemailer,
2091
+ # and no other module imports the package. The words and layout of each mail belong to the domain that sends it.
2092
+ path: "src/modules/integrations/mail/"
2093
+ presence: required
2094
+ tracked: tracked
2095
+ tier: integrations
2096
+ owner: true
2097
+ requires: [index.ts, mail.config.ts, mail.options.ts, mail.decorators.ts, mail.module-definition.ts, mail.module.ts]
2098
+ allows: [index.ts, mail.config.ts, mail.options.ts, mail.decorators.ts, mail.module-definition.ts, mail.module.ts, errors/]
2099
+ tests: unit-beside
2100
+ rules: [BE_TIER_DIRECTION, BE_ERROR_HOME, BE_SECRET_DEFAULT, BE_INFRA_OWNER]
2101
+ coverage: required
2102
+ since: 5.3.0
2031
2103
  - id: be.integrations.object-storage
2032
2104
  profiles: [be]
2033
2105
  exemplifiedBy:
@@ -2047,6 +2119,25 @@ slots:
2047
2119
  rules: [BE_TIER_DIRECTION, BE_ERROR_HOME, BE_SECRET_DEFAULT, BE_INFRA_OWNER, BE_STORAGE_OWNER]
2048
2120
  coverage: required
2049
2121
  since: 3.9.0
2122
+ - id: be.integrations.keycloak
2123
+ profiles: [be]
2124
+ editions: [full]
2125
+ # The glue of @starci/auth-oidc, the OIDC client of the full edition's identity provider (Keycloak): discovery, the provider's JWKS,
2126
+ # algorithm-pinned token checks and the authorization code flow with PKCE and nonce behind the PrincipalVerifier of @starci/auth.
2127
+ # This folder parses the typed options, hands the package the app's bounded HTTP client and clock, and names the injectors;
2128
+ # no app file imports jose, and no other module imports the package (ruleParams.be.infraOwners).
2129
+ path: "src/modules/integrations/keycloak/"
2130
+ presence: optional
2131
+ tracked: tracked
2132
+ tier: integrations
2133
+ owner: true
2134
+ requires: [index.ts, keycloak.config.ts, keycloak.options.ts, keycloak.decorators.ts, keycloak.registration.ts]
2135
+ allows: [index.ts, keycloak.config.ts, keycloak.options.ts, keycloak.decorators.ts, keycloak.registration.ts, errors/, messages/]
2136
+ tests: unit-beside
2137
+ rules: [BE_TIER_DIRECTION, BE_ERROR_HOME, BE_SECRET_DEFAULT, BE_INFRA_OWNER, BE_AUTH_PASSPORT_OWNER, BE_INTEGRATION_SPEC_MISSING]
2138
+ coverage: required
2139
+ since: 3.17.0
2140
+
2050
2141
 
2051
2142
  # ----- BE: tests (owner test layout 2026-09-30). Unit specs are <name>.service.spec.ts beside their service and nothing else; everything else
2052
2143
  # lives under src/tests/, one folder per kind, and the folder and the file suffix always agree (a file whose suffix does
@@ -2520,9 +2611,9 @@ slots:
2520
2611
  presence: required
2521
2612
  tracked: tracked
2522
2613
  tier: foundation
2523
- # The app's routing, request config and catalogs, or its adapter to the shared i18n factory. A single app
2524
- # owns createNavigation in fe.modules.routes, keeping this public entry safe for its proxy. Shared stacks
2525
- # belong to fe.package.i18n and expose a routing-only proxy entry separately from navigation.
2614
+ # The app's description of its languages (defineI18n of @starci/next-i18n), its request config and catalogs, or its adapter to the
2615
+ # shared i18n workspace package. A single app builds its navigation (createI18nNavigation) in fe.modules.routes, keeping this
2616
+ # public entry safe for its proxy. A description shared by several apps belongs to fe.package.i18n.
2526
2617
  requires: [index.ts]
2527
2618
  tests: none
2528
2619
  rules: [FE_I18N_PLACEMENT, FE_I18N_CATALOG]
@@ -2592,7 +2683,7 @@ slots:
2592
2683
  editions: [lite]
2593
2684
  editionWhy: "lite signs people in with the package @starci/auth, so its auth client is rendered, not written (owner ruling auth-package); full wires its identity provider."
2594
2685
  # The AuthClient every edition shares, implemented by one @starci/auth/client instance over the app's own back-end auth doors.
2595
- path: "apps/web/src/modules/auth/client.ts"
2686
+ path: "apps/web/src/modules/auth/{client.ts,email-doors.ts,index.ts}"
2596
2687
  presence: optional
2597
2688
  tracked: tracked
2598
2689
  tier: inherit
@@ -2674,7 +2765,7 @@ slots:
2674
2765
  owner: true
2675
2766
  requires: [package.json, src/index.ts, tsconfig.json]
2676
2767
  tests: none
2677
- why: the next-intl stack (routing, navigation, request config) written once, exported as the createAppI18n factory every app calls
2768
+ why: the product's languages (defineI18n of @starci/next-i18n) written once and composed into the createAppI18n factory every app calls; the next-intl stack itself is the package's
2678
2769
  rules: [FE_I18N_PLACEMENT, FE_PACKAGE_SHAPE, HFS_UNUSED_EXPORT]
2679
2770
 
2680
2771
  # Checks that read this manifest (all ship from .claude; none keeps its own path list)
@@ -3,7 +3,8 @@ import path from 'node:path';
3
3
  import { slash } from '../../lib/path-key.mjs';
4
4
  import { readJsonFile as readJson } from '../../lib/json.mjs';
5
5
  import { isInside } from '../../lib/walk.mjs';
6
- import { openHfs } from '../slots.mjs';
6
+ import { createSlotResolver, openHfs, readRepoDeclaration } from '../slots.mjs';
7
+ import { ROOT_PACKAGE_SLOTS, rootPackageRecord, rootWorkspaceDirectories } from './root-packages.mjs';
7
8
  import { byCodeUnit } from '../../lib/list.mjs';
8
9
  import { grammarContractFor } from '../../lib/grammar-lint.mjs';
9
10
  /** A file's identity: its resolved real path, or the resolved path when it does not exist. */
@@ -182,6 +183,28 @@ function workspaceDirectories(root, { packageRoot: appPackageRoot = root, side =
182
183
  }
183
184
  return [...directories].sort(byCodeUnit);
184
185
  }
186
+ /**
187
+ * The workspace packages of the app root that no side owns: the `workspaces` patterns of the app's one package.json that do not start
188
+ * with a side folder (`packages/*`), and the directories the slots `app.packages` and `app.module-registry` place under `packages/`
189
+ * even when the manifest forgot to list them. A side imports them as first-party packages (through their entry points), never as
190
+ * "outside the side". A side of the app is judged here; the app root itself already counts them among its workspaces.
191
+ */
192
+ function rootPackageRecords(root, packageRoot, side, opened) {
193
+ if (side === null)
194
+ return [];
195
+ const candidates = new Set(rootWorkspaceDirectories(packageRoot));
196
+ if (opened.manifest && existingDirectory(packageRoot, 'packages')) {
197
+ const resolver = createSlotResolver(opened.manifest, readRepoDeclaration(opened.manifest, packageRoot));
198
+ const container = path.join(packageRoot, 'packages');
199
+ for (const entry of fs.readdirSync(container, { withFileTypes: true }).filter(item => item.isDirectory())) {
200
+ const slot = resolver.classifyPath(`packages/${entry.name}`).slot;
201
+ if (slot !== undefined && ROOT_PACKAGE_SLOTS.has(slot))
202
+ candidates.add(path.join(container, entry.name));
203
+ }
204
+ }
205
+ return [...candidates].sort(byCodeUnit).filter(candidate => !isInside(root, candidate))
206
+ .flatMap(candidate => rootPackageRecord(packageRoot, candidate, canonical) ?? []);
207
+ }
185
208
  function discoveredProjects(root, workspaces) {
186
209
  const projects = [];
187
210
  if (fs.existsSync(path.join(root, 'tsconfig.json')))
@@ -373,6 +396,7 @@ export function loadArchitectureConfig(repositoryRoot, { hfs } = {}) {
373
396
  kinds,
374
397
  projects,
375
398
  workspaces,
399
+ rootPackages: rootPackageRecords(root, packageRoot, side, opened),
376
400
  apps: apps.map(app => ({ name: app.name, kind: app.kind })),
377
401
  hfs: opened,
378
402
  owners: derivedOwners(root, opened),
@@ -11,7 +11,7 @@ import { trimTrailingSlashes } from '../trailing-slashes.mjs';
11
11
  *
12
12
  * - a `*.module.ts` file below a transport folder other than `<f>-<protocol>.module.ts`, a `*.module-definition.ts` that is
13
13
  * not the sibling of the application module or of the transport module of its own folder, and an `@Module` class (or a
14
- * `ConfigurableModuleBuilder`/`ConfigurableModuleClass` use) in a feature file that is neither the application module, the
14
+ * `ConfigurableModuleBuilder`/`ConfigurableModuleClass` use or a `defineConfigurableModule`/`defineCapabilityModule` call of `@starci/nest-kit`) in a feature file that is neither the application module, the
15
15
  * transport module of its own folder nor that module's definition are refused;
16
16
  * - apps import only transport modules, of the kinds their app kind allows: the `composedBy` list of each transport slot
17
17
  * in knowledge/hfs/slots.yaml (api: graphql, http, websocket; worker: schedule, message). An app root (app.module.ts
@@ -99,6 +99,8 @@ function reportModuleClassLocation(file, name, place, composedModuleOf, report)
99
99
  if (file.rel !== own)
100
100
  report(file, name, `${name.text} is a Nest module declared in ${file.rel}; a feature declares exactly its application module (${place.feature}.module.ts) and one module per transport folder (${place.feature}-<protocol>.module.ts), never a module per operation.`, { feature: place.feature });
101
101
  }
102
+ /** The module-definition helpers of @starci/nest-kit: each builds the definition of one module, like `ConfigurableModuleBuilder`. */
103
+ const DEFINITION_HELPERS = ['defineConfigurableModule', 'defineCapabilityModule'];
102
104
  function reportConfigurableModuleUses(file, checker, kit, ts, place, env) {
103
105
  const own = ownModuleOf(place, file.rel, env.composedModuleOf);
104
106
  const isDefinition = file.rel === definitionOf(own);
@@ -106,6 +108,9 @@ function reportConfigurableModuleUses(file, checker, kit, ts, place, env) {
106
108
  if (ts.isNewExpression(node) && kit.isImportOf(checker, node.expression, 'ConfigurableModuleBuilder', '@nestjs/common') && !isDefinition) {
107
109
  env.report(file, node, `ConfigurableModuleBuilder builds the module definition of one module; in feature ${place.feature} it is written only in ${path.posix.basename(definitionOf(own))}, beside ${path.posix.basename(own)}.`, { feature: place.feature });
108
110
  }
111
+ else if (ts.isCallExpression(node) && !isDefinition && DEFINITION_HELPERS.some(name => kit.isImportOf(checker, node.expression, name, '@starci/nest-kit'))) {
112
+ env.report(file, node, `${node.expression.getText(file.sourceFile)} builds the module definition of one module; in feature ${place.feature} it is written only in ${path.posix.basename(definitionOf(own))}, beside ${path.posix.basename(own)}.`, { feature: place.feature });
113
+ }
109
114
  else if (ts.isHeritageClause(node) && file.rel !== own) {
110
115
  for (const type of node.types)
111
116
  if (kit.isImportOf(checker, type.expression, 'ConfigurableModuleClass', '@nestjs/common')) {
@@ -0,0 +1,78 @@
1
+ // root-packages.mjs - the workspace packages of an app ROOT (packages/agent-sdk, packages/module-<name>, the managed packages/modules).
2
+ // They lie outside both side folders and belong to no side; both sides import them by package name, through their package entry points
3
+ // (the `exports` of the manifest), so the import graph reads them as first-party packages and the back-end canon never reads them as
4
+ // a third-party library. The app root's one package.json lists them in `workspaces`; the patterns that start with a side folder
5
+ // (`fe/apps/*`) are that side's own workspaces and are not read here.
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { readJsonFile as readJson } from '../../lib/json.mjs';
9
+ import { byCodeUnit } from '../../lib/list.mjs';
10
+ import { isInside } from '../../lib/walk.mjs';
11
+ import { slash } from '../../lib/path-key.mjs';
12
+ /** The slots whose directories at the app root are first-party packages of the app (agent SDK, module packages, the managed registry). */
13
+ export const ROOT_PACKAGE_SLOTS = new Set(['app.packages', 'app.module-registry']);
14
+ const SIDE_PREFIX = /^(?:be|fe)\//;
15
+ /** A workspace pattern this reader expands (a literal path, or `*` as a whole segment); the others are judged by the workspace rules. */
16
+ const expandablePattern = (written) => !path.isAbsolute(written) && written.split('/').every(segment => segment !== '..' && (!segment.includes('*') || segment === '*'));
17
+ function expandSegment(base, segment) {
18
+ if (segment !== '*')
19
+ return [path.join(base, segment)];
20
+ if (!fs.existsSync(base) || !fs.lstatSync(base).isDirectory())
21
+ return [];
22
+ return fs.readdirSync(base, { withFileTypes: true }).filter(item => item.isDirectory()).sort((a, b) => byCodeUnit(a.name, b.name)).map(item => path.join(base, item.name));
23
+ }
24
+ /** A package folder with a regular package.json (a link is not one). */
25
+ export function packageDirectory(absolute) {
26
+ try {
27
+ if (!fs.lstatSync(absolute).isDirectory())
28
+ return false;
29
+ const manifest = fs.lstatSync(path.join(absolute, 'package.json'));
30
+ return manifest.isFile() && !manifest.isSymbolicLink();
31
+ }
32
+ catch {
33
+ return false;
34
+ }
35
+ }
36
+ /** The folders the `workspaces` of the app root's package.json name that no side owns, as absolute paths in code-unit order. */
37
+ export function rootWorkspaceDirectories(packageRoot) {
38
+ const manifest = readJson(path.join(packageRoot, 'package.json'));
39
+ const declared = manifest?.workspaces;
40
+ const patterns = Array.isArray(declared) ? declared : declared?.packages;
41
+ const found = new Set();
42
+ for (const pattern of Array.isArray(patterns) ? patterns : []) {
43
+ if (typeof pattern !== 'string' || !pattern.trim())
44
+ continue;
45
+ const written = slash(pattern.trim()).replace(/^\.\//, '');
46
+ if (SIDE_PREFIX.test(written) || !expandablePattern(written))
47
+ continue;
48
+ let bases = [packageRoot];
49
+ for (const segment of written.split('/'))
50
+ bases = bases.flatMap(base => expandSegment(base, segment));
51
+ for (const base of bases)
52
+ found.add(path.resolve(base));
53
+ }
54
+ return [...found].sort(byCodeUnit);
55
+ }
56
+ /**
57
+ * The record of one package folder of the app root, or null when it is no named package inside the app root.
58
+ * `canonical` is the file-identity function of the caller (the real path), so a record agrees with the files the graph reads.
59
+ */
60
+ export function rootPackageRecord(packageRoot, directory, canonical) {
61
+ if (!packageDirectory(directory) || !isInside(packageRoot, directory))
62
+ return null;
63
+ const manifest = readJson(path.join(directory, 'package.json'));
64
+ const name = manifest?.['name'];
65
+ if (manifest === null || typeof name !== 'string' || !name)
66
+ return null;
67
+ return { root: canonical(directory), relative: slash(path.relative(packageRoot, directory)), name, exports: manifest['exports'], manifest, workspace: true, app: false };
68
+ }
69
+ /** The names of the workspace packages of the app root: what the back-end canon must not read as third-party libraries. */
70
+ export function rootPackageNames(packageRoot) {
71
+ const names = new Set();
72
+ for (const directory of rootWorkspaceDirectories(packageRoot)) {
73
+ const record = rootPackageRecord(packageRoot, directory, (file) => file);
74
+ if (record !== null)
75
+ names.add(record.name);
76
+ }
77
+ return names;
78
+ }
@@ -241,8 +241,38 @@ function appendEdge(from, sourceFile, project, owningWorkspace, reference, actua
241
241
  errors.push(helpers.boundaryViolation(config.root, edge, owningWorkspace, targetWorkspace, 'ARCH_PACKAGE_EXPORT_BYPASS', 'Cross-package imports must use the target package name and a declared package export.'));
242
242
  }
243
243
  }
244
+ /**
245
+ * An import of one of the app's root workspace packages (`@scope/agent-sdk`, `@scope/agent-sdk/workbench`). The package lies outside the side
246
+ * folder, yet it is first-party source both sides may import: through its package entry points (the `exports` of its manifest) and never past
247
+ * them. An entry point is no import that leaves the side and adds no edge (the package is not part of this side's graph; the package's own
248
+ * lint judges its files); a path into the package's source is refused. Returns true when the reference was settled here.
249
+ */
250
+ function rootPackageReference(sourceFile, project, reference, state) {
251
+ const { config, errors, helpers, host, ts } = state;
252
+ if (!config.rootPackages?.length)
253
+ return false;
254
+ const item = helpers.rootPackageOf(config, reference.specifier);
255
+ if (item === null)
256
+ return false;
257
+ if (!helpers.rootPackageEntryDeclared(item, reference.specifier)) {
258
+ errors.push({
259
+ ruleId: 'ARCH_INTERNAL_IMPORT_OUTSIDE',
260
+ project: project.relative,
261
+ path: helpers.relativePath(config.root, sourceFile.fileName),
262
+ ...helpers.sourceLocation(sourceFile, reference.node),
263
+ specifier: reference.specifier,
264
+ message: `Internal import ${reference.specifier} reaches into the package ${item.name} past its package entry points; import ${helpers.rootPackageEntries(item).join(', ')} instead.`,
265
+ });
266
+ return true;
267
+ }
268
+ // An entry point that a path alias sends outside the package is no entry point: the ordinary boundary judges it.
269
+ const resolved = helpers.resolveTypeScriptModule(ts, reference.specifier, sourceFile.fileName, project.options, host, resolutionCache(state, project));
270
+ return resolved === undefined || helpers.isInside(item.root, helpers.canonical(resolved));
271
+ }
244
272
  function processReference(from, sourceFile, project, owningWorkspace, reference, state) {
245
273
  const { config, ts, workspaceNames, workspaces, host, errors, helpers } = state;
274
+ if (rootPackageReference(sourceFile, project, reference, state))
275
+ return;
246
276
  // A workspace package is read at its source, never at its build: the same import resolves whether or not dist exists.
247
277
  const workspaceTarget = workspaces.filter(item => item.root !== helpers.canonical(config.root)).map(item => helpers.workspaceSourceEntry(ts, item, reference.specifier)).find(Boolean);
248
278
  const resolvedName = workspaceTarget ?? helpers.resolveTypeScriptModule(ts, reference.specifier, sourceFile.fileName, project.options, host, resolutionCache(state, project));
@@ -385,6 +385,26 @@ function packageExported(ts, workspace, specifier, actualTarget) {
385
385
  return expected === canonical(actualTarget) || sourceOfTarget(ts, workspace, target) === canonical(actualTarget);
386
386
  });
387
387
  }
388
+ /** The root workspace package of the app (`packages/agent-sdk`) a specifier names, its name or a subpath of it; the longest name when several match. */
389
+ export function rootPackageOf(config, specifier) {
390
+ let found = null;
391
+ for (const item of config.rootPackages ?? [])
392
+ if (sameOrUnder(specifier, item.name) && (found === null || item.name.length > found.name.length))
393
+ found = item;
394
+ return found;
395
+ }
396
+ /** Whether a specifier into a root package names one of its entry points: a subpath its `exports` declare, or its root through `exports`, `main`/`types` or `src/index`. A path into its source is not an entry point. */
397
+ export function rootPackageEntryDeclared(item, specifier) {
398
+ if (exportCandidates(item, specifier).length > 0)
399
+ return true;
400
+ return specifier === item.name && !item.exports && (manifestEntryTargets(item.manifest).length > 0 || packageIndexSource(item) !== null);
401
+ }
402
+ /** The subpaths a root package declares for import (`.` first), for the message of a refused deep import. */
403
+ export function rootPackageEntries(item) {
404
+ const declaration = item.exports;
405
+ const keys = declaration && typeof declaration === 'object' && !Array.isArray(declaration) ? Object.keys(declaration).filter(key => key.startsWith('.')) : ['.'];
406
+ return (keys.length ? keys : ['.']).map(key => (key === '.' ? item.name : `${item.name}${key.slice(1)}`));
407
+ }
388
408
  function boundaryViolation(root, edge, fromWorkspace, toWorkspace, ruleId, message) {
389
409
  return {
390
410
  ruleId,
@@ -413,7 +433,7 @@ function typeScriptContext(config, loaded, paths) {
413
433
  canonical, compilerError, isInside, slash, readTypeScriptProject, typeScriptProjectReferencePath,
414
434
  sameOrUnder, createTypeScriptProgram, isProductionSource, sourceLocation, workspaceMetadata, workspaceOf,
415
435
  pathAliasMatches, moduleReferences, workspaceSourceEntry, resolveTypeScriptModule, crossesSide, assetExtension: ASSET_EXTENSION,
416
- relativePath, packageExported, boundaryViolation,
436
+ relativePath, packageExported, boundaryViolation, rootPackageOf, rootPackageEntryDeclared, rootPackageEntries,
417
437
  });
418
438
  }
419
439
  export function relativePath(root, fileName) {
@@ -6,7 +6,7 @@ const HINTS = {
6
6
  'parameter type': 'Give the parameter a type: `(value: Input)`.',
7
7
  'property type': 'Give the property a type: `name: Result = ...`.',
8
8
  'accessor type': 'Give the accessor a type: `get name(): Result` (a setter takes it from its parameter).',
9
- 'destructured export': 'A destructuring pattern cannot carry a type per name: export each name as its own variable with a type, `export const name: Type = source.name`.',
9
+ 'destructured export': 'A destructuring pattern cannot carry a type per name: export each name as its own variable with a type, `export const name: Type = source.name`; a module definition is `export const { ConfigurableModuleClass, OPTIONS_TYPE } = defineCapabilityModule<XOptions>()` (or `defineConfigurableModule`) of @starci/nest-kit, which states the types (`starci app codemod module-definition` writes it).',
10
10
  'exported value': 'Declare the type (an interface or a type alias) and annotate the export with it: `export const name: Shape = ...`.',
11
11
  };
12
12
  /** The `--isolatedDeclarations` flag name TypeScript ends its reasons with says nothing to the reader. */
@@ -5,6 +5,7 @@
5
5
  import path from 'node:path';
6
6
  import { allowsFile } from './allows.mjs';
7
7
  import { openHfs } from './slots.mjs';
8
+ import { ROOT_PACKAGE_SLOTS } from './architecture/root-packages.mjs';
8
9
  const posix = (p) => String(p).replaceAll('\\', '/');
9
10
  const isAbsoluteAny = (text) => path.isAbsolute(text) || /^[A-Za-z]:\//.test(text);
10
11
  /**
@@ -69,3 +70,70 @@ export const openHfsView = ({ runtimeRoot, repoRoot }) => hfsView(openHfs({ root
69
70
  * @returns {object} The frozen view.
70
71
  */
71
72
  export const declaredHfsView = ({ runtimeRoot, declaration, repoRoot, side }) => hfsView(openHfs({ root: runtimeRoot, declaration, side }), repoRoot);
73
+ /**
74
+ * The view of the root workspace packages of an app (`packages/<pkg>/src/**`, slots app.packages and app.module-registry), as the lint of one
75
+ * side sees them: the rule parameters, the apps and the Grammar of `side` (a package is judged by the laws of the side whose subpath a file
76
+ * sits in), and the paths of the APP ROOT (`repoRoot` is the app root, not a side folder). The slots answer for the root paths; a file of a
77
+ * package is of the tier `package` and has no owner: the owner laws (one `index.ts` per owner root) are the module folders', a package's public
78
+ * surface being the `exports` of its manifest.
79
+ *
80
+ * @param {object} opened - The result of `openHfs` for the app root (profile app, both sides resolved).
81
+ * @param {string} repoRoot - The absolute app root.
82
+ * @param {string} side - `be` or `fe`: the side whose laws the file is judged by.
83
+ * @returns {object} The frozen view: the profile of the side over the paths of the app root.
84
+ */
85
+ function packagesView(opened, repoRoot, side) {
86
+ const scope = opened.sides?.[side];
87
+ const sideRepo = opened.repo.sides?.[side];
88
+ if (scope === undefined || sideRepo === undefined)
89
+ throw new Error('the view of the root packages needs the declaration of an app with both sides');
90
+ const rel = (file) => {
91
+ const text = posix(file);
92
+ return isAbsoluteAny(text) ? posix(path.relative(repoRoot, file)) : text.replace(/^\.\//, '');
93
+ };
94
+ const cache = new Map();
95
+ const classify = (file) => {
96
+ const key = rel(file);
97
+ if (!cache.has(key))
98
+ cache.set(key, opened.classifyPath(key));
99
+ return cache.get(key);
100
+ };
101
+ const packageOf = (file) => {
102
+ const slot = classify(file).slot;
103
+ const relative = rel(file);
104
+ return slot !== undefined && ROOT_PACKAGE_SLOTS.has(slot) ? relative.split('/').slice(0, 2).join('/') : null;
105
+ };
106
+ return Object.freeze({
107
+ repoRoot,
108
+ profile: side,
109
+ side,
110
+ edition: opened.repo.edition ?? 'full',
111
+ ...(sideRepo.grammar === undefined ? {} : { grammar: sideRepo.grammar }),
112
+ apps: sideRepo.apps,
113
+ connections: sideRepo.connections,
114
+ patterns: Object.freeze([...(sideRepo.patterns ?? [])]),
115
+ ruleParams: scope.ruleParams(),
116
+ relative: rel,
117
+ classify,
118
+ slotOf: (file) => classify(file).slot ?? null,
119
+ tierOf: (file) => (packageOf(file) === null ? null : 'package'),
120
+ // A package is no owner of the module laws (one index.ts at the owner root): its public surface is the `exports` of its manifest, so no file of it has an owner.
121
+ ownerOf: () => null,
122
+ allows: (file) => allowsFile(opened, rel(file)),
123
+ slot: (id) => opened.slot(id),
124
+ });
125
+ }
126
+ /**
127
+ * The view of the root packages of the app at `repoRoot` (the app root, where the root `eslint.config.mjs` lives), for the laws of `side`.
128
+ *
129
+ * @param {{ runtimeRoot: string, repoRoot: string, side: string }} input - Where the manifest copy lives, the app root, and the side whose laws apply.
130
+ * @returns {object} The frozen view.
131
+ */
132
+ export const openPackagesHfsView = ({ runtimeRoot, repoRoot, side }) => packagesView(openHfs({ root: runtimeRoot, repoRoot }), repoRoot, side);
133
+ /**
134
+ * The view of the root packages of an in-memory app declaration (rule tests): no file is read except the manifest.
135
+ *
136
+ * @param {{ runtimeRoot: string, declaration: object, repoRoot: string, side: string }} input - Manifest root, the app hfs.json object, the app root and the side.
137
+ * @returns {object} The frozen view.
138
+ */
139
+ export const declaredPackagesHfsView = ({ runtimeRoot, declaration, repoRoot, side }) => packagesView(openHfs({ root: runtimeRoot, declaration }), repoRoot, side);
@@ -55,6 +55,52 @@ function returnsOnlyJsx(ts, source, start) {
55
55
  ts.forEachChild(fn.body, visit);
56
56
  return only;
57
57
  }
58
+ /** The package whose module-definition helpers return a type they state in their own declaration (R368 accepts a destructured export of their call). */
59
+ export const STATED_SURFACE_PACKAGE = '@starci/nest-kit';
60
+ /** The helpers of that package: each is called with the options type as a type argument and returns the explicitly typed definition. */
61
+ export const STATED_SURFACE_HELPERS = new Set(['defineConfigurableModule', 'defineCapabilityModule']);
62
+ /** The local names under which `source` imports a stated-surface helper from its package (`import { defineConfigurableModule as define }` gives `define`). */
63
+ function statedHelperNames(ts, source) {
64
+ const names = new Set();
65
+ for (const statement of source.statements) {
66
+ if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier) || statement.moduleSpecifier.text !== STATED_SURFACE_PACKAGE)
67
+ continue;
68
+ const bindings = statement.importClause?.namedBindings;
69
+ if (bindings === undefined || !ts.isNamedImports(bindings))
70
+ continue;
71
+ for (const element of bindings.elements)
72
+ if (STATED_SURFACE_HELPERS.has((element.propertyName ?? element.name).text))
73
+ names.add(element.name.text);
74
+ }
75
+ return names;
76
+ }
77
+ /**
78
+ * Whether the destructured export the diagnostic at `start` points at is `export const { A, B } = helper<Options>()` for a stated-surface helper:
79
+ * each name has the type the helper's declaration gives it, written there, so the file states its surface by naming the helper and the options
80
+ * type, exactly as `export const A: Host["A"] = built.A` states it through `Host`.
81
+ */
82
+ function statesSurfaceThroughHelper(ts, source, start) {
83
+ const helpers = statedHelperNames(ts, source);
84
+ if (helpers.size === 0)
85
+ return null;
86
+ let innermost = source;
87
+ const descend = (node) => {
88
+ if (start < node.getFullStart() || start >= node.end)
89
+ return;
90
+ innermost = node;
91
+ ts.forEachChild(node, descend);
92
+ };
93
+ ts.forEachChild(source, descend);
94
+ for (let node = innermost; node !== undefined && node !== source; node = node.parent) {
95
+ if (!ts.isVariableDeclaration(node))
96
+ continue;
97
+ const call = node.initializer;
98
+ const stated = ts.isObjectBindingPattern(node.name) && call !== undefined && ts.isCallExpression(call) && ts.isIdentifier(call.expression) && helpers.has(call.expression.text)
99
+ && (call.typeArguments?.length ?? 0) > 0 && node.name.elements.every((element) => ts.isIdentifier(element.name) && element.dotDotDotToken === undefined);
100
+ return stated && call !== undefined ? call.getText(source) : null;
101
+ }
102
+ return null;
103
+ }
58
104
  /** What to annotate for each isolated-declaration diagnostic code; any other code asks for the exported value's type. */
59
105
  const ANNOTATE = new Map([
60
106
  [9007, 'return type'], [9008, 'return type'], [9009, 'accessor type'], [9010, 'variable type'], [9011, 'parameter type'], [9012, 'property type'],
@@ -98,7 +144,14 @@ export function declarationSurface(ts, file, text, module) {
98
144
  return { outputText: declared.outputText, problems: [] };
99
145
  const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, file.endsWith('x') ? ts.ScriptKind.TSX : ts.ScriptKind.TS);
100
146
  const problems = [];
147
+ // The emit writes `any` for a destructured export; the call that states its type joins the digest, so a change of the options type reaches the importers.
148
+ const stated = new Set();
101
149
  for (const diagnostic of diagnostics) {
150
+ const call = diagnostic.code === 9019 && diagnostic.start !== undefined ? statesSurfaceThroughHelper(ts, source, diagnostic.start) : null;
151
+ if (call !== null) {
152
+ stated.add(call);
153
+ continue;
154
+ }
102
155
  const exempt = (diagnostic.code === 9007 || diagnostic.code === 9008) && diagnostic.start !== undefined && returnsOnlyJsx(ts, source, diagnostic.start);
103
156
  if (exempt)
104
157
  continue;
@@ -108,5 +161,6 @@ export function declarationSurface(ts, file, text, module) {
108
161
  reason: ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n').split('\n')[0] ?? '',
109
162
  });
110
163
  }
111
- return { outputText: declared.outputText, problems };
164
+ const outputText = stated.size === 0 ? declared.outputText : `${declared.outputText}${[...stated].sort().map((call) => `// stated by ${call}\n`).join('')}`;
165
+ return { outputText, problems };
112
166
  }