@apifuse/provider-sdk 2.1.0-beta.21 → 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 +4 -0
- package/bin/apifuse-submit-check.ts +655 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -285,6 +285,9 @@ export async function buildSubmitCheckReport(
|
|
|
285
285
|
checks.push(scoreLocaleCatalog(providerRoot, provider));
|
|
286
286
|
checks.push(scoreOperationMetadata(provider));
|
|
287
287
|
checks.push(scoreFixtureCoverage(provider));
|
|
288
|
+
checks.push(scoreFixtureProvenance(providerRoot, provider));
|
|
289
|
+
checks.push(scoreVendorKeyLeak(providerRoot));
|
|
290
|
+
checks.push(scoreVendorTimestampLeak(providerRoot));
|
|
288
291
|
checks.push(scoreHealthCoverage(provider));
|
|
289
292
|
checks.push(scoreAuthSafety(provider));
|
|
290
293
|
checks.push(scoreSmoke(smokeResult, args.smokeNote));
|
|
@@ -728,10 +731,11 @@ function unwrapParens(expr: string): string {
|
|
|
728
731
|
// closing bracket. This lets a property value be read across newlines, so a
|
|
729
732
|
// multi-line `input: z.object({...})\n.passthrough()` is captured whole.
|
|
730
733
|
function balancedValueExpression(source: string, valueStart: number): string {
|
|
734
|
+
const masked = maskCommentsAndStrings(source);
|
|
731
735
|
let depth = 0;
|
|
732
736
|
let index = valueStart;
|
|
733
737
|
for (; index < source.length; index += 1) {
|
|
734
|
-
const ch =
|
|
738
|
+
const ch = masked[index];
|
|
735
739
|
if (ch === "(" || ch === "{" || ch === "[") {
|
|
736
740
|
depth += 1;
|
|
737
741
|
} else if (ch === ")" || ch === "}" || ch === "]") {
|
|
@@ -1973,6 +1977,656 @@ function scoreFixtureCoverage(provider: ProviderDefinition): SubmitCheck {
|
|
|
1973
1977
|
);
|
|
1974
1978
|
}
|
|
1975
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
|
+
|
|
1976
2630
|
function scoreHealthCoverage(provider: ProviderDefinition): SubmitCheck {
|
|
1977
2631
|
const operations = Object.entries(provider.operations);
|
|
1978
2632
|
const missing: string[] = [];
|
package/package.json
CHANGED