Files
wehub-resource-sync 2114b14ee0
Sync main into demo / sync (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:35:26 +08:00

212 lines
7.4 KiB
JavaScript
Executable File

#!/usr/bin/env node
/**
* lint_store_getters.mjs
*
* Detects query-like getter functions defined in Zustand store actions.
* These have stable references — subscribing via useStore(s => s.isLiked)
* will NOT trigger re-renders when the underlying data changes.
*
* Two-pass analysis:
* Pass 1 — Store definitions: finds query-like methods in *Actions interfaces
* Pass 2 — Consumer subscriptions: finds useXxxStore(s => s.<getter>) in .tsx files
*
* See docs/platform/state-model.md "Store actions: no query-style getters"
*
* Usage:
* node scripts/lint_store_getters.mjs # scan all apps
* node scripts/lint_store_getters.mjs Spotify X # scan specific apps
* node scripts/lint_store_getters.mjs --json # JSON output
*/
import fs from 'fs';
import path from 'path';
const WORKSPACE = process.cwd();
const APPS_DIR = path.join(WORKSPACE, 'apps');
export const QUERY_PREFIXES = ['is', 'get', 'check', 'has'];
export const SAFE_NAMES = new Set([
'isPlaying', 'isLoggedIn', 'isLoading', 'isEditing', 'isRecording',
'isExpanded', 'isVisible', 'isMuted', 'isPaused', 'isOpen',
]);
const args = process.argv.slice(2);
const jsonMode = args.includes('--json');
const appFilters = args.filter(a => !a.startsWith('--'));
// ── Pass 1: Scan store definitions ──────────────────────────────────
export function findActionsInterfaces(source, filePath) {
const issues = [];
const queryMethods = new Set();
// [^{]* allows extends / implements clauses between name and opening brace
const interfaceRe = /interface\s+(\w*Actions\w*)\b[^{]*\{/g;
let match;
while ((match = interfaceRe.exec(source)) !== null) {
const ifaceName = match[1];
const startIdx = match.index + match[0].length;
let depth = 1;
let i = startIdx;
while (i < source.length && depth > 0) {
if (source[i] === '{') depth++;
else if (source[i] === '}') depth--;
i++;
}
const body = source.slice(startIdx, i - 1);
// [\s\S]*? handles multi-line parameter lists (non-greedy stops at first `)`).
// Optional <...> before ( handles generic methods like getX: <T>(k: string) => T
const methodRe = /^\s+(\w+)\s*:\s*(?:<[^>]*>\s*)?\(([\s\S]*?)\)\s*=>\s*(\S+)/gm;
let mMatch;
while ((mMatch = methodRe.exec(body)) !== null) {
const name = mMatch[1];
const returnType = mMatch[3].replace(/[;,]$/, '');
if (returnType === 'void') continue;
if (SAFE_NAMES.has(name)) continue;
const isQuery = QUERY_PREFIXES.some(prefix => {
if (!name.startsWith(prefix)) return false;
const charAfter = name[prefix.length];
return charAfter && charAfter === charAfter.toUpperCase();
});
if (isQuery) {
const lineNum = source.slice(0, startIdx + mMatch.index).split('\n').length;
issues.push({
file: filePath,
line: lineNum,
method: name,
iface: ifaceName,
returnType,
message: `Query getter "${name}" in ${ifaceName} — should be a memoSelector or derived in component (§5.3)`,
});
queryMethods.add(name);
}
}
}
return { issues, queryMethods };
}
// ── Pass 2: Scan consumer subscriptions ─────────────────────────────
export function findGetterSubscriptions(source, filePath, knownGetters) {
const issues = [];
if (knownGetters.size === 0) return issues;
const getterPattern = [...knownGetters].map(n => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|');
// Accept any single identifier as the arrow param (not just s/state/st)
const re = new RegExp(
`(use\\w+Store)\\(\\s*(\\w+)\\s*=>\\s*\\2\\.(${getterPattern})\\s*\\)`,
'g',
);
let match;
while ((match = re.exec(source)) !== null) {
const lineNum = source.slice(0, match.index).split('\n').length;
issues.push({
file: filePath,
line: lineNum,
store: match[1],
method: match[3],
message: `Subscribing to getter "${match[3]}" via ${match[1]} — will NOT trigger re-renders (§5.3)`,
});
}
return issues;
}
// ── Main ────────────────────────────────────────────────────────────
function run() {
const appDirs = fs.readdirSync(APPS_DIR, { withFileTypes: true })
.filter(d => d.isDirectory())
.filter(d => appFilters.length === 0 || appFilters.includes(d.name))
.map(d => d.name);
const allStoreIssues = [];
const allConsumerIssues = [];
const appGetters = new Map(); // app -> Set<string>
// Pass 1
for (const app of appDirs) {
const stateFile = path.join(APPS_DIR, app, 'state.ts');
if (!fs.existsSync(stateFile)) continue;
const source = fs.readFileSync(stateFile, 'utf-8');
const { issues, queryMethods } = findActionsInterfaces(source, path.relative(WORKSPACE, stateFile));
allStoreIssues.push(...issues);
if (queryMethods.size > 0) {
appGetters.set(app, queryMethods);
}
}
// Pass 2: scan each app's consumers using ONLY that app's getters (no cross-app pollution)
for (const app of appDirs) {
const getters = appGetters.get(app);
if (!getters || getters.size === 0) continue;
const appDir = path.join(APPS_DIR, app);
const tsxFiles = collectFiles(appDir, /\.tsx$/);
for (const f of tsxFiles) {
const source = fs.readFileSync(f, 'utf-8');
const issues = findGetterSubscriptions(source, path.relative(WORKSPACE, f), getters);
allConsumerIssues.push(...issues);
}
}
// Output
const totalIssues = allStoreIssues.length + allConsumerIssues.length;
if (jsonMode) {
console.log(JSON.stringify({ storeDefinitions: allStoreIssues, consumerSubscriptions: allConsumerIssues }, null, 2));
process.exit(totalIssues > 0 ? 1 : 0);
}
if (totalIssues === 0) {
console.log('✅ No store getter anti-patterns found.');
process.exit(0);
}
if (allStoreIssues.length > 0) {
console.log(`\n⛔ Store definitions — query getters in action interfaces (${allStoreIssues.length}):\n`);
for (const issue of allStoreIssues) {
console.log(` ${issue.file}:${issue.line}`);
console.log(` ${issue.message}\n`);
}
}
if (allConsumerIssues.length > 0) {
console.log(`⛔ Consumer subscriptions — subscribing to getter functions (${allConsumerIssues.length}):\n`);
for (const issue of allConsumerIssues) {
console.log(` ${issue.file}:${issue.line}`);
console.log(` ${issue.message}\n`);
}
}
console.log(`Found ${totalIssues} issue(s). See docs/platform/state-model.md "Store actions: no query-style getters"`);
process.exit(1);
}
function collectFiles(dir, pattern) {
const results = [];
try {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory() && entry.name !== 'node_modules') {
results.push(...collectFiles(full, pattern));
} else if (entry.isFile() && pattern.test(entry.name)) {
results.push(full);
}
}
} catch { /* skip unreadable dirs */ }
return results;
}
// Only run when executed directly (not when imported as a module)
const isDirectRun = process.argv[1] && import.meta.url.endsWith(process.argv[1].replace(/\\/g, '/'));
if (isDirectRun) run();