Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
435 changes: 435 additions & 0 deletions .api-sync/unmodeled.json

Large diffs are not rendered by default.

34 changes: 32 additions & 2 deletions .github/workflows/api-sync.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,41 @@ jobs:
with:
bun-version: 1.2.18

- name: Fetch the delivered spec
# Fails loudly rather than syncing against a wrong, truncated, or already-superseded spec:
# the delivery branch must carry both spec-current.json and a delivery.json manifest whose
# spec_sha256 actually hashes to spec-current.json's bytes, and (when the dispatch payload
# carries its own spec_sha256) the manifest must agree with what triggered this run.
- name: Fetch and verify the delivered spec manifest
env:
DISPATCH_SHA256: ${{ github.event.client_payload.spec_sha256 }}
run: |
set -e
git fetch origin api-sync-data
mkdir -p .api-sync
git show origin/api-sync-data:.api-sync/spec-current.json > .api-sync/spec-current.json

if ! git show origin/api-sync-data:.api-sync/spec-current.json > .api-sync/spec-current.json; then
echo "::error::.api-sync/spec-current.json is missing on the api-sync-data branch."
exit 1
fi
if ! git show origin/api-sync-data:.api-sync/delivery.json > .api-sync/delivery.json; then
echo "::error::.api-sync/delivery.json is missing on the api-sync-data branch."
exit 1
fi

MANIFEST_SHA256=$(jq -r '.spec_sha256' .api-sync/delivery.json)
ACTUAL_SHA256=$(sha256sum .api-sync/spec-current.json | cut -d' ' -f1)
if [ "$MANIFEST_SHA256" != "$ACTUAL_SHA256" ]; then
echo "::error::spec-current.json sha256 ($ACTUAL_SHA256) does not match delivery.json's spec_sha256 ($MANIFEST_SHA256)."
exit 1
fi

if [ -n "$DISPATCH_SHA256" ] && [ "$DISPATCH_SHA256" != "$MANIFEST_SHA256" ]; then
echo "::error::stale delivery: the dispatch payload's spec_sha256 ($DISPATCH_SHA256) differs from delivery.json's ($MANIFEST_SHA256)."
exit 1
fi

echo "Verified spec-current.json (sha256 $ACTUAL_SHA256) against delivery.json:"
jq -r '" source_repository: \(.source_repository)\n source_sha: \(.source_sha)\n generated_at: \(.generated_at)"' .api-sync/delivery.json

- name: Check for existing api-sync PR
id: check-pr
Expand Down
143 changes: 143 additions & 0 deletions scripts/api-sync/enum-coverage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { describe, expect, it } from "vitest";
import { findEnumConstraints, findUnmappedEnumProperties } from "./enum-coverage";
import type { Spec } from "./spec";
import type { SpecMap, UnmodeledFile } from "./types";

function emptyMap(overrides: Partial<SpecMap> = {}): SpecMap {
return { enums: [], types: [], ignore: { schemas: [] }, ...overrides };
}

function emptyUnmodeled(overrides: Partial<UnmodeledFile> = {}): UnmodeledFile {
return {
properties: [],
knownDivergences: [],
enumPropertyOmissions: [],
nestedShapeOmissions: [],
...overrides,
};
}

describe("findEnumConstraints", () => {
it("finds direct, items, anyOf, and oneOf enum-constrained properties", () => {
const spec: Spec = {
paths: { "/x": { get: { responses: { "200": {} } } } },
components: {
schemas: {
S: {
type: "object",
properties: {
direct: { type: "string", enum: ["a", "b"] },
items: { type: "array", items: { type: "string", enum: ["a"] } },
wrappedAny: {
anyOf: [{ type: "string", enum: ["a"] }, { type: "null" }],
},
wrappedOne: {
oneOf: [{ type: "string", enum: ["a"] }, { type: "null" }],
},
plain: { type: "string" },
},
},
},
},
};
// S must be reachable: seed it via a $ref from paths.
spec.paths["/x"].get.responses["200"] = {
content: { "application/json": { schema: { $ref: "#/components/schemas/S" } } },
};
const kinds = findEnumConstraints(spec, emptyMap())
.map((c) => `${c.property}:${c.kind}`)
.sort();
expect(kinds).toEqual([
"direct:direct",
"items:items",
"wrappedAny:anyOf",
"wrappedOne:oneOf",
]);
});

it("skips schemas listed in map.ignore.schemas", () => {
const spec: Spec = {
paths: {
"/x": {
get: {
responses: {
"200": {
content: {
"application/json": {
schema: { $ref: "#/components/schemas/S" },
},
},
},
},
},
},
},
components: {
schemas: {
S: { type: "object", properties: { f: { type: "string", enum: ["a"] } } },
},
},
};
const map = emptyMap({ ignore: { schemas: [{ name: "S", reason: "n/a" }] } });
expect(findEnumConstraints(spec, map)).toEqual([]);
});
});

describe("findUnmappedEnumProperties", () => {
const baseSpec: Spec = {
paths: {
"/x": {
get: {
responses: {
"200": {
content: {
"application/json": { schema: { $ref: "#/components/schemas/S" } },
},
},
},
},
},
},
components: {
schemas: {
S: {
type: "object",
properties: {
direct: { type: "string", enum: ["a", "b"] },
wrapped: { anyOf: [{ type: "string", enum: ["a"] }, { type: "null" }] },
},
},
},
},
};

it("a direct/items enum on a schema covered by a map.types entry is not a gap, even without a dedicated enums[] entry", () => {
const map = emptyMap({ types: [{ spec: { schema: "S" }, sdk: [] }] });
const gaps = findUnmappedEnumProperties(baseSpec, map, emptyUnmodeled());
expect(gaps.map((g) => g.property)).toEqual(["wrapped"]);
});

it("a direct enum with a dedicated map.enums entry is covered even without a map.types entry", () => {
const map = emptyMap({
enums: [{ spec: { schema: "S", property: "direct" }, sdk: [] }],
});
const gaps = findUnmappedEnumProperties(baseSpec, map, emptyUnmodeled());
expect(gaps.map((g) => g.property).sort()).toEqual(["wrapped"]);
});

it("anyOf/oneOf-wrapped enums are never covered by the ordinary field-type comparison", () => {
const map = emptyMap({ types: [{ spec: { schema: "S" }, sdk: [] }] });
const gaps = findUnmappedEnumProperties(baseSpec, map, emptyUnmodeled());
expect(gaps).toEqual([{ schema: "S", property: "wrapped", kind: "anyOf" }]);
});

it("a recorded omission suppresses the gap", () => {
const map = emptyMap({ types: [{ spec: { schema: "S" }, sdk: [] }] });
const unmodeled = emptyUnmodeled({
enumPropertyOmissions: [
{ schema: "S", property: "wrapped", reason: "test", owner: "eric@blindpay.com" },
],
});
expect(findUnmappedEnumProperties(baseSpec, map, unmodeled)).toEqual([]);
});
});
93 changes: 93 additions & 0 deletions scripts/api-sync/enum-coverage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { reachableSchemas, resolveProperties, type Spec } from "./spec";
import { isPathLocator, type SpecMap, type UnmodeledFile } from "./types";

export type EnumConstraintKind = "direct" | "items" | "anyOf" | "oneOf";

export type EnumConstraint = {
schema: string;
property: string;
kind: EnumConstraintKind;
};

// biome-ignore lint/suspicious/noExplicitAny: raw OpenAPI schema node
function enumKind(node: any): EnumConstraintKind | null {
if (!node || typeof node !== "object") return null;
if (Array.isArray(node.enum)) return "direct";
const t = node.type;
const isArray = Array.isArray(t) ? t.includes("array") : t === "array";
// biome-ignore lint/suspicious/noExplicitAny: raw OpenAPI schema node
if (isArray && node.items && Array.isArray((node.items as any).enum)) return "items";
// biome-ignore lint/suspicious/noExplicitAny: raw OpenAPI schema node
if (Array.isArray(node.anyOf) && node.anyOf.some((m: any) => Array.isArray(m?.enum)))
return "anyOf";
// biome-ignore lint/suspicious/noExplicitAny: raw OpenAPI schema node
if (Array.isArray(node.oneOf) && node.oneOf.some((m: any) => Array.isArray(m?.enum)))
return "oneOf";
return null;
}

/** Every enum-constrained top-level property on every in-scope (reachable, non-ignored) schema. */
export function findEnumConstraints(spec: Spec, map: SpecMap): EnumConstraint[] {
const reachable = reachableSchemas(spec);
const ignored = new Set(map.ignore.schemas.map((e) => e.name));
const out: EnumConstraint[] = [];
for (const schemaName of reachable) {
if (ignored.has(schemaName)) continue;
const node = spec.components.schemas[schemaName];
const props = node?.properties ?? {};
for (const [property, propNode] of Object.entries(props)) {
const kind = enumKind(propNode);
if (kind) out.push({ schema: schemaName, property, kind });
}
}
return out;
}

function isMappedEnumSymbol(map: SpecMap, c: EnumConstraint): boolean {
return map.enums.some((e) => {
if (isPathLocator(e.spec)) return false;
if (e.spec.schema !== c.schema || e.spec.property !== c.property) return false;
return c.kind === "items" ? e.spec.itemsEnum === true : !e.spec.itemsEnum;
});
}

/**
* "direct" and "items" enums on a schema.property already covered by a map.types entry are not
* invisible: reconcile.ts's per-field type comparison (specTypeSignature) reads node.enum /
* node.items.enum and flags any drift as a needs-human failure or a recorded known-divergence,
* even without a dedicated map.enums entry. "anyOf"/"oneOf"-wrapped enums are the real blind
* spot: specTypeSignature does not look inside anyOf/oneOf, so a property shaped that way is
* genuinely unchecked by any existing mechanism.
*/
function isCoveredByFieldTypeCheck(spec: Spec, map: SpecMap, c: EnumConstraint): boolean {
if (c.kind !== "direct" && c.kind !== "items") return false;
for (const entry of map.types) {
if (isPathLocator(entry.spec) || entry.spec.schema !== c.schema) continue;
try {
if (resolveProperties(spec, entry.spec).has(c.property)) return true;
} catch {
// locator doesn't resolve; reconcile.ts already reports this separately
}
}
return false;
}

function isRecordedOmission(unmodeled: UnmodeledFile, c: EnumConstraint): boolean {
return unmodeled.enumPropertyOmissions.some(
(e) => e.schema === c.schema && e.property === c.property
);
}

/** Enum-constrained properties invisible to both the enum-insert patcher and the field-type audit. */
export function findUnmappedEnumProperties(
spec: Spec,
map: SpecMap,
unmodeled: UnmodeledFile
): EnumConstraint[] {
return findEnumConstraints(spec, map).filter(
(c) =>
!isMappedEnumSymbol(map, c) &&
!isCoveredByFieldTypeCheck(spec, map, c) &&
!isRecordedOmission(unmodeled, c)
);
}
Binary file modified scripts/api-sync/index.ts
Binary file not shown.
12 changes: 12 additions & 0 deletions scripts/api-sync/map.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
import { findUnmappedEnumProperties } from "./enum-coverage";
import { isKnownEnumDivergence, isUnmodeledProperty, loadSpecMap, loadUnmodeled } from "./map";
import { findUnmappedNestedShapes } from "./nested-coverage";
import { findDeclarationSpan, readSource } from "./sdk-scan";
import {
loadSpec,
Expand Down Expand Up @@ -64,6 +66,16 @@ describe("spec-map.json validity", () => {
const uncovered = [...reachable].filter((n) => !mappedNames.has(n) && !ignoredNames.has(n));
expect(uncovered).toEqual([]);
});

it("every enum-constrained property resolves to a mapped enum symbol, the ordinary field-type audit, or a recorded omission", () => {
const gaps = findUnmappedEnumProperties(spec, map, unmodeled);
expect(gaps).toEqual([]);
});

it("every inline nested object/array-item shape has a recorded omission (the map format has no nested entries yet)", () => {
const gaps = findUnmappedNestedShapes(spec, map, unmodeled);
expect(gaps).toEqual([]);
});
});

describe("unmodeled.json validity", () => {
Expand Down
11 changes: 9 additions & 2 deletions scripts/api-sync/map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,15 @@ export function loadSpecMap(path: string): SpecMap {

export function loadUnmodeled(path: string): UnmodeledFile {
const raw = JSON.parse(readFileSync(path, "utf8"));
if (!Array.isArray(raw.properties) || !Array.isArray(raw.knownDivergences)) {
throw new Error(`${path}: expected {properties: [], knownDivergences: []}`);
if (
!Array.isArray(raw.properties) ||
!Array.isArray(raw.knownDivergences) ||
!Array.isArray(raw.enumPropertyOmissions) ||
!Array.isArray(raw.nestedShapeOmissions)
) {
throw new Error(
`${path}: expected {properties: [], knownDivergences: [], enumPropertyOmissions: [], nestedShapeOmissions: []}`
);
}
return raw;
}
Expand Down
Loading
Loading