Files
openclaude/scripts/any-usage-report.ts
T
6d09260291 chore(types): reduce any usage with safety tooling + 7-batch phase 1 (#2208)
* chore(types): add any-usage baseline + lint budget gate (Phase 0)

Phase 0 of a multi-phase plan to reduce explicit `any` usage safely.
No source files modified in this PR.

- Add eslint@9 + @typescript-eslint deps (lint only, no fix rules).
- New minimal flat eslint.config.js enabling only
  `@typescript-eslint/no-explicit-any` as warn. Stub plugins registered
  for legacy `custom-rules`, `eslint-plugin-n`, and `react-hooks`
  references in existing `// eslint-disable` comments so the comments
  stay valid (no rule name resolution errors).
- New `scripts/any-usage-report.ts`: walks src/, tests/, scripts/ and
  buckets `any` usage into `: any` annotations, `as any` casts,
  `[key: ...]: any` index signatures, `// @ts-ignore` directives, and
  generic `any` args. Emits `reports/any-usage.{json,md}`. `--check`
  mode exits non-zero if the count grows vs the baseline.
- Lock the baseline at 1,031 occurrences across 198 files.
- Add `lint`, `lint:any-budget`, `check:strict` scripts.
- Plan: docs/plans/imperative-hugging-sphinx.md

Verification:
- `bun run typecheck` clean
- `bun run lint` exit 0 (845 warnings, 0 errors)
- `bun run lint:any-budget` ok (current=1031 baseline=1031)

* chore(types): narrow any in 6 small src files (Phase 1, batch 1)

Phase 1 of the plan to reduce `any` usage. Targets only files with
1–2 lint hits where the concrete type is reachable in scope.

- src/services/api/withRetry.ts: isQuotaExhausted signature `any` → `unknown`,
  add local structural narrowing for `.message`/`.status` access.
- src/utils/validation.ts: assertFunction predicate signature `any[]`/`any` →
  `unknown[]`/`unknown`.
- src/utils/conversationArc.ts: extractTextFromContent — replace `(block: any)`
  in filter/map with explicit type predicate narrowing.
- src/utils/optionalRuntimeModule.ts: dynamic-import wrapper returns
  `Promise<unknown>` instead of `Promise<any>`.
- src/entrypoints/mcp.ts: tighten block mapping with the existing structural
  type already in scope; replace `(finalResult as any).isError` with a
  narrow cast.
- src/components/EffortPicker.tsx: drop redundant `: any` on useAppState
  selector — the function's generic infers `AppState` for free.

Verification:
- bun run typecheck clean
- bun run lint: 835 warnings (was 845)
- bun run lint:any-budget: 1022 / 1031 baseline (delta -9)
- bun test src/utils/optionalRuntimeModule.test.ts + conversationArc.test.ts:
  36 pass / 0 fail
- bun test src/services/api/withRetry.test.ts: 44 pass / 0 fail

* chore(types): narrow any in WebSearchTool + adapters (Phase 1, batch 2)

Phase 1 continues. These are all boundary code (HTTP responses from
external search APIs), so every `any` is at a module boundary per the
plan. Each adapter now types its response with `unknown` + structural
narrowing instead of `any`.

- providers/types.ts: firstMatch/normalizeHit accept `unknown` and narrow
  to `Record<string, unknown>` before index access.
- providers/timeout.ts: fetchJsonWithWebSearchTimeout returns
  Promise<unknown>.
- providers/{bing,brave,jina,linkup,mojeek,tavily,you,exa,custom}.ts: each
  adapter's response data is cast to a minimal shape (`{ results?: unknown }`
  etc.), then array elements are narrowed to `Record<string, unknown>` with
  `typeof` checks per field.
- WebSearchTool.ts: getCodexSources and extractCodexWebSearchFailure now
  take `Record<string, unknown>`; nested `action`/`error`/`result` are
  cast locally to `Record<string, unknown>` so `?.field` chains type-check.

Verification:
- bun run typecheck clean
- bun run lint: 805 warnings (was 835, -30)
- bun run lint:any-budget: 992 / 1022 baseline
- bun test src/tools/WebSearchTool: 138 pass / 0 fail across 9 files

* chore(types): narrow any in 6 mid-size src files (Phase 1, batch 3)

Phase 1 continues. Each replacement uses a precise structural cast
(`as { uuid?: string }` etc.) or `unknown` with typeof/Array.isArray
narrows at use sites.

- src/utils/settings/types.ts: zod preprocess callback `any` → `unknown`,
  copy via `Record<string, unknown>`.
- src/entrypoints/sdk/v2.ts: initialMessages param/var `any[]` → `Message[]`;
  local cast at the boundary with stripChainFields.
- src/entrypoints/sdk/query.ts: msg `.uuid` access uses
  `as { uuid?: string }`; `fileHistoryCanRestore` etc. accept `UUID` (from
  `crypto`); `mcpServerStatus` accesses `client.error` / `client.config`
  directly since the union members already have them.
- src/commands/cache-probe/cache-probe.ts: getField reduce `any` → `unknown`
  with typeof guard; catch (err: any) → `err: unknown` with
  `err instanceof Error`; mainMakeUsage/mainConvertChunkUsage take `unknown`
  and read fields via typeof checks.
- src/services/compact/snipProjection.ts: snipMetadata / uuid accessed via
  narrow structural casts, no longer `as any`.
- src/utils/messages.ts: tool_use block destructuring for extra_content uses
  `as { extra_content?: unknown; [k: string]: unknown }` instead of
  `as any`.

Public SDK surface (entrypoints/sdk.d.ts, entrypoints/sdk/index.ts
SdkMcpToolDefinition<Schema = any>) is intentionally permissive for
consumer ergonomics and is left untouched per the plan's do-not-touch
list.

Verification:
- bun run typecheck clean
- bun run lint: 782 warnings (was 805, -23)
- bun run lint:any-budget: 969 / 992 baseline
- bun test (sdk v2/factories + compact + messages): 91 pass / 0 fail

* fix(types): correct BetaContentBlock cast in messages.ts (Phase 1 fix)

Verifier caught that the `as { [k: string]: unknown; extra_content?: unknown }`
cast on a `BetaToolUseBlock` did not sufficiently overlap (BetaToolUseBlock
lacks an index signature), so the destructured spread of `restBlock`
yielded a malformed union member that failed to assign to BetaContentBlock[].

Fix: cast through `unknown` first, then assert the final return value as
`BetaContentBlock` (the declared element type of the outer `.map`). This
narrows from the prior `as any` while keeping the previous structural
intent (preserve all block fields, optionally inject `extra_content`).

Verification:
- bun run typecheck clean
- bun test src/utils/messages/: 79 pass / 0 fail

* chore(types): narrow any in 5 dense src + 1 script (Phase 1, batch 4)

Each replacement preserves runtime behavior while using concrete types
where reachable and `unknown` with structural narrowing where not.

- src/components/ClaudeMdExternalIncludesDialog.tsx: 4 helper updaters
  (acceptProject/User, declineProject/User) take `ProjectConfig` from
  `src/utils/config.ts` instead of `any` (matches the
  `saveCurrentProjectConfig` updater signature).
- src/components/ProviderManager.tsx: env-clearing objects in
  `activateGithubProvider` and the GitHub disable path used
  `undefined as any`. The intent is "delete this key on merge" but the
  zod schema coerces to string. Replaced with
  `undefined as unknown as string` (honest about the lie) and added a
  comment explaining intent.
- src/services/compact/snipCompact.ts: introduced a structural
  `SnipMessage` type for the public function params. Tests construct
  fake messages with `uuid: 'u4'` (not branded UUID), so a strict
  `Message[]` would reject them; the structural type accepts both
  production messages and test fixtures. `snipCompactIfNeeded` is
  generic `<T extends SnipMessage>` so callers get the same shape back.
  Internal content blocks use `unknown` + type-predicate narrowing.
- src/query.ts: yield site for the boundary message casts to `Message`
  since the value is constructed as a real `Message` in production.
- scripts/grpc-cli.ts: proto descriptor, stream, and message callback
  typed as `unknown` / `ClientDuplexStream<unknown, unknown>`. The
  dynamic `.proto` payload is cast once at the boundary into a
  structural shape so per-field access (`text_chunk.text` etc.) is
  tracked explicitly.
- scripts/generate-sdk-types.ts: the JSON-Schema-walking converter
  takes `unknown` everywhere and narrows via typeof / Array.isArray at
  each access. `Map<unknown, string>` for placeholder identity
  comparison, `Set<string | undefined>` no longer used here.

Verification:
- bun run typecheck clean
- bun run lint: 728 warnings (was 782, -54)
- bun run lint:any-budget: 916 / 969 baseline
- bun test src/services/compact: 91 pass / 0 fail

* chore(types): remove 3 eslint-disable no-explicit-any suppressions

Tighten three small call sites where the `any` was used as a lazy
escape hatch and a real type was reachable in scope.

- src/main.tsx: `(global as any).require('inspector')` becomes
  `(global as unknown as { require: (s: string) => { url: () => string } })`.
  The dynamic require is being phased out, but while it remains the
  narrow structural cast documents the shape we actually consume.
- src/ink/reconciler.ts: catch (error: any) → catch (error: unknown);
  `error.code === 'ERR_MODULE_NOT_FOUND'` accesses the code via
  `(error as { code?: unknown }).code`.
- src/components/wizard/WizardProvider.tsx:
  `createContext<WizardContextValue<any> | null>(null)` →
  `createContext<WizardContextValue | null>(null)`. The
  `WizardContextValue` generic already defaults to
  `Record<string, unknown>`, so the `<any>` was redundant; consumers
  that need a tighter type can supply it via
  `useContext<WizardContextValue<T>>`.

Note: most remaining lint warnings come from file-level
`/* eslint-disable @typescript-eslint/no-explicit-any */` blocks at the
top of do-not-touch files (src/types/{message,utils,tools}.ts,
src/constants/querySource.ts, src/entrypoints/sdk/controlTypes.ts) and
from public SDK surface (src/entrypoints/sdk{,.d.ts,v2.ts}). Both are
intentional per the plan.

Verification:
- bun run typecheck clean

* chore(types): refresh any-usage baseline

* fix(types): address PR review feedback on any-reduction tooling

- Pin eslint to resolved version (9.39.5) so lint warning totals stay
  stable across lockfile updates.
- Wire lint:any-budget into the regular `check` gate (was only in
  check:strict), so any-usage regressions block PRs.
- any-usage-report: use lstat and skip symlinks so a self-referencing
  link under src/tests/scripts can't stall the budget scan.
- any-usage-report --check: exit nonzero when the baseline file is
  missing or invalid so the gate can't be silently disabled.
- Remove dangling references to docs/plans/imperative-hugging-sphinx.md
  from eslint.config.js and scripts/any-usage-report.ts headers.
- mcp.ts: defensively skip non-object tool-output entries (primitives
  / null) instead of crashing on boundary data.
- messages.ts: realign the mis-indented tool_search-off branch so the
  blockExtra + return block reads at the right level.

No budget delta (913 -> 913). Typecheck, lint, budget, and the affected
unit tests all green.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore(types): narrow any in src/grpc/server.ts (5 warnings)

- protoDescriptor cast: replace `as any` with a structural type that only
  declares the AgentService shape we actually use.
- handleChat: ServerDuplexStream<any, any> -> <unknown, unknown>; the
  clientMessage is narrowed locally where it's read.
- previousMessages: any[] -> Message[] (already imported, already used
  as the type of this.sessions).
- catch (err: any) -> catch (err); err.message is now gated behind an
  instanceof Error check.

Typecheck, lint, budget, and the 2 grpc unit tests all green.
Lint 728 -> 723 (-5). Budget 913 -> 909 (-4).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore(types): narrow any in src/utils/knowledgeGraph.ts (18 warnings)

Add explicit LegacyEntity / LegacyRelation / LegacySummary / LegacyData
types so the migration path stops round-tripping through `any`.

- LegacySource.data, mergeLegacySources, normalizeLegacyData,
  readLegacySqliteStore, SqliteReadResult.data, and doMigration all take
  the new LegacyData shape.
- LegacyData.entities is typed `Record<string, LegacyEntity> | LegacyEntity[]`
  because older JSON dumps stored entities as an array. normalizeLegacyData
  converts the array form to the record form so downstream code can keep
  using Object.entries. (Old loop relied on the same `id ?? entryKey`
  fallback, so behavior is preserved; verified by all 20 unit tests.)
- SQLite row accessor now returns `Record<string, unknown>` (was `any[]`);
  per-row readers narrow `row.id`, `row.attributes`, `row.keywords`,
  `row.content` from `unknown` and the existing typeof guards preserve
  the previous fail-soft behavior on malformed rows.

Lint 723 -> 705 (-18). Budget 909 -> 894 (-15). All 20 knowledgeGraph
unit tests pass; typecheck clean.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore(types): narrow SDK tool() generic and handler signature (12 warnings)

The public SDK MCP tool() helper previously defaulted its Schema generic
to `any` and typed handler args as `any`. Switch both to `unknown`:

- SdkMcpToolDefinition<Schema = unknown>
- tool<Schema = unknown> with handler (args: Schema, extra: unknown)
- annotations?: any -> ToolAnnotations (imported from MCP SDK types)

This is a strictly tighter API: callers who pass a concrete schema get
the same behavior; callers who relied on implicit any now have to
validate handler args (which they should have been doing anyway).

All 4 SDK MCP tool wiring tests pass; typecheck clean.
Lint 705 -> 695 (-10). Budget 894 -> 884 (-10).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(types): address PR feedback on SDK + grpc type hardening

Apply 4 of 5 CodeRabbit review items (skipped item 5: SDK resume flow
JSONL validation, deferred as larger refactor).

- sdk.d.ts: switch MCP type imports to the package's public
  `@modelcontextprotocol/sdk/types.js` subpath (was the internal
  dist/esm/types.js path).
- sdk.d.ts: handler return Promise<any> -> Promise<CallToolResult> in
  both SdkMcpToolDefinition and the tool() helper, matching the
  runtime contract and the sibling declarations in sdk/index.ts and
  sdk/v2.ts.
- grpc/server.ts: introduce a local ClientMessage interface (matching
  the openclaude.proto oneof payload) and type the duplex-stream
  request as ServerDuplexStream<ClientMessage, unknown>. The handler
  body was already reading these fields by name; the type now
  documents them.
- grpc/server.ts: ServiceDefinition generic was being passed a
  ServerDuplexStream, which isn't a valid T (T must be a record
  keyed by method name). Switch to ServiceDefinition<{ Chat: never }>
  so the call to addService accepts a real implementation-map key.

Typecheck, lint, budget, and the 6 affected unit tests all green.
Lint 695 -> 693 (-2). Budget 884 -> 882 (-2).

* fix(types): address 6 reviewer findings on PR #2208

Six findings (four P2, two P3) from the second review pass.

1. knowledgeGraph legacy array normalization: previously dropped entries
   whose `id` was neither string nor number, breaking recovery for
   idless array entries. Use the array index as the fallback key so
   mergeLegacySources' existing `id ?? entryKey` logic continues to
   recover the entry. All 20 unit tests still pass.

2. SDK tool() generic conflated schema descriptor with invocation
   value type, breaking the test fixture (and any external consumer)
   that declares the handler with a concrete value type. Restore
   `args: any` for the handler across the factory, SdkMcpToolDefinition,
   and the .d.ts declaration; keep `Schema` as the generic for
   inputSchema typing.

3. sdk.d.ts now declares ToolAnnotations and CallToolResult locally
   instead of importing from @modelcontextprotocol/sdk, preserving
   the optional peer contract. A consumer without MCP installed no
   longer gets TS2307 on a no-op SDKSession type import.

4. any-usage-report now strips comments, string literals, and
   template-literal contents before applying the annotation regex,
   so `// example: any`, `': any'`, and similar non-type occurrences
   no longer count. @ts-ignore / @ts-expect-error / @ts-nocheck
   directives are preserved for the ignore category. Budget went
   from 882 to 776.

5. generate-sdk-types: schemas module cast as Record<string, () => unknown>
   was wrong because HOOK_EVENTS is a non-callable; cast as
   Record<string, unknown> and narrow per-access. def.getter() and
   Reflect.get(schema, 'description') both narrowed to a callable /
   object shape before use. Generated output unchanged (diff empty).

6. any-usage-report walk(): readdir failures now propagate instead
   of being silently swallowed, so a partial scan cannot publish a
   too-low replacement baseline or pass --check.

Typecheck, lint, budget, and 26 affected unit tests all green.

* chore(types): refresh any-usage baseline after upstream rebase

Baseline 776 -> 779. The +3 comes from upstream #2196 (Command Code
gateway) test mocks merged to main after the baseline was recorded:
2x (payload: any) in ProviderManager.test.tsx, 1x 'as any' in
providerProfiles.test.ts. Symptom-level narrowing of another merged
PR's test fixtures is out of scope; re-baseline to the rebased tree.

---------

Co-authored-by: Gravirei <gravirei@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-21 09:08:34 +08:00

331 lines
10 KiB
TypeScript

// Walk src/, tests/, scripts/ and report `any` usage by category.
// Produces reports/any-usage.{json,md} and a budget gate against
// reports/any-usage.baseline.json.
//
// Part of the phased `any`-reduction work landing on fix/reduce-any-types.
import { mkdir, readFile, writeFile } from 'fs/promises'
import { resolve } from 'path'
const ROOT = process.cwd()
const REPORT_DIR = resolve(ROOT, 'reports')
const JSON_OUT = resolve(REPORT_DIR, 'any-usage.json')
const MD_OUT = resolve(REPORT_DIR, 'any-usage.md')
const BASELINE_PATH = resolve(REPORT_DIR, 'any-usage.baseline.json')
const SCAN_DIRS = ['src', 'tests', 'scripts']
const EXCLUDE_DIR_NAMES = new Set([
'node_modules',
'dist',
'vendor',
'web',
'vscode-extension',
'coverage',
'reports',
])
type CategoryCounts = {
annotation: number
cast: number
indexSig: number
ignore: number
genericArg: number
}
type FileReport = {
path: string
total: number
byCategory: CategoryCounts
}
type Report = {
generatedAt: string
total: number
byCategory: CategoryCounts
files: FileReport[]
}
// Regexes (each match counted; multi-line `m`, no `g` so we don't loop with /lastIndex).
// Match `any` as a complete identifier (avoid `many`, `company`, etc.).
const ANNOTATION_RE = /(?<![A-Za-z0-9_$])any(?![A-Za-z0-9_$])/m
// `: any` annotation including optional whitespace.
const ANNOTATION_NEAR_RE = /:\s*(?<![A-Za-z0-9_$.])any(?![A-Za-z0-9_$])/m
const CAST_RE = /\bas\s+(?<![A-Za-z0-9_$.])any(?![A-Za-z0-9_$])/m
const INDEX_SIG_RE = /\[\s*(?:k?e?y?[^:\]]*:\s*[^:\]]*)?:\s*(?<![A-Za-z0-9_$.])any(?![A-Za-z0-9_$])/m
const IGNORE_RE = /\/\/\s*@(ts-ignore|ts-expect-error|ts-nocheck)\b/m
const GENERIC_RE = /<[^>]*\bany\b[^>]*>/m
function emptyCategory(): CategoryCounts {
return { annotation: 0, cast: 0, indexSig: 0, ignore: 0, genericArg: 0 }
}
async function walk(dir: string, out: string[]): Promise<void> {
const { readdir, lstat } = await import('fs/promises')
let entries: string[]
try {
entries = await readdir(dir)
} catch (err) {
// Propagate enumeration failures so a partial scan doesn't silently
// report a too-low count. The caller wraps this in a try and exits
// nonzero with the original error.
throw new Error(`readdir failed for ${dir}: ${err instanceof Error ? err.message : String(err)}`)
}
for (const name of entries) {
if (EXCLUDE_DIR_NAMES.has(name)) continue
const full = resolve(dir, name)
// Use lstat so we don't follow symlinks — a self-referencing link would
// recurse forever and stall the budget check.
const st = await lstat(full)
if (st.isSymbolicLink()) continue
if (st.isDirectory()) {
await walk(full, out)
} else if (/\.tsx?$/.test(name)) {
out.push(full)
}
}
}
// Replace the contents of comments, strings, and template literals with
// spaces of equal length so the type-annotation regex only matches real
// TypeScript code. Newlines and offsets are preserved so report line numbers
// stay correct.
function stripNonCode(source: string): string {
let out = ''
let i = 0
const n = source.length
while (i < n) {
const c = source[i]
const c2 = i + 1 < n ? source[i + 1] : ''
// Block comment /* ... */ — preserve @ts-ignore markers in IGNORE_RE
// by NOT stripping the directive prefix; IGNORE_RE only matches
// `// @ts-...` (line) so block comments are safe to fully strip.
if (c === '/' && c2 === '*') {
const end = source.indexOf('*/', i + 2)
if (end === -1) {
out += ' '.repeat(n - i)
i = n
break
}
// Replace with spaces, keep newlines so line numbers stay stable.
for (let j = i; j < end + 2; j++) {
out += source[j] === '\n' ? '\n' : ' '
}
i = end + 2
continue
}
// Line comment — keep `// @ts-...` markers for IGNORE_RE; strip the
// rest of the line. This preserves the existing ignore-directive
// accounting while still excluding `: any` in prose.
if (c === '/' && c2 === '/') {
// Find next newline
let end = i
while (end < n && source[end] !== '\n') end++
const segment = source.slice(i, end)
const isDirective = /^\s*\/\/\s*@(ts-ignore|ts-expect-error|ts-nocheck)\b/.test(segment)
if (isDirective) {
out += segment
} else {
out += ' '.repeat(segment.length)
}
i = end
continue
}
// String literals
if (c === '"' || c === "'") {
const quote = c
let end = i + 1
while (end < n) {
if (source[end] === '\\' && end + 1 < n) {
end += 2
continue
}
if (source[end] === quote) {
end++
break
}
if (source[end] === '\n') {
// Unterminated string — bail and keep the rest verbatim.
break
}
end++
}
out += ' '.repeat(end - i)
i = end
continue
}
// Template literal — simpler handling: strip ${...} interpolation
// contents as well, since the inner expression has its own type scope.
if (c === '`') {
let end = i + 1
while (end < n && source[end] !== '`') {
if (source[end] === '\\' && end + 1 < n) {
end += 2
continue
}
if (source[end] === '$' && source[end + 1] === '{') {
// Strip interpolation body
let depth = 1
end += 2
while (end < n && depth > 0) {
if (source[end] === '{') depth++
else if (source[end] === '}') depth--
if (depth > 0) end++
}
continue
}
end++
}
if (end < n) end++ // consume closing `
out += ' '.repeat(end - i)
i = end
continue
}
out += c
i++
}
return out
}
function countMatches(source: string, re: RegExp): number {
// Rebuild with `g` for counting while preserving the anchored pattern.
const flags = re.flags.replace('g', '') + 'g'
const global = new RegExp(re.source, flags)
let n = 0
while (global.exec(source) !== null) {
n++
if (global.lastIndex === 0) break
}
return n
}
function categorize(source: string): CategoryCounts {
const counts = emptyCategory()
counts.annotation = countMatches(source, ANNOTATION_NEAR_RE)
counts.cast = countMatches(source, CAST_RE)
counts.indexSig = countMatches(source, INDEX_SIG_RE)
counts.ignore = countMatches(source, IGNORE_RE)
counts.genericArg = countMatches(source, GENERIC_RE)
return counts
}
function totalOf(c: CategoryCounts): number {
return c.annotation + c.cast + c.indexSig + c.ignore + c.genericArg
}
async function scan(): Promise<Report> {
const files: FileReport[] = []
const totals = emptyCategory()
for (const dir of SCAN_DIRS) {
const root = resolve(ROOT, dir)
const found: string[] = []
await walk(root, found)
for (const absPath of found) {
const raw = await readFile(absPath, 'utf8')
// Strip comments, strings, and template literals so `: any` in prose
// and `': any'` literals don't count as type annotations.
const source = stripNonCode(raw)
if (!ANNOTATION_RE.test(source) && !CAST_RE.test(source) && !IGNORE_RE.test(source)) {
continue
}
const byCategory = categorize(source)
const total = totalOf(byCategory)
if (total === 0) continue
files.push({
path: absPath.slice(ROOT.length + 1),
total,
byCategory,
})
for (const k of Object.keys(totals) as (keyof CategoryCounts)[]) {
totals[k] += byCategory[k]
}
}
}
files.sort((a, b) => b.total - a.total)
return {
generatedAt: new Date().toISOString(),
total: totalOf(totals),
byCategory: totals,
files,
}
}
function renderMarkdown(report: Report, baselineTotal: number | null): string {
const lines: string[] = []
lines.push('# `any` Usage Report')
lines.push('')
lines.push(`Generated: ${report.generatedAt}`)
lines.push('')
lines.push('## Totals')
lines.push('')
lines.push(`| Category | Count |`)
lines.push(`| --- | ---: |`)
lines.push(`| \`: any\` annotation | ${report.byCategory.annotation} |`)
lines.push(`| \`as any\` cast | ${report.byCategory.cast} |`)
lines.push(`| \`[key: ...]: any\` index signature | ${report.byCategory.indexSig} |`)
lines.push(`| \`// @ts-ignore\` (and friends) | ${report.byCategory.ignore} |`)
lines.push(`| Generic \`any\` arg | ${report.byCategory.genericArg} |`)
lines.push(`| **Total** | **${report.total}** |`)
if (baselineTotal !== null) {
const delta = report.total - baselineTotal
const sign = delta > 0 ? '+' : ''
lines.push('')
lines.push(`Baseline: ${baselineTotal} Delta: ${sign}${delta}`)
}
lines.push('')
lines.push(`## Top offenders (${Math.min(20, report.files.length)} of ${report.files.length})`)
lines.push('')
lines.push('| File | Total | Annot | Cast | IndexSig | Ignore | Generic |')
lines.push('| --- | ---: | ---: | ---: | ---: | ---: | ---: |')
for (const f of report.files.slice(0, 20)) {
lines.push(
`| ${f.path} | ${f.total} | ${f.byCategory.annotation} | ${f.byCategory.cast} | ${f.byCategory.indexSig} | ${f.byCategory.ignore} | ${f.byCategory.genericArg} |`,
)
}
lines.push('')
return lines.join('\n')
}
async function readBaselineTotal(): Promise<number | null> {
try {
const raw = await readFile(BASELINE_PATH, 'utf8')
const parsed = JSON.parse(raw) as Report
return typeof parsed.total === 'number' ? parsed.total : null
} catch {
return null
}
}
async function main(): Promise<void> {
const checkMode = process.argv.includes('--check')
await mkdir(REPORT_DIR, { recursive: true })
const report = await scan()
const baselineTotal = await readBaselineTotal()
await writeFile(JSON_OUT, JSON.stringify(report, null, 2) + '\n')
await writeFile(MD_OUT, renderMarkdown(report, baselineTotal))
// Always refresh baseline file unless --check (we don't want CI to clobber it).
if (!checkMode) {
await writeFile(BASELINE_PATH, JSON.stringify({ total: report.total, generatedAt: report.generatedAt }, null, 2) + '\n')
}
if (checkMode) {
if (baselineTotal === null) {
console.error(`any-usage baseline is missing or invalid: ${BASELINE_PATH}`)
process.exit(1)
}
if (report.total > baselineTotal) {
console.error(
`any-usage budget exceeded: current=${report.total} > baseline=${baselineTotal} (delta=+${report.total - baselineTotal})`,
)
process.exit(1)
}
console.log(`any-usage budget ok: current=${report.total} baseline=${baselineTotal}`)
return
}
console.log(`any-usage report written: total=${report.total} files=${report.files.length}`)
}
await main()