From c63a833677abce0c2c05547660727dea5ac7f4be Mon Sep 17 00:00:00 2001 From: Jeff Repanich Date: Thu, 27 Aug 2026 15:24:09 -0400 Subject: [PATCH 1/4] fix(analyze): follow state accessor bindings --- src/analyze/rules.ts | 49 ++++++++++++++++++++++++++++--------- tests/analyze-rules.test.ts | 22 +++++++++++++++++ 2 files changed, 60 insertions(+), 11 deletions(-) diff --git a/src/analyze/rules.ts b/src/analyze/rules.ts index fdac964..e5f03ac 100644 --- a/src/analyze/rules.ts +++ b/src/analyze/rules.ts @@ -280,12 +280,20 @@ const stableRenderRule: AnalyzeRule = { interface StateBindings { readonly getters: Set; readonly setters: Set; + readonly getterSymbols: Set; + readonly setterSymbols: Set; readonly owners: Map; } -function collectStateBindings(sourceFile: ts.SourceFile, bindings: SourceBindings): StateBindings { +function collectStateBindings( + sourceFile: ts.SourceFile, + bindings: SourceBindings, + checker: ts.TypeChecker, +): StateBindings { const getters = new Set(); const setters = new Set(); + const getterSymbols = new Set(); + const setterSymbols = new Set(); const owners = new Map(); visit(sourceFile, (node) => { if ( @@ -299,21 +307,27 @@ function collectStateBindings(sourceFile: ts.SourceFile, bindings: SourceBinding const owner = containingFunction(node); if (ts.isIdentifier(node.name)) { getters.add(node.name.text); + const symbol = checker.getSymbolAtLocation(node.name); + if (symbol) getterSymbols.add(symbol); owners.set(node.name.text, owner); } if (ts.isArrayBindingPattern(node.name)) { const [getter, setter] = node.name.elements; if (getter && ts.isBindingElement(getter) && ts.isIdentifier(getter.name)) { getters.add(getter.name.text); + const symbol = checker.getSymbolAtLocation(getter.name); + if (symbol) getterSymbols.add(symbol); owners.set(getter.name.text, owner); } if (setter && ts.isBindingElement(setter) && ts.isIdentifier(setter.name)) { setters.add(setter.name.text); + const symbol = checker.getSymbolAtLocation(setter.name); + if (symbol) setterSymbols.add(symbol); owners.set(setter.name.text, owner); } } }); - return { getters, setters, owners }; + return { getters, setters, getterSymbols, setterSymbols, owners }; } function isCallableJsxProp(node: ts.Identifier, checker: ts.TypeChecker): boolean { @@ -379,7 +393,11 @@ function identifierIsReadAsValue(node: ts.Identifier, checker: ts.TypeChecker): if (ts.isJsxAttribute(parent) && parent.name === node) return false; if (isCallableJsxProp(node, checker)) return false; if (ts.isPropertyAssignment(parent) && parent.name === node) return false; - if (ts.isShorthandPropertyAssignment(parent)) return true; + if (ts.isShorthandPropertyAssignment(parent)) { + const contextual = checker.getContextualType(node); + if (contextual?.getCallSignatures().length) return false; + return true; + } // All remaining expression positions (arguments, operators, assignments, // object values, template substitutions, JSX expressions, and returns) read // the identifier's value. @@ -395,12 +413,13 @@ const stateAccessRule: AnalyzeRule = { const diagnostics: AnalyzeDiagnostic[] = []; for (const sourceFile of context.sourceFiles) { const bindings = sourceBindings(sourceFile); - const state = collectStateBindings(sourceFile, bindings); + const state = collectStateBindings(sourceFile, bindings, context.checker); visit(sourceFile, (node) => { if ( ts.isCallExpression(node) && ts.isIdentifier(node.expression) && state.setters.has(node.expression.text) && + state.setterSymbols.has(context.checker.getSymbolAtLocation(node.expression)!) && node.arguments.length === 0 ) { diagnostics.push( @@ -415,6 +434,7 @@ const stateAccessRule: AnalyzeRule = { } else if ( ts.isIdentifier(node) && state.getters.has(node.text) && + state.getterSymbols.has(context.checker.getSymbolAtLocation(node)!) && identifierIsReadAsValue(node, context.checker) ) { diagnostics.push( @@ -442,17 +462,24 @@ const stateRenderWriteRule: AnalyzeRule = { const diagnostics: AnalyzeDiagnostic[] = []; for (const sourceFile of context.sourceFiles) { const bindings = sourceBindings(sourceFile); - const state = collectStateBindings(sourceFile, bindings); + const state = collectStateBindings(sourceFile, bindings, context.checker); visit(sourceFile, (node) => { if (!ts.isCallExpression(node)) return; let cellName: string | null = null; - if (ts.isIdentifier(node.expression) && state.setters.has(node.expression.text)) { + if ( + ts.isIdentifier(node.expression) && + state.setters.has(node.expression.text) && + state.setterSymbols.has(context.checker.getSymbolAtLocation(node.expression)!) + ) { cellName = node.expression.text; } else if ( ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === "set" && ts.isIdentifier(node.expression.expression) && - state.getters.has(node.expression.expression.text) + state.getters.has(node.expression.expression.text) && + state.getterSymbols.has( + context.checker.getSymbolAtLocation(node.expression.expression)!, + ) ) { cellName = node.expression.expression.text; } @@ -938,7 +965,7 @@ const preferForRule: AnalyzeRule = { const diagnostics: AnalyzeDiagnostic[] = []; for (const sourceFile of context.sourceFiles) { const bindings = sourceBindings(sourceFile); - const state = collectStateBindings(sourceFile, bindings); + const state = collectStateBindings(sourceFile, bindings, context.checker); visit(sourceFile, (node) => { if ( !ts.isCallExpression(node) || @@ -2773,7 +2800,7 @@ const exhaustiveDependenciesRule: AnalyzeRule = { ) { continue; } - const reactive = collectStateBindings(sourceFile, bindings).getters; + const reactive = collectStateBindings(sourceFile, bindings, context.checker).getters; for (const { node, name } of sourceFacts(sourceFile).calls) { if (name !== "resource" && name !== "stream") continue; const loader = node.arguments[0]; @@ -2834,7 +2861,7 @@ const forRowClosureCaptureRule: AnalyzeRule = { for (const sourceFile of context.sourceFiles) { const bindings = sourceBindings(sourceFile); if (!sourceFacts(sourceFile).jsx.some((fact) => fact.name === "For")) continue; - const reactive = collectStateBindings(sourceFile, bindings).getters; + const reactive = collectStateBindings(sourceFile, bindings, context.checker).getters; const snapshots = new Set(); visit(sourceFile, (candidate) => { if ( @@ -3668,7 +3695,7 @@ const noEffectDataLoadingRule: AnalyzeRule = { for (const sourceFile of context.sourceFiles) { const bindings = sourceBindings(sourceFile); if (!sourceFacts(sourceFile).calls.some((fact) => fact.name === "task")) continue; - const state = collectStateBindings(sourceFile, bindings); + const state = collectStateBindings(sourceFile, bindings, context.checker); for (const { node, name } of sourceFacts(sourceFile).calls) { if (name !== "task") continue; const callback = node.arguments[0]; diff --git a/tests/analyze-rules.test.ts b/tests/analyze-rules.test.ts index 14916d9..21b61c2 100644 --- a/tests/analyze-rules.test.ts +++ b/tests/analyze-rules.test.ts @@ -128,6 +128,28 @@ describe("analyzer rules", () => { expect(found.filter((entry) => entry.ruleId === "askr/state-access")).toHaveLength(7); }); + it("should follow state bindings and allow callable accessor references", async () => { + const root = await fixture({ + "src/page.tsx": ` + import { state } from "@askrjs/askr"; + interface Adapter { isAuthenticated(): boolean; user(): string | null; } + export function Page() { + const isAuthenticated = state(false); + const user = state(null); + const otp = state(""); + const mutation = { + action: ({ otp: code, token }: { otp: string; token: string }) => code + token, + }; + const adapter: Adapter = { isAuthenticated, user }; + return
{mutation.action({ otp: otp(), token: adapter.user() ?? "" })}
; + } + `, + }); + + const found = await diagnostics(root); + expect(found.filter((entry) => entry.ruleId === "askr/state-access")).toEqual([]); + }); + it("should validate statically known For key strategies without rejecting dynamic values", async () => { const root = await fixture({ "src/page.tsx": ` From ad7a61c836c539ccca27a680062cbbed21814472 Mon Sep 17 00:00:00 2001 From: Jeff Repanich Date: Fri, 28 Aug 2026 10:40:46 -0400 Subject: [PATCH 2/4] fix: recognize watch source accessors --- src/analyze/rules.ts | 41 ++++++++++++++++++++++++++++++++++--- tests/analyze-rules.test.ts | 3 +++ 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/src/analyze/rules.ts b/src/analyze/rules.ts index e5f03ac..75220d5 100644 --- a/src/analyze/rules.ts +++ b/src/analyze/rules.ts @@ -392,6 +392,7 @@ function identifierIsReadAsValue(node: ts.Identifier, checker: ts.TypeChecker): } if (ts.isJsxAttribute(parent) && parent.name === node) return false; if (isCallableJsxProp(node, checker)) return false; + if (isWatchSourceReference(node)) return false; if (ts.isPropertyAssignment(parent) && parent.name === node) return false; if (ts.isShorthandPropertyAssignment(parent)) { const contextual = checker.getContextualType(node); @@ -404,6 +405,42 @@ function identifierIsReadAsValue(node: ts.Identifier, checker: ts.TypeChecker): return true; } +function isWatchSourceReference(node: ts.Identifier): boolean { + let sourceExpression: ts.Node = node; + while ( + sourceExpression.parent && + (ts.isArrayLiteralExpression(sourceExpression.parent) || + ts.isAsExpression(sourceExpression.parent) || + ts.isParenthesizedExpression(sourceExpression.parent)) + ) { + sourceExpression = sourceExpression.parent; + } + + const call = sourceExpression.parent; + if (!call || !ts.isCallExpression(call) || call.arguments[0] !== sourceExpression) return false; + if (!ts.isIdentifier(call.expression)) return false; + + const localName = call.expression.text; + return node.getSourceFile().statements.some((statement) => { + if ( + !ts.isImportDeclaration(statement) || + !ts.isStringLiteral(statement.moduleSpecifier) || + statement.moduleSpecifier.text !== "@askrjs/askr/resources" + ) { + return false; + } + return Boolean( + statement.importClause?.namedBindings && + ts.isNamedImports(statement.importClause.namedBindings) && + statement.importClause.namedBindings.elements.some( + (specifier) => + specifier.name.text === localName && + (specifier.propertyName?.text ?? specifier.name.text) === "watch", + ), + ); + }); +} + const stateAccessRule: AnalyzeRule = { id: "askr/state-access", category: "correctness", @@ -477,9 +514,7 @@ const stateRenderWriteRule: AnalyzeRule = { node.expression.name.text === "set" && ts.isIdentifier(node.expression.expression) && state.getters.has(node.expression.expression.text) && - state.getterSymbols.has( - context.checker.getSymbolAtLocation(node.expression.expression)!, - ) + state.getterSymbols.has(context.checker.getSymbolAtLocation(node.expression.expression)!) ) { cellName = node.expression.expression.text; } diff --git a/tests/analyze-rules.test.ts b/tests/analyze-rules.test.ts index 21b61c2..45a26f8 100644 --- a/tests/analyze-rules.test.ts +++ b/tests/analyze-rules.test.ts @@ -132,6 +132,7 @@ describe("analyzer rules", () => { const root = await fixture({ "src/page.tsx": ` import { state } from "@askrjs/askr"; + import { watch } from "@askrjs/askr/resources"; interface Adapter { isAuthenticated(): boolean; user(): string | null; } export function Page() { const isAuthenticated = state(false); @@ -141,6 +142,8 @@ describe("analyzer rules", () => { action: ({ otp: code, token }: { otp: string; token: string }) => code + token, }; const adapter: Adapter = { isAuthenticated, user }; + watch(isAuthenticated, () => {}); + watch([isAuthenticated, user] as const, () => {}); return
{mutation.action({ otp: otp(), token: adapter.user() ?? "" })}
; } `, From 195cf6a536aa073f63643094f15fccb94fde94ca Mon Sep 17 00:00:00 2001 From: Jeff Repanich Date: Fri, 28 Aug 2026 13:27:10 -0400 Subject: [PATCH 3/4] fix(analyze): track state bindings by symbol --- CHANGELOG.md | 15 ++- package-lock.json | 248 ++++++++++++++++++------------------ package.json | 4 +- src/analyze/rules.ts | 128 ++++++++++++------- tests/analyze-rules.test.ts | 68 ++++++++++ 5 files changed, 288 insertions(+), 175 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce34309..c848ea5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.2.3] - 2026-08-28 + +### Fixed + +- Follow state getter and setter symbol identity across every state-sensitive analyzer rule, preserving same-named bindings in nested and sibling scopes. +- Accept state and derived accessors passed deliberately to imported `watch()` sources and callable adapter properties. + +### Changed + +- Update `js-yaml` from 5.4.0 to 5.4.1 and refresh the validated AskrJS patch release set. + ## [0.2.2] - 2026-08-25 ### Fixed @@ -111,7 +122,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Make database tooling work consistently across supported operating systems. -[Unreleased]: https://github.com/askrjs/askr-cli/compare/v0.2.1...HEAD +[Unreleased]: https://github.com/askrjs/askr-cli/compare/v0.2.3...HEAD +[0.2.3]: https://github.com/askrjs/askr-cli/compare/v0.2.2...v0.2.3 +[0.2.2]: https://github.com/askrjs/askr-cli/compare/v0.2.1...v0.2.2 [0.2.1]: https://github.com/askrjs/askr-cli/compare/v0.2.0...v0.2.1 [0.2.0]: https://github.com/askrjs/askr-cli/compare/v0.0.25...v0.2.0 [0.0.25]: https://github.com/askrjs/askr-cli/compare/v0.0.24...v0.0.25 diff --git a/package-lock.json b/package-lock.json index b0862e5..499c395 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,16 @@ { "name": "@askrjs/cli", - "version": "0.2.2", + "version": "0.2.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@askrjs/cli", - "version": "0.2.2", + "version": "0.2.3", "license": "Apache-2.0", "dependencies": { "@npmcli/config": "^11.0.1", - "js-yaml": "^5.4.0", + "js-yaml": "^5.4.1", "minimatch": "^10.2.6", "npm-registry-fetch": "^20.0.1", "parse5": "^8.0.1", @@ -51,14 +51,14 @@ } }, "node_modules/@askrjs/askr": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@askrjs/askr/-/askr-0.2.3.tgz", - "integrity": "sha512-lVBduK5eWALX/eUh8GglEUF8B5SjPhEZAD0FISJ4hp5VJIKN+KiWHnfH7znvOjZU2Km4rla7OLCmdmHTFgbwfA==", + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@askrjs/askr/-/askr-0.2.4.tgz", + "integrity": "sha512-GlXI724dyqlZz1OxVxxO3ZmR+uQ5E1iPBNZAkRbyvdztGSABMf4IFEOtHoOVoT5o7KeNU+XTppsG72TGojIqng==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@askrjs/auth": ">=0.2.0 <0.3.0", - "@askrjs/schema": ">=0.2.0 <0.3.0" + "@askrjs/auth": ">=0.2.1 <0.3.0", + "@askrjs/schema": ">=0.2.1 <0.3.0" }, "engines": { "node": ">=24.0.0" @@ -80,16 +80,16 @@ } }, "node_modules/@askrjs/charts": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@askrjs/charts/-/charts-0.2.0.tgz", - "integrity": "sha512-YankcK5pTwhdlZ7e/g71yLgrawYn0Woz8ug9aGGpm9p+yLDhWvUS6pklLH8Bw7Rc7egVEKb/kJB+NB09NKTVNg==", + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@askrjs/charts/-/charts-0.2.2.tgz", + "integrity": "sha512-rttjMgKY0dXtjMSoQgXWPnT2vi1op7/1NzSstRo06D3k/FBZQyVV1A1AxwkKTWkuHoYkfVlbmARyc7zZ5nKJYQ==", "dev": true, "license": "Apache-2.0", "engines": { "node": ">=24.0.0" }, "peerDependencies": { - "@askrjs/askr": ">=0.2.0 <0.3.0" + "@askrjs/askr": ">=0.2.3 <0.3.0" } }, "node_modules/@askrjs/logos": { @@ -119,14 +119,14 @@ } }, "node_modules/@askrjs/node": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/@askrjs/node/-/node-0.2.1.tgz", - "integrity": "sha512-3aFMYUgfM7cM50MfypwKgP3rJt1l6UrktZZj82mZ4eqzFkZBIWjbji4DHiY8OzYc0ZnEDiU1wUMNxuBX0UkvsQ==", + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@askrjs/node/-/node-0.2.2.tgz", + "integrity": "sha512-BT+DxDNnIgbOn1sUZ0zMs+fSW36iniYCfonF6sxuexy1hu63a1N7+dgte/Lac7dbpumOhZMiMxeGYB2X6/TIuw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@askrjs/auth": ">=0.2.0 <0.3.0", - "@askrjs/server": ">=0.2.0 <0.3.0", + "@askrjs/auth": ">=0.2.1 <0.3.0", + "@askrjs/server": ">=0.2.1 <0.3.0", "@types/ws": "^8.18.1", "ws": "^8.21.3" }, @@ -145,20 +145,20 @@ } }, "node_modules/@askrjs/server": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/@askrjs/server/-/server-0.2.1.tgz", - "integrity": "sha512-Gk0nvYdm2OgbjvNFMZ2ooMUAvekqKfDhZ/RqBCWY0goalL5hd7V/yBdVw1uCssn7TLpCXgDowKv73HyONGV2hQ==", + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@askrjs/server/-/server-0.2.2.tgz", + "integrity": "sha512-cvthrEdQ/soWmX8IxcCKGJCh6TEXonDihCyGPhyN7+kkuDL9yaN1ipWZmFb3ZhOtVn7SMgZPpFt9PmT9e51JsA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@askrjs/auth": ">=0.2.0 <0.3.0", - "@askrjs/schema": ">=0.2.0 <0.3.0" + "@askrjs/auth": ">=0.2.1 <0.3.0", + "@askrjs/schema": ">=0.2.1 <0.3.0" }, "engines": { "node": ">=24.0.0" }, "peerDependencies": { - "@askrjs/askr": ">=0.2.0 <0.3.0" + "@askrjs/askr": ">=0.2.3 <0.3.0" }, "peerDependenciesMeta": { "@askrjs/askr": { @@ -167,9 +167,9 @@ } }, "node_modules/@askrjs/themes": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/@askrjs/themes/-/themes-0.2.4.tgz", - "integrity": "sha512-A1NnPuDkf4+lZWEo6u3AAKQ+7WbzFXTKUvPWGsFd1alDZqtHebPZgQ0FoJchWJFJUxAquL09JJ08q2+Cwyvc8w==", + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/@askrjs/themes/-/themes-0.2.5.tgz", + "integrity": "sha512-NUSdBONW9gCH6HpBppethnuWc21KMFbq2VOhJ1qU1cGCiamer1amBSv0XWBdYK0V8DHgfKVyg4pS5ct6I+Gp3g==", "dev": true, "license": "Apache-2.0", "engines": { @@ -181,37 +181,37 @@ } }, "node_modules/@askrjs/ui": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@askrjs/ui/-/ui-0.2.3.tgz", - "integrity": "sha512-iLwSyaMTh7tDOF3TS9Y4nirspiRnE3Awqf4Rm4MjVW/pyeBWYEoG628a+ls/ZBW9XTXOo2CA+jGS3TYskFKS+w==", + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@askrjs/ui/-/ui-0.2.4.tgz", + "integrity": "sha512-pZztrq2I9fEveLxt6rfwrA+RYjlcwQ2z/VR+DGLo6DkcWrr/tWqkykmrVngrK9RdGFZRTSePa2P5JTIQIRSfhg==", "dev": true, "license": "Apache-2.0", "engines": { "node": ">=24.0.0" }, "peerDependencies": { - "@askrjs/askr": ">=0.2.0 <0.3.0" + "@askrjs/askr": ">=0.2.3 <0.3.0" } }, "node_modules/@askrjs/vite": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/@askrjs/vite/-/vite-0.2.1.tgz", - "integrity": "sha512-TBXxIXcIxhkwc8OLxUL9+tFu2F8rkq00dEMvqulgBZVv48LZtwukqU9p7it9riDM5vO+uqVHr0kwOswfIWTgdg==", + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@askrjs/vite/-/vite-0.2.2.tgz", + "integrity": "sha512-xScTxWqftad3N/un9LL7yVjawPpxEKNZKG4qq2M0pi3dUWQv+1Tyy2oE+UgJN5e64Bkgq7gY1BYFfr8Th0HwSw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@askrjs/node": ">=0.2.0 <0.3.0", - "oxc-parser": "^0.144.0", + "@askrjs/node": ">=0.2.1 <0.3.0", + "oxc-parser": "^0.147.0", "parse5": "^8.0.1" }, "engines": { "node": ">=24.0.0" }, "peerDependencies": { - "@askrjs/askr": ">=0.2.0 <0.3.0", + "@askrjs/askr": ">=0.2.3 <0.3.0", "sharp": "^0.35.3", - "vite": "^8.2.1", - "vite-plus": "^0.2.8" + "vite": "^8.2.2", + "vite-plus": "^0.2.8 || ^0.3.0" }, "peerDependenciesMeta": { "sharp": { @@ -907,9 +907,9 @@ } }, "node_modules/@oxc-parser/binding-android-arm-eabi": { - "version": "0.144.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.144.0.tgz", - "integrity": "sha512-IaoGBEp/huvja99PxI/b72TbKFzA/UzxxAka7f233dc/Tg/rRTX9Qn8IquFLWwWf4IddN/5TaJ8S4Subbjq7wQ==", + "version": "0.147.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.147.0.tgz", + "integrity": "sha512-fOtoGvIoirkvxQVw9J1WJPxz571XPgLsPf9uhRD+PJteUnvrJHMDmK9pw2yZEGGyismtRoEsp+JcXUdF/JDMDw==", "cpu": [ "arm" ], @@ -924,9 +924,9 @@ } }, "node_modules/@oxc-parser/binding-android-arm64": { - "version": "0.144.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.144.0.tgz", - "integrity": "sha512-u6fJu8XQXP99+9pYO3jq7F1D7V9fyFuDBShYFlr+gY+GcJzhveeN/zoMfuXxX6XBquJO0kjqKd7BjhJ7pClWXQ==", + "version": "0.147.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.147.0.tgz", + "integrity": "sha512-emjQHOYJaomo4ykaXQ1EItunr/I94Nk01oqBmU4dSkKSTupIDx6OysVDf2e8Eytm77rb+4ZxzgElyWP7rcEX7A==", "cpu": [ "arm64" ], @@ -941,9 +941,9 @@ } }, "node_modules/@oxc-parser/binding-darwin-arm64": { - "version": "0.144.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.144.0.tgz", - "integrity": "sha512-o9xGSmMQcboJLjwI+acFf6xa7nYdp0/nRFE8ry4Xrt8OviQ9ITFDBUkAXVJMOLchSV9Pu981GxJuW0mt4i6vQQ==", + "version": "0.147.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.147.0.tgz", + "integrity": "sha512-kXvBPJL7RmDPJ2mze/vXPPVQimCDtFr9OFLjf7dyhV5Dx64cgcXh9KKrA1sMWvCObvJll9CZZUO0FBlFwD0l6A==", "cpu": [ "arm64" ], @@ -958,9 +958,9 @@ } }, "node_modules/@oxc-parser/binding-darwin-x64": { - "version": "0.144.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.144.0.tgz", - "integrity": "sha512-2yNm4tX++W3KLbyziVhs5alSb74a3C1uNDu/1P/AQj1ux8yZYuvbCAeJCCrGkr8J18ZmnBAzDthdTZBEAEb71w==", + "version": "0.147.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.147.0.tgz", + "integrity": "sha512-mgFF8pLU6R64LbT27lSrtVRspVC/3IcZ0qyIikzmi78Y3Ik2OPnlAHHI0UEBRcC3qmNgtjaef7zkFt7/uPxIcw==", "cpu": [ "x64" ], @@ -975,9 +975,9 @@ } }, "node_modules/@oxc-parser/binding-freebsd-x64": { - "version": "0.144.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.144.0.tgz", - "integrity": "sha512-TG4CjY1OjynplkF9nAQ9m9zboPJksnbAF+U/9xQGSXyIt+5sQRitwfQrUgjrG17/up9G8k/boNjLD2zp4xq1Kw==", + "version": "0.147.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.147.0.tgz", + "integrity": "sha512-v38aiF11qufOTBcCAKL4skgQf0zJ4NEvRlivq7B5kHrlyvjCLjvNrMtNWDTz1SDUL6/xVsJRmLDxv2e+Cp4oWw==", "cpu": [ "x64" ], @@ -992,9 +992,9 @@ } }, "node_modules/@oxc-parser/binding-linux-arm-gnueabihf": { - "version": "0.144.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.144.0.tgz", - "integrity": "sha512-i0T9NagVmqc+rbSyBr5mDKj7TCMIBRrSteQlQJt1WhWIH/sZeOP9GB09H9w98YdinuZkDIPmO7Fz0jDC7bMvSA==", + "version": "0.147.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.147.0.tgz", + "integrity": "sha512-AeIiBbwUaP0H1+4/qGW9l5qHecS/+XA5iMuieVcGb1T+tyc2dVGspFW13BWk/XrLsiGP/CiDJTJqAPLLCzZHkw==", "cpu": [ "arm" ], @@ -1009,9 +1009,9 @@ } }, "node_modules/@oxc-parser/binding-linux-arm-musleabihf": { - "version": "0.144.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.144.0.tgz", - "integrity": "sha512-YUsEqM3WMS3mOON+TFf7RzS0QthzEifx7tpUQu0GSF2MsT+D6t154ZBs6WhWaCZNl0GuVDEvndCyEAUBHzSHGw==", + "version": "0.147.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.147.0.tgz", + "integrity": "sha512-/41MKPW4RgPY4DJco0NCF0RYX3IMZaVlRNMNzvhaxRavc7tN3Txm+qllZbh0aMRs0VHdgUlbI8TcAOiTai4TKg==", "cpu": [ "arm" ], @@ -1026,9 +1026,9 @@ } }, "node_modules/@oxc-parser/binding-linux-arm64-gnu": { - "version": "0.144.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.144.0.tgz", - "integrity": "sha512-LlWH4kt+IET3qIAe0e0IFLNlQ3CVUAfN//UFsA6N0/FghMh/FBk1e+wzvgG+t8WSnXkvf8B1TovquS2EJras9g==", + "version": "0.147.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.147.0.tgz", + "integrity": "sha512-bmpw/RPhVXgZbtb3xBDuwW5s8+LvZYdqcDSX/sP2ltL77aTio3DP/B5ZTwwgoJ6Mr9vJs4RrmgEKW9XkLNUU1g==", "cpu": [ "arm64" ], @@ -1046,9 +1046,9 @@ } }, "node_modules/@oxc-parser/binding-linux-arm64-musl": { - "version": "0.144.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.144.0.tgz", - "integrity": "sha512-ajXbXIWBWUD4U3IQxr2p6DiXwD7GPHEBLa+JteKhIfvLmBEBdTjO28lP+5r3AF2qal8cxLERfTnGs64Z22ZuXw==", + "version": "0.147.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.147.0.tgz", + "integrity": "sha512-gd7VX/FDVOw6mjQcu45iIcp4QkgybgJwh3a0OFG2NxmPCj628mQWD96QGu1kK8+mZF9qK4b/gIEyC63vQoB7+Q==", "cpu": [ "arm64" ], @@ -1066,9 +1066,9 @@ } }, "node_modules/@oxc-parser/binding-linux-ppc64-gnu": { - "version": "0.144.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.144.0.tgz", - "integrity": "sha512-/+sDzL/4cWEwdqenKo/DX3gkkxu7H7ytFAtealDey/Gd59yPWn64obVk6wXKVjVfXMciUUUTySxZG9AIMX3RNQ==", + "version": "0.147.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.147.0.tgz", + "integrity": "sha512-HnAzcfki7dSUNHf510Q2NmbJlz8Ys7rn8l9l588Pkx0tYe1BHLZnmELIgqizJ4WPhHGSwN8Ce+B/menVxS3odA==", "cpu": [ "ppc64" ], @@ -1086,9 +1086,9 @@ } }, "node_modules/@oxc-parser/binding-linux-riscv64-gnu": { - "version": "0.144.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.144.0.tgz", - "integrity": "sha512-dMVhPBbrd8y6aeLd7Ihn9OZhKO8QgCQVtLBTRgbmf4lKrcR61SpaQRJPJuocTc/Cn5SJMm+alHYPnzkbOGM7Dg==", + "version": "0.147.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.147.0.tgz", + "integrity": "sha512-qlkOL6wT44U+fT5s/+sR6Shx0OdwvQF83JyIPZUxG/ovqZF5/7atOtjH+JPZ5/7ATQLbFBBSmghcy/+2NVB/ew==", "cpu": [ "riscv64" ], @@ -1106,9 +1106,9 @@ } }, "node_modules/@oxc-parser/binding-linux-riscv64-musl": { - "version": "0.144.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.144.0.tgz", - "integrity": "sha512-jQ8O0+b6J2IhJgm0DnqEJq8hG9OocmF1b4TBWCk08CRWqTmLZj/+lYs7w3OA60nb2SiqOmthQyJPacrCi7y+oQ==", + "version": "0.147.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.147.0.tgz", + "integrity": "sha512-DlefD7L7sMXs/3hIBH23Egk0phj8kG0SA81dVGOQ3S1ekjOlmTLH2E+F2Thwfh1slKx6aH+lNc5fQYAw0GU7/g==", "cpu": [ "riscv64" ], @@ -1126,9 +1126,9 @@ } }, "node_modules/@oxc-parser/binding-linux-s390x-gnu": { - "version": "0.144.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.144.0.tgz", - "integrity": "sha512-/mZxZtcGrzuvqPLPV7gjavbROYs/dHy6+yQ2Sl/2to/+qoC/v6CcruGFnfQPzQbXXTYReXJzLb5QY9KmgCbJOg==", + "version": "0.147.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.147.0.tgz", + "integrity": "sha512-Xqpagk/031IvZ4svrk2FF01YEqM/iN3MJV3SVZadKg/CsGlDCGoREqKHXYnoV5+8SfGe/m6RM1szXFLusTu/Uw==", "cpu": [ "s390x" ], @@ -1146,9 +1146,9 @@ } }, "node_modules/@oxc-parser/binding-linux-x64-gnu": { - "version": "0.144.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.144.0.tgz", - "integrity": "sha512-/caRGFHcarHZlBrucBwQwBbzqhD+UfZZ/r7soocS0/mp6/5KTq+1Zl/OQx5lFLcN+GpUPYszbrvQU9MCFLEzJg==", + "version": "0.147.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.147.0.tgz", + "integrity": "sha512-QioQOeUbI4ATUr0S2z88uA3Cds2R3Mm5Ge7U8XNYtlTb2GJF3rWlcj70z0AJhhOlbdm0YgVjqPBldUNbFylDIg==", "cpu": [ "x64" ], @@ -1166,9 +1166,9 @@ } }, "node_modules/@oxc-parser/binding-linux-x64-musl": { - "version": "0.144.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.144.0.tgz", - "integrity": "sha512-qFtwAo6BWuWDjh57QDdZdYi746GW0mIeoZSGK2jJqlxIjo389Y/7lrriTOI+ou7tTvusOrSYGQZ+e+nDswt2vQ==", + "version": "0.147.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.147.0.tgz", + "integrity": "sha512-NXy1tv/OdC+pPTwf9RiCZWPK53V/Xq/2cjSnjOSyKopajdaDqMIkgtDY+jXZemp2e8px5FeWfY2L2LwhKZQovg==", "cpu": [ "x64" ], @@ -1186,9 +1186,9 @@ } }, "node_modules/@oxc-parser/binding-openharmony-arm64": { - "version": "0.144.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-openharmony-arm64/-/binding-openharmony-arm64-0.144.0.tgz", - "integrity": "sha512-n+NgMGWWEYpH+rlkMhDvLR2k8vJDHQp3j8SoS86IS6J0hc4kuDaiYAAvu9dF86xjeGYy+h9WLj12sylmBJV9sg==", + "version": "0.147.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-openharmony-arm64/-/binding-openharmony-arm64-0.147.0.tgz", + "integrity": "sha512-GpGWZ6oKz4bjCWW9Mz5pCaGPyk2Aaze6zEoaslIQqpSLtpx5pXj/ap5gUNb5Jn2LIbqWyjkGLX9yv3NMuNcBVQ==", "cpu": [ "arm64" ], @@ -1203,9 +1203,9 @@ } }, "node_modules/@oxc-parser/binding-win32-arm64-msvc": { - "version": "0.144.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.144.0.tgz", - "integrity": "sha512-fShxpJiCBOdG4+jBAvahTTFUDI5djXc/+IPC1ldeC8LbyCW0h9m/7oP8DRZWI7WT2Ahv8sHtZz4ugECylCFpTA==", + "version": "0.147.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.147.0.tgz", + "integrity": "sha512-a8mlt7CC8z7LUdCfaxhff4kCd+vSjE+NEFL0cxA8ukfuSnvAto/pWTjytW4BuVLnQGcCVdHJwcRKOfs++H+tjw==", "cpu": [ "arm64" ], @@ -1220,9 +1220,9 @@ } }, "node_modules/@oxc-parser/binding-win32-ia32-msvc": { - "version": "0.144.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.144.0.tgz", - "integrity": "sha512-vFrYV+C3lJhIiSdNhdkZHnZ0YIClgTSluXaPMYjlGslVPD+uJg6K1s2xNL/X/gdBcy9IIbjbp0vNBwQhdMMdkw==", + "version": "0.147.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.147.0.tgz", + "integrity": "sha512-M5ViVDBcFLnl2632AuuWuP35zEL5oikK1jTx8r3+902VEDeSNHxFZAB6RZyfZ7MU6Oi5QTOG8MmMkIeHsudSaw==", "cpu": [ "ia32" ], @@ -1237,9 +1237,9 @@ } }, "node_modules/@oxc-parser/binding-win32-x64-msvc": { - "version": "0.144.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.144.0.tgz", - "integrity": "sha512-0ASbKSwdeihMekyy7y4jC0CwW3XBDZk5Sw64m/W7IReVQHaduqLYssF9KCJA2oHG9oldnl/1CMxqCoImXfqQkA==", + "version": "0.147.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.147.0.tgz", + "integrity": "sha512-DUaE13OwnUSlHpLZNcC/nuT10ivlWqc5EZgsfgXuAmWYw0r3nDxGeLD1zlGwYwIgVk1/ZMAxoXpV+05stvbHaA==", "cpu": [ "x64" ], @@ -1264,9 +1264,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.144.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.144.0.tgz", - "integrity": "sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==", + "version": "0.147.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.147.0.tgz", + "integrity": "sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg==", "dev": true, "license": "MIT", "funding": { @@ -4170,9 +4170,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.4.0.tgz", - "integrity": "sha512-jE7vUJIebKzYQI5xu4co5CRBDlDEYnHrdzsxs4O2giCz4v2SbVMYKpmt1D9L38OKQAeCWmrOTRiCV93u0UkaJA==", + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.4.1.tgz", + "integrity": "sha512-28R/k+NAjeuf7+CKlTxWZVExJGwVVLwY06DgEnOMz2gEpfNkDcD7QvyiVPT0xy0XXhU8vHsd4Ot42OOPdJG7dQ==", "funding": [ { "type": "github", @@ -4848,13 +4848,13 @@ } }, "node_modules/oxc-parser": { - "version": "0.144.0", - "resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.144.0.tgz", - "integrity": "sha512-eacM4wMgGWXctHubY262yo+50E76qtQBqe+uK73YEV1IT3qP12Acbnf9Nc8t+agIAdnko9iVT4KF83/d0EjY5w==", + "version": "0.147.0", + "resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.147.0.tgz", + "integrity": "sha512-5xaug6t7GfV3BO5Iv+xHW1rmQkDEQ3BEu3L8g3InsvWO5i8CYGc4tCZ2X985QcwWNycFJam+aOns6Nr2XAThTA==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "^0.144.0" + "@oxc-project/types": "^0.147.0" }, "engines": { "node": "^20.19.0 || >=22.12.0" @@ -4863,25 +4863,25 @@ "url": "https://github.com/sponsors/Boshen" }, "optionalDependencies": { - "@oxc-parser/binding-android-arm-eabi": "0.144.0", - "@oxc-parser/binding-android-arm64": "0.144.0", - "@oxc-parser/binding-darwin-arm64": "0.144.0", - "@oxc-parser/binding-darwin-x64": "0.144.0", - "@oxc-parser/binding-freebsd-x64": "0.144.0", - "@oxc-parser/binding-linux-arm-gnueabihf": "0.144.0", - "@oxc-parser/binding-linux-arm-musleabihf": "0.144.0", - "@oxc-parser/binding-linux-arm64-gnu": "0.144.0", - "@oxc-parser/binding-linux-arm64-musl": "0.144.0", - "@oxc-parser/binding-linux-ppc64-gnu": "0.144.0", - "@oxc-parser/binding-linux-riscv64-gnu": "0.144.0", - "@oxc-parser/binding-linux-riscv64-musl": "0.144.0", - "@oxc-parser/binding-linux-s390x-gnu": "0.144.0", - "@oxc-parser/binding-linux-x64-gnu": "0.144.0", - "@oxc-parser/binding-linux-x64-musl": "0.144.0", - "@oxc-parser/binding-openharmony-arm64": "0.144.0", - "@oxc-parser/binding-win32-arm64-msvc": "0.144.0", - "@oxc-parser/binding-win32-ia32-msvc": "0.144.0", - "@oxc-parser/binding-win32-x64-msvc": "0.144.0" + "@oxc-parser/binding-android-arm-eabi": "0.147.0", + "@oxc-parser/binding-android-arm64": "0.147.0", + "@oxc-parser/binding-darwin-arm64": "0.147.0", + "@oxc-parser/binding-darwin-x64": "0.147.0", + "@oxc-parser/binding-freebsd-x64": "0.147.0", + "@oxc-parser/binding-linux-arm-gnueabihf": "0.147.0", + "@oxc-parser/binding-linux-arm-musleabihf": "0.147.0", + "@oxc-parser/binding-linux-arm64-gnu": "0.147.0", + "@oxc-parser/binding-linux-arm64-musl": "0.147.0", + "@oxc-parser/binding-linux-ppc64-gnu": "0.147.0", + "@oxc-parser/binding-linux-riscv64-gnu": "0.147.0", + "@oxc-parser/binding-linux-riscv64-musl": "0.147.0", + "@oxc-parser/binding-linux-s390x-gnu": "0.147.0", + "@oxc-parser/binding-linux-x64-gnu": "0.147.0", + "@oxc-parser/binding-linux-x64-musl": "0.147.0", + "@oxc-parser/binding-openharmony-arm64": "0.147.0", + "@oxc-parser/binding-win32-arm64-msvc": "0.147.0", + "@oxc-parser/binding-win32-ia32-msvc": "0.147.0", + "@oxc-parser/binding-win32-x64-msvc": "0.147.0" } }, "node_modules/oxfmt": { diff --git a/package.json b/package.json index 79e690a..0817c9d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@askrjs/cli", - "version": "0.2.2", + "version": "0.2.3", "description": "Unified CLI for the Askr platform", "homepage": "https://github.com/askrjs/askr-cli#readme", "bugs": { @@ -58,7 +58,7 @@ }, "dependencies": { "@npmcli/config": "^11.0.1", - "js-yaml": "^5.4.0", + "js-yaml": "^5.4.1", "minimatch": "^10.2.6", "npm-registry-fetch": "^20.0.1", "parse5": "^8.0.1", diff --git a/src/analyze/rules.ts b/src/analyze/rules.ts index 75220d5..7b86c32 100644 --- a/src/analyze/rules.ts +++ b/src/analyze/rules.ts @@ -282,7 +282,7 @@ interface StateBindings { readonly setters: Set; readonly getterSymbols: Set; readonly setterSymbols: Set; - readonly owners: Map; + readonly owners: Map; } function collectStateBindings( @@ -294,7 +294,7 @@ function collectStateBindings( const setters = new Set(); const getterSymbols = new Set(); const setterSymbols = new Set(); - const owners = new Map(); + const owners = new Map(); visit(sourceFile, (node) => { if ( !ts.isVariableDeclaration(node) || @@ -308,28 +308,61 @@ function collectStateBindings( if (ts.isIdentifier(node.name)) { getters.add(node.name.text); const symbol = checker.getSymbolAtLocation(node.name); - if (symbol) getterSymbols.add(symbol); - owners.set(node.name.text, owner); + if (symbol) { + getterSymbols.add(symbol); + owners.set(symbol, owner); + } } if (ts.isArrayBindingPattern(node.name)) { const [getter, setter] = node.name.elements; if (getter && ts.isBindingElement(getter) && ts.isIdentifier(getter.name)) { getters.add(getter.name.text); const symbol = checker.getSymbolAtLocation(getter.name); - if (symbol) getterSymbols.add(symbol); - owners.set(getter.name.text, owner); + if (symbol) { + getterSymbols.add(symbol); + owners.set(symbol, owner); + } } if (setter && ts.isBindingElement(setter) && ts.isIdentifier(setter.name)) { setters.add(setter.name.text); const symbol = checker.getSymbolAtLocation(setter.name); - if (symbol) setterSymbols.add(symbol); - owners.set(setter.name.text, owner); + if (symbol) { + setterSymbols.add(symbol); + owners.set(symbol, owner); + } } } }); return { getters, setters, getterSymbols, setterSymbols, owners }; } +function stateBindingSymbol( + node: ts.Identifier, + names: ReadonlySet, + symbols: ReadonlySet, + checker: ts.TypeChecker, +): ts.Symbol | null { + if (!names.has(node.text)) return null; + const symbol = checker.getSymbolAtLocation(node); + return symbol && symbols.has(symbol) ? symbol : null; +} + +function stateGetterSymbol( + node: ts.Identifier, + state: StateBindings, + checker: ts.TypeChecker, +): ts.Symbol | null { + return stateBindingSymbol(node, state.getters, state.getterSymbols, checker); +} + +function stateSetterSymbol( + node: ts.Identifier, + state: StateBindings, + checker: ts.TypeChecker, +): ts.Symbol | null { + return stateBindingSymbol(node, state.setters, state.setterSymbols, checker); +} + function isCallableJsxProp(node: ts.Identifier, checker: ts.TypeChecker): boolean { const expression = node.parent; if (!ts.isJsxExpression(expression) || !expression.expression) return false; @@ -455,8 +488,7 @@ const stateAccessRule: AnalyzeRule = { if ( ts.isCallExpression(node) && ts.isIdentifier(node.expression) && - state.setters.has(node.expression.text) && - state.setterSymbols.has(context.checker.getSymbolAtLocation(node.expression)!) && + stateSetterSymbol(node.expression, state, context.checker) && node.arguments.length === 0 ) { diagnostics.push( @@ -470,8 +502,7 @@ const stateAccessRule: AnalyzeRule = { ); } else if ( ts.isIdentifier(node) && - state.getters.has(node.text) && - state.getterSymbols.has(context.checker.getSymbolAtLocation(node)!) && + stateGetterSymbol(node, state, context.checker) && identifierIsReadAsValue(node, context.checker) ) { diagnostics.push( @@ -502,31 +533,27 @@ const stateRenderWriteRule: AnalyzeRule = { const state = collectStateBindings(sourceFile, bindings, context.checker); visit(sourceFile, (node) => { if (!ts.isCallExpression(node)) return; - let cellName: string | null = null; - if ( - ts.isIdentifier(node.expression) && - state.setters.has(node.expression.text) && - state.setterSymbols.has(context.checker.getSymbolAtLocation(node.expression)!) - ) { - cellName = node.expression.text; + let cell: { name: string; symbol: ts.Symbol } | null = null; + if (ts.isIdentifier(node.expression)) { + const symbol = stateSetterSymbol(node.expression, state, context.checker); + if (symbol) cell = { name: node.expression.text, symbol }; } else if ( ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === "set" && - ts.isIdentifier(node.expression.expression) && - state.getters.has(node.expression.expression.text) && - state.getterSymbols.has(context.checker.getSymbolAtLocation(node.expression.expression)!) + ts.isIdentifier(node.expression.expression) ) { - cellName = node.expression.expression.text; + const symbol = stateGetterSymbol(node.expression.expression, state, context.checker); + if (symbol) cell = { name: node.expression.expression.text, symbol }; } - if (!cellName) return; - const declarationOwner = state.owners.get(cellName); + if (!cell) return; + const declarationOwner = state.owners.get(cell.symbol); if (!declarationOwner || containingFunction(node) !== declarationOwner) return; diagnostics.push( diagnostic( context, node.expression, this, - `State '${cellName}' is mutated during component render.`, + `State '${cell.name}' is mutated during component render.`, "Move the update to an event handler, task, or other post-render operation.", ), ); @@ -977,16 +1004,17 @@ const stableKeyRule: AnalyzeRule = { function reactiveMapReceiver( expression: ts.Expression, - stateGetters: ReadonlySet, + state: StateBindings, + checker: ts.TypeChecker, ): boolean { if (ts.isCallExpression(expression) && ts.isIdentifier(expression.expression)) { - return stateGetters.has(expression.expression.text); + return Boolean(stateGetterSymbol(expression.expression, state, checker)); } if (ts.isCallExpression(expression) && ts.isPropertyAccessExpression(expression.expression)) { - return reactiveMapReceiver(expression.expression.expression, stateGetters); + return reactiveMapReceiver(expression.expression.expression, state, checker); } if (ts.isPropertyAccessExpression(expression)) { - return reactiveMapReceiver(expression.expression, stateGetters); + return reactiveMapReceiver(expression.expression, state, checker); } return false; } @@ -1006,7 +1034,7 @@ const preferForRule: AnalyzeRule = { !ts.isCallExpression(node) || !ts.isPropertyAccessExpression(node.expression) || node.expression.name.text !== "map" || - !reactiveMapReceiver(node.expression.expression, state.getters) || + !reactiveMapReceiver(node.expression.expression, state, context.checker) || !node.parent || !ts.isJsxExpression(node.parent) ) { @@ -2835,7 +2863,7 @@ const exhaustiveDependenciesRule: AnalyzeRule = { ) { continue; } - const reactive = collectStateBindings(sourceFile, bindings, context.checker).getters; + const state = collectStateBindings(sourceFile, bindings, context.checker); for (const { node, name } of sourceFacts(sourceFile).calls) { if (name !== "resource" && name !== "stream") continue; const loader = node.arguments[0]; @@ -2861,7 +2889,7 @@ const exhaustiveDependenciesRule: AnalyzeRule = { if ( ts.isCallExpression(candidate) && ts.isIdentifier(candidate.expression) && - reactive.has(candidate.expression.text) && + stateGetterSymbol(candidate.expression, state, context.checker) && !declared.has(candidate.expression.text) ) { missing.add(candidate.expression.text); @@ -2896,8 +2924,8 @@ const forRowClosureCaptureRule: AnalyzeRule = { for (const sourceFile of context.sourceFiles) { const bindings = sourceBindings(sourceFile); if (!sourceFacts(sourceFile).jsx.some((fact) => fact.name === "For")) continue; - const reactive = collectStateBindings(sourceFile, bindings, context.checker).getters; - const snapshots = new Set(); + const state = collectStateBindings(sourceFile, bindings, context.checker); + const snapshots = new Set(); visit(sourceFile, (candidate) => { if ( ts.isVariableDeclaration(candidate) && @@ -2905,9 +2933,10 @@ const forRowClosureCaptureRule: AnalyzeRule = { candidate.initializer && ts.isCallExpression(candidate.initializer) && ts.isIdentifier(candidate.initializer.expression) && - reactive.has(candidate.initializer.expression.text) + stateGetterSymbol(candidate.initializer.expression, state, context.checker) ) { - snapshots.add(candidate.name.text); + const symbol = context.checker.getSymbolAtLocation(candidate.name); + if (symbol) snapshots.add(symbol); } }); visit(sourceFile, (node) => { @@ -2938,19 +2967,22 @@ const forRowClosureCaptureRule: AnalyzeRule = { if ( ts.isCallExpression(candidate) && ts.isIdentifier(candidate.expression) && - reactive.has(candidate.expression.text) + stateGetterSymbol(candidate.expression, state, context.checker) ) { captured.add(candidate.expression.text); } - if ( - ts.isIdentifier(candidate) && - snapshots.has(candidate.text) && - !( - ts.isPropertyAccessExpression(candidate.parent) && - candidate.parent.name === candidate - ) - ) { - captured.add(candidate.text); + if (ts.isIdentifier(candidate)) { + const symbol = context.checker.getSymbolAtLocation(candidate); + if ( + symbol && + snapshots.has(symbol) && + !( + ts.isPropertyAccessExpression(candidate.parent) && + candidate.parent.name === candidate + ) + ) { + captured.add(candidate.text); + } } ts.forEachChild(candidate, walk); }; @@ -3746,7 +3778,7 @@ const noEffectDataLoadingRule: AnalyzeRule = { if ( ts.isCallExpression(candidate) && ts.isIdentifier(candidate.expression) && - state.setters.has(candidate.expression.text) + stateSetterSymbol(candidate.expression, state, context.checker) ) { writesState = true; } diff --git a/tests/analyze-rules.test.ts b/tests/analyze-rules.test.ts index 45a26f8..fa048fa 100644 --- a/tests/analyze-rules.test.ts +++ b/tests/analyze-rules.test.ts @@ -153,6 +153,53 @@ describe("analyzer rules", () => { expect(found.filter((entry) => entry.ruleId === "askr/state-access")).toEqual([]); }); + it("should scope every state-sensitive rule to the bound accessor symbols", async () => { + const root = await fixture({ + "src/page.tsx": ` + import { For, state } from "@askrjs/askr"; + import { resource, task } from "@askrjs/askr/resources"; + export function Page() { + const [items, setItems] = state([{ id: "state" }]); + const [query, setQuery] = state("state"); + function Shadowed() { + const items = () => [{ id: "local" }]; + const query = () => "local"; + const setQuery = (_value: string) => {}; + const snapshot = query(); + setQuery("render"); + resource(() => query(), []); + task(async () => { + const response = await fetch("/api/value"); + setQuery(await response.text()); + }); + return
+ {items().map((item) => {item.id})} + item.id}> + {() => {query()}{snapshot}} + +
; + } + return ; + } + `, + }); + + const found = await diagnostics(root); + for (const ruleId of [ + "askr/state-access", + "askr/state-render-write", + "askr/prefer-for", + "askr/exhaustive-dependencies", + "askr/for-row-closure-capture", + "askr/no-effect-data-loading", + ]) { + expect( + found.filter((entry) => entry.ruleId === ruleId), + ruleId, + ).toEqual([]); + } + }); + it("should validate statically known For key strategies without rejecting dynamic values", async () => { const root = await fixture({ "src/page.tsx": ` @@ -597,6 +644,27 @@ describe("analyzer rules", () => { expect(found.filter((entry) => entry.ruleId === "askr/state-render-write")).toHaveLength(1); }); + it("should preserve same-named state ownership across sibling components", async () => { + const root = await fixture({ + "src/page.tsx": ` + import { state } from "@askrjs/askr"; + export function First() { + const count = state(0); + count.set(1); + return
{count()}
; + } + export function Second() { + const count = state(0); + count.set(2); + return
{count()}
; + } + `, + }); + + const found = await diagnostics(root); + expect(found.filter((entry) => entry.ruleId === "askr/state-render-write")).toHaveLength(2); + }); + it("should report malformed source and analyze JavaScript without a tsconfig", async () => { const root = await fixture( { From 148b4a098499c9afe5b1d0228d53e3f291d3bb71 Mon Sep 17 00:00:00 2001 From: Jeff Repanich Date: Fri, 28 Aug 2026 13:29:11 -0400 Subject: [PATCH 4/4] fix(analyze): bind watch exception by symbol --- src/analyze/rules.ts | 30 +++++++++++++++++++----------- tests/analyze-rules.test.ts | 21 +++++++++++++++++++++ 2 files changed, 40 insertions(+), 11 deletions(-) diff --git a/src/analyze/rules.ts b/src/analyze/rules.ts index 7b86c32..3f0fb5d 100644 --- a/src/analyze/rules.ts +++ b/src/analyze/rules.ts @@ -425,7 +425,7 @@ function identifierIsReadAsValue(node: ts.Identifier, checker: ts.TypeChecker): } if (ts.isJsxAttribute(parent) && parent.name === node) return false; if (isCallableJsxProp(node, checker)) return false; - if (isWatchSourceReference(node)) return false; + if (isWatchSourceReference(node, checker)) return false; if (ts.isPropertyAssignment(parent) && parent.name === node) return false; if (ts.isShorthandPropertyAssignment(parent)) { const contextual = checker.getContextualType(node); @@ -438,7 +438,7 @@ function identifierIsReadAsValue(node: ts.Identifier, checker: ts.TypeChecker): return true; } -function isWatchSourceReference(node: ts.Identifier): boolean { +function isWatchSourceReference(node: ts.Identifier, checker: ts.TypeChecker): boolean { let sourceExpression: ts.Node = node; while ( sourceExpression.parent && @@ -462,15 +462,23 @@ function isWatchSourceReference(node: ts.Identifier): boolean { ) { return false; } - return Boolean( - statement.importClause?.namedBindings && - ts.isNamedImports(statement.importClause.namedBindings) && - statement.importClause.namedBindings.elements.some( - (specifier) => - specifier.name.text === localName && - (specifier.propertyName?.text ?? specifier.name.text) === "watch", - ), - ); + if ( + !statement.importClause?.namedBindings || + !ts.isNamedImports(statement.importClause.namedBindings) + ) { + return false; + } + const callSymbol = checker.getSymbolAtLocation(call.expression); + return statement.importClause.namedBindings.elements.some((specifier) => { + if ( + specifier.name.text !== localName || + (specifier.propertyName?.text ?? specifier.name.text) !== "watch" + ) { + return false; + } + const importSymbol = checker.getSymbolAtLocation(specifier.name); + return Boolean(callSymbol && importSymbol && callSymbol === importSymbol); + }); }); } diff --git a/tests/analyze-rules.test.ts b/tests/analyze-rules.test.ts index fa048fa..b414b2c 100644 --- a/tests/analyze-rules.test.ts +++ b/tests/analyze-rules.test.ts @@ -153,6 +153,27 @@ describe("analyzer rules", () => { expect(found.filter((entry) => entry.ruleId === "askr/state-access")).toEqual([]); }); + it("should only allow state accessors passed to the imported watch binding", async () => { + const root = await fixture({ + "src/page.tsx": ` + import { state } from "@askrjs/askr"; + import { watch } from "@askrjs/askr/resources"; + export function Page() { + const value = state("state"); + function Shadowed() { + const watch = (source: () => string) => source; + watch(value); + return {value()}; + } + return ; + } + `, + }); + + const found = await diagnostics(root); + expect(found.filter((entry) => entry.ruleId === "askr/state-access")).toHaveLength(1); + }); + it("should scope every state-sensitive rule to the bound accessor symbols", async () => { const root = await fixture({ "src/page.tsx": `