@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.
- package/CHANGELOG.md +8 -0
- package/bin/apifuse-submit-check.ts +1050 -2
- package/dist/cli/create.js +32 -0
- package/dist/cli/templates/provider/AGENTS.md.tpl +87 -0
- package/dist/cli/templates/provider/CLAUDE.md.tpl +1 -0
- package/dist/cli/templates/provider/skills/fixtures-and-recording/SKILL.md.tpl +58 -0
- package/dist/cli/templates/provider/skills/health-checks-and-fail-closed/SKILL.md.tpl +65 -0
- package/dist/cli/templates/provider/skills/normalization-standards/SKILL.md.tpl +57 -0
- package/dist/cli/templates/provider/skills/pagination-and-counts/SKILL.md.tpl +52 -0
- package/dist/cli/templates/provider/skills/upstream-contract-verification/SKILL.md.tpl +45 -0
- package/dist/cli/templates/provider/skills/upstream-notes/README.md.tpl +13 -0
- package/package.json +2 -1
- package/src/cli/create.ts +32 -0
- package/src/cli/templates/provider/AGENTS.md.tpl +87 -0
- package/src/cli/templates/provider/CLAUDE.md.tpl +1 -0
- package/src/cli/templates/provider/skills/fixtures-and-recording/SKILL.md.tpl +58 -0
- package/src/cli/templates/provider/skills/health-checks-and-fail-closed/SKILL.md.tpl +65 -0
- package/src/cli/templates/provider/skills/normalization-standards/SKILL.md.tpl +57 -0
- package/src/cli/templates/provider/skills/pagination-and-counts/SKILL.md.tpl +52 -0
- package/src/cli/templates/provider/skills/upstream-contract-verification/SKILL.md.tpl +45 -0
- package/src/cli/templates/provider/skills/upstream-notes/README.md.tpl +13 -0
|
@@ -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 =
|
|
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
|
|
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,
|