diff --git a/docs/analyze.md b/docs/analyze.md index fdee69f..e85fb0a 100644 --- a/docs/analyze.md +++ b/docs/analyze.md @@ -32,6 +32,9 @@ another package or local module is not treated as an Askr API. - `askr/parse-error` reports malformed source before other results can be considered complete. +- `askr/no-hardcoded-theme-token` reports `--ak-*` token names in runtime + JavaScript and TypeScript string, template, and JSX attribute literals. The + exact `@askrjs/themes` workspace is exempt because it owns those declarations. - `askr/stable-render-call` enforces stable top-level calls for state, derived values, selectors, resources, lifecycle operations, actions, queries, and mutations where the AST establishes a component render context. diff --git a/docs/superpowers/plans/2026-07-30-hardcoded-theme-token-rule.md b/docs/superpowers/plans/2026-07-30-hardcoded-theme-token-rule.md new file mode 100644 index 0000000..cd60ec5 --- /dev/null +++ b/docs/superpowers/plans/2026-07-30-hardcoded-theme-token-rule.md @@ -0,0 +1,333 @@ +# Hardcoded Theme Token Analyzer Rule Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add `askr/no-hardcoded-theme-token`, a warning-only analyzer rule that reports literal `--ak-*` references in runtime JS/TS while exempting the exact `@askrjs/themes` workspace. + +**Architecture:** Extend the existing monolithic rule registry with one syntax-only rule. It walks each already-selected TypeScript source file, extracts string and template literal segments, deduplicates nodes, and emits diagnostics through the existing helper; project exclusions remain in source discovery, and the token-owner exemption uses `context.workspace.name`. + +**Tech Stack:** TypeScript 6 compiler API, Vitest, Node.js filesystem fixtures, Vite Plus project scripts. + +--- + +## File Map + +- Modify `src/analyze/rules.ts`: define literal-segment extraction, implement the rule, and register it in `ANALYZE_RULES`. +- Modify `tests/analyze.rules.test.ts`: add focused rule behavior, exclusion, and workspace-identity regression tests. +- Modify `docs/analyze.md`: document the new correctness rule and its exact exemption. + +### Task 1: Detect hardcoded theme-token literals + +**Files:** +- Modify: `tests/analyze.rules.test.ts` in the `describe("analyzer rules")` block +- Modify: `src/analyze/rules.ts` near the shared `visit` helper and before `frameworkConfigRule` + +- [ ] **Step 1: Write the failing literal-detection test** + +Add this test to `tests/analyze.rules.test.ts`: + +```ts +it("reports hardcoded theme tokens in runtime literal segments", async () => { + const root = await fixture({ + "src/tokens.tsx": ` + declare const dynamicToken: string; + declare const suffix: string; + const plain = "var(--ak-color-fg-muted)"; + const staticTemplate = \`--ak-surface-raised\`; + const interpolated = \`var(--ak-\${suffix}) and --ak-border-subtle\`; + const indirect = dynamicToken; + // --ak-comment-only + export const view =
; + `, + }); + + const found = (await diagnostics(root)).filter( + (entry) => entry.ruleId === "askr/no-hardcoded-theme-token", + ); + + expect(found).toHaveLength(5); + expect(found.every((entry) => entry.file === "src/tokens.tsx")).toBe(true); + expect(found.every((entry) => entry.category === "correctness")).toBe(true); + expect(found.every((entry) => entry.severity === "warning")).toBe(true); + expect(found.every((entry) => entry.fix === undefined)).toBe(true); + expect(found.map((entry) => entry.line)).toEqual([4, 5, 6, 6, 9]); +}); +``` + +This covers an ordinary string, a no-substitution template, both static segments +of an interpolated template, a JSX string attribute, and negative cases for a +comment and a non-literal identifier. + +- [ ] **Step 2: Run the focused test and verify it fails** + +Run: + +```bash +rtk npm test -- tests/analyze.rules.test.ts -t "reports hardcoded theme tokens in runtime literal segments" +``` + +Expected: FAIL because `found` has length `0` instead of `5`. + +- [ ] **Step 3: Add the literal-segment extractor and rule** + +Add this helper near the existing `visit` helper in `src/analyze/rules.ts`: + +```ts +type RuntimeLiteralSegment = ts.StringLiteralLike | ts.TemplateLiteralLikeNode; + +function runtimeLiteralSegments(node: ts.Node): readonly RuntimeLiteralSegment[] { + if (ts.isStringLiteralLike(node)) return [node]; + if (ts.isTemplateExpression(node)) { + return [node.head, ...node.templateSpans.map((span) => span.literal)]; + } + return []; +} +``` + +Add this rule before `frameworkConfigRule`: + +```ts +const hardcodedThemeTokenRule: AnalyzeRule = { + id: "askr/no-hardcoded-theme-token", + category: "correctness", + severity: "warning", + description: "Runtime source must not hardcode Askr theme token names.", + analyze(context) { + const diagnostics: AnalyzeDiagnostic[] = []; + const reported = new Set(); + + for (const sourceFile of context.sourceFiles) { + visit(sourceFile, (node) => { + for (const segment of runtimeLiteralSegments(node)) { + if (reported.has(segment) || !segment.text.includes("--ak-")) continue; + reported.add(segment); + diagnostics.push( + diagnostic( + context, + segment, + this, + "Runtime code hardcodes an Askr theme token.", + "Move the token mapping to theme CSS and select it with a semantic class or data-* attribute.", + ), + ); + } + }); + } + + return diagnostics; + }, +}; +``` + +Register it in `ANALYZE_RULES` by inserting this exact entry immediately after +`parseErrorRule` and before `stableRenderRule`: + +```ts + hardcodedThemeTokenRule, +``` + +The `Set` prevents duplicate reports if TypeScript traverses a template segment +both through its `TemplateExpression` and as a child node. + +- [ ] **Step 4: Run the focused test and verify it passes** + +Run: + +```bash +rtk npm test -- tests/analyze.rules.test.ts -t "reports hardcoded theme tokens in runtime literal segments" +``` + +Expected: PASS with five diagnostics from `src/tokens.tsx`. + +- [ ] **Step 5: Run the complete analyzer rule test file** + +Run: + +```bash +rtk npm test -- tests/analyze.rules.test.ts +``` + +Expected: PASS; existing analyzer rules remain unchanged. + +- [ ] **Step 6: Commit the detection behavior** + +```bash +rtk git add src/analyze/rules.ts tests/analyze.rules.test.ts +rtk git commit -m "feat(analyze): report hardcoded theme tokens" +``` + +### Task 2: Respect configured exclusions and the token-owner workspace + +**Files:** +- Modify: `tests/analyze.rules.test.ts` in the `describe("analyzer rules")` block +- Modify: `src/analyze/rules.ts` in `hardcodedThemeTokenRule.analyze` + +- [ ] **Step 1: Write the failing exemption and exclusion test** + +Add this test to `tests/analyze.rules.test.ts`: + +```ts +it("honors theme-token exclusions and exempts only the exact theme owner", async () => { + const excludedRoot = await fixture( + { + "src/app.ts": 'export const token = "--ak-included";', + "vendor/ignored.ts": 'export const token = "--ak-excluded";', + }, + { + manifest: { + name: "fixture", + askr: { analyze: { exclude: ["vendor/**"] } }, + }, + }, + ); + const ownerRoot = await fixture( + { "src/theme.ts": 'export const token = "--ak-owned";' }, + { manifest: { name: "@askrjs/themes" } }, + ); + const lookalikeRoot = await fixture( + { "src/theme.ts": 'export const token = "--ak-not-owned";' }, + { manifest: { name: "@askrjs/themes-preview" } }, + ); + + const ruleFindings = async (root: string) => + (await diagnostics(root)).filter( + (entry) => entry.ruleId === "askr/no-hardcoded-theme-token", + ); + + await expect(ruleFindings(excludedRoot)).resolves.toEqual([ + expect.objectContaining({ file: "src/app.ts" }), + ]); + await expect(ruleFindings(ownerRoot)).resolves.toEqual([]); + await expect(ruleFindings(lookalikeRoot)).resolves.toEqual([ + expect.objectContaining({ file: "src/theme.ts" }), + ]); +}); +``` + +- [ ] **Step 2: Run the focused test and verify the exact-owner assertion fails** + +Run: + +```bash +rtk npm test -- tests/analyze.rules.test.ts -t "honors theme-token exclusions and exempts only the exact theme owner" +``` + +Expected: FAIL because `@askrjs/themes` still receives one diagnostic. The +configured exclusion assertion should already pass because source discovery +omits `vendor/ignored.ts`. + +- [ ] **Step 3: Add the exact workspace-name exemption** + +At the start of `hardcodedThemeTokenRule.analyze`, add: + +```ts +analyze(context) { + if (context.workspace.name === "@askrjs/themes") return []; + + const diagnostics: AnalyzeDiagnostic[] = []; + // existing literal walk remains unchanged +``` + +Do not use substring, path, dependency, or scope matching; only the exact +manifest-derived workspace name owns the token namespace. + +- [ ] **Step 4: Run the focused test and verify it passes** + +Run: + +```bash +rtk npm test -- tests/analyze.rules.test.ts -t "honors theme-token exclusions and exempts only the exact theme owner" +``` + +Expected: PASS: one included-file diagnostic, no owner diagnostic, and one +lookalike-package diagnostic. + +- [ ] **Step 5: Run all analyzer tests** + +Run: + +```bash +rtk npm test -- tests/analyze.rules.test.ts tests/analyze.cli.test.ts +``` + +Expected: PASS. + +- [ ] **Step 6: Commit the exemption behavior** + +```bash +rtk git add src/analyze/rules.ts tests/analyze.rules.test.ts +rtk git commit -m "test(analyze): cover theme token rule boundaries" +``` + +### Task 3: Document and verify the complete rule + +**Files:** +- Modify: `docs/analyze.md` under `### Correctness` + +- [ ] **Step 1: Add the rule to the public analyzer catalog** + +Insert this bullet after `askr/parse-error` in `docs/analyze.md`: + +```markdown +- `askr/no-hardcoded-theme-token` reports `--ak-*` token names in runtime + JavaScript and TypeScript string, template, and JSX attribute literals. The + exact `@askrjs/themes` workspace is exempt because it owns those declarations. +``` + +- [ ] **Step 2: Run formatting and inspect any mechanical changes** + +Run: + +```bash +rtk npm run fmt +rtk git diff --check +rtk git status --short +``` + +Expected: formatting succeeds, `git diff --check` prints nothing, and only the +intended rule, tests, documentation, and plan/spec files differ from `main`. + +- [ ] **Step 3: Run the repository's complete quality gate** + +Run: + +```bash +rtk npm run check +``` + +Expected: lint, TypeScript type checking, coverage tests, build, publint, and +package dry-run all pass. + +- [ ] **Step 4: Commit documentation or formatter changes** + +```bash +rtk git add docs/analyze.md src/analyze/rules.ts tests/analyze.rules.test.ts +rtk git commit -m "docs(analyze): describe theme token diagnostics" +``` + +If formatting made no source/test changes, the commit contains only +`docs/analyze.md`. + +- [ ] **Step 5: Verify the final branch state and issue coverage** + +Run: + +```bash +rtk git status --short --branch +rtk git log --oneline origin/main..HEAD +rtk git diff --check origin/main...HEAD +rtk gh pr list --repo askrjs/askr-cli --state all --search "48 in:body" --json number,title,state,url +``` + +Expected: a clean branch, the design and implementation commits listed, no +whitespace errors, and no competing pull request for issue #48. + +- [ ] **Step 6: Push and open the pull request** + +```bash +rtk git push -u origin fix/48-hardcoded-theme-token-rule +rtk gh pr create --repo askrjs/askr-cli --base main --head lntutor:fix/48-hardcoded-theme-token-rule --title "feat(analyze): report hardcoded theme tokens" --body $'## Summary\n- add askr/no-hardcoded-theme-token for runtime JS/TS literals\n- respect configured source exclusions and exempt only @askrjs/themes\n- document the rule and cover strings, templates, JSX, comments, exclusions, and workspace identity\n\n## Verification\n- npm run check\n\nCloses #48' +``` + +Expected: GitHub returns a new PR URL created after the contribution-goal +baseline. diff --git a/docs/superpowers/specs/2026-07-30-hardcoded-theme-token-rule-design.md b/docs/superpowers/specs/2026-07-30-hardcoded-theme-token-rule-design.md new file mode 100644 index 0000000..1acb6f8 --- /dev/null +++ b/docs/superpowers/specs/2026-07-30-hardcoded-theme-token-rule-design.md @@ -0,0 +1,99 @@ +# Hardcoded Theme Token Analyzer Rule Design + +## Goal + +Add an `askr analyze` rule that reports direct references to Askr CSS custom +properties (`--ak-*`) in runtime JavaScript and TypeScript. The rule should +guide users toward semantic classes or `data-*` attributes without changing +source code automatically. + +## Rule Contract + +- ID: `askr/no-hardcoded-theme-token` +- Category: `correctness` +- Default severity: `warning` +- Autofix: none +- Message: runtime code should not name Askr theme tokens directly; move the + token mapping to theme CSS and select it through a semantic class or a + `data-*` attribute. + +The rule applies to `.js`, `.jsx`, `.ts`, and `.tsx` files already selected by +the analyzer. Existing configured `exclude` globs continue to control source +discovery and require no rule-specific matching logic. + +## Detection Architecture + +Implement the behavior as a dedicated rule in the existing analyzer rule +registry. The rule walks the TypeScript AST and inspects literal-like runtime +values rather than searching raw source text. This keeps comments out of scope +and gives each diagnostic an exact source location. + +The visitor reports a node when its literal text contains `--ak-`: + +- string literals; +- no-substitution template literals; +- template head, middle, and tail segments in interpolated templates; and +- JSX attribute values written as string literals. + +Each AST node is reported at most once. The rule does not evaluate identifiers, +concatenations, function results, or other value flow. It also does not inspect +comments, CSS files, or arbitrary raw source text. + +## Theme-Owner Exemption + +The analyzer should skip this rule when the analyzed workspace manifest has the +exact package name `@askrjs/themes`. This is the narrow token-owner exemption +requested by the issue. Package paths or names that merely contain `theme` are +not exempt, which avoids suppressing diagnostics in ordinary applications. + +The exemption uses `context.workspace.name`, which is already available to +every analyzer rule from the discovered workspace manifest. No new analyzer +context or project-discovery behavior is required. + +## Data Flow + +1. Existing project discovery selects runtime JS/TS files and applies configured + exclusions. +2. Project analysis reads the workspace manifest identity. +3. If the package name is `@askrjs/themes`, this rule returns no diagnostics. +4. Otherwise, the rule visits relevant literal nodes in each selected source + file. +5. A literal segment containing `--ak-` produces one warning at that segment's + source location. +6. The existing analyzer reporting pipeline formats and emits the diagnostic. + +## Error Handling and Boundaries + +Missing or malformed optional manifest metadata must not crash analysis. When +the package cannot be identified as the exact token owner, the rule runs +normally. Existing parser and project-discovery error behavior remains +unchanged. + +There is deliberately no autofix: selecting the correct semantic class or +state attribute requires application-specific intent. + +## Tests + +Add analyzer CLI integration coverage for: + +- an ordinary string containing an Askr token; +- a no-substitution template literal; +- interpolated templates, including offending static segments around an + expression; +- a JSX string attribute; +- multiple offending literals producing distinct diagnostics; +- comments and identifier-based/non-literal values producing no diagnostics; +- files omitted by configured `exclude` globs; +- an exact `@askrjs/themes` package producing no diagnostics; and +- a similarly named non-owner package still producing diagnostics. + +Run the repository's complete `npm run check` gate before opening the pull +request. + +## Alternatives Rejected + +Raw-source regular-expression scanning is smaller but would report comments and +can overlap AST-derived findings. Type-checker or value-flow analysis could find +dynamically assembled tokens, but it adds complexity and false-positive risk +beyond issue #48's syntactic scope. AST literal inspection is precise and fits +the analyzer's existing rule model. diff --git a/src/analyze/rules.ts b/src/analyze/rules.ts index 6e9c04e..236f560 100644 --- a/src/analyze/rules.ts +++ b/src/analyze/rules.ts @@ -167,6 +167,19 @@ function visit(sourceFile: ts.SourceFile, callback: (node: ts.Node) => void): vo walk(sourceFile); } +function runtimeLiteralText(node: ts.Node): string | null { + if ( + ts.isStringLiteral(node) || + ts.isNoSubstitutionTemplateLiteral(node) || + ts.isTemplateHead(node) || + ts.isTemplateMiddle(node) || + ts.isTemplateTail(node) + ) { + return node.text; + } + return null; +} + function containingFunction(node: ts.Node): ts.SignatureDeclaration | null { for (let current = node.parent; current; current = current.parent) { if (ts.isFunctionLike(current)) return current; @@ -2812,26 +2825,40 @@ const hardcodedThemeTokenRule: AnalyzeRule = { id: "askr/no-hardcoded-theme-token", category: "correctness", severity: "warning", - description: "Runtime UI literals should use semantic theme tokens.", + description: "Runtime UI literals should use semantic theme styling.", analyze(context) { - if (["@askrjs/askr", "@askrjs/themes"].includes(packageName(context.workspace.manifest))) - return []; + const workspacePackage = packageName(context.workspace.manifest); + const ownsThemeTokens = workspacePackage === "@askrjs/themes"; + const mayHardcodeColors = ["@askrjs/askr", "@askrjs/themes"].includes(workspacePackage); const diagnostics: AnalyzeDiagnostic[] = []; const color = /(?:#[0-9a-f]{3,8}\b|\brgba?\s*\(|\bhsla?\s*\()/i; for (const sourceFile of context.sourceFiles) { - if (/(?:^|[./_-])(?:test|spec)\.[cm]?[jt]sx?$/.test(sourceFile.fileName)) continue; - if (!color.test(sourceFile.text)) continue; + const checkColors = + !mayHardcodeColors && + !/(?:^|[./_-])(?:test|spec)\.[cm]?[jt]sx?$/.test(sourceFile.fileName) && + color.test(sourceFile.text); + const checkTokens = !ownsThemeTokens && sourceFile.text.includes("--ak-"); + if (!checkColors && !checkTokens) continue; visit(sourceFile, (node) => { - if ( - (ts.isStringLiteralLike(node) || ts.isNoSubstitutionTemplateLiteral(node)) && - color.test(node.text) - ) { + const text = runtimeLiteralText(node); + if (text === null) return; + if (checkTokens && text.includes("--ak-")) { + diagnostics.push( + diagnostic( + context, + node, + this, + "Runtime code names an Askr theme token directly; use a semantic class or data-* attribute instead.", + "Move the token mapping to theme CSS and select it through a semantic class or data-* attribute.", + ), + ); + } else if (checkColors && color.test(text)) { diagnostics.push( diagnostic( context, node, this, - `Runtime UI literal '${node.text}' hardcodes a color instead of a theme token.`, + `Runtime UI literal '${text}' hardcodes a color instead of a theme token.`, "Use a semantic prop, theme variable, or design-system class.", ), ); diff --git a/templates/ssr/src/pages/example.tsx b/templates/ssr/src/pages/example.tsx index 354678d..a02841c 100644 --- a/templates/ssr/src/pages/example.tsx +++ b/templates/ssr/src/pages/example.tsx @@ -92,7 +92,7 @@ export default function Example() {

Reactive state driving UI updates in real time.

-
+
setBold((b) => !b)}> Bold diff --git a/templates/ssr/src/styles.css b/templates/ssr/src/styles.css index 001555e..d7714a9 100644 --- a/templates/ssr/src/styles.css +++ b/templates/ssr/src/styles.css @@ -127,6 +127,13 @@ code { margin-bottom: var(--ak-space-md); } +.showcase-controls { + display: flex; + align-items: center; + gap: var(--ak-space-md); + margin-bottom: var(--ak-space-md); +} + /* Hero buttons */ .hero-actions { display: flex; diff --git a/templates/startkit/src/components/app-header.tsx b/templates/startkit/src/components/app-header.tsx index 1bca567..c651dd1 100644 --- a/templates/startkit/src/components/app-header.tsx +++ b/templates/startkit/src/components/app-header.tsx @@ -24,9 +24,9 @@ export default function AppHeader() { return (