Skip to content
Open
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
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ jobs:
mkdir -p ../../npm-dist
npm pack --pack-destination ../../npm-dist
node ../../tools/smoke_npm.mjs ../../npm-dist/*.tgz
- name: Reject broken npm declarations
run: node ../../tools/test_smoke_npm.mjs ../../npm-dist/*.tgz

test:
runs-on: ubuntu-latest
Expand Down
24 changes: 24 additions & 0 deletions tools/npm-consumer.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// Compiled in a temporary consumer of the packed artifact, never the checkout.
import {EventFactory, EventValidationError, SchemaValidator} from "@agentrust-io/telemetry";
import type {EnvelopeFields, NormalizedEvent} from "@agentrust-io/telemetry";

const validator: SchemaValidator = SchemaValidator.bundled();
const fields: EnvelopeFields = {runId: "run-1", timeUnixNano: 1787079000000000000n};
const factory = new EventFactory(validator, {name: "consumer", version: "1.0.0"});
const event: NormalizedEvent = factory.build("usage.recorded", fields,
{scope: "model_call", operation: "chat", input_tokens: 7});
const runId: string = event.run_id;
const candidate: unknown = event;
validator.validate(candidate);
const validated: NormalizedEvent = candidate;
const error: Error = new EventValidationError("invalid event");
void [runId, validated, error];

// Guard against declarations silently widening the consumer API to any.
// @ts-expect-error runId must be a string
factory.build("usage.recorded", {runId: 42}, {});
// @ts-expect-error the envelope requires runId
factory.build("usage.recorded", {}, {});
// @ts-expect-error normalized event IDs are strings
const numericRunId: number = event.run_id;
void numericRunId;
20 changes: 18 additions & 2 deletions tools/smoke_npm.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Exercise the packed consumer surface outside the checkout, without dev tools.
// Exercise the packed consumer surface outside the checkout.
import assert from "node:assert/strict";
import {execFileSync} from "node:child_process";
import {mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync} from "node:fs";
Expand Down Expand Up @@ -60,7 +60,23 @@ test("installed event construction matches the golden control", () => {
{scope: "model_call", operation: "chat", input_tokens: 7}), golden);
});
`);
execFileSync(process.execPath, ["--test", "smoke.test.mjs"], {cwd: directory, stdio: "inherit"});
execFileSync(process.execPath, ["--test", "--test-reporter=tap", "smoke.test.mjs"],
{cwd: directory, stdio: "inherit"});
// Use the compiler installed by npm ci (and pinned by package-lock.json),
// but resolve SDK imports only from the isolated consumer's node_modules.
writeFileSync(join(directory, "consumer.mts"),
readFileSync(join(root, "tools/npm-consumer.mts"), "utf8"));
writeFileSync(join(directory, "tsconfig.json"), JSON.stringify({
compilerOptions: {
target: "ES2022", module: "NodeNext", moduleResolution: "NodeNext",
strict: true, noEmit: true, skipLibCheck: false, types: [],
},
files: ["consumer.mts"],
}));
execFileSync(process.execPath,
[join(root, "packages/typescript/node_modules/typescript/bin/tsc"), "-p", "tsconfig.json"],
{cwd: directory, stdio: "inherit"});
console.log("Installed TypeScript consumer compiled successfully");
} finally {
rmSync(directory, {recursive: true, force: true});
}
56 changes: 56 additions & 0 deletions tools/test_smoke_npm.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// Regression controls: JavaScript remains intact while declarations are broken.
import assert from "node:assert/strict";
import {execFileSync, spawnSync} from "node:child_process";
import {mkdtempSync, readFileSync, rmSync, writeFileSync} from "node:fs";
import {tmpdir} from "node:os";
import {join, resolve} from "node:path";
import test from "node:test";
import {fileURLToPath} from "node:url";

assert.equal(process.argv.length, 3, "usage: node tools/test_smoke_npm.mjs distribution.tgz");
const distribution = resolve(process.argv[2]);
const smoke = fileURLToPath(new URL("smoke_npm.mjs", import.meta.url));
const controls = [
{
name: "empty declaration exports", diagnostic: /TS2305/,
mutate(directory) {
writeFileSync(join(directory, "package/dist/index.d.ts"), "export {};\n");
},
},
{
name: "missing transitive declaration", diagnostic: /TS7016/,
mutate(directory) {
rmSync(join(directory, "package/dist/types.d.ts"));
},
},
{
name: "widened input type", diagnostic: /TS2578/,
mutate(directory) {
const path = join(directory, "package/dist/factory.d.ts");
const original = readFileSync(path, "utf8");
assert.match(original, /runId: string;/);
writeFileSync(path, original.replace("runId: string;", "runId: any;"));
},
},
];

for (const control of controls) {
test(`packed consumer rejects ${control.name}`, () => {
const directory = mkdtempSync(join(tmpdir(), "agentrust-npm-regression-"));
try {
execFileSync("tar", ["-xzf", distribution, "-C", directory]);
control.mutate(directory);
const mutant = join(directory, "mutant.tgz");
execFileSync("tar", ["-czf", mutant, "-C", directory, "package"]);
const result = spawnSync(process.execPath, [smoke, mutant], {encoding: "utf8"});
assert.ifError(result.error);
const output = result.stdout + result.stderr;
// Confirm failure is in declaration compilation, not install/runtime.
assert.match(output, /# pass 4/, output);
assert.equal(result.status, 1, output);
assert.match(output, control.diagnostic, output);
} finally {
rmSync(directory, {recursive: true, force: true});
}
});
}
Loading