|
1 | 1 | import { spawnSync } from "node:child_process"; |
2 | 2 | import { |
3 | 3 | existsSync, |
| 4 | + mkdirSync, |
| 5 | + mkdtempSync, |
4 | 6 | readdirSync, |
5 | 7 | readFileSync, |
6 | 8 | rmSync, |
7 | 9 | writeFileSync, |
8 | 10 | } from "node:fs"; |
| 11 | +import { tmpdir } from "node:os"; |
9 | 12 | import { join, relative } from "node:path"; |
10 | 13 | import { describe, expect, test } from "bun:test"; |
11 | 14 |
|
@@ -240,7 +243,10 @@ describe("live knip scan semantics", () => { |
240 | 243 |
|
241 | 244 | test("production agent scan removes test-file consumers from the surface", () => { |
242 | 245 | const rules = loadAllowlist(); |
243 | | - const spawn = knipAgentProductionSpawn(knipBin, agentWorkspaceIds(rules)); |
| 246 | + const spawn = knipAgentProductionSpawn( |
| 247 | + knipBin, |
| 248 | + agentWorkspaceIds(join(repoRoot, "agents")), |
| 249 | + ); |
244 | 250 | const ran = spawnSync(spawn.command, spawn.args, { |
245 | 251 | cwd: repoRoot, |
246 | 252 | encoding: "utf8", |
@@ -449,19 +455,24 @@ describe("pinned scan invocation", () => { |
449 | 455 | expect(() => knipAgentProductionSpawn("/repo/x", [])).toThrow(); |
450 | 456 | }); |
451 | 457 |
|
452 | | - test("agent workspace ids derive from the allowlist and fail closed", () => { |
453 | | - const rules = parseAllowlistText( |
454 | | - "agents/coder/src/index.ts: agent\n" + |
455 | | - "agents/coder/src/index.ts: agent\n" + |
456 | | - "agents/dispatch/src/index.ts: agent\n" + |
457 | | - "src/auth/codex/constants.ts: CODEX_BASE_URL\n", |
458 | | - ); |
459 | | - // Parse dedupes nothing at rule level; the derivation dedupes ids. |
460 | | - expect(agentWorkspaceIds(rules)).toEqual([ |
461 | | - "agents/coder", |
462 | | - "agents/dispatch", |
463 | | - ]); |
464 | | - expect(() => agentWorkspaceIds(parseAllowlistText("vendor/\n"))).toThrow(); |
| 458 | + test("agent workspace ids are the agents directory and fail closed", () => { |
| 459 | + const agentsRoot = join(repoRoot, "agents"); |
| 460 | + const fromDisk = readdirSync(agentsRoot, { withFileTypes: true }) |
| 461 | + .filter( |
| 462 | + (entry) => |
| 463 | + entry.isDirectory() && |
| 464 | + existsSync(join(agentsRoot, entry.name, "package.json")), |
| 465 | + ) |
| 466 | + .map((entry) => `agents/${entry.name}`) |
| 467 | + .sort(); |
| 468 | + expect(fromDisk.length).toBeGreaterThan(0); |
| 469 | + expect(agentWorkspaceIds(agentsRoot)).toEqual(fromDisk); |
| 470 | + const empty = mkdtempSync(join(tmpdir(), "dead-export-agents-")); |
| 471 | + try { |
| 472 | + expect(() => agentWorkspaceIds(empty)).toThrow(); |
| 473 | + } finally { |
| 474 | + rmSync(empty, { recursive: true, force: true }); |
| 475 | + } |
465 | 476 | }); |
466 | 477 |
|
467 | 478 | test("parseGuardConfig rejects a missing tsconfig or floor and extra keys", () => { |
@@ -543,6 +554,65 @@ describe("violation end to end", () => { |
543 | 554 | rmSync(probePath, { force: true }); |
544 | 555 | } |
545 | 556 | }, 240_000); |
| 557 | + |
| 558 | + test("a test-only export in an extra agent workspace fails the guard", () => { |
| 559 | + const id = `dead-export-guard-agent-${process.pid}`; |
| 560 | + const exportName = `deadExportGuardAgentProbe${process.pid}`; |
| 561 | + const dir = join(repoRoot, "agents", id); |
| 562 | + mkdirSync(join(dir, "src"), { recursive: true }); |
| 563 | + writeFileSync( |
| 564 | + join(dir, "package.json"), |
| 565 | + JSON.stringify({ |
| 566 | + name: `@corbits/code-agent-${id}`, |
| 567 | + private: true, |
| 568 | + type: "module", |
| 569 | + }), |
| 570 | + ); |
| 571 | + writeFileSync( |
| 572 | + join(dir, "src", "index.ts"), |
| 573 | + `export const ${exportName} = 1;\n`, |
| 574 | + ); |
| 575 | + writeFileSync( |
| 576 | + join(dir, "src", "index.test.ts"), |
| 577 | + `import { ${exportName} } from "./index.js";\nvoid ${exportName};\n`, |
| 578 | + ); |
| 579 | + try { |
| 580 | + const knipBin = join(repoRoot, "node_modules", ".bin", "knip"); |
| 581 | + const defaultSpawn = knipDefaultSpawn(knipBin); |
| 582 | + const defaultRun = spawnSync(defaultSpawn.command, defaultSpawn.args, { |
| 583 | + cwd: repoRoot, |
| 584 | + encoding: "utf8", |
| 585 | + }); |
| 586 | + expect(defaultRun.status).toBe(0); |
| 587 | + const defaultFindings = parseKnipReport(String(defaultRun.stdout)); |
| 588 | + expect( |
| 589 | + defaultFindings.some( |
| 590 | + (finding) => |
| 591 | + finding.file === `agents/${id}/src/index.ts` && |
| 592 | + finding.name === exportName, |
| 593 | + ), |
| 594 | + ).toBe(false); |
| 595 | + |
| 596 | + const ran = spawnSync( |
| 597 | + process.execPath, |
| 598 | + ["scripts/check-dead-exports.ts"], |
| 599 | + { |
| 600 | + cwd: repoRoot, |
| 601 | + encoding: "utf8", |
| 602 | + env: { |
| 603 | + ...process.env, |
| 604 | + DEAD_EXPORT_GUARD_SCANNED_FILES: String(liveScannedFileCount()), |
| 605 | + }, |
| 606 | + }, |
| 607 | + ); |
| 608 | + expect(ran.status).toBe(1); |
| 609 | + expect(`${ran.stdout}\n${ran.stderr}`).toContain( |
| 610 | + `agents/${id}/src/index.ts: ${exportName}`, |
| 611 | + ); |
| 612 | + } finally { |
| 613 | + rmSync(dir, { recursive: true, force: true }); |
| 614 | + } |
| 615 | + }, 240_000); |
546 | 616 | }); |
547 | 617 |
|
548 | 618 | // A re-created barrel would silently resurrect a surface the purge removed. |
|
0 commit comments