@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.
- package/CHANGELOG.md +9 -0
- package/README.md +6 -0
- package/callback-shape.mjs +1 -13
- package/docs/grammar-boundary.md +25 -1
- package/docs/surface-rules.md +41 -0
- package/grammar-boundary.mjs +112 -0
- package/hooks-folder.mjs +9 -3
- package/icon.mjs +31 -1
- package/index.mjs +4 -0
- package/lib/bindings.mjs +15 -0
- package/lib/config.mjs +1 -0
- package/lib/fe-stack.mjs +8 -4
- package/lib/scope.mjs +2 -2
- package/lib/typed-class-values.mjs +72 -0
- package/lib/why.mjs +17 -2
- package/lint-escape-hatch.mjs +2 -2
- package/menu-entries.mjs +133 -0
- package/package.json +1 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +65 -9
- package/runtime/knowledge/hfs/fe-stack.yaml +23 -5
- package/runtime/knowledge/hfs/slots.yaml +110 -52
- package/runtime/knowledge/sonar-gate.yaml +1 -1
- package/runtime/modules/kernel/removed-vocabulary.yaml +62 -0
- package/runtime/modules/models/registry.yaml +3 -1
- package/runtime/modules/models/runtimes.yaml +2 -0
- package/runtime/scripts/api/git/ls-tree.mjs +5 -0
- package/runtime/scripts/api/git/show.mjs +6 -0
- package/runtime/scripts/hfs/architecture/dead-exports.mjs +10 -10
- package/runtime/scripts/hfs/architecture/function-spec-providers.mjs +1 -1
- package/runtime/scripts/hfs/architecture/operation-contract-roots.mjs +53 -0
- package/runtime/scripts/hfs/architecture/reachability.mjs +1 -1
- package/runtime/scripts/hfs/architecture/sql-owner.mjs +17 -6
- package/runtime/scripts/hfs/architecture/sql-token-forms.mjs +105 -0
- package/runtime/scripts/hfs/architecture/sql-tokens.mjs +38 -52
- package/runtime/scripts/hfs/architecture/supabase-keys.mjs +261 -0
- package/runtime/scripts/hfs/architecture/transport-owner.mjs +49 -3
- package/runtime/scripts/hfs/declaration-shape.mjs +4 -4
- package/runtime/scripts/hfs/declaration-slots.mjs +12 -3
- package/runtime/scripts/hfs/manifest-shape.mjs +10 -6
- package/runtime/scripts/hfs/rule-params-shape.mjs +16 -6
- package/runtime/scripts/hfs/rules/database-constants.mjs +26 -0
- package/runtime/scripts/hfs/rules/database-migrations.mjs +489 -0
- package/runtime/scripts/hfs/slot-side-problems.mjs +2 -2
- package/runtime/scripts/hfs/slots.mjs +1 -0
- package/runtime/scripts/hfs/view.mjs +2 -1
- package/runtime/scripts/lib/in-order.mjs +71 -0
- package/runtime/scripts/lib/language.mjs +94 -1
- package/runtime/scripts/lib/package-at.mjs +62 -0
- package/runtime/scripts/lib/same-text.mjs +5 -0
- package/runtime/scripts/lib/ts-analysis.mjs +10 -0
- package/size-and-state-budget.mjs +3 -3
- package/surface-rules.mjs +230 -0
- package/swr-family.mjs +136 -7
- 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
|
-
|
|
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.
|
|
209
|
-
*
|
|
210
|
-
*
|
|
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) =>
|
|
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
|
/**
|