@apifuse/provider-sdk 2.1.0-beta.20 → 2.1.0-beta.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,6 +7,7 @@ import { createServer } from "node:net";
7
7
  import { basename, dirname, join, relative, resolve } from "node:path";
8
8
  import { pathToFileURL } from "node:url";
9
9
 
10
+ import * as acorn from "acorn";
10
11
  import { z } from "zod";
11
12
 
12
13
  import packageJson from "../package.json";
@@ -284,6 +285,9 @@ export async function buildSubmitCheckReport(
284
285
  checks.push(scoreLocaleCatalog(providerRoot, provider));
285
286
  checks.push(scoreOperationMetadata(provider));
286
287
  checks.push(scoreFixtureCoverage(provider));
288
+ checks.push(scoreFixtureProvenance(providerRoot, provider));
289
+ checks.push(scoreVendorKeyLeak(providerRoot));
290
+ checks.push(scoreVendorTimestampLeak(providerRoot));
287
291
  checks.push(scoreHealthCoverage(provider));
288
292
  checks.push(scoreAuthSafety(provider));
289
293
  checks.push(scoreSmoke(smokeResult, args.smokeNote));
@@ -727,10 +731,11 @@ function unwrapParens(expr: string): string {
727
731
  // closing bracket. This lets a property value be read across newlines, so a
728
732
  // multi-line `input: z.object({...})\n.passthrough()` is captured whole.
729
733
  function balancedValueExpression(source: string, valueStart: number): string {
734
+ const masked = maskCommentsAndStrings(source);
730
735
  let depth = 0;
731
736
  let index = valueStart;
732
737
  for (; index < source.length; index += 1) {
733
- const ch = source[index];
738
+ const ch = masked[index];
734
739
  if (ch === "(" || ch === "{" || ch === "[") {
735
740
  depth += 1;
736
741
  } else if (ch === ")" || ch === "}" || ch === "]") {
@@ -1544,6 +1549,9 @@ function scoreRepositoryDx(providerRoot: string): SubmitCheck {
1544
1549
  if (!existsSync(resolve(providerRoot, ".gitignore"))) {
1545
1550
  missing.push(".gitignore");
1546
1551
  }
1552
+ if (!existsSync(resolve(providerRoot, "AGENTS.md"))) {
1553
+ missing.push("AGENTS.md");
1554
+ }
1547
1555
 
1548
1556
  const packageJsonPath = resolve(providerRoot, "package.json");
1549
1557
  const packageScripts = readPackageScripts(packageJsonPath);
@@ -1572,7 +1580,7 @@ function scoreRepositoryDx(providerRoot: string): SubmitCheck {
1572
1580
  maxPoints: 0,
1573
1581
  message: `Generated repository DX guardrails are missing: ${missing.join(", ")}.`,
1574
1582
  remediation:
1575
- "Regenerate with the current `apifuse create` template or add .gitignore plus `type-check: tsc --noEmit` and include it from `check`.",
1583
+ "Regenerate with the current `apifuse create` template or restore the missing files: .gitignore, AGENTS.md (agent contribution guide), plus `type-check: tsc --noEmit` included from `check`.",
1576
1584
  evidence: missing,
1577
1585
  };
1578
1586
  }
@@ -1969,9 +1977,660 @@ function scoreFixtureCoverage(provider: ProviderDefinition): SubmitCheck {
1969
1977
  );
1970
1978
  }
1971
1979
 
1980
+ const GENERATED_LOCAL_ONLY_SCAFFOLD_REASON = /generated local-only scaffold/i;
1981
+
1982
+ function scoreFixtureProvenance(
1983
+ providerRoot: string,
1984
+ provider: ProviderDefinition,
1985
+ ): SubmitCheck {
1986
+ const rawPath = resolve(providerRoot, "__fixtures__", "raw.json");
1987
+ let hasRecordedEvidence = false;
1988
+ if (existsSync(rawPath)) {
1989
+ try {
1990
+ hasRecordedEvidence = hasNonEmptyRecordedFixture(JSON.parse(readFileSync(rawPath, "utf8")));
1991
+ } catch {
1992
+ hasRecordedEvidence = false;
1993
+ }
1994
+ }
1995
+
1996
+ if (hasRecordedEvidence) {
1997
+ return pass(
1998
+ "fixture-provenance",
1999
+ "fixtures",
2000
+ "Recorded upstream fixture evidence is present.",
2001
+ 0,
2002
+ );
2003
+ }
2004
+
2005
+ if (allOperationsAreGeneratedLocalScaffold(provider)) {
2006
+ return {
2007
+ id: "fixture-provenance",
2008
+ category: "fixtures",
2009
+ level: "warn",
2010
+ status: "warn",
2011
+ points: 0,
2012
+ maxPoints: 0,
2013
+ message:
2014
+ "Generated local-only scaffold has no recorded upstream fixture evidence yet; run `bun run record` once real operations exist.",
2015
+ remediation:
2016
+ "Run `bun run record` (apifuse record) against the real upstream to capture raw payloads once real operations exist.",
2017
+ evidence: ["__fixtures__/raw.json"],
2018
+ };
2019
+ }
2020
+
2021
+ return blocker(
2022
+ "fixture-provenance",
2023
+ "fixtures",
2024
+ "No recorded upstream fixture evidence (__fixtures__/raw.json is empty or missing).",
2025
+ "Run `bun run record` (apifuse record) against the real upstream to capture actual recorded upstream payloads per operation in __fixtures__/raw.json; derive normalized expectations in tests from mapper(recorded raw). Hand-authored fixtures without recorded provenance are not reviewable.",
2026
+ 0,
2027
+ ["__fixtures__/raw.json"],
2028
+ );
2029
+ }
2030
+
2031
+ function hasNonEmptyRecordedFixture(value: unknown): boolean {
2032
+ return recordedFixtureStats(value, 0).hasNestedSubstance;
2033
+ }
2034
+
2035
+ function recordedFixtureStats(
2036
+ value: unknown,
2037
+ depth: number,
2038
+ ): { hasNestedSubstance: boolean; leafValues: number } {
2039
+ if (value === null || value === undefined) {
2040
+ return { hasNestedSubstance: false, leafValues: 0 };
2041
+ }
2042
+ if (Array.isArray(value)) {
2043
+ let leafValues = 0;
2044
+ let hasNestedSubstance = false;
2045
+ for (const item of value) {
2046
+ const child = recordedFixtureStats(item, depth + 1);
2047
+ leafValues += child.leafValues;
2048
+ hasNestedSubstance ||= child.hasNestedSubstance;
2049
+ }
2050
+ return {
2051
+ hasNestedSubstance: hasNestedSubstance || (depth >= 1 && value.length > 0 && leafValues >= 2),
2052
+ leafValues,
2053
+ };
2054
+ }
2055
+ if (typeof value === "object") {
2056
+ let leafValues = 0;
2057
+ let hasNestedSubstance = false;
2058
+ for (const item of Object.values(value)) {
2059
+ const child = recordedFixtureStats(item, depth + 1);
2060
+ leafValues += child.leafValues;
2061
+ hasNestedSubstance ||= child.hasNestedSubstance;
2062
+ }
2063
+ return {
2064
+ hasNestedSubstance:
2065
+ hasNestedSubstance || (depth >= 1 && Object.keys(value).length > 0 && leafValues >= 2),
2066
+ leafValues,
2067
+ };
2068
+ }
2069
+ if (typeof value === "string" && value.length === 0) {
2070
+ return { hasNestedSubstance: false, leafValues: 0 };
2071
+ }
2072
+ return { hasNestedSubstance: false, leafValues: 1 };
2073
+ }
2074
+
2075
+ function allOperationsAreGeneratedLocalScaffold(provider: ProviderDefinition): boolean {
2076
+ const operations = Object.values(provider.operations);
2077
+ return (
2078
+ operations.length > 0 &&
2079
+ operations.every((operation) =>
2080
+ GENERATED_LOCAL_ONLY_SCAFFOLD_REASON.test(operation.healthCheckUnsupported?.reason ?? ""),
2081
+ )
2082
+ );
2083
+ }
2084
+
2085
+ function scoreVendorKeyLeak(providerRoot: string): SubmitCheck {
2086
+ return escapeHatchResult(providerRoot, "vendor-key-leak", findVendorKeyLeakFindings(providerRoot), {
2087
+ blockerMessage: "Public schema keys leak raw vendor field names.",
2088
+ remediation:
2089
+ "Normalize public request/response fields to APIFuse-standard lowerCamelCase names (e.g. isOpen24h, latitude); keep raw vendor keys only in upstream-parsing schemas (const upstream... = z.object(...)). Add `// @apifuse-allow vendor-key-leak` only with a comment explaining why the vendor name is genuinely canonical.",
2090
+ passMessage: "No vendor field-name leaks detected in public schemas.",
2091
+ });
2092
+ }
2093
+
2094
+ function scoreVendorTimestampLeak(providerRoot: string): SubmitCheck {
2095
+ return escapeHatchResult(
2096
+ providerRoot,
2097
+ "vendor-timestamp-leak",
2098
+ findVendorTimestampLeakFindings(providerRoot),
2099
+ {
2100
+ blockerMessage: "Normalized fixtures carry raw vendor timestamp formats.",
2101
+ remediation:
2102
+ "Convert vendor compact timestamps (yyyymmdd, HHmm, yyyymmddHHmmss) to ISO 8601 (date, time with timezone) at the mapper boundary; fixtures.response must show the normalized form. Add `// @apifuse-allow vendor-timestamp-leak` only when the value is genuinely not a timestamp.",
2103
+ passMessage: "No vendor timestamp formats detected in normalized fixtures.",
2104
+ },
2105
+ );
2106
+ }
2107
+
2108
+ type ObjectRange = {
2109
+ start: number;
2110
+ end: number;
2111
+ };
2112
+
2113
+ type ZObjectLiteral = {
2114
+ objectStart: number;
2115
+ objectEnd: number;
2116
+ callStart: number;
2117
+ };
2118
+
2119
+ type NamedObjectRange = ObjectRange & {
2120
+ name: string;
2121
+ };
2122
+
2123
+ function findVendorKeyLeakFindings(providerRoot: string): SourceFinding[] {
2124
+ const findings: SourceFinding[] = [];
2125
+ const seen = new Set<string>();
2126
+
2127
+ for (const filePath of listNonTestTypeScriptFiles(providerRoot)) {
2128
+ const source = readFileSync(filePath, "utf8");
2129
+ const relPath = toRelativeProviderPath(providerRoot, filePath);
2130
+ const upstreamRanges = findUpstreamMarkedConstRanges(source);
2131
+ for (const zObject of findZObjectLiterals(source)) {
2132
+ if (rangeContainsOffset(upstreamRanges, zObject.callStart)) {
2133
+ continue;
2134
+ }
2135
+ if (!zObjectAppearsPublicOutput(source, zObject)) {
2136
+ continue;
2137
+ }
2138
+ for (const keyFinding of vendorKeyFindingsForObject(source, zObject)) {
2139
+ const key = `${relPath}:${keyFinding.line}:${keyFinding.key}`;
2140
+ if (!seen.has(key)) {
2141
+ seen.add(key);
2142
+ findings.push({ file: relPath, line: keyFinding.line });
2143
+ if (findings.length >= MAX_SOURCE_FINDING_EVIDENCE) {
2144
+ return findings;
2145
+ }
2146
+ }
2147
+ }
2148
+ }
2149
+ }
2150
+
2151
+ return findings;
2152
+ }
2153
+
2154
+ function findZObjectLiterals(source: string): ZObjectLiteral[] {
2155
+ const literals: ZObjectLiteral[] = [];
2156
+ const masked = maskCommentsAndStrings(source);
2157
+ const callPattern = /\bz\s*\.\s*object\s*\(/g;
2158
+ for (let match = callPattern.exec(masked); match !== null; match = callPattern.exec(masked)) {
2159
+ const parenIndex = masked.indexOf("(", match.index);
2160
+ const objectStart = findNextNonWhitespace(masked, parenIndex + 1);
2161
+ if (objectStart === -1 || masked[objectStart] !== "{") {
2162
+ continue;
2163
+ }
2164
+ const objectEnd = findMatchingBracket(masked, objectStart);
2165
+ if (objectEnd === -1) {
2166
+ continue;
2167
+ }
2168
+ literals.push({
2169
+ objectStart,
2170
+ objectEnd,
2171
+ callStart: match.index,
2172
+ });
2173
+ callPattern.lastIndex = objectEnd;
2174
+ }
2175
+ return literals;
2176
+ }
2177
+
2178
+ function zObjectAppearsPublicOutput(source: string, zObject: ZObjectLiteral): boolean {
2179
+ const enclosingConst = findConstValueRangeContaining(source, zObject.callStart);
2180
+ if (enclosingConst && /output|response|result/i.test(enclosingConst.name)) {
2181
+ return true;
2182
+ }
2183
+ const before = source.slice(Math.max(0, zObject.callStart - 160), zObject.callStart);
2184
+ return /(?:^|[\s,{])(?:output|response)\s*:\s*$/.test(before);
2185
+ }
2186
+
2187
+ function vendorKeyFindingsForObject(
2188
+ source: string,
2189
+ zObject: ZObjectLiteral,
2190
+ ): Array<{ key: string; line: number }> {
2191
+ const keys = collectTopLevelObjectKeys(source, zObject.objectStart, zObject.objectEnd);
2192
+ const digitFamilies = new Map<string, Set<string>>();
2193
+ for (const key of keys) {
2194
+ const digitMatch = /^([a-z][a-zA-Z]*)(\d+)[a-z]*$/i.exec(key.name);
2195
+ if (!digitMatch?.[1] || !digitMatch[2]) {
2196
+ continue;
2197
+ }
2198
+ const digits = digitFamilies.get(digitMatch[1]) ?? new Set<string>();
2199
+ digits.add(digitMatch[2]);
2200
+ digitFamilies.set(digitMatch[1], digits);
2201
+ }
2202
+
2203
+ return keys
2204
+ .filter((key) => {
2205
+ if (!/^[a-z][a-zA-Z0-9]*$/.test(key.name)) {
2206
+ return true;
2207
+ }
2208
+ const digitMatch = /^([a-z][a-zA-Z]*)(\d+)[a-z]*$/i.exec(key.name);
2209
+ return digitMatch?.[1] !== undefined && (digitFamilies.get(digitMatch[1])?.size ?? 0) >= 3;
2210
+ })
2211
+ .map((key) => ({ key: key.name, line: offsetToLine(source, key.offset) }));
2212
+ }
2213
+
2214
+ function collectTopLevelObjectKeys(
2215
+ source: string,
2216
+ objectStart: number,
2217
+ objectEnd: number,
2218
+ ): Array<{ name: string; offset: number }> {
2219
+ const keys: Array<{ name: string; offset: number }> = [];
2220
+ const masked = maskCommentsAndStrings(source);
2221
+ let index = objectStart + 1;
2222
+ while (index < objectEnd) {
2223
+ index = skipWhitespaceAndComments(masked, index, objectEnd);
2224
+ if (index >= objectEnd || masked[index] === "}") {
2225
+ break;
2226
+ }
2227
+ const keyStart = index;
2228
+ let key: string | undefined;
2229
+ const quote = source[index];
2230
+ if (quote === '"' || quote === "'") {
2231
+ const endQuote = findStringEnd(source, index);
2232
+ if (endQuote === -1) {
2233
+ break;
2234
+ }
2235
+ key = source.slice(index + 1, endQuote);
2236
+ index = endQuote + 1;
2237
+ } else if (masked[index] === "[") {
2238
+ const computedEnd = findMatchingBracket(masked, index);
2239
+ const literalStart = findNextNonWhitespace(masked, index + 1);
2240
+ if (computedEnd === -1 || literalStart === -1) {
2241
+ break;
2242
+ }
2243
+ const computedQuote = source[literalStart];
2244
+ if (computedQuote === '"' || computedQuote === "'") {
2245
+ const literalEnd = findStringEnd(source, literalStart);
2246
+ const afterLiteral =
2247
+ literalEnd === -1
2248
+ ? -1
2249
+ : skipWhitespaceAndComments(masked, literalEnd + 1, computedEnd);
2250
+ if (literalEnd !== -1 && afterLiteral === computedEnd) {
2251
+ key = source.slice(literalStart + 1, literalEnd);
2252
+ }
2253
+ }
2254
+ index = computedEnd + 1;
2255
+ } else {
2256
+ const idMatch = /^[A-Za-z_$][\w$]*/.exec(masked.slice(index));
2257
+ if (idMatch?.[0]) {
2258
+ key = idMatch[0];
2259
+ index += idMatch[0].length;
2260
+ }
2261
+ }
2262
+ index = skipWhitespaceAndComments(masked, index, objectEnd);
2263
+ if (key && masked[index] === ":") {
2264
+ keys.push({ name: key, offset: keyStart });
2265
+ index = skipObjectValue(masked, index + 1, objectEnd);
2266
+ } else {
2267
+ // Spread-based composition is intentionally not expanded here; this gate
2268
+ // only evaluates keys visible in the object literal.
2269
+ index = skipObjectValue(masked, index, objectEnd);
2270
+ }
2271
+ if (masked[index] === ",") {
2272
+ index += 1;
2273
+ }
2274
+ }
2275
+ return keys;
2276
+ }
2277
+
2278
+ function findVendorTimestampLeakFindings(providerRoot: string): SourceFinding[] {
2279
+ const findings: SourceFinding[] = [];
2280
+ const seen = new Set<string>();
2281
+
2282
+ for (const filePath of listNonTestTypeScriptFiles(providerRoot)) {
2283
+ const source = readFileSync(filePath, "utf8");
2284
+ const relPath = toRelativeProviderPath(providerRoot, filePath);
2285
+ const zObjectRanges = findZObjectLiterals(source).map((zObject) => ({
2286
+ start: zObject.callStart,
2287
+ end: zObject.objectEnd,
2288
+ }));
2289
+ const upstreamRanges = findUpstreamMarkedConstRanges(source);
2290
+ const fixtureRanges = findPropertyObjectRanges(source, "fixtures");
2291
+ const fixtureResponseRanges = [
2292
+ ...findPropertyObjectRanges(source, "response"),
2293
+ ...findPropertyObjectRanges(source, "output"),
2294
+ ].filter((range) => rangeContainedInRanges(fixtureRanges, range));
2295
+
2296
+ for (const range of fixtureResponseRanges) {
2297
+ for (const literal of findStringLiteralsInRange(source, range)) {
2298
+ if (
2299
+ rangeContainsOffset(zObjectRanges, literal.offset) ||
2300
+ rangeContainsOffset(upstreamRanges, literal.offset) ||
2301
+ !isVendorTimestampCandidate(
2302
+ literal.value,
2303
+ propertyKeyForStringLiteral(source, literal.offset),
2304
+ )
2305
+ ) {
2306
+ continue;
2307
+ }
2308
+ const line = offsetToLine(source, literal.offset);
2309
+ const key = `${relPath}:${line}:${literal.value}`;
2310
+ if (!seen.has(key)) {
2311
+ seen.add(key);
2312
+ findings.push({ file: relPath, line });
2313
+ if (findings.length >= MAX_SOURCE_FINDING_EVIDENCE) {
2314
+ return findings;
2315
+ }
2316
+ }
2317
+ }
2318
+ }
2319
+ }
2320
+
2321
+ return findings;
2322
+ }
2323
+
2324
+ function findPropertyObjectRanges(source: string, propertyName: string): ObjectRange[] {
2325
+ const ranges: ObjectRange[] = [];
2326
+ const masked = maskCommentsAndStrings(source);
2327
+ const escaped = propertyName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2328
+ const pattern = new RegExp(`(?:^|[^\\w$])["']?${escaped}["']?\\s*:`, "g");
2329
+ for (let match = pattern.exec(masked); match !== null; match = pattern.exec(masked)) {
2330
+ const objectStart = findNextNonWhitespace(masked, match.index + match[0].length);
2331
+ if (objectStart === -1 || masked[objectStart] !== "{") {
2332
+ continue;
2333
+ }
2334
+ const objectEnd = findMatchingBracket(masked, objectStart);
2335
+ if (objectEnd === -1) {
2336
+ continue;
2337
+ }
2338
+ ranges.push({ start: objectStart, end: objectEnd });
2339
+ pattern.lastIndex = objectEnd;
2340
+ }
2341
+ return ranges;
2342
+ }
2343
+
2344
+ function findUpstreamMarkedConstRanges(source: string): ObjectRange[] {
2345
+ return findNamedConstValueRanges(source)
2346
+ .filter((range) => /upstream|raw|vendor/i.test(range.name))
2347
+ .map(({ start, end }) => ({ start, end }));
2348
+ }
2349
+
2350
+ function findNamedConstValueRanges(source: string): NamedObjectRange[] {
2351
+ const ranges: NamedObjectRange[] = [];
2352
+ const masked = maskCommentsAndStrings(source);
2353
+ const pattern =
2354
+ /(?:^|\n)[ \t]*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*(?::[^=\n]+)?\s*=/g;
2355
+ for (let match = pattern.exec(masked); match !== null; match = pattern.exec(masked)) {
2356
+ const name = match[1];
2357
+ if (!name) {
2358
+ continue;
2359
+ }
2360
+ const start = match.index + match[0].length;
2361
+ const expression = balancedValueExpression(masked, start);
2362
+ ranges.push({ name, start, end: start + expression.length });
2363
+ }
2364
+ return ranges;
2365
+ }
2366
+
2367
+ function findConstValueRangeContaining(source: string, offset: number): NamedObjectRange | undefined {
2368
+ return findNamedConstValueRanges(source).find((range) => offset >= range.start && offset <= range.end);
2369
+ }
2370
+
2371
+ function findStringLiteralsInRange(
2372
+ source: string,
2373
+ range: ObjectRange,
2374
+ ): Array<{ value: string; offset: number }> {
2375
+ const literals: Array<{ value: string; offset: number }> = [];
2376
+ let index = range.start;
2377
+ while (index <= range.end) {
2378
+ const quote = source[index];
2379
+ if (quote !== '"' && quote !== "'" && quote !== "`") {
2380
+ index += 1;
2381
+ continue;
2382
+ }
2383
+ const end = findStringEnd(source, index);
2384
+ if (end === -1) {
2385
+ break;
2386
+ }
2387
+ if (quote === "`" && source.slice(index + 1, end).includes("${")) {
2388
+ index = end + 1;
2389
+ continue;
2390
+ }
2391
+ literals.push({ value: source.slice(index + 1, end), offset: index });
2392
+ index = end + 1;
2393
+ }
2394
+ return literals;
2395
+ }
2396
+
2397
+ function isVendorTimestampCandidate(value: string, key: string | undefined): boolean {
2398
+ if (/^\d{8}$/.test(value)) {
2399
+ return isPlausibleCompactDate(value);
2400
+ }
2401
+ if (/^\d{12}$/.test(value)) {
2402
+ return isPlausibleCompactDate(value.slice(0, 8)) && isPlausibleHourMinute(value.slice(8, 12));
2403
+ }
2404
+ if (/^\d{14}$/.test(value)) {
2405
+ const seconds = Number(value.slice(12, 14));
2406
+ return (
2407
+ isPlausibleCompactDate(value.slice(0, 8)) &&
2408
+ isPlausibleHourMinute(value.slice(8, 12)) &&
2409
+ seconds >= 0 &&
2410
+ seconds <= 59
2411
+ );
2412
+ }
2413
+ if (
2414
+ /^\d{4}$/.test(value) &&
2415
+ key !== undefined &&
2416
+ /(?:^|_)at$|At$|time|date|open|close|updated|created/i.test(key)
2417
+ ) {
2418
+ return isPlausibleHourMinute(value);
2419
+ }
2420
+ return false;
2421
+ }
2422
+
2423
+ function isPlausibleCompactDate(value: string): boolean {
2424
+ const year = Number(value.slice(0, 4));
2425
+ const month = Number(value.slice(4, 6));
2426
+ const day = Number(value.slice(6, 8));
2427
+ return year >= 1900 && year <= 2099 && month >= 1 && month <= 12 && day >= 1 && day <= 31;
2428
+ }
2429
+
2430
+ function isPlausibleHourMinute(value: string): boolean {
2431
+ const hour = Number(value.slice(0, 2));
2432
+ const minute = Number(value.slice(2, 4));
2433
+ return hour >= 0 && hour <= 23 && minute >= 0 && minute <= 59;
2434
+ }
2435
+
2436
+ function findNextNonWhitespace(source: string, start: number): number {
2437
+ for (let index = start; index < source.length; index += 1) {
2438
+ if (!/\s/.test(source[index] ?? "")) {
2439
+ return index;
2440
+ }
2441
+ }
2442
+ return -1;
2443
+ }
2444
+
2445
+ function findMatchingBracket(source: string, openIndex: number): number {
2446
+ const open = source[openIndex];
2447
+ const close = open === "{" ? "}" : open === "(" ? ")" : open === "[" ? "]" : undefined;
2448
+ if (!close) {
2449
+ return -1;
2450
+ }
2451
+ let depth = 0;
2452
+ for (let index = openIndex; index < source.length; index += 1) {
2453
+ const char = source[index];
2454
+ if (char === '"' || char === "'" || char === "`") {
2455
+ const stringEnd = findStringEnd(source, index);
2456
+ if (stringEnd === -1) {
2457
+ return -1;
2458
+ }
2459
+ index = stringEnd;
2460
+ continue;
2461
+ }
2462
+ if (char === open) {
2463
+ depth += 1;
2464
+ } else if (char === close) {
2465
+ depth -= 1;
2466
+ if (depth === 0) {
2467
+ return index;
2468
+ }
2469
+ }
2470
+ }
2471
+ return -1;
2472
+ }
2473
+
2474
+ function findStringEnd(source: string, start: number): number {
2475
+ const quote = source[start];
2476
+ for (let index = start + 1; index < source.length; index += 1) {
2477
+ if (source[index] === "\\") {
2478
+ index += 1;
2479
+ continue;
2480
+ }
2481
+ if (source[index] === quote) {
2482
+ return index;
2483
+ }
2484
+ }
2485
+ return -1;
2486
+ }
2487
+
2488
+ function maskCommentsAndStrings(source: string): string {
2489
+ const chars = source.split("");
2490
+ for (let index = 0; index < source.length; index += 1) {
2491
+ if (source.startsWith("//", index)) {
2492
+ const bodyStart = index + 2;
2493
+ const newline = source.indexOf("\n", bodyStart);
2494
+ const end = newline === -1 ? source.length : newline;
2495
+ for (let bodyIndex = bodyStart; bodyIndex < end; bodyIndex += 1) {
2496
+ chars[bodyIndex] = " ";
2497
+ }
2498
+ index = end;
2499
+ continue;
2500
+ }
2501
+ if (source.startsWith("/*", index)) {
2502
+ const bodyStart = index + 2;
2503
+ const close = source.indexOf("*/", bodyStart);
2504
+ const end = close === -1 ? source.length : close;
2505
+ for (let bodyIndex = bodyStart; bodyIndex < end; bodyIndex += 1) {
2506
+ if (chars[bodyIndex] !== "\n") {
2507
+ chars[bodyIndex] = " ";
2508
+ }
2509
+ }
2510
+ index = close === -1 ? source.length : close + 1;
2511
+ continue;
2512
+ }
2513
+ const quote = source[index];
2514
+ if (quote !== '"' && quote !== "'" && quote !== "`") {
2515
+ continue;
2516
+ }
2517
+ const end = findStringEnd(source, index);
2518
+ if (end === -1) {
2519
+ break;
2520
+ }
2521
+ // Preserve quoted property keys ("response": ...) so range/key scanners
2522
+ // can still match them; only string VALUES are blanked.
2523
+ let probe = end + 1;
2524
+ while (probe < source.length && /\s/.test(source[probe] ?? "")) {
2525
+ probe += 1;
2526
+ }
2527
+ if (source[probe] !== ":") {
2528
+ for (let bodyIndex = index + 1; bodyIndex < end; bodyIndex += 1) {
2529
+ if (chars[bodyIndex] !== "\n") {
2530
+ chars[bodyIndex] = " ";
2531
+ }
2532
+ }
2533
+ }
2534
+ index = end;
2535
+ }
2536
+ return chars.join("");
2537
+ }
2538
+
2539
+ function skipWhitespaceAndComments(source: string, start: number, end: number): number {
2540
+ let index = start;
2541
+ while (index < end) {
2542
+ if (/\s/.test(source[index] ?? "")) {
2543
+ index += 1;
2544
+ continue;
2545
+ }
2546
+ if (source.startsWith("//", index)) {
2547
+ const newline = source.indexOf("\n", index + 2);
2548
+ index = newline === -1 ? end : newline + 1;
2549
+ continue;
2550
+ }
2551
+ if (source.startsWith("/*", index)) {
2552
+ const close = source.indexOf("*/", index + 2);
2553
+ index = close === -1 ? end : close + 2;
2554
+ continue;
2555
+ }
2556
+ break;
2557
+ }
2558
+ return index;
2559
+ }
2560
+
2561
+ function skipObjectValue(source: string, start: number, end: number): number {
2562
+ let index = start;
2563
+ while (index < end) {
2564
+ const char = source[index];
2565
+ if (char === '"' || char === "'" || char === "`") {
2566
+ const stringEnd = findStringEnd(source, index);
2567
+ if (stringEnd === -1) {
2568
+ return end;
2569
+ }
2570
+ index = stringEnd + 1;
2571
+ continue;
2572
+ }
2573
+ if (char === "{" || char === "(" || char === "[") {
2574
+ const close = findMatchingBracket(source, index);
2575
+ if (close === -1) {
2576
+ return end;
2577
+ }
2578
+ index = close + 1;
2579
+ continue;
2580
+ }
2581
+ if (char === "," || char === "}") {
2582
+ return index;
2583
+ }
2584
+ index += 1;
2585
+ }
2586
+ return index;
2587
+ }
2588
+
2589
+ function rangeContainsOffset(ranges: readonly ObjectRange[], offset: number): boolean {
2590
+ return ranges.some((range) => offset >= range.start && offset <= range.end);
2591
+ }
2592
+
2593
+ function rangeContainedInRanges(ranges: readonly ObjectRange[], candidate: ObjectRange): boolean {
2594
+ return ranges.some((range) => candidate.start >= range.start && candidate.end <= range.end);
2595
+ }
2596
+
2597
+ function propertyKeyForStringLiteral(source: string, literalOffset: number): string | undefined {
2598
+ const masked = maskCommentsAndStrings(source);
2599
+ let index = skipWhitespaceBackward(masked, literalOffset - 1);
2600
+ if (masked[index] !== ":") {
2601
+ return undefined;
2602
+ }
2603
+ index = skipWhitespaceBackward(masked, index - 1);
2604
+ if (index < 0) {
2605
+ return undefined;
2606
+ }
2607
+ if (source[index] === '"' || source[index] === "'") {
2608
+ const quote = source[index];
2609
+ let start = index - 1;
2610
+ while (start >= 0) {
2611
+ if (source[start] === quote && source[start - 1] !== "\\") {
2612
+ return source.slice(start + 1, index);
2613
+ }
2614
+ start -= 1;
2615
+ }
2616
+ return undefined;
2617
+ }
2618
+ const keyMatch = /[A-Za-z_$][\w$]*$/.exec(masked.slice(0, index + 1));
2619
+ return keyMatch?.[0];
2620
+ }
2621
+
2622
+ function skipWhitespaceBackward(source: string, start: number): number {
2623
+ let index = start;
2624
+ while (index >= 0 && /\s/.test(source[index] ?? "")) {
2625
+ index -= 1;
2626
+ }
2627
+ return index;
2628
+ }
2629
+
1972
2630
  function scoreHealthCoverage(provider: ProviderDefinition): SubmitCheck {
1973
2631
  const operations = Object.entries(provider.operations);
1974
2632
  const missing: string[] = [];
2633
+ const vacuous: string[] = [];
1975
2634
  const placeholder: string[] = [];
1976
2635
  const unsupported: string[] = [];
1977
2636
  const generatedStarter: string[] = [];
@@ -1983,6 +2642,9 @@ function scoreHealthCoverage(provider: ProviderDefinition): SubmitCheck {
1983
2642
  missing.push(operationId);
1984
2643
  continue;
1985
2644
  }
2645
+ if (hasCheck && !hasUnsupported && hasOnlyVacuousHealthCases(operation.healthCheck)) {
2646
+ vacuous.push(operationId);
2647
+ }
1986
2648
  if (hasUnsupported) {
1987
2649
  const reason = operation.healthCheckUnsupported?.reason ?? "";
1988
2650
  unsupported.push(operationId);
@@ -2008,6 +2670,17 @@ function scoreHealthCoverage(provider: ProviderDefinition): SubmitCheck {
2008
2670
  );
2009
2671
  }
2010
2672
 
2673
+ if (vacuous.length > 0) {
2674
+ return blocker(
2675
+ "health-coverage",
2676
+ "health",
2677
+ "One or more operations have healthCheck cases with empty assertions.",
2678
+ `healthCheck.assertions for ${vacuous.join(", ")} is empty — assert on status and response shape (e.g. throw or return {status:'degraded'} when the upstream contract breaks), or declare healthCheckUnsupported with a specific reason if the operation genuinely cannot be probed.`,
2679
+ CATEGORY_MAX_POINTS.health,
2680
+ vacuous.map((operationId) => `${operationId}: empty healthCheck.assertions`),
2681
+ );
2682
+ }
2683
+
2011
2684
  if (placeholder.length > 0) {
2012
2685
  return {
2013
2686
  id: "health-coverage",
@@ -2059,6 +2732,381 @@ function scoreHealthCoverage(provider: ProviderDefinition): SubmitCheck {
2059
2732
  );
2060
2733
  }
2061
2734
 
2735
+ function hasOnlyVacuousHealthCases(
2736
+ healthCheck: ProviderDefinition["operations"][string]["healthCheck"],
2737
+ ): boolean {
2738
+ const cases = healthCheck?.cases;
2739
+ if (!Array.isArray(cases) || cases.length === 0) {
2740
+ return true;
2741
+ }
2742
+ return cases.every((healthCase) => isVacuousAssertionFunction(healthCase?.assertions));
2743
+ }
2744
+
2745
+ function isVacuousAssertionFunction(assertions: unknown): boolean {
2746
+ if (typeof assertions !== "function") {
2747
+ return true;
2748
+ }
2749
+
2750
+ let source: string;
2751
+ try {
2752
+ source = Function.prototype.toString.call(assertions);
2753
+ } catch {
2754
+ return false;
2755
+ }
2756
+
2757
+ // Native / bound functions stringify to `function () { [native code] }` with
2758
+ // no inspectable body or params. The underlying implementation may inspect
2759
+ // ctx, so fail open (do not flag) rather than mistake it for an empty body.
2760
+ if (/\[native code\]/.test(source)) {
2761
+ return false;
2762
+ }
2763
+
2764
+ const fn = parseAssertionFunction(source);
2765
+ if (!fn) {
2766
+ // Unparseable source → fail open (treat as a real assertion). A false
2767
+ // negative here only misses a no-op; a false positive would wrongly
2768
+ // reject a valid contributor.
2769
+ return false;
2770
+ }
2771
+
2772
+ // A real health assertion MUST either throw when the upstream contract
2773
+ // breaks, or inspect the probe response, which is delivered exclusively
2774
+ // through the assertion's own parameter(s). Working on the parsed AST (not
2775
+ // text) makes this precise at the syntactic layer: a `throw` only counts
2776
+ // when it is a real ThrowStatement in THIS function's body (not inside a
2777
+ // nested, uninvoked function), and a parameter reference is checked against
2778
+ // the actual bound names (destructuring binds the local alias, not the
2779
+ // property key). This closes the whole equivalent-no-op class — empty
2780
+ // bodies, `void 0`, `({})`, `Promise.resolve()`, `await Promise.resolve()`,
2781
+ // `.then()`, `new Promise(r => r())`, side-effect-only bodies, throws hidden
2782
+ // in uninvoked closures — without enumerating spellings.
2783
+ if (functionThrows(fn)) {
2784
+ return false;
2785
+ }
2786
+ const bound = new Set<string>();
2787
+ for (const param of fn.params) {
2788
+ collectBoundNames(param, bound);
2789
+ }
2790
+ if (bound.size === 0) {
2791
+ return true;
2792
+ }
2793
+ // A parameter reference anywhere in the (reachable) body is treated as
2794
+ // inspecting the response. This is deliberately syntactic, not a dataflow
2795
+ // analysis.
2796
+ //
2797
+ // KNOWN LIMITATION (accepted): a body that reads the parameter but never
2798
+ // turns that read into an outcome — no throw, no returned verdict — still
2799
+ // passes, e.g. `({ status }) => { console.info(status); }`. Precisely
2800
+ // rejecting it would require tracking whether the read flows to a throw
2801
+ // argument or return value through arbitrary local bindings and invoked
2802
+ // helpers (`const ok = ctx.output.ok; return ok ? ...` / a called helper that
2803
+ // throws). That is transitive use-def dataflow, and an imprecise version
2804
+ // FALSE-BLOCKS real assertions of exactly those shapes — verified
2805
+ // empirically. Under the fail-open contract (rejecting a real contributor is
2806
+ // strictly worse than missing a no-op) we accept the miss here. This gate
2807
+ // stops accidental/lazy no-ops (empty bodies, `void 0`, `Promise.resolve()`,
2808
+ // throws in uninvoked closures); a determined bypass via a decorative ctx
2809
+ // read is no easier than writing the real one-line `throw`, and the actual
2810
+ // defense against a runtime-empty assertion is the live `--smoke` probe.
2811
+ return !referencesBoundNames(fn, bound);
2812
+ }
2813
+
2814
+ type AssertionFunctionNode =
2815
+ | acorn.ArrowFunctionExpression
2816
+ | acorn.FunctionExpression
2817
+ | acorn.FunctionDeclaration;
2818
+
2819
+ function isFunctionNode(node: acorn.AnyNode): node is AssertionFunctionNode {
2820
+ return (
2821
+ node.type === "ArrowFunctionExpression" ||
2822
+ node.type === "FunctionExpression" ||
2823
+ node.type === "FunctionDeclaration"
2824
+ );
2825
+ }
2826
+
2827
+ /**
2828
+ * Parse the `Function.prototype.toString()` output of an assertion into its AST
2829
+ * function node. The stringified form can be an arrow (`(a) => {}`), a function
2830
+ * expression (`function (a) {}`), or a bare method (`foo() {}`), so try a few
2831
+ * wrappers until one parses. Returns undefined on any parse failure so callers
2832
+ * fail open.
2833
+ */
2834
+ function parseAssertionFunction(source: string): AssertionFunctionNode | undefined {
2835
+ const candidates = [source, `(${source})`, `({${source}})`];
2836
+ for (const candidate of candidates) {
2837
+ let program: acorn.Program;
2838
+ try {
2839
+ program = acorn.parse(candidate, { ecmaVersion: "latest" });
2840
+ } catch {
2841
+ continue;
2842
+ }
2843
+ const fn = findFirstFunction(program);
2844
+ if (fn) {
2845
+ return fn;
2846
+ }
2847
+ }
2848
+ return undefined;
2849
+ }
2850
+
2851
+ /** Depth-first search for the first function node in a parsed program. */
2852
+ function findFirstFunction(root: acorn.AnyNode): AssertionFunctionNode | undefined {
2853
+ let found: AssertionFunctionNode | undefined;
2854
+ walkAst(root, (node) => {
2855
+ if (found) {
2856
+ return false;
2857
+ }
2858
+ if (isFunctionNode(node)) {
2859
+ found = node;
2860
+ return false;
2861
+ }
2862
+ return true;
2863
+ });
2864
+ return found;
2865
+ }
2866
+
2867
+ /**
2868
+ * Collect the identifier names actually BOUND by a parameter pattern. For
2869
+ * destructuring, the binding is the local target (`value`), not the source
2870
+ * property key — so `({ status: ignored })` binds `ignored`, and a body that
2871
+ * merely mentions `status` is not referencing a parameter.
2872
+ */
2873
+ function collectBoundNames(pattern: acorn.Pattern | null, out: Set<string>): void {
2874
+ if (!pattern) {
2875
+ return;
2876
+ }
2877
+ switch (pattern.type) {
2878
+ case "Identifier":
2879
+ out.add(pattern.name);
2880
+ break;
2881
+ case "AssignmentPattern":
2882
+ collectBoundNames(pattern.left, out);
2883
+ break;
2884
+ case "RestElement":
2885
+ collectBoundNames(pattern.argument, out);
2886
+ break;
2887
+ case "ArrayPattern":
2888
+ for (const element of pattern.elements) {
2889
+ collectBoundNames(element, out);
2890
+ }
2891
+ break;
2892
+ case "ObjectPattern":
2893
+ for (const property of pattern.properties) {
2894
+ if (property.type === "RestElement") {
2895
+ collectBoundNames(property.argument, out);
2896
+ } else {
2897
+ // `.value` is the local binding target, `.key` is the source
2898
+ // property name — bind only the former.
2899
+ collectBoundNames(property.value, out);
2900
+ }
2901
+ }
2902
+ break;
2903
+ }
2904
+ }
2905
+
2906
+ /**
2907
+ * True if the function contains a real `throw` statement in ITS OWN body —
2908
+ * descending through control flow but NOT into nested functions, whose throws
2909
+ * do not execute unless that nested function is invoked.
2910
+ */
2911
+ function functionThrows(fn: AssertionFunctionNode): boolean {
2912
+ if (fn.body.type !== "BlockStatement") {
2913
+ // Concise arrow returning an expression cannot contain a throw statement.
2914
+ return false;
2915
+ }
2916
+ let throws = false;
2917
+ walkAst(fn.body, (node) => {
2918
+ if (throws) {
2919
+ return false;
2920
+ }
2921
+ if (node.type === "ThrowStatement") {
2922
+ throws = true;
2923
+ return false;
2924
+ }
2925
+ // Do not descend into nested function bodies.
2926
+ if (isFunctionNode(node)) {
2927
+ return false;
2928
+ }
2929
+ return true;
2930
+ });
2931
+ return throws;
2932
+ }
2933
+
2934
+ /**
2935
+ * True if the function's body references any of the given bound parameter names
2936
+ * as an actual value. Property KEYS (`{ status: ... }`, `obj.status`) are not
2937
+ * references; computed members (`obj[status]`) are. Nested functions that
2938
+ * re-bind the same name shadow it, so their bodies are searched with the
2939
+ * shadowed name removed from the target set.
2940
+ */
2941
+ function referencesBoundNames(fn: AssertionFunctionNode, bound: Set<string>): boolean {
2942
+ if (bound.size === 0) {
2943
+ return false;
2944
+ }
2945
+ let referenced = false;
2946
+ walkAstValues(fn.body, bound, fn.body, null, null, () => {
2947
+ referenced = true;
2948
+ });
2949
+ return referenced;
2950
+ }
2951
+
2952
+ /**
2953
+ * Walk `node`, invoking `onReference` when an Identifier in value position
2954
+ * matches a name in `names`. Skips property keys and non-computed member
2955
+ * properties. On entering a nested function, removes any parameter names it
2956
+ * rebinds (shadowing) from the active set for that subtree, and does NOT descend
2957
+ * into a PROVABLY-UNINVOKED helper — a function bound to a local name that is
2958
+ * never referenced again anywhere in the assertion body, so it cannot run when
2959
+ * the assertion runs (e.g. `(ctx) => { const later = () => ctx.status; }`). Its
2960
+ * parameter reads therefore must not count as inspecting the response, mirroring
2961
+ * how `functionThrows` ignores throws inside nested functions.
2962
+ *
2963
+ * Crucially, a helper that IS referenced again (a call site like `check()`) is
2964
+ * NOT skipped — its body is searched — so real assertions that factor the check
2965
+ * into a local helper still pass. Immediately-invoked callbacks (`.every(cb)`,
2966
+ * IIFEs, callees) are likewise searched. When in doubt we descend (fail open):
2967
+ * the only skip is a helper we can prove is never invoked.
2968
+ */
2969
+ function walkAstValues(
2970
+ node: acorn.AnyNode,
2971
+ names: Set<string>,
2972
+ outerBody: acorn.AnyNode,
2973
+ parent: acorn.AnyNode | null,
2974
+ parentKey: string | null,
2975
+ onReference: () => void,
2976
+ ): void {
2977
+ if (names.size === 0) {
2978
+ return;
2979
+ }
2980
+ if (node.type === "Identifier") {
2981
+ if (names.has(node.name)) {
2982
+ onReference();
2983
+ }
2984
+ return;
2985
+ }
2986
+ // Nested function: subtract its own parameter bindings (shadowing) before
2987
+ // descending into its body — and skip it only when it is a provably
2988
+ // uninvoked helper.
2989
+ if (isFunctionNode(node)) {
2990
+ const shadowed = new Set<string>();
2991
+ for (const param of node.params) {
2992
+ collectBoundNames(param, shadowed);
2993
+ }
2994
+ const visible = new Set<string>();
2995
+ for (const name of names) {
2996
+ if (!shadowed.has(name)) {
2997
+ visible.add(name);
2998
+ }
2999
+ }
3000
+ if (visible.size === 0 || isProvablyUninvokedHelper(node, parent, parentKey, outerBody)) {
3001
+ return;
3002
+ }
3003
+ for (const [key, child] of childEntries(node)) {
3004
+ if (key === "params") {
3005
+ continue;
3006
+ }
3007
+ walkAstValues(child, visible, outerBody, node, key, onReference);
3008
+ }
3009
+ return;
3010
+ }
3011
+ for (const [key, child] of childEntries(node)) {
3012
+ // Skip non-computed property keys (`{ status: x }`) and member
3013
+ // properties (`obj.status`) — these are names, not references.
3014
+ if (key === "key" && node.type === "Property" && !node.computed) {
3015
+ continue;
3016
+ }
3017
+ if (key === "property" && node.type === "MemberExpression" && !node.computed) {
3018
+ continue;
3019
+ }
3020
+ walkAstValues(child, names, outerBody, node, key, onReference);
3021
+ }
3022
+ }
3023
+
3024
+ /**
3025
+ * True if `fn` is a local helper bound to a name that is NEVER referenced again
3026
+ * anywhere in `outerBody` — meaning it is never invoked, so its body does not run
3027
+ * as part of evaluating the assertion. Only these provably-dead helpers are
3028
+ * skipped; a helper with any call site (its name appearing more than once, i.e.
3029
+ * beyond its own declaration) is treated as potentially executed and searched.
3030
+ * Anonymous functions in expression position (call args, callees, returns) are
3031
+ * never "uninvoked helpers" — they may run — so they are not skipped here.
3032
+ */
3033
+ function isProvablyUninvokedHelper(
3034
+ fn: AssertionFunctionNode,
3035
+ parent: acorn.AnyNode | null,
3036
+ parentKey: string | null,
3037
+ outerBody: acorn.AnyNode,
3038
+ ): boolean {
3039
+ let helperName: string | undefined;
3040
+ if (fn.type === "FunctionDeclaration" && fn.id) {
3041
+ helperName = fn.id.name;
3042
+ } else if (
3043
+ parent &&
3044
+ parent.type === "VariableDeclarator" &&
3045
+ parentKey === "init" &&
3046
+ parent.id.type === "Identifier"
3047
+ ) {
3048
+ helperName = parent.id.name;
3049
+ }
3050
+ if (helperName === undefined) {
3051
+ // Not a name-bound helper (anonymous callback / expression). It may run,
3052
+ // so do not skip it.
3053
+ return false;
3054
+ }
3055
+ // Count every occurrence of the helper name in the assertion body. Exactly
3056
+ // one occurrence is its own binding declaration; more than one means there is
3057
+ // at least one reference (call site), so the helper can run.
3058
+ let occurrences = 0;
3059
+ walkAst(outerBody, (node) => {
3060
+ if (node.type === "Identifier" && node.name === helperName) {
3061
+ occurrences += 1;
3062
+ }
3063
+ return true;
3064
+ });
3065
+ return occurrences <= 1;
3066
+ }
3067
+
3068
+ /** Generic pre-order AST walk; `visit` returns false to stop descending. */
3069
+ function walkAst(node: acorn.AnyNode, visit: (node: acorn.AnyNode) => boolean): void {
3070
+ if (!visit(node)) {
3071
+ return;
3072
+ }
3073
+ for (const child of childNodes(node)) {
3074
+ walkAst(child, visit);
3075
+ }
3076
+ }
3077
+
3078
+ function* childNodes(node: acorn.AnyNode): Generator<acorn.AnyNode> {
3079
+ for (const [, child] of childEntries(node)) {
3080
+ yield child;
3081
+ }
3082
+ }
3083
+
3084
+ function* childEntries(node: acorn.AnyNode): Generator<[string, acorn.AnyNode]> {
3085
+ for (const key of Object.keys(node)) {
3086
+ if (key === "type" || key === "start" || key === "end" || key === "loc" || key === "range") {
3087
+ continue;
3088
+ }
3089
+ const value = (node as unknown as Record<string, unknown>)[key];
3090
+ if (Array.isArray(value)) {
3091
+ for (const item of value) {
3092
+ if (isAstNode(item)) {
3093
+ yield [key, item];
3094
+ }
3095
+ }
3096
+ } else if (isAstNode(value)) {
3097
+ yield [key, value];
3098
+ }
3099
+ }
3100
+ }
3101
+
3102
+ function isAstNode(value: unknown): value is acorn.AnyNode {
3103
+ return (
3104
+ typeof value === "object" &&
3105
+ value !== null &&
3106
+ typeof (value as { type?: unknown }).type === "string"
3107
+ );
3108
+ }
3109
+
2062
3110
  function scoreSmoke(
2063
3111
  smokeResult: SmokeResult | undefined,
2064
3112
  smokeNote: string | undefined,