up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
sdk-generator-smoke / sdk-smoke (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
sdk-generator-smoke / sdk-smoke (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled
This commit is contained in:
@@ -5,6 +5,10 @@ info:
|
||||
paths:
|
||||
/foo:
|
||||
get:
|
||||
parameters:
|
||||
- in: query
|
||||
name: tenant
|
||||
required: true
|
||||
responses:
|
||||
"201":
|
||||
description: created
|
||||
@@ -13,3 +17,14 @@ paths:
|
||||
responses:
|
||||
"200":
|
||||
description: ok
|
||||
/baz:
|
||||
post:
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
responses:
|
||||
"201":
|
||||
description: created
|
||||
|
||||
@@ -5,6 +5,25 @@ info:
|
||||
paths:
|
||||
/foo:
|
||||
get:
|
||||
parameters:
|
||||
- in: query
|
||||
name: filter
|
||||
required: false
|
||||
responses:
|
||||
"200":
|
||||
description: ok
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
/baz:
|
||||
post:
|
||||
requestBody:
|
||||
required: false
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
responses:
|
||||
"201":
|
||||
description: created
|
||||
|
||||
@@ -7,15 +7,16 @@
|
||||
* node scripts/api-compat-diff.mjs <oldSpec> <newSpec> [--output json|text] [--fail-on-breaking]
|
||||
*
|
||||
* Output (text):
|
||||
* - Added operations (additive)
|
||||
* - Removed operations (breaking)
|
||||
* - Added responses (additive)
|
||||
* - Removed responses (breaking)
|
||||
* - Added/removed operations
|
||||
* - Added/removed responses
|
||||
* - Parameter additions/removals/requiredness changes
|
||||
* - Response content-type additions/removals
|
||||
* - Request body additions/removals/requiredness and content-type changes
|
||||
*
|
||||
* Output (json):
|
||||
* {
|
||||
* additive: { operations: [...], responses: [...] },
|
||||
* breaking: { operations: [...], responses: [...] }
|
||||
* additive: { operations, responses, parameters, responseContentTypes, requestBodies },
|
||||
* breaking: { operations, responses, parameters, responseContentTypes, requestBodies }
|
||||
* }
|
||||
*
|
||||
* Exit codes:
|
||||
@@ -79,6 +80,35 @@ function loadSpec(specPath) {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeParams(params) {
|
||||
const map = new Map();
|
||||
if (!Array.isArray(params)) return map;
|
||||
|
||||
for (const param of params) {
|
||||
if (!param || typeof param !== 'object') continue;
|
||||
if (param.$ref) {
|
||||
map.set(`ref:${param.$ref}`, { required: param.required === true, isRef: true });
|
||||
continue;
|
||||
}
|
||||
const name = param.name;
|
||||
const loc = param.in;
|
||||
if (!name || !loc) continue;
|
||||
const key = `${name}:${loc}`;
|
||||
map.set(key, { required: param.required === true, isRef: false });
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
function describeParam(key, requiredFlag) {
|
||||
if (key.startsWith('ref:')) {
|
||||
return key.replace(/^ref:/, '');
|
||||
}
|
||||
const [name, loc] = key.split(':');
|
||||
const requiredLabel = requiredFlag ? ' (required)' : '';
|
||||
return `${name} in ${loc}${requiredLabel}`;
|
||||
}
|
||||
|
||||
function enumerateOperations(spec) {
|
||||
const ops = new Map();
|
||||
if (!spec?.paths || typeof spec.paths !== 'object') {
|
||||
@@ -89,17 +119,52 @@ function enumerateOperations(spec) {
|
||||
if (!pathItem || typeof pathItem !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const pathParams = normalizeParams(pathItem.parameters ?? []);
|
||||
|
||||
for (const method of Object.keys(pathItem)) {
|
||||
const lowerMethod = method.toLowerCase();
|
||||
if (!['get', 'put', 'post', 'delete', 'patch', 'head', 'options', 'trace'].includes(lowerMethod)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const op = pathItem[method];
|
||||
if (!op || typeof op !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const opId = `${lowerMethod} ${pathKey}`;
|
||||
const responses = pathItem[method]?.responses ?? {};
|
||||
|
||||
const opParams = normalizeParams(op.parameters ?? []);
|
||||
const parameters = new Map(pathParams);
|
||||
for (const [key, val] of opParams.entries()) {
|
||||
parameters.set(key, val);
|
||||
}
|
||||
|
||||
const responseContentTypes = new Map();
|
||||
const responses = new Set();
|
||||
const responseEntries = Object.entries(op.responses ?? {});
|
||||
for (const [code, resp] of responseEntries) {
|
||||
responses.add(code);
|
||||
const contentTypes = new Set(Object.keys(resp?.content ?? {}));
|
||||
responseContentTypes.set(code, contentTypes);
|
||||
}
|
||||
|
||||
const requestBody = op.requestBody
|
||||
? {
|
||||
present: true,
|
||||
required: op.requestBody.required === true,
|
||||
contentTypes: new Set(Object.keys(op.requestBody.content ?? {})),
|
||||
}
|
||||
: { present: false, required: false, contentTypes: new Set() };
|
||||
|
||||
ops.set(opId, {
|
||||
method: lowerMethod,
|
||||
path: pathKey,
|
||||
responses: new Set(Object.keys(responses)),
|
||||
responses,
|
||||
responseContentTypes,
|
||||
parameters,
|
||||
requestBody,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -112,9 +177,15 @@ function diffOperations(oldOps, newOps) {
|
||||
const breakingOps = [];
|
||||
const additiveResponses = [];
|
||||
const breakingResponses = [];
|
||||
const additiveParams = [];
|
||||
const breakingParams = [];
|
||||
const additiveResponseContentTypes = [];
|
||||
const breakingResponseContentTypes = [];
|
||||
const additiveRequestBodies = [];
|
||||
const breakingRequestBodies = [];
|
||||
|
||||
// Operations added or removed
|
||||
for (const [id, op] of newOps.entries()) {
|
||||
for (const [id] of newOps.entries()) {
|
||||
if (!oldOps.has(id)) {
|
||||
additiveOps.push(id);
|
||||
}
|
||||
@@ -126,7 +197,7 @@ function diffOperations(oldOps, newOps) {
|
||||
}
|
||||
}
|
||||
|
||||
// Response-level diffs for shared operations
|
||||
// Response- and parameter-level diffs for shared operations
|
||||
for (const [id, newOp] of newOps.entries()) {
|
||||
if (!oldOps.has(id)) continue;
|
||||
const oldOp = oldOps.get(id);
|
||||
@@ -142,16 +213,92 @@ function diffOperations(oldOps, newOps) {
|
||||
breakingResponses.push(`${id} -> ${code}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const code of newOp.responses) {
|
||||
if (!oldOp.responses.has(code)) continue;
|
||||
const oldTypes = oldOp.responseContentTypes.get(code) ?? new Set();
|
||||
const newTypes = newOp.responseContentTypes.get(code) ?? new Set();
|
||||
|
||||
for (const ct of newTypes) {
|
||||
if (!oldTypes.has(ct)) {
|
||||
additiveResponseContentTypes.push(`${id} -> ${code} (${ct})`);
|
||||
}
|
||||
}
|
||||
for (const ct of oldTypes) {
|
||||
if (!newTypes.has(ct)) {
|
||||
breakingResponseContentTypes.push(`${id} -> ${code} (${ct})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, oldParam] of oldOp.parameters.entries()) {
|
||||
if (!newOp.parameters.has(key)) {
|
||||
breakingParams.push(`${id} -> - parameter ${describeParam(key, oldParam.required)}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, newParam] of newOp.parameters.entries()) {
|
||||
if (!oldOp.parameters.has(key)) {
|
||||
const target = newParam.required ? breakingParams : additiveParams;
|
||||
target.push(`${id} -> + parameter ${describeParam(key, newParam.required)}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const oldParam = oldOp.parameters.get(key);
|
||||
if (oldParam.required !== newParam.required) {
|
||||
if (newParam.required) {
|
||||
breakingParams.push(`${id} -> parameter ${describeParam(key)} made required`);
|
||||
} else {
|
||||
additiveParams.push(`${id} -> parameter ${describeParam(key)} made optional`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { requestBody: oldBody } = oldOp;
|
||||
const { requestBody: newBody } = newOp;
|
||||
|
||||
if (oldBody.present && !newBody.present) {
|
||||
breakingRequestBodies.push(`${id} -> - requestBody`);
|
||||
} else if (!oldBody.present && newBody.present) {
|
||||
const target = newBody.required ? breakingRequestBodies : additiveRequestBodies;
|
||||
const label = newBody.required ? 'required' : 'optional';
|
||||
target.push(`${id} -> + requestBody (${label})`);
|
||||
} else if (oldBody.present && newBody.present) {
|
||||
if (oldBody.required !== newBody.required) {
|
||||
if (newBody.required) {
|
||||
breakingRequestBodies.push(`${id} -> requestBody made required`);
|
||||
} else {
|
||||
additiveRequestBodies.push(`${id} -> requestBody made optional`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const ct of newBody.contentTypes) {
|
||||
if (!oldBody.contentTypes.has(ct)) {
|
||||
additiveRequestBodies.push(`${id} -> requestBody content-type added: ${ct}`);
|
||||
}
|
||||
}
|
||||
for (const ct of oldBody.contentTypes) {
|
||||
if (!newBody.contentTypes.has(ct)) {
|
||||
breakingRequestBodies.push(`${id} -> requestBody content-type removed: ${ct}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
additive: {
|
||||
operations: additiveOps.sort(),
|
||||
responses: additiveResponses.sort(),
|
||||
parameters: additiveParams.sort(),
|
||||
responseContentTypes: additiveResponseContentTypes.sort(),
|
||||
requestBodies: additiveRequestBodies.sort(),
|
||||
},
|
||||
breaking: {
|
||||
operations: breakingOps.sort(),
|
||||
responses: breakingResponses.sort(),
|
||||
parameters: breakingParams.sort(),
|
||||
responseContentTypes: breakingResponseContentTypes.sort(),
|
||||
requestBodies: breakingRequestBodies.sort(),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -163,11 +310,23 @@ function renderText(diff) {
|
||||
diff.additive.operations.forEach((op) => lines.push(` + ${op}`));
|
||||
lines.push(` Responses: ${diff.additive.responses.length}`);
|
||||
diff.additive.responses.forEach((resp) => lines.push(` + ${resp}`));
|
||||
lines.push(` Parameters: ${diff.additive.parameters.length}`);
|
||||
diff.additive.parameters.forEach((param) => lines.push(` + ${param}`));
|
||||
lines.push(` Response content-types: ${diff.additive.responseContentTypes.length}`);
|
||||
diff.additive.responseContentTypes.forEach((ct) => lines.push(` + ${ct}`));
|
||||
lines.push(` Request bodies: ${diff.additive.requestBodies.length}`);
|
||||
diff.additive.requestBodies.forEach((rb) => lines.push(` + ${rb}`));
|
||||
lines.push('Breaking:');
|
||||
lines.push(` Operations: ${diff.breaking.operations.length}`);
|
||||
diff.breaking.operations.forEach((op) => lines.push(` - ${op}`));
|
||||
lines.push(` Responses: ${diff.breaking.responses.length}`);
|
||||
diff.breaking.responses.forEach((resp) => lines.push(` - ${resp}`));
|
||||
lines.push(` Parameters: ${diff.breaking.parameters.length}`);
|
||||
diff.breaking.parameters.forEach((param) => lines.push(` - ${param}`));
|
||||
lines.push(` Response content-types: ${diff.breaking.responseContentTypes.length}`);
|
||||
diff.breaking.responseContentTypes.forEach((ct) => lines.push(` - ${ct}`));
|
||||
lines.push(` Request bodies: ${diff.breaking.requestBodies.length}`);
|
||||
diff.breaking.requestBodies.forEach((rb) => lines.push(` - ${rb}`));
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
@@ -184,7 +343,13 @@ function main() {
|
||||
console.log(renderText(diff));
|
||||
}
|
||||
|
||||
if (opts.failOnBreaking && (diff.breaking.operations.length > 0 || diff.breaking.responses.length > 0)) {
|
||||
if (opts.failOnBreaking && (
|
||||
diff.breaking.operations.length > 0
|
||||
|| diff.breaking.responses.length > 0
|
||||
|| diff.breaking.parameters.length > 0
|
||||
|| diff.breaking.responseContentTypes.length > 0
|
||||
|| diff.breaking.requestBodies.length > 0
|
||||
)) {
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,5 +21,14 @@ assert.deepStrictEqual(diff.additive.operations, ['get /bar']);
|
||||
assert.deepStrictEqual(diff.breaking.operations, []);
|
||||
assert.deepStrictEqual(diff.additive.responses, ['get /foo -> 201']);
|
||||
assert.deepStrictEqual(diff.breaking.responses, ['get /foo -> 200']);
|
||||
assert.deepStrictEqual(diff.additive.parameters, []);
|
||||
assert.deepStrictEqual(diff.breaking.parameters, [
|
||||
'get /foo -> + parameter tenant in query (required)',
|
||||
'get /foo -> - parameter filter in query',
|
||||
]);
|
||||
assert.deepStrictEqual(diff.additive.requestBodies, []);
|
||||
assert.deepStrictEqual(diff.breaking.requestBodies, ['post /baz -> requestBody made required']);
|
||||
assert.deepStrictEqual(diff.additive.responseContentTypes, []);
|
||||
assert.deepStrictEqual(diff.breaking.responseContentTypes, []);
|
||||
|
||||
console.log('api-compat-diff test passed');
|
||||
|
||||
10
scripts/bench/README.md
Normal file
10
scripts/bench/README.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# Bench scripts
|
||||
|
||||
- `determinism-run.sh`: runs BENCH-DETERMINISM-401-057 harness (`src/Bench/StellaOps.Bench/Determinism`), writes artifacts to `out/bench-determinism`, and enforces threshold via `BENCH_DETERMINISM_THRESHOLD` (default 0.95). Defaults to 10 runs per scanner/SBOM pair. Pass `DET_EXTRA_INPUTS` (space-separated globs) to include frozen feeds in `inputs.sha256`; `DET_RUN_EXTRA_ARGS` to forward extra args to the harness.
|
||||
|
||||
Usage:
|
||||
```sh
|
||||
BENCH_DETERMINISM_THRESHOLD=0.97 \
|
||||
DET_EXTRA_INPUTS="offline/feeds/*.tar.gz" \
|
||||
scripts/bench/determinism-run.sh
|
||||
```
|
||||
32
scripts/bench/determinism-run.sh
Normal file
32
scripts/bench/determinism-run.sh
Normal file
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# BENCH-DETERMINISM-401-057: run determinism harness and collect artifacts
|
||||
|
||||
ROOT="$(git rev-parse --show-toplevel)"
|
||||
HARNESS="${ROOT}/src/Bench/StellaOps.Bench/Determinism"
|
||||
OUT="${ROOT}/out/bench-determinism"
|
||||
THRESHOLD="${BENCH_DETERMINISM_THRESHOLD:-0.95}"
|
||||
mkdir -p "$OUT"
|
||||
|
||||
cd "$HARNESS"
|
||||
|
||||
python run_bench.py \
|
||||
--sboms inputs/sboms/*.json \
|
||||
--vex inputs/vex/*.json \
|
||||
--config configs/scanners.json \
|
||||
--runs 10 \
|
||||
--shuffle \
|
||||
--output results \
|
||||
--manifest-extra "${DET_EXTRA_INPUTS:-}" \
|
||||
${DET_RUN_EXTRA_ARGS:-}
|
||||
|
||||
cp -a results "$OUT"/
|
||||
det_rate=$(python -c "import json;print(json.load(open('results/summary.json'))['determinism_rate'])")
|
||||
printf "determinism_rate=%s\n" "$det_rate" > "$OUT/summary.txt"
|
||||
printf "timestamp=%s\n" "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" >> "$OUT/summary.txt"
|
||||
|
||||
awk -v rate="$det_rate" -v th="$THRESHOLD" 'BEGIN {if (rate+0 < th+0) {printf("determinism_rate %s is below threshold %s\n", rate, th); exit 1}}'
|
||||
|
||||
tar -C "$OUT" -czf "$OUT/bench-determinism-artifacts.tgz" .
|
||||
echo "[bench-determinism] artifacts at $OUT"
|
||||
Reference in New Issue
Block a user