diff --git a/.changeset/bright-cycles-sync.md b/.changeset/bright-cycles-sync.md new file mode 100644 index 00000000..908cbda4 --- /dev/null +++ b/.changeset/bright-cycles-sync.md @@ -0,0 +1,5 @@ +--- +'@ankhorage/devtools': minor +--- + +Add a centrally managed PKGViz cyclic-dependency CI audit for repositories with source trees, including a pinned tool invocation and diagnostic audit artifact upload. diff --git a/src/policy/resolvePkgvizAuditPolicyAsync.test.ts b/src/policy/resolvePkgvizAuditPolicyAsync.test.ts new file mode 100644 index 00000000..eff8dd6b --- /dev/null +++ b/src/policy/resolvePkgvizAuditPolicyAsync.test.ts @@ -0,0 +1,44 @@ +import { mkdir, mkdtemp, rm } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { afterEach, describe, expect, test } from 'bun:test'; + +import { resolvePkgvizAuditPolicyAsync } from './resolvePkgvizAuditPolicyAsync.js'; + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((path) => rm(path, { force: true, recursive: true })), + ); +}); + +describe('PKGViz audit policy', () => { + test('pins the blocking cycle audit for repositories with source', async () => { + const target = await createTarget(); + await mkdir(join(target, 'src')); + + const policy = await resolvePkgvizAuditPolicyAsync(target); + + expect(policy).toEqual({ + artifactName: 'pkgviz-audit', + artifactPath: 'pkgviz-audit.json', + command: 'bunx pkgviz@0.8.1 --out pkgviz-audit.json --rule cyclic-dependencies=block', + }); + }); + + test('does not enable PKGViz for repositories without a source tree', async () => { + const target = await createTarget(); + + const policy = await resolvePkgvizAuditPolicyAsync(target); + + expect(policy).toBeUndefined(); + }); +}); + +/*** Create an isolated managed-repository fixture. */ +async function createTarget(): Promise { + const target = await mkdtemp('/tmp/devtools-pkgviz-audit-'); + temporaryDirectories.push(target); + return target; +} diff --git a/src/policy/resolvePkgvizAuditPolicyAsync.ts b/src/policy/resolvePkgvizAuditPolicyAsync.ts new file mode 100644 index 00000000..2bd9b4f7 --- /dev/null +++ b/src/policy/resolvePkgvizAuditPolicyAsync.ts @@ -0,0 +1,36 @@ +import { stat } from 'node:fs/promises'; +import { join } from 'node:path'; + +/*** Resolve the centrally pinned PKGViz CI audit for repositories with analyzable source. */ +export async function resolvePkgvizAuditPolicyAsync( + targetDirectory: string, +): Promise { + if (!(await hasSourceDirectoryAsync(targetDirectory))) return undefined; + + return { + artifactName: 'pkgviz-audit', + artifactPath: 'pkgviz-audit.json', + command: 'bunx pkgviz@0.8.1 --out pkgviz-audit.json --rule cyclic-dependencies=block', + }; +} + +interface PkgvizAuditPolicy { + readonly artifactName: string; + readonly artifactPath: string; + readonly command: string; +} + +/*** Detect whether the managed repository has a source tree that PKGViz can inspect. */ +async function hasSourceDirectoryAsync(targetDirectory: string): Promise { + try { + return (await stat(join(targetDirectory, 'src'))).isDirectory(); + } catch (error) { + if (isNodeError(error) && error.code === 'ENOENT') return false; + throw error; + } +} + +/*** Narrow filesystem failures to Node errors with stable error codes. */ +function isNodeError(error: unknown): error is NodeJS.ErrnoException { + return error instanceof Error && 'code' in error; +} diff --git a/src/tools/workflows/files/ci.yml b/src/tools/workflows/files/ci.yml index 26b43611..7b8f13f2 100644 --- a/src/tools/workflows/files/ci.yml +++ b/src/tools/workflows/files/ci.yml @@ -99,3 +99,5 @@ jobs: else echo "No changeset:status script found; skipping." fi + + # __ANKH_PKGVIZ_AUDIT_STEPS__ diff --git a/src/tools/workflows/index.test.ts b/src/tools/workflows/index.test.ts index f459d23b..715604b7 100644 --- a/src/tools/workflows/index.test.ts +++ b/src/tools/workflows/index.test.ts @@ -148,6 +148,31 @@ test('managed release synchronizes main before build and recovers a matching his expect(release).toContain('echo "versioned=false" >> "$GITHUB_OUTPUT"'); }); +describe('managed PKGViz audit', () => { + test('renders the pinned blocking audit and diagnostic artifact for source repositories', async () => { + const target = await createWorkflowTarget(); + await mkdir(join(target, 'src')); + + const ci = await workflowManagedFiles[0].render?.(target); + + expect(ci).toContain( + 'bunx pkgviz@0.8.1 --out pkgviz-audit.json --rule cyclic-dependencies=block', + ); + expect(ci).toContain("if: always() && hashFiles('pkgviz-audit.json') != ''"); + expect(ci).toContain('name: pkgviz-audit'); + expect(ci).toContain('path: pkgviz-audit.json'); + }); + + test('omits the audit from repositories without analyzable source', async () => { + const target = await createWorkflowTarget(); + + const ci = await workflowManagedFiles[0].render?.(target); + + expect(ci).not.toContain('Enforce PKGViz cyclic-dependencies rule'); + expect(ci).not.toContain('pkgviz-audit.json'); + }); +}); + describe('managed CI Changesets contract', () => { test('keeps the missing-Changeset guard strict for every ordinary pull request', async () => { const ci = await workflowManagedFiles[0].render?.('.'); diff --git a/src/tools/workflows/index.ts b/src/tools/workflows/index.ts index 9ae0b1b1..88f572f4 100644 --- a/src/tools/workflows/index.ts +++ b/src/tools/workflows/index.ts @@ -4,6 +4,7 @@ import { join } from 'node:path'; import { resolveApmReleaseCommandAsync } from '../../features/apm-release-validation/adapters/outbound/resolveApmReleaseCommandAsync.js'; import { resolveStructureReleaseCommandAsync } from '../../features/structure-descriptor-generation/adapters/outbound/resolveStructureReleaseCommandAsync.js'; import { bunRuntimePolicy, nodeRuntimePolicy } from '../../policy/bunRuntimePolicy.js'; +import { resolvePkgvizAuditPolicyAsync } from '../../policy/resolvePkgvizAuditPolicyAsync.js'; import type { ManagedFileDefinition } from '../shared/managedFiles.js'; import { readCurrentDoctorVersion } from './readCurrentDoctorVersion.js'; import { renderRenovateWorkflowAsync } from './renderRenovateWorkflowAsync.js'; @@ -43,6 +44,7 @@ function createWorkflowDefinition(relativePath: string, sourcePath: string): Man bunVersion: bunRuntimePolicy.version, doctorVersion: readCurrentDoctorVersion(), nodeVersion: nodeRuntimePolicy.setupVersion, + pkgvizAudit: await resolvePkgvizAuditPolicyAsync(targetDirectory), }), }; } diff --git a/src/tools/workflows/renderWorkflowAsync.ts b/src/tools/workflows/renderWorkflowAsync.ts index 2b1a71f0..dcf3f890 100644 --- a/src/tools/workflows/renderWorkflowAsync.ts +++ b/src/tools/workflows/renderWorkflowAsync.ts @@ -7,6 +7,11 @@ export interface WorkflowPolicy { readonly doctorVersion: string; readonly nodeVersion: string; readonly apmReleaseCommand?: string; + readonly pkgvizAudit?: { + readonly artifactName: string; + readonly artifactPath: string; + readonly command: string; + }; readonly structureReleaseCommand?: string; } @@ -15,6 +20,7 @@ export async function renderWorkflowAsync(sourceUrl: URL, policy: WorkflowPolicy const template = await readFile(sourceUrl, 'utf8'); return template .replaceAll(BUN_VERSION_TOKEN, policy.bunVersion) + .replaceAll(PKGVIZ_AUDIT_STEPS_TOKEN, renderPkgvizAuditSteps(policy)) .replaceAll(CHANGESETS_PUBLISH_COMMAND_TOKEN, changesetsPolicy.workflowCommands.publish) .replaceAll(CHANGESETS_STATUS_COMMAND_TOKEN, changesetsPolicy.workflowCommands.status) .replaceAll(CHANGESETS_VERSION_COMMAND_TOKEN, changesetsPolicy.workflowCommands.version) @@ -36,3 +42,21 @@ const CHANGESETS_STATUS_COMMAND_TOKEN = '__ANKH_CHANGESETS_STATUS_COMMAND__'; const CHANGESETS_VERSION_COMMAND_TOKEN = '__ANKH_CHANGESETS_VERSION_COMMAND__'; const DOCTOR_VERSION_TOKEN = '__ANKH_DOCTOR_VERSION__'; const NODE_VERSION_TOKEN = '__ANKH_NODE_VERSION__'; + +/*** Render the centrally managed PKGViz audit steps only for applicable repositories. */ +function renderPkgvizAuditSteps(policy: WorkflowPolicy): string { + if (policy.pkgvizAudit === undefined) return ''; + + return ` - name: Enforce PKGViz cyclic-dependencies rule + run: ${policy.pkgvizAudit.command} + + - name: Upload PKGViz audit + if: always() && hashFiles('${policy.pkgvizAudit.artifactPath}') != '' + uses: actions/upload-artifact@v4 + with: + name: ${policy.pkgvizAudit.artifactName} + path: ${policy.pkgvizAudit.artifactPath} +`; +} + +const PKGVIZ_AUDIT_STEPS_TOKEN = ' # __ANKH_PKGVIZ_AUDIT_STEPS__';