@starci/eslint-canon-fe 8.4.40 → 8.4.41

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.
@@ -0,0 +1,92 @@
1
+ /** Traverse only the visible bar composition and its local React owners. */
2
+ import { functionOf, variableOf } from "./bindings.mjs"
3
+ import { grammarName, propOf, resolveLocal } from "./app-shell.mjs"
4
+
5
+ const BAR_SLOTS = {
6
+ WorkspaceShell: ["header", "compactHeader"],
7
+ TopBar: ["brand", "navigation", "actions"],
8
+ NavigationFeatureNav: ["identity", "navigation", "actions", "compactNavigationTrigger"],
9
+ DropdownMenu: ["trigger"],
10
+ Popover: ["trigger"],
11
+ Drawer: ["trigger"],
12
+ }
13
+
14
+ /** Visible JSX composition only: menu, popover and drawer bodies are outside the navbar band. */
15
+ export const visitVisible = (context, expression, visit, seen = new Set(), branch = () => "both", ancestors = []) => {
16
+ const node = resolveLocal(context, expression)
17
+ if (!node || seen.has(node)) return
18
+ const visited = new Set(seen).add(node)
19
+ const next = (child) => visitVisible(context, child, visit, visited, branch, ancestors)
20
+ switch (node.type) {
21
+ case "JSXExpressionContainer": next(node.expression); break
22
+ case "JSXFragment": for (const child of node.children) next(child); break
23
+ case "ConditionalExpression": visitConditional(node, next, branch); break
24
+ case "LogicalExpression": visitLogical(context, node, next, branch); break
25
+ case "ArrayExpression": for (const item of node.elements) next(item); break
26
+ case "JSXElement": visitElement(context, node, visit, visited, branch, ancestors); break
27
+ }
28
+ }
29
+
30
+ const visitConditional = (node, next, branch) => {
31
+ const choice = branch(node)
32
+ if (choice !== "alternate") next(node.consequent)
33
+ if (choice !== "consequent") next(node.alternate)
34
+ }
35
+
36
+ const visitLogical = (context, node, next, branch) => {
37
+ const left = resolveLocal(context, node.left)
38
+ const known = left?.type === "Literal"
39
+ let right = node.operator === "&&" ? Boolean(left?.value) : !left?.value
40
+ if (node.operator === "??") right = left?.value === null
41
+ if (!known) {
42
+ const choice = branch(node)
43
+ if (choice === "both") { next(node.left); next(node.right); return }
44
+ right = choice === "consequent"
45
+ }
46
+ next(right ? node.right : node.left)
47
+ }
48
+
49
+ const visitElement = (context, node, visit, seen, branch, ancestors) => {
50
+ const opening = node.openingElement
51
+ const name = grammarName(context, opening)
52
+ if (visit(opening, name, ancestors) === false) return
53
+ const inside = [...ancestors, opening]
54
+ const next = (child) => visitVisible(context, child, visit, seen, branch, inside)
55
+ if (name === null && opening.name.type === "JSXIdentifier") {
56
+ const fn = functionOf(context, resolveLocal(context, opening.name)) ?? variableOf(context, opening.name)?.defs.find((definition) => definition.type === "FunctionName")?.node
57
+ if (fn) visitFunction(context, fn, visit, seen, branch, inside)
58
+ }
59
+ const slots = BAR_SLOTS[name]
60
+ if (slots) for (const slot of slots) next(propOf(context, opening, slot).value)
61
+ if (["DropdownMenu", "Popover", "Drawer"].includes(name)) return
62
+ for (const child of node.children) next(child)
63
+ }
64
+
65
+ /** Visit a rendered owner's returns with the same branch, overlay and local-binding semantics as inline JSX. */
66
+ export const visitFunction = (context, fn, visit, seen, branch, ancestors) => {
67
+ if (fn.body.type !== "BlockStatement") return visitVisible(context, fn.body, visit, seen, branch, ancestors)
68
+ visitStatements(context, fn.body.body, visit, seen, branch, ancestors)
69
+ }
70
+
71
+ const visitStatements = (context, statements, visit, seen, branch, ancestors) => {
72
+ for (const statement of statements) {
73
+ if (statement.type === "ReturnStatement") {
74
+ visitVisible(context, statement.argument, visit, seen, branch, ancestors)
75
+ return true
76
+ }
77
+ if (statement.type === "BlockStatement" && visitStatements(context, statement.body, visit, seen, branch, ancestors)) return true
78
+ if (statement.type === "IfStatement") {
79
+ if (visitIf(context, statement, visit, seen, branch, ancestors)) return true
80
+ }
81
+ }
82
+ return false
83
+ }
84
+
85
+ const visitIf = (context, statement, visit, seen, branch, ancestors) => {
86
+ const condition = resolveLocal(context, statement.test)
87
+ let choice = branch(statement)
88
+ if (condition?.type === "Literal") choice = condition.value ? "consequent" : "alternate"
89
+ const consequent = choice !== "alternate" && visitStatements(context, [statement.consequent], visit, seen, branch, ancestors)
90
+ const alternate = choice !== "consequent" && statement.alternate && visitStatements(context, [statement.alternate], visit, seen, branch, ancestors)
91
+ return choice === "consequent" && consequent || choice === "alternate" && alternate || consequent && alternate
92
+ }
@@ -0,0 +1,148 @@
1
+ /** Static readers for the visible Grammar app-bar composition, including local JSX owners. */
2
+ import { attributeValue, stringOf } from "./ast.mjs"
3
+ import { functionOf, importOf, unwrap, variableOf } from "./bindings.mjs"
4
+
5
+ const GRAMMAR_SOURCE = /^@starci\/grammar(?:\/|$)/
6
+ /** Grammar controls whose visible hit target belongs to the navbar band's size contract. */
7
+ const BAR_CONTROLS = new Set(["Button", "IconButton", "Switch", "AvatarButton", "PlainPressable", "IdentityLockup", "SearchField", "Input", "TextField", "Select", "TextAction"])
8
+ const BAR_WRAPPERS = new Set(["GrammarRoot", "WorkspaceShell", "TopBar", "NavigationFeatureNav", "Region", "PageContainer", "NavLandmark", "Form", "Fieldset", "Field", "ButtonGroup", "DropdownMenu", "Popover", "Drawer", "Tooltip"])
9
+ const ACTION_PROPS = ["onPress", "onClick", "onAction", "onValueChange", "onSelectionChange", "onSelectedChange", "onChange", "onInputChange"]
10
+ const INTERACTION_PROPS = new Set(["label", "value", "defaultValue", "selectedKey", "selectedKeys", "selectionMode", "href", ...ACTION_PROPS])
11
+
12
+ /** Bar policy also counts labelled/value/selection/action field owners, so an extra control cannot vanish from the cluster. */
13
+ export const isBarControl = (context, opening, name) => {
14
+ if (BAR_CONTROLS.has(name)) return true
15
+ if (!name || !/^[A-Z]/.test(name) || BAR_WRAPPERS.has(name)) return false
16
+ if (opening.attributes.some((entry) => entry.type === "JSXAttribute" && INTERACTION_PROPS.has(entry.name.name))) return true
17
+ return [...INTERACTION_PROPS].some((prop) => propOf(context, opening, prop).value !== null)
18
+ }
19
+ /** A literal or constant's initializer, without type-only wrappers. */
20
+ export const resolveLocal = (context, expression, seen = new Set()) => {
21
+ const node = unwrap(expression)
22
+ if (node?.type === "MemberExpression") return resolveMember(context, node, seen)
23
+ if (node?.type === "CallExpression") return resolveCall(context, node, seen)
24
+ if (!["Identifier", "JSXIdentifier"].includes(node?.type) || seen.has(node)) return node
25
+ const definition = variableOf(context, node)?.defs.find((entry) => entry.type === "Variable")
26
+ if (!definition || definition.parent?.kind !== "const") return node
27
+ seen.add(node)
28
+ return resolveLocal(context, initializerOf(definition.node, node), seen)
29
+ }
30
+
31
+ const resolveCall = (context, node, seen) => {
32
+ if (seen.has(node)) return node
33
+ seen.add(node)
34
+ const fn = functionOf(context, resolveLocal(context, node.callee, seen))
35
+ if (!fn) return node
36
+ if (fn.body.type !== "BlockStatement") return resolveLocal(context, fn.body, seen)
37
+ if (fn.body.body.some((statement) => !["VariableDeclaration", "ReturnStatement"].includes(statement.type))) return node
38
+ const returned = fn.body.body.find((statement) => statement.type === "ReturnStatement")
39
+ return returned ? resolveLocal(context, returned.argument, seen) : node
40
+ }
41
+
42
+ const resolveMember = (context, node, seen) => {
43
+ const key = node.computed ? stringOf(node.property) : node.property.name
44
+ if (!key) return node
45
+ const property = objectValue(context, node.object, key, seen)
46
+ return property.known && property.value !== null ? resolveLocal(context, property.value, seen) : node
47
+ }
48
+
49
+ const initializerOf = (declaration, node) => {
50
+ if (declaration.id.type === "ObjectPattern") {
51
+ const property = declaration.id.properties.find((entry) => entry.type === "Property" && entry.value.name === node.name)
52
+ if (!property) return node
53
+ return { type: "MemberExpression", object: declaration.init, property: property.key, computed: property.computed }
54
+ }
55
+ return declaration.init
56
+ }
57
+
58
+ /** The imported Grammar component's name; aliases and namespace imports keep their identity. */
59
+ export const grammarName = (context, opening) => {
60
+ const name = opening.name
61
+ if (name.type === "JSXIdentifier") {
62
+ const canonical = expressionName(context, name)
63
+ if (canonical) return canonical
64
+ return variableOf(context, name)?.defs.length ? null : name.name
65
+ }
66
+ if (name.type !== "JSXMemberExpression" || name.object.type !== "JSXIdentifier") return null
67
+ return expressionName(context, { type: "MemberExpression", object: name.object, property: name.property, computed: false })
68
+ }
69
+
70
+ const expressionName = (context, expression) => {
71
+ const resolved = resolveLocal(context, expression)
72
+ if (resolved?.type === "MemberExpression") {
73
+ return namespaceName(context, resolved.object, resolved.computed ? stringOf(resolved.property) : resolved.property.name)
74
+ }
75
+ if (!["Identifier", "JSXIdentifier"].includes(resolved?.type)) return null
76
+ const imported = importOf(context, resolved)
77
+ return imported && GRAMMAR_SOURCE.test(imported.source) ? imported.imported : null
78
+ }
79
+
80
+ const namespaceName = (context, expression, property) => {
81
+ const object = resolveLocal(context, expression)
82
+ if (!["Identifier", "JSXIdentifier"].includes(object?.type)) return null
83
+ const imported = importOf(context, object)
84
+ return imported && GRAMMAR_SOURCE.test(imported.source) ? property : null
85
+ }
86
+
87
+ const objectValue = (context, expression, name, seen = new Set()) => {
88
+ const node = resolveLocal(context, expression)
89
+ if (node?.type === "ConditionalExpression") return conditionalObjectValue(context, node, name, seen)
90
+ if (node?.type !== "ObjectExpression" || seen.has(node)) return { known: false, value: null }
91
+ seen.add(node)
92
+ let result = { known: true, value: null }
93
+ for (const property of node.properties) {
94
+ if (property.type === "SpreadElement") {
95
+ const spread = objectValue(context, property.argument, name, seen)
96
+ if (!spread.known || spread.value !== null) result = spread
97
+ } else if (!property.computed && (property.key.name ?? property.key.value) === name) {
98
+ result = { known: true, value: property.value }
99
+ }
100
+ }
101
+ return result
102
+ }
103
+
104
+ const conditionalObjectValue = (context, node, name, seen) => {
105
+ if (seen.has(node)) return { known: false, value: null }
106
+ const visited = new Set(seen).add(node)
107
+ const values = [node.consequent, node.alternate].map((branch) => objectValue(context, branch, name, new Set(visited)))
108
+ return { known: values.every((value) => value.known && value.value === null), value: null }
109
+ }
110
+
111
+ /** The effective prop, respecting JSX spread order; unknown later spreads cannot prove a value. */
112
+ export const propOf = (context, opening, name) => {
113
+ let result = { known: true, value: null }
114
+ for (const entry of opening.attributes) {
115
+ if (entry.type === "JSXSpreadAttribute") {
116
+ const spread = objectValue(context, entry.argument, name)
117
+ if (!spread.known || spread.value !== null) result = spread
118
+ } else if (entry.name.name === name) result = { known: true, value: attributeValue(entry), present: true }
119
+ }
120
+ return result
121
+ }
122
+
123
+ /** A statically proven string prop, or null for missing/unknown values. */
124
+ export const textProp = (context, opening, name) => {
125
+ const prop = propOf(context, opening, name)
126
+ return prop.known ? stringOf(resolveLocal(context, prop.value)) : null
127
+ }
128
+
129
+ /** The source spelling is semantic evidence for an icon or action whose translated label is dynamic. */
130
+ export const semanticText = (context, opening) => {
131
+ const values = ["source", "icon", "label", "aria-label", "id", ...ACTION_PROPS].map((name) => {
132
+ const prop = propOf(context, opening, name)
133
+ return prop.value ? context.sourceCode.getText(prop.value) : ""
134
+ })
135
+ return values.join(" ")
136
+ }
137
+
138
+ /** The right-cluster role and the Grammar kind that must draw it. */
139
+ export const clusterRole = (context, opening, name) => {
140
+ if (name === "Switch") return "theme"
141
+ if (name === "AvatarButton") return "account"
142
+ const text = semanticText(context, opening)
143
+ if (/language|locale|translate/i.test(text)) return "language"
144
+ if (/notification|bell/i.test(text)) return "notifications"
145
+ if (/account|avatar|profile/i.test(text)) return "account"
146
+ if (/theme|darkmode|color.?mode/i.test(text)) return "theme"
147
+ return null
148
+ }
package/lib/why.mjs CHANGED
@@ -29,6 +29,31 @@ export const why = {
29
29
  en: "`<file>` adds horizontal wrapper padding to a Grammar list or its section caption, stacking the content inset.",
30
30
  fix: "Remove the wrapper padding and use ListBox inset=\"panel\" so headers, captions and rows share the panel content edge.",
31
31
  },
32
+ "app-shell-frame": {
33
+ code: "FE_APP_SHELL_FRAME",
34
+ en: "The app frame must use Grammar WorkspaceShell with its fixed viewport.",
35
+ fix: "Compose WorkspaceShell viewport=\"fixed\" and supply its header, navigation and primary slots.",
36
+ },
37
+ "no-shell-bottom-tabs": {
38
+ code: "FE_APP_SHELL_BOTTOM_TABS",
39
+ en: "Bottom tab navigation replaces the required mobile sidebar drawer.",
40
+ fix: "Use WorkspaceShell navigationDrawer and Sidebar presentation=\"drawer\".",
41
+ },
42
+ "navbar-control-size": {
43
+ code: "FE_NAVBAR_CONTROL_SIZE",
44
+ en: "A navbar control does not use Grammar's shared control height.",
45
+ fix: "Set size=\"control\" on every visible bar control.",
46
+ },
47
+ "navbar-cluster": {
48
+ code: "FE_NAVBAR_CLUSTER",
49
+ en: "Navbar action component kinds or order differ from the app-shell canon.",
50
+ fix: "Compose Language IconButton, theme Switch, notification IconButton and account AvatarButton in that order.",
51
+ },
52
+ "no-navbar-create": {
53
+ code: "FE_NAVBAR_CREATE",
54
+ en: "A create action is composed into the global navbar.",
55
+ fix: "Move the create action to the page that owns it.",
56
+ },
32
57
  "no-env-outside-config": {
33
58
  code: "FE_ENV_OWNER",
34
59
  en: "`<file>` reads an environment variable. Only `modules/config` may read env.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starci/eslint-canon-fe",
3
- "version": "8.4.40",
3
+ "version": "8.4.41",
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": "MIT",
@@ -23,7 +23,7 @@ pins:
23
23
  source: packages/heroicons/package.json
24
24
  why: 'StarCi custom Heroicons-compatible cuts; eslint-canon-fe (icon.mjs) admits @starci/heroicons/24/outline and /16/solid as icon sources, so an app that uses them gets this exact version.'
25
25
  '@starci/grammar':
26
- version: 0.12.0
26
+ version: 0.12.1
27
27
  group: starci
28
28
  install: registry
29
29
  side: fe
@@ -37,7 +37,7 @@ pins:
37
37
  source: packages/eslint/be/package.json
38
38
  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.'
39
39
  '@starci/eslint-canon-fe':
40
- version: 8.4.40
40
+ version: 8.4.41
41
41
  group: starci
42
42
  install: registry
43
43
  side: fe
@@ -251,8 +251,8 @@ ruleParams:
251
251
  socket.io: [platform/realtime]
252
252
  graphql-subscriptions: [platform/realtime]
253
253
  graphql-ws: [platform/realtime]
254
- "@socket.io/postgres-adapter": [platform/realtime] # the stateless socket.io adapter of a scaled lite back end
255
- "@socket.io/redis-adapter": [platform/realtime] # the sharded socket.io adapter of a scaled full back end
254
+ "@socket.io/postgres-adapter": [platform/realtime] # the stateless socket.io adapter of a scaled lite back end without Redis
255
+ "@socket.io/redis-adapter": [platform/realtime] # the sharded socket.io adapter of a scaled back end on the declared Redis
256
256
  pg: [platform/realtime] # the LISTEN connection of the Postgres hub; every other query goes through TypeORM
257
257
  # R87 BE_CQRS_SHAPE no-event-bus: the in-process event vocabulary the rule refuses. `imports` maps a module
258
258
  # specifier to the member names that are an in-process event; `[]` flags the package itself (its emitter packages
@@ -371,11 +371,11 @@ ruleParams:
371
371
  # ruling:R212/case-10 (R212 HFS_BACKING_UNDECLARED / HFS_BACKING_UNUSED, R276 STACKS_LITE_SELFHOSTED_DB): THE backing services a back
372
372
  # end may declare in hfs.json sides.be.backing. `clients` are the libraries that consume the service: one of them as a dependency
373
373
  # is its consumer, and under lite none of them may be a dependency while the service is undeclared. `patterns` are the declared
374
- # patterns that need the service. `images` are the image repositories (the last path segment) a lite stack runs it from; an
375
- # undeclared one is refused there.
374
+ # patterns that need the service. `images` are the image repositories (the last path segment) a stack runs it from.
375
+ # `editions` limits admission: owner:lite-vector-is-pgvector keeps vector search on Supabase pgvector in lite.
376
376
  backing:
377
377
  redis: {clients: [bullmq, "@nestjs/bullmq", bull, "@nestjs/bull", ioredis, redis, "@keyv/redis", cache-manager-redis-yet, cache-manager-ioredis-yet, "@socket.io/redis-adapter"], patterns: [queue, fenced-job], images: [redis, redis-stack, redis-stack-server]}
378
- qdrant: {clients: ["@qdrant/js-client-rest", "@qdrant/qdrant-js"], patterns: [], images: [qdrant]}
378
+ qdrant: {clients: ["@qdrant/js-client-rest", "@qdrant/qdrant-js"], patterns: [], images: [qdrant], editions: [full]}
379
379
  # Edition lite (ruleParams(manifest, 'be', 'lite') merges these keys over the base; a map merges one level deep): the schema
380
380
  # authority is the Supabase migration tree (supabase/migrations/*.sql, forward-only), so a lite back end holds no TypeORM
381
381
  # migration or entity under be/ (R34/R35 read schemaAuthority; be.persistence's lite overlay allows neither). The unit roles,
@@ -389,6 +389,8 @@ ruleParams:
389
389
  schemaAuthority: supabase
390
390
  infraOwners:
391
391
  kafkajs: []
392
+ "@qdrant/js-client-rest": []
393
+ "@qdrant/qdrant-js": []
392
394
 
393
395
  slots:
394
396
 
@@ -764,7 +766,7 @@ slots:
764
766
  - id: app.starcistacks
765
767
  since: 3.1.0
766
768
  profiles: [app]
767
- editionWhy: "owner:lite-production-swarm: lite requires declared docker-swarm production with Supabase backing; full also admits Compose development, Kubernetes and Terraform."
769
+ editionWhy: "owner:lite-production-swarm: lite requires declared docker-swarm production with Supabase backing; owner:dev-docker-prod-swarm keeps dependencies in Docker Linux containers in dev and the app and dependencies on Swarm in prod except hosted Supabase."
768
770
  exemplifiedBy:
769
771
  - {path: packages/hfs/templates/app/skeleton/.starcistacks/, edition: full}
770
772
  - {path: packages/hfs/templates/app/skeleton-lite/.starcistacks/, edition: lite}
@@ -0,0 +1,95 @@
1
+ # ============================================================================
2
+ # modules/goal/source-phrases.yaml
3
+ #
4
+ # The Vietnamese phrase lists runtime source matches against owner or product
5
+ # text (scripts/lib/source-phrases.mjs). This is DATA for matchers, never
6
+ # owner-visible messages - those live in modules/i18n/messages/ (i18n.mjs).
7
+ # Each list holds the Vietnamese alternatives one matcher adds to its English
8
+ # pattern (a regex fragment when the source pattern used regex syntax, a plain
9
+ # word otherwise); the consumer is named above each group. The leaf keys are
10
+ # declared Vietnamese fields (scripts/lib/language.mjs DECLARED_VIETNAMESE_FIELDS),
11
+ # like the signal phrase lists of archetypes.yaml.
12
+ # ============================================================================
13
+
14
+ schema: starci/module-goal-source-phrases@1
15
+
16
+ phrases:
17
+
18
+ # packages/eslint/fe/lib/app-shell-create.mjs - localized labels of create actions prohibited in the navbar.
19
+ appShell:
20
+ product: ['Tạo mới', 'tao moi']
21
+
22
+ # scripts/agent/bias.mjs extractRoutingBias - the routing markers an owner
23
+ # prompt types before a pool alias. Regex alternatives; syllables join with \s*.
24
+ bias:
25
+ prefer: ['ưu\s*tiên', 'uu\s*tien', 'u-u\s*tien', prefer]
26
+ avoid: ['không\s*dùng', 'khong\s*dung', 'tránh', 'tranh', avoid, 'don''t\s+use']
27
+ only: ['chỉ\s*dùng', 'chi\s*dung', 'only\s+use', only]
28
+
29
+ # scripts/checks/check-guidance-commands.mjs excusedBy - a negation or a
30
+ # refusal verb, in either language, excuses a refused command in guidance.
31
+ guidance:
32
+ negation: ['không', 'đừng', 'cấm', 'tránh']
33
+ refused: ['bị chặn', 'bị từ chối']
34
+
35
+ # scripts/kernel/ask-server.mjs drawReplyDecision - a Telegram reply made only
36
+ # of accept words (plus optionally "golden") accepts a drawing. accept is a
37
+ # word list; golden holds regex alternatives.
38
+ drawReply:
39
+ accept: ['duyệt', 'đồng', 'ý', 'chấp', 'nhận', 'được', 'đẹp', 'rồi', 'nhé', 'nha', 'ừ']
40
+ golden: ['(?:đặt làm |làm )?(?:mẫu|ảnh|hình) chuẩn', 'làm mẫu']
41
+
42
+ # scripts/kernel/progress-rca.mjs causesOf - the blocker/report phrases of
43
+ # each failure cause, whichever language the report was written in.
44
+ rca:
45
+ missingPaths: ['không tồn tại', 'vắng mặt', '0 tệp', 'quét 0']
46
+ grantTooNarrow: ['ngoài owned', 'ngoài allowlist', 'ngoài grant', 'ngoài own']
47
+ toolTimeout: ['30 giây', 'ngắt sau']
48
+ testGap: ['kiểm thử hồi quy']
49
+ checkerUnavailable: ['không khả dụng']
50
+
51
+ # scripts/supervisor/owed.mjs - notes addressed to the supervisor, conditions
52
+ # only the owner can meet, and the label heuristics.
53
+ owed:
54
+ supervisorVerbs: ['cho', 'cần', 'chờ', 'đợi', 'phản hồi(?: của)?', 'hỏi']
55
+ ownerWord: ['chủ sở hữu']
56
+ orWord: ['hoặc']
57
+ beyondAuthority: ['ngoài thẩm quyền']
58
+ ownerOnly: ['bàn giao', 'mật khẩu', 'thanh toán']
59
+ workerDied: ['không nộp report']
60
+ contractConflict: ['mâu thuẫn']
61
+ decision: ['ủy quyền', 'uỷ quyền', '\bquyết\b']
62
+
63
+ # scripts/work/draw-feedback.mjs - the owner-note classification words
64
+ # (GOLDEN_WORDS, product-direction, grammar, knowledge).
65
+ drawNote:
66
+ golden: ['mẫu chuẩn', 'ảnh chuẩn', 'hình chuẩn']
67
+ product: ['luôn', 'không bao giờ', 'mọi', 'tất cả', 'từ nay', 'màu', 'thương hiệu', 'phông', 'chữ', 'khoảng cách', 'bo góc', 'đổ bóng', 'biểu tượng']
68
+ grammar: ['thành phần mới', 'biến thể', 'thiếu thành phần', 'không có thành phần']
69
+ knowledge: ['kiến thức', 'quy tắc chung', 'nguyên tắc chung']
70
+
71
+ # scripts/work/draw/draw-quality.mjs - internal-copy jargon, actor and
72
+ # success words a render source's visible copy may carry in either language.
73
+ drawCopy:
74
+ sourceLabel: ['nguồn']
75
+ internalVocabulary: ['nguồn hiện tại', 'hệ thống lõi']
76
+ actor: ['người dùng', 'chủ']
77
+ success: ['đã cài', 'đang hoạt động', 'sẵn sàng', 'đã xác nhận', 'thành công']
78
+
79
+ # scripts/work/ui-archetype.mjs archetypeOf - surface title/intent words in
80
+ # either language that derive an archetype when ui.archetype is absent.
81
+ uiArchetype:
82
+ wizard: ['thiết lập', 'các bước']
83
+ form: ['chỉnh sửa', 'tạo mới', 'cài đặt', 'đăng nhập']
84
+ dashboard: ['tổng quan', 'trang chủ']
85
+
86
+ # scripts/work/grammar-proposal.mjs MD_FIELDS - the section markers of a
87
+ # markdown grammar proposal in either language.
88
+ proposal:
89
+ gap: ['không có', 'thiếu']
90
+ anatomy: ['giải phẫu']
91
+
92
+ # scripts/lib/redact.mjs - the verification-code label a secret value may sit
93
+ # behind (regex alternative; a separator between syllables is [ _-]?).
94
+ redact:
95
+ otpLabel: ['mã[ _-]?(?:otp|xác[ _-]?(?:thực|minh))']
@@ -311,7 +311,7 @@ const packageIndexSource = workspace => {
311
311
  };
312
312
 
313
313
  /** The source entry a workspace import resolves to without a build: its export targets mapped back to source, `src/index.ts` for the package root. */
314
- function workspaceSourceEntry(ts, workspace, specifier) {
314
+ export function workspaceSourceEntry(ts, workspace, specifier) {
315
315
  if (!workspace.workspace || !workspace.name || !sameOrUnder(specifier, workspace.name)) return null;
316
316
  const isRoot = specifier === workspace.name;
317
317
  const candidates = isRoot && !workspace.exports ? manifestEntryTargets(workspace.manifest) : exportCandidates(workspace, specifier);
@@ -2,6 +2,7 @@
2
2
  // (a slot with `appKind`) and by declared pattern (a slot with `pattern: <name>` is enabled by `sides.<side>.patterns: [<name>]`, one
3
3
  // mechanism for pattern slots). A cross-cutting pattern can instead be owned by the side's canonical proof scenario catalog.
4
4
  // slots.mjs asks this file to judge a side's lists and to say whether a slot is enabled for a repository.
5
+ import { judgedInEdition } from './edition-slots.mjs';
5
6
 
6
7
  /** The problems of a side's `optionalSlots` and `patterns` against the manifest. */
7
8
  export function optionalSlotProblems(manifest, side, s) {
@@ -24,11 +25,12 @@ export function optionalSlotProblems(manifest, side, s) {
24
25
  return bad;
25
26
  }
26
27
 
27
- /** The problems of a side's `backing` list: every name is a backing service of ruleParams.be.backing (ruling:R212/case-10). */
28
- export function backingProblems(manifest, side, s) {
29
- const services = Object.keys(manifest.ruleParams?.be?.backing ?? {});
28
+ /** The problems of a side's `backing` list: every name is admitted by ruleParams.be.backing in this edition. */
29
+ export function backingProblems(manifest, side, s, edition = 'full') {
30
+ const services = Object.entries(manifest.ruleParams?.be?.backing ?? {})
31
+ .filter(([, entry]) => judgedInEdition(entry, edition)).map(([name]) => name);
30
32
  return (s.backing ?? []).filter((name) => !services.includes(name))
31
- .map((name) => `sides.${side}.backing names ${name}, which is not one of ruleParams.be.backing (${services.join(', ')})`);
33
+ .map((name) => `sides.${side}.backing names ${name}, which is not admitted in edition ${edition} by ruleParams.be.backing (${services.join(', ')})`);
32
34
  }
33
35
 
34
36
  /** The problems of the shape of a side's `patterns` list (a unique list of pattern names). */
@@ -6,13 +6,15 @@ import { paramNamesOk } from './param-names.mjs';
6
6
  import { kindParamProblems, scenarioProblem } from './declaration-slots.mjs';
7
7
  import { NAME, namedMapValid, PACKAGE_NAME, roleListProblems, SCHEMA_AUTHORITIES, unitRolesProblems } from './manifest-shape.mjs';
8
8
  import { byCodeUnit, nonemptyUniqueList } from '../lib/list.mjs';
9
+ import { ruleEditionProblems } from './edition-slots.mjs';
9
10
 
10
11
  const PROFILES = ['be', 'fe'];
11
12
  const BE_OWN = ['infraOwners', 'eventBus', 'specDoubles', 'paramNames', 'suffixes', 'bannedSuffixes', 'contractShape', 'unitRoles', 'logicRoles', 'valueRoles', 'thinRoles', 'patternScenarios', 'kindPatterns', 'addKinds', 'backing'];
12
- const backingEntryOk = (entry) => isPlainObject(entry) && Object.keys(entry).sort(byCodeUnit).join() === 'clients,images,patterns'
13
+ const backingEntryOk = (entry) => isPlainObject(entry) && Object.keys(entry).every((key) => ['clients', 'images', 'patterns', 'editions'].includes(key))
13
14
  && nonemptyUniqueList(entry.clients, (name) => PACKAGE_NAME.test(String(name)))
14
15
  && Array.isArray(entry.patterns) && entry.patterns.every((name) => NAME.test(String(name))) && new Set(entry.patterns).size === entry.patterns.length
15
- && nonemptyUniqueList(entry.images, (name) => PACKAGE_NAME.test(String(name)));
16
+ && nonemptyUniqueList(entry.images, (name) => PACKAGE_NAME.test(String(name)))
17
+ && ruleEditionProblems(entry, 'backing service').length === 0;
16
18
  const FORM_NAMES = new Set(['call', 'new', 'curried', 'object', 'primitive', 'array']);
17
19
  const blockOk = (v) => isPlainObject(v) && Number.isInteger(v.lines) && v.lines >= 2 && Number.isInteger(v.tokens) && v.tokens >= 1 && Object.keys(v).length === 2;
18
20
  const fileLinesOk = (v) => isPlainObject(v) && Number.isInteger(v.soft) && v.soft >= 1 && typeof v.hardGrowth === 'boolean' && Object.keys(v).length === 2;
@@ -51,7 +53,7 @@ function beSpecificProblems(be, manifest) {
51
53
  const eventBus = be.eventBus;
52
54
  if (!isPlainObject(eventBus) || Object.keys(eventBus).sort(byCodeUnit).join() !== 'classes,imports' || !memberMap(eventBus.imports, true) || !memberMap(eventBus.classes, false)) bad.push('ruleParams.be.eventBus must be {imports: {<module>: [unique PascalCase members]}, classes: {<module>: [non-empty unique PascalCase classes]}}');
53
55
  if (!paramNamesOk(be.paramNames)) bad.push('ruleParams.be.paramNames must be a non-empty list of unique {type | typeSuffix, names, nameSuffix?} entries');
54
- if (!namedMapValid(be.backing, backingEntryOk)) bad.push('ruleParams.be.backing must map a backing service name to {clients: [unique package names], patterns: [unique pattern names], images: [unique image repositories]}');
56
+ if (!namedMapValid(be.backing, backingEntryOk)) bad.push('ruleParams.be.backing must map a backing service name to {clients: [unique package names], patterns: [unique pattern names], images: [unique image repositories], editions?: [unique known editions]}');
55
57
  if (!roleList(be.suffixes)) bad.push('ruleParams.be.suffixes must be a non-empty list of unique kebab-case role suffixes');
56
58
  if (!roleList(be.bannedSuffixes)) bad.push('ruleParams.be.bannedSuffixes must be a non-empty list of unique kebab-case suffixes');
57
59
  else if (roleList(be.suffixes) && be.suffixes.some((x) => be.bannedSuffixes.includes(x))) bad.push('ruleParams.be.suffixes and bannedSuffixes must be disjoint');
@@ -19,9 +19,9 @@ function requiredAppKindProblems(manifest, side, declaration) {
19
19
  }
20
20
 
21
21
  /** Problems for one side declaration, preserving the manifest's checks and their order. */
22
- export function sideProblems(manifest, side, declaration) {
22
+ export function sideProblems(manifest, side, declaration, edition = 'full') {
23
23
  const bad = sideAppKindProblems(manifest, side, declaration.apps);
24
- bad.push(...optionalSlotProblems(manifest, side, declaration), ...backingProblems(manifest, side, declaration));
24
+ bad.push(...optionalSlotProblems(manifest, side, declaration), ...backingProblems(manifest, side, declaration, edition));
25
25
  for (const read of declaration.reads ?? []) if (!manifest.sides[side].reads.includes(read)) bad.push(`sides.${side}.reads names ${read}; ${side} may read only ${manifest.sides[side].reads.join(', ') || 'nothing of the other side'}`);
26
26
  bad.push(...requiredAppKindProblems(manifest, side, declaration));
27
27
  return bad;
@@ -140,7 +140,7 @@ export function resolveRepoDeclaration(manifest, declaration, { file = HFS_DECLA
140
140
  if (declaration.kind === RUNTIME_KIND) return runtimeDeclaration(manifest, declaration, file, side);
141
141
  const { edition, known, valid } = declarationEdition(manifest, declaration);
142
142
  if (!valid) fail('HFS_EDITION_INVALID', `hfs.json edition is ${JSON.stringify(declaration.edition)}; the editions this manifest knows are ${known.join(', ')} (absent means full)`, { file, edition: declaration.edition });
143
- const bad = PROFILES.flatMap((name) => sideProblems(manifest, name, declaration.sides[name]));
143
+ const bad = PROFILES.flatMap((name) => sideProblems(manifest, name, declaration.sides[name], edition));
144
144
  if (bad.length) declarationInvalid(bad, file);
145
145
  if (side !== null && !PROFILES.includes(side)) fail('HFS_DECLARATION_INVALID', `${side} is not a side of an app (be, fe)`, { file, side });
146
146
  // A provider declared on any connection enables the provider slots of every profile (a fe side declares none): each view carries the union.
@@ -56,6 +56,45 @@ const ASSIGNED_UNQUOTED = new RegExp(
56
56
  SECRET_NAME + String.raw`(?!['"\`]|env\()([A-Za-z0-9_+=-]{12,})(?![A-Za-z0-9_+=.(\[-])`,
57
57
  );
58
58
 
59
+ const SQL_ROLE_HEAD = /^\s*(?:CREATE|ALTER)\s+(?:ROLE|USER)\b/i;
60
+ const SQL_STATEMENT = /[^;\r\n]+/g;
61
+ const SQL_PASSWORD_WORD = /\bPASSWORD\s+/gi;
62
+ const PSQL_PASSWORD_REFERENCE = /:(?:'[A-Za-z_]\w*'|"[A-Za-z_]\w*")/y;
63
+ const SQL_PASSWORD_QUOTED = /'([^'\r\n]{12,})'/y;
64
+ const ASSIGNED_PLACEHOLDER = /fixture|stub|fake|dummy|placeholder|example|sample|changeme|redacted|mock/i;
65
+ const SQL_PASSWORD_RULE = { name: 'assigned-secret' };
66
+
67
+ /** Read only password tokens in role statements; sticky value readers never retry at later character positions. */
68
+ function sqlPasswordParts(file, text) {
69
+ const references = [], literals = [];
70
+ if (!/\.(?:sql|sh)(?:\.tpl)?$/i.test(file ?? '')) return { references, literals };
71
+ for (const statement of text.matchAll(SQL_STATEMENT)) {
72
+ if (!SQL_ROLE_HEAD.test(statement[0])) continue;
73
+ for (const keyword of statement[0].matchAll(SQL_PASSWORD_WORD)) {
74
+ const start = statement.index + keyword.index + keyword[0].length;
75
+ PSQL_PASSWORD_REFERENCE.lastIndex = start;
76
+ SQL_PASSWORD_QUOTED.lastIndex = start;
77
+ const reference = PSQL_PASSWORD_REFERENCE.exec(text);
78
+ const literal = SQL_PASSWORD_QUOTED.exec(text);
79
+ if (reference) references.push([start, start + reference[0].length]);
80
+ if (literal) literals.push(literal[1]);
81
+ }
82
+ }
83
+ return { references, literals };
84
+ }
85
+
86
+ /** Psql variable references retain their positions while the assignment heuristic sees no literal value there. */
87
+ function maskedReferences(text, references) {
88
+ const parts = [];
89
+ let cursor = 0;
90
+ for (const [start, end] of references) {
91
+ parts.push(text.slice(cursor, start), ' '.repeat(end - start));
92
+ cursor = end;
93
+ }
94
+ parts.push(text.slice(cursor));
95
+ return parts.join('');
96
+ }
97
+
59
98
  const NAME_WORD = (word, tail) => new RegExp(`^${NAME_REST}${SEGMENT_BEFORE}(?:${word})${tail}`);
60
99
  const SECRET_NAME_TEST = NAME_WORD(SECRET_WORD, String.raw`(?:[_\-.]|[A-Z]|[^A-Za-z0-9_$]|$)`);
61
100
  const BENIGN_TAIL_TEST = NAME_WORD(BENIGN_WORD, '$');
@@ -84,27 +123,37 @@ export const SECRET_PATTERNS = [
84
123
  // A spec file's keyword-assigned values are test inputs (a spec's sign-in
85
124
  // form password and a mocked accessToken once refused a push): the heuristic skips spec files too.
86
125
  // A SCREAMING_SNAKE quoted value in source is a public code (an enum member whose value is an upper-case code), not a credential.
87
- { name: 'assigned-secret', re: ASSIGNED_QUOTED, sourceCode: /^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+$/, placeholder: /fixture|stub|fake|dummy|placeholder|example|sample|changeme|redacted|mock/i,
126
+ { name: 'assigned-secret', re: ASSIGNED_QUOTED, sourceCode: /^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+$/, placeholder: ASSIGNED_PLACEHOLDER,
88
127
  skipFile: /\.(?:spec|test|e2e-spec)\.[cm]?[jt]sx?$/ },
89
128
  // An unquoted value in TypeScript or JavaScript source is an identifier or a type (`token: InjectionToken`,
90
129
  // `MODULE_OPTIONS_TOKEN: DATABASE_OPTIONS_TOKEN`), never a literal; the unquoted form is the dotenv, compose and toml shape.
91
- { name: 'assigned-secret', re: ASSIGNED_UNQUOTED, placeholder: /fixture|stub|fake|dummy|placeholder|example|sample|changeme|redacted|mock/i,
130
+ { name: 'assigned-secret', re: ASSIGNED_UNQUOTED, placeholder: ASSIGNED_PLACEHOLDER,
92
131
  skipFile: /\.(?:spec|test|e2e-spec)\.[cm]?[jt]sx?$|\.[cm]?[jt]sx?$/ },
93
132
  ];
94
133
 
95
134
 
96
- /** The names of the patterns one line of `file` matches (never the value). A stand-in value and a spec file's keyword assignment are no hit. */
97
- export function secretHits(file, text) {
98
- const names = [];
135
+ /** Matched pattern metadata stays private; callers receive pattern names, never credential values. */
136
+ function matchingSecretPatterns(file, text) {
137
+ const matches = [];
138
+ const sql = sqlPasswordParts(file, text);
139
+ const assignments = maskedReferences(text, sql.references);
99
140
  for (const rule of SECRET_PATTERNS) {
100
141
  if (rule.skipFile?.test(file ?? '')) continue;
101
- const hit = rule.re.exec(text);
102
- if (hit && rule.sourceCode?.test(hit[1] ?? '') && /\.[cm]?[jt]sx?$/.test(file ?? '')) continue;
103
- if (hit && !rule.placeholder?.test(hit[1] ?? '')) names.push(rule.name);
142
+ const hit = rule.re.exec(rule.name === 'assigned-secret' ? assignments : text);
143
+ if (hit && !rule.placeholder?.test(hit[1] ?? '')) matches.push({ rule, value: hit[1] ?? '' });
104
144
  }
105
- return names;
145
+ for (const value of sql.literals) if (!ASSIGNED_PLACEHOLDER.test(value)) matches.push({ rule: SQL_PASSWORD_RULE, value });
146
+ return matches;
106
147
  }
107
148
 
149
+ /** The strict secret-shape judgement used by Work custody, including uppercase keyword-assigned credentials. */
150
+ export const secretPatternHits = (file, text) => matchingSecretPatterns(file, text).map(({ rule }) => rule.name);
151
+
152
+ /** The source judgement also admits public SCREAMING_SNAKE codes; every provider shape remains refused. */
153
+ export const secretHits = (file, text) => matchingSecretPatterns(file, text)
154
+ .filter(({ rule, value }) => !(rule.sourceCode?.test(value) && /\.[cm]?[jt]sx?$/.test(file ?? '')))
155
+ .map(({ rule }) => rule.name);
156
+
108
157
  const BADGE_BEGIN = '<!-- >>> starci app sync (managed block; do not edit) >>> -->';
109
158
  const BADGE_END = '<!-- <<< starci app sync <<< -->';
110
159