@starci/eslint-canon-fe 8.4.39 → 8.4.40

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.
Files changed (54) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +6 -0
  3. package/callback-shape.mjs +1 -13
  4. package/docs/grammar-boundary.md +25 -1
  5. package/docs/surface-rules.md +41 -0
  6. package/grammar-boundary.mjs +112 -0
  7. package/hooks-folder.mjs +9 -3
  8. package/icon.mjs +31 -1
  9. package/index.mjs +4 -0
  10. package/lib/bindings.mjs +15 -0
  11. package/lib/config.mjs +1 -0
  12. package/lib/fe-stack.mjs +8 -4
  13. package/lib/scope.mjs +2 -2
  14. package/lib/typed-class-values.mjs +72 -0
  15. package/lib/why.mjs +17 -2
  16. package/lint-escape-hatch.mjs +2 -2
  17. package/menu-entries.mjs +133 -0
  18. package/package.json +1 -1
  19. package/runtime/knowledge/hfs/canon-pins.yaml +65 -9
  20. package/runtime/knowledge/hfs/fe-stack.yaml +23 -5
  21. package/runtime/knowledge/hfs/slots.yaml +110 -52
  22. package/runtime/knowledge/sonar-gate.yaml +1 -1
  23. package/runtime/modules/kernel/removed-vocabulary.yaml +62 -0
  24. package/runtime/modules/models/registry.yaml +3 -1
  25. package/runtime/modules/models/runtimes.yaml +2 -0
  26. package/runtime/scripts/api/git/ls-tree.mjs +5 -0
  27. package/runtime/scripts/api/git/show.mjs +6 -0
  28. package/runtime/scripts/hfs/architecture/dead-exports.mjs +10 -10
  29. package/runtime/scripts/hfs/architecture/function-spec-providers.mjs +1 -1
  30. package/runtime/scripts/hfs/architecture/operation-contract-roots.mjs +53 -0
  31. package/runtime/scripts/hfs/architecture/reachability.mjs +1 -1
  32. package/runtime/scripts/hfs/architecture/sql-owner.mjs +17 -6
  33. package/runtime/scripts/hfs/architecture/sql-token-forms.mjs +105 -0
  34. package/runtime/scripts/hfs/architecture/sql-tokens.mjs +38 -52
  35. package/runtime/scripts/hfs/architecture/supabase-keys.mjs +261 -0
  36. package/runtime/scripts/hfs/architecture/transport-owner.mjs +49 -3
  37. package/runtime/scripts/hfs/declaration-shape.mjs +4 -4
  38. package/runtime/scripts/hfs/declaration-slots.mjs +12 -3
  39. package/runtime/scripts/hfs/manifest-shape.mjs +10 -6
  40. package/runtime/scripts/hfs/rule-params-shape.mjs +16 -6
  41. package/runtime/scripts/hfs/rules/database-constants.mjs +26 -0
  42. package/runtime/scripts/hfs/rules/database-migrations.mjs +489 -0
  43. package/runtime/scripts/hfs/slot-side-problems.mjs +2 -2
  44. package/runtime/scripts/hfs/slots.mjs +1 -0
  45. package/runtime/scripts/hfs/view.mjs +2 -1
  46. package/runtime/scripts/lib/in-order.mjs +71 -0
  47. package/runtime/scripts/lib/language.mjs +94 -1
  48. package/runtime/scripts/lib/package-at.mjs +62 -0
  49. package/runtime/scripts/lib/same-text.mjs +5 -0
  50. package/runtime/scripts/lib/ts-analysis.mjs +10 -0
  51. package/size-and-state-budget.mjs +3 -3
  52. package/surface-rules.mjs +230 -0
  53. package/swr-family.mjs +136 -7
  54. package/transport.mjs +2 -1
@@ -15,7 +15,7 @@ export const SECOND_LANGUAGE_LETTER = /[À-ÃÈ-ÊÌÍÒ-ÕÙÚÝà-ãè-êìí
15
15
  export const hasSecondLanguage = (text) => typeof text === 'string' && SECOND_LANGUAGE_LETTER.test(text.normalize('NFC'));
16
16
 
17
17
  /** Every line of `text` with a Vietnamese letter: `[{ line, column }]`, both 1-based. */
18
- export function secondLanguageHits(text) {
18
+ function secondLanguageHits(text) {
19
19
  const hits = [];
20
20
  String(text).split(/\r?\n/).forEach((raw, index) => {
21
21
  const match = SECOND_LANGUAGE_LETTER.exec(raw.normalize('NFC'));
@@ -24,6 +24,99 @@ export function secondLanguageHits(text) {
24
24
  return hits;
25
25
  }
26
26
 
27
+ /** The named bindings of an import from the backend message catalog contract. */
28
+ function messageCatalogBindings(ts, statement) {
29
+ if (!ts.isImportDeclaration(statement) || statement.moduleSpecifier.text !== '@modules/platform/i18n') return null;
30
+ return statement.importClause?.namedBindings ?? null;
31
+ }
32
+
33
+ /** The local type names imported from the backend message catalog contract. */
34
+ function messageBundleImports(ts, source) {
35
+ const named = new Set();
36
+ const namespaces = new Set();
37
+ for (const statement of source.statements) {
38
+ const bindings = messageCatalogBindings(ts, statement);
39
+ if (!bindings) continue;
40
+ if (ts.isNamespaceImport(bindings)) namespaces.add(bindings.name.text);
41
+ for (const entry of ts.isNamedImports(bindings) ? bindings.elements : []) {
42
+ if ((entry.propertyName ?? entry.name).text === 'MessageBundle') named.add(entry.name.text);
43
+ }
44
+ }
45
+ return { named, namespaces };
46
+ }
47
+
48
+ /** A declaration that introduces a same-named local type or namespace. */
49
+ function declaresTypeName(ts, statement, name) {
50
+ if (statement.name?.text !== name) return false;
51
+ return [ts.isTypeAliasDeclaration, ts.isInterfaceDeclaration, ts.isClassDeclaration, ts.isEnumDeclaration, ts.isModuleDeclaration]
52
+ .some(predicate => predicate(statement));
53
+ }
54
+
55
+ /** A type reference bound to the imported MessageBundle contract, including import aliases. */
56
+ function isMessageBundleType(ts, type, imports) {
57
+ if (!type || !ts.isTypeReferenceNode(type)) return false;
58
+ const name = type.typeName;
59
+ if (ts.isIdentifier(name)) return imports.named.has(name.text) && !shadowsImportedType(ts, type, name.text);
60
+ return ts.isQualifiedName(name) && ts.isIdentifier(name.left)
61
+ && imports.namespaces.has(name.left.text) && name.right.text === 'MessageBundle' && !shadowsImportedType(ts, type, name.left.text);
62
+ }
63
+
64
+ /** A local declaration or generic type parameter takes precedence over a same-named import. */
65
+ function shadowsImportedType(ts, node, name) {
66
+ for (let scope = node.parent; scope; scope = scope.parent) {
67
+ if (scope.typeParameters?.some(parameter => parameter.name.text === name)) return true;
68
+ if (scope.statements?.some(statement => declaresTypeName(ts, statement, name))) return true;
69
+ }
70
+ return false;
71
+ }
72
+
73
+ /** The literal object supplied by a variable typed with the imported MessageBundle contract. */
74
+ function messageCatalogObject(ts, node, imports) {
75
+ if (!ts.isVariableDeclaration(node) || !node.initializer) return null;
76
+ let value = node.initializer;
77
+ const type = ts.isSatisfiesExpression(value) ? value.type : node.type;
78
+ if (ts.isSatisfiesExpression(value)) value = value.expression;
79
+ return isMessageBundleType(ts, type, imports) && ts.isObjectLiteralExpression(value) ? value : null;
80
+ }
81
+
82
+ /** Literal string values in the Vietnamese locale of one message catalog. */
83
+ function vietnameseValueRanges(ts, catalog, source) {
84
+ const ranges = [];
85
+ for (const locale of catalog.properties) {
86
+ if (!ts.isPropertyAssignment(locale) || locale.name.text !== 'vi' || !ts.isObjectLiteralExpression(locale.initializer)) continue;
87
+ for (const message of locale.initializer.properties) {
88
+ if (ts.isPropertyAssignment(message) && ts.isStringLiteralLike(message.initializer))
89
+ ranges.push({ start: message.initializer.getStart(source), end: message.initializer.end });
90
+ }
91
+ }
92
+ return ranges;
93
+ }
94
+
95
+ /** Literal Vietnamese message values of typed catalogs; keys, comments, English values and expressions remain source. */
96
+ function messageBundleValueRanges(ts, source) {
97
+ const imports = messageBundleImports(ts, source);
98
+ const ranges = [];
99
+ const visit = (node) => {
100
+ const catalog = messageCatalogObject(ts, node, imports);
101
+ if (catalog) ranges.push(...vietnameseValueRanges(ts, catalog, source));
102
+ ts.forEachChild(node, visit);
103
+ };
104
+ visit(source);
105
+ return ranges;
106
+ }
107
+
108
+ /** Source-language hits, treating only literal vi values of typed backend MessageBundle objects as translated product data. */
109
+ export function sourceLanguageHits(rel, text, ts) {
110
+ if (!hasSecondLanguage(text)) return [];
111
+ if (!ts || !/\.[cm]?tsx?$/.test(rel)) return secondLanguageHits(text);
112
+ const source = ts.createSourceFile(rel, text, ts.ScriptTarget.Latest, true);
113
+ let sourceText = text;
114
+ for (const range of messageBundleValueRanges(ts, source).sort((left, right) => right.start - left.start)) {
115
+ sourceText = sourceText.slice(0, range.start) + sourceText.slice(range.start, range.end).replace(/[^\r\n]/g, ' ') + sourceText.slice(range.end);
116
+ }
117
+ return secondLanguageHits(sourceText);
118
+ }
119
+
27
120
  /**
28
121
  * The Vietnamese fields of one failure-code entry: the operator text the owner mandated for the failure-code catalog
29
122
  * (scripts/checks/check-failure-codes.mjs types the entry with exactly these). causes_vi is the list companion of the three scalar fields.
@@ -0,0 +1,62 @@
1
+ // scripts/lib/package-at.mjs — a package the runtime does not ship (playwright, esbuild, tailwindcss) comes from the
2
+ // project that owns it: node resolution from that project's directory, walking up its node_modules.
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { createRequire } from 'node:module';
6
+ import { skillRoot } from '../../engine/runtime-root.mjs';
7
+
8
+ /** The package.json of `name` as resolved from `dir`, or null. */
9
+ export const packageAt = (dir, name) => {
10
+ try { return createRequire(path.join(dir, 'package.json')).resolve(`${name}/package.json`); } catch { return null; }
11
+ };
12
+
13
+ /** This runtime's own `starci` bin (packages/cli is the only package that declares one). */
14
+ const RUNTIME_STARCI_BIN = path.join(skillRoot, 'packages', 'cli', 'bin', 'starci.mjs');
15
+
16
+ /** The `starci` bin of the @starci/cli that `dir` installs (a product runs its own pinned CLI), else this runtime's own. */
17
+ function starciBin(dir) {
18
+ const packageFile = packageAt(dir, '@starci/cli');
19
+ if (packageFile) {
20
+ try {
21
+ const { bin } = JSON.parse(fs.readFileSync(packageFile, 'utf8'));
22
+ const rel = typeof bin === 'string' ? bin : bin?.starci;
23
+ const file = rel ? path.join(path.dirname(packageFile), rel) : null;
24
+ if (file && fs.existsSync(file)) return file;
25
+ } catch { /* an unreadable install falls back to the runtime's own CLI */ }
26
+ }
27
+ return RUNTIME_STARCI_BIN;
28
+ }
29
+
30
+ /** The first of `names` resolvable from the first of `dirs` that has one: {name, root, packageFile, version} or null. */
31
+ export function findPackage(dirs, names) {
32
+ for (const dir of dirs.filter(Boolean)) {
33
+ for (const name of names) {
34
+ const packageFile = packageAt(dir, name);
35
+ if (!packageFile) continue;
36
+ const require = createRequire(packageFile);
37
+ return { name, root: path.dirname(packageFile), packageFile, version: require(packageFile).version };
38
+ }
39
+ }
40
+ return null;
41
+ }
42
+
43
+ /** The CommonJS entry of a package found by findPackage. */
44
+ export const requirePackage = (found) => createRequire(found.packageFile)(found.name);
45
+
46
+ /** The TypeScript compiler resolvable from the first of `dirs` (an app root, then the caller's own), or null. */
47
+ export const loadTypescript = (...dirs) => {
48
+ const located = findPackage(dirs, ['typescript']);
49
+ return located ? requirePackage(located) : null;
50
+ };
51
+
52
+ /**
53
+ * The app's command line and the app implementation behind it: `bin` the `starci` bin of the app's installed @starci/cli, else
54
+ * this runtime's packages/cli/bin/starci.mjs (starciBin); `dir` the @starci/hfs that answers
55
+ * `starci app` - the app's own install, else the one beside its @starci/cli, else this runtime's packages/hfs - whose runtime
56
+ * slice the gate's base lint (scripts/gates/gate.mjs) and the READ digest (scripts/gates/read-digest.mjs) load in-process.
57
+ */
58
+ export function hfsEntry(root) {
59
+ const cli = packageAt(root, '@starci/cli');
60
+ const manifest = packageAt(root, '@starci/hfs') ?? (cli && packageAt(path.dirname(cli), '@starci/hfs'));
61
+ return { dir: manifest ? path.dirname(manifest) : path.join(skillRoot, 'packages', 'hfs'), bin: starciBin(root) };
62
+ }
@@ -0,0 +1,5 @@
1
+ // same-text.mjs - text equality that ignores the line-ending form and a trailing blank tail (a git runner may trim stdout, a work tree may
2
+ // carry CRLF): the one comparison the database rules use for a committed file against its base or against a regenerated one.
3
+
4
+ /** True when both texts exist and are equal once CRLF is folded to LF and trailing whitespace at the end is dropped. */
5
+ export const sameText = (a, b) => a !== null && b !== null && a.replaceAll('\r\n', '\n').trimEnd() === b.replaceAll('\r\n', '\n').trimEnd();
@@ -25,3 +25,13 @@ export function importsOf(ts, sourceFile, { moduleKey = 'module', stringLiterals
25
25
 
26
26
  /** Catalog values unwrap parentheses, `as`, and `satisfies`, preserving assertions and non-null nodes. */
27
27
  export const unwrapCatalogExpression = (ts, node) => unwrapExpression(ts, node, { typeAssertions: false, nonNullExpressions: false });
28
+
29
+ /** Bind this file in isolation: the compiler resolves lexical names, but imports and library files are never loaded. */
30
+ export function lexicalChecker(ts, source) {
31
+ const options = { noLib: true, noResolve: true, allowJs: true };
32
+ const host = ts.createCompilerHost(options);
33
+ host.getSourceFile = (file) => file === source.fileName ? source : undefined;
34
+ host.fileExists = () => false;
35
+ host.readFile = () => undefined;
36
+ return ts.createProgram([source.fileName], options, host).getTypeChecker();
37
+ }
@@ -205,9 +205,9 @@ const readsData = (context, fn, seen = new Set()) => {
205
205
  * No hand-written polling loop in a component or hook.
206
206
  *
207
207
  * A timer is polling when what it runs READS DATA (see `readsData`, decided by the type of each call, not by a name): a
208
- * `setInterval`, or a `setTimeout` whose callback schedules the enclosing function again. The FE convention names no shared clock
209
- * owner (knowledge/hfs/README.md section 6: "one refresh mechanism per resource, poll or socket"), so a timer that only ticks time
210
- * or state - a `useNow`-style one-minute clock behind `useSyncExternalStore` - is not a second refresh mechanism.
208
+ * `setInterval`, or a `setTimeout` whose callback schedules the enclosing function again. A timer that only ticks time or state
209
+ * is not a data-refresh mechanism. Grammar's live-time ownership is independently enforced by `no-app-relative-clock` in
210
+ * `grammar-boundary.mjs`.
211
211
  */
212
212
  export const noHandRolledPolling = {
213
213
  meta: {
@@ -0,0 +1,230 @@
1
+ /** Grammar owns surface nesting and the single content edge of a panel list (ANATOMY-2, PADDING-0). */
2
+ import { attributeValue, stringOf } from "./lib/ast.mjs"
3
+ import { importOf, isFn, patternPath, unwrap, variableOf } from "./lib/bindings.mjs"
4
+ import { typedClassStrings, typedPropertyStrings } from "./lib/typed-class-values.mjs"
5
+ import { reactHookOf } from "./lib/react.mjs"
6
+
7
+ const GRAMMAR_ENTRY = /^@starci\/grammar(?:\/(?:common|core|heritage|offset-pop))?$/
8
+ const SURFACES = new Set([
9
+ "SurfaceCard", "SurfaceListCard", "SurfaceAccordionCard", "Rail", "Sidebar",
10
+ "Dialog", "Drawer", "AlertDialog", "Popover",
11
+ ])
12
+ const FIELDS = new Set([
13
+ "Input", "TextField", "Textarea", "TextArea", "SearchField", "NumberField", "DateField", "TimeField",
14
+ "Select", "ComboBox", "DatePicker", "DateRangePicker", "Checkbox", "CheckboxGroup", "RadioGroup", "PressableField", "OtpInput",
15
+ ])
16
+ const CLASS_BUILDERS = new Set(["cn", "clsx", "classNames", "cva", "tv"])
17
+
18
+ /** Resolve a JSX name to its Grammar export and optional compound parts, preserving lexical import identity. */
19
+ const grammarName = (context, name) => {
20
+ if (name.type === "JSXMemberExpression") {
21
+ const owner = grammarName(context, name.object)
22
+ return owner === null ? null : [...owner, name.property.name]
23
+ }
24
+ if (name.type !== "JSXIdentifier") return null
25
+ const imported = importOf(context, name)
26
+ if (!imported || !GRAMMAR_ENTRY.test(imported.source)) return null
27
+ if (imported.imported !== null) return [imported.imported]
28
+ const binding = variableOf(context, name)?.defs.find((definition) => definition.type === "ImportBinding")
29
+ return binding?.node.type === "ImportNamespaceSpecifier" ? [] : null
30
+ }
31
+
32
+ /** Class and variant expressions expose their statically authored strings, including const aliases and cn branches. */
33
+ const authoredStrings = (context, input, seen = new Set(), mode = "classes") => {
34
+ const node = unwrap(input?.type === "JSXExpressionContainer" ? input.expression : input)
35
+ if (!node || seen.has(node)) return []
36
+ seen.add(node)
37
+ const text = stringOf(node)
38
+ if (text !== null) return [{ node, text }]
39
+ if (node.type === "TemplateElement") return [{ node, text: node.value.cooked ?? node.value.raw }]
40
+ if (node.type === "Identifier") {
41
+ const values = bindingValues(context, node)
42
+ return values.length ? values.flatMap((value) => authoredStrings(context, value, seen, mode)) : typedClassStrings(context, node, mode)
43
+ }
44
+ if (node.type === "MemberExpression") {
45
+ const propertyName = node.computed ? stringOf(node.property) : node.property.name
46
+ const values = propertyValues(context, node.object, propertyName)
47
+ return values.length ? values.flatMap((value) => authoredStrings(context, value, seen, mode)) : typedClassStrings(context, node, mode)
48
+ }
49
+ const children = stringChildren(node, mode)
50
+ return children.flatMap((child) => authoredStrings(context, child, seen, mode))
51
+ }
52
+
53
+ /** Only value-bearing positions contribute classes; object keys support cn's conditional class dictionary. */
54
+ const stringChildren = (node, mode) => {
55
+ if (node.type === "TemplateLiteral") return mode === "classes" ? [...node.quasis, ...node.expressions] : []
56
+ if (node.type === "ArrayExpression") return node.elements
57
+ if (node.type === "CallExpression") return mode === "classes" ? node.arguments : []
58
+ if (node.type === "ConditionalExpression") return [node.consequent, node.alternate]
59
+ if (node.type === "LogicalExpression") return node.operator === "&&" ? [node.right] : [node.left, node.right]
60
+ if (node.type === "ObjectExpression") return node.properties.flatMap((property) => property.type === "Property" ? [property.key, property.value] : [property.argument])
61
+ return []
62
+ }
63
+
64
+ /** A declared React component is a separate render tree; inline collection/render callbacks keep the surrounding context. */
65
+ const componentBoundary = (context, node) => {
66
+ if (!isFn(node)) return false
67
+ if (node.parent?.type === "CallExpression" && ["memo", "forwardRef"].includes(reactHookOf(context, node.parent))) return true
68
+ if (node.parent?.type === "JSXExpressionContainer" || node.parent?.type === "CallExpression" && node.parent.arguments.includes(node)) return false
69
+ const name = node.id?.name ?? (node.parent?.type === "VariableDeclarator" ? node.parent.id.name : null)
70
+ return name !== null && /^[A-Z]/.test(name)
71
+ }
72
+
73
+ /** The Grammar ancestors whose rendered content contains this node, excluding an overlay's trigger. */
74
+ function* surfaceAncestors(context, node) {
75
+ const triggers = new Set()
76
+ for (let current = node.parent; current; current = current.parent) {
77
+ if (componentBoundary(context, current)) return
78
+ if (current.type === "JSXAttribute" && current.name.name === "trigger") triggers.add(current.parent)
79
+ if (current.type === "JSXElement" && !triggers.has(current.openingElement)) yield current.openingElement
80
+ }
81
+ }
82
+
83
+ const isSurface = (context, node, descendant) => {
84
+ const name = grammarName(context, node.name)
85
+ if (name?.length !== 1) return false
86
+ if (SURFACES.has(name[0])) return true
87
+ if (name[0] !== "ChatWorkspace") return false
88
+ for (let current = descendant; current && current !== node.parent; current = current.parent) {
89
+ if (current.type === "JSXAttribute" && current.parent === node && current.name.name === "rail") return true
90
+ }
91
+ return false
92
+ }
93
+ const classTokens = (text) => text.split(/\s+/).map((token) => token.split(":").at(-1).replace(/^!|!$/g, ""))
94
+ const rawSurface = (token) => /^bg-surface(?:-[a-z0-9-]+)?(?:\/[^\s]+)?$/.test(token)
95
+ const horizontalPadding = (token) => /^(?:px|ps|pe)-.+$/.test(token)
96
+
97
+ /** Computed keys select their evaluated static name, rather than the identifier's spelling. */
98
+ const propertyName = (context, property) => {
99
+ if (!property.computed) return property.key.name ?? property.key.value
100
+ const values = authoredStrings(context, property.key, new Set(), "variant")
101
+ return values.length === 1 ? values[0].text : null
102
+ }
103
+
104
+ /** A binding selects its own object property; sibling declarations do not become its value. */
105
+ const bindingValues = (context, node, seen = new Set()) => {
106
+ const definition = variableOf(context, node)?.defs.find((entry) => entry.type === "Variable")
107
+ if (!definition) return []
108
+ const path = patternPath(definition.node.id, node.name)
109
+ if (path === null) return []
110
+ return path.reduce((values, key) => values.flatMap((value) => propertyValues(context, value, key, seen)), [definition.node.init]).filter(Boolean)
111
+ }
112
+
113
+ /** Static object spreads retain the same published-prop obligations as ordinary JSX attributes. */
114
+ const propertyValues = (context, input, key, seen = new Set()) => {
115
+ const node = unwrap(input)
116
+ if (!node || seen.has(node)) return []
117
+ seen.add(node)
118
+ if (node.type === "Identifier") {
119
+ return bindingValues(context, node, seen).flatMap((value) => propertyValues(context, value, key, seen))
120
+ }
121
+ if (node.type === "MemberExpression") {
122
+ const member = node.computed ? stringOf(node.property) : node.property.name
123
+ return propertyValues(context, node.object, member, seen).flatMap((value) => propertyValues(context, value, key, seen))
124
+ }
125
+ if (node.type === "ConditionalExpression") return [node.consequent, node.alternate].flatMap((branch) => propertyValues(context, branch, key, seen))
126
+ if (node.type !== "ObjectExpression") return []
127
+ return node.properties.flatMap((property) => {
128
+ if (property.type === "SpreadElement") return propertyValues(context, property.argument, key, seen)
129
+ return propertyName(context, property) === key ? [property.value] : []
130
+ })
131
+ }
132
+
133
+ const propStrings = (context, entry, name, mode = "classes") => {
134
+ if (entry.type === "JSXSpreadAttribute") {
135
+ const values = propertyValues(context, entry.argument, name)
136
+ return values.length ? values.flatMap((value) => authoredStrings(context, value, new Set(), mode)) : typedPropertyStrings(context, entry.argument, name, mode)
137
+ }
138
+ return entry.name.name === name ? authoredStrings(context, attributeValue(entry), new Set(), mode) : []
139
+ }
140
+
141
+ /** An explicit section caption excludes the row's own content subtree. */
142
+ const inSectionCaption = (context, node) => {
143
+ for (const ancestor of surfaceAncestors(context, node.parent)) {
144
+ const owner = grammarName(context, ancestor.name)
145
+ if (owner?.[0] !== "ListBox") continue
146
+ if (owner[1] === "Item") return false
147
+ if (["Section", "Header", "SectionHeader"].includes(owner[1])) return true
148
+ }
149
+ return false
150
+ }
151
+
152
+ /** ANATOMY-2: Grammar provides the secondary default; apps cannot force primary or paint a raw surface. */
153
+ export const nestedControlVariant = {
154
+ meta: {
155
+ type: "problem",
156
+ docs: { description: "ANATOMY-2: nested fields use Grammar's secondary default and surfaces belong to Grammar." },
157
+ schema: [],
158
+ messages: {
159
+ primary: "FE_NESTED_VARIANT: a field inside a Grammar surface cannot force variant=\"primary\". Omit variant so Grammar supplies secondary through its nesting context.",
160
+ surface: "FE_NESTED_VARIANT: bg-surface paints a surface in app CSS. Compose SurfaceCard, Rail, Sidebar or another Grammar surface instead.",
161
+ },
162
+ },
163
+ create(context) {
164
+ const reported = new Set()
165
+ const checkClasses = (value) => {
166
+ for (const entry of authoredStrings(context, value)) {
167
+ if (!classTokens(entry.text).some(rawSurface) || reported.has(entry.node)) continue
168
+ reported.add(entry.node)
169
+ context.report({ node: entry.node, messageId: "surface" })
170
+ }
171
+ }
172
+ return {
173
+ JSXOpeningElement(node) {
174
+ const name = grammarName(context, node.name)
175
+ const variant = node.attributes.find((entry) => propStrings(context, entry, "variant", "variant").some((value) => value.text === "primary"))
176
+ if (name?.length !== 1 || !FIELDS.has(name[0]) || !variant) return
177
+ if ([...surfaceAncestors(context, node.parent)].some((ancestor) => isSurface(context, ancestor, node))) context.report({ node: variant, messageId: "primary" })
178
+ },
179
+ JSXAttribute(node) {
180
+ if (["className", "class", "classNames"].includes(node.name.name)) checkClasses(node.value)
181
+ },
182
+ CallExpression(node) {
183
+ if (node.callee.type === "Identifier" && CLASS_BUILDERS.has(node.callee.name)) checkClasses(node)
184
+ },
185
+ VariableDeclarator(node) {
186
+ if (/[Cc]lass/.test(node.id.name ?? "") || /(?:^|[/\\])classNames\.[cm]?[jt]sx?$/.test(context.filename)) checkClasses(node.init)
187
+ },
188
+ Property(node) {
189
+ if (["className", "class", "classNames"].includes(propertyName(context, node))) checkClasses(node.value)
190
+ },
191
+ }
192
+ },
193
+ }
194
+
195
+ /** PADDING-0: app padding wrappers cannot add a second horizontal inset to Grammar's list or section caption. */
196
+ export const noListPanelPadding = {
197
+ meta: {
198
+ type: "problem",
199
+ docs: { description: "PADDING-0: panel lists and section captions share the Grammar surface's content edge." },
200
+ schema: [],
201
+ messages: { padding: "FE_LIST_PANEL_PADDING: horizontal padding stacks with the Grammar list inset. Remove this wrapper padding and use ListBox inset=\"panel\" so rows and section captions share the panel content edge." },
202
+ },
203
+ create(context) {
204
+ const reported = new Set()
205
+ const check = (opening) => {
206
+ const className = opening.attributes.find((entry) => ["className", "class"].some((name) => propStrings(context, entry, name).some((value) => classTokens(value.text).some(horizontalPadding))))
207
+ if (!className || reported.has(className)) return
208
+ reported.add(className)
209
+ context.report({ node: className, messageId: "padding" })
210
+ }
211
+ return {
212
+ JSXOpeningElement(node) {
213
+ const name = grammarName(context, node.name)
214
+ if (name?.[0] === "ListBox" && (name.length === 1 || ["Section", "Header", "SectionHeader"].includes(name[1]))) {
215
+ check(node)
216
+ for (const ancestor of surfaceAncestors(context, node.parent)) {
217
+ check(ancestor)
218
+ if (isSurface(context, ancestor, node)) break
219
+ }
220
+ } else if (inSectionCaption(context, node)) check(node)
221
+ },
222
+ }
223
+ },
224
+ }
225
+
226
+ /** The surface laws ship at error in every composing source tree. */
227
+ export const rules = { "nested-control-variant": nestedControlVariant, "no-list-panel-padding": noListPanelPadding }
228
+
229
+ /** No app can lower or omit a surface law. */
230
+ export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
package/swr-family.mjs CHANGED
@@ -1,8 +1,9 @@
1
1
  /** R285: API operations are wrapped by the SWR family before product code calls them. */
2
2
  import { hfsOf } from "./lib/hfs.mjs"
3
3
  import { readFileSync, readdirSync } from "node:fs"
4
- import { join } from "node:path"
4
+ import { dirname, join, resolve } from "node:path"
5
5
  import { stringOf, walk } from "./lib/ast.mjs"
6
+ import { functionOf } from "./lib/bindings.mjs"
6
7
  import { classOf, fileOf, globalReferences, isApiLayer, isProductSource, resolveImport } from "./lib/scope.mjs"
7
8
  import { why } from "./lib/why.mjs"
8
9
 
@@ -13,7 +14,7 @@ const isSwrHook = (context) => {
13
14
  }
14
15
 
15
16
  /** Cache providers and revalidation may be used by consumers; only request hooks own operations. */
16
- const isRequestHook = (name) => /^useSWR(?:Immutable|Infinite|Mutation)?$/.test(name) || name === "default"
17
+ const isRequestHook = (name) => /^useSWR(?:Immutable|Infinite|Mutation|Subscription)?$/.test(name) || name === "default"
17
18
  const swrSource = (value) => value === "swr" || value.startsWith("swr/")
18
19
  const propertyName = (node) => node.computed ? node.property.value : node.property.name
19
20
 
@@ -68,8 +69,11 @@ export const swrOnlyInFamily = {
68
69
  },
69
70
  }
70
71
 
72
+ /** The slot of the stream reader: only a stream operation of the API layer imports it, never an SWR hook, even inside a fetcher. */
73
+ const STREAM_READER_SLOT = "fe.transport.stream"
74
+
71
75
  /** Type imports stay available; the API layer may compose its own operations and helpers. */
72
- const isOperationSource = (context, value) => {
76
+ const isOperationSource = (context, value, only) => {
73
77
  if (typeof value !== "string") return false
74
78
  const hfs = hfsOf(context)
75
79
  let target = resolveImport(context, value)
@@ -93,7 +97,43 @@ const isOperationSource = (context, value) => {
93
97
  const candidates = /\.[cm]?[jt]sx?$/.test(target) ? [target] : [`${target}.ts`, `${target}.tsx`, `${target}/index.ts`, `${target}/index.tsx`, target]
94
98
  const found = candidates.map((file) => hfs.classify(file)).filter((entry) => entry.status === "owned")
95
99
  if (found.some((entry) => ["fe.transport.outcome", "fe.package.api.outcome"].includes(entry.slot))) return false
96
- return found.some((entry) => ["fe.modules.api", "fe.package.api", "fe.transport.client", "fe.package.api.client"].includes(entry.slot))
100
+ if (only !== undefined) return found.some((entry) => entry.slot === only)
101
+ return found.some((entry) => ["fe.modules.api", "fe.package.api", "fe.transport.client", "fe.transport.stream", "fe.package.api.client"].includes(entry.slot))
102
+ }
103
+
104
+ /** The generated input-bounds module holds constants and a pure reader derived from the contract, never an operation. */
105
+ const BOUNDS_MODULE = /(?:^|\/)__generated__\/input-bounds\.ts$/
106
+ const RE_EXPORT = /export\s*\{([^}]*)\}\s*from\s*["']([^"']+)["']/g
107
+ const ENTRY = /^\s*(type\s+)?([\w$]+)(?:\s+as\s+([\w$]+))?\s*$/
108
+
109
+ /** True when the named export of `target` is, through a chain of relative named re-exports, defined by a generated input-bounds module. */
110
+ const isBoundsExport = (target, name, depth = 0) => {
111
+ if (depth > 4) return false
112
+ for (const file of [`${target}.ts`, `${target}/index.ts`, target]) {
113
+ if (BOUNDS_MODULE.test(file.replaceAll("\\", "/"))) return true
114
+ let text
115
+ try { text = readFileSync(file, "utf8") } catch { continue }
116
+ for (const [, list, source] of text.matchAll(RE_EXPORT)) {
117
+ if (!source.startsWith(".")) continue
118
+ for (const entry of list.split(",")) {
119
+ const [, kind, origin, exported] = ENTRY.exec(entry) ?? []
120
+ if (origin !== undefined && kind === undefined && (exported ?? origin) === name && isBoundsExport(resolve(dirname(file), source), origin, depth + 1)) return true
121
+ }
122
+ }
123
+ }
124
+ return false
125
+ }
126
+
127
+ /** An import of named values that all come from a generated input-bounds module is data, not an operation. */
128
+ const importsOnlyBounds = (context, node, value) => {
129
+ const specifiers = node.specifiers?.filter((s) => s.importKind !== "type" && s.exportKind !== "type") ?? []
130
+ if (!specifiers.length || typeof value !== "string") return false
131
+ const target = resolveImport(context, value)
132
+ if (target === null) return false
133
+ return specifiers.every((s) => {
134
+ const name = s.imported?.name ?? s.imported?.value ?? s.local?.name ?? s.local?.value
135
+ return (s.type === "ImportSpecifier" || s.type === "ExportSpecifier") && typeof name === "string" && isBoundsExport(target, name)
136
+ })
97
137
  }
98
138
 
99
139
  const unwrap = (node) => {
@@ -162,7 +202,7 @@ export const operationsOnlyInSwrHooks = {
162
202
  if (!isProductSource(context) || isApiLayer(context)) return {}
163
203
  const imports = []
164
204
  const inspect = (node, value) => {
165
- if (isOperationSource(context, value)) imports.push({ node, value })
205
+ if (isOperationSource(context, value) && !importsOnlyBounds(context, node, value)) imports.push({ node, value, reader: isOperationSource(context, value, STREAM_READER_SLOT) })
166
206
  }
167
207
  return {
168
208
  ImportDeclaration(node) {
@@ -180,7 +220,95 @@ export const operationsOnlyInSwrHooks = {
180
220
  "Program:exit"(program) {
181
221
  if (!imports.length) return
182
222
  const wrapped = isSwrHook(context) ? wrappedOperations(context, program, imports) : new Set()
183
- for (const { node, value } of imports) if (!wrapped.has(node)) context.report({ node, messageId: "outside", data: { source: value } })
223
+ for (const { node, value, reader } of imports) if (reader || !wrapped.has(node)) context.report({ node, messageId: "outside", data: { source: value } })
224
+ },
225
+ }
226
+ },
227
+ }
228
+
229
+ /** A stream operation: `modules/api/sse/<area>/stream-<channel>` or `modules/api/graphql/subscriptions/subscription-<name>`. */
230
+ const STREAM_OPERATION = /modules\/api\/(?:sse\/[^/]+\/stream-[^/]+|graphql\/subscriptions\/subscription-[^/]+)(?:\.tsx?)?$/
231
+
232
+ /** A subscription hook file of the SWR family: `useSubscribe<X>Swr.ts`. */
233
+ const isSubscriptionHook = (context) => isSwrHook(context) && /^useSubscribe[A-Z0-9]\w*Swr\.ts$/.test(fileOf(context).replaceAll("\\", "/").split("/").at(-1) ?? "")
234
+
235
+ /** The `onOpen` property of an object literal, else undefined. */
236
+ const onOpenOf = (options) =>
237
+ options?.type === "ObjectExpression"
238
+ ? options.properties.find((property) => property.type === "Property" && !property.computed && property.key.type === "Identifier" && property.key.name === "onOpen")
239
+ : undefined
240
+
241
+ /** True when a function calls `mutate` (the bound mutate of a hook or the one of `useSWRConfig`): the revalidation of the snapshot query. */
242
+ const callsMutate = (source, handler) => {
243
+ let found = false
244
+ walk(source, handler.body, (node) => {
245
+ if (found) return false
246
+ if (node.type !== "CallExpression") return undefined
247
+ const callee = unwrap(node.callee)
248
+ if ((callee?.type === "Identifier" && callee.name === "mutate") || (callee?.type === "MemberExpression" && propertyName(callee) === "mutate")) found = true
249
+ return undefined
250
+ })
251
+ return found
252
+ }
253
+
254
+ /** True when a callee is a stream operation: a named import, a member of a namespace import, or a const alias of either. */
255
+ const isStreamCallee = (source, input, locals, seen = new Set()) => {
256
+ const node = unwrap(input)
257
+ if (node?.type === "MemberExpression") {
258
+ const object = unwrap(node.object)
259
+ return object?.type === "Identifier" && locals.get(object.name) === "namespace" && bindingOf(source, object)?.defs[0]?.type === "ImportBinding"
260
+ }
261
+ if (node?.type !== "Identifier") return false
262
+ const variable = bindingOf(source, node)
263
+ if (!variable || seen.has(variable) || variable.defs.length !== 1) return false
264
+ const definition = variable.defs[0]
265
+ if (definition.type === "ImportBinding") return locals.get(variable.name) === "named"
266
+ if (definition.node.type !== "VariableDeclarator" || definition.parent.kind !== "const") return false
267
+ return isStreamCallee(source, definition.node.init, locals, new Set([...seen, variable]))
268
+ }
269
+
270
+ /**
271
+ * A stream or subscription has no replay of what it missed (graphql-ws has none, an SSE reconnect replays a bounded buffer), so the
272
+ * subscription hook revalidates the snapshot query each time the stream opens: its call of the stream operation (named, through a
273
+ * namespace or an alias) passes an `onOpen` handler that calls `mutate`, and a subscription hook that calls no stream operation at
274
+ * all is not one (FE-REALTIME-2).
275
+ */
276
+ export const subscriptionRefetchesSnapshot = {
277
+ meta: {
278
+ type: "problem",
279
+ docs: { description: "FE_SUBSCRIPTION_NO_SNAPSHOT: a subscription hook passes the stream operation an onOpen handler that revalidates the snapshot query." },
280
+ schema: [],
281
+ messages: {
282
+ missing:
283
+ "FE_SUBSCRIPTION_NO_SNAPSHOT: this call of a stream operation has no `onOpen` handler that calls `mutate`. A stream carries changes, not the state: pass `onOpen: () => void mutate(<snapshot query key>)` so the screen reads its snapshot with the query each time the stream (re)opens.",
284
+ noStream:
285
+ "FE_SUBSCRIPTION_NO_SNAPSHOT: a `useSubscribe<X>Swr` hook calls one stream operation (`modules/api/sse/<area>/stream-*.ts` or `modules/api/graphql/subscriptions/subscription-*.ts`) and revalidates its snapshot query in `onOpen`; this file calls none.",
286
+ },
287
+ },
288
+ create(context) {
289
+ if (!isProductSource(context) || !isSubscriptionHook(context)) return {}
290
+ const source = context.sourceCode ?? context.getSourceCode()
291
+ const locals = new Map()
292
+ let calls = 0
293
+ return {
294
+ ImportDeclaration(node) {
295
+ if (node.importKind === "type" || typeof node.source.value !== "string") return
296
+ const target = resolveImport(context, node.source.value)
297
+ if (target === null || !STREAM_OPERATION.test(target.replaceAll("\\", "/"))) return
298
+ for (const specifier of node.specifiers) {
299
+ if (specifier.importKind === "type") continue
300
+ if (specifier.type === "ImportSpecifier") locals.set(specifier.local.name, "named")
301
+ if (specifier.type === "ImportNamespaceSpecifier") locals.set(specifier.local.name, "namespace")
302
+ }
303
+ },
304
+ CallExpression(node) {
305
+ if (!isStreamCallee(source, node.callee, locals)) return
306
+ calls += 1
307
+ const handler = functionOf(context, onOpenOf(node.arguments[0])?.value)
308
+ if (!handler || !callsMutate(source, handler)) context.report({ node, messageId: "missing" })
309
+ },
310
+ "Program:exit"(program) {
311
+ if (calls === 0) context.report({ node: program, messageId: "noStream" })
184
312
  },
185
313
  }
186
314
  },
@@ -190,8 +318,9 @@ export const operationsOnlyInSwrHooks = {
190
318
  Object.assign(why, {
191
319
  "swr-only-in-family": { code: "FE_SWR_OUTSIDE_FAMILY", en: "A request hook is used outside the SWR family.", fix: "Call the operation's SWR wrapper hook." },
192
320
  "operations-only-in-swr-hooks": { code: "FE_OP_OUTSIDE_SWR_HOOK", en: "An API operation is consumed outside an SWR wrapper.", fix: "Wrap the operation in hooks/swr/." },
321
+ "subscription-refetches-snapshot": { code: "FE_SUBSCRIPTION_NO_SNAPSHOT", en: "A subscription hook does not revalidate its snapshot query when the stream opens.", fix: "Pass the stream operation an onOpen handler that revalidates the snapshot query key." },
193
322
  })
194
323
 
195
324
  /** R285's published lint enforcers. */
196
- export const rules = { "swr-only-in-family": swrOnlyInFamily, "operations-only-in-swr-hooks": operationsOnlyInSwrHooks }
325
+ export const rules = { "swr-only-in-family": swrOnlyInFamily, "operations-only-in-swr-hooks": operationsOnlyInSwrHooks, "subscription-refetches-snapshot": subscriptionRefetchesSnapshot }
197
326
  export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
package/transport.mjs CHANGED
@@ -28,12 +28,13 @@ import { typed } from "./lib/types.mjs"
28
28
 
29
29
  /**
30
30
  * The canonical transport libraries and the one place each may be imported (fe-stack graphql, rest and realtime): Apollo in
31
- * the API layer, axios in its REST calls, socket.io-client in `hooks/socketio`. Matched on the package a specifier names.
31
+ * the API layer, axios in its REST calls, graphql-ws in the Apollo clients folder, socket.io-client in `hooks/socketio`. Matched on the package a specifier names.
32
32
  */
33
33
  const PLACED_TRANSPORTS = new Map([
34
34
  ["@apollo/client", { where: "the API layer (`modules/api`)", allowed: (context) => isApiLayer(context) }],
35
35
  ["axios", { where: "the REST calls of the API layer (`modules/api/rest/`)", allowed: (context) => isApiRest(context) }],
36
36
  ["socket.io-client", { where: "`hooks/socketio/`", allowed: (context) => inSlot(context, "fe.hooks.socketio") }],
37
+ ["graphql-ws", { where: "the Apollo clients of the API layer (`modules/api/graphql/clients/`, or the shared api package's `graphql/clients/`)", allowed: (context) => isApiClient(context) }],
37
38
  ])
38
39
 
39
40
  /**