@starci/eslint-canon-fe 9.11.0 → 9.12.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 +6 -0
- package/dist/index.d.ts +14 -1
- package/dist/index.js +18 -2
- package/dist/lib/config.d.ts +13 -0
- package/dist/lib/config.js +18 -0
- package/dist/lib/hfs.d.ts +16 -0
- package/dist/lib/hfs.js +17 -1
- package/dist/lib/why.js +2 -2
- package/dist/next-conventions.d.ts +4 -4
- package/dist/next-conventions.js +9 -24
- package/docs/next-conventions.md +8 -8
- package/package.json +1 -1
- package/runtime/bundle.json +1 -0
- package/runtime/knowledge/hfs/canon-pins.yaml +43 -10
- package/runtime/knowledge/hfs/fe-stack.yaml +3 -3
- package/runtime/knowledge/hfs/slots.yaml +101 -10
- package/runtime/scripts/hfs/architecture/config.mjs +25 -1
- package/runtime/scripts/hfs/architecture/module-per-transport.mjs +6 -1
- package/runtime/scripts/hfs/architecture/root-packages.mjs +78 -0
- package/runtime/scripts/hfs/architecture/type-context.mjs +30 -0
- package/runtime/scripts/hfs/architecture/typescript.mjs +21 -1
- package/runtime/scripts/hfs/explicit-surface-rule.mjs +1 -1
- package/runtime/scripts/hfs/view.mjs +68 -0
- package/runtime/scripts/lib/declaration-surface.mjs +55 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 9.12.0 - 2026-11-03
|
|
4
|
+
|
|
5
|
+
- 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).
|
|
6
|
+
- 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.
|
|
7
|
+
- Changed: the runtime copy of the slot manifest carries the slots `app.packages`, `app.module-registry` and `app.packages-lint`.
|
|
8
|
+
|
|
3
9
|
## 9.11.0 - 2026-11-03
|
|
4
10
|
|
|
5
11
|
- 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
|
package/dist/lib/config.d.ts
CHANGED
|
@@ -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[];
|
package/dist/lib/config.js
CHANGED
|
@@ -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
|
|
388
|
-
fix: "
|
|
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
|
|
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),
|
|
38
|
-
* config
|
|
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.
|
package/dist/next-conventions.js
CHANGED
|
@@ -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
|
|
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),
|
|
382
|
-
* config
|
|
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: "
|
|
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
|
|
395
|
-
proxyNavigation: "The proxy imports navigation runtime through {{source}}.
|
|
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
|
|
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
|
|
418
|
+
context.report({ node, messageId: "stack", data: { name: factory.name } });
|
|
434
419
|
},
|
|
435
420
|
};
|
|
436
421
|
},
|
package/docs/next-conventions.md
CHANGED
|
@@ -207,9 +207,7 @@ router.push(routes.newWorkspace())
|
|
|
207
207
|
|
|
208
208
|
## `starci-fe/i18n-stack-in-one-module`
|
|
209
209
|
|
|
210
|
-
The
|
|
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/
|
|
219
|
+
**Valid** (`apps/web/src/modules/i18n/request.ts`)
|
|
222
220
|
|
|
223
221
|
```ts
|
|
224
|
-
import {
|
|
225
|
-
|
|
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
|
|
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:**
|
|
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
package/runtime/bundle.json
CHANGED
|
@@ -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.
|
|
41
|
+
version: 0.21.15
|
|
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.
|
|
55
|
+
version: 3.14.0
|
|
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.
|
|
62
|
+
version: 9.12.0
|
|
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.
|
|
69
|
+
version: 0.21.15
|
|
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.
|
|
76
|
+
version: 0.21.15
|
|
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.
|
|
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.
|
|
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/
|
|
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.0
|
|
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.0
|
|
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.
|
|
158
|
+
version: 5.3.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
|
|
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
|
|
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
|
|
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/
|
|
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
|
|
2524
|
-
#
|
|
2525
|
-
#
|
|
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
|
|
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
|
-
|
|
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
|
}
|