morphit/apps/indexer/scripts/indexer-result-shape-smoke.ts

266 lines
9.8 KiB
TypeScript

/**
* Static-analysis smoke: Indexer-Result-shape antipattern detector.
*
* BATCH19D-result-shape (audit doc Part 19) caught ~16 production
* code paths reading `result.value` (instead of `result.data`) or
* `result.error.kind` (instead of `result.code`) on indexer-Result
* types — undefined at runtime, silent breakage. The bug shipped
* because `tsc --noEmit` cannot resolve SvelteKit aliases without
* a generated `.svelte-kit/tsconfig.json`, and the smoke runner
* does not run `svelte-kit sync` before tsc.
*
* This smoke is the cheap regression guard: it scans every file
* that imports from `$indexer/client` (or the relative path), and
* fails if it finds `.value` / `.error.kind` access on a binding
* that came from one of the indexer-client API functions.
*
* False-positive avoidance: we explicitly skip files that
* additionally import from any of the modules whose Result types
* legitimately use `.value` (`runWithActiveKey`, release-fetch /
* release-validate / hash-check, AvatarResult, sanitizeSvg). We
* do NOT try to disambiguate per-binding inside a file that has
* BOTH kinds of Result types — those files should be reviewed
* manually. Today there are zero such files; the smoke prints a
* warning if one appears so the maintainer can add explicit
* disambiguation.
*
* Output: exit-code-0 and a count of clean files; exit-code-1 +
* concrete findings (file:line:col + offending text) on any hit.
*/
import { readFile, readdir } from 'node:fs/promises';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Script is at apps/indexer/scripts/, repo root is 3 up.
const REPO = join(__dirname, '..', '..', '..');
const WEB_SRC = join(REPO, 'apps/web/src');
// Modules whose Result-shaped types legitimately use `.value` —
// files importing from any of these are excluded from the
// `result.value` check (because there's no way to tell, by grep
// alone, which `.value` is on which Result).
const VALUE_LEGITIMATE_IMPORTS = [
'$crypto/runWithActiveKey',
'$net/releaseFetch',
'@morphit/release-schema',
'$net/releaseHashCheck',
'$lib/avatar',
'sanitizeSvg',
'$avatar/sanitize'
];
// Modules whose Result-shaped types legitimately use `.error` /
// `.kind` discriminator — `r.error.kind` may be valid on these.
const ERROR_KIND_LEGITIMATE_IMPORTS = [
'$stores/release',
'$net/releaseFetch',
'@morphit/release-schema',
'$net/releaseHashCheck'
];
// Per-binding allowlist. Some named bindings legitimately carry
// a `.value` field even though their containing file imports from
// $indexer/client. E.g. `loadDraftWithMeta()` returns
// `{ value: T; meta: DraftMeta }`, so a binding like `saved` /
// `draft` whose value comes from that call has a real `.value`
// property. When we see `<binding>.value.X` and the same file
// has a call `loadDraftWithMeta(...)` that assigns to
// `<binding>`, we suppress.
//
// The check is a cheap source-text proximity heuristic, not real
// data-flow analysis — but it's sufficient for the codebase's
// idioms: if `loadDraftWithMeta` is called and a binding name
// `saved` / `draft` is `.value`-accessed on a nearby line, it's
// almost certainly the draft API, not an indexer Result.
const PER_BINDING_VALUE_LEGIT_CALLERS: ReadonlyArray<{
api: string;
bindings: readonly string[];
}> = [{ api: 'loadDraftWithMeta', bindings: ['saved', 'draft'] }];
// Pattern: ANY identifier . value followed by a property access
// or end-of-token. We deliberately match all bindings (not just
// `result|res|r`) because real call sites use domain-specific
// names like `received`, `given`, `existing`, etc. False
// positives are filtered downstream by:
// 1. The file must import from $indexer/client (else skipped).
// 2. The file must NOT import from a $value-legitimate module
// (runWithActiveKey, releaseFetch, etc.).
// 3. The binding must NOT be in the per-binding allowlist when
// the file calls a known legitimate-value API.
// 4. The line must NOT match common DOM/state false-positives
// handled inline (HTMLInputElement.value, $state etc.).
const VALUE_RE = /\b([A-Za-z_$][\w$]*)\.value(?:\b|\.)/g;
const ERROR_KIND_RE = /\b([A-Za-z_$][\w$]*)\.error\.kind\b/g;
// Lines we treat as guaranteed-not-an-indexer-Result. Cheap
// substring exclusions for the common DOM and Svelte-rune false
// positives.
const FALSE_POSITIVE_SUBSTRINGS: readonly string[] = [
// DOM input value (e.g. e.target.value, input.value)
'.target.value',
'(target as ',
'currentTarget.value',
// Svelte 5 runes
'$state(',
'$derived(',
'$props(',
'$bindable(',
// Generic patterns where `.value` is a legitimate field name
// of a non-Result type — these are ones we've already audited.
'fetchResult.value', // ReleaseFetchResult — checked
'sanitizeSvg' // Returns { ok, value } — checked
];
// Binding names that are, by universal convention in this codebase,
// DOM elements (HTMLInputElement / HTMLTextAreaElement / Node) —
// never an indexer-client Result. `el.value` is the DOM `.value`
// property (e.g. the /post `syncCleaned(el: HTMLInputElement)`
// helper added in cp368), not a Result read. Checked against the
// VALUE_RE capture as a WHOLE TOKEN, so this does NOT suppress
// `model.value`, `cancel.value`, `panel.value`, etc. — only an exact
// `el`/`input`/… binding.
const DOM_VALUE_BINDINGS: ReadonlySet<string> = new Set([
'el',
'input',
'node',
'elem',
'textarea'
]);
// Pattern that says "this file imports from indexer/client". We
// match the alias forms ($indexer/client and $lib/indexer/client)
// and the relative form. $ is a regex metachar so we escape it.
const INDEXER_IMPORT_RE =
/from\s+['"](?:\$indexer\/client|\$lib\/indexer\/client|\.\.?\/[^'"]*indexer\/client)['"]/;
interface Finding {
file: string;
line: number;
col: number;
kind: 'value' | 'error_kind';
excerpt: string;
}
async function* walk(dir: string): AsyncGenerator<string> {
const entries = await readdir(dir, { withFileTypes: true });
for (const e of entries) {
const full = join(dir, e.name);
if (e.isDirectory()) {
if (e.name === 'node_modules' || e.name === '.svelte-kit' || e.name === '.git') continue;
yield* walk(full);
} else if (e.isFile()) {
if (full.endsWith('.ts') || full.endsWith('.svelte')) yield full;
}
}
}
async function main(): Promise<void> {
const findings: Finding[] = [];
const ambiguousFiles: string[] = [];
let scanned = 0;
let candidate = 0;
for await (const file of walk(WEB_SRC)) {
if (file.endsWith('.test.ts') || file.endsWith('.spec.ts')) continue;
const src = await readFile(file, 'utf8');
scanned++;
if (!INDEXER_IMPORT_RE.test(src)) continue;
candidate++;
const importsLegitValue = VALUE_LEGITIMATE_IMPORTS.some((m) => src.includes(m));
const importsLegitErrorKind = ERROR_KIND_LEGITIMATE_IMPORTS.some((m) => src.includes(m));
if (importsLegitValue && importsLegitErrorKind) {
ambiguousFiles.push(file);
// Skip — can't disambiguate with grep alone; the
// maintainer must either split this file or add
// explicit type annotations on the offending bindings.
continue;
}
// Build the per-file binding-allowlist. If the file calls
// a legitimate-`.value` API, suppress matches on the
// associated bindings. We check `api(` and `api<` to handle
// generic call forms like `loadDraftWithMeta<T>(...)`.
const allowedValueBindings = new Set<string>();
for (const { api, bindings } of PER_BINDING_VALUE_LEGIT_CALLERS) {
if (src.includes(api + '(') || src.includes(api + '<')) {
for (const b of bindings) allowedValueBindings.add(b);
}
}
const lines = src.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i] ?? '';
// Cheap line-level false-positive filter. Avoids running
// the regex on lines that obviously aren't indexer-Result
// access.
const skipLine = FALSE_POSITIVE_SUBSTRINGS.some((s) => line.includes(s));
if (!importsLegitValue && !skipLine) {
let m: RegExpExecArray | null;
VALUE_RE.lastIndex = 0;
while ((m = VALUE_RE.exec(line))) {
const binding = m[1] ?? '';
if (allowedValueBindings.has(binding)) continue;
if (DOM_VALUE_BINDINGS.has(binding)) continue;
findings.push({
file: file.replace(REPO, ''),
line: i + 1,
col: m.index + 1,
kind: 'value',
excerpt: line.trim().slice(0, 120)
});
}
}
if (!importsLegitErrorKind) {
let m: RegExpExecArray | null;
ERROR_KIND_RE.lastIndex = 0;
while ((m = ERROR_KIND_RE.exec(line))) {
findings.push({
file: file.replace(REPO, ''),
line: i + 1,
col: m.index + 1,
kind: 'error_kind',
excerpt: line.trim().slice(0, 120)
});
}
}
}
}
console.log(`Scanned ${scanned} files, ${candidate} import from $indexer/client.`);
if (ambiguousFiles.length > 0) {
console.warn(
`WARNING: ${ambiguousFiles.length} file(s) import BOTH indexer-client and a $value-Result module — manual review required:`
);
for (const f of ambiguousFiles) console.warn(' - ' + f.replace(REPO, ''));
}
if (findings.length > 0) {
console.error(`\nFAIL: ${findings.length} indexer-Result shape misuse(s):\n`);
for (const f of findings) {
const want = f.kind === 'value' ? '.data' : '.code';
console.error(` ${f.file}:${f.line}:${f.col} → use ${want} instead`);
console.error(` ${f.excerpt}`);
}
console.error(
'\nIndexer-client Result type is `{ ok:true, data:T } | { ok:false, code, message }`.\n' +
'Read `data` on success, `code`/`message` on failure.\n'
);
process.exit(1);
}
console.log(`OK: 0 indexer-Result-shape misuses across ${candidate} candidate file(s).`);
// Runner protocol: scenario count signaled via `✓ all N`. Each
// candidate file scanned counts as one scenario.
console.log(`✓ all ${candidate} scenarios passed`);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});