@starci/eslint-canon-fe 7.1.2 → 8.0.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 +11 -0
- package/client-boundary.mjs +70 -0
- package/docs/client-boundary.md +24 -0
- package/lib/config.mjs +1 -1
- package/lib/hfs.mjs +7 -6
- package/lib/next.mjs +2 -2
- package/lib/why.mjs +12 -2
- package/package.json +1 -1
- package/props-and-slots.mjs +120 -0
- package/runtime/knowledge/hfs/canon-pins.yaml +28 -9
- package/runtime/knowledge/hfs/slots.yaml +193 -128
- package/runtime/knowledge/patterns/fe/folder.yaml +36 -36
- package/runtime/packages/hfs/templates/app/package-scripts/package.json +22 -0
- package/runtime/scripts/checks/architecture/config.mjs +31 -11
- package/runtime/scripts/checks/architecture/contracts.mjs +4 -4
- package/runtime/scripts/checks/architecture/fe-slot-allows.mjs +7 -3
- package/runtime/scripts/checks/architecture/framework-pinned.mjs +5 -47
- package/runtime/scripts/checks/architecture/frontend.mjs +6 -4
- package/runtime/scripts/checks/architecture/hfs.mjs +104 -66
- package/runtime/scripts/checks/architecture/next-data.mjs +3 -2
- package/runtime/scripts/checks/architecture/registration.mjs +1 -1
- package/runtime/scripts/checks/architecture/symbols.mjs +13 -2
- package/runtime/scripts/checks/architecture/test-world-files.mjs +83 -45
- package/runtime/scripts/checks/architecture/typescript.mjs +45 -20
- package/runtime/scripts/checks/typescript-programs.mjs +2 -2
- package/runtime/scripts/lib/hfs-path-findings.mjs +13 -2
- package/runtime/scripts/lib/hfs-slots.mjs +244 -61
- package/runtime/scripts/lib/hfs-view.mjs +9 -7
- package/runtime/scripts/lib/language.mjs +11 -1
- package/runtime/scripts/lib/next-contract.mjs +2 -2
- package/transport.mjs +3 -3
- package/runtime/packages/hfs/templates/be/package-scripts/package.json +0 -19
- package/runtime/packages/hfs/templates/fe/package-scripts/package.json +0 -13
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 8.0.1 - 2026-10-01
|
|
4
|
+
|
|
5
|
+
- Changed: the bundled runtime copy of knowledge/hfs/canon-pins.yaml pins @starci/hfs 4.0.1 (the scaffold resolves a real lockfile) and this canon at 8.0.1. No rule changed.
|
|
6
|
+
|
|
7
|
+
## 8.0.0 - 2026-10-01
|
|
8
|
+
|
|
9
|
+
- Breaking: the app monorepo standard (hfs 4.0.0). `loadHfs(import.meta.url)` in `fe/eslint.config.mjs` finds the app-root `hfs.json` (kind `app`) one level up and gives every linted file the view of the fe side (paths relative to `fe/`); a standalone front-end `hfs.json` (`profile: fe`) is refused. The project graph (`project-graph` law) is built per side from the side folder. The config stays the managed one-liner.
|
|
10
|
+
- New (lane c0-orphan): `props-fields-readonly` (R110 `FE_PROPS_MUTABLE`, law props-and-slots). The props type of an exported rendering function in product source is readonly all the way down: every field and index signature is `readonly`, and every collection it holds, directly or in a nested object declared in the file, is `readonly T[]`, `readonly [A, B]` or `ReadonlyArray<T>`. Replaces the unrun readonly-props script adapter of the retired code-pattern folder.
|
|
11
|
+
- New (lane AUTHCHK): `response-cookie-attributes` (R107 `FE_COOKIE_ATTRIBUTES`, law client-boundary). A `set` on Next's `ResponseCookies` (`response.cookies.set`, `(await cookies()).set`), identified by the method's declaring class of `next`, must state `httpOnly` as a literal, carry `secure` and use `sameSite` `lax` or `strict`, judged on the TYPE of the options. The typed fixture's `next/server` and `next/headers` stubs gain the cookie classes.
|
|
12
|
+
- Fixed (false positives): `route-files-thin` counts a feature owner a route file mounts through the app alias `@/` (the project graph gives each file to the deepest tsconfig project that holds it, the app's own, whose paths define the alias). `alias-reexport` does not report a route file binding a declaration to a Next.js segment export (`export const generateMetadata = homeMetadata`): the framework fixes that name. Each former false positive is a valid RuleTester case.
|
|
13
|
+
|
|
3
14
|
## 7.1.2 - 2026-10-01
|
|
4
15
|
|
|
5
16
|
- Fixed: the bundled machine no longer crashes on a local export list (shared with eslint-canon-be 2.1.2).
|
package/client-boundary.mjs
CHANGED
|
@@ -272,6 +272,75 @@ export const noDangerousHtml = {
|
|
|
272
272
|
},
|
|
273
273
|
}
|
|
274
274
|
|
|
275
|
+
// -- FE-CLIENT-5 (R107 FE_COOKIE_ATTRIBUTES) -------------------------------------------------------
|
|
276
|
+
|
|
277
|
+
/** Forward-slash file name of a declaration, and the package a `node_modules/<pkg>/` (or `@scope/<pkg>`) file belongs to. */
|
|
278
|
+
const packageOfDeclaration = (declaration) => {
|
|
279
|
+
const match = /\/node_modules\/((?:@[^/]+\/)?[^/]+)\//.exec(String(declaration.getSourceFile().fileName).split("\\").join("/"))
|
|
280
|
+
return match ? match[1] : null
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Whether a call is `set` of Next's `ResponseCookies` (a `Set-Cookie` write), whatever the receiver is called: `response.cookies.set`, `(await cookies()).set`. */
|
|
284
|
+
const isResponseCookieWrite = (checker, toTs, call) => {
|
|
285
|
+
const tsCall = toTs(call)
|
|
286
|
+
const declaration = tsCall ? checker.getResolvedSignature(tsCall)?.declaration : undefined
|
|
287
|
+
if (!declaration || !ts.isMethodDeclaration(declaration) || declaration.name.getText() !== "set") return false
|
|
288
|
+
const owner = declaration.parent
|
|
289
|
+
return ts.isClassDeclaration(owner) && owner.name?.text === "ResponseCookies" && packageOfDeclaration(declaration) === "next"
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** The present (non-nullish) members of a property's type on an options type, or [] when the property is absent or only nullish. */
|
|
293
|
+
const presentPropertyParts = (checker, type, name, at) => {
|
|
294
|
+
const symbol = type.getProperty(name)
|
|
295
|
+
if (!symbol) return []
|
|
296
|
+
const propertyType = checker.getTypeOfSymbolAtLocation(symbol, at)
|
|
297
|
+
const parts = propertyType.isUnion() ? propertyType.types : [propertyType]
|
|
298
|
+
return parts.filter((part) => !(part.flags & (ts.TypeFlags.Undefined | ts.TypeFlags.Null | ts.TypeFlags.Void)))
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const isBooleanLiteral = (part, value) => Boolean(part.flags & ts.TypeFlags.BooleanLiteral) && part.intrinsicName === String(value)
|
|
302
|
+
const SAME_SITE = new Set(["lax", "strict"])
|
|
303
|
+
|
|
304
|
+
/** The attributes a written cookie is missing, judged on the TYPE of its options (a spread `as const` constant keeps its literals). */
|
|
305
|
+
const missingAttributes = (checker, type, at) => {
|
|
306
|
+
const missing = []
|
|
307
|
+
// httpOnly is a stated decision: `true` for a session, `false` only for a preference page script reads; a bare `boolean` decides nothing
|
|
308
|
+
const httpOnly = presentPropertyParts(checker, type, "httpOnly", at)
|
|
309
|
+
const stated = httpOnly.length > 0 && (httpOnly.every((part) => isBooleanLiteral(part, true)) || httpOnly.every((part) => isBooleanLiteral(part, false)))
|
|
310
|
+
if (!stated) missing.push("`httpOnly` stated as `true` or `false`")
|
|
311
|
+
const secure = presentPropertyParts(checker, type, "secure", at)
|
|
312
|
+
if (secure.length === 0 || secure.every((part) => isBooleanLiteral(part, false))) missing.push("`secure`")
|
|
313
|
+
const sameSite = presentPropertyParts(checker, type, "sameSite", at)
|
|
314
|
+
if (sameSite.length === 0 || !sameSite.every((part) => part.isStringLiteral() && SAME_SITE.has(part.value))) missing.push('`sameSite: "lax"` or `"strict"`')
|
|
315
|
+
return missing
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** A cookie the app writes states its script reach, is sent only over TLS and is kept off cross-site requests. */
|
|
319
|
+
export const responseCookieAttributes = {
|
|
320
|
+
meta: {
|
|
321
|
+
type: "problem",
|
|
322
|
+
docs: { description: "A cookie written through Next's response cookies states `httpOnly` as a literal, carries `secure` and `sameSite` `lax` or `strict`." },
|
|
323
|
+
schema: [],
|
|
324
|
+
messages: {
|
|
325
|
+
attributes:
|
|
326
|
+
"This cookie is written without {{missing}}. A cookie left to the defaults is readable by page script and sent on cross-site requests: state `httpOnly: true` for a session (`false` only for a preference page script must read), `secure` (from `modules/config`) to keep it off plain HTTP, and `sameSite` `lax` or `strict`. Pass one options constant that states all three, declared `as const` by the module that owns the cookie.",
|
|
327
|
+
},
|
|
328
|
+
},
|
|
329
|
+
create(context) {
|
|
330
|
+
const { checker, toTs } = typed(context)
|
|
331
|
+
return {
|
|
332
|
+
CallExpression(node) {
|
|
333
|
+
if (node.callee.type !== "MemberExpression" || !isResponseCookieWrite(checker, toTs, node)) return
|
|
334
|
+
// `set(name, value, options)` or `set({ name, value, ...options })`; `set(name, value)` states nothing
|
|
335
|
+
const options = node.arguments.length === 1 ? node.arguments[0] : node.arguments[2]
|
|
336
|
+
const tsOptions = options ? toTs(options) : undefined
|
|
337
|
+
const missing = tsOptions ? missingAttributes(checker, checker.getTypeAtLocation(tsOptions), tsOptions) : ["`httpOnly` stated as `true` or `false`", "`secure`", '`sameSite: "lax"` or `"strict"`']
|
|
338
|
+
if (missing.length > 0) context.report({ node, messageId: "attributes", data: { missing: missing.join(", ") } })
|
|
339
|
+
},
|
|
340
|
+
}
|
|
341
|
+
},
|
|
342
|
+
}
|
|
343
|
+
|
|
275
344
|
/** The rules this law contributes to the plugin. */
|
|
276
345
|
export const rules = {
|
|
277
346
|
"use-client-only-at-boundary": useClientOnlyAtBoundary,
|
|
@@ -279,6 +348,7 @@ export const rules = {
|
|
|
279
348
|
"server-module-marks-server-only": serverModuleMarksServerOnly,
|
|
280
349
|
"web-storage-only-in-modules": webStorageOnlyInModules,
|
|
281
350
|
"no-dangerous-html": noDangerousHtml,
|
|
351
|
+
"response-cookie-attributes": responseCookieAttributes,
|
|
282
352
|
}
|
|
283
353
|
|
|
284
354
|
/** Every rule is an error. */
|
package/docs/client-boundary.md
CHANGED
|
@@ -122,3 +122,27 @@ const draft = useTopUpDraft()
|
|
|
122
122
|
**Why:** `dangerouslySetInnerHTML` on `<tag>` in `<file>`: an arbitrary string becomes executable HTML.
|
|
123
123
|
|
|
124
124
|
**Fix:** Render content as elements; only a `<script>` carrying JSON-LD or theme code from an app constant may use it.
|
|
125
|
+
|
|
126
|
+
## `starci-fe/response-cookie-attributes`
|
|
127
|
+
|
|
128
|
+
A cookie written through Next's response cookies (`response.cookies.set`, `(await cookies()).set`, found by the method's declaring class `ResponseCookies` of `next`, never by a name) states `httpOnly` as a literal, carries `secure` and has `sameSite` `lax` or `strict`. The options are judged by their TYPE, so an `as const` constant and a spread of it (`{ ...SESSION_COOKIE_OPTIONS, maxAge: 0 }`) keep their literals (R107 `FE_COOKIE_ATTRIBUTES`).
|
|
129
|
+
|
|
130
|
+
**Invalid** (`src/app/api/session/route.ts`)
|
|
131
|
+
|
|
132
|
+
```ts
|
|
133
|
+
response.cookies.set(SESSION_COOKIE, token)
|
|
134
|
+
response.cookies.set(SESSION_COOKIE, token, { sameSite: "none", secure: true })
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
**Valid** (`src/app/api/session/route.ts`)
|
|
138
|
+
|
|
139
|
+
```ts
|
|
140
|
+
export const SESSION_COOKIE_OPTIONS = { httpOnly: true, sameSite: "lax", path: "/", secure: SESSION_COOKIE_SECURE } as const
|
|
141
|
+
response.cookies.set(SESSION_COOKIE, token, SESSION_COOKIE_OPTIONS)
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
**Finding code:** `FE_COOKIE_ATTRIBUTES`
|
|
145
|
+
|
|
146
|
+
**Why:** `<file>` writes a cookie without a literal `httpOnly`, without `secure`, or with `sameSite` other than `lax`/`strict`; a session cookie left to the defaults is readable by page script and rides cross-site requests.
|
|
147
|
+
|
|
148
|
+
**Fix:** Pass one `as const` options constant of the module that owns the cookie: `httpOnly: true` (`false` only for a preference page script reads), `secure` from `modules/config`, `sameSite` `lax` or `strict`.
|
package/lib/config.mjs
CHANGED
|
@@ -39,7 +39,7 @@ export const buildFeConfig = ({ hfs, plugin, source, reactHooks }) => {
|
|
|
39
39
|
if (!hfs || typeof hfs.slotOf !== "function" || typeof hfs.repoRoot !== "string") {
|
|
40
40
|
throw new Error("starciFeConfig needs { hfs: loadHfs(import.meta.url) } - the rules read the repository's slots through it")
|
|
41
41
|
}
|
|
42
|
-
if (hfs.profile !== "fe") throw new Error(`starciFeConfig lints
|
|
42
|
+
if (hfs.profile !== "fe") throw new Error(`starciFeConfig lints the fe side of an app (its eslint.config.mjs lives in fe/); this view is ${hfs.side ? `the ${hfs.side} side` : "the app root"}`)
|
|
43
43
|
if (Object.keys(source).length === 0) throw new Error("starciFeConfig received an empty source recommendation - a config with no rules is not adoption")
|
|
44
44
|
const weak = Object.entries(source).filter(([, setting]) => levelOf(setting) !== "error").map(([name]) => name)
|
|
45
45
|
if (weak.length > 0) throw new Error(`starciFeConfig refuses source rules not at error: ${weak.join(", ")}`)
|
package/lib/hfs.mjs
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* The one door between the front-end lint rules and the HFS slot manifest of the repository being linted.
|
|
3
3
|
*
|
|
4
4
|
* A consuming `eslint.config.mjs` is the managed one-liner `export default starciFeConfig({ hfs: loadHfs(import.meta.url) })`.
|
|
5
|
-
* `loadHfs`
|
|
5
|
+
* The config lives in the fe/ side folder of an app; `loadHfs` finds the app-root `hfs.json` one level up and gives every file
|
|
6
|
+
* linted under that folder the view of the fe side (paths relative to fe/), with the slot manifest this package ships in
|
|
6
7
|
* `runtime/` (a byte copy of the runtime's `knowledge/hfs/slots.yaml`, `scripts/lib/hfs-slots.mjs` and
|
|
7
8
|
* `scripts/lib/hfs-view.mjs`, refreshed by `packages/hfs/scripts/sync-runtime.mjs`), and returns the view the factory puts
|
|
8
9
|
* in `settings.starci.hfs`.
|
|
@@ -17,7 +18,7 @@ import { declaredHfsView, openHfsView } from "../runtime/scripts/lib/hfs-view.mj
|
|
|
17
18
|
const RUNTIME = join(dirname(fileURLToPath(import.meta.url)), "..", "runtime")
|
|
18
19
|
|
|
19
20
|
/**
|
|
20
|
-
* The HFS view of the
|
|
21
|
+
* The HFS view of the side whose `eslint.config.mjs` (in fe/) calls this.
|
|
21
22
|
*
|
|
22
23
|
* @param {string} configUrl - `import.meta.url` of the repository's `eslint.config.mjs`.
|
|
23
24
|
* @returns {object} The frozen HFS view.
|
|
@@ -25,13 +26,13 @@ const RUNTIME = join(dirname(fileURLToPath(import.meta.url)), "..", "runtime")
|
|
|
25
26
|
export const loadHfs = (configUrl) => openHfsView({ runtimeRoot: RUNTIME, repoRoot: dirname(fileURLToPath(configUrl)) })
|
|
26
27
|
|
|
27
28
|
/**
|
|
28
|
-
* The HFS view of an in-memory declaration, for rule tests.
|
|
29
|
+
* The HFS view of the fe side of an in-memory app declaration, for rule tests.
|
|
29
30
|
*
|
|
30
|
-
* @param {object} declaration - An `hfs.json` object.
|
|
31
|
-
* @param {string} repoRoot - The absolute
|
|
31
|
+
* @param {object} declaration - An app `hfs.json` object.
|
|
32
|
+
* @param {string} repoRoot - The absolute fe side folder the linted filenames live under.
|
|
32
33
|
* @returns {object} The frozen HFS view.
|
|
33
34
|
*/
|
|
34
|
-
export const hfsFromDeclaration = (declaration, repoRoot) => declaredHfsView({ runtimeRoot: RUNTIME, declaration, repoRoot })
|
|
35
|
+
export const hfsFromDeclaration = (declaration, repoRoot) => declaredHfsView({ runtimeRoot: RUNTIME, declaration, repoRoot, side: "fe" })
|
|
35
36
|
|
|
36
37
|
/**
|
|
37
38
|
* The HFS view a rule runs under; a rule that needs it and gets none refuses to guess.
|
package/lib/next.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Next.js's own contract for route segment files, read from the runtime's one statement of it
|
|
3
|
-
* (`scripts/lib/next-contract.mjs`, shipped in `runtime/` by `packages/hfs/scripts/sync-runtime.mjs`)
|
|
4
|
-
*
|
|
3
|
+
* (`scripts/lib/next-contract.mjs`, shipped in `runtime/` by `packages/hfs/scripts/sync-runtime.mjs`), so every law
|
|
4
|
+
* holds the same list by construction.
|
|
5
5
|
*/
|
|
6
6
|
export { NEXT_RESERVED_EXPORTS, NEXT_ROUTE_SEGMENT_STEMS } from "../runtime/scripts/lib/next-contract.mjs"
|
package/lib/why.mjs
CHANGED
|
@@ -53,8 +53,8 @@ export const why = {
|
|
|
53
53
|
},
|
|
54
54
|
"no-hand-typed-wire": {
|
|
55
55
|
code: "FE_WIRE_GENERATED",
|
|
56
|
-
vi: "`<file>` tự gõ kiểu wire hoặc ép kiểu phản hồi. Dùng kiểu sinh từ `
|
|
57
|
-
fixVi: "Chạy codegen từ
|
|
56
|
+
vi: "`<file>` tự gõ kiểu wire hoặc ép kiểu phản hồi. Dùng kiểu sinh từ `be/contracts/`.",
|
|
57
|
+
fixVi: "Chạy codegen từ ảnh chụp hợp đồng của be (`be/contracts/`) và nhập kiểu sinh ra; đưa tài liệu GraphQL vào tệp `.graphql`.",
|
|
58
58
|
},
|
|
59
59
|
"use-client-only-at-boundary": {
|
|
60
60
|
code: "FE_CLIENT_BOUNDARY",
|
|
@@ -231,6 +231,16 @@ export const why = {
|
|
|
231
231
|
vi: "`dangerouslySetInnerHTML` trên `<tag>` ở `<file>`: chuỗi bất kỳ trở thành HTML chạy được.",
|
|
232
232
|
fixVi: "Render nội dung thành phần tử; chỉ `<script>` mang JSON-LD hoặc mã theme từ hằng số của app mới được dùng.",
|
|
233
233
|
},
|
|
234
|
+
"response-cookie-attributes": {
|
|
235
|
+
code: "FE_COOKIE_ATTRIBUTES",
|
|
236
|
+
vi: "`<file>` ghi cookie qua cookie phản hồi của Next mà không nêu `httpOnly` cố định, thiếu `secure` hoặc `sameSite` không phải `lax`/`strict`.",
|
|
237
|
+
fixVi: "Truyền một hằng số options `as const` của module sở hữu cookie: `httpOnly: true` (chỉ `false` cho cookie tùy chọn script cần đọc), `secure` từ `modules/config`, `sameSite: \"lax\"` hoặc `\"strict\"`.",
|
|
238
|
+
},
|
|
239
|
+
"props-fields-readonly": {
|
|
240
|
+
code: "FE_PROPS_MUTABLE",
|
|
241
|
+
vi: "Kiểu props của component ở `<file>` có field hoặc collection không `readonly`, nên component có thể ghi vào thứ nó được truyền.",
|
|
242
|
+
fixVi: "Đánh dấu `readonly` cho mọi field và index signature, viết collection là `readonly T[]`, `readonly [A, B]` hoặc `ReadonlyArray<T>`.",
|
|
243
|
+
},
|
|
234
244
|
"no-native-img": {
|
|
235
245
|
code: "FE_NATIVE_IMAGE",
|
|
236
246
|
vi: "`<img>` thô ở `<file>`: tải ảnh gốc, không giữ chỗ nên trang nhảy khi ảnh tải xong.",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@starci/eslint-canon-fe",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "8.0.1",
|
|
4
4
|
"description": "The StarCi front-end lint canon: every rule its laws publish, gathered as one flat-config plugin and attached by one factory.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "UNLICENSED",
|
package/props-and-slots.mjs
CHANGED
|
@@ -142,8 +142,128 @@ export const noCssDoorTypeLaundering = {
|
|
|
142
142
|
},
|
|
143
143
|
}
|
|
144
144
|
|
|
145
|
+
/** The top-level declaration of a type name in this file (`interface X` or `type X = ...`), or null. */
|
|
146
|
+
const localTypeDeclaration = (program, name) => {
|
|
147
|
+
for (const statement of program.body) {
|
|
148
|
+
const declaration = statement.type === "ExportNamedDeclaration" ? statement.declaration : statement
|
|
149
|
+
if ((declaration?.type === "TSInterfaceDeclaration" || declaration?.type === "TSTypeAliasDeclaration") && declaration.id.name === name) return declaration
|
|
150
|
+
}
|
|
151
|
+
return null
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The type the first parameter of a function declares, or null. */
|
|
155
|
+
const firstParameterType = (fn) => {
|
|
156
|
+
const param = fn.params?.[0]
|
|
157
|
+
const annotated = param?.type === "AssignmentPattern" ? param.left : param
|
|
158
|
+
return annotated?.typeAnnotation?.typeAnnotation ?? null
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** The exported functions of a file that render (contain JSX): a component, whatever it is called. */
|
|
162
|
+
const exportedRenderers = (program) => program.body.flatMap((statement) => {
|
|
163
|
+
const declaration = statement.type === "ExportNamedDeclaration" || statement.type === "ExportDefaultDeclaration" ? statement.declaration : null
|
|
164
|
+
if (!declaration) return []
|
|
165
|
+
const functions = declaration.type === "VariableDeclaration"
|
|
166
|
+
? declaration.declarations.map((item) => item.init).filter((init) => init?.type === "ArrowFunctionExpression" || init?.type === "FunctionExpression")
|
|
167
|
+
: declaration.type === "FunctionDeclaration" || declaration.type === "ArrowFunctionExpression" || declaration.type === "FunctionExpression" ? [declaration] : []
|
|
168
|
+
return functions.filter((fn) => fn.params.length === 1 && containsJsx(fn.body))
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
/** Props are readonly all the way down: every field, index signature, nested object and collection (FE-TYPING-2). */
|
|
172
|
+
export const propsFieldsReadonly = {
|
|
173
|
+
meta: {
|
|
174
|
+
type: "problem",
|
|
175
|
+
docs: { description: "Every field of a component's props type, nested objects and collections included, is readonly." },
|
|
176
|
+
schema: [],
|
|
177
|
+
messages: {
|
|
178
|
+
field: "Props field `{{name}}` is not readonly. A component reads its props and never writes them: mark it `readonly` so a write is a type error.",
|
|
179
|
+
index: "This props index signature is not readonly. Mark it `readonly [key: ...]` so no entry can be written through props.",
|
|
180
|
+
collection: "This props collection is mutable. Write `readonly T[]`, `readonly [A, B]` or `ReadonlyArray<T>` so the component cannot change what it was given.",
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
create(context) {
|
|
184
|
+
if (!isProductSource(context)) return {}
|
|
185
|
+
const sourceCode = context.sourceCode || context.getSourceCode()
|
|
186
|
+
return {
|
|
187
|
+
"Program:exit"(program) {
|
|
188
|
+
const seen = new Set()
|
|
189
|
+
/** Checks one type: object members, collections and the local declarations it names. */
|
|
190
|
+
const checkType = (type) => {
|
|
191
|
+
if (!type || seen.has(type)) return
|
|
192
|
+
seen.add(type)
|
|
193
|
+
if (type.type === "TSTypeOperator" && type.operator === "readonly") {
|
|
194
|
+
checkElements(type.typeAnnotation)
|
|
195
|
+
return
|
|
196
|
+
}
|
|
197
|
+
if (type.type === "TSArrayType" || type.type === "TSTupleType") {
|
|
198
|
+
context.report({ node: type, messageId: "collection" })
|
|
199
|
+
checkElements(type)
|
|
200
|
+
return
|
|
201
|
+
}
|
|
202
|
+
if (type.type === "TSUnionType" || type.type === "TSIntersectionType") {
|
|
203
|
+
for (const part of type.types) checkType(part)
|
|
204
|
+
return
|
|
205
|
+
}
|
|
206
|
+
if (type.type === "TSTypeLiteral") {
|
|
207
|
+
checkMembers(type.members)
|
|
208
|
+
return
|
|
209
|
+
}
|
|
210
|
+
if (type.type !== "TSTypeReference" || type.typeName.type !== "Identifier") return
|
|
211
|
+
const name = type.typeName.name
|
|
212
|
+
const args = type.typeArguments?.params ?? type.typeParameters?.params ?? []
|
|
213
|
+
// `Readonly<T>` makes the top level readonly; what T nests is still judged
|
|
214
|
+
if (name === "Readonly" && args.length === 1 && !isShadowed(type.typeName)) {
|
|
215
|
+
checkNested(args[0])
|
|
216
|
+
return
|
|
217
|
+
}
|
|
218
|
+
if (name === "Array" && !isShadowed(type.typeName)) context.report({ node: type, messageId: "collection" })
|
|
219
|
+
if ((name === "Array" || name === "ReadonlyArray") && !isShadowed(type.typeName)) {
|
|
220
|
+
for (const arg of args) checkType(arg)
|
|
221
|
+
return
|
|
222
|
+
}
|
|
223
|
+
const declaration = localTypeDeclaration(program, name)
|
|
224
|
+
if (!declaration || seen.has(declaration)) return
|
|
225
|
+
seen.add(declaration)
|
|
226
|
+
if (declaration.type === "TSTypeAliasDeclaration") checkType(declaration.typeAnnotation)
|
|
227
|
+
else {
|
|
228
|
+
checkMembers(declaration.body.body)
|
|
229
|
+
for (const heritage of declaration.extends ?? []) {
|
|
230
|
+
if (heritage.expression.type === "Identifier") checkType({ type: "TSTypeReference", typeName: heritage.expression, typeArguments: heritage.typeArguments })
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
/** The element types of a collection, judged like any other props type. */
|
|
235
|
+
const checkElements = (collection) => {
|
|
236
|
+
if (collection?.type === "TSArrayType") checkType(collection.elementType)
|
|
237
|
+
else if (collection?.type === "TSTupleType") for (const element of collection.elementTypes) checkType(element.type === "TSNamedTupleMember" ? element.elementType : element)
|
|
238
|
+
else checkType(collection)
|
|
239
|
+
}
|
|
240
|
+
/** A `Readonly<T>` argument: its own fields are readonly, its nested values are judged. */
|
|
241
|
+
const checkNested = (type) => {
|
|
242
|
+
if (type?.type === "TSTypeLiteral") for (const member of type.members) checkType(member.typeAnnotation?.typeAnnotation)
|
|
243
|
+
else checkType(type)
|
|
244
|
+
}
|
|
245
|
+
const checkMembers = (members) => {
|
|
246
|
+
for (const member of members) {
|
|
247
|
+
if (member.type === "TSPropertySignature") {
|
|
248
|
+
if (!member.readonly) context.report({ node: member, messageId: "field", data: { name: sourceCode.getText(member.key) } })
|
|
249
|
+
checkType(member.typeAnnotation?.typeAnnotation)
|
|
250
|
+
} else if (member.type === "TSIndexSignature") {
|
|
251
|
+
if (!member.readonly) context.report({ node: member, messageId: "index" })
|
|
252
|
+
checkType(member.typeAnnotation?.typeAnnotation)
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
/** True when a built-in name is redeclared in this file, so it is not the built-in. */
|
|
257
|
+
const isShadowed = (identifier) => localTypeDeclaration(program, identifier.name) !== null
|
|
258
|
+
for (const fn of exportedRenderers(program)) checkType(firstParameterType(fn))
|
|
259
|
+
},
|
|
260
|
+
}
|
|
261
|
+
},
|
|
262
|
+
}
|
|
263
|
+
|
|
145
264
|
export const rules = {
|
|
146
265
|
"no-inline-parameter-type": noInlineParameterType,
|
|
266
|
+
"props-fields-readonly": propsFieldsReadonly,
|
|
147
267
|
"public-component-signature": publicComponentSignature,
|
|
148
268
|
"no-per-part-classname-prop": noPerPartClassNameProp,
|
|
149
269
|
"no-public-classname-prop": noPublicClassNameProp,
|
|
@@ -5,7 +5,14 @@ purpose: >-
|
|
|
5
5
|
then the repositories' own upgrade lane (each sets the exact version and reinstalls; nothing else installs a @starci package). Read on 2026-09-29 from the packages under
|
|
6
6
|
.claude/packages and from the installed node_modules of nivo-backend, nivo-fe, starci-next, starci-next-fe,
|
|
7
7
|
mia-mia-backend and miamia-fe; where they disagreed the newest version installed in more than one repository
|
|
8
|
-
won, and the runtime's own version won for @starci packages.
|
|
8
|
+
won, and the runtime's own version won for @starci packages. An app (hfs 4) has ONE package.json for both sides, so a
|
|
9
|
+
dependency both sides declared at different versions takes one: its pin where it has one, otherwise the higher of the
|
|
10
|
+
two, pinned here with a why (2026-10-01, from nivo-backend/nivo-fe, starci-next/starci-next-fe and
|
|
11
|
+
mia-mia-backend/miamia-fe: every such conflict but graphql was already pinned). The runtime's example apps merged the
|
|
12
|
+
same way on 2026-10-01 (examples/todo-app from todo-app-backend/todo-app-frontend, examples/ecommerce-app from
|
|
13
|
+
ecommerce-app-be/ecommerce-app-fe): no dependency outside this file was declared at two versions across their sides, so
|
|
14
|
+
every choice is a pin (each side's older @starci pins and graphql moved to the version here). `side` names the side whose code uses
|
|
15
|
+
the dependency; the app root package.json carries the pins of both.
|
|
9
16
|
pins:
|
|
10
17
|
# --- @starci packages. Every @starci package is a PUBLISHED npm package (install: registry): a product repository
|
|
11
18
|
# installs the exact pinned version from the npm registry in the root and every workspace package.json, never `file:`.
|
|
@@ -17,19 +24,19 @@ pins:
|
|
|
17
24
|
source: packages/grammar/package.json
|
|
18
25
|
why: nivo-fe pins 0.4.11 and 0.5.0 in one workspace, starci-next-fe 0.5.1, miamia-fe 0.5.0; the runtime source is 0.8.0 (the brand layer sets `--font-sans` and `--font-mono`; the grammar reads them; 0.7.2 added the Input tel kind and IconButton disclosure props).
|
|
19
26
|
'@starci/eslint-canon-be':
|
|
20
|
-
version:
|
|
27
|
+
version: 3.0.1
|
|
21
28
|
group: starci
|
|
22
29
|
install: registry
|
|
23
30
|
side: be
|
|
24
31
|
source: packages/eslint/be/package.json
|
|
25
|
-
why: '2.0.0 (C0 release): starciBeConfig({ hfs: loadHfs(import.meta.url) }) typed factory and the BE-CONVENTION laws
|
|
32
|
+
why: '3.0.1: its bundled canon-pins copy pins hfs 4.0.1. 3.0.0: `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; the project graph is built per side. 2.0.0 (C0 release): starciBeConfig({ hfs: loadHfs(import.meta.url) }) typed factory and the BE-CONVENTION laws.'
|
|
26
33
|
'@starci/eslint-canon-fe':
|
|
27
|
-
version:
|
|
34
|
+
version: 8.0.1
|
|
28
35
|
group: starci
|
|
29
36
|
install: registry
|
|
30
37
|
side: fe
|
|
31
38
|
source: packages/eslint/fe/package.json
|
|
32
|
-
why: '7.0.0: the front end has no tests (
|
|
39
|
+
why: '8.0.1: its bundled canon-pins copy pins hfs 4.0.1. 8.0.0: `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; the project graph is built per side. 7.0.0: the front end has no tests (FE_NO_TESTS R97 in hfs); no-vietnamese-in-source (R91).'
|
|
33
40
|
'@starci/stylelint-canon':
|
|
34
41
|
version: 2.0.1
|
|
35
42
|
group: starci
|
|
@@ -50,19 +57,26 @@ pins:
|
|
|
50
57
|
side: both
|
|
51
58
|
source: packages/prettier-config/package.json
|
|
52
59
|
'@starci/jest-preset':
|
|
53
|
-
version: 2.
|
|
60
|
+
version: 2.2.0
|
|
54
61
|
group: starci
|
|
55
62
|
install: registry
|
|
56
63
|
side: be
|
|
57
64
|
source: packages/jest-preset/package.json
|
|
58
|
-
why: '2.1.0: four projects on the test world, the unit kit (mockEntityManager, fakeTransaction, fakeIds, FakeClock, Outcome matchers) and the recordingOutbox claim side.'
|
|
65
|
+
why: '2.2.0: the integration, e2e and contract projects run every spec file in a worker process of its own (world-runner.cjs), so a process-global framework registry (@nestjs/graphql type metadata) never leaks between e2e files; 2.1.0: four projects on the test world, the unit kit (mockEntityManager, fakeTransaction, fakeIds, FakeClock, Outcome matchers) and the recordingOutbox claim side.'
|
|
66
|
+
'@starci/test-world':
|
|
67
|
+
version: 1.0.0
|
|
68
|
+
group: starci
|
|
69
|
+
install: registry
|
|
70
|
+
side: be
|
|
71
|
+
source: packages/test-world/package.json
|
|
72
|
+
why: 'the shared e2e library of every back end (R47, R48): the warm stack behind toxiproxy, the network-edge fakes, the Nest boot, the typed useTestWorld handle, useSandbox for contract specs, the outage lock and the starci-test-stack bin behind the managed test:stack script; a devDependency of every back end.'
|
|
59
73
|
'@starci/hfs':
|
|
60
|
-
version:
|
|
74
|
+
version: 4.0.1
|
|
61
75
|
group: starci
|
|
62
76
|
install: registry
|
|
63
77
|
side: both
|
|
64
78
|
source: packages/hfs/package.json
|
|
65
|
-
why: '
|
|
79
|
+
why: '4.0.1: `hfs scaffold app` resolves the real lockfile with npm (`npm install --package-lock-only`) instead of writing a root-only stub that `npm ci` refuses. 4.0.0: the app monorepo standard: one app repository with the root package.json, lockfile and hfs.json of kind app, and the be/ and fe/ sides; `hfs scaffold app <name>` makes it, `hfs lint` at the root lints be/** with the BE canon and fe/** with the FE canon; the standalone be/fe repository kinds and `hfs init` are deleted.'
|
|
66
80
|
# --- tooling
|
|
67
81
|
typescript:
|
|
68
82
|
version: 5.9.3
|
|
@@ -152,6 +166,11 @@ pins:
|
|
|
152
166
|
group: framework
|
|
153
167
|
side: fe
|
|
154
168
|
why: every HFS front end uses next-intl with the [locale] segment (hfs sync --init writes it); nivo-fe resolves 4.13.6.
|
|
169
|
+
graphql:
|
|
170
|
+
version: 16.14.2
|
|
171
|
+
group: framework
|
|
172
|
+
side: both
|
|
173
|
+
why: 'one app package.json for both sides (hfs 4): nivo-backend declares ^16.14.1 and nivo-fe ^16.14.2, the higher wins; all four product installs resolve 16.14.2. The example apps: todo-app-backend declared ^16.14.2 (devDependencies) and ecommerce-app-be ^16.14.2 (dependencies and devDependencies); each app root now has one dependencies entry at the pin.'
|
|
155
174
|
supertest:
|
|
156
175
|
version: 7.3.0
|
|
157
176
|
group: tooling
|