From 4ddc792b00a8340a533a82b7a46eeb89a1db6d75 Mon Sep 17 00:00:00 2001 From: AndresL230 <190146319+AndresL230@users.noreply.github.com> Date: Sat, 23 May 2026 20:00:03 -0400 Subject: [PATCH 1/8] docs(wave10): config hygiene design spec --- ...2026-05-23-wave10-config-hygiene-design.md | 148 ++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-23-wave10-config-hygiene-design.md diff --git a/docs/superpowers/specs/2026-05-23-wave10-config-hygiene-design.md b/docs/superpowers/specs/2026-05-23-wave10-config-hygiene-design.md new file mode 100644 index 0000000..872a6a1 --- /dev/null +++ b/docs/superpowers/specs/2026-05-23-wave10-config-hygiene-design.md @@ -0,0 +1,148 @@ +# Wave 10 — Config Hygiene Design + +**Date:** 2026-05-23 +**Closes:** #97, #98 +**Wave label:** `wave/10-config-hygiene` +**Area:** `area/extension-ux` +**PR shape:** one bundled PR + +## Goal + +Two small hygiene defects ship together because both are scattered string-literal cleanups in the same surface area: + +1. **#97** — `https://api.recost.dev` and `https://recost.dev/dashboard/...` appear as raw literals in five call sites across four files. Staging/regional deployments require multi-file edits; new contributors propagate the pattern. +2. **#98** — Local-only `scanId` is generated as `local-${Date.now()}`, which has a 1-millisecond collision window. Any caller that keys by `scanId` silently overwrites the previous local scan when two scans land in the same millisecond. Practically rare in human use; possible via CLI automation or rapid double-clicks. + +## Non-goals + +- Third-party provider URLs (`api.openai.com`, `api.anthropic.com`, `api.x.ai`, `api.cohere.com`, `api.mistral.ai`) in `src/chat/providers/*.ts` stay literal — not ReCost-owned, no override use case. +- Fingerprint JSON `endpoint` strings in `src/scanner/fingerprints/*.json` stay literal — these describe pricing tables for real third-party endpoints; rewriting them would corrupt the pricing data. +- VSCode setting UI for the URL override is out of scope. Staging-deploy ergonomics is a developer concern, not an end-user concern; env vars are the right surface. +- Cross-process scanId uniqueness (CLI + extension running concurrently against the same project) is achieved by the entropy in the new format but is not a tested invariant. + +## Architecture + +Two new modules in `src/`, both pure, both zero-dependency: + +- `src/config.ts` — exports `RECOST_API_BASE_URL` and `RECOST_DASHBOARD_BASE_URL` constants. Reads `process.env.RECOST_API_BASE_URL` / `process.env.RECOST_DASHBOARD_BASE_URL` once at module load, falling back to production defaults. +- `src/scan-id.ts` — exports `newLocalScanId(): string` returning `local-${Date.now()}-${randomHex(8)}` using `crypto.randomUUID()`. + +Single bundled PR on branch `wave10/config-hygiene` closing #97 + #98. No new dependencies, no IPC surface changes, no scanner/detector impact. Production behavior unchanged: defaults preserve the current literals. + +--- + +## Section A — #97: centralize ReCost URLs + +### `src/config.ts` + +```ts +const PROD_API_BASE_URL = "https://api.recost.dev"; +const PROD_DASHBOARD_BASE_URL = "https://recost.dev"; + +export const RECOST_API_BASE_URL = + process.env.RECOST_API_BASE_URL?.trim() || PROD_API_BASE_URL; + +export const RECOST_DASHBOARD_BASE_URL = + process.env.RECOST_DASHBOARD_BASE_URL?.trim() || PROD_DASHBOARD_BASE_URL; +``` + +Design notes: +- **Trim + truthiness** — empty / whitespace-only env vars fall back to production defaults instead of silently producing a broken URL. +- **Dashboard URL has no `/dashboard` suffix** — call sites append their own path (`/dashboard/account`, `/dashboard/projects/${id}`). Keeping suffixes at the call site preserves grep-ability and lets a future marketing-site URL diverge from dashboard cleanly without a config split. +- **Module load-time resolution** — env vars are read once when the module is first imported. Changing env vars at runtime won't affect already-loaded constants. Matches Node convention; restart of the extension host (or VSCode reload) picks up changes. +- **No `process.env` access in webview code** — `process.env` is only available in the extension host (Node.js context). All five call sites being migrated live in extension-host code, so this is safe. + +### Call site migration + +| File | Before | After | +|------|--------|-------| +| `src/api-client.ts:3` | `const BASE_URL = "https://api.recost.dev";` | `import { RECOST_API_BASE_URL } from "./config";` then `const BASE_URL = RECOST_API_BASE_URL;` | +| `src/extension.ts:17` | `const GET_KEY_URL = "https://recost.dev/dashboard/account";` | `import { RECOST_DASHBOARD_BASE_URL } from "./config";` then `const GET_KEY_URL = \`${RECOST_DASHBOARD_BASE_URL}/dashboard/account\`;` | +| `src/extension.ts:18` | `const PRICING_BACKEND_URL = "https://api.recost.dev";` | `const PRICING_BACKEND_URL = RECOST_API_BASE_URL;` (after adding the import) | +| `src/chat/providers/eco.ts:7` | `baseUrl: "https://api.recost.dev",` | `import { RECOST_API_BASE_URL } from "../../config";` then `baseUrl: RECOST_API_BASE_URL,` | +| `src/webview-provider.ts:734-735` | `` `https://recost.dev/dashboard/projects/${targetProjectId}` `` and `"https://recost.dev/dashboard/projects"` | `` `${RECOST_DASHBOARD_BASE_URL}/dashboard/projects/${targetProjectId}` `` and `` `${RECOST_DASHBOARD_BASE_URL}/dashboard/projects` `` | + +### Tests + +`src/test/config.test.ts` (new): +- **Default behavior** — with `RECOST_API_BASE_URL` and `RECOST_DASHBOARD_BASE_URL` unset, the exported constants equal the production literals. +- **Override behavior** — module-load reads are cached after first import, so this test must spawn a sub-process (`child_process.spawnSync` with `node -e "..."`) to test that env vars override the defaults. Two cases: `https://staging.recost.dev` (typical override) and ` ` (whitespace-only, must fall back to default). +- **Tradeoff** — spawning a sub-process is more expensive than an inline test but is the only way to test load-time resolution without refactoring `config.ts` to export a resolver function. The simplicity of `export const X = process.env.X?.trim() || DEFAULT;` is worth the slightly heavier test. + +--- + +## Section B — #98: collision-resistant local scanId + +### `src/scan-id.ts` + +```ts +import { randomUUID } from "crypto"; + +export function newLocalScanId(): string { + const suffix = randomUUID().replace(/-/g, "").slice(0, 8); + return `local-${Date.now()}-${suffix}`; +} +``` + +Design notes: +- **Format** — `local-1716501234567-a1b2c3d4`. Human-readable timestamp prefix preserved for log/debug filtering; 32 bits of entropy in the suffix means collision probability inside a single millisecond is ~2⁻³² ≈ 2.3e-10, which is far past the 1 ms collision window the issue flagged. For a typical extension session that generates fewer than a few thousand local scans total, total collision probability rounds to zero. +- **Why `randomUUID()` instead of `randomBytes(4).toString("hex")`** — both work; `randomUUID()` is what the issue suggested. Stripping dashes before slicing means `slice(0, 8)` returns 8 hex chars (`a1b2c3d4`) rather than 7 chars + a dash (`a1b2c3d-`). +- **Why a helper, not inline** — centralizes the format so future changes (longer entropy, different prefix, etc.) touch one place. Also makes the function trivially mockable in tests. +- **Format compatibility audit** — confirmed via grep: no code anywhere does `scanId.startsWith("local-")` or otherwise discriminates on the format. The hyphen-delimited shape is unchanged from the old `local-${ts}` form (just with a third segment appended), so any code that splits on `-` and reads the first two segments still works. + +### Call site migration + +Eight call sites become `newLocalScanId()`: + +| File | Lines | +|------|-------| +| `src/cli/scan.ts` | 229 | +| `src/webview/chat-handler.ts` | 414 (used as a fallback when `lastEndpoints[0]?.scanId` is undefined) | +| `src/webview/scan-publishing-handler.ts` | 666, 678, 688, 703, 793, 817 | + +All sites currently use `` `local-${Date.now()}` ``; replace verbatim with `newLocalScanId()` plus the import. + +### Tests + +`src/test/scan-id.test.ts` (new): +- **Format** — `newLocalScanId()` matches the regex `/^local-\d+-[0-9a-f]{8}$/`. +- **Uniqueness** — 1000 sequential calls produce 1000 distinct IDs (regression test for #98 — fails with the old `local-${Date.now()}` form in roughly any run that takes < 1 ms per iteration). +- **Timestamp prefix is non-decreasing** — across 100 calls, the integer parsed from segment 2 of each ID never goes backwards (sanity check that `Date.now()` is in fact used). + +--- + +## Verification gates + +Standard: +- `npm run test:scanner` — existing + new `config` + new `scan-id` tests pass. +- `npm run build` — clean. + +Grep gates (must pass before merge): +- `grep -rn "api.recost.dev\|recost.dev/dashboard" src/` returns hits only inside `src/config.ts`. CLAUDE.md and fingerprint docs may still contain the literals (out of scope). +- `grep -rn 'local-\${Date.now()}' src/` returns zero hits. + +Manual EDH gate (low effort, 30 seconds): +- Start the EDH, confirm status bar still shows "Connected" / "Not Configured" correctly and that the "Get a key" / "Open dashboard" links still resolve to `https://recost.dev/...`. Both should be visually identical to current behavior because defaults preserve the literals. + +D1 benchmark sanity: Δ +0.00pp (no scanner change expected; only string-literal centralization). + +--- + +## Files touched + +| File | Change | +|------|--------| +| `src/config.ts` (new) | `RECOST_API_BASE_URL`, `RECOST_DASHBOARD_BASE_URL` constants with env-var override | +| `src/scan-id.ts` (new) | `newLocalScanId()` helper using `crypto.randomUUID()` | +| `src/api-client.ts` | Import + use `RECOST_API_BASE_URL` | +| `src/extension.ts` | Import + use both URL constants | +| `src/chat/providers/eco.ts` | Import + use `RECOST_API_BASE_URL` | +| `src/webview-provider.ts` | Import + use `RECOST_DASHBOARD_BASE_URL` in template literals | +| `src/cli/scan.ts` | Import + use `newLocalScanId()` | +| `src/webview/chat-handler.ts` | Import + use `newLocalScanId()` | +| `src/webview/scan-publishing-handler.ts` | Import + 6 call sites switch to `newLocalScanId()` | +| `src/test/config.test.ts` (new) | Default + env-override + whitespace fallback coverage | +| `src/test/scan-id.test.ts` (new) | Format + uniqueness + monotonic timestamp coverage | +| `package.json` | Register both new tests in `scripts.test:scanner` | + +No removed files. No IPC message changes. No scanner/detector impact. From ba32c7fdf214aca93515a0507197f2d004e695ff Mon Sep 17 00:00:00 2001 From: AndresL230 <190146319+AndresL230@users.noreply.github.com> Date: Sat, 23 May 2026 20:04:32 -0400 Subject: [PATCH 2/8] docs(wave10): config hygiene implementation plan --- .../plans/2026-05-23-wave10-config-hygiene.md | 553 ++++++++++++++++++ 1 file changed, 553 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-23-wave10-config-hygiene.md diff --git a/docs/superpowers/plans/2026-05-23-wave10-config-hygiene.md b/docs/superpowers/plans/2026-05-23-wave10-config-hygiene.md new file mode 100644 index 0000000..af5c5ce --- /dev/null +++ b/docs/superpowers/plans/2026-05-23-wave10-config-hygiene.md @@ -0,0 +1,553 @@ +# Wave 10 — Config Hygiene 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:** Close #97 (hard-coded ReCost URLs) and #98 (1ms `local-${Date.now()}` scanId collision) in one bundled PR via two small new pure modules — `src/config.ts` and `src/scan-id.ts` — and mechanical migration of every call site. + +**Architecture:** `src/config.ts` exports `RECOST_API_BASE_URL` and `RECOST_DASHBOARD_BASE_URL` constants that read `process.env.RECOST_API_BASE_URL` / `process.env.RECOST_DASHBOARD_BASE_URL` once at module load, falling back to production defaults. `src/scan-id.ts` exports `newLocalScanId()` returning `local-${Date.now()}-${randomHex(8)}` using `crypto.randomUUID()`. Both modules are pure, zero-dependency, and replace 5 URL literals + 7 scanId construction sites. Production behavior is unchanged (defaults preserve current literals; new scanId format passes any existing `local-` prefix check — confirmed none exist). + +**Tech Stack:** TypeScript (strict mode), Node `crypto.randomUUID()`, `process.env`, `node:assert/strict` for tests, `tsc -p tsconfig.scanner-tests.json` test compile. + +--- + +## Note on rebase + +This plan was written against `main` HEAD, which has 7 `local-${Date.now()}` sites. The open Wave 8 PR (#123) adds an 8th site inside its new 429-branch in `src/webview/scan-publishing-handler.ts`. If Wave 8 merges before Wave 10: +- Rebase Wave 10 onto `main`. +- The grep gate in Task 5 (`grep -rn 'local-\${Date.now()}' src/` returns zero) will catch the 8th site; replace it with `newLocalScanId()` in a follow-up commit before opening the PR. + +--- + +## File Structure + +| File | Responsibility | +|------|----------------| +| `src/config.ts` (new) | Centralize ReCost-owned base URLs. Two exported constants with env-var override. Pure, no runtime branching. | +| `src/scan-id.ts` (new) | Produce collision-resistant local scan IDs. One exported function. Pure (delegates to `crypto`). | +| `src/api-client.ts` | Use `RECOST_API_BASE_URL` for `BASE_URL`. | +| `src/extension.ts` | Use `RECOST_API_BASE_URL` for `PRICING_BACKEND_URL`. Use `RECOST_DASHBOARD_BASE_URL` for `GET_KEY_URL`. | +| `src/chat/providers/eco.ts` | Use `RECOST_API_BASE_URL` for ReCost chat provider `baseUrl`. | +| `src/webview-provider.ts` | Use `RECOST_DASHBOARD_BASE_URL` in dashboard URL template literals (2 sites). | +| `src/cli/scan.ts` | Use `newLocalScanId()`. | +| `src/webview/chat-handler.ts` | Use `newLocalScanId()` as the fallback when `lastEndpoints[0]?.scanId` is undefined. | +| `src/webview/scan-publishing-handler.ts` | Use `newLocalScanId()` at 5 sites (665, 677, 687, 702, 800 on main; one more from Wave 8 if rebased). | +| `src/test/config.test.ts` (new) | Default + env-override + whitespace-fallback coverage. Spawns sub-process to test load-time resolution. | +| `src/test/scan-id.test.ts` (new) | Format regex + 1000-call uniqueness + non-decreasing timestamp coverage. | +| `package.json` | Register both new tests in `scripts.test:scanner`. | + +--- + +## Task 1: Create `src/config.ts` with default + env override + +**Files:** +- Create: `src/config.ts` +- Create: `src/test/config.test.ts` +- Modify: `package.json` (add `config.test.js` to `scripts.test:scanner`) + +- [ ] **Step 1: Create the failing default-behavior test** + +Create `src/test/config.test.ts`: + +```ts +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import * as path from "node:path"; + +// Resolve the compiled config.js relative to this test file's compiled output. +// Tests compile to dist-test/test/config.test.js; config.js compiles to dist-test/config.js. +const CONFIG_PATH = path.resolve(__dirname, "..", "config.js"); + +function runConfigInSubprocess(env: Record): { + api: string; + dashboard: string; +} { + const script = ` + const c = require(${JSON.stringify(CONFIG_PATH)}); + process.stdout.write(JSON.stringify({ + api: c.RECOST_API_BASE_URL, + dashboard: c.RECOST_DASHBOARD_BASE_URL, + })); + `; + const child = spawnSync(process.execPath, ["-e", script], { + env: { ...process.env, ...env, RECOST_API_BASE_URL: env.RECOST_API_BASE_URL ?? "", RECOST_DASHBOARD_BASE_URL: env.RECOST_DASHBOARD_BASE_URL ?? "" }, + encoding: "utf8", + }); + if (child.status !== 0) { + throw new Error(`config subprocess exited ${child.status}: ${child.stderr}`); + } + return JSON.parse(child.stdout); +} + +async function runTests() { + // 1. Defaults apply when env vars are unset (passed as empty strings -> trimmed to falsy -> fall back) + { + const result = runConfigInSubprocess({}); + assert.equal(result.api, "https://api.recost.dev"); + assert.equal(result.dashboard, "https://recost.dev"); + } + + // 2. Env override applies cleanly + { + const result = runConfigInSubprocess({ + RECOST_API_BASE_URL: "https://staging.api.recost.dev", + RECOST_DASHBOARD_BASE_URL: "https://staging.recost.dev", + }); + assert.equal(result.api, "https://staging.api.recost.dev"); + assert.equal(result.dashboard, "https://staging.recost.dev"); + } + + // 3. Whitespace-only env vars fall back to defaults + { + const result = runConfigInSubprocess({ + RECOST_API_BASE_URL: " ", + RECOST_DASHBOARD_BASE_URL: "\t\n", + }); + assert.equal(result.api, "https://api.recost.dev"); + assert.equal(result.dashboard, "https://recost.dev"); + } + + console.log("PASS config"); +} + +runTests().catch((e) => { + console.error(e); + process.exit(1); +}); +``` + +- [ ] **Step 2: Register the new test in `package.json`** + +In `package.json`, find the `"test:scanner"` script (a single very long command string). Append ` && node dist-test/test/config.test.js` to the end of the command string (immediately before the closing `"`). Do not reorder existing entries. + +- [ ] **Step 3: Run the test to verify it fails** + +Run: +```bash +npm run test:scanner 2>&1 | tail -20 +``` + +Expected: `config` fails (probably with `Cannot find module '.../dist-test/config.js'` because `src/config.ts` doesn't exist yet). This proves the test is wired and ready. + +- [ ] **Step 4: Create `src/config.ts`** + +Create `src/config.ts`: + +```ts +const PROD_API_BASE_URL = "https://api.recost.dev"; +const PROD_DASHBOARD_BASE_URL = "https://recost.dev"; + +export const RECOST_API_BASE_URL = + process.env.RECOST_API_BASE_URL?.trim() || PROD_API_BASE_URL; + +export const RECOST_DASHBOARD_BASE_URL = + process.env.RECOST_DASHBOARD_BASE_URL?.trim() || PROD_DASHBOARD_BASE_URL; +``` + +- [ ] **Step 5: Run the test to verify it passes** + +Run: +```bash +npm run test:scanner 2>&1 | tail -10 +``` + +Expected: `PASS config` appears; all other tests still pass. + +- [ ] **Step 6: Commit** + +```bash +git add src/config.ts src/test/config.test.ts package.json +git commit -m "feat(wave10): centralize ReCost base URLs with env-var override (#97)" +``` + +--- + +## Task 2: Migrate 5 URL call sites to `src/config.ts` + +**Files:** +- Modify: `src/api-client.ts:3` +- Modify: `src/extension.ts:17-18` +- Modify: `src/chat/providers/eco.ts:7` +- Modify: `src/webview-provider.ts:731-732` + +- [ ] **Step 1: Migrate `src/api-client.ts`** + +Open `src/api-client.ts`. Replace the top import block (line 1) + the `BASE_URL` declaration (line 3) so the file starts with: + +```ts +import type { ApiCallInput, EndpointRecord, Suggestion, ScanSummary } from "./analysis/types"; +import { RECOST_API_BASE_URL } from "./config"; + +const BASE_URL = RECOST_API_BASE_URL; +``` + +The body of `apiFetchWith` (which uses `${BASE_URL}${path}`) is unchanged. + +- [ ] **Step 2: Migrate `src/extension.ts`** + +Open `src/extension.ts`. Find lines 17-18: +```ts +const GET_KEY_URL = "https://recost.dev/dashboard/account"; +const PRICING_BACKEND_URL = "https://api.recost.dev"; +``` + +Replace with: +```ts +const GET_KEY_URL = `${RECOST_DASHBOARD_BASE_URL}/dashboard/account`; +const PRICING_BACKEND_URL = RECOST_API_BASE_URL; +``` + +And add to the import block at the top (immediately after the `import * as vscode from "vscode";` line): +```ts +import { RECOST_API_BASE_URL, RECOST_DASHBOARD_BASE_URL } from "./config"; +``` + +- [ ] **Step 3: Migrate `src/chat/providers/eco.ts`** + +Open `src/chat/providers/eco.ts`. At the top of the file (above the existing exports), add: +```ts +import { RECOST_API_BASE_URL } from "../../config"; +``` + +Then find the line `baseUrl: "https://api.recost.dev",` (around line 7) and replace with: +```ts + baseUrl: RECOST_API_BASE_URL, +``` + +(Indentation matches the surrounding object literal — two spaces.) + +- [ ] **Step 4: Migrate `src/webview-provider.ts`** + +Open `src/webview-provider.ts`. Find the existing import block at the top and add: +```ts +import { RECOST_DASHBOARD_BASE_URL } from "./config"; +``` + +Then find the two consecutive dashboard URL template literals (lines 731-732): +```ts + ? `https://recost.dev/dashboard/projects/${targetProjectId}` + : "https://recost.dev/dashboard/projects"; +``` + +Replace with: +```ts + ? `${RECOST_DASHBOARD_BASE_URL}/dashboard/projects/${targetProjectId}` + : `${RECOST_DASHBOARD_BASE_URL}/dashboard/projects`; +``` + +Note: the second arm changes from a plain string to a template literal because we're interpolating the constant. + +- [ ] **Step 5: Verify the build** + +Run: +```bash +npm run build:ext 2>&1 | tail -5 +``` + +Expected: `Extension built successfully (dev mode).` No TypeScript errors. + +- [ ] **Step 6: Verify the grep gate** + +Run: +```bash +grep -rn "api\.recost\.dev\|recost\.dev/dashboard" src/ --exclude-dir=test +``` + +Expected: zero hits, OR only `src/config.ts` (production defaults). If any other production file shows up, that site was missed — go back and migrate it. + +- [ ] **Step 7: Run the full test suite** + +Run: +```bash +npm run test:scanner 2>&1 | tail -10 +``` + +Expected: all tests pass. + +- [ ] **Step 8: Commit** + +```bash +git add src/api-client.ts src/extension.ts src/chat/providers/eco.ts src/webview-provider.ts +git commit -m "refactor(wave10): use RECOST_*_BASE_URL constants at all call sites (#97)" +``` + +--- + +## Task 3: Create `src/scan-id.ts` + +**Files:** +- Create: `src/scan-id.ts` +- Create: `src/test/scan-id.test.ts` +- Modify: `package.json` (add `scan-id.test.js` to `scripts.test:scanner`) + +- [ ] **Step 1: Create the failing test** + +Create `src/test/scan-id.test.ts`: + +```ts +import assert from "node:assert/strict"; +import { newLocalScanId } from "../scan-id"; + +const FORMAT = /^local-\d+-[0-9a-f]{8}$/; + +async function runTests() { + // 1. Format: matches local--<8 hex chars> + { + const id = newLocalScanId(); + assert.match(id, FORMAT, `expected ${id} to match ${FORMAT}`); + } + + // 2. Uniqueness: 1000 sequential calls produce 1000 distinct IDs + { + const ids = new Set(); + for (let i = 0; i < 1000; i++) { + ids.add(newLocalScanId()); + } + assert.equal(ids.size, 1000, `expected 1000 unique IDs, got ${ids.size}`); + } + + // 3. Timestamp prefix is non-decreasing across 100 calls + { + let prev = 0; + for (let i = 0; i < 100; i++) { + const id = newLocalScanId(); + const ts = Number(id.split("-")[1]); + assert.ok(ts >= prev, `timestamp regressed: ${ts} < ${prev}`); + prev = ts; + } + } + + console.log("PASS scan-id"); +} + +runTests().catch((e) => { + console.error(e); + process.exit(1); +}); +``` + +- [ ] **Step 2: Register the new test in `package.json`** + +Append ` && node dist-test/test/scan-id.test.js` to the end of the `test:scanner` command string (after the `config.test.js` entry added in Task 1). + +- [ ] **Step 3: Run the test to verify it fails** + +Run: +```bash +npm run test:scanner 2>&1 | tail -10 +``` + +Expected: `scan-id` fails because `../scan-id` cannot be resolved. + +- [ ] **Step 4: Create `src/scan-id.ts`** + +Create `src/scan-id.ts`: + +```ts +import { randomUUID } from "crypto"; + +export function newLocalScanId(): string { + const suffix = randomUUID().replace(/-/g, "").slice(0, 8); + return `local-${Date.now()}-${suffix}`; +} +``` + +- [ ] **Step 5: Run the test to verify it passes** + +Run: +```bash +npm run test:scanner 2>&1 | tail -10 +``` + +Expected: `PASS scan-id` appears; all other tests still pass. + +- [ ] **Step 6: Commit** + +```bash +git add src/scan-id.ts src/test/scan-id.test.ts package.json +git commit -m "feat(wave10): collision-resistant newLocalScanId() helper (#98)" +``` + +--- + +## Task 4: Migrate 7 scanId call sites to `newLocalScanId()` + +**Files:** +- Modify: `src/cli/scan.ts:229` +- Modify: `src/webview/chat-handler.ts:414` +- Modify: `src/webview/scan-publishing-handler.ts:665, 677, 687, 702, 800` + +- [ ] **Step 1: Migrate `src/cli/scan.ts`** + +Open `src/cli/scan.ts`. Add to the imports near the top: +```ts +import { newLocalScanId } from "../scan-id"; +``` + +Find line 229: +```ts + let scanId = `local-${Date.now()}`; +``` + +Replace with: +```ts + let scanId = newLocalScanId(); +``` + +- [ ] **Step 2: Migrate `src/webview/chat-handler.ts`** + +Open `src/webview/chat-handler.ts`. Add to the imports: +```ts +import { newLocalScanId } from "../scan-id"; +``` + +Find line 414: +```ts + const scanId = lastEndpoints[0]?.scanId ?? providerProjectId ?? `local-${Date.now()}`; +``` + +Replace with: +```ts + const scanId = lastEndpoints[0]?.scanId ?? providerProjectId ?? newLocalScanId(); +``` + +- [ ] **Step 3: Migrate `src/webview/scan-publishing-handler.ts`** + +Open `src/webview/scan-publishing-handler.ts`. Add to the imports: +```ts +import { newLocalScanId } from "../scan-id"; +``` + +Find each `` `local-${Date.now()}` `` occurrence and replace with `newLocalScanId()`. There are 5 sites on main: + +Line 665 (inside an object literal): +```ts + scanId: `local-${Date.now()}`, +``` +becomes: +```ts + scanId: newLocalScanId(), +``` + +Lines 677, 687, 702, 800 (each inside a `publishLocalOnlyResults(...)` call): +```ts + publishLocalOnlyResults(manualProjectId ?? this.ctx.getProjectId() ?? "local", `local-${Date.now()}`); +``` +each becomes: +```ts + publishLocalOnlyResults(manualProjectId ?? this.ctx.getProjectId() ?? "local", newLocalScanId()); +``` + +(Indentation varies by site — preserve whatever is there.) + +If after Wave 8 has merged you find a 6th site in this file (in the 429 branch), apply the same replacement there too. + +- [ ] **Step 4: Verify the grep gate** + +Run: +```bash +grep -rn 'local-\${Date.now()}' src/ +``` + +Expected: zero hits. If any remain, migrate them. + +- [ ] **Step 5: Verify the build** + +Run: +```bash +npm run build:ext 2>&1 | tail -5 +``` + +Expected: `Extension built successfully (dev mode).` No TypeScript errors. + +- [ ] **Step 6: Run the full test suite** + +Run: +```bash +npm run test:scanner 2>&1 | tail -10 +``` + +Expected: all tests pass. + +- [ ] **Step 7: Commit** + +```bash +git add src/cli/scan.ts src/webview/chat-handler.ts src/webview/scan-publishing-handler.ts +git commit -m "refactor(wave10): use newLocalScanId() at all local-scanId call sites (#98)" +``` + +--- + +## Task 5: Final verification + PR + +- [ ] **Step 1: Full test suite + build** + +Run: +```bash +npm run test:scanner 2>&1 | tail -10 && npm run build 2>&1 | tail -5 +``` + +Expected: every test ends in `PASS`, including `PASS config` and `PASS scan-id`. Build clean. + +- [ ] **Step 2: Confirm grep gates one more time** + +Run both: +```bash +grep -rn "api\.recost\.dev\|recost\.dev/dashboard" src/ --exclude-dir=test +grep -rn 'local-\${Date.now()}' src/ +``` + +Expected: zero hits outside `src/config.ts` for the URL grep; zero hits at all for the scanId grep. If anything turns up, fix it before opening the PR. + +- [ ] **Step 3: Manual EDH gate (30 seconds)** + +Press F5 in VSCode to launch the Extension Development Host. In the EDH: +1. Confirm the status bar shows "ReCost: Not Configured" or "ReCost: " as it did before this change. +2. If no key configured, click "Get a key" in the info notification and confirm it opens `https://recost.dev/dashboard/account` in the browser (the URL bar should show the literal production domain). +3. With a key configured, open the dashboard from the sidebar and confirm the URL resolves correctly. + +If any of these visually differ from production behavior, something is wrong — investigate before merging. + +- [ ] **Step 4: Push the branch** + +```bash +git push -u origin wave10/config-hygiene +``` + +- [ ] **Step 5: Open the PR** + +```bash +gh pr create --title "wave10: config hygiene (#97, #98)" --body "$(cat <<'EOF' +Closes #97 +Closes #98 + +## Summary +- **#97** — Centralized two ReCost-owned base URLs (`https://api.recost.dev`, `https://recost.dev`) into a new `src/config.ts` module with env-var overrides (`RECOST_API_BASE_URL`, `RECOST_DASHBOARD_BASE_URL`). Migrated 5 call sites across `api-client.ts`, `extension.ts`, `chat/providers/eco.ts`, `webview-provider.ts`. +- **#98** — Replaced `local-${Date.now()}` with a new `newLocalScanId()` helper in `src/scan-id.ts` that produces `local--<8 hex>`. Migrated 7 call sites across `cli/scan.ts`, `webview/chat-handler.ts`, `webview/scan-publishing-handler.ts` (+1 more if Wave 8 has merged). + +Production behavior is unchanged: env defaults preserve the existing URL literals, and the new scanId format passes any prefix check (confirmed none exist). + +## Test plan +- [x] `npm run test:scanner` — existing suite + new `config` test (3 cases, sub-process load-time resolution) + new `scan-id` test (format regex, 1000-call uniqueness, monotonic timestamp) all pass. +- [x] `npm run build` — dashboard + webview + extension build clean. +- [x] Grep gate — `api.recost.dev` / `recost.dev/dashboard` only in `src/config.ts`; `local-${Date.now()}` returns zero hits. +- [ ] D1 benchmark Δ +0.00pp sanity (no scanner change expected). +- [ ] EDH manual gate — confirm status bar + "Get a key" link + dashboard link all resolve to production URLs. + +Plan: `docs/superpowers/plans/2026-05-23-wave10-config-hygiene.md`. Design spec: `docs/superpowers/specs/2026-05-23-wave10-config-hygiene-design.md`. + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` + +Return the PR URL. + +--- + +## Self-review notes + +- **Spec coverage:** Section A (#97 URLs) → Tasks 1 + 2. Section B (#98 scanId) → Tasks 3 + 4. Verification gates → Task 5. +- **Tests covered:** Spec requires (a) defaults/override/whitespace for `config.ts` (Task 1 cases 1/2/3), (b) format + uniqueness + monotonic for `scan-id.ts` (Task 3 cases 1/2/3). Both covered. +- **Type consistency:** `RECOST_API_BASE_URL`, `RECOST_DASHBOARD_BASE_URL`, `newLocalScanId()` names match across spec, plan, and call sites. +- **Risk:** Task 2 Step 4 changes the dashboard URL template from a plain string to a template literal — TypeScript accepts both as `string`, so no callers break. Task 4's 5 sites in `scan-publishing-handler.ts` assume current line numbers (665/677/687/702/800) — the grep gate at Step 4 catches misses if line numbers have drifted. +- **Rebase scenario:** If Wave 8 merges first, Task 4 Step 3's note flags the 6th site to migrate (the 429 branch). The grep gate enforces this. From 6026686013f81fe8c9c8b27275662777f3172cce Mon Sep 17 00:00:00 2001 From: AndresL230 <190146319+AndresL230@users.noreply.github.com> Date: Sat, 23 May 2026 20:10:23 -0400 Subject: [PATCH 3/8] feat(wave10): centralize ReCost base URLs with env-var override (#97) Co-Authored-By: Claude Sonnet 4.6 --- package.json | 2 +- src/config.ts | 8 +++++ src/test/config.test.ts | 64 +++++++++++++++++++++++++++++++++++++ tsconfig.scanner-tests.json | 2 +- 4 files changed, 74 insertions(+), 2 deletions(-) create mode 100644 src/config.ts create mode 100644 src/test/config.test.ts diff --git a/package.json b/package.json index 6a6e1c2..c76e1d7 100644 --- a/package.json +++ b/package.json @@ -198,7 +198,7 @@ "build:webview": "cd webview && npm run build", "build:dashboard": "cd dashboard && npm run build && rm -rf ../dashboard-dist && cp -r dist ../dashboard-dist", "test": "npm run test:scanner", - "test:scanner": "tsc -p tsconfig.scanner-tests.json && tsc -p tsconfig.benchmark.json && node dist-test/test/scanner-patterns.test.js && node dist-test/test/workspace-scanner.test.js && node dist-test/test/workspace-file-access.test.js && node dist-test/test/endpoint-classification.test.js && node dist-test/test/local-waste-detector.test.js && node dist-test/test/chat-providers.test.js && node dist-test/test/fingerprint-registry.test.js && node dist-test/test/pricing-sync.test.js && node dist-test/test/ast-parser-loader.test.js && node dist-test/test/ast-call-visitor.test.js && node dist-test/test/ast-import-resolver.test.js && node dist-test/test/ast-scanner.test.js && node dist-test/test/ast-python.test.js && node dist-test/test/ast-frequency-analyzer.test.js && node dist-test/test/ast-cache-detector.test.js && node dist-test/test/ast-batch-detector.test.js && node dist-test/test/ast-concurrency-detector.test.js && node dist-test/test/ast-cross-file-resolver.test.js && node dist-test/test/a1-multi-hop-wrappers.test.js && node dist-test/intelligence/__tests__/builder.test.js && node dist-test/intelligence/__tests__/clusters.test.js && node dist-test/intelligence/__tests__/compression.test.js && node dist-test/intelligence/__tests__/export.test.js && node dist-test/test/api-client.test.js && node dist-test/test/key-management.test.js && node dist-test/test/ast-parser-loader-fallback.test.js && node dist-test/intelligence/__tests__/cost-utils.test.js && node dist-test/test/intelligence-compression-async.test.js && node dist-test/test/webview-provider-dispatch.test.js && node dist-test/test/extension-activation.test.js && node dist-test/test/source-span.test.js && node dist-test/test/url-template.test.js && node dist-test/test/enclosing-function.test.js && node dist-test/test/endpoint-id.test.js && node dist-test/test/parity.test.js && node dist-test/test/a6-object-literal-fps.test.js && node dist-test/test/a2-const-fold.test.js && node dist-test/test/a7-url-path-fallback.test.js && node dist-test/test/c1-pr2-cache-tightening.test.js && node dist-test/test/c1-pr3-batch-tightening.test.js && node dist-test/src/test/benchmark-schema.test.js && node dist-test/src/test/benchmark-metrics.test.js && node dist-test/test/c1-pr4-rate-limit-tightening.test.js && node dist-test/test/c1-pr4-batch-residual.test.js && node dist-test/test/pre-a-scanfiles-resolution.test.js && node dist-test/test/pre-b-export-const-tracking.test.js && node dist-test/test/a3-barrel-reexports.test.js && node dist-test/test/a5-factory-di-aliased.test.js && node dist-test/test/wave6-pr1-submit-filter.test.js && node dist-test/test/scan-publishing-handler.test.js", + "test:scanner": "tsc -p tsconfig.scanner-tests.json && tsc -p tsconfig.benchmark.json && node dist-test/test/scanner-patterns.test.js && node dist-test/test/workspace-scanner.test.js && node dist-test/test/workspace-file-access.test.js && node dist-test/test/endpoint-classification.test.js && node dist-test/test/local-waste-detector.test.js && node dist-test/test/chat-providers.test.js && node dist-test/test/fingerprint-registry.test.js && node dist-test/test/pricing-sync.test.js && node dist-test/test/ast-parser-loader.test.js && node dist-test/test/ast-call-visitor.test.js && node dist-test/test/ast-import-resolver.test.js && node dist-test/test/ast-scanner.test.js && node dist-test/test/ast-python.test.js && node dist-test/test/ast-frequency-analyzer.test.js && node dist-test/test/ast-cache-detector.test.js && node dist-test/test/ast-batch-detector.test.js && node dist-test/test/ast-concurrency-detector.test.js && node dist-test/test/ast-cross-file-resolver.test.js && node dist-test/test/a1-multi-hop-wrappers.test.js && node dist-test/intelligence/__tests__/builder.test.js && node dist-test/intelligence/__tests__/clusters.test.js && node dist-test/intelligence/__tests__/compression.test.js && node dist-test/intelligence/__tests__/export.test.js && node dist-test/test/api-client.test.js && node dist-test/test/key-management.test.js && node dist-test/test/ast-parser-loader-fallback.test.js && node dist-test/intelligence/__tests__/cost-utils.test.js && node dist-test/test/intelligence-compression-async.test.js && node dist-test/test/webview-provider-dispatch.test.js && node dist-test/test/extension-activation.test.js && node dist-test/test/source-span.test.js && node dist-test/test/url-template.test.js && node dist-test/test/enclosing-function.test.js && node dist-test/test/endpoint-id.test.js && node dist-test/test/parity.test.js && node dist-test/test/a6-object-literal-fps.test.js && node dist-test/test/a2-const-fold.test.js && node dist-test/test/a7-url-path-fallback.test.js && node dist-test/test/c1-pr2-cache-tightening.test.js && node dist-test/test/c1-pr3-batch-tightening.test.js && node dist-test/src/test/benchmark-schema.test.js && node dist-test/src/test/benchmark-metrics.test.js && node dist-test/test/c1-pr4-rate-limit-tightening.test.js && node dist-test/test/c1-pr4-batch-residual.test.js && node dist-test/test/pre-a-scanfiles-resolution.test.js && node dist-test/test/pre-b-export-const-tracking.test.js && node dist-test/test/a3-barrel-reexports.test.js && node dist-test/test/a5-factory-di-aliased.test.js && node dist-test/test/wave6-pr1-submit-filter.test.js && node dist-test/test/scan-publishing-handler.test.js && node dist-test/test/config.test.js", "calibrate-detectors": "tsc -p tsconfig.scanner-tests.json && node dist-test/test/waste-calibration.js", "watch:ext": "node esbuild.mjs --watch", "watch:webview": "cd webview && npm run build -- --watch", diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..1366821 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,8 @@ +const PROD_API_BASE_URL = "https://api.recost.dev"; +const PROD_DASHBOARD_BASE_URL = "https://recost.dev"; + +export const RECOST_API_BASE_URL = + process.env.RECOST_API_BASE_URL?.trim() || PROD_API_BASE_URL; + +export const RECOST_DASHBOARD_BASE_URL = + process.env.RECOST_DASHBOARD_BASE_URL?.trim() || PROD_DASHBOARD_BASE_URL; diff --git a/src/test/config.test.ts b/src/test/config.test.ts new file mode 100644 index 0000000..e1b28df --- /dev/null +++ b/src/test/config.test.ts @@ -0,0 +1,64 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import * as path from "node:path"; + +// Resolve the compiled config.js relative to this test file's compiled output. +// Tests compile to dist-test/test/config.test.js; config.js compiles to dist-test/config.js. +const CONFIG_PATH = path.resolve(__dirname, "..", "config.js"); + +function runConfigInSubprocess(env: Record): { + api: string; + dashboard: string; +} { + const script = ` + const c = require(${JSON.stringify(CONFIG_PATH)}); + process.stdout.write(JSON.stringify({ + api: c.RECOST_API_BASE_URL, + dashboard: c.RECOST_DASHBOARD_BASE_URL, + })); + `; + const child = spawnSync(process.execPath, ["-e", script], { + env: { ...process.env, ...env, RECOST_API_BASE_URL: env.RECOST_API_BASE_URL ?? "", RECOST_DASHBOARD_BASE_URL: env.RECOST_DASHBOARD_BASE_URL ?? "" }, + encoding: "utf8", + }); + if (child.status !== 0) { + throw new Error(`config subprocess exited ${child.status}: ${child.stderr}`); + } + return JSON.parse(child.stdout); +} + +async function runTests() { + // 1. Defaults apply when env vars are unset (passed as empty strings -> trimmed to falsy -> fall back) + { + const result = runConfigInSubprocess({}); + assert.equal(result.api, "https://api.recost.dev"); + assert.equal(result.dashboard, "https://recost.dev"); + } + + // 2. Env override applies cleanly + { + const result = runConfigInSubprocess({ + RECOST_API_BASE_URL: "https://staging.api.recost.dev", + RECOST_DASHBOARD_BASE_URL: "https://staging.recost.dev", + }); + assert.equal(result.api, "https://staging.api.recost.dev"); + assert.equal(result.dashboard, "https://staging.recost.dev"); + } + + // 3. Whitespace-only env vars fall back to defaults + { + const result = runConfigInSubprocess({ + RECOST_API_BASE_URL: " ", + RECOST_DASHBOARD_BASE_URL: "\t\n", + }); + assert.equal(result.api, "https://api.recost.dev"); + assert.equal(result.dashboard, "https://recost.dev"); + } + + console.log("PASS config"); +} + +runTests().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/tsconfig.scanner-tests.json b/tsconfig.scanner-tests.json index dcbf1f3..0a8f9c0 100644 --- a/tsconfig.scanner-tests.json +++ b/tsconfig.scanner-tests.json @@ -4,6 +4,6 @@ "rootDir": "src", "outDir": "dist-test" }, - "include": ["src/scanner/**/*", "src/ast/**/*", "src/intelligence/**/*", "src/test/**/*", "src/workspace-file-access.ts"], + "include": ["src/scanner/**/*", "src/ast/**/*", "src/intelligence/**/*", "src/test/**/*", "src/workspace-file-access.ts", "src/config.ts"], "exclude": ["node_modules", "dist", "webview", "dashboard", "dashboard-dist", "src/test/fixtures", "src/test/benchmark-*.test.ts"] } From 9e77f8c33bf7c7715bcc3e5911b7d1a699a66d78 Mon Sep 17 00:00:00 2001 From: AndresL230 <190146319+AndresL230@users.noreply.github.com> Date: Sat, 23 May 2026 20:14:57 -0400 Subject: [PATCH 4/8] refactor(wave10): use RECOST_*_BASE_URL constants at all call sites (#97) Co-Authored-By: Claude Sonnet 4.6 --- src/api-client.ts | 3 ++- src/chat/providers/eco.ts | 3 ++- src/extension.ts | 5 +++-- src/webview-provider.ts | 5 +++-- 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/api-client.ts b/src/api-client.ts index 22f72fd..0eea290 100644 --- a/src/api-client.ts +++ b/src/api-client.ts @@ -1,6 +1,7 @@ import type { ApiCallInput, EndpointRecord, Suggestion, ScanSummary } from "./analysis/types"; +import { RECOST_API_BASE_URL } from "./config"; -const BASE_URL = "https://api.recost.dev"; +const BASE_URL = RECOST_API_BASE_URL; interface ApiError { error?: { message?: string }; diff --git a/src/chat/providers/eco.ts b/src/chat/providers/eco.ts index 973fe83..a789a60 100644 --- a/src/chat/providers/eco.ts +++ b/src/chat/providers/eco.ts @@ -1,10 +1,11 @@ import { ChatAdapterError, ensureStringContent } from "../errors"; import type { ChatProviderAdapter, HttpErrorContext, NormalizedChatResponse } from "../types"; +import { RECOST_API_BASE_URL } from "../../config"; export const ecoAdapter: ChatProviderAdapter = { id: "recost", displayName: "ReCost AI", - baseUrl: "https://api.recost.dev", + baseUrl: RECOST_API_BASE_URL, defaultChatEndpoint: "/chat", authHeaderFormat: "none", supportsStreaming: false, diff --git a/src/extension.ts b/src/extension.ts index 85268a0..b085e35 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,4 +1,5 @@ import * as vscode from "vscode"; +import { RECOST_API_BASE_URL, RECOST_DASHBOARD_BASE_URL } from "./config"; import { ReCostSidebarProvider, collectLocalScanData } from "./webview-provider"; import { validateApiKey } from "./api-client"; import { syncPricingFromBackend } from "./scanner/fingerprints/registry"; @@ -14,8 +15,8 @@ import { setIncludeTestFiles as setScorerTestFiles } from "./intelligence/scorer import { setIncludeTestFiles as setClusterTestFiles } from "./intelligence/clusters"; const ECO_API_KEY = "recost.apiKey"; -const GET_KEY_URL = "https://recost.dev/dashboard/account"; -const PRICING_BACKEND_URL = "https://api.recost.dev"; +const GET_KEY_URL = `${RECOST_DASHBOARD_BASE_URL}/dashboard/account`; +const PRICING_BACKEND_URL = RECOST_API_BASE_URL; const DEFAULT_SYNC_INTERVAL_HOURS = 6; const KEY_VALIDATION_STATE_STORAGE_KEY = "recost.keyValidationState"; diff --git a/src/webview-provider.ts b/src/webview-provider.ts index 4e9bfa5..6f0b442 100644 --- a/src/webview-provider.ts +++ b/src/webview-provider.ts @@ -34,6 +34,7 @@ import { ChatHandler } from "./webview/chat-handler"; import { KeyManagementHandler } from "./webview/key-management-handler"; import { SimulationHandler } from "./webview/simulation-handler"; import { ScanPublishingHandler, type ExportDebugPayload } from "./webview/scan-publishing-handler"; +import { RECOST_DASHBOARD_BASE_URL } from "./config"; async function resolveWorkspaceFileSafely( workspaceFolder: vscode.WorkspaceFolder, @@ -731,8 +732,8 @@ export class ReCostSidebarProvider implements vscode.WebviewViewProvider { : null; const url = targetProjectId - ? `https://recost.dev/dashboard/projects/${targetProjectId}` - : "https://recost.dev/dashboard/projects"; + ? `${RECOST_DASHBOARD_BASE_URL}/dashboard/projects/${targetProjectId}` + : `${RECOST_DASHBOARD_BASE_URL}/dashboard/projects`; await vscode.env.openExternal(vscode.Uri.parse(url)); } catch (err: unknown) { const message = err instanceof Error ? err.message : "Failed to open dashboard"; From 568c6d45be405b1b30c9bd5f95c742d62fed0471 Mon Sep 17 00:00:00 2001 From: AndresL230 <190146319+AndresL230@users.noreply.github.com> Date: Sat, 23 May 2026 20:22:12 -0400 Subject: [PATCH 5/8] feat(wave10): collision-resistant newLocalScanId() helper (#98) Co-Authored-By: Claude Sonnet 4.6 --- package.json | 2 +- src/scan-id.ts | 6 ++++++ src/test/scan-id.test.ts | 39 +++++++++++++++++++++++++++++++++++++ tsconfig.scanner-tests.json | 2 +- 4 files changed, 47 insertions(+), 2 deletions(-) create mode 100644 src/scan-id.ts create mode 100644 src/test/scan-id.test.ts diff --git a/package.json b/package.json index c76e1d7..cf18e0e 100644 --- a/package.json +++ b/package.json @@ -198,7 +198,7 @@ "build:webview": "cd webview && npm run build", "build:dashboard": "cd dashboard && npm run build && rm -rf ../dashboard-dist && cp -r dist ../dashboard-dist", "test": "npm run test:scanner", - "test:scanner": "tsc -p tsconfig.scanner-tests.json && tsc -p tsconfig.benchmark.json && node dist-test/test/scanner-patterns.test.js && node dist-test/test/workspace-scanner.test.js && node dist-test/test/workspace-file-access.test.js && node dist-test/test/endpoint-classification.test.js && node dist-test/test/local-waste-detector.test.js && node dist-test/test/chat-providers.test.js && node dist-test/test/fingerprint-registry.test.js && node dist-test/test/pricing-sync.test.js && node dist-test/test/ast-parser-loader.test.js && node dist-test/test/ast-call-visitor.test.js && node dist-test/test/ast-import-resolver.test.js && node dist-test/test/ast-scanner.test.js && node dist-test/test/ast-python.test.js && node dist-test/test/ast-frequency-analyzer.test.js && node dist-test/test/ast-cache-detector.test.js && node dist-test/test/ast-batch-detector.test.js && node dist-test/test/ast-concurrency-detector.test.js && node dist-test/test/ast-cross-file-resolver.test.js && node dist-test/test/a1-multi-hop-wrappers.test.js && node dist-test/intelligence/__tests__/builder.test.js && node dist-test/intelligence/__tests__/clusters.test.js && node dist-test/intelligence/__tests__/compression.test.js && node dist-test/intelligence/__tests__/export.test.js && node dist-test/test/api-client.test.js && node dist-test/test/key-management.test.js && node dist-test/test/ast-parser-loader-fallback.test.js && node dist-test/intelligence/__tests__/cost-utils.test.js && node dist-test/test/intelligence-compression-async.test.js && node dist-test/test/webview-provider-dispatch.test.js && node dist-test/test/extension-activation.test.js && node dist-test/test/source-span.test.js && node dist-test/test/url-template.test.js && node dist-test/test/enclosing-function.test.js && node dist-test/test/endpoint-id.test.js && node dist-test/test/parity.test.js && node dist-test/test/a6-object-literal-fps.test.js && node dist-test/test/a2-const-fold.test.js && node dist-test/test/a7-url-path-fallback.test.js && node dist-test/test/c1-pr2-cache-tightening.test.js && node dist-test/test/c1-pr3-batch-tightening.test.js && node dist-test/src/test/benchmark-schema.test.js && node dist-test/src/test/benchmark-metrics.test.js && node dist-test/test/c1-pr4-rate-limit-tightening.test.js && node dist-test/test/c1-pr4-batch-residual.test.js && node dist-test/test/pre-a-scanfiles-resolution.test.js && node dist-test/test/pre-b-export-const-tracking.test.js && node dist-test/test/a3-barrel-reexports.test.js && node dist-test/test/a5-factory-di-aliased.test.js && node dist-test/test/wave6-pr1-submit-filter.test.js && node dist-test/test/scan-publishing-handler.test.js && node dist-test/test/config.test.js", + "test:scanner": "tsc -p tsconfig.scanner-tests.json && tsc -p tsconfig.benchmark.json && node dist-test/test/scanner-patterns.test.js && node dist-test/test/workspace-scanner.test.js && node dist-test/test/workspace-file-access.test.js && node dist-test/test/endpoint-classification.test.js && node dist-test/test/local-waste-detector.test.js && node dist-test/test/chat-providers.test.js && node dist-test/test/fingerprint-registry.test.js && node dist-test/test/pricing-sync.test.js && node dist-test/test/ast-parser-loader.test.js && node dist-test/test/ast-call-visitor.test.js && node dist-test/test/ast-import-resolver.test.js && node dist-test/test/ast-scanner.test.js && node dist-test/test/ast-python.test.js && node dist-test/test/ast-frequency-analyzer.test.js && node dist-test/test/ast-cache-detector.test.js && node dist-test/test/ast-batch-detector.test.js && node dist-test/test/ast-concurrency-detector.test.js && node dist-test/test/ast-cross-file-resolver.test.js && node dist-test/test/a1-multi-hop-wrappers.test.js && node dist-test/intelligence/__tests__/builder.test.js && node dist-test/intelligence/__tests__/clusters.test.js && node dist-test/intelligence/__tests__/compression.test.js && node dist-test/intelligence/__tests__/export.test.js && node dist-test/test/api-client.test.js && node dist-test/test/key-management.test.js && node dist-test/test/ast-parser-loader-fallback.test.js && node dist-test/intelligence/__tests__/cost-utils.test.js && node dist-test/test/intelligence-compression-async.test.js && node dist-test/test/webview-provider-dispatch.test.js && node dist-test/test/extension-activation.test.js && node dist-test/test/source-span.test.js && node dist-test/test/url-template.test.js && node dist-test/test/enclosing-function.test.js && node dist-test/test/endpoint-id.test.js && node dist-test/test/parity.test.js && node dist-test/test/a6-object-literal-fps.test.js && node dist-test/test/a2-const-fold.test.js && node dist-test/test/a7-url-path-fallback.test.js && node dist-test/test/c1-pr2-cache-tightening.test.js && node dist-test/test/c1-pr3-batch-tightening.test.js && node dist-test/src/test/benchmark-schema.test.js && node dist-test/src/test/benchmark-metrics.test.js && node dist-test/test/c1-pr4-rate-limit-tightening.test.js && node dist-test/test/c1-pr4-batch-residual.test.js && node dist-test/test/pre-a-scanfiles-resolution.test.js && node dist-test/test/pre-b-export-const-tracking.test.js && node dist-test/test/a3-barrel-reexports.test.js && node dist-test/test/a5-factory-di-aliased.test.js && node dist-test/test/wave6-pr1-submit-filter.test.js && node dist-test/test/scan-publishing-handler.test.js && node dist-test/test/config.test.js && node dist-test/test/scan-id.test.js", "calibrate-detectors": "tsc -p tsconfig.scanner-tests.json && node dist-test/test/waste-calibration.js", "watch:ext": "node esbuild.mjs --watch", "watch:webview": "cd webview && npm run build -- --watch", diff --git a/src/scan-id.ts b/src/scan-id.ts new file mode 100644 index 0000000..9cb90ba --- /dev/null +++ b/src/scan-id.ts @@ -0,0 +1,6 @@ +import { randomUUID } from "crypto"; + +export function newLocalScanId(): string { + const suffix = randomUUID().replace(/-/g, "").slice(0, 8); + return `local-${Date.now()}-${suffix}`; +} diff --git a/src/test/scan-id.test.ts b/src/test/scan-id.test.ts new file mode 100644 index 0000000..9e60863 --- /dev/null +++ b/src/test/scan-id.test.ts @@ -0,0 +1,39 @@ +import assert from "node:assert/strict"; +import { newLocalScanId } from "../scan-id"; + +const FORMAT = /^local-\d+-[0-9a-f]{8}$/; + +async function runTests() { + // 1. Format: matches local--<8 hex chars> + { + const id = newLocalScanId(); + assert.match(id, FORMAT, `expected ${id} to match ${FORMAT}`); + } + + // 2. Uniqueness: 1000 sequential calls produce 1000 distinct IDs + { + const ids = new Set(); + for (let i = 0; i < 1000; i++) { + ids.add(newLocalScanId()); + } + assert.equal(ids.size, 1000, `expected 1000 unique IDs, got ${ids.size}`); + } + + // 3. Timestamp prefix is non-decreasing across 100 calls + { + let prev = 0; + for (let i = 0; i < 100; i++) { + const id = newLocalScanId(); + const ts = Number(id.split("-")[1]); + assert.ok(ts >= prev, `timestamp regressed: ${ts} < ${prev}`); + prev = ts; + } + } + + console.log("PASS scan-id"); +} + +runTests().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/tsconfig.scanner-tests.json b/tsconfig.scanner-tests.json index 0a8f9c0..1756b4b 100644 --- a/tsconfig.scanner-tests.json +++ b/tsconfig.scanner-tests.json @@ -4,6 +4,6 @@ "rootDir": "src", "outDir": "dist-test" }, - "include": ["src/scanner/**/*", "src/ast/**/*", "src/intelligence/**/*", "src/test/**/*", "src/workspace-file-access.ts", "src/config.ts"], + "include": ["src/scanner/**/*", "src/ast/**/*", "src/intelligence/**/*", "src/test/**/*", "src/workspace-file-access.ts", "src/config.ts", "src/scan-id.ts"], "exclude": ["node_modules", "dist", "webview", "dashboard", "dashboard-dist", "src/test/fixtures", "src/test/benchmark-*.test.ts"] } From 68a5be235c6e6941817556bbf04ade84319fcaaf Mon Sep 17 00:00:00 2001 From: AndresL230 <190146319+AndresL230@users.noreply.github.com> Date: Sat, 23 May 2026 20:25:41 -0400 Subject: [PATCH 6/8] docs(wave10): JSDoc newLocalScanId for Task 4 call sites --- src/scan-id.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/scan-id.ts b/src/scan-id.ts index 9cb90ba..70eaa2a 100644 --- a/src/scan-id.ts +++ b/src/scan-id.ts @@ -1,5 +1,11 @@ import { randomUUID } from "crypto"; +/** + * Returns a locally-generated scan ID for use when no remote scan ID is + * available (no key configured, submission failed, offline mode). Format: + * `local--<8 hex chars>`. The random suffix prevents collisions + * when two scans land in the same millisecond. + */ export function newLocalScanId(): string { const suffix = randomUUID().replace(/-/g, "").slice(0, 8); return `local-${Date.now()}-${suffix}`; From d5c7ac85549fe00d0778fbd7bbdb7215cd19da93 Mon Sep 17 00:00:00 2001 From: AndresL230 <190146319+AndresL230@users.noreply.github.com> Date: Sat, 23 May 2026 20:28:46 -0400 Subject: [PATCH 7/8] refactor(wave10): use newLocalScanId() at all local-scanId call sites (#98) Co-Authored-By: Claude Sonnet 4.6 --- src/cli/scan.ts | 3 ++- src/webview/chat-handler.ts | 3 ++- src/webview/scan-publishing-handler.ts | 11 ++++++----- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/cli/scan.ts b/src/cli/scan.ts index 8e6bc93..57509ae 100644 --- a/src/cli/scan.ts +++ b/src/cli/scan.ts @@ -1,5 +1,6 @@ import fs from "node:fs"; import * as path from "path"; +import { newLocalScanId } from "../scan-id"; import { createFilesystemScanAccess } from "./filesystem-adapter"; import { detectLocalWastePatternsInFiles, scanFiles } from "../scanner/core-scanner"; import { createProject, getAllEndpoints, getAllSuggestions, submitScan } from "../api-client"; @@ -226,7 +227,7 @@ async function main(): Promise { const localWasteFindings = await detectLocalWastePatternsInFiles(access); let mode: CliResult["mode"] = "local-only"; let projectId = "local"; - let scanId = `local-${Date.now()}`; + let scanId = newLocalScanId(); let finalResults = buildLocalScanResults(apiCalls, localWasteFindings, projectId, scanId); const rcApiKey = resolveRcApiKey(); diff --git a/src/webview/chat-handler.ts b/src/webview/chat-handler.ts index 0a0cbe8..fd04099 100644 --- a/src/webview/chat-handler.ts +++ b/src/webview/chat-handler.ts @@ -16,6 +16,7 @@ import { type NormalizedChatMessage, type NormalizedChatRequest, } from "../chat"; +import { newLocalScanId } from "../scan-id"; // Local copies of small pure helpers used here. Avoid importing from // webview-provider.ts to prevent a circular import. Originals remain in // webview-provider.ts where non-chat code also uses them. @@ -411,7 +412,7 @@ export class ChatHandler { private mapAiFindingToSuggestion(finding: AiFinding, index: number): Suggestion { const lastEndpoints = this.ctx.getLastEndpoints(); const providerProjectId = this.ctx.getProjectId(); - const scanId = lastEndpoints[0]?.scanId ?? providerProjectId ?? `local-${Date.now()}`; + const scanId = lastEndpoints[0]?.scanId ?? providerProjectId ?? newLocalScanId(); const projectId = lastEndpoints[0]?.projectId ?? providerProjectId ?? "local"; const fileEndpoints = lastEndpoints.filter((ep) => ep.files.includes(finding.affectedFile)); const related = fileEndpoints.map((endpoint) => endpoint.id); diff --git a/src/webview/scan-publishing-handler.ts b/src/webview/scan-publishing-handler.ts index 2bb5d40..4e8751f 100644 --- a/src/webview/scan-publishing-handler.ts +++ b/src/webview/scan-publishing-handler.ts @@ -17,6 +17,7 @@ import { estimateLocalMonthlyCost } from "../intelligence/cost-utils"; import { buildKeyFingerprint, type PersistedKeyValidationSnapshot } from "../key-management"; import { getOutputChannel } from "../output"; import { buildRemoteApiCalls } from "./build-remote-api-calls"; +import { newLocalScanId } from "../scan-id"; export interface ExportDebugPayload { mode: "local-only" | "remote-enriched"; @@ -663,7 +664,7 @@ export class ScanPublishingHandler { remote: null, final: { projectId: "local", - scanId: `local-${Date.now()}`, + scanId: newLocalScanId(), endpoints: [], suggestions: [], summary: emptySummary, @@ -675,7 +676,7 @@ export class ScanPublishingHandler { const manualProjectId = this.ctx.getManualProjectId(); let rcApiKey = await this.ctx.getRcApiKey(); if (!rcApiKey) { - publishLocalOnlyResults(manualProjectId ?? this.ctx.getProjectId() ?? "local", `local-${Date.now()}`); + publishLocalOnlyResults(manualProjectId ?? this.ctx.getProjectId() ?? "local", newLocalScanId()); this.ctx.postMessage({ type: "scanNotification", message: "No ReCost API key — showing local results only. Add a key in Keys to enable remote sync.", @@ -685,7 +686,7 @@ export class ScanPublishingHandler { const { submitted: remoteApiCalls, unknownProviderCount, unknownProviderHosts } = buildRemoteApiCalls(apiCalls); if (remoteApiCalls.length === 0) { - publishLocalOnlyResults(manualProjectId ?? this.ctx.getProjectId() ?? "local", `local-${Date.now()}`); + publishLocalOnlyResults(manualProjectId ?? this.ctx.getProjectId() ?? "local", newLocalScanId()); return; } if (unknownProviderCount > 0) { @@ -700,7 +701,7 @@ export class ScanPublishingHandler { }); } - publishLocalOnlyResults(manualProjectId ?? this.ctx.getProjectId() ?? "local", `local-${Date.now()}`); + publishLocalOnlyResults(manualProjectId ?? this.ctx.getProjectId() ?? "local", newLocalScanId()); try { const projectTarget = await this.ctx.resolveScanProjectTarget(rcApiKey); @@ -814,7 +815,7 @@ export class ScanPublishingHandler { this.ctx.refreshStatusBar(); this.ctx.openKeys("recost"); } - publishLocalOnlyResults(manualProjectId ?? this.ctx.getProjectId() ?? "local", `local-${Date.now()}`); + publishLocalOnlyResults(manualProjectId ?? this.ctx.getProjectId() ?? "local", newLocalScanId()); if (status === 404 && manualProjectId) { this.ctx.postMessage({ type: "scanNotification", From 20a0771763017f6ffa0bcf60bed37b824f17be89 Mon Sep 17 00:00:00 2001 From: AndresL230 <190146319+AndresL230@users.noreply.github.com> Date: Sat, 23 May 2026 21:50:17 -0400 Subject: [PATCH 8/8] refactor(wave10): migrate 8th scanId site added by wave 8 429 branch (#98) --- src/webview/scan-publishing-handler.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/webview/scan-publishing-handler.ts b/src/webview/scan-publishing-handler.ts index 4e8751f..c34213e 100644 --- a/src/webview/scan-publishing-handler.ts +++ b/src/webview/scan-publishing-handler.ts @@ -791,7 +791,7 @@ export class ScanPublishingHandler { type: "scanNotification", message: `ReCost scan rate limit reached. ${waitText} Showing local results.`, }); - publishLocalOnlyResults(manualProjectId ?? this.ctx.getProjectId() ?? "local", `local-${Date.now()}`); + publishLocalOnlyResults(manualProjectId ?? this.ctx.getProjectId() ?? "local", newLocalScanId()); return; }