Skip to content

Commit d3eebec

Browse files
committed
compute: judge the CLI against the same toolchains as the runtimes
Every result file records the CLI it was built with, but the compute model only used that to credit the CLI with a Node.js major. A cell's verdict ignored which CLI built it, so an older CLI failing where a newer one passed read as verified, and ns doctor had no way to ask about its own CLI. Each build is now evidence about both packages it pinned. The runtime and the CLI each see the other as a companion pin, so a failure lands on the CLI's row when a newer CLI builds the same runtime and toolchain, and stays on both until something distinguishes them. The CLI row gains Xcode, CocoaPods and the Android keys next to Node.js. The per-release endpoint accepts cli=<version> and answers with the same verified, failed, suspect and inferred lists for that CLI release, or tracked: false when the document does not carry it.
1 parent 5cd52e9 commit d3eebec

5 files changed

Lines changed: 155 additions & 79 deletions

File tree

‎README.md‎

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -75,18 +75,23 @@ result file for a verification done outside CI.
7575

7676
## How a cell gets its state
7777

78-
A failed build pins several toolchains at once and cannot say which one is to
79-
blame, so a failure only implicates toolchain versions that no successful
80-
build of the same release used; when one of those versions is already the
78+
A failed build pins several things at once and cannot say which one is to
79+
blame: the toolchains, and the other package it was built with (the CLI for a
80+
runtime, the runtime for the CLI). A failure only implicates pins that no
81+
successful build of the same release used; when one of them is already the
8182
sole suspect of another failure, it explains the failure and the rest stay
82-
unjudged.
83+
unjudged. So when a newer CLI builds the same runtime and toolchain, the
84+
failure lands on the older CLI's row and the runtime's cell stays verified.
85+
Until then both rows show the failure.
8386

8487
For a release and a toolchain version, in this order:
8588

8689
1. **Unsupported / Advisory**: a maintainer advisory matches the release and
8790
the toolchain version.
8891
2. **Verified**: a recorded CI build succeeded with that toolchain version.
89-
Any success outranks failures. A failure only marks the cell unsupported
92+
A build is evidence about the runtime and about the CLI it was built with
93+
alike, so the same result verifies the toolchain for both, and the Node.js
94+
major for the CLI. Any success outranks failures. A failure only marks the cell unsupported
9095
once a second, independent run has confirmed it; a single failure shows as
9196
unverified with a pending second attempt. A confirmed failure on a newer
9297
release also marks older releases without a result of their own as
@@ -115,9 +120,12 @@ than the declaration. Clicking a cell shows the same timeline over releases.
115120

116121
- `/` the app.
117122
- `/v1/compatibility.json` the full document (`$schema` points at its schema).
118-
- `/v1/packages/<name>/<version>?xcode=26.2&jdk=21` one release: its
119-
effective requirements, the toolchain versions verified, failed, suspect or
120-
inferred for it, and the advisories matching the given toolchain versions.
123+
- `/v1/packages/<name>/<version>?xcode=26.2&jdk=21&cli=9.1.1` one release:
124+
its effective requirements, the toolchain versions verified, failed, suspect
125+
or inferred for it, and the advisories matching the given toolchain
126+
versions. With `cli`, the response carries the same for that CLI release
127+
under `cli`, so a project's CLI is judged against the same toolchains; a
128+
CLI release the document does not track answers with `tracked: false`.
121129
- `/v1/schemas/{compatibility,requirements,package,overrides,verified}.json`.
122130
- `/health`.
123131

‎shared/compute.ts‎

Lines changed: 71 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,13 @@ export interface TrackedPackageSpec {
2020
keep: number;
2121
}
2222

23+
export const CLI_PACKAGE = "nativescript";
24+
2325
export const TRACKED_PACKAGES: TrackedPackageSpec[] = [
2426
{ name: "@nativescript/ios", toolchains: ["xcode", "cocoapods"], keep: 12 },
2527
{ name: "@nativescript/android", toolchains: ["compileSdk", "buildTools", "jdk"], keep: 12 },
2628
{ name: "@nativescript/visionos", toolchains: ["xcode"], keep: 6 },
27-
{ name: "nativescript", toolchains: ["node"], keep: 8 },
29+
{ name: CLI_PACKAGE, toolchains: ["node", "xcode", "cocoapods", "compileSdk", "buildTools", "jdk"], keep: 8 },
2830
];
2931

3032
/**
@@ -167,90 +169,102 @@ export function classify(entry: VerificationResult): ResultClass {
167169
return (entry.attempts ?? 1) >= 2 ? "confirmed" : "suspect";
168170
}
169171

172+
interface PinnedBuild {
173+
package: string;
174+
version: string;
175+
toolchains: Partial<Record<ToolchainKey, string>>;
176+
/** The other package the build pinned: the CLI for a runtime, the runtime for the CLI. */
177+
companion: { package: string; version: string };
178+
outcome: ResultClass;
179+
}
180+
170181
/**
171-
* A successful result proves every toolchain it pinned for the runtime it
172-
* built, and the Node.js major for the CLI it built with. A failure only
173-
* speaks about the runtime's toolchains.
182+
* One CI build pins a runtime release and a CLI release together, so it is
183+
* evidence about both: each toolchain for the runtime, and each toolchain plus
184+
* the Node.js major for the CLI.
174185
*/
186+
function pinnedBuilds(entry: VerificationResult): PinnedBuild[] {
187+
const outcome = classify(entry);
188+
const runtime = { package: entry.package, version: entry.version };
189+
const cli = { package: CLI_PACKAGE, version: entry.with.nativescript };
190+
return [
191+
{ ...runtime, toolchains: entry.toolchains, companion: cli, outcome },
192+
{ ...cli, toolchains: { ...entry.toolchains, node: entry.with.node }, companion: runtime, outcome },
193+
];
194+
}
195+
175196
export function verifiedPairs(
176197
verified: VerificationResult[],
177198
outcome: ResultClass = "success",
178199
): Array<{ package: string; version: string; toolchain: ToolchainKey; toolchainVersion: string }> {
179200
return verified
180-
.filter((entry) => classify(entry) === outcome)
181-
.flatMap((entry) => [
182-
...Object.entries(entry.toolchains).map(([toolchain, toolchainVersion]) => ({
183-
package: entry.package,
184-
version: entry.version,
185-
toolchain: toolchain as ToolchainKey,
186-
toolchainVersion: toolchainVersion!,
201+
.flatMap(pinnedBuilds)
202+
.filter((build) => build.outcome === outcome)
203+
.flatMap((build) =>
204+
(Object.entries(build.toolchains) as Array<[ToolchainKey, string]>).map(([toolchain, toolchainVersion]) => ({
205+
package: build.package,
206+
version: build.version,
207+
toolchain,
208+
toolchainVersion,
187209
})),
188-
// A failed runtime build says nothing about the CLI's Node.js support.
189-
...(outcome === "success"
190-
? [
191-
{
192-
package: "nativescript",
193-
version: entry.with.nativescript,
194-
toolchain: "node" as ToolchainKey,
195-
toolchainVersion: entry.with.node,
196-
},
197-
]
198-
: []),
199-
]);
210+
);
211+
}
212+
213+
interface Pin {
214+
key: string;
215+
version: string;
216+
/** Companion-package pins take part in blame but are never reported as a toolchain. */
217+
toolchain: boolean;
200218
}
201219

202220
/**
203-
* A failed build pins several toolchains at once and cannot say which one is
204-
* to blame. A failure therefore implicates only toolchain versions that no
205-
* successful build of the same release used, and once one of its pinned
206-
* versions is the sole suspect of another failure, that version explains the
207-
* failure and the rest stay unjudged.
221+
* A failed build pins several things at once and cannot say which one is to
222+
* blame: its toolchains and the other package it was built with. A failure
223+
* therefore implicates only pins that no successful build of the same release
224+
* used, and once one of its pins is the sole suspect of another failure, that
225+
* pin explains the failure and the rest stay unjudged. A failure whose only
226+
* suspect is the companion package says nothing about any toolchain.
208227
*/
209228
function verifiedFor(
210229
packageName: string,
211230
version: string,
212231
verified: VerificationResult[],
213232
outcome: ResultClass,
214233
): VersionCompatibility["verified"] {
215-
const forThisVersion = (entries: VerificationResult[]) =>
216-
entries.filter((entry) => entry.package === packageName && entry.version === version);
217-
const successes = forThisVersion(verified).filter((entry) => classify(entry) === "success");
218-
const provenToWork = (toolchain: ToolchainKey, toolchainVersion: string) =>
219-
successes.some((ok) => {
220-
const okVersion = ok.toolchains[toolchain as Exclude<ToolchainKey, "node">];
221-
return okVersion !== undefined && sameLine(okVersion, toolchainVersion);
222-
});
234+
const builds = verified
235+
.flatMap(pinnedBuilds)
236+
.filter((build) => build.package === packageName && build.version === version);
237+
const successes = builds.filter((build) => build.outcome === "success");
238+
const pinsOf = (build: PinnedBuild): Pin[] => [
239+
...Object.entries(build.toolchains).map(([key, pinned]) => ({ key, version: pinned!, toolchain: true })),
240+
{ key: build.companion.package, version: build.companion.version, toolchain: false },
241+
];
242+
const samePin = (a: Pin, b: Pin) => a.key === b.key && (a.toolchain ? sameLine(a.version, b.version) : a.version === b.version);
243+
const provenToWork = (pin: Pin) => successes.some((ok) => pinsOf(ok).some((used) => samePin(used, pin)));
223244

224245
const result: VersionCompatibility["verified"] = {};
225-
const add = (toolchain: ToolchainKey, toolchainVersion: string) => {
226-
const list = (result[toolchain] ??= []);
227-
if (!list.includes(toolchainVersion)) {
228-
list.push(toolchainVersion);
246+
const add = (pin: Pin) => {
247+
if (!pin.toolchain) {
248+
return;
249+
}
250+
const list = (result[pin.key as ToolchainKey] ??= []);
251+
if (!list.includes(pin.version)) {
252+
list.push(pin.version);
229253
}
230254
};
231255

232256
if (outcome === "success") {
233-
for (const pair of verifiedPairs(verified, "success")) {
234-
if (pair.package === packageName && pair.version === version) {
235-
add(pair.toolchain, pair.toolchainVersion);
236-
}
257+
for (const ok of successes) {
258+
pinsOf(ok).forEach(add);
237259
}
238260
} else {
239-
const suspects = forThisVersion(verified)
240-
.filter((entry) => classify(entry) === outcome)
241-
.map((entry) =>
242-
(Object.entries(entry.toolchains) as Array<[ToolchainKey, string]>).filter(
243-
([toolchain, toolchainVersion]) => !provenToWork(toolchain, toolchainVersion),
244-
),
245-
);
261+
const suspects = builds
262+
.filter((build) => build.outcome === outcome)
263+
.map((build) => pinsOf(build).filter((pin) => !provenToWork(pin)));
246264
const culprits = suspects.filter((pins) => pins.length === 1).map(([pin]) => pin);
247265
for (const pins of suspects) {
248-
const explained = culprits.find(([toolchain, toolchainVersion]) =>
249-
pins.some(([key, value]) => key === toolchain && sameLine(value, toolchainVersion)),
250-
);
251-
for (const [toolchain, toolchainVersion] of explained ? [explained] : pins) {
252-
add(toolchain, toolchainVersion);
253-
}
266+
const explained = culprits.find((culprit) => pins.some((pin) => samePin(pin, culprit)));
267+
(explained ? [explained] : pins).forEach(add);
254268
}
255269
}
256270

‎shared/types.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -104,10 +104,10 @@ export interface Overrides {
104104
}
105105

106106
/**
107-
* One CI-proven build of a runtime version with every relevant version
108-
* pinned, stored as its own file under data/verified/. It verifies each
109-
* toolchain in `toolchains` for the runtime, and the Node.js major for the
110-
* CLI version it was built with.
107+
* One CI build of a runtime version with every relevant version pinned,
108+
* stored as its own file under data/verified/. It is evidence about the
109+
* runtime and about the CLI it was built with alike: each toolchain in
110+
* `toolchains` for both, and the Node.js major for the CLI.
111111
*/
112112
export interface VerificationResult {
113113
$schema?: string;

‎test/compute.test.ts‎

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -135,9 +135,10 @@ describe("summarizeCell", () => {
135135
import { verifiedPairs } from "../shared/compute";
136136

137137
describe("verifiedPairs", () => {
138-
it("derives runtime toolchain pairs and the CLI's Node.js pair from one build", () => {
138+
it("credits one build to the runtime and to the CLI it was built with", () => {
139139
expect(verifiedPairs(verified)).toEqual([
140140
{ package: "@nativescript/ios", version: "9.1.0", toolchain: "xcode", toolchainVersion: "26.2" },
141+
{ package: "nativescript", version: "9.1.1", toolchain: "xcode", toolchainVersion: "26.2" },
141142
{ package: "nativescript", version: "9.1.1", toolchain: "node", toolchainVersion: "22" },
142143
]);
143144
});
@@ -188,9 +189,9 @@ describe("failed builds", () => {
188189
expect(cellFor(doc(), "@nativescript/android", "9.1.1", "jdk", "21")).toMatchObject({ state: "unverified", reason: "one CI build failed; a second attempt is pending" });
189190
});
190191

191-
it("a success outranks a sibling failure and only successes prove the CLI's Node.js support", () => {
192+
it("a success outranks a sibling failure and proves the CLI's toolchains too", () => {
192193
expect(cellFor(doc(), "@nativescript/android", "9.1.1", "jdk", "17")).toMatchObject({ state: "verified", reason: "verified by CI" });
193-
expect(doc().packages["nativescript"].versions["9.1.1"].verified).toEqual({ node: ["24"] });
194+
expect(doc().packages["nativescript"].versions["9.1.1"].verified).toEqual({ jdk: ["17"], node: ["24"] });
194195
expect(doc().packages["@nativescript/android"].versions["9.1.1"].failed).toEqual({ jdk: ["25"] });
195196
// The JDK 17 failure is not attributed to JDK 17: another build of the release succeeded with it.
196197
expect(doc().packages["@nativescript/android"].versions["9.1.1"].suspect).toEqual({ jdk: ["21"] });
@@ -309,6 +310,45 @@ describe("failure attribution", () => {
309310
});
310311
});
311312

313+
describe("CLI attribution", () => {
314+
const build = (cli: string, xcode: string, extra: Partial<VerificationResult> = {}): VerificationResult => ({
315+
package: "@nativescript/ios", version: "9.0.2", toolchains: { xcode }, with: { nativescript: cli, node: "24" }, recordedAt: "2026-09-09T00:00:00Z", ...extra,
316+
});
317+
const failed = { outcome: "failure" as const, attempts: 2 };
318+
const doc = (verified: VerificationResult[]) =>
319+
buildDocument({
320+
generatedAt: "2026-09-09T00:00:00.000Z",
321+
toolchains: { xcode: [{ version: "27.0" }, { version: "26.4" }], cocoapods: [], compileSdk: [], buildTools: [], jdk: [], node: [{ version: "24.0.0" }] },
322+
packages: [
323+
{
324+
spec: { name: "@nativescript/ios", toolchains: ["xcode"], keep: 5 },
325+
document: { distTags: {}, manifests: [{ version: "9.0.2", requirements: { xcode: ">=16" } }] },
326+
},
327+
{
328+
spec: { name: "nativescript", toolchains: ["node", "xcode"], keep: 5 },
329+
document: { distTags: {}, manifests: [{ version: "9.1.1", requirements: { node: ">=20" } }, { version: "9.1.0", requirements: { node: ">=20" } }] },
330+
},
331+
],
332+
overrides: { requirements: [], advisories: [] },
333+
verified,
334+
});
335+
336+
it("blames the CLI, not the toolchain, when a newer CLI builds the same combination", () => {
337+
const d = doc([build("9.1.0", "27.0", failed), build("9.1.1", "27.0"), build("9.1.0", "26.4")]);
338+
expect(cellFor(d, "@nativescript/ios", "9.0.2", "xcode", "27.0").state).toBe("verified");
339+
expect(d.packages["@nativescript/ios"].versions["9.0.2"].failed).toBeUndefined();
340+
expect(cellFor(d, "nativescript", "9.1.0", "xcode", "27.0")).toMatchObject({ state: "unsupported" });
341+
expect(cellFor(d, "nativescript", "9.1.1", "xcode", "27.0").state).toBe("verified");
342+
expect(d.packages["nativescript"].versions["9.1.0"].verified).toEqual({ xcode: ["26.4"], node: ["24"] });
343+
});
344+
345+
it("blames both until another CLI proves the toolchain", () => {
346+
const d = doc([build("9.1.0", "27.0", failed), build("9.1.0", "26.4")]);
347+
expect(cellFor(d, "@nativescript/ios", "9.0.2", "xcode", "27.0").state).toBe("unsupported");
348+
expect(cellFor(d, "nativescript", "9.1.0", "xcode", "27.0").state).toBe("unsupported");
349+
});
350+
});
351+
312352
describe("summarizeToolchain", () => {
313353
it("names the run of releases that support a toolchain version", () => {
314354
const doc = document();

‎worker/index.ts‎

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import overridesSchema from "../schemas/overrides.json";
33
import packageSchema from "../schemas/package.json";
44
import requirementsSchema from "../schemas/requirements.json";
55
import verifiedSchema from "../schemas/verified.json";
6-
import { buildDocument, collectOverrides, matchingAdvisories, TRACKED_PACKAGES } from "../shared/compute";
6+
import { buildDocument, CLI_PACKAGE, collectOverrides, matchingAdvisories, TRACKED_PACKAGES } from "../shared/compute";
77
import { fetchPackage } from "./sources/npm";
88
import { fetchToolchains } from "./sources/toolchains";
99
import type { CompatibilityDocument, OverrideEntry, ToolchainKey, VerificationResult } from "../shared/types";
@@ -61,7 +61,8 @@ export default {
6161
return Response.json(document, { headers: cacheHeaders() });
6262
}
6363

64-
// /v1/packages/@scope/name/1.2.3?xcode=26.2&jdk=21 → effective requirements plus matching advisories
64+
// /v1/packages/@scope/name/1.2.3?xcode=26.2&jdk=21&cli=9.1.1 → effective requirements plus
65+
// matching advisories, and the same for the CLI release when one is given
6566
const match = url.pathname.match(/^\/v1\/packages\/((?:@[^/]+\/)?[^/]+)\/([^/]+)$/);
6667
if (match) {
6768
const [, name, version] = match.map(decodeURIComponent);
@@ -70,15 +71,28 @@ export default {
7071
return Response.json({ error: "unknown package version" }, { status: 404 });
7172
}
7273
const toolchain: Partial<Record<ToolchainKey, string>> = {};
74+
let cli: string | undefined;
7375
for (const [key, value] of url.searchParams) {
74-
toolchain[key as ToolchainKey] = value;
76+
if (key === "cli") {
77+
cli = value;
78+
} else {
79+
toolchain[key as ToolchainKey] = value;
80+
}
7581
}
82+
const describe = (pkg: string, release: string) => {
83+
const known = document.packages[pkg]?.versions[release];
84+
return {
85+
package: pkg,
86+
version: release,
87+
...(known
88+
? { ...known, advisories: matchingAdvisories(document.advisories, pkg, release, toolchain) }
89+
: { tracked: false }),
90+
};
91+
};
7692
return Response.json(
7793
{
78-
package: name,
79-
version,
80-
...entry,
81-
advisories: matchingAdvisories(document.advisories, name, version, toolchain),
94+
...describe(name, version),
95+
...(cli ? { cli: describe(CLI_PACKAGE, cli) } : {}),
8296
},
8397
{ headers: cacheHeaders() },
8498
);

0 commit comments

Comments
 (0)