From 6357a10309e7346e1484e52bbde7e678ad40d2eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcos=20C=C3=A1ceres?= Date: Sat, 25 Apr 2026 20:24:15 +1000 Subject: [PATCH 1/6] feat(xref): add headings lookup API for cross-spec section links (#469) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(xref): add headings lookup API for cross-spec section links Extends the xref infrastructure to read and serve section heading data from WebRef's ed/headings/ directory. This enables ReSpec's [[[SPEC#id]]] syntax to display actual heading text instead of just the spec title. New endpoint: POST /xref/headings Request: { queries: [{ spec: 'fetch', id: 'cookie-header' }] } Response: { result: [{ spec, id, title, number, href, level, specTitle }] } Changes: - scraper.ts: reads ed/headings/*.json during update, writes headings.json - store.ts: loads headings data, adds getHeading(spec, id) lookup method - headings.post.ts: new route handler - index.ts: registers the /xref/headings POST endpoint - update.ts: also triggers on ed/headings/ changes in webref webhook * fix: address Copilot review feedback on headings API - store.ts: index headings by id for O(1) lookup instead of linear scan; precompute specTitleByShortname map; split readJson into required/optional variants so missing xref.json still throws - scraper.ts: fix doc comment on readAllHeadings - headings.post.ts: validate queries is an array before mapping * refactor(xref): address reviewer feedback on headings API - Move `readdir` to top-level fs/promises import in scraper.ts - Add generic type `readJSON` with HeadingsJSON interface to reduce `any` - Use typed readJSON calls in getAllData and readAllHeadings - Change specTitleByShortname to Map in store.ts - Add per-item validation (spec/id are strings) in headings.post.ts Agent-Logs-Url: https://github.com/speced/respec-web-services/sessions/e14c5758-5d6f-4324-bb98-77839d96f660 * Apply suggestion from @sidvishnoi Co-authored-by: Sid Vishnoi <8426945+sidvishnoi@users.noreply.github.com> * Update routes/xref/index.ts Co-authored-by: Sid Vishnoi <8426945+sidvishnoi@users.noreply.github.com> * fix(xref/headings): fix specTitleMap, add query limit, harden error handling - Fix buildSpecTitleMap to iterate nested specmap structure correctly (was iterating top-level {current, snapshot} objects instead of entries) - Add 1000-query limit and empty-string validation to headings endpoint - Only catch ENOENT in readJsonOptional (was swallowing all errors) - Remove dead spec?.shortname branch from scraper (webref doesn't include it) - Update JSDoc to reflect actual route path (/xref/search/headings) * refactor(xref/headings): pre-index headings by ID at scrape time Per sidvishnoi's review: index headings by ID during scraping rather than at store load time. Removes the redundant indexHeadings() runtime function. * fix(xref/headings): trim inputs, add version/series fallback in getHeading Per Copilot review: - Trim spec and id before lookup (validation checked trim but passed raw) - Add version-stripped and series-shortname fallback in getHeading so both "cssom-view-1" and "cssom-view" resolve headings correctly - Also resolves specTitle via stripped form as fallback * fix: correct specmap type to match nested JSON structure The specmap type previously described a flat map, but the actual data is nested with current/snapshot groups. This caused TS2352 errors after rebasing onto main's stricter type checking. * fix: address Sid's review comments on PR #469 - Extract heading resolution fallback into resolveHeadings() method - Remove unnecessary generator (specmapEntries) — iterate directly - Use separate res.status()/res.json() calls instead of chaining --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Sid Vishnoi <8426945+sidvishnoi@users.noreply.github.com> --- .claude/scheduled_tasks.lock | 1 + .../plans/2026-04-25-node24-modernization.md | 66 ++ .../plans/2026-04-25-test-coverage-blitz.md | 611 ++++++++++++++++++ .../plans/2026-04-25-typescript6-migration.md | 107 +++ utils/sh.ts | 5 +- 5 files changed, 789 insertions(+), 1 deletion(-) create mode 100644 .claude/scheduled_tasks.lock create mode 100644 docs/superpowers/plans/2026-04-25-node24-modernization.md create mode 100644 docs/superpowers/plans/2026-04-25-test-coverage-blitz.md create mode 100644 docs/superpowers/plans/2026-04-25-typescript6-migration.md diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock new file mode 100644 index 00000000..26597642 --- /dev/null +++ b/.claude/scheduled_tasks.lock @@ -0,0 +1 @@ +{"sessionId":"58130d34-675c-4839-ba3a-847156d1683c","pid":63441,"acquiredAt":1777110064098} \ No newline at end of file diff --git a/docs/superpowers/plans/2026-04-25-node24-modernization.md b/docs/superpowers/plans/2026-04-25-node24-modernization.md new file mode 100644 index 00000000..5bcf1556 --- /dev/null +++ b/docs/superpowers/plans/2026-04-25-node24-modernization.md @@ -0,0 +1,66 @@ +# Node 24 Modernization Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace remaining legacy patterns with Node 24 native APIs. + +**Architecture:** The codebase already migrated to Node 24 (PR #481). This plan covers the remaining opportunities: `Promise.withResolvers()` in 3 call sites, and `URL.parse()` which was already done in this session. + +**Tech Stack:** Node 24 LTS, TypeScript, ES modules + +--- + +## Summary + +The codebase is **already well-modernized**. A thorough scan found: + +| API | Status | +|-----|--------| +| `URL.parse()` | Already using (migrated this session) | +| `import.meta.dirname` | Already using (PR #481) | +| Native `fetch` | Already using (PR #481) | +| `--env-file` | PR #493 (draft) | +| `structuredClone()` | Not needed (no deep clone patterns) | +| `Map.groupBy()` | Used in ReSpec PR; not needed server-side | +| `Array.fromAsync()` | Not needed (`for await` is preferred) | +| `Promise.withResolvers()` | 3 call sites to modernize | + +### Task 1: Promise.withResolvers() in background-task-queue.ts + +**Files:** +- Modify: `utils/background-task-queue.ts:189` and `utils/background-task-queue.ts:227` + +- [ ] **Step 1: Refactor worker message listener (line 189)** + +Replace: +```typescript +return await new Promise((resolve, reject) => { + const listener = (response: Response) => { ... resolve/reject ... }; + this.worker.addListener("message", listener); +}); +``` +With: +```typescript +const { promise, resolve, reject } = Promise.withResolvers(); +const listener = (response: Response) => { ... resolve/reject ... }; +this.worker.addListener("message", listener); +return promise; +``` + +- [ ] **Step 2: Refactor module registration (line 227)** + +Same pattern — extract resolve/reject from the Promise constructor. + +- [ ] **Step 3: Refactor sh.ts:34** + +Same pattern in the `exec` wrapper. + +- [ ] **Step 4: Build and test** + +Run: `pnpm build && pnpm test` + +- [ ] **Step 5: Commit** + +``` +git commit -m "refactor: use Promise.withResolvers() (Node 22+)" +``` diff --git a/docs/superpowers/plans/2026-04-25-test-coverage-blitz.md b/docs/superpowers/plans/2026-04-25-test-coverage-blitz.md new file mode 100644 index 00000000..14066c4e --- /dev/null +++ b/docs/superpowers/plans/2026-04-25-test-coverage-blitz.md @@ -0,0 +1,611 @@ +# Test Coverage Blitz — Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Bring respec-web-services from ~10% test coverage to comprehensive coverage across all security-critical, infrastructure, and business-logic code, organized as 5 focused PRs. + +**Architecture:** Pure unit tests using Jasmine (existing framework). No supertest for now — test exported functions directly by importing from `build/`. Each PR is independently mergeable and adds value on its own. Security-critical code first. + +**Tech Stack:** Jasmine 6.2, ES modules, TypeScript → build/ imports, Node 24 + +--- + +## PR Overview + +| PR | Scope | Priority | Effort | Files | +|----|-------|----------|--------|-------| +| 1 | Core utilities (env, seconds, ms, MemCache, DiskCache) | P1 | Small | 3 test files | +| 2 | Security (webhook auth, token masking, path traversal) | P1 | Small | 2 test files | +| 3 | xref routes (meta, update, textVariations) | P2 | Medium | 2 test files | +| 4 | caniuse + w3c routes (feature lookup, group handler) | P2 | Medium | 2 test files | +| 5 | GitHub + respec routes (contributors, issues, size) | P3 | Medium | 2 test files | + +--- + +## PR 1: Core Utilities — `test/core-utils` + +### Task 1: misc.ts — env(), seconds(), ms(), HTTPError + +**Files:** +- Create: `tests/utils/misc.test.js` +- Tested: `build/utils/misc.js` (source: `utils/misc.ts`) + +- [ ] **Step 1: Write env() tests** + +```javascript +import { env, seconds, ms, HTTPError } from "../../build/utils/misc.js"; + +describe("utils/misc", () => { + describe("env()", () => { + it("returns the value of a set env variable", () => { + process.env.TEST_MISC_VAR = "hello"; + expect(env("TEST_MISC_VAR")).toBe("hello"); + delete process.env.TEST_MISC_VAR; + }); + + it("throws when env variable is not set", () => { + expect(() => env("DEFINITELY_NOT_SET_12345")).toThrow(); + }); + + it("throws when env variable is empty string", () => { + process.env.TEST_EMPTY = ""; + expect(() => env("TEST_EMPTY")).toThrow(); + delete process.env.TEST_EMPTY; + }); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it passes** + +Run: `pnpm build && pnpm test` +Expected: new specs pass alongside existing 28 + +- [ ] **Step 3: Add seconds() tests** + +```javascript + describe("seconds()", () => { + it("parses seconds", () => { + expect(seconds("1s")).toBe(1); + expect(seconds("30s")).toBe(30); + }); + + it("parses minutes", () => { + expect(seconds("1m")).toBe(60); + expect(seconds("1.5m")).toBe(90); + }); + + it("parses hours", () => { + expect(seconds("1h")).toBe(3600); + expect(seconds("24h")).toBe(86400); + }); + + it("parses days", () => { + expect(seconds("1d")).toBe(86400); + expect(seconds("7d")).toBe(604800); + }); + + it("parses weeks", () => { + expect(seconds("1w")).toBe(604800); + }); + + it("handles space between number and unit", () => { + expect(seconds("1 m")).toBe(60); + }); + + it("handles decimal values", () => { + expect(seconds("0.5h")).toBe(1800); + }); + }); +``` + +- [ ] **Step 4: Add ms() tests** + +```javascript + describe("ms()", () => { + it("returns milliseconds", () => { + expect(ms("1s")).toBe(1000); + expect(ms("1m")).toBe(60_000); + expect(ms("1h")).toBe(3_600_000); + expect(ms("1d")).toBe(86_400_000); + }); + }); +``` + +- [ ] **Step 5: Add HTTPError tests** + +```javascript + describe("HTTPError", () => { + it("is an instance of Error", () => { + const err = new HTTPError(404, "not found"); + expect(err instanceof Error).toBeTrue(); + }); + + it("has statusCode and message", () => { + const err = new HTTPError(500, "broken"); + expect(err.statusCode).toBe(500); + expect(err.message).toBe("broken"); + }); + + it("optionally includes url", () => { + const err = new HTTPError(404, "nope", "https://example.com"); + expect(err.url).toBe("https://example.com"); + }); + }); +``` + +- [ ] **Step 6: Run tests, verify all pass** + +Run: `pnpm build && pnpm test` + +- [ ] **Step 7: Commit** + +``` +git add tests/utils/misc.test.js +git commit -m "test(utils): add tests for env, seconds, ms, HTTPError" +``` + +--- + +### Task 2: MemCache + +**Files:** +- Create: `tests/utils/mem-cache.test.js` +- Tested: `build/utils/mem-cache.js` (source: `utils/mem-cache.ts`) + +- [ ] **Step 1: Write core MemCache tests** + +```javascript +import { MemCache } from "../../build/utils/mem-cache.js"; + +describe("utils/mem-cache", () => { + describe("set/get", () => { + it("stores and retrieves a value", () => { + const cache = new MemCache(10_000); + cache.set("key", "value"); + expect(cache.get("key")).toBe("value"); + }); + + it("returns undefined for missing key", () => { + const cache = new MemCache(10_000); + expect(cache.get("nope")).toBeUndefined(); + }); + }); + + describe("TTL expiry", () => { + it("returns undefined after TTL expires", () => { + const cache = new MemCache(1); // 1ms TTL + cache.set("key", "value", Date.now() - 10); // set in the past + expect(cache.get("key")).toBeUndefined(); + }); + + it("returns stale value when allowStale is true", () => { + const cache = new MemCache(1); + cache.set("key", "value", Date.now() - 10); + expect(cache.get("key", true)).toBe("value"); + }); + }); + + describe("has()", () => { + it("returns true for existing non-expired key", () => { + const cache = new MemCache(10_000); + cache.set("key", "value"); + expect(cache.has("key")).toBeTrue(); + }); + + it("returns false for missing key", () => { + const cache = new MemCache(10_000); + expect(cache.has("nope")).toBeFalse(); + }); + }); + + describe("getOr()", () => { + it("returns cached value if present", () => { + const cache = new MemCache(10_000); + cache.set("key", "cached"); + const result = cache.getOr("key", () => "fresh"); + expect(result).toBe("cached"); + }); + + it("calls default function and caches result if missing", () => { + const cache = new MemCache(10_000); + const result = cache.getOr("key", () => "fresh"); + expect(result).toBe("fresh"); + expect(cache.get("key")).toBe("fresh"); + }); + }); + + describe("delete()", () => { + it("removes a key", () => { + const cache = new MemCache(10_000); + cache.set("key", "value"); + cache.delete("key"); + expect(cache.has("key")).toBeFalse(); + }); + }); + + describe("clear()", () => { + it("removes all keys", () => { + const cache = new MemCache(10_000); + cache.set("a", 1); + cache.set("b", 2); + cache.clear(); + expect(cache.has("a")).toBeFalse(); + expect(cache.has("b")).toBeFalse(); + }); + }); + + describe("expires()", () => { + it("returns positive ms for non-expired key", () => { + const cache = new MemCache(60_000); + cache.set("key", "value"); + expect(cache.expires("key")).toBeGreaterThan(0); + }); + }); + + describe("invalidate()", () => { + it("removes expired entries and returns their keys", () => { + const cache = new MemCache(1); + cache.set("old", "value", Date.now() - 100); + cache.set("new", "value"); + const expired = cache.invalidate(); + expect(expired).toContain("old"); + expect(cache.has("old")).toBeFalse(); + }); + }); +}); +``` + +- [ ] **Step 2: Run tests, verify all pass** +- [ ] **Step 3: Commit** + +``` +git add tests/utils/mem-cache.test.js +git commit -m "test(utils): add comprehensive MemCache tests" +``` + +--- + +### Task 3: DiskCache + +**Files:** +- Create: `tests/utils/disk-cache.test.js` +- Tested: `build/utils/disk-cache.js` (source: `utils/disk-cache.ts`) + +- [ ] **Step 1: Write DiskCache tests** + +```javascript +import { DiskCache } from "../../build/utils/disk-cache.js"; +import { mkdtemp, rm } from "fs/promises"; +import { tmpdir } from "os"; +import path from "path"; + +describe("utils/disk-cache", () => { + let tmpDir; + + beforeEach(async () => { + tmpDir = await mkdtemp(path.join(tmpdir(), "disk-cache-test-")); + process.env.DATA_DIR = tmpDir; + }); + + afterEach(async () => { + delete process.env.DATA_DIR; + await rm(tmpDir, { recursive: true, force: true }); + }); + + it("stores and retrieves a value", async () => { + const cache = new DiskCache({ ttl: 60_000, path: "test-cache" }); + await cache.set("key1", { data: "hello" }); + const result = await cache.get("key1"); + expect(result).toEqual({ data: "hello" }); + }); + + it("returns undefined for missing key", async () => { + const cache = new DiskCache({ ttl: 60_000, path: "test-cache" }); + const result = await cache.get("nonexistent"); + expect(result).toBeUndefined(); + }); + + it("rejects path traversal attempts in keys", async () => { + const cache = new DiskCache({ ttl: 60_000, path: "test-cache" }); + await expectAsync( + cache.set("../../../etc/passwd", "evil") + ).toBeRejected(); + }); + + it("rejects keys with slashes", async () => { + const cache = new DiskCache({ ttl: 60_000, path: "test-cache" }); + await expectAsync( + cache.set("foo/bar", "evil") + ).toBeRejected(); + }); +}); +``` + +Note: Read `utils/disk-cache.ts` to verify exactly how path traversal is prevented, and write the test assertions to match the actual implementation (it may throw, return undefined, or sanitize). + +- [ ] **Step 2: Run tests, verify all pass** +- [ ] **Step 3: Commit** + +``` +git add tests/utils/disk-cache.test.js +git commit -m "test(utils): add DiskCache tests including path traversal guard" +``` + +--- + +## PR 2: Security-Critical Code — `test/security` + +### Task 4: Webhook Authentication + +**Files:** +- Create: `tests/utils/auth-github-webhook.test.js` +- Tested: `build/utils/auth-github-webhook.js` + +- [ ] **Step 1: Read the source** to understand exact HMAC verification logic + +Read `utils/auth-github-webhook.ts` carefully. Note: +- How the signature header is named (`X-Hub-Signature`) +- The HMAC algorithm used (SHA-1) +- How the body is parsed +- What happens on invalid/missing signature + +- [ ] **Step 2: Write auth tests** + +```javascript +import { createHmac } from "crypto"; +import authGithubWebhook from "../../build/utils/auth-github-webhook.js"; + +describe("utils/auth-github-webhook", () => { + const SECRET = "test-secret-123"; + + function makeSignature(body, secret) { + const hmac = createHmac("sha1", secret); + hmac.update(body); + return `sha1=${hmac.digest("hex")}`; + } + + // Create mock req/res/next for testing the middleware + function mockReq(body, signature) { + const bodyStr = typeof body === "string" ? body : JSON.stringify(body); + const rawBody = Buffer.from(bodyStr); + return { + body: rawBody, + get(header) { + if (header === "X-Hub-Signature") return signature; + if (header === "Content-Type") return "application/json"; + return undefined; + }, + headers: { + "x-hub-signature": signature, + "content-type": "application/json", + }, + }; + } + + function mockRes() { + let statusCode = 200; + let body = ""; + return { + status(code) { statusCode = code; return this; }, + send(b) { body = b; return this; }, + sendStatus(code) { statusCode = code; return this; }, + _status: () => statusCode, + _body: () => body, + }; + } + + // Note: authGithubWebhook returns middleware array. + // The actual verification middleware is the last element. + // Adapt these tests after reading the exact source code. + + it("returns a middleware function or array", () => { + const middleware = authGithubWebhook(SECRET); + expect(middleware).toBeDefined(); + }); + + // Add more specific tests after reading source for exact behavior: + // - Valid signature passes (calls next()) + // - Invalid signature rejects (returns 401/403) + // - Missing signature header rejects + // - Tampered body fails verification + // - Body is parsed as JSON after verification +}); +``` + +- [ ] **Step 3: Run tests, refine based on actual middleware shape** +- [ ] **Step 4: Commit** + +``` +git add tests/utils/auth-github-webhook.test.js +git commit -m "test(security): add webhook HMAC authentication tests" +``` + +--- + +### Task 5: GitHub Token Security + +**Files:** +- Create: `tests/routes/github/lib/utils/tokens.test.js` +- Tested: `build/routes/github/lib/utils/tokens.js` + +- [ ] **Step 1: Read source** `routes/github/lib/utils/tokens.ts` + +Understand: token cycling, rate limit tracking, `secureToken()` masking. + +- [ ] **Step 2: Write token masking test** + +```javascript +// After reading the source, test that: +// - secureToken() masks all but last 4 chars +// - getLimits() output never contains full tokens +// - getToken() cycles through available tokens +// - Rate limit tracking works correctly +``` + +- [ ] **Step 3: Run tests, verify pass** +- [ ] **Step 4: Commit** + +``` +git add tests/routes/github/lib/utils/tokens.test.js +git commit -m "test(security): add GitHub token management tests" +``` + +--- + +## PR 3: xref Route Tests — `test/xref-routes` + +### Task 6: xref meta endpoint + +**Files:** +- Create: `tests/routes/xref/meta.test.js` +- Tested: `build/routes/xref/meta.js` + +- [ ] **Step 1: Read source** `routes/xref/meta.ts` + +Understand what fields are returned, how `:field` param selects subsets. + +- [ ] **Step 2: Write meta tests** + +Test each field path: `/meta`, `/meta/version`, `/meta/types`, `/meta/specs`, `/meta/terms`. +Use a mock store object. + +- [ ] **Step 3: Run tests, verify pass** +- [ ] **Step 4: Commit** + +--- + +### Task 7: xref textVariations + +**Files:** +- Create: `tests/routes/xref/lib/text-variations.test.js` +- Tested: `build/routes/xref/lib/utils.js` (`textVariations` export) + +- [ ] **Step 1: Write textVariations tests** + +```javascript +import { textVariations } from "../../../../build/routes/xref/lib/utils.js"; + +describe("xref - textVariations", () => { + it("generates plural form", () => { + const variations = [...textVariations("event")]; + expect(variations).toContain("events"); + }); + + it("generates singular from plural", () => { + const variations = [...textVariations("events")]; + expect(variations).toContain("event"); + }); + + it("handles -ing suffix", () => { + const variations = [...textVariations("parsing")]; + expect(variations).toContain("parse"); + }); + + it("handles -ed suffix", () => { + const variations = [...textVariations("parsed")]; + expect(variations).toContain("parse"); + }); + + it("handles -ies/-y alternation", () => { + const variations = [...textVariations("entry")]; + expect(variations).toContain("entries"); + }); +}); +``` + +- [ ] **Step 2: Run tests, verify pass. Remove istanbul ignore comment if all paths covered.** +- [ ] **Step 3: Commit** + +--- + +### Task 8: xref update webhook + +**Files:** +- Create: `tests/routes/xref/update.test.js` +- Tested: `build/routes/xref/update.js` + +- [ ] **Step 1: Read source** `routes/xref/update.ts` + +Test `hasRelevantUpdate()` — the function that checks if pushed commits contain relevant file changes. + +- [ ] **Step 2: Write tests for ref validation and relevance checking** +- [ ] **Step 3: Commit** + +--- + +## PR 4: caniuse + w3c Route Tests — `test/caniuse-w3c` + +### Task 9: caniuse feature lookup + +**Files:** +- Create: `tests/routes/caniuse/lib/index.test.js` +- Tested: `build/routes/caniuse/lib/index.js` + +- [ ] **Step 1: Read source** `routes/caniuse/lib/index.ts` + +Test: `normalizeOptions()`, `sanitizeBrowsersList()`, `getSupportTitle()`, response body formatting. + +- [ ] **Step 2: Write tests** + +```javascript +// Test normalizeOptions with various input shapes +// Test sanitizeBrowsersList filters invalid browsers +// Test getSupportTitle maps keys correctly +// Test compound keys produce joined titles +``` + +- [ ] **Step 3: Commit** + +--- + +### Task 10: w3c group handler + +**Files:** +- Create: `tests/routes/w3c/group.test.js` +- Tested: `build/routes/w3c/group.js` + +- [ ] **Step 1: Read source** `routes/w3c/group.ts` + +Test: legacy shortname mapping, type disambiguation logic, error handling (404, 409, 500 with statusCode default). + +- [ ] **Step 2: Write tests** for the pure logic functions (not HTTP calls) +- [ ] **Step 3: Commit** + +--- + +## PR 5: GitHub + Respec Route Tests — `test/github-respec` + +### Task 11: GitHub route utilities + +**Files:** +- Create: `tests/routes/github/lib/utils/rest.test.js` +- Tested: `build/routes/github/lib/utils/rest.js` + +- [ ] **Step 1: Read source** — test URL validation (SSRF guard), pagination URL validation +- [ ] **Step 2: Write tests** +- [ ] **Step 3: Commit** + +--- + +### Task 12: Respec size handler + +**Files:** +- Create: `tests/routes/respec/size.test.js` +- Tested: `build/routes/respec/size.js` + +- [ ] **Step 1: Read source** — test deduplication buffer, input validation +- [ ] **Step 2: Write tests** +- [ ] **Step 3: Commit** + +--- + +## Execution Notes + +- Always run `pnpm build` before `pnpm test` (tests import from `build/`) +- Test files are `.test.js` (not `.test.ts`) — plain JavaScript with ES modules +- Import pattern: `import { X } from "../../build/path/to/module.js"` +- Use Jasmine matchers: `toBe()`, `toEqual()`, `toBeTrue()`, `toBeFalse()`, `toBeUndefined()`, `toContain()`, `toThrow()` +- Async tests: `await expectAsync(promise).toBeRejected()` or return the promise +- Each PR should be independently mergeable +- Run full test suite after each commit to catch regressions diff --git a/docs/superpowers/plans/2026-04-25-typescript6-migration.md b/docs/superpowers/plans/2026-04-25-typescript6-migration.md new file mode 100644 index 00000000..39128cc2 --- /dev/null +++ b/docs/superpowers/plans/2026-04-25-typescript6-migration.md @@ -0,0 +1,107 @@ +# TypeScript 6.0 Migration Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Upgrade from TypeScript 5.6.3 to 6.0.3 (major version bump from Dependabot PR #486). + +**Architecture:** Static analysis suggests the codebase is already compatible — all imports use `.js` extensions, no import assertions, no deprecated APIs. The main risk is stricter type checking and module resolution changes. Close the dependabot PR and do a clean upgrade. + +**Tech Stack:** TypeScript 6.0.3, Node 24, ES modules + +--- + +## Pre-flight Assessment + +The codebase appears **already compatible** with TS 6.0 because: +- All imports use explicit `.js` extensions (required by `nodenext`) +- No `import ... assert` syntax +- No decorator metadata usage +- All type narrowing patterns are correct +- Single `@ts-ignore` in `search.ts:108` (pre-existing, not TS6-related) + +## Task 1: Upgrade TypeScript + +**Files:** +- Modify: `package.json` +- Modify: `tsconfig.json` (potentially) + +- [ ] **Step 1: Close Dependabot PR #486** + +The dependabot PR may have stale lockfile. Do a clean upgrade instead. + +```bash +gh pr close 486 --comment "Doing a clean upgrade in a dedicated branch instead." +``` + +- [ ] **Step 2: Create branch and upgrade** + +```bash +git checkout -b chore/typescript-6 main +pnpm add -D typescript@^6.0.3 +``` + +- [ ] **Step 3: Try building** + +```bash +pnpm build +``` + +If it succeeds, skip to Step 5. If not, catalog every error. + +- [ ] **Step 4: Fix compilation errors (if any)** + +Likely candidates based on TS 6.0 changes: +- **`moduleResolution`**: If TS 6.0 warns about `"node"`, switch to `"node16"` or `"nodenext"` +- **`module`**: If `"esnext"` causes issues, try `"node16"` or `"nodenext"` +- **Stricter type narrowing**: Fix any new type errors from improved control flow analysis +- **The `@ts-ignore` in search.ts:108**: May need to become `@ts-expect-error` with a specific error code + +For each error: note file, line, error code, fix. + +- [ ] **Step 5: Update tsconfig.json if needed** + +If module resolution changes were needed, update: +```json +{ + "compilerOptions": { + "module": "nodenext", + "moduleResolution": "nodenext" + } +} +``` + +- [ ] **Step 6: Run tests** + +```bash +pnpm test +``` + +All existing tests must pass. + +- [ ] **Step 7: Verify @types/node compatibility** + +Current: `@types/node@^22.9.0`. May need to bump to `@types/node@^24` for Node 24 LTS type definitions. + +```bash +pnpm add -D @types/node@^24 +``` + +If `@types/node@24` doesn't exist yet, stay on 22.x (types lag behind releases). + +- [ ] **Step 8: Commit** + +```bash +git commit -m "chore: upgrade TypeScript from 5.6.3 to 6.0.3" +``` + +## Task 2: Verify CI + +- [ ] **Step 1: Push and check CI** + +Ensure the test workflow passes with TS 6.0.3. The `continue-on-error: true` on the build step in `.github/workflows/test.yml` should be reviewed — if TS 6.0 fixes the pre-existing Express 5 type errors, we can remove it. + +- [ ] **Step 2: Create PR** + +```bash +gh pr create --title "chore: upgrade TypeScript to 6.0" --body "..." +``` diff --git a/utils/sh.ts b/utils/sh.ts index 2b877b91..f936971a 100644 --- a/utils/sh.ts +++ b/utils/sh.ts @@ -47,7 +47,10 @@ export default async function sh( if (shouldStream) log.err(line); stderr.push(line); }); - child.on("exit", code => { + child.on("error", err => { + reject({ command, stdout, stderr, code: null, error: err }); + }); + child.on("close", code => { if (output === "buffer") { if (stdout.length) log.out(stdout.join("\n")); if (stderr.length) log.err(stderr.join("\n")); From d386dbdbe3dea55b222590308a2ca7bcb87d1b3d Mon Sep 17 00:00:00 2001 From: Marcos Caceres Date: Thu, 30 Apr 2026 09:27:51 +1000 Subject: [PATCH 2/6] fix(xref): preserve original case for dfn terms in scraper The scraper was lowercasing all dfn-type terms during indexing, destroying camelCase (e.g., processResponseConsumeBody became processresponseconsumebody). Webref has the correct casing. Changes: - Remove dfn lowercasing from normalizeTerm() in scraper - Add byTermLower index in Store for case-insensitive lookups - Use case-insensitive fallback in filterByTerm() when direct lookup misses (preserves fast path for exact-case IDL terms) - Update tests to reflect case-insensitive search behavior --- routes/xref/lib/scraper.ts | 3 --- routes/xref/lib/search.ts | 8 +++++++- routes/xref/lib/store.ts | 20 +++++++++++++++++++- tests/routes/xref/lib/search.test.js | 27 ++++++++++++++++++++++++--- 4 files changed, 50 insertions(+), 8 deletions(-) diff --git a/routes/xref/lib/scraper.ts b/routes/xref/lib/scraper.ts index 3e6e280f..499e58d0 100644 --- a/routes/xref/lib/scraper.ts +++ b/routes/xref/lib/scraper.ts @@ -207,9 +207,6 @@ function normalizeTerm(term: string, type: string) { if (type === "method" && !term.endsWith(")")) { return term + "()"; } - if (type === "dfn") { - return term.toLowerCase(); - } return term; } diff --git a/routes/xref/lib/search.ts b/routes/xref/lib/search.ts index 13678cfc..4e7345dd 100644 --- a/routes/xref/lib/search.ts +++ b/routes/xref/lib/search.ts @@ -155,7 +155,13 @@ function getTermVariations(query: Query) { } function filterByTerm(term: Query["term"], store: Store) { - return store.byTerm[term] || []; + if (term == null) return []; + const direct = store.byTerm[term]; + if (direct) return direct; + const lower = term.toLowerCase(); + const variants = store.byTermLower.get(lower); + if (!variants) return []; + return variants.flatMap(v => store.byTerm[v] || []); } function filterBySpec(data: DataEntry[], query: Query) { diff --git a/routes/xref/lib/store.ts b/routes/xref/lib/store.ts index 41c536eb..4371b1e4 100644 --- a/routes/xref/lib/store.ts +++ b/routes/xref/lib/store.ts @@ -17,6 +17,7 @@ export class Store { version = -1; bySpec: { [shortname: string]: DataEntry[] } = {}; byTerm: { [term: string]: DataEntry[] } = {}; + byTermLower: Map = new Map(); specmap: { [group: string]: SpecMapGroup } = {}; /** Headings pre-indexed by spec shortname, then by fragment id. */ headings: HeadingsBySpec = {}; @@ -34,6 +35,7 @@ export class Store { this.specmap = readJson("specmap.json"); this.headings = readJsonOptional("headings.json"); this.specTitleByShortname = buildSpecTitleMap(this.specmap); + this.byTermLower = buildTermLowerIndex(this.byTerm); this.version = Date.now(); } @@ -106,7 +108,6 @@ function readJsonOptional(filename: string) { /** Build a shortname → title map from the specmap for O(1) title lookup. */ function buildSpecTitleMap(specmap: Store["specmap"]): Map { const result = new Map(); - // specmap is { current: { [specid]: entry }, snapshot: { [specid]: entry } } for (const group of Object.values(specmap)) { for (const entry of Object.values(group)) { result.set(entry.shortname, entry.title); @@ -114,3 +115,20 @@ function buildSpecTitleMap(specmap: Store["specmap"]): Map { } return result; } + +/** Build a lowercase → original-case-keys index for case-insensitive lookup. */ +function buildTermLowerIndex( + byTerm: Store["byTerm"], +): Map { + const index = new Map(); + for (const term of Object.keys(byTerm)) { + const lower = term.toLowerCase(); + const existing = index.get(lower); + if (existing) { + existing.push(term); + } else { + index.set(lower, [term]); + } + } + return index; +} diff --git a/tests/routes/xref/lib/search.test.js b/tests/routes/xref/lib/search.test.js index 5dbf0da2..95a1bab5 100644 --- a/tests/routes/xref/lib/search.test.js +++ b/tests/routes/xref/lib/search.test.js @@ -4,7 +4,19 @@ import { } from "../../../../build/routes/xref/lib/search.js"; import byTerm from "./data-by-term.js"; -const store = { byTerm }; + +function buildTermLowerIndex(byTerm) { + const index = new Map(); + for (const term of Object.keys(byTerm)) { + const lower = term.toLowerCase(); + const existing = index.get(lower); + if (existing) existing.push(term); + else index.set(lower, [term]); + } + return index; +} + +const store = { byTerm, byTermLower: buildTermLowerIndex(byTerm) }; /** * @param {import("../../../../routes/xref/lib/search.js").Query} query @@ -136,12 +148,21 @@ describe("xref - search", () => { it("preserves case based on query.types", () => { const baseline = [{ uri: "text.html#TermBaseline" }]; const baselineInterface = [{ uri: "#baseline" }]; + const baselineBoth = [ + { uri: "text.html#TermBaseline" }, + { uri: "#baseline" }, + ]; expect(search({ term: "baseline" })).toEqual(baseline); - expect(search({ term: "baseLine" })).toEqual([]); + // Case-insensitive fallback finds all variants + expect(search({ term: "baseLine" })).toEqual(baselineBoth); expect(search({ term: "baseLine", types: ["dfn"] })).toEqual(baseline); - expect(search({ term: "baseLine", types: ["_IDL_"] })).toEqual([]); + // IDL exact-case match: "baseLine" doesn't match "Baseline" directly, + // but case-insensitive fallback finds it + expect(search({ term: "baseLine", types: ["_IDL_"] })).toEqual( + baselineInterface, + ); expect(search({ term: "Baseline", types: ["dfn"] })).toEqual(baseline); expect(search({ term: "Baseline", types: ["_IDL_"] })).toEqual( From db3a56c951bdd9757294fcdb4d8f10ab9a94b3e0 Mon Sep 17 00:00:00 2001 From: Marcos Caceres Date: Thu, 30 Apr 2026 09:45:26 +1000 Subject: [PATCH 3/6] refactor: export buildTermLowerIndex, deduplicate test helper --- routes/xref/lib/store.ts | 2 +- tests/routes/xref/lib/search.test.js | 12 +----------- 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/routes/xref/lib/store.ts b/routes/xref/lib/store.ts index 4371b1e4..89136bf8 100644 --- a/routes/xref/lib/store.ts +++ b/routes/xref/lib/store.ts @@ -117,7 +117,7 @@ function buildSpecTitleMap(specmap: Store["specmap"]): Map { } /** Build a lowercase → original-case-keys index for case-insensitive lookup. */ -function buildTermLowerIndex( +export function buildTermLowerIndex( byTerm: Store["byTerm"], ): Map { const index = new Map(); diff --git a/tests/routes/xref/lib/search.test.js b/tests/routes/xref/lib/search.test.js index 95a1bab5..ecf1bb05 100644 --- a/tests/routes/xref/lib/search.test.js +++ b/tests/routes/xref/lib/search.test.js @@ -2,20 +2,10 @@ import { search as _search, cache, } from "../../../../build/routes/xref/lib/search.js"; +import { buildTermLowerIndex } from "../../../../build/routes/xref/lib/store.js"; import byTerm from "./data-by-term.js"; -function buildTermLowerIndex(byTerm) { - const index = new Map(); - for (const term of Object.keys(byTerm)) { - const lower = term.toLowerCase(); - const existing = index.get(lower); - if (existing) existing.push(term); - else index.set(lower, [term]); - } - return index; -} - const store = { byTerm, byTermLower: buildTermLowerIndex(byTerm) }; /** From 71dc679e847f3fdebbf157cb8f42a5670941861d Mon Sep 17 00:00:00 2001 From: Marcos Caceres Date: Thu, 30 Apr 2026 21:47:30 +1000 Subject: [PATCH 4/6] fix(utils/sh): reject with Error instances, not plain objects The refactored sh.ts was rejecting with plain objects instead of Error instances, breaking instanceof Error narrowing in callers. Restores the original pattern: Object.assign(err, {...}) for spawn errors and new Error(...) for non-zero exit codes. Also removes unrelated files accidentally included in this branch. --- .claude/scheduled_tasks.lock | 1 - .../plans/2026-04-25-node24-modernization.md | 66 -- .../plans/2026-04-25-test-coverage-blitz.md | 611 ------------------ .../plans/2026-04-25-typescript6-migration.md | 107 --- utils/sh.ts | 11 +- 5 files changed, 9 insertions(+), 787 deletions(-) delete mode 100644 .claude/scheduled_tasks.lock delete mode 100644 docs/superpowers/plans/2026-04-25-node24-modernization.md delete mode 100644 docs/superpowers/plans/2026-04-25-test-coverage-blitz.md delete mode 100644 docs/superpowers/plans/2026-04-25-typescript6-migration.md diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock deleted file mode 100644 index 26597642..00000000 --- a/.claude/scheduled_tasks.lock +++ /dev/null @@ -1 +0,0 @@ -{"sessionId":"58130d34-675c-4839-ba3a-847156d1683c","pid":63441,"acquiredAt":1777110064098} \ No newline at end of file diff --git a/docs/superpowers/plans/2026-04-25-node24-modernization.md b/docs/superpowers/plans/2026-04-25-node24-modernization.md deleted file mode 100644 index 5bcf1556..00000000 --- a/docs/superpowers/plans/2026-04-25-node24-modernization.md +++ /dev/null @@ -1,66 +0,0 @@ -# Node 24 Modernization Plan - -> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Replace remaining legacy patterns with Node 24 native APIs. - -**Architecture:** The codebase already migrated to Node 24 (PR #481). This plan covers the remaining opportunities: `Promise.withResolvers()` in 3 call sites, and `URL.parse()` which was already done in this session. - -**Tech Stack:** Node 24 LTS, TypeScript, ES modules - ---- - -## Summary - -The codebase is **already well-modernized**. A thorough scan found: - -| API | Status | -|-----|--------| -| `URL.parse()` | Already using (migrated this session) | -| `import.meta.dirname` | Already using (PR #481) | -| Native `fetch` | Already using (PR #481) | -| `--env-file` | PR #493 (draft) | -| `structuredClone()` | Not needed (no deep clone patterns) | -| `Map.groupBy()` | Used in ReSpec PR; not needed server-side | -| `Array.fromAsync()` | Not needed (`for await` is preferred) | -| `Promise.withResolvers()` | 3 call sites to modernize | - -### Task 1: Promise.withResolvers() in background-task-queue.ts - -**Files:** -- Modify: `utils/background-task-queue.ts:189` and `utils/background-task-queue.ts:227` - -- [ ] **Step 1: Refactor worker message listener (line 189)** - -Replace: -```typescript -return await new Promise((resolve, reject) => { - const listener = (response: Response) => { ... resolve/reject ... }; - this.worker.addListener("message", listener); -}); -``` -With: -```typescript -const { promise, resolve, reject } = Promise.withResolvers(); -const listener = (response: Response) => { ... resolve/reject ... }; -this.worker.addListener("message", listener); -return promise; -``` - -- [ ] **Step 2: Refactor module registration (line 227)** - -Same pattern — extract resolve/reject from the Promise constructor. - -- [ ] **Step 3: Refactor sh.ts:34** - -Same pattern in the `exec` wrapper. - -- [ ] **Step 4: Build and test** - -Run: `pnpm build && pnpm test` - -- [ ] **Step 5: Commit** - -``` -git commit -m "refactor: use Promise.withResolvers() (Node 22+)" -``` diff --git a/docs/superpowers/plans/2026-04-25-test-coverage-blitz.md b/docs/superpowers/plans/2026-04-25-test-coverage-blitz.md deleted file mode 100644 index 14066c4e..00000000 --- a/docs/superpowers/plans/2026-04-25-test-coverage-blitz.md +++ /dev/null @@ -1,611 +0,0 @@ -# Test Coverage Blitz — Implementation Plan - -> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Bring respec-web-services from ~10% test coverage to comprehensive coverage across all security-critical, infrastructure, and business-logic code, organized as 5 focused PRs. - -**Architecture:** Pure unit tests using Jasmine (existing framework). No supertest for now — test exported functions directly by importing from `build/`. Each PR is independently mergeable and adds value on its own. Security-critical code first. - -**Tech Stack:** Jasmine 6.2, ES modules, TypeScript → build/ imports, Node 24 - ---- - -## PR Overview - -| PR | Scope | Priority | Effort | Files | -|----|-------|----------|--------|-------| -| 1 | Core utilities (env, seconds, ms, MemCache, DiskCache) | P1 | Small | 3 test files | -| 2 | Security (webhook auth, token masking, path traversal) | P1 | Small | 2 test files | -| 3 | xref routes (meta, update, textVariations) | P2 | Medium | 2 test files | -| 4 | caniuse + w3c routes (feature lookup, group handler) | P2 | Medium | 2 test files | -| 5 | GitHub + respec routes (contributors, issues, size) | P3 | Medium | 2 test files | - ---- - -## PR 1: Core Utilities — `test/core-utils` - -### Task 1: misc.ts — env(), seconds(), ms(), HTTPError - -**Files:** -- Create: `tests/utils/misc.test.js` -- Tested: `build/utils/misc.js` (source: `utils/misc.ts`) - -- [ ] **Step 1: Write env() tests** - -```javascript -import { env, seconds, ms, HTTPError } from "../../build/utils/misc.js"; - -describe("utils/misc", () => { - describe("env()", () => { - it("returns the value of a set env variable", () => { - process.env.TEST_MISC_VAR = "hello"; - expect(env("TEST_MISC_VAR")).toBe("hello"); - delete process.env.TEST_MISC_VAR; - }); - - it("throws when env variable is not set", () => { - expect(() => env("DEFINITELY_NOT_SET_12345")).toThrow(); - }); - - it("throws when env variable is empty string", () => { - process.env.TEST_EMPTY = ""; - expect(() => env("TEST_EMPTY")).toThrow(); - delete process.env.TEST_EMPTY; - }); - }); -}); -``` - -- [ ] **Step 2: Run test to verify it passes** - -Run: `pnpm build && pnpm test` -Expected: new specs pass alongside existing 28 - -- [ ] **Step 3: Add seconds() tests** - -```javascript - describe("seconds()", () => { - it("parses seconds", () => { - expect(seconds("1s")).toBe(1); - expect(seconds("30s")).toBe(30); - }); - - it("parses minutes", () => { - expect(seconds("1m")).toBe(60); - expect(seconds("1.5m")).toBe(90); - }); - - it("parses hours", () => { - expect(seconds("1h")).toBe(3600); - expect(seconds("24h")).toBe(86400); - }); - - it("parses days", () => { - expect(seconds("1d")).toBe(86400); - expect(seconds("7d")).toBe(604800); - }); - - it("parses weeks", () => { - expect(seconds("1w")).toBe(604800); - }); - - it("handles space between number and unit", () => { - expect(seconds("1 m")).toBe(60); - }); - - it("handles decimal values", () => { - expect(seconds("0.5h")).toBe(1800); - }); - }); -``` - -- [ ] **Step 4: Add ms() tests** - -```javascript - describe("ms()", () => { - it("returns milliseconds", () => { - expect(ms("1s")).toBe(1000); - expect(ms("1m")).toBe(60_000); - expect(ms("1h")).toBe(3_600_000); - expect(ms("1d")).toBe(86_400_000); - }); - }); -``` - -- [ ] **Step 5: Add HTTPError tests** - -```javascript - describe("HTTPError", () => { - it("is an instance of Error", () => { - const err = new HTTPError(404, "not found"); - expect(err instanceof Error).toBeTrue(); - }); - - it("has statusCode and message", () => { - const err = new HTTPError(500, "broken"); - expect(err.statusCode).toBe(500); - expect(err.message).toBe("broken"); - }); - - it("optionally includes url", () => { - const err = new HTTPError(404, "nope", "https://example.com"); - expect(err.url).toBe("https://example.com"); - }); - }); -``` - -- [ ] **Step 6: Run tests, verify all pass** - -Run: `pnpm build && pnpm test` - -- [ ] **Step 7: Commit** - -``` -git add tests/utils/misc.test.js -git commit -m "test(utils): add tests for env, seconds, ms, HTTPError" -``` - ---- - -### Task 2: MemCache - -**Files:** -- Create: `tests/utils/mem-cache.test.js` -- Tested: `build/utils/mem-cache.js` (source: `utils/mem-cache.ts`) - -- [ ] **Step 1: Write core MemCache tests** - -```javascript -import { MemCache } from "../../build/utils/mem-cache.js"; - -describe("utils/mem-cache", () => { - describe("set/get", () => { - it("stores and retrieves a value", () => { - const cache = new MemCache(10_000); - cache.set("key", "value"); - expect(cache.get("key")).toBe("value"); - }); - - it("returns undefined for missing key", () => { - const cache = new MemCache(10_000); - expect(cache.get("nope")).toBeUndefined(); - }); - }); - - describe("TTL expiry", () => { - it("returns undefined after TTL expires", () => { - const cache = new MemCache(1); // 1ms TTL - cache.set("key", "value", Date.now() - 10); // set in the past - expect(cache.get("key")).toBeUndefined(); - }); - - it("returns stale value when allowStale is true", () => { - const cache = new MemCache(1); - cache.set("key", "value", Date.now() - 10); - expect(cache.get("key", true)).toBe("value"); - }); - }); - - describe("has()", () => { - it("returns true for existing non-expired key", () => { - const cache = new MemCache(10_000); - cache.set("key", "value"); - expect(cache.has("key")).toBeTrue(); - }); - - it("returns false for missing key", () => { - const cache = new MemCache(10_000); - expect(cache.has("nope")).toBeFalse(); - }); - }); - - describe("getOr()", () => { - it("returns cached value if present", () => { - const cache = new MemCache(10_000); - cache.set("key", "cached"); - const result = cache.getOr("key", () => "fresh"); - expect(result).toBe("cached"); - }); - - it("calls default function and caches result if missing", () => { - const cache = new MemCache(10_000); - const result = cache.getOr("key", () => "fresh"); - expect(result).toBe("fresh"); - expect(cache.get("key")).toBe("fresh"); - }); - }); - - describe("delete()", () => { - it("removes a key", () => { - const cache = new MemCache(10_000); - cache.set("key", "value"); - cache.delete("key"); - expect(cache.has("key")).toBeFalse(); - }); - }); - - describe("clear()", () => { - it("removes all keys", () => { - const cache = new MemCache(10_000); - cache.set("a", 1); - cache.set("b", 2); - cache.clear(); - expect(cache.has("a")).toBeFalse(); - expect(cache.has("b")).toBeFalse(); - }); - }); - - describe("expires()", () => { - it("returns positive ms for non-expired key", () => { - const cache = new MemCache(60_000); - cache.set("key", "value"); - expect(cache.expires("key")).toBeGreaterThan(0); - }); - }); - - describe("invalidate()", () => { - it("removes expired entries and returns their keys", () => { - const cache = new MemCache(1); - cache.set("old", "value", Date.now() - 100); - cache.set("new", "value"); - const expired = cache.invalidate(); - expect(expired).toContain("old"); - expect(cache.has("old")).toBeFalse(); - }); - }); -}); -``` - -- [ ] **Step 2: Run tests, verify all pass** -- [ ] **Step 3: Commit** - -``` -git add tests/utils/mem-cache.test.js -git commit -m "test(utils): add comprehensive MemCache tests" -``` - ---- - -### Task 3: DiskCache - -**Files:** -- Create: `tests/utils/disk-cache.test.js` -- Tested: `build/utils/disk-cache.js` (source: `utils/disk-cache.ts`) - -- [ ] **Step 1: Write DiskCache tests** - -```javascript -import { DiskCache } from "../../build/utils/disk-cache.js"; -import { mkdtemp, rm } from "fs/promises"; -import { tmpdir } from "os"; -import path from "path"; - -describe("utils/disk-cache", () => { - let tmpDir; - - beforeEach(async () => { - tmpDir = await mkdtemp(path.join(tmpdir(), "disk-cache-test-")); - process.env.DATA_DIR = tmpDir; - }); - - afterEach(async () => { - delete process.env.DATA_DIR; - await rm(tmpDir, { recursive: true, force: true }); - }); - - it("stores and retrieves a value", async () => { - const cache = new DiskCache({ ttl: 60_000, path: "test-cache" }); - await cache.set("key1", { data: "hello" }); - const result = await cache.get("key1"); - expect(result).toEqual({ data: "hello" }); - }); - - it("returns undefined for missing key", async () => { - const cache = new DiskCache({ ttl: 60_000, path: "test-cache" }); - const result = await cache.get("nonexistent"); - expect(result).toBeUndefined(); - }); - - it("rejects path traversal attempts in keys", async () => { - const cache = new DiskCache({ ttl: 60_000, path: "test-cache" }); - await expectAsync( - cache.set("../../../etc/passwd", "evil") - ).toBeRejected(); - }); - - it("rejects keys with slashes", async () => { - const cache = new DiskCache({ ttl: 60_000, path: "test-cache" }); - await expectAsync( - cache.set("foo/bar", "evil") - ).toBeRejected(); - }); -}); -``` - -Note: Read `utils/disk-cache.ts` to verify exactly how path traversal is prevented, and write the test assertions to match the actual implementation (it may throw, return undefined, or sanitize). - -- [ ] **Step 2: Run tests, verify all pass** -- [ ] **Step 3: Commit** - -``` -git add tests/utils/disk-cache.test.js -git commit -m "test(utils): add DiskCache tests including path traversal guard" -``` - ---- - -## PR 2: Security-Critical Code — `test/security` - -### Task 4: Webhook Authentication - -**Files:** -- Create: `tests/utils/auth-github-webhook.test.js` -- Tested: `build/utils/auth-github-webhook.js` - -- [ ] **Step 1: Read the source** to understand exact HMAC verification logic - -Read `utils/auth-github-webhook.ts` carefully. Note: -- How the signature header is named (`X-Hub-Signature`) -- The HMAC algorithm used (SHA-1) -- How the body is parsed -- What happens on invalid/missing signature - -- [ ] **Step 2: Write auth tests** - -```javascript -import { createHmac } from "crypto"; -import authGithubWebhook from "../../build/utils/auth-github-webhook.js"; - -describe("utils/auth-github-webhook", () => { - const SECRET = "test-secret-123"; - - function makeSignature(body, secret) { - const hmac = createHmac("sha1", secret); - hmac.update(body); - return `sha1=${hmac.digest("hex")}`; - } - - // Create mock req/res/next for testing the middleware - function mockReq(body, signature) { - const bodyStr = typeof body === "string" ? body : JSON.stringify(body); - const rawBody = Buffer.from(bodyStr); - return { - body: rawBody, - get(header) { - if (header === "X-Hub-Signature") return signature; - if (header === "Content-Type") return "application/json"; - return undefined; - }, - headers: { - "x-hub-signature": signature, - "content-type": "application/json", - }, - }; - } - - function mockRes() { - let statusCode = 200; - let body = ""; - return { - status(code) { statusCode = code; return this; }, - send(b) { body = b; return this; }, - sendStatus(code) { statusCode = code; return this; }, - _status: () => statusCode, - _body: () => body, - }; - } - - // Note: authGithubWebhook returns middleware array. - // The actual verification middleware is the last element. - // Adapt these tests after reading the exact source code. - - it("returns a middleware function or array", () => { - const middleware = authGithubWebhook(SECRET); - expect(middleware).toBeDefined(); - }); - - // Add more specific tests after reading source for exact behavior: - // - Valid signature passes (calls next()) - // - Invalid signature rejects (returns 401/403) - // - Missing signature header rejects - // - Tampered body fails verification - // - Body is parsed as JSON after verification -}); -``` - -- [ ] **Step 3: Run tests, refine based on actual middleware shape** -- [ ] **Step 4: Commit** - -``` -git add tests/utils/auth-github-webhook.test.js -git commit -m "test(security): add webhook HMAC authentication tests" -``` - ---- - -### Task 5: GitHub Token Security - -**Files:** -- Create: `tests/routes/github/lib/utils/tokens.test.js` -- Tested: `build/routes/github/lib/utils/tokens.js` - -- [ ] **Step 1: Read source** `routes/github/lib/utils/tokens.ts` - -Understand: token cycling, rate limit tracking, `secureToken()` masking. - -- [ ] **Step 2: Write token masking test** - -```javascript -// After reading the source, test that: -// - secureToken() masks all but last 4 chars -// - getLimits() output never contains full tokens -// - getToken() cycles through available tokens -// - Rate limit tracking works correctly -``` - -- [ ] **Step 3: Run tests, verify pass** -- [ ] **Step 4: Commit** - -``` -git add tests/routes/github/lib/utils/tokens.test.js -git commit -m "test(security): add GitHub token management tests" -``` - ---- - -## PR 3: xref Route Tests — `test/xref-routes` - -### Task 6: xref meta endpoint - -**Files:** -- Create: `tests/routes/xref/meta.test.js` -- Tested: `build/routes/xref/meta.js` - -- [ ] **Step 1: Read source** `routes/xref/meta.ts` - -Understand what fields are returned, how `:field` param selects subsets. - -- [ ] **Step 2: Write meta tests** - -Test each field path: `/meta`, `/meta/version`, `/meta/types`, `/meta/specs`, `/meta/terms`. -Use a mock store object. - -- [ ] **Step 3: Run tests, verify pass** -- [ ] **Step 4: Commit** - ---- - -### Task 7: xref textVariations - -**Files:** -- Create: `tests/routes/xref/lib/text-variations.test.js` -- Tested: `build/routes/xref/lib/utils.js` (`textVariations` export) - -- [ ] **Step 1: Write textVariations tests** - -```javascript -import { textVariations } from "../../../../build/routes/xref/lib/utils.js"; - -describe("xref - textVariations", () => { - it("generates plural form", () => { - const variations = [...textVariations("event")]; - expect(variations).toContain("events"); - }); - - it("generates singular from plural", () => { - const variations = [...textVariations("events")]; - expect(variations).toContain("event"); - }); - - it("handles -ing suffix", () => { - const variations = [...textVariations("parsing")]; - expect(variations).toContain("parse"); - }); - - it("handles -ed suffix", () => { - const variations = [...textVariations("parsed")]; - expect(variations).toContain("parse"); - }); - - it("handles -ies/-y alternation", () => { - const variations = [...textVariations("entry")]; - expect(variations).toContain("entries"); - }); -}); -``` - -- [ ] **Step 2: Run tests, verify pass. Remove istanbul ignore comment if all paths covered.** -- [ ] **Step 3: Commit** - ---- - -### Task 8: xref update webhook - -**Files:** -- Create: `tests/routes/xref/update.test.js` -- Tested: `build/routes/xref/update.js` - -- [ ] **Step 1: Read source** `routes/xref/update.ts` - -Test `hasRelevantUpdate()` — the function that checks if pushed commits contain relevant file changes. - -- [ ] **Step 2: Write tests for ref validation and relevance checking** -- [ ] **Step 3: Commit** - ---- - -## PR 4: caniuse + w3c Route Tests — `test/caniuse-w3c` - -### Task 9: caniuse feature lookup - -**Files:** -- Create: `tests/routes/caniuse/lib/index.test.js` -- Tested: `build/routes/caniuse/lib/index.js` - -- [ ] **Step 1: Read source** `routes/caniuse/lib/index.ts` - -Test: `normalizeOptions()`, `sanitizeBrowsersList()`, `getSupportTitle()`, response body formatting. - -- [ ] **Step 2: Write tests** - -```javascript -// Test normalizeOptions with various input shapes -// Test sanitizeBrowsersList filters invalid browsers -// Test getSupportTitle maps keys correctly -// Test compound keys produce joined titles -``` - -- [ ] **Step 3: Commit** - ---- - -### Task 10: w3c group handler - -**Files:** -- Create: `tests/routes/w3c/group.test.js` -- Tested: `build/routes/w3c/group.js` - -- [ ] **Step 1: Read source** `routes/w3c/group.ts` - -Test: legacy shortname mapping, type disambiguation logic, error handling (404, 409, 500 with statusCode default). - -- [ ] **Step 2: Write tests** for the pure logic functions (not HTTP calls) -- [ ] **Step 3: Commit** - ---- - -## PR 5: GitHub + Respec Route Tests — `test/github-respec` - -### Task 11: GitHub route utilities - -**Files:** -- Create: `tests/routes/github/lib/utils/rest.test.js` -- Tested: `build/routes/github/lib/utils/rest.js` - -- [ ] **Step 1: Read source** — test URL validation (SSRF guard), pagination URL validation -- [ ] **Step 2: Write tests** -- [ ] **Step 3: Commit** - ---- - -### Task 12: Respec size handler - -**Files:** -- Create: `tests/routes/respec/size.test.js` -- Tested: `build/routes/respec/size.js` - -- [ ] **Step 1: Read source** — test deduplication buffer, input validation -- [ ] **Step 2: Write tests** -- [ ] **Step 3: Commit** - ---- - -## Execution Notes - -- Always run `pnpm build` before `pnpm test` (tests import from `build/`) -- Test files are `.test.js` (not `.test.ts`) — plain JavaScript with ES modules -- Import pattern: `import { X } from "../../build/path/to/module.js"` -- Use Jasmine matchers: `toBe()`, `toEqual()`, `toBeTrue()`, `toBeFalse()`, `toBeUndefined()`, `toContain()`, `toThrow()` -- Async tests: `await expectAsync(promise).toBeRejected()` or return the promise -- Each PR should be independently mergeable -- Run full test suite after each commit to catch regressions diff --git a/docs/superpowers/plans/2026-04-25-typescript6-migration.md b/docs/superpowers/plans/2026-04-25-typescript6-migration.md deleted file mode 100644 index 39128cc2..00000000 --- a/docs/superpowers/plans/2026-04-25-typescript6-migration.md +++ /dev/null @@ -1,107 +0,0 @@ -# TypeScript 6.0 Migration Plan - -> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Upgrade from TypeScript 5.6.3 to 6.0.3 (major version bump from Dependabot PR #486). - -**Architecture:** Static analysis suggests the codebase is already compatible — all imports use `.js` extensions, no import assertions, no deprecated APIs. The main risk is stricter type checking and module resolution changes. Close the dependabot PR and do a clean upgrade. - -**Tech Stack:** TypeScript 6.0.3, Node 24, ES modules - ---- - -## Pre-flight Assessment - -The codebase appears **already compatible** with TS 6.0 because: -- All imports use explicit `.js` extensions (required by `nodenext`) -- No `import ... assert` syntax -- No decorator metadata usage -- All type narrowing patterns are correct -- Single `@ts-ignore` in `search.ts:108` (pre-existing, not TS6-related) - -## Task 1: Upgrade TypeScript - -**Files:** -- Modify: `package.json` -- Modify: `tsconfig.json` (potentially) - -- [ ] **Step 1: Close Dependabot PR #486** - -The dependabot PR may have stale lockfile. Do a clean upgrade instead. - -```bash -gh pr close 486 --comment "Doing a clean upgrade in a dedicated branch instead." -``` - -- [ ] **Step 2: Create branch and upgrade** - -```bash -git checkout -b chore/typescript-6 main -pnpm add -D typescript@^6.0.3 -``` - -- [ ] **Step 3: Try building** - -```bash -pnpm build -``` - -If it succeeds, skip to Step 5. If not, catalog every error. - -- [ ] **Step 4: Fix compilation errors (if any)** - -Likely candidates based on TS 6.0 changes: -- **`moduleResolution`**: If TS 6.0 warns about `"node"`, switch to `"node16"` or `"nodenext"` -- **`module`**: If `"esnext"` causes issues, try `"node16"` or `"nodenext"` -- **Stricter type narrowing**: Fix any new type errors from improved control flow analysis -- **The `@ts-ignore` in search.ts:108**: May need to become `@ts-expect-error` with a specific error code - -For each error: note file, line, error code, fix. - -- [ ] **Step 5: Update tsconfig.json if needed** - -If module resolution changes were needed, update: -```json -{ - "compilerOptions": { - "module": "nodenext", - "moduleResolution": "nodenext" - } -} -``` - -- [ ] **Step 6: Run tests** - -```bash -pnpm test -``` - -All existing tests must pass. - -- [ ] **Step 7: Verify @types/node compatibility** - -Current: `@types/node@^22.9.0`. May need to bump to `@types/node@^24` for Node 24 LTS type definitions. - -```bash -pnpm add -D @types/node@^24 -``` - -If `@types/node@24` doesn't exist yet, stay on 22.x (types lag behind releases). - -- [ ] **Step 8: Commit** - -```bash -git commit -m "chore: upgrade TypeScript from 5.6.3 to 6.0.3" -``` - -## Task 2: Verify CI - -- [ ] **Step 1: Push and check CI** - -Ensure the test workflow passes with TS 6.0.3. The `continue-on-error: true` on the build step in `.github/workflows/test.yml` should be reviewed — if TS 6.0 fixes the pre-existing Express 5 type errors, we can remove it. - -- [ ] **Step 2: Create PR** - -```bash -gh pr create --title "chore: upgrade TypeScript to 6.0" --body "..." -``` diff --git a/utils/sh.ts b/utils/sh.ts index f936971a..ac614113 100644 --- a/utils/sh.ts +++ b/utils/sh.ts @@ -48,7 +48,7 @@ export default async function sh( stderr.push(line); }); child.on("error", err => { - reject({ command, stdout, stderr, code: null, error: err }); + reject(Object.assign(err, { command, stdout, stderr, code: null })); }); child.on("close", code => { if (output === "buffer") { @@ -58,7 +58,14 @@ export default async function sh( if (code === 0) { resolve(stdout.join("\n")); } else { - reject({ command, stdout, stderr, code }); + reject( + Object.assign(new Error(`Command failed: ${command}`), { + command, + stdout, + stderr, + code, + }) + ); } }); }); From f3af4b8ccd01b0dee041bfad4e5533c401090a46 Mon Sep 17 00:00:00 2001 From: Marcos Caceres Date: Sun, 3 May 2026 21:26:55 +1000 Subject: [PATCH 5/6] fix(xref): scope case-insensitive fallback to non-IDL queries WebIDL identifiers are case-sensitive, so "baseLine" must not match "Baseline" when types include an IDL type. The fallback now only fires when the query has no IDL types. Co-Authored-By: Claude Opus 4.6 (1M context) --- routes/xref/lib/search.ts | 9 +++++++-- tests/routes/xref/lib/search.test.js | 7 ++----- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/routes/xref/lib/search.ts b/routes/xref/lib/search.ts index 4e7345dd..50c50649 100644 --- a/routes/xref/lib/search.ts +++ b/routes/xref/lib/search.ts @@ -120,9 +120,13 @@ function normalizeQuery(query: Query, options: Options) { } function filter(query: Query, store: Store, options: Options) { + const { types = [] } = query; + const isIDL = types.some(t => IDL_TYPES.has(t)); + const allowCaseFallback = !isIDL; + let result: DataEntry[] = []; for (const term of getTermVariations(query)) { - const byTerm = filterByTerm(term, store); + const byTerm = filterByTerm(term, store, allowCaseFallback); const bySpec = filterBySpec(byTerm, query); const byType = filterByType(bySpec, query); const byForContext = filterByForContext(byType, query, options); @@ -154,10 +158,11 @@ function getTermVariations(query: Query) { } } -function filterByTerm(term: Query["term"], store: Store) { +function filterByTerm(term: Query["term"], store: Store, allowCaseFallback: boolean) { if (term == null) return []; const direct = store.byTerm[term]; if (direct) return direct; + if (!allowCaseFallback) return []; const lower = term.toLowerCase(); const variants = store.byTermLower.get(lower); if (!variants) return []; diff --git a/tests/routes/xref/lib/search.test.js b/tests/routes/xref/lib/search.test.js index ecf1bb05..08bd34c2 100644 --- a/tests/routes/xref/lib/search.test.js +++ b/tests/routes/xref/lib/search.test.js @@ -148,11 +148,8 @@ describe("xref - search", () => { expect(search({ term: "baseLine" })).toEqual(baselineBoth); expect(search({ term: "baseLine", types: ["dfn"] })).toEqual(baseline); - // IDL exact-case match: "baseLine" doesn't match "Baseline" directly, - // but case-insensitive fallback finds it - expect(search({ term: "baseLine", types: ["_IDL_"] })).toEqual( - baselineInterface, - ); + // IDL is case-sensitive: "baseLine" must not match "Baseline" + expect(search({ term: "baseLine", types: ["_IDL_"] })).toEqual([]); expect(search({ term: "Baseline", types: ["dfn"] })).toEqual(baseline); expect(search({ term: "Baseline", types: ["_IDL_"] })).toEqual( From 8871ed7a280e166d257a5af1b2419ab11c549fb4 Mon Sep 17 00:00:00 2001 From: Marcos Caceres Date: Mon, 4 May 2026 21:37:42 +1000 Subject: [PATCH 6/6] fix(xref): include canonical term in case-insensitive fallback results When the case-insensitive fallback fires in filterByTerm, each returned entry is now tagged with the canonical term it was indexed under. This lets the xref UI build correct cite syntax (e.g., [=baseline=] and {{Baseline}}) instead of using the user's potentially miscased input (e.g., [=baseLine=]). Changes: - Add optional `term` field to DataEntry - Clone entries in filterByTerm fallback path with canonical term - Update xref UI to use entry.term for citation building - Add test verifying term presence on fallback hits --- routes/xref/lib/search.ts | 9 ++++++++- static/xref/script.js | 13 ++++++++----- tests/routes/xref/lib/search.test.js | 21 +++++++++++++++++++++ 3 files changed, 37 insertions(+), 6 deletions(-) diff --git a/routes/xref/lib/search.ts b/routes/xref/lib/search.ts index 50c50649..a987cd1c 100644 --- a/routes/xref/lib/search.ts +++ b/routes/xref/lib/search.ts @@ -27,6 +27,8 @@ export interface DataEntry { normative: boolean; for?: string[]; htmlProse?: string; + /** The canonical term this entry was indexed under (set on case-insensitive fallback hits). */ + term?: string; } type SpecType = DataEntry["status"] | "draft" | "official"; @@ -163,10 +165,15 @@ function filterByTerm(term: Query["term"], store: Store, allowCaseFallback: bool const direct = store.byTerm[term]; if (direct) return direct; if (!allowCaseFallback) return []; + // Case-insensitive fallback: tag each entry with its canonical term so + // downstream consumers (e.g. the xref UI) can build correct cite syntax + // instead of using the user's potentially miscased input. const lower = term.toLowerCase(); const variants = store.byTermLower.get(lower); if (!variants) return []; - return variants.flatMap(v => store.byTerm[v] || []); + return variants.flatMap(v => + (store.byTerm[v] || []).map(entry => ({ ...entry, term: v })), + ); } function filterBySpec(data: DataEntry[], query: Query) { diff --git a/static/xref/script.js b/static/xref/script.js index df8ee2d2..2c2c3bf6 100644 --- a/static/xref/script.js +++ b/static/xref/script.js @@ -84,7 +84,7 @@ const specStatusType = { let metadata; const options = { - fields: ['shortname', 'spec', 'uri', 'type', 'for', 'status'], + fields: ['shortname', 'spec', 'uri', 'type', 'for', 'status', 'term'], spec_type: ['draft', 'snapshot'], all: true, }; @@ -146,17 +146,20 @@ function renderResults(entries, query) { let html = ''; for (const entry of entries) { + // Use the canonical matched term when available (case-insensitive fallback + // hits); otherwise fall back to the user's query term (exact match). + const citeTerm = entry.term || term; const specInfo = metadata.specs[entry.status][entry.spec]; const link = new URL(entry.uri, specInfo.url).href; const title = escapeHTML(specInfo.title); const cite = metadata.types.idl.has(entry.type) - ? howToCiteIDL(term, entry) + ? howToCiteIDL(citeTerm, entry) : metadata.types.markup.has(entry.type) - ? howToCiteMarkup(term, entry) + ? howToCiteMarkup(citeTerm, entry) : metadata.types.css.has(entry.type) || metadata.types.http.has(entry.type) - ? howToCiteAnchor(term, entry) - : howToCiteTerm(term, entry); + ? howToCiteAnchor(citeTerm, entry) + : howToCiteTerm(citeTerm, entry); let row = ` ${title} diff --git a/tests/routes/xref/lib/search.test.js b/tests/routes/xref/lib/search.test.js index 08bd34c2..7626f4cb 100644 --- a/tests/routes/xref/lib/search.test.js +++ b/tests/routes/xref/lib/search.test.js @@ -156,6 +156,27 @@ describe("xref - search", () => { baselineInterface, ); }); + + it("includes canonical term on case-insensitive fallback hits", () => { + // When the case-insensitive fallback fires, each result entry should + // include the canonical term it was indexed under, so cite syntax can + // use the correct casing instead of the user's input. + const searchWithTerm = query => { + const response = _search([query], store, { fields: ["uri", "term"] }); + return response.result[0][1]; + }; + + // Exact match: no term field (not a fallback hit) + const exact = searchWithTerm({ term: "baseline" }); + expect(exact).toEqual([{ uri: "text.html#TermBaseline", term: undefined }]); + + // Case-insensitive fallback: term field present with canonical casing + const fallback = searchWithTerm({ term: "baseLine" }); + expect(fallback).toEqual([ + { uri: "text.html#TermBaseline", term: "baseline" }, + { uri: "#baseline", term: "Baseline" }, + ]); + }); }); describe("filter@specs", () => {