chore: import upstream snapshot with attribution
CI / Shell Format Check (push) Has been cancelled
CI / Check Ruby (3.4) (push) Has been cancelled
CI / CI Config (push) Has been cancelled
CI / Test on Node ${{ matrix.node }} and ${{ matrix.os }}${{ matrix.shard && format(' (shard {0}/3)', matrix.shard) || '' }} (push) Has been cancelled
CI / Build on Node ${{ matrix.node }} (push) Has been cancelled
CI / Style Check (push) Has been cancelled
CI / Generate Assets (push) Has been cancelled
CI / Check Python (3.14) (push) Has been cancelled
CI / Check Python (3.9) (push) Has been cancelled
CI / Build Docs (push) Has been cancelled
CI / Code Scan Action (push) Has been cancelled
CI / Site tests (push) Has been cancelled
CI / webui tests (push) Has been cancelled
CI / Run Integration Tests (push) Has been cancelled
CI / Run Smoke Tests (push) Has been cancelled
CI / Go Tests (push) Has been cancelled
CI / Share Test (push) Has been cancelled
CI / Redteam (Production API) (push) Has been cancelled
CI / Redteam (Staging API) (push) Has been cancelled
CI / GitHub Actions Lint (push) Has been cancelled
CI / Check Ruby (3.0) (push) Has been cancelled
release-please / release-please (push) Has been cancelled
release-please / build (push) Has been cancelled
release-please / publish-npm (push) Has been cancelled
release-please / publish-npm-backfill (push) Has been cancelled
release-please / docker (push) Has been cancelled
release-please / publish-code-scan-action (push) Has been cancelled
release-please / attest-code-scan-action (push) Has been cancelled
Deploy local.promptfoo.app / Deploy to Cloudflare Pages (push) Has been cancelled
Test and Publish Multi-arch Docker Image / test (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-amd64 platform:linux/amd64 runner:ubuntu-latest]) (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-arm64 platform:linux/arm64 runner:ubuntu-24.04-arm]) (push) Has been cancelled
Test and Publish Multi-arch Docker Image / merge-docker-digests (push) Has been cancelled
Test and Publish Multi-arch Docker Image / Attest Multi-arch Image (push) Has been cancelled
Validate Renovate Config / Validate Renovate Configuration (push) Has been cancelled
CI / Shell Format Check (push) Has been cancelled
CI / Check Ruby (3.4) (push) Has been cancelled
CI / CI Config (push) Has been cancelled
CI / Test on Node ${{ matrix.node }} and ${{ matrix.os }}${{ matrix.shard && format(' (shard {0}/3)', matrix.shard) || '' }} (push) Has been cancelled
CI / Build on Node ${{ matrix.node }} (push) Has been cancelled
CI / Style Check (push) Has been cancelled
CI / Generate Assets (push) Has been cancelled
CI / Check Python (3.14) (push) Has been cancelled
CI / Check Python (3.9) (push) Has been cancelled
CI / Build Docs (push) Has been cancelled
CI / Code Scan Action (push) Has been cancelled
CI / Site tests (push) Has been cancelled
CI / webui tests (push) Has been cancelled
CI / Run Integration Tests (push) Has been cancelled
CI / Run Smoke Tests (push) Has been cancelled
CI / Go Tests (push) Has been cancelled
CI / Share Test (push) Has been cancelled
CI / Redteam (Production API) (push) Has been cancelled
CI / Redteam (Staging API) (push) Has been cancelled
CI / GitHub Actions Lint (push) Has been cancelled
CI / Check Ruby (3.0) (push) Has been cancelled
release-please / release-please (push) Has been cancelled
release-please / build (push) Has been cancelled
release-please / publish-npm (push) Has been cancelled
release-please / publish-npm-backfill (push) Has been cancelled
release-please / docker (push) Has been cancelled
release-please / publish-code-scan-action (push) Has been cancelled
release-please / attest-code-scan-action (push) Has been cancelled
Deploy local.promptfoo.app / Deploy to Cloudflare Pages (push) Has been cancelled
Test and Publish Multi-arch Docker Image / test (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-amd64 platform:linux/amd64 runner:ubuntu-latest]) (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-arm64 platform:linux/arm64 runner:ubuntu-24.04-arm]) (push) Has been cancelled
Test and Publish Multi-arch Docker Image / merge-docker-digests (push) Has been cancelled
Test and Publish Multi-arch Docker Image / Attest Multi-arch Image (push) Has been cancelled
Validate Renovate Config / Validate Renovate Configuration (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,346 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildEdgeBaseline,
|
||||
type CrossLayerEdge,
|
||||
compareEdgesToBaseline,
|
||||
computeCrossLayerEdges,
|
||||
computeStronglyConnectedComponents,
|
||||
edgeKey,
|
||||
findBackEdges,
|
||||
findForbiddenDependencyViolations,
|
||||
type LayerConfig,
|
||||
readEdgeBaseline,
|
||||
readLayerConfig,
|
||||
scanArchitectureSources,
|
||||
} from '../../scripts/architectureUtils';
|
||||
|
||||
function edge(from: string, to: string, count: number): CrossLayerEdge {
|
||||
return { from, to, count, files: [] };
|
||||
}
|
||||
|
||||
function configWith(layers: LayerConfig['layers']): LayerConfig {
|
||||
return { publicFacade: 'src/index.ts', layers };
|
||||
}
|
||||
|
||||
describe('computeCrossLayerEdges', () => {
|
||||
let repoRoot: string;
|
||||
|
||||
beforeEach(() => {
|
||||
repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'archdag-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(repoRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function write(relativePath: string, contents = ''): void {
|
||||
const absolute = path.join(repoRoot, relativePath);
|
||||
fs.mkdirSync(path.dirname(absolute), { recursive: true });
|
||||
fs.writeFileSync(absolute, contents);
|
||||
}
|
||||
|
||||
it('tallies cross-layer import edges and skips the facade importer and same-layer imports', () => {
|
||||
write(
|
||||
'architecture/layers.json',
|
||||
JSON.stringify({
|
||||
publicFacade: 'src/index.ts',
|
||||
leafLayers: ['contracts'],
|
||||
layers: [
|
||||
{ name: 'facade', roots: ['src/index.ts'], allowedDependencies: [] },
|
||||
{ name: 'app', roots: ['src/app'], allowedDependencies: ['core', 'contracts'] },
|
||||
{ name: 'core', roots: ['src/core'], allowedDependencies: ['contracts'] },
|
||||
{ name: 'contracts', roots: ['src/contracts'], allowedDependencies: [] },
|
||||
],
|
||||
}),
|
||||
);
|
||||
write('src/index.ts', "export * from './core/b.js';"); // facade importer is skipped
|
||||
write('src/contracts/c.ts', 'export const c = 1;');
|
||||
write('src/core/b.ts', "import { c } from '../contracts/c.js';");
|
||||
write(
|
||||
'src/app/a.ts',
|
||||
"import { b } from '../core/b.js';\nimport { c } from '../contracts/c.js';",
|
||||
);
|
||||
write('src/app/a2.ts', "import { b } from '../core/b.js';"); // 2nd app->core importer
|
||||
|
||||
const config = readLayerConfig(repoRoot);
|
||||
const sourceScan = scanArchitectureSources(repoRoot, config);
|
||||
const edges = computeCrossLayerEdges(repoRoot, config, sourceScan);
|
||||
|
||||
expect(sourceScan.sourceFiles).toHaveLength(5);
|
||||
expect(edges).toEqual([
|
||||
{ from: 'app', to: 'contracts', count: 1, files: ['src/app/a.ts'] },
|
||||
{ from: 'app', to: 'core', count: 2, files: ['src/app/a.ts', 'src/app/a2.ts'] },
|
||||
{ from: 'core', to: 'contracts', count: 1, files: ['src/core/b.ts'] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeStronglyConnectedComponents', () => {
|
||||
it('returns a multi-layer component for a cycle and singletons otherwise, largest first', () => {
|
||||
const config = configWith([
|
||||
{ name: 'a', roots: ['src/a'], allowedDependencies: [] },
|
||||
{ name: 'b', roots: ['src/b'], allowedDependencies: [] },
|
||||
{ name: 'c', roots: ['src/c'], allowedDependencies: [] },
|
||||
]);
|
||||
const edges = [edge('a', 'b', 1), edge('b', 'a', 1), edge('c', 'a', 1)];
|
||||
|
||||
expect(computeStronglyConnectedComponents(config, edges)).toEqual([['a', 'b'], ['c']]);
|
||||
});
|
||||
|
||||
it('reports every layer as its own component when the graph is a DAG', () => {
|
||||
const config = configWith([
|
||||
{ name: 'a', roots: ['src/a'], allowedDependencies: [] },
|
||||
{ name: 'b', roots: ['src/b'], allowedDependencies: [] },
|
||||
]);
|
||||
const components = computeStronglyConnectedComponents(config, [edge('a', 'b', 3)]);
|
||||
expect(components.every((component) => component.length === 1)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findBackEdges', () => {
|
||||
it('flags edges from a lower tier to a higher tier (cycle-causing)', () => {
|
||||
const tierOrder = ['contracts', 'core', 'app'];
|
||||
const edges = [
|
||||
edge('app', 'core', 4), // high -> low: allowed
|
||||
edge('core', 'contracts', 2), // high -> low: allowed
|
||||
edge('contracts', 'core', 1), // low -> high: BACK
|
||||
edge('core', 'app', 3), // low -> high: BACK
|
||||
edge('app', 'unranked', 9), // touches a layer absent from tierOrder: ignored
|
||||
];
|
||||
|
||||
expect(findBackEdges(edges, tierOrder)).toEqual([
|
||||
edge('contracts', 'core', 1),
|
||||
edge('core', 'app', 3),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge baseline', () => {
|
||||
it('builds a baseline keyed by edge with import counts', () => {
|
||||
const config = configWith([
|
||||
{ name: 'a', roots: ['src/a'], allowedDependencies: [] },
|
||||
{ name: 'b', roots: ['src/b'], allowedDependencies: [] },
|
||||
{ name: 'c', roots: ['src/c'], allowedDependencies: [] },
|
||||
]);
|
||||
|
||||
expect(buildEdgeBaseline([edge('a', 'b', 2), edge('a', 'c', 5)], config)).toEqual({
|
||||
[edgeKey('a', 'b')]: 2,
|
||||
[edgeKey('a', 'c')]: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects edges that reference unclassified or unknown layers', () => {
|
||||
const config = configWith([
|
||||
{ name: 'a', roots: ['src/a'], allowedDependencies: [] },
|
||||
{ name: 'b', roots: ['src/b'], allowedDependencies: [] },
|
||||
]);
|
||||
|
||||
expect(() => buildEdgeBaseline([edge('unclassified', 'a', 1)], config)).toThrow(
|
||||
'references an unknown layer',
|
||||
);
|
||||
expect(() => buildEdgeBaseline([edge('a', 'ghost', 1)], config)).toThrow(
|
||||
'references an unknown layer',
|
||||
);
|
||||
});
|
||||
|
||||
it('reports grown/new edges as regressions and shrunk/removed edges as improvements', () => {
|
||||
const baseline = { [edgeKey('a', 'b')]: 5, [edgeKey('c', 'd')]: 2, [edgeKey('g', 'h')]: 3 };
|
||||
const edges = [
|
||||
edge('a', 'b', 6), // grew -> regression
|
||||
edge('c', 'd', 1), // shrank -> improvement
|
||||
edge('e', 'f', 1), // new edge (baseline 0) -> regression
|
||||
];
|
||||
|
||||
const { regressions, improvements } = compareEdgesToBaseline(edges, baseline);
|
||||
|
||||
expect(regressions).toEqual([
|
||||
{ from: 'a', to: 'b', count: 6, allowed: 5 },
|
||||
{ from: 'e', to: 'f', count: 1, allowed: 0 },
|
||||
]);
|
||||
expect(improvements).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ from: 'c', to: 'd', count: 1, allowed: 2 },
|
||||
{ from: 'g', to: 'h', count: 0, allowed: 3 }, // disappeared entirely
|
||||
]),
|
||||
);
|
||||
expect(improvements).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('treats an unchanged edge as neither a regression nor an improvement', () => {
|
||||
const result = compareEdgesToBaseline([edge('a', 'b', 4)], { [edgeKey('a', 'b')]: 4 });
|
||||
expect(result.regressions).toEqual([]);
|
||||
expect(result.improvements).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('readEdgeBaseline', () => {
|
||||
let repoRoot: string;
|
||||
const config = configWith([
|
||||
{ name: 'a', roots: ['src/a'], allowedDependencies: [] },
|
||||
{ name: 'b', roots: ['src/b'], allowedDependencies: [] },
|
||||
]);
|
||||
|
||||
beforeEach(() => {
|
||||
repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'archdag-baseline-'));
|
||||
fs.mkdirSync(path.join(repoRoot, 'architecture'), { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(repoRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function writeBaseline(baseline: unknown): void {
|
||||
fs.writeFileSync(
|
||||
path.join(repoRoot, 'architecture/edge-baseline.json'),
|
||||
JSON.stringify(baseline),
|
||||
);
|
||||
}
|
||||
|
||||
it('accepts positive integer counts for known layer edges', () => {
|
||||
writeBaseline({ [edgeKey('a', 'b')]: 2 });
|
||||
expect(readEdgeBaseline(repoRoot, config)).toEqual({ [edgeKey('a', 'b')]: 2 });
|
||||
});
|
||||
|
||||
it('rejects baseline edges that reference unknown layers', () => {
|
||||
writeBaseline({ [edgeKey('a', 'ghost')]: 2 });
|
||||
expect(() => readEdgeBaseline(repoRoot, config)).toThrow('contains invalid edge');
|
||||
});
|
||||
|
||||
it('rejects non-positive or non-integer import counts', () => {
|
||||
writeBaseline({ [edgeKey('a', 'b')]: 0 });
|
||||
expect(() => readEdgeBaseline(repoRoot, config)).toThrow('contains invalid import count');
|
||||
|
||||
writeBaseline({ [edgeKey('a', 'b')]: 1.5 });
|
||||
expect(() => readEdgeBaseline(repoRoot, config)).toThrow('contains invalid import count');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findForbiddenDependencyViolations', () => {
|
||||
it('flags direct dependencies that violate an explicit forbiddenDependencies lock', () => {
|
||||
const config = configWith([
|
||||
{ name: 'a', roots: ['src/a'], allowedDependencies: ['c'], forbiddenDependencies: ['b'] },
|
||||
{ name: 'b', roots: ['src/b'], allowedDependencies: [] },
|
||||
{ name: 'c', roots: ['src/c'], allowedDependencies: [] },
|
||||
]);
|
||||
const edges = [edge('a', 'b', 1), edge('a', 'c', 3)];
|
||||
|
||||
expect(findForbiddenDependencyViolations(config, edges)).toEqual([
|
||||
{ from: 'a', to: 'b', path: [edge('a', 'b', 1)] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('flags transitive dependencies that violate an explicit forbiddenDependencies lock', () => {
|
||||
const config = configWith([
|
||||
{ name: 'a', roots: ['src/a'], allowedDependencies: ['c'], forbiddenDependencies: ['b'] },
|
||||
{ name: 'b', roots: ['src/b'], allowedDependencies: [] },
|
||||
{ name: 'c', roots: ['src/c'], allowedDependencies: ['b'] },
|
||||
]);
|
||||
const edges = [edge('a', 'c', 3), edge('c', 'b', 2)];
|
||||
|
||||
expect(findForbiddenDependencyViolations(config, edges)).toEqual([
|
||||
{ from: 'a', to: 'b', path: [edge('a', 'c', 3), edge('c', 'b', 2)] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('readLayerConfig DAG policy validation', () => {
|
||||
let repoRoot: string;
|
||||
|
||||
beforeEach(() => {
|
||||
repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'archdag-cfg-'));
|
||||
fs.mkdirSync(path.join(repoRoot, 'src/core'), { recursive: true });
|
||||
fs.writeFileSync(path.join(repoRoot, 'src/index.ts'), '');
|
||||
fs.writeFileSync(path.join(repoRoot, 'src/core/a.ts'), '');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(repoRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function writeConfig(extra: Record<string, unknown>): void {
|
||||
fs.mkdirSync(path.join(repoRoot, 'architecture'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(repoRoot, 'architecture/layers.json'),
|
||||
JSON.stringify({
|
||||
publicFacade: 'src/index.ts',
|
||||
layers: [
|
||||
{ name: 'facade', roots: ['src/index.ts'], allowedDependencies: [] },
|
||||
{ name: 'core', roots: ['src/core'], allowedDependencies: [] },
|
||||
],
|
||||
...extra,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
it('accepts a valid tierOrder, maxStronglyConnectedComponentSize, and forbiddenDependencies', () => {
|
||||
writeConfig({
|
||||
tierOrder: ['core', 'facade'],
|
||||
maxStronglyConnectedComponentSize: 1,
|
||||
layers: [
|
||||
{
|
||||
name: 'facade',
|
||||
roots: ['src/index.ts'],
|
||||
allowedDependencies: [],
|
||||
forbiddenDependencies: ['core'],
|
||||
},
|
||||
{ name: 'core', roots: ['src/core'], allowedDependencies: [] },
|
||||
],
|
||||
});
|
||||
expect(() => readLayerConfig(repoRoot)).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects a tierOrder referencing an unknown layer', () => {
|
||||
writeConfig({ tierOrder: ['core', 'ghost'] });
|
||||
expect(() => readLayerConfig(repoRoot)).toThrow('tierOrder contains unknown layer "ghost"');
|
||||
});
|
||||
|
||||
it('rejects a tierOrder that omits a configured layer', () => {
|
||||
writeConfig({ tierOrder: ['core'] });
|
||||
expect(() => readLayerConfig(repoRoot)).toThrow(
|
||||
'tierOrder must list every layer. Missing: facade',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a non-positive maxStronglyConnectedComponentSize', () => {
|
||||
writeConfig({ maxStronglyConnectedComponentSize: 0 });
|
||||
expect(() => readLayerConfig(repoRoot)).toThrow(
|
||||
'maxStronglyConnectedComponentSize must be a positive integer',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a layer that both allows and forbids the same dependency', () => {
|
||||
writeConfig({
|
||||
layers: [
|
||||
{ name: 'facade', roots: ['src/index.ts'], allowedDependencies: [] },
|
||||
{
|
||||
name: 'core',
|
||||
roots: ['src/core'],
|
||||
allowedDependencies: ['facade'],
|
||||
forbiddenDependencies: ['facade'],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(() => readLayerConfig(repoRoot)).toThrow('both allows and forbids dependency "facade"');
|
||||
});
|
||||
|
||||
it('rejects a non-array forbiddenDependencies value', () => {
|
||||
writeConfig({
|
||||
layers: [
|
||||
{ name: 'facade', roots: ['src/index.ts'], allowedDependencies: [] },
|
||||
{
|
||||
name: 'core',
|
||||
roots: ['src/core'],
|
||||
allowedDependencies: [],
|
||||
forbiddenDependencies: 'facade',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(() => readLayerConfig(repoRoot)).toThrow(
|
||||
'forbiddenDependencies must be an array of layer names',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,707 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
extractModuleSpecifiers,
|
||||
findUnclassifiedFiles,
|
||||
findViolations,
|
||||
getExternalModuleName,
|
||||
getLayerForFile,
|
||||
getSourceFiles,
|
||||
type LayerConfig,
|
||||
readLayerConfig,
|
||||
resolveInternalModule,
|
||||
} from '../../scripts/architectureUtils';
|
||||
|
||||
describe('extractModuleSpecifiers', () => {
|
||||
it('collects static ESM and CommonJS module specifiers', () => {
|
||||
const source = `
|
||||
import imported from 'esm-import';
|
||||
export { exported } from 'esm-export';
|
||||
import('dynamic-import');
|
||||
const required = require('cjs-require');
|
||||
const resolved = require.resolve('cjs-resolve');
|
||||
require(nonLiteral);
|
||||
`;
|
||||
|
||||
expect(extractModuleSpecifiers(source, 'fixture.ts')).toEqual([
|
||||
'esm-import',
|
||||
'esm-export',
|
||||
'dynamic-import',
|
||||
'cjs-require',
|
||||
'cjs-resolve',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveInternalModule', () => {
|
||||
it('maps runtime .js specifiers back to TypeScript source files', () => {
|
||||
expect(
|
||||
resolveInternalModule(process.cwd(), 'src/server/routes/eval.ts', '../../index.js'),
|
||||
).toBe('src/index.ts');
|
||||
});
|
||||
|
||||
it('resolves baseUrl-style src imports so they cannot bypass layer checks', () => {
|
||||
expect(resolveInternalModule(process.cwd(), 'src/contracts/index.ts', 'src/index.js')).toBe(
|
||||
'src/index.ts',
|
||||
);
|
||||
});
|
||||
|
||||
describe('edge cases (against synthesized fixture trees)', () => {
|
||||
let repoRoot: string;
|
||||
|
||||
beforeEach(() => {
|
||||
repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'archutils-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(repoRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function write(relativePath: string, contents = ''): void {
|
||||
const absolute = path.join(repoRoot, relativePath);
|
||||
fs.mkdirSync(path.dirname(absolute), { recursive: true });
|
||||
fs.writeFileSync(absolute, contents);
|
||||
}
|
||||
|
||||
it('resolves bare "src" specifier to src/index.ts', () => {
|
||||
write('src/index.ts');
|
||||
expect(resolveInternalModule(repoRoot, 'scripts/check.ts', 'src')).toBe('src/index.ts');
|
||||
});
|
||||
|
||||
it('resolves directory imports to index.ts', () => {
|
||||
write('src/contracts/index.ts');
|
||||
expect(resolveInternalModule(repoRoot, 'src/foo.ts', './contracts')).toBe(
|
||||
'src/contracts/index.ts',
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves directory index for .tsx', () => {
|
||||
write('src/components/index.tsx');
|
||||
expect(resolveInternalModule(repoRoot, 'src/foo.ts', './components')).toBe(
|
||||
'src/components/index.tsx',
|
||||
);
|
||||
});
|
||||
|
||||
it('maps .mjs specifiers to .mts source files', () => {
|
||||
write('src/foo.mts');
|
||||
expect(resolveInternalModule(repoRoot, 'src/bar.ts', './foo.mjs')).toBe('src/foo.mts');
|
||||
});
|
||||
|
||||
it('maps .cjs specifiers to .cts source files', () => {
|
||||
write('src/foo.cts');
|
||||
expect(resolveInternalModule(repoRoot, 'src/bar.ts', './foo.cjs')).toBe('src/foo.cts');
|
||||
});
|
||||
|
||||
it('resolves .tsx source for runtime .js when both .ts and .tsx are absent', () => {
|
||||
write('src/component.tsx');
|
||||
expect(resolveInternalModule(repoRoot, 'src/index.ts', './component.js')).toBe(
|
||||
'src/component.tsx',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns undefined for non-existent internal paths', () => {
|
||||
expect(resolveInternalModule(repoRoot, 'src/foo.ts', './missing')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for non-source relative imports', () => {
|
||||
write('package.json', '{}');
|
||||
expect(resolveInternalModule(repoRoot, 'src/app/vite.config.ts', '../../package.json')).toBe(
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns undefined for external (non-relative, non-src) specifiers', () => {
|
||||
expect(resolveInternalModule(repoRoot, 'src/foo.ts', 'zod')).toBeUndefined();
|
||||
expect(resolveInternalModule(repoRoot, 'src/foo.ts', '@scoped/pkg')).toBeUndefined();
|
||||
expect(resolveInternalModule(repoRoot, 'src/foo.ts', 'node:fs')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('resolves configured source aliases', () => {
|
||||
write('src/core/util.ts');
|
||||
expect(
|
||||
resolveInternalModule(repoRoot, 'src/app/component.tsx', '@promptfoo/core/util', {
|
||||
'@promptfoo': 'src',
|
||||
}),
|
||||
).toBe('src/core/util.ts');
|
||||
});
|
||||
|
||||
it('resolves exact configured source aliases', () => {
|
||||
write('src/index.ts');
|
||||
expect(
|
||||
resolveInternalModule(repoRoot, 'src/app/component.tsx', '@promptfoo', {
|
||||
'@promptfoo': 'src',
|
||||
}),
|
||||
).toBe('src/index.ts');
|
||||
});
|
||||
|
||||
it('prefers the longest configured source alias', () => {
|
||||
write('src/app/foo.ts');
|
||||
write('src/app/src/foo.ts');
|
||||
expect(
|
||||
resolveInternalModule(repoRoot, 'src/app/component.tsx', '@promptfoo/app/foo', {
|
||||
'@promptfoo': 'src',
|
||||
'@promptfoo/app': 'src/app/src',
|
||||
}),
|
||||
).toBe('src/app/src/foo.ts');
|
||||
});
|
||||
|
||||
it('prefers a file over a directory when both could match', () => {
|
||||
write('src/index.ts', '// file');
|
||||
write('src/index/index.ts', '// directory');
|
||||
expect(resolveInternalModule(repoRoot, 'src/foo.ts', '../src/index')).toBe('src/index.ts');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSourceFiles', () => {
|
||||
let repoRoot: string;
|
||||
|
||||
beforeEach(() => {
|
||||
repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'getsourcefiles-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(repoRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function write(relativePath: string, contents = ''): void {
|
||||
const absolute = path.join(repoRoot, relativePath);
|
||||
fs.mkdirSync(path.dirname(absolute), { recursive: true });
|
||||
fs.writeFileSync(absolute, contents);
|
||||
}
|
||||
|
||||
it('ignores nested node_modules and configured roots', () => {
|
||||
write('src/core/a.ts');
|
||||
write('src/app/node_modules/pkg/index.ts');
|
||||
write('src/__mocks__/database.ts');
|
||||
|
||||
expect(getSourceFiles(repoRoot, true, ['src/__mocks__'])).toEqual(['src/core/a.ts']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('readLayerConfig', () => {
|
||||
let repoRoot: string;
|
||||
|
||||
beforeEach(() => {
|
||||
repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'readlayerconfig-'));
|
||||
fs.mkdirSync(path.join(repoRoot, 'architecture'), { recursive: true });
|
||||
fs.mkdirSync(path.join(repoRoot, 'src/core'), { recursive: true });
|
||||
fs.writeFileSync(path.join(repoRoot, 'src/index.ts'), '');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(repoRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function writeConfig(config: unknown): void {
|
||||
fs.writeFileSync(path.join(repoRoot, 'architecture/layers.json'), JSON.stringify(config));
|
||||
}
|
||||
|
||||
function coreLayer(overrides: Partial<LayerConfig['layers'][number]> = {}) {
|
||||
return {
|
||||
name: 'core',
|
||||
roots: ['src/core'],
|
||||
allowedDependencies: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it('rejects configs that omit the layers array', () => {
|
||||
writeConfig({ publicFacade: 'src/index.ts' });
|
||||
|
||||
expect(() => readLayerConfig(repoRoot)).toThrow('must define a layers array.');
|
||||
});
|
||||
|
||||
it('rejects configs that omit an existing public facade', () => {
|
||||
writeConfig({ layers: [coreLayer()] });
|
||||
|
||||
expect(() => readLayerConfig(repoRoot)).toThrow('must define an existing publicFacade path.');
|
||||
});
|
||||
|
||||
it('rejects duplicate layer names', () => {
|
||||
writeConfig({ publicFacade: 'src/index.ts', layers: [coreLayer(), coreLayer()] });
|
||||
|
||||
expect(() => readLayerConfig(repoRoot)).toThrow('contains duplicate layer "core".');
|
||||
});
|
||||
|
||||
it('rejects layers without names', () => {
|
||||
writeConfig({
|
||||
publicFacade: 'src/index.ts',
|
||||
layers: [{ roots: ['src/core'], allowedDependencies: [] }],
|
||||
});
|
||||
|
||||
expect(() => readLayerConfig(repoRoot)).toThrow('contains a layer without a name.');
|
||||
});
|
||||
|
||||
it('rejects layers that omit roots', () => {
|
||||
writeConfig({ publicFacade: 'src/index.ts', layers: [coreLayer({ roots: [] })] });
|
||||
|
||||
expect(() => readLayerConfig(repoRoot)).toThrow(
|
||||
'Architecture layer "core" must declare roots.',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an invalid allowedExternal entry', () => {
|
||||
writeConfig({
|
||||
publicFacade: 'src/index.ts',
|
||||
layers: [coreLayer({ allowedExternal: ['zod', ''] })],
|
||||
});
|
||||
|
||||
expect(() => readLayerConfig(repoRoot)).toThrow(
|
||||
'Architecture layer "core" contains an invalid allowedExternal entry.',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects directory entries in allowed import paths', () => {
|
||||
writeConfig({
|
||||
publicFacade: 'src/index.ts',
|
||||
layers: [coreLayer({ allowedImportPaths: ['src/core'] })],
|
||||
});
|
||||
|
||||
expect(() => readLayerConfig(repoRoot)).toThrow(
|
||||
'Architecture layer "core" contains an invalid allowedImportPaths entry.',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects missing layer roots', () => {
|
||||
writeConfig({
|
||||
publicFacade: 'src/index.ts',
|
||||
layers: [coreLayer({ roots: ['src/missing'] })],
|
||||
});
|
||||
|
||||
expect(() => readLayerConfig(repoRoot)).toThrow(
|
||||
'Architecture layer "core" root "src/missing" does not exist.',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects layers that omit allowed dependencies', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(repoRoot, 'architecture/layers.json'),
|
||||
JSON.stringify({
|
||||
publicFacade: 'src/index.ts',
|
||||
layers: [{ name: 'core', roots: ['src/core'] }],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(() => readLayerConfig(repoRoot)).toThrow(
|
||||
'Architecture layer "core" must declare allowedDependencies.',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects dependencies on unknown layers', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(repoRoot, 'architecture/layers.json'),
|
||||
JSON.stringify({
|
||||
publicFacade: 'src/index.ts',
|
||||
layers: [{ name: 'core', roots: ['src/core'], allowedDependencies: ['missing'] }],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(() => readLayerConfig(repoRoot)).toThrow(
|
||||
'Architecture layer "core" allows unknown dependency "missing".',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects aliases that point to missing paths', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(repoRoot, 'architecture/layers.json'),
|
||||
JSON.stringify({
|
||||
publicFacade: 'src/index.ts',
|
||||
aliases: { '@missing': 'src/missing' },
|
||||
layers: [{ name: 'core', roots: ['src/core'], allowedDependencies: [] }],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(() => readLayerConfig(repoRoot)).toThrow(
|
||||
'Architecture alias "@missing" points to missing path "src/missing".',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects overlapping layer roots', () => {
|
||||
fs.mkdirSync(path.join(repoRoot, 'src/core/nested'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(repoRoot, 'architecture/layers.json'),
|
||||
JSON.stringify({
|
||||
publicFacade: 'src/index.ts',
|
||||
layers: [
|
||||
{ name: 'core', roots: ['src/core'], allowedDependencies: [] },
|
||||
{ name: 'nested', roots: ['src/core/nested'], allowedDependencies: [] },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(() => readLayerConfig(repoRoot)).toThrow(
|
||||
'Architecture roots "src/core" (core) and "src/core/nested" (nested) overlap.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getExternalModuleName', () => {
|
||||
it('names npm packages and scoped packages by their package root', () => {
|
||||
expect(getExternalModuleName('zod')).toBe('zod');
|
||||
expect(getExternalModuleName('zod/v4')).toBe('zod');
|
||||
expect(getExternalModuleName('@scoped/pkg/sub')).toBe('@scoped/pkg');
|
||||
});
|
||||
|
||||
it('names Node builtins with the node: prefix stripped', () => {
|
||||
expect(getExternalModuleName('node:fs')).toBe('fs');
|
||||
expect(getExternalModuleName('fs/promises')).toBe('fs');
|
||||
});
|
||||
|
||||
it('returns undefined for relative, absolute, and subpath-import specifiers', () => {
|
||||
expect(getExternalModuleName('./shared.js')).toBeUndefined();
|
||||
expect(getExternalModuleName('../prompts')).toBeUndefined();
|
||||
expect(getExternalModuleName('/abs/path')).toBeUndefined();
|
||||
expect(getExternalModuleName('#internal')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('production layer config', () => {
|
||||
it('classifies the public contracts entrypoint as leaf-safe', () => {
|
||||
expect(getLayerForFile('src/contracts.ts', readLayerConfig(process.cwd()))).toBe('contracts');
|
||||
});
|
||||
|
||||
it('allows core logic to consume leaf-safe contracts', () => {
|
||||
const config = readLayerConfig(process.cwd());
|
||||
const coreLayer = config.layers.find((layer) => layer.name === 'core');
|
||||
|
||||
expect(coreLayer?.allowedDependencies).toContain('contracts');
|
||||
});
|
||||
|
||||
it('allows transitional runtime shims to consume leaf-safe contracts', () => {
|
||||
const config = readLayerConfig(process.cwd());
|
||||
const legacyRuntimeLayer = config.layers.find((layer) => layer.name === 'legacy-runtime');
|
||||
|
||||
expect(legacyRuntimeLayer?.allowedDependencies).toContain('contracts');
|
||||
});
|
||||
|
||||
it('classifies evaluator runtime ports as transitional runtime modules', () => {
|
||||
expect(getLayerForFile('src/evaluator/runtime.ts', readLayerConfig(process.cwd()))).toBe(
|
||||
'legacy-runtime',
|
||||
);
|
||||
});
|
||||
|
||||
it('classifies the evaluator runtime adapter as a node module', () => {
|
||||
expect(getLayerForFile('src/node/evaluatorRuntime.ts', readLayerConfig(process.cwd()))).toBe(
|
||||
'node',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the contracts leaf layer free of disallowed external dependencies', () => {
|
||||
const leafExternal = findViolations(process.cwd(), readLayerConfig(process.cwd())).filter(
|
||||
(violation) => violation.kind === 'leaf-external',
|
||||
);
|
||||
expect(leafExternal).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findViolations', () => {
|
||||
let repoRoot: string;
|
||||
|
||||
beforeEach(() => {
|
||||
repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'findviolations-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(repoRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function write(relativePath: string, contents = ''): void {
|
||||
const absolute = path.join(repoRoot, relativePath);
|
||||
fs.mkdirSync(path.dirname(absolute), { recursive: true });
|
||||
fs.writeFileSync(absolute, contents);
|
||||
}
|
||||
|
||||
function configWithLeaf(leafLayers: string[] = ['leaf']): LayerConfig {
|
||||
return {
|
||||
publicFacade: 'src/index.ts',
|
||||
leafLayers,
|
||||
layers: [
|
||||
{ name: 'facade', roots: ['src/index.ts'], allowedDependencies: [] },
|
||||
{ name: 'leaf', roots: ['src/leaf'], allowedDependencies: [], allowedExternal: ['zod'] },
|
||||
{ name: 'core', roots: ['src/core'], allowedDependencies: [] },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function configWithLayerRules(): LayerConfig {
|
||||
return {
|
||||
publicFacade: 'src/index.ts',
|
||||
aliases: {
|
||||
'@promptfoo': 'src',
|
||||
},
|
||||
ignoredRoots: ['src/__mocks__'],
|
||||
layers: [
|
||||
{ name: 'facade', roots: ['src/index.ts'], allowedDependencies: [] },
|
||||
{
|
||||
name: 'app',
|
||||
roots: ['src/app'],
|
||||
allowedDependencies: ['shared'],
|
||||
allowedImportPaths: ['src/shared/allowed.ts'],
|
||||
},
|
||||
{ name: 'core', roots: ['src/core'], allowedDependencies: [] },
|
||||
{ name: 'shared', roots: ['src/shared'], allowedDependencies: [] },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
it('returns empty when nothing imports anything internal', () => {
|
||||
write('src/index.ts', '// barrel');
|
||||
write('src/leaf/a.ts', "import { z } from 'zod';");
|
||||
|
||||
expect(findViolations(repoRoot, configWithLeaf())).toEqual([]);
|
||||
});
|
||||
|
||||
it('flags a leaf-layer file importing from another product layer', () => {
|
||||
write('src/index.ts');
|
||||
write('src/core/util.ts', 'export const x = 1;');
|
||||
write('src/leaf/a.ts', "import { x } from '../core/util';");
|
||||
|
||||
const violations = findViolations(repoRoot, configWithLeaf());
|
||||
expect(violations).toHaveLength(1);
|
||||
expect(violations[0]).toMatchObject({
|
||||
kind: 'leaf',
|
||||
importer: 'src/leaf/a.ts',
|
||||
importerLayer: 'leaf',
|
||||
imported: 'src/core/util.ts',
|
||||
importedLayer: 'core',
|
||||
});
|
||||
});
|
||||
|
||||
it('flags a leaf-layer file importing a non-allowlisted external package or builtin', () => {
|
||||
write('src/index.ts');
|
||||
write('src/leaf/a.ts', "import fs from 'node:fs';\nimport _ from 'lodash';");
|
||||
|
||||
const violations = findViolations(repoRoot, configWithLeaf());
|
||||
expect(violations.map((v) => v.specifier).sort()).toEqual(['lodash', 'node:fs']);
|
||||
expect(violations.every((v) => v.kind === 'leaf-external')).toBe(true);
|
||||
expect(violations[0]).toMatchObject({ importerLayer: 'leaf', importedLayer: 'external' });
|
||||
});
|
||||
|
||||
it('allows allowlisted externals (ignoring the node: prefix) from a leaf layer', () => {
|
||||
write('src/index.ts');
|
||||
write('src/leaf/a.ts', "import { z } from 'zod';\nimport fs from 'node:fs';");
|
||||
|
||||
// allowedExternal uses the bare name; "node:fs" must match an "fs" allowlist entry.
|
||||
const config = configWithLeaf();
|
||||
config.layers.find((layer) => layer.name === 'leaf')!.allowedExternal = ['zod', 'fs'];
|
||||
|
||||
expect(findViolations(repoRoot, config)).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not apply external allowlisting to non-leaf layers', () => {
|
||||
write('src/index.ts');
|
||||
write('src/core/a.ts', "import fs from 'node:fs';\nimport _ from 'lodash';");
|
||||
|
||||
// 'core' is not a leaf layer, so external imports are unconstrained.
|
||||
expect(findViolations(repoRoot, configWithLeaf())).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not flag intra-leaf imports', () => {
|
||||
write('src/index.ts');
|
||||
write('src/leaf/a.ts', "import { y } from './b';");
|
||||
write('src/leaf/b.ts', 'export const y = 2;');
|
||||
|
||||
expect(findViolations(repoRoot, configWithLeaf())).toEqual([]);
|
||||
});
|
||||
|
||||
it('flags a non-facade file importing the public facade', () => {
|
||||
write('src/index.ts', "export const facade = 'me';");
|
||||
write('src/core/util.ts', "import { facade } from '../index';");
|
||||
|
||||
const violations = findViolations(repoRoot, configWithLeaf());
|
||||
expect(violations).toHaveLength(1);
|
||||
expect(violations[0]).toMatchObject({
|
||||
kind: 'facade',
|
||||
importer: 'src/core/util.ts',
|
||||
importerLayer: 'core',
|
||||
});
|
||||
});
|
||||
|
||||
it('flags both facade and leaf when a leaf imports the public facade', () => {
|
||||
write('src/index.ts', "export const facade = 'me';");
|
||||
write('src/leaf/a.ts', "import { facade } from '../index';");
|
||||
|
||||
const violations = findViolations(repoRoot, configWithLeaf());
|
||||
const kinds = violations.map((v) => v.kind).sort();
|
||||
expect(kinds).toEqual(['facade', 'leaf']);
|
||||
});
|
||||
|
||||
it('still applies layer dependency rules when leafLayers is unset', () => {
|
||||
write('src/index.ts');
|
||||
write('src/leaf/a.ts', "import { x } from '../core/util';");
|
||||
write('src/core/util.ts', 'export const x = 1;');
|
||||
|
||||
expect(findViolations(repoRoot, configWithLeaf([]))).toMatchObject([
|
||||
{
|
||||
kind: 'layer',
|
||||
importer: 'src/leaf/a.ts',
|
||||
importerLayer: 'leaf',
|
||||
imported: 'src/core/util.ts',
|
||||
importedLayer: 'core',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('flags baseUrl-style src/... imports out of a leaf layer', () => {
|
||||
write('src/index.ts');
|
||||
write('src/core/util.ts', 'export const x = 1;');
|
||||
write('src/leaf/a.ts', "import { x } from 'src/core/util';");
|
||||
|
||||
const violations = findViolations(repoRoot, configWithLeaf());
|
||||
expect(violations).toHaveLength(1);
|
||||
expect(violations[0].kind).toBe('leaf');
|
||||
expect(violations[0].specifier).toBe('src/core/util');
|
||||
});
|
||||
|
||||
it('skips the public facade as an importer (the rule applies to imports OF it, not from it)', () => {
|
||||
write('src/index.ts', "import { x } from './core/util';");
|
||||
write('src/core/util.ts', 'export const x = 1;');
|
||||
|
||||
expect(findViolations(repoRoot, configWithLeaf())).toEqual([]);
|
||||
});
|
||||
|
||||
it('flags dependencies that are not explicitly allowed by the importing layer', () => {
|
||||
write('src/index.ts');
|
||||
write('src/shared/util.ts', 'export const x = 1;');
|
||||
write('src/core/a.ts', "import { x } from '../shared/util';");
|
||||
|
||||
expect(findViolations(repoRoot, configWithLayerRules())).toMatchObject([
|
||||
{
|
||||
kind: 'layer',
|
||||
importer: 'src/core/a.ts',
|
||||
importerLayer: 'core',
|
||||
imported: 'src/shared/util.ts',
|
||||
importedLayer: 'shared',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('reports a layer violation without a duplicate path violation', () => {
|
||||
write('src/index.ts');
|
||||
write('src/shared/not-allowed.ts', 'export const x = 1;');
|
||||
write('src/app/a.ts', "import { x } from '@promptfoo/shared/not-allowed';");
|
||||
|
||||
const config = configWithLayerRules();
|
||||
config.layers[1].allowedDependencies = [];
|
||||
|
||||
expect(findViolations(repoRoot, config)).toEqual([
|
||||
{
|
||||
kind: 'layer',
|
||||
importer: 'src/app/a.ts',
|
||||
importerLayer: 'app',
|
||||
specifier: '@promptfoo/shared/not-allowed',
|
||||
imported: 'src/shared/not-allowed.ts',
|
||||
importedLayer: 'shared',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('allows explicitly configured layer dependencies', () => {
|
||||
write('src/index.ts');
|
||||
write('src/shared/allowed.ts', 'export const x = 1;');
|
||||
write('src/app/a.ts', "import { x } from '@promptfoo/shared/allowed';");
|
||||
|
||||
expect(findViolations(repoRoot, configWithLayerRules())).toEqual([]);
|
||||
});
|
||||
|
||||
it('allows configured layer dependencies without path allowlists', () => {
|
||||
write('src/index.ts');
|
||||
write('src/shared/allowed.ts', 'export const x = 1;');
|
||||
write('src/core/a.ts', "import { x } from '../shared/allowed';");
|
||||
|
||||
const config = configWithLayerRules();
|
||||
config.layers[2].allowedDependencies = ['shared'];
|
||||
|
||||
expect(findViolations(repoRoot, config)).toEqual([]);
|
||||
});
|
||||
|
||||
it('flags new imports outside a restricted layer path allowlist', () => {
|
||||
write('src/index.ts');
|
||||
write('src/shared/not-allowed.ts', 'export const x = 1;');
|
||||
write('src/app/a.ts', "import { x } from '@promptfoo/shared/not-allowed';");
|
||||
|
||||
expect(findViolations(repoRoot, configWithLayerRules())).toMatchObject([
|
||||
{
|
||||
kind: 'path',
|
||||
importer: 'src/app/a.ts',
|
||||
importerLayer: 'app',
|
||||
imported: 'src/shared/not-allowed.ts',
|
||||
importedLayer: 'shared',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not treat restricted layer directory entries as broad allowlist roots', () => {
|
||||
write('src/index.ts');
|
||||
write('src/shared/new-file.ts', 'export const x = 1;');
|
||||
write('src/app/a.ts', "import { x } from '@promptfoo/shared/new-file';");
|
||||
|
||||
const config = configWithLayerRules();
|
||||
config.layers[1].allowedImportPaths = ['src/shared'];
|
||||
|
||||
expect(findViolations(repoRoot, config)).toMatchObject([
|
||||
{
|
||||
kind: 'path',
|
||||
importer: 'src/app/a.ts',
|
||||
importerLayer: 'app',
|
||||
imported: 'src/shared/new-file.ts',
|
||||
importedLayer: 'shared',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('flags restricted layer dependencies referenced through inline import types', () => {
|
||||
write('src/index.ts');
|
||||
write('src/shared/not-allowed.ts', 'export interface X {}');
|
||||
write('src/app/a.ts', "export type X = import('@promptfoo/shared/not-allowed').X;");
|
||||
|
||||
expect(findViolations(repoRoot, configWithLayerRules())).toMatchObject([
|
||||
{
|
||||
kind: 'path',
|
||||
importer: 'src/app/a.ts',
|
||||
importerLayer: 'app',
|
||||
imported: 'src/shared/not-allowed.ts',
|
||||
importedLayer: 'shared',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('flags restricted layer dependencies referenced through dynamic imports and exports', () => {
|
||||
write('src/index.ts');
|
||||
write('src/shared/not-allowed.ts', 'export const x = 1;');
|
||||
write(
|
||||
'src/app/a.ts',
|
||||
`
|
||||
export { x } from '@promptfoo/shared/not-allowed';
|
||||
import('@promptfoo/shared/not-allowed');
|
||||
`,
|
||||
);
|
||||
|
||||
expect(findViolations(repoRoot, configWithLayerRules())).toHaveLength(2);
|
||||
expect(findViolations(repoRoot, configWithLayerRules())).toMatchObject([
|
||||
{
|
||||
kind: 'path',
|
||||
importer: 'src/app/a.ts',
|
||||
imported: 'src/shared/not-allowed.ts',
|
||||
},
|
||||
{
|
||||
kind: 'path',
|
||||
importer: 'src/app/a.ts',
|
||||
imported: 'src/shared/not-allowed.ts',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('reports unclassified source files outside configured ignored roots', () => {
|
||||
write('src/index.ts');
|
||||
write('src/core/a.ts');
|
||||
write('src/missing/a.ts');
|
||||
write('src/__mocks__/database.ts');
|
||||
|
||||
expect(findUnclassifiedFiles(repoRoot, configWithLayerRules())).toEqual(['src/missing/a.ts']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,234 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
COVERAGE_RATCHET_REPORTS,
|
||||
DEFAULT_COVERAGE_THRESHOLDS,
|
||||
evaluateCoverageRatchets,
|
||||
parseChangedFileList,
|
||||
readGithubPullRequestBaseSha,
|
||||
runCoverageRatchetCli,
|
||||
summarizeFileCoverage,
|
||||
} from '../../scripts/checkCoverageRatchets';
|
||||
|
||||
type MetricCounts = {
|
||||
covered: number;
|
||||
total: number;
|
||||
};
|
||||
|
||||
function coverageFile(
|
||||
filePath: string,
|
||||
{
|
||||
branches = { covered: 0, total: 0 },
|
||||
functions = { covered: 0, total: 0 },
|
||||
statements = { covered: 1, total: 1 },
|
||||
}: {
|
||||
branches?: MetricCounts;
|
||||
functions?: MetricCounts;
|
||||
statements?: MetricCounts;
|
||||
} = {},
|
||||
) {
|
||||
const statementMap: Record<string, { start: { line: number } }> = {};
|
||||
const statementHits: Record<string, number> = {};
|
||||
|
||||
for (let i = 0; i < statements.total; i += 1) {
|
||||
statementMap[i] = { start: { line: i + 1 } };
|
||||
statementHits[i] = i < statements.covered ? 1 : 0;
|
||||
}
|
||||
|
||||
const fnMap: Record<string, unknown> = {};
|
||||
const functionHits: Record<string, number> = {};
|
||||
|
||||
for (let i = 0; i < functions.total; i += 1) {
|
||||
fnMap[i] = {};
|
||||
functionHits[i] = i < functions.covered ? 1 : 0;
|
||||
}
|
||||
|
||||
const branchMap: Record<string, unknown> = {};
|
||||
const branchHits: Record<string, number[]> = {};
|
||||
|
||||
if (branches.total > 0) {
|
||||
branchMap[0] = {};
|
||||
branchHits[0] = Array.from({ length: branches.total }, (_, i) =>
|
||||
i < branches.covered ? 1 : 0,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
path: filePath,
|
||||
statementMap,
|
||||
s: statementHits,
|
||||
fnMap,
|
||||
f: functionHits,
|
||||
branchMap,
|
||||
b: branchHits,
|
||||
};
|
||||
}
|
||||
|
||||
describe('coverage ratchets', () => {
|
||||
const repoRoot = path.resolve('/repo');
|
||||
const backendReport = COVERAGE_RATCHET_REPORTS.find((report) => report.name === 'backend');
|
||||
const frontendReport = COVERAGE_RATCHET_REPORTS.find((report) => report.name === 'frontend');
|
||||
|
||||
if (!backendReport || !frontendReport) {
|
||||
throw new Error('Expected backend and frontend coverage ratchet reports');
|
||||
}
|
||||
|
||||
it('enforces coverage floors for added backend source files', () => {
|
||||
const file = 'src/newFeature.ts';
|
||||
const result = evaluateCoverageRatchets({
|
||||
changedFiles: [{ path: file, status: 'A' }],
|
||||
coverageMap: {
|
||||
[file]: coverageFile(file, {
|
||||
branches: { covered: 1, total: 2 },
|
||||
functions: { covered: 1, total: 2 },
|
||||
statements: { covered: 1, total: 2 },
|
||||
}),
|
||||
},
|
||||
repoRoot,
|
||||
report: backendReport,
|
||||
});
|
||||
|
||||
expect(result.checkedFiles).toHaveLength(1);
|
||||
expect(result.failures).toEqual([
|
||||
{
|
||||
file,
|
||||
reason: 'new source file',
|
||||
message: expect.stringContaining(`lines 50.00% < ${DEFAULT_COVERAGE_THRESHOLDS.lines}%`),
|
||||
},
|
||||
]);
|
||||
expect(result.failures[0].message).toContain(
|
||||
`branches 50.00% < ${DEFAULT_COVERAGE_THRESHOLDS.branches}%`,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not gate ordinary modified legacy source files', () => {
|
||||
const result = evaluateCoverageRatchets({
|
||||
changedFiles: [{ path: 'src/legacy.ts', status: 'M' }],
|
||||
coverageMap: {
|
||||
'src/legacy.ts': coverageFile('src/legacy.ts', {
|
||||
statements: { covered: 0, total: 4 },
|
||||
}),
|
||||
},
|
||||
repoRoot,
|
||||
report: backendReport,
|
||||
});
|
||||
|
||||
expect(result.checkedFiles).toEqual([]);
|
||||
expect(result.failures).toEqual([]);
|
||||
expect(result.skippedFiles).toEqual(['src/legacy.ts']);
|
||||
});
|
||||
|
||||
it('enforces coverage floors for modified critical backend paths', () => {
|
||||
const file = 'src/assertions/contains.ts';
|
||||
const result = evaluateCoverageRatchets({
|
||||
changedFiles: [{ path: file, status: 'M' }],
|
||||
coverageMap: {
|
||||
[file]: coverageFile(file, {
|
||||
statements: { covered: 1, total: 4 },
|
||||
}),
|
||||
},
|
||||
repoRoot,
|
||||
report: backendReport,
|
||||
});
|
||||
|
||||
expect(result.checkedFiles).toHaveLength(1);
|
||||
expect(result.failures).toEqual([
|
||||
{
|
||||
file,
|
||||
reason: 'critical path',
|
||||
message: expect.stringContaining('lines 25.00% < 80%'),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('normalizes absolute frontend coverage paths', () => {
|
||||
const file = 'src/app/src/components/NewThing.tsx';
|
||||
const result = evaluateCoverageRatchets({
|
||||
changedFiles: [{ path: file, status: 'A' }],
|
||||
coverageMap: {
|
||||
[path.join(repoRoot, file)]: coverageFile(path.join(repoRoot, file), {
|
||||
branches: { covered: 3, total: 4 },
|
||||
functions: { covered: 4, total: 4 },
|
||||
statements: { covered: 5, total: 5 },
|
||||
}),
|
||||
},
|
||||
repoRoot,
|
||||
report: frontendReport,
|
||||
});
|
||||
|
||||
expect(result.failures).toEqual([]);
|
||||
expect(result.checkedFiles).toHaveLength(1);
|
||||
expect(result.checkedFiles[0].file).toBe(file);
|
||||
});
|
||||
|
||||
it('parses added, modified, and renamed files from git name-status output', () => {
|
||||
expect(
|
||||
parseChangedFileList(
|
||||
'A\tsrc/new.ts\nM\tsrc/existing.ts\nR100\tsrc/old.ts\tsrc/new-name.ts\n',
|
||||
),
|
||||
).toEqual([
|
||||
{ path: 'src/new.ts', status: 'A' },
|
||||
{ path: 'src/existing.ts', status: 'M' },
|
||||
{ path: 'src/new-name.ts', status: 'R' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('summarizes statement-backed line coverage', () => {
|
||||
expect(
|
||||
summarizeFileCoverage(
|
||||
coverageFile('src/newFeature.ts', {
|
||||
branches: { covered: 0, total: 0 },
|
||||
functions: { covered: 1, total: 1 },
|
||||
statements: { covered: 2, total: 4 },
|
||||
}),
|
||||
).lines,
|
||||
).toEqual({ covered: 2, total: 4, pct: 50 });
|
||||
});
|
||||
|
||||
it('reports missing CLI flag values before reading git state', () => {
|
||||
expect(() => runCoverageRatchetCli(['--report'])).toThrow('Missing value for --report');
|
||||
expect(() => runCoverageRatchetCli(['--base', '--report', 'backend'])).toThrow(
|
||||
'Missing value for --base',
|
||||
);
|
||||
});
|
||||
|
||||
it('fails explicit report runs when the coverage artifact is missing', () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'coverage-ratchet-'));
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
try {
|
||||
expect(runCoverageRatchetCli(['--report', 'backend'], tempDir)).toBe(1);
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
'[coverage-ratchet] backend: missing coverage/coverage-final.json',
|
||||
);
|
||||
} finally {
|
||||
errorSpy.mockRestore();
|
||||
fs.rmSync(tempDir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('reads the base SHA from a GitHub pull request event payload', () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'coverage-ratchet-'));
|
||||
const eventPath = path.join(tempDir, 'event.json');
|
||||
|
||||
try {
|
||||
fs.writeFileSync(
|
||||
eventPath,
|
||||
JSON.stringify({
|
||||
pull_request: {
|
||||
base: {
|
||||
sha: '0123456789abcdef',
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(readGithubPullRequestBaseSha(eventPath)).toBe('0123456789abcdef');
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { findMissingRootTypeScriptFiles } from '../../scripts/checkTypeScriptCoverage';
|
||||
|
||||
describe('root TypeScript coverage', () => {
|
||||
it('keeps tracked root-owned TypeScript files inside a typechecked project', () => {
|
||||
expect(findMissingRootTypeScriptFiles()).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,245 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { shouldCopyDrizzlePath } from '../../scripts/postbuild';
|
||||
|
||||
describe('postbuild', () => {
|
||||
let tempDir: string;
|
||||
let originalCwd: string;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create a temp directory structure that mimics the project
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'postbuild-test-'));
|
||||
originalCwd = process.cwd();
|
||||
|
||||
// Create source structure
|
||||
const srcDir = path.join(tempDir, 'src');
|
||||
fs.mkdirSync(srcDir, { recursive: true });
|
||||
|
||||
// Create HTML file
|
||||
fs.writeFileSync(path.join(srcDir, 'tableOutput.html'), '<html></html>');
|
||||
|
||||
// Create wrapper directories and files
|
||||
const pythonDir = path.join(srcDir, 'python');
|
||||
fs.mkdirSync(pythonDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(pythonDir, 'wrapper.py'), '# python wrapper');
|
||||
fs.writeFileSync(path.join(pythonDir, 'persistent_wrapper.py'), '# persistent wrapper');
|
||||
|
||||
const golangDir = path.join(srcDir, 'golang');
|
||||
fs.mkdirSync(golangDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(golangDir, 'wrapper.go'), '// go wrapper');
|
||||
|
||||
const rubyDir = path.join(srcDir, 'ruby');
|
||||
fs.mkdirSync(rubyDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(rubyDir, 'wrapper.rb'), '# ruby wrapper');
|
||||
|
||||
// Create drizzle directory with migrations and files to exclude
|
||||
const drizzleDir = path.join(tempDir, 'drizzle');
|
||||
fs.mkdirSync(drizzleDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(drizzleDir, '0001_migration.sql'), 'CREATE TABLE test;');
|
||||
fs.writeFileSync(path.join(drizzleDir, '0002_migration.sql'), 'ALTER TABLE test;');
|
||||
fs.writeFileSync(path.join(drizzleDir, 'CLAUDE.md'), '# Should be excluded');
|
||||
fs.writeFileSync(path.join(drizzleDir, 'AGENTS.md'), '# Should be excluded');
|
||||
|
||||
// Create dist directory with required build outputs
|
||||
const distSrcDir = path.join(tempDir, 'dist', 'src');
|
||||
const distServerDir = path.join(tempDir, 'dist', 'src', 'server');
|
||||
fs.mkdirSync(distServerDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(distSrcDir, 'main.js'), '#!/usr/bin/env node\nconsole.log("cli");');
|
||||
fs.writeFileSync(path.join(distSrcDir, 'index.js'), 'export const foo = 1;');
|
||||
fs.writeFileSync(path.join(distSrcDir, 'index.cjs'), 'module.exports = { foo: 1 };');
|
||||
fs.writeFileSync(path.join(distServerDir, 'index.js'), 'export const server = {};');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(originalCwd);
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('WRAPPER_TYPES constant', () => {
|
||||
it('should include all supported wrapper types', async () => {
|
||||
// Import the module - this verifies it loads without error
|
||||
const { postbuild } = await import('../../scripts/postbuild');
|
||||
// The postbuild function should be exported and callable
|
||||
expect(typeof postbuild).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('REQUIRED_BUILD_OUTPUTS constant', () => {
|
||||
it('should verify critical build outputs exist', () => {
|
||||
// Verify the build outputs we created
|
||||
expect(fs.existsSync(path.join(tempDir, 'dist', 'src', 'main.js'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(tempDir, 'dist', 'src', 'index.js'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(tempDir, 'dist', 'src', 'index.cjs'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(tempDir, 'dist', 'src', 'server', 'index.js'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('drizzle directory copy', () => {
|
||||
it('should exclude .md files from drizzle copy', () => {
|
||||
const drizzleDir = path.join(tempDir, 'drizzle');
|
||||
const files = fs.readdirSync(drizzleDir);
|
||||
|
||||
// Source should have .md files
|
||||
expect(files).toContain('CLAUDE.md');
|
||||
expect(files).toContain('AGENTS.md');
|
||||
|
||||
// After postbuild, dist/drizzle should NOT have .md files
|
||||
// This would be tested by running postbuild
|
||||
});
|
||||
|
||||
it('should include .sql migration files', () => {
|
||||
const drizzleDir = path.join(tempDir, 'drizzle');
|
||||
const files = fs.readdirSync(drizzleDir);
|
||||
|
||||
expect(files.filter((f) => f.endsWith('.sql'))).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('wrapper files', () => {
|
||||
it('should have all required python wrapper files in source', () => {
|
||||
const pythonDir = path.join(tempDir, 'src', 'python');
|
||||
expect(fs.existsSync(path.join(pythonDir, 'wrapper.py'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(pythonDir, 'persistent_wrapper.py'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should have golang wrapper file in source', () => {
|
||||
const golangDir = path.join(tempDir, 'src', 'golang');
|
||||
expect(fs.existsSync(path.join(golangDir, 'wrapper.go'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should have ruby wrapper file in source', () => {
|
||||
const rubyDir = path.join(tempDir, 'src', 'ruby');
|
||||
expect(fs.existsSync(path.join(rubyDir, 'wrapper.rb'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('HTML files', () => {
|
||||
it('should find HTML files in src directory', () => {
|
||||
const srcDir = path.join(tempDir, 'src');
|
||||
const htmlFiles = fs.readdirSync(srcDir).filter((f) => f.endsWith('.html'));
|
||||
|
||||
expect(htmlFiles).toContain('tableOutput.html');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ESM package.json marker', () => {
|
||||
it('should create valid ESM marker content', () => {
|
||||
const expected = JSON.stringify({ type: 'module' }, null, 2) + '\n';
|
||||
expect(JSON.parse(expected.trim())).toEqual({ type: 'module' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('filter function for drizzle', () => {
|
||||
it('should exclude files containing CLAUDE', () => {
|
||||
expect(shouldCopyDrizzlePath('/path/to/CLAUDE.md')).toBe(false);
|
||||
expect(shouldCopyDrizzlePath('/path/to/AGENTS.md')).toBe(false);
|
||||
expect(shouldCopyDrizzlePath('/path/to/0001_migration.sql')).toBe(true);
|
||||
expect(shouldCopyDrizzlePath('/path/to/meta/_journal.json')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should handle missing source files gracefully', () => {
|
||||
const nonExistentPath = path.join(tempDir, 'src', 'nonexistent.html');
|
||||
expect(fs.existsSync(nonExistentPath)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle missing build outputs', () => {
|
||||
// Remove a required build output
|
||||
const mainJs = path.join(tempDir, 'dist', 'src', 'main.js');
|
||||
fs.rmSync(mainJs);
|
||||
expect(fs.existsSync(mainJs)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('postbuild integration', () => {
|
||||
it('should verify the actual project has all required wrapper files', () => {
|
||||
const projectRoot = path.resolve(__dirname, '../../');
|
||||
const srcDir = path.join(projectRoot, 'src');
|
||||
|
||||
// Verify python wrappers exist
|
||||
expect(fs.existsSync(path.join(srcDir, 'python', 'wrapper.py'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(srcDir, 'python', 'persistent_wrapper.py'))).toBe(true);
|
||||
|
||||
// Verify golang wrapper exists
|
||||
expect(fs.existsSync(path.join(srcDir, 'golang', 'wrapper.go'))).toBe(true);
|
||||
|
||||
// Verify ruby wrapper exists
|
||||
expect(fs.existsSync(path.join(srcDir, 'ruby', 'wrapper.rb'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should copy wrapper files to both CLI and server directories after build', () => {
|
||||
const projectRoot = path.resolve(__dirname, '../../');
|
||||
const distDir = path.join(projectRoot, 'dist', 'src');
|
||||
const serverDir = path.join(distDir, 'server');
|
||||
|
||||
// Skip if dist doesn't exist or wrapper files haven't been copied yet (postbuild incomplete)
|
||||
// Check for one representative wrapper file to determine if postbuild has run
|
||||
if (!fs.existsSync(distDir) || !fs.existsSync(path.join(serverDir, 'python', 'wrapper.py'))) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Wrapper files should exist at dist/src/{type}/ for CLI builds
|
||||
// These paths are used when import.meta.url points to dist/src/main.js or entrypoint.js
|
||||
const cliWrapperPaths = [
|
||||
path.join(distDir, 'python', 'wrapper.py'),
|
||||
path.join(distDir, 'python', 'persistent_wrapper.py'),
|
||||
path.join(distDir, 'ruby', 'wrapper.rb'),
|
||||
path.join(distDir, 'golang', 'wrapper.go'),
|
||||
];
|
||||
|
||||
for (const wrapperPath of cliWrapperPaths) {
|
||||
expect(fs.existsSync(wrapperPath)).toBe(true);
|
||||
}
|
||||
|
||||
// Wrapper files should also exist at dist/src/server/{type}/ for bundled server builds
|
||||
// These paths are used when import.meta.url points to dist/src/server/index.js (Docker)
|
||||
// See: https://github.com/promptfoo/promptfoo/issues/7139
|
||||
const serverWrapperPaths = [
|
||||
path.join(serverDir, 'python', 'wrapper.py'),
|
||||
path.join(serverDir, 'python', 'persistent_wrapper.py'),
|
||||
path.join(serverDir, 'ruby', 'wrapper.rb'),
|
||||
path.join(serverDir, 'golang', 'wrapper.go'),
|
||||
];
|
||||
|
||||
for (const wrapperPath of serverWrapperPaths) {
|
||||
expect(fs.existsSync(wrapperPath)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should verify the actual project has HTML files', () => {
|
||||
const projectRoot = path.resolve(__dirname, '../../');
|
||||
const srcDir = path.join(projectRoot, 'src');
|
||||
const htmlFiles = fs.readdirSync(srcDir).filter((f) => f.endsWith('.html'));
|
||||
|
||||
expect(htmlFiles.length).toBeGreaterThan(0);
|
||||
expect(htmlFiles).toContain('tableOutput.html');
|
||||
});
|
||||
|
||||
it('should verify drizzle directory has migrations', () => {
|
||||
const projectRoot = path.resolve(__dirname, '../../');
|
||||
const drizzleDir = path.join(projectRoot, 'drizzle');
|
||||
|
||||
expect(fs.existsSync(drizzleDir)).toBe(true);
|
||||
const files = fs.readdirSync(drizzleDir);
|
||||
const sqlFiles = files.filter((f) => f.endsWith('.sql'));
|
||||
|
||||
expect(sqlFiles.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should verify drizzle has files that need to be excluded', () => {
|
||||
const projectRoot = path.resolve(__dirname, '../../');
|
||||
const drizzleDir = path.join(projectRoot, 'drizzle');
|
||||
|
||||
// These should exist in source but be excluded from dist
|
||||
const hasExcludableFiles =
|
||||
fs.existsSync(path.join(drizzleDir, 'CLAUDE.md')) ||
|
||||
fs.existsSync(path.join(drizzleDir, 'AGENTS.md'));
|
||||
|
||||
expect(hasExcludableFiles).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,474 @@
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
const {
|
||||
updateChangelog,
|
||||
isEmptySection,
|
||||
extractUnreleasedContent,
|
||||
buildUpdatedChangelog,
|
||||
CHANGELOG_CATEGORIES,
|
||||
} = require('../../scripts/update-changelog-version.cjs') as {
|
||||
updateChangelog: (options?: {
|
||||
changelogPath?: string;
|
||||
packageJsonPath?: string;
|
||||
date?: string;
|
||||
}) => {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
version?: string;
|
||||
changelog?: string;
|
||||
};
|
||||
isEmptySection: (content: string) => boolean;
|
||||
extractUnreleasedContent: (
|
||||
changelog: string,
|
||||
) => { content: string; match: RegExpMatchArray } | null;
|
||||
buildUpdatedChangelog: (
|
||||
changelog: string,
|
||||
version: string,
|
||||
unreleasedContent: string,
|
||||
date: string,
|
||||
) => string;
|
||||
CHANGELOG_CATEGORIES: string[];
|
||||
};
|
||||
|
||||
describe('update-changelog-version', () => {
|
||||
describe('CHANGELOG_CATEGORIES', () => {
|
||||
it('should include all Keep a Changelog categories', () => {
|
||||
expect(CHANGELOG_CATEGORIES).toContain('Added');
|
||||
expect(CHANGELOG_CATEGORIES).toContain('Changed');
|
||||
expect(CHANGELOG_CATEGORIES).toContain('Deprecated');
|
||||
expect(CHANGELOG_CATEGORIES).toContain('Removed');
|
||||
expect(CHANGELOG_CATEGORIES).toContain('Fixed');
|
||||
expect(CHANGELOG_CATEGORIES).toContain('Security');
|
||||
});
|
||||
|
||||
it('should include custom categories', () => {
|
||||
expect(CHANGELOG_CATEGORIES).toContain('Dependencies');
|
||||
expect(CHANGELOG_CATEGORIES).toContain('Documentation');
|
||||
expect(CHANGELOG_CATEGORIES).toContain('Tests');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isEmptySection', () => {
|
||||
it('should return true for empty section with only headers', () => {
|
||||
const content = `### Added
|
||||
|
||||
### Changed
|
||||
|
||||
### Fixed`;
|
||||
expect(isEmptySection(content)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when section has entries', () => {
|
||||
const content = `### Added
|
||||
|
||||
- feat: new feature (#1234)
|
||||
|
||||
### Changed`;
|
||||
expect(isEmptySection(content)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for completely empty string', () => {
|
||||
expect(isEmptySection('')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for whitespace only', () => {
|
||||
expect(isEmptySection(' \n\n ')).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle all Keep a Changelog categories', () => {
|
||||
const content = `### Added
|
||||
|
||||
### Changed
|
||||
|
||||
### Deprecated
|
||||
|
||||
### Removed
|
||||
|
||||
### Fixed
|
||||
|
||||
### Security`;
|
||||
expect(isEmptySection(content)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle custom categories', () => {
|
||||
const content = `### Dependencies
|
||||
|
||||
### Documentation
|
||||
|
||||
### Tests`;
|
||||
expect(isEmptySection(content)).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect entry under Deprecated category', () => {
|
||||
const content = `### Deprecated
|
||||
|
||||
- chore: deprecate old API (#1234)`;
|
||||
expect(isEmptySection(content)).toBe(false);
|
||||
});
|
||||
|
||||
it('should detect entry under Security category', () => {
|
||||
const content = `### Security
|
||||
|
||||
- fix: patch XSS vulnerability (#5678)`;
|
||||
expect(isEmptySection(content)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractUnreleasedContent', () => {
|
||||
it('should extract content from Unreleased section', () => {
|
||||
const changelog = `# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- feat: new feature (#1234)
|
||||
|
||||
## [1.0.0] - 2024-01-01
|
||||
|
||||
### Added
|
||||
|
||||
- feat: old feature (#100)`;
|
||||
|
||||
const result = extractUnreleasedContent(changelog);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.content).toContain('feat: new feature (#1234)');
|
||||
});
|
||||
|
||||
it('should return null if no Unreleased section', () => {
|
||||
const changelog = `# Changelog
|
||||
|
||||
## [1.0.0] - 2024-01-01
|
||||
|
||||
### Added
|
||||
|
||||
- feat: feature (#100)`;
|
||||
|
||||
expect(extractUnreleasedContent(changelog)).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null if no versioned section after Unreleased', () => {
|
||||
const changelog = `# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- feat: new feature (#1234)`;
|
||||
|
||||
expect(extractUnreleasedContent(changelog)).toBeNull();
|
||||
});
|
||||
|
||||
it('should trim whitespace from extracted content', () => {
|
||||
const changelog = `# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
|
||||
### Added
|
||||
|
||||
- feat: new feature (#1234)
|
||||
|
||||
|
||||
## [1.0.0] - 2024-01-01`;
|
||||
|
||||
const result = extractUnreleasedContent(changelog);
|
||||
expect(result!.content).not.toMatch(/^\s/);
|
||||
expect(result!.content).not.toMatch(/\s$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildUpdatedChangelog', () => {
|
||||
const baseChangelog = `# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- feat: new feature (#1234)
|
||||
|
||||
## [1.0.0] - 2024-01-01
|
||||
|
||||
### Added
|
||||
|
||||
- feat: old feature (#100)`;
|
||||
|
||||
it('should create new versioned section', () => {
|
||||
const result = buildUpdatedChangelog(
|
||||
baseChangelog,
|
||||
'1.1.0',
|
||||
'### Added\n\n- feat: new feature (#1234)',
|
||||
'2024-02-01',
|
||||
);
|
||||
|
||||
expect(result).toContain('## [1.1.0] - 2024-02-01');
|
||||
expect(result).toContain('feat: new feature (#1234)');
|
||||
});
|
||||
|
||||
it('should include empty Unreleased template', () => {
|
||||
const result = buildUpdatedChangelog(
|
||||
baseChangelog,
|
||||
'1.1.0',
|
||||
'### Added\n\n- feat: new feature (#1234)',
|
||||
'2024-02-01',
|
||||
);
|
||||
|
||||
expect(result).toContain('## [Unreleased]');
|
||||
expect(result).toContain('### Deprecated');
|
||||
expect(result).toContain('### Security');
|
||||
});
|
||||
|
||||
it('should clean up triple newlines', () => {
|
||||
const result = buildUpdatedChangelog(
|
||||
baseChangelog,
|
||||
'1.1.0',
|
||||
'### Added\n\n- feat: new feature (#1234)',
|
||||
'2024-02-01',
|
||||
);
|
||||
|
||||
expect(result).not.toMatch(/\n{3,}/);
|
||||
});
|
||||
|
||||
it('should preserve existing versions', () => {
|
||||
const result = buildUpdatedChangelog(
|
||||
baseChangelog,
|
||||
'1.1.0',
|
||||
'### Added\n\n- feat: new feature (#1234)',
|
||||
'2024-02-01',
|
||||
);
|
||||
|
||||
expect(result).toContain('## [1.0.0] - 2024-01-01');
|
||||
expect(result).toContain('feat: old feature (#100)');
|
||||
});
|
||||
|
||||
it('should place new version between Unreleased and old versions', () => {
|
||||
const result = buildUpdatedChangelog(
|
||||
baseChangelog,
|
||||
'1.1.0',
|
||||
'### Added\n\n- feat: new feature (#1234)',
|
||||
'2024-02-01',
|
||||
);
|
||||
|
||||
const unreleasedIndex = result.indexOf('## [Unreleased]');
|
||||
const newVersionIndex = result.indexOf('## [1.1.0]');
|
||||
const oldVersionIndex = result.indexOf('## [1.0.0]');
|
||||
|
||||
expect(unreleasedIndex).toBeLessThan(newVersionIndex);
|
||||
expect(newVersionIndex).toBeLessThan(oldVersionIndex);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateChangelog', () => {
|
||||
let tempDir: string;
|
||||
let changelogPath: string;
|
||||
let packageJsonPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'changelog-test-'));
|
||||
changelogPath = path.join(tempDir, 'CHANGELOG.md');
|
||||
packageJsonPath = path.join(tempDir, 'package.json');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should successfully update changelog', () => {
|
||||
fs.writeFileSync(
|
||||
changelogPath,
|
||||
`# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- feat: new feature (#1234)
|
||||
|
||||
## [1.0.0] - 2024-01-01
|
||||
|
||||
### Added
|
||||
|
||||
- feat: old feature (#100)`,
|
||||
);
|
||||
|
||||
fs.writeFileSync(packageJsonPath, JSON.stringify({ version: '1.1.0' }));
|
||||
|
||||
const result = updateChangelog({
|
||||
changelogPath,
|
||||
packageJsonPath,
|
||||
date: '2024-02-01',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.version).toBe('1.1.0');
|
||||
expect(result.changelog).toContain('## [1.1.0] - 2024-02-01');
|
||||
});
|
||||
|
||||
it('should fail if package.json is missing', () => {
|
||||
fs.writeFileSync(changelogPath, '# Changelog\n\n## [Unreleased]');
|
||||
|
||||
const result = updateChangelog({
|
||||
changelogPath,
|
||||
packageJsonPath: path.join(tempDir, 'nonexistent.json'),
|
||||
date: '2024-02-01',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Failed to read package.json');
|
||||
});
|
||||
|
||||
it('should fail if changelog is missing', () => {
|
||||
fs.writeFileSync(packageJsonPath, JSON.stringify({ version: '1.0.0' }));
|
||||
|
||||
const result = updateChangelog({
|
||||
changelogPath: path.join(tempDir, 'nonexistent.md'),
|
||||
packageJsonPath,
|
||||
date: '2024-02-01',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Failed to read CHANGELOG.md');
|
||||
});
|
||||
|
||||
it('should fail if no version in package.json', () => {
|
||||
fs.writeFileSync(changelogPath, '# Changelog\n\n## [Unreleased]');
|
||||
fs.writeFileSync(packageJsonPath, JSON.stringify({ name: 'test' }));
|
||||
|
||||
const result = updateChangelog({
|
||||
changelogPath,
|
||||
packageJsonPath,
|
||||
date: '2024-02-01',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('No version found in package.json');
|
||||
});
|
||||
|
||||
it('should fail if no Unreleased section', () => {
|
||||
fs.writeFileSync(
|
||||
changelogPath,
|
||||
`# Changelog
|
||||
|
||||
## [1.0.0] - 2024-01-01
|
||||
|
||||
### Added
|
||||
|
||||
- feat: old feature (#100)`,
|
||||
);
|
||||
fs.writeFileSync(packageJsonPath, JSON.stringify({ version: '1.1.0' }));
|
||||
|
||||
const result = updateChangelog({
|
||||
changelogPath,
|
||||
packageJsonPath,
|
||||
date: '2024-02-01',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Could not find Unreleased section in CHANGELOG.md');
|
||||
});
|
||||
|
||||
it('should fail if Unreleased section is empty', () => {
|
||||
fs.writeFileSync(
|
||||
changelogPath,
|
||||
`# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
### Changed
|
||||
|
||||
## [1.0.0] - 2024-01-01`,
|
||||
);
|
||||
fs.writeFileSync(packageJsonPath, JSON.stringify({ version: '1.1.0' }));
|
||||
|
||||
const result = updateChangelog({
|
||||
changelogPath,
|
||||
packageJsonPath,
|
||||
date: '2024-02-01',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('No entries in Unreleased section');
|
||||
});
|
||||
|
||||
it('should write updated changelog to disk', () => {
|
||||
fs.writeFileSync(
|
||||
changelogPath,
|
||||
`# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- feat: new feature (#1234)
|
||||
|
||||
## [1.0.0] - 2024-01-01`,
|
||||
);
|
||||
fs.writeFileSync(packageJsonPath, JSON.stringify({ version: '1.1.0' }));
|
||||
|
||||
updateChangelog({
|
||||
changelogPath,
|
||||
packageJsonPath,
|
||||
date: '2024-02-01',
|
||||
});
|
||||
|
||||
const written = fs.readFileSync(changelogPath, 'utf8');
|
||||
expect(written).toContain('## [1.1.0] - 2024-02-01');
|
||||
expect(written).toContain('feat: new feature (#1234)');
|
||||
});
|
||||
|
||||
it('should handle multiple entries in different categories', () => {
|
||||
fs.writeFileSync(
|
||||
changelogPath,
|
||||
`# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- feat: new feature (#1234)
|
||||
|
||||
### Fixed
|
||||
|
||||
- fix: bug fix (#5678)
|
||||
|
||||
### Security
|
||||
|
||||
- fix: security patch (#9999)
|
||||
|
||||
## [1.0.0] - 2024-01-01`,
|
||||
);
|
||||
fs.writeFileSync(packageJsonPath, JSON.stringify({ version: '1.1.0' }));
|
||||
|
||||
const result = updateChangelog({
|
||||
changelogPath,
|
||||
packageJsonPath,
|
||||
date: '2024-02-01',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.changelog).toContain('feat: new feature (#1234)');
|
||||
expect(result.changelog).toContain('fix: bug fix (#5678)');
|
||||
expect(result.changelog).toContain('fix: security patch (#9999)');
|
||||
});
|
||||
|
||||
it('should fail on invalid JSON in package.json', () => {
|
||||
fs.writeFileSync(changelogPath, '# Changelog\n\n## [Unreleased]');
|
||||
fs.writeFileSync(packageJsonPath, 'not valid json');
|
||||
|
||||
const result = updateChangelog({
|
||||
changelogPath,
|
||||
packageJsonPath,
|
||||
date: '2024-02-01',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Failed to read package.json');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,577 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
interface ValidationResult {
|
||||
valid: boolean;
|
||||
error?: string;
|
||||
message?: string;
|
||||
entries?: string[];
|
||||
footer?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
findEntriesMissingPrNumber,
|
||||
findEntriesWithCommitHash,
|
||||
parseAddedEntries,
|
||||
findUnreleasedEnd,
|
||||
findEntriesInVersionedSections,
|
||||
validateChangelogDiff,
|
||||
} = require('../../scripts/validate-changelog.cjs') as {
|
||||
findEntriesMissingPrNumber: (entries: string[]) => string[];
|
||||
findEntriesWithCommitHash: (entries: string[]) => string[];
|
||||
parseAddedEntries: (diff: string) => string[];
|
||||
findUnreleasedEnd: (changelogContent: string) => number;
|
||||
findEntriesInVersionedSections: (
|
||||
diff: string,
|
||||
unreleasedEnd: number,
|
||||
) => Array<{ line: number; content: string }>;
|
||||
validateChangelogDiff: (options: {
|
||||
diff: string;
|
||||
changelogContent: string;
|
||||
branchName: string;
|
||||
prNumber: string;
|
||||
}) => ValidationResult;
|
||||
};
|
||||
|
||||
describe('validate-changelog', () => {
|
||||
describe('findEntriesMissingPrNumber', () => {
|
||||
it('should return empty array when all entries have PR numbers', () => {
|
||||
const entries = [
|
||||
'- feat(cli): add new feature (#1234)',
|
||||
'- fix(api): resolve bug (#5678)',
|
||||
'- chore: update deps (#9999)',
|
||||
];
|
||||
expect(findEntriesMissingPrNumber(entries)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should find entries missing PR numbers', () => {
|
||||
const entries = [
|
||||
'- feat(cli): add new feature (#1234)',
|
||||
'- fix(api): resolve bug',
|
||||
'- chore: update deps (#9999)',
|
||||
'- docs: update readme',
|
||||
];
|
||||
expect(findEntriesMissingPrNumber(entries)).toEqual([
|
||||
'- fix(api): resolve bug',
|
||||
'- docs: update readme',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle empty array', () => {
|
||||
expect(findEntriesMissingPrNumber([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('should recognize various PR number formats', () => {
|
||||
const entries = ['- feat: feature (#1)', '- fix: fix (#12345)', '- chore: update (#999999)'];
|
||||
expect(findEntriesMissingPrNumber(entries)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should not be fooled by hash in text without parentheses', () => {
|
||||
const entries = ['- feat: implement #1234 feature'];
|
||||
expect(findEntriesMissingPrNumber(entries)).toEqual(['- feat: implement #1234 feature']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findEntriesWithCommitHash', () => {
|
||||
it('should return empty array when no commit hashes present', () => {
|
||||
const entries = ['- feat(cli): add new feature (#1234)', '- fix(api): resolve bug (#5678)'];
|
||||
expect(findEntriesWithCommitHash(entries)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should find entries with commit hashes', () => {
|
||||
const entries = [
|
||||
'- feat(cli): add new feature (abc1234)',
|
||||
'- fix(api): resolve bug (#5678)',
|
||||
'- chore: update deps (1234567)',
|
||||
];
|
||||
expect(findEntriesWithCommitHash(entries)).toEqual([
|
||||
'- feat(cli): add new feature (abc1234)',
|
||||
'- chore: update deps (1234567)',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should flag entries that have both hash and PR number', () => {
|
||||
// Commit hashes should not be used even if PR number is present
|
||||
const entries = ['- feat: feature (abc1234) related to (#1234)'];
|
||||
expect(findEntriesWithCommitHash(entries)).toEqual([
|
||||
'- feat: feature (abc1234) related to (#1234)',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should recognize long commit hashes (40 chars)', () => {
|
||||
const entries = ['- fix: bug (1234567890abcdef1234567890abcdef12345678)'];
|
||||
expect(findEntriesWithCommitHash(entries)).toEqual([
|
||||
'- fix: bug (1234567890abcdef1234567890abcdef12345678)',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not flag short hashes (less than 7 chars)', () => {
|
||||
const entries = ['- fix: bug (abc123)'];
|
||||
expect(findEntriesWithCommitHash(entries)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseAddedEntries', () => {
|
||||
it('should extract added entry lines from diff', () => {
|
||||
const diff = `diff --git a/CHANGELOG.md b/CHANGELOG.md
|
||||
index abc1234..def5678 100644
|
||||
--- a/CHANGELOG.md
|
||||
+++ b/CHANGELOG.md
|
||||
@@ -7,6 +7,8 @@
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
+
|
||||
+- feat(cli): add new feature (#1234)
|
||||
+- fix(api): resolve bug (#5678)
|
||||
|
||||
## [1.0.0] - 2024-01-01`;
|
||||
|
||||
const entries = parseAddedEntries(diff);
|
||||
expect(entries).toEqual([
|
||||
'- feat(cli): add new feature (#1234)',
|
||||
'- fix(api): resolve bug (#5678)',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should ignore non-entry added lines', () => {
|
||||
const diff = `+### Added
|
||||
+
|
||||
+- feat: new feature (#1234)
|
||||
+Some context line`;
|
||||
|
||||
const entries = parseAddedEntries(diff);
|
||||
expect(entries).toEqual(['- feat: new feature (#1234)']);
|
||||
});
|
||||
|
||||
it('should handle empty diff', () => {
|
||||
expect(parseAddedEntries('')).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle diff with only removals', () => {
|
||||
const diff = `--- a/CHANGELOG.md
|
||||
+++ b/CHANGELOG.md
|
||||
@@ -10,3 +10,2 @@
|
||||
-- feat: removed feature (#1234)
|
||||
- feat: kept feature (#5678)`;
|
||||
|
||||
expect(parseAddedEntries(diff)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findUnreleasedEnd', () => {
|
||||
it('should find line number of first versioned section', () => {
|
||||
const content = `# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- feat: new feature (#1234)
|
||||
|
||||
## [1.0.0] - 2024-01-01
|
||||
|
||||
### Added
|
||||
|
||||
- feat: released feature (#100)`;
|
||||
|
||||
expect(findUnreleasedEnd(content)).toBe(9);
|
||||
});
|
||||
|
||||
it('should return 999999 when no versioned sections exist', () => {
|
||||
const content = `# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- feat: new feature (#1234)`;
|
||||
|
||||
expect(findUnreleasedEnd(content)).toBe(999999);
|
||||
});
|
||||
|
||||
it('should find first versioned section among multiple', () => {
|
||||
const content = `# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2.0.0] - 2024-06-01
|
||||
|
||||
## [1.0.0] - 2024-01-01`;
|
||||
|
||||
expect(findUnreleasedEnd(content)).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findEntriesInVersionedSections', () => {
|
||||
it('should find entries added after unreleased section', () => {
|
||||
const diff = `@@ -1,10 +1,12 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
## [1.0.0] - 2024-01-01
|
||||
|
||||
### Added
|
||||
+
|
||||
+- feat: added to old version (#1234)`;
|
||||
|
||||
const entries = findEntriesInVersionedSections(diff, 7);
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0].content).toBe('- feat: added to old version (#1234)');
|
||||
expect(entries[0].line).toBeGreaterThanOrEqual(7);
|
||||
});
|
||||
|
||||
it('should not flag entries in unreleased section', () => {
|
||||
const diff = `@@ -1,6 +1,8 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
+
|
||||
+- feat: new feature (#1234)`;
|
||||
|
||||
const entries = findEntriesInVersionedSections(diff, 10);
|
||||
expect(entries).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle multiple hunks', () => {
|
||||
const diff = `@@ -3,3 +3,4 @@
|
||||
## [Unreleased]
|
||||
+- feat: in unreleased (#1)
|
||||
|
||||
@@ -10,3 +11,4 @@
|
||||
## [1.0.0]
|
||||
+- feat: in versioned (#2)`;
|
||||
|
||||
const entries = findEntriesInVersionedSections(diff, 8);
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0].content).toBe('- feat: in versioned (#2)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateChangelogDiff', () => {
|
||||
// Note: changelogContent should reflect the state AFTER the diff is applied
|
||||
// (as it would be in the working directory during CI)
|
||||
|
||||
it('should pass validation for correct entries', () => {
|
||||
const diff = `@@ -3,4 +3,6 @@
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
+
|
||||
+- feat: new feature (#1234)`;
|
||||
|
||||
// Changelog content after the diff is applied
|
||||
const changelogAfterDiff = `# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- feat: new feature (#1234)
|
||||
|
||||
## [1.0.0] - 2024-01-01
|
||||
|
||||
### Added
|
||||
|
||||
- feat: old feature (#100)`;
|
||||
|
||||
const result = validateChangelogDiff({
|
||||
diff,
|
||||
changelogContent: changelogAfterDiff,
|
||||
branchName: 'feat/my-feature',
|
||||
prNumber: '1234',
|
||||
});
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('should skip validation for version bump branches', () => {
|
||||
const diff = `+- feat: no pr number`;
|
||||
|
||||
const changelogContent = `# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- feat: no pr number
|
||||
|
||||
## [1.0.0] - 2024-01-01`;
|
||||
|
||||
const result = validateChangelogDiff({
|
||||
diff,
|
||||
changelogContent,
|
||||
branchName: 'chore/bump-version-12345',
|
||||
prNumber: '1234',
|
||||
});
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('should fail when PR numbers are missing', () => {
|
||||
const diff = `@@ -3,4 +3,6 @@
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
+
|
||||
+- feat: new feature without pr`;
|
||||
|
||||
const changelogContent = `# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- feat: new feature without pr
|
||||
|
||||
## [1.0.0] - 2024-01-01`;
|
||||
|
||||
const result = validateChangelogDiff({
|
||||
diff,
|
||||
changelogContent,
|
||||
branchName: 'feat/my-feature',
|
||||
prNumber: '1234',
|
||||
});
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toBe('missing_pr_number');
|
||||
expect(result.entries).toContain('- feat: new feature without pr');
|
||||
});
|
||||
|
||||
it('should fail when commit hashes are used instead of PR numbers', () => {
|
||||
// Entry with a valid PR number AND a commit hash - tests that hashes are detected
|
||||
const diff = `@@ -3,4 +3,6 @@
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
+
|
||||
+- feat: new feature (abc1234)`;
|
||||
|
||||
const changelogContent = `# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- feat: new feature (abc1234)
|
||||
|
||||
## [1.0.0] - 2024-01-01`;
|
||||
|
||||
const result = validateChangelogDiff({
|
||||
diff,
|
||||
changelogContent,
|
||||
branchName: 'feat/my-feature',
|
||||
prNumber: '1234',
|
||||
});
|
||||
|
||||
// Note: This fails the "missing PR number" check first, since (abc1234) is not (#1234)
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toBe('missing_pr_number');
|
||||
});
|
||||
|
||||
it('should detect commit hash when entry also has PR-like text', () => {
|
||||
// Entry with text containing # but using commit hash in parentheses
|
||||
const diff = `+- feat: fix issue #123 (abc1234)`;
|
||||
|
||||
const changelogContent = `# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- feat: fix issue #123 (abc1234)
|
||||
|
||||
## [1.0.0] - 2024-01-01`;
|
||||
|
||||
const result = validateChangelogDiff({
|
||||
diff,
|
||||
changelogContent,
|
||||
branchName: 'feat/my-feature',
|
||||
prNumber: '1234',
|
||||
});
|
||||
|
||||
// This should fail the commit hash check since it has # in text but not (#\d+) format
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toBe('missing_pr_number');
|
||||
});
|
||||
|
||||
it('should detect commit hash even when valid PR number is present', () => {
|
||||
// Entry has valid PR format (#1234) AND a commit hash - should fail
|
||||
const diff = `+- feat: new feature (abc1234) (#1234)`;
|
||||
|
||||
const changelogContent = `# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- feat: new feature (abc1234) (#1234)
|
||||
|
||||
## [1.0.0] - 2024-01-01`;
|
||||
|
||||
const result = validateChangelogDiff({
|
||||
diff,
|
||||
changelogContent,
|
||||
branchName: 'feat/my-feature',
|
||||
prNumber: '1234',
|
||||
});
|
||||
|
||||
// Should pass PR number check but fail commit hash check
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toBe('commit_hash_instead_of_pr');
|
||||
});
|
||||
|
||||
it('should fail when entries are in versioned sections', () => {
|
||||
const diff = `@@ -8,4 +8,6 @@
|
||||
## [1.0.0] - 2024-01-01
|
||||
|
||||
### Added
|
||||
+
|
||||
+- feat: added to wrong section (#1234)`;
|
||||
|
||||
const changelogContent = `# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
## [1.0.0] - 2024-01-01
|
||||
|
||||
### Added
|
||||
|
||||
- feat: added to wrong section (#1234)
|
||||
|
||||
- feat: old feature (#100)`;
|
||||
|
||||
const result = validateChangelogDiff({
|
||||
diff,
|
||||
changelogContent,
|
||||
branchName: 'feat/my-feature',
|
||||
prNumber: '1234',
|
||||
});
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toBe('entries_in_versioned_section');
|
||||
});
|
||||
|
||||
it('should pass when no entries are added', () => {
|
||||
const diff = `@@ -1,3 +1,3 @@
|
||||
# Changelog
|
||||
-old header
|
||||
+new header`;
|
||||
|
||||
const changelogContent = `# Changelog
|
||||
new header
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.0.0] - 2024-01-01`;
|
||||
|
||||
const result = validateChangelogDiff({
|
||||
diff,
|
||||
changelogContent,
|
||||
branchName: 'feat/my-feature',
|
||||
prNumber: '1234',
|
||||
});
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('should include PR number in error message', () => {
|
||||
const diff = `+- feat: missing pr number`;
|
||||
|
||||
const changelogContent = `# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- feat: missing pr number
|
||||
|
||||
## [1.0.0] - 2024-01-01`;
|
||||
|
||||
const result = validateChangelogDiff({
|
||||
diff,
|
||||
changelogContent,
|
||||
branchName: 'feat/my-feature',
|
||||
prNumber: '5678',
|
||||
});
|
||||
|
||||
expect(result.message).toContain('(#5678)');
|
||||
});
|
||||
|
||||
it('should validate multiple entries correctly', () => {
|
||||
const diff = `@@ -3,4 +3,8 @@
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
+
|
||||
+- feat: first feature (#1234)
|
||||
+- fix: second fix (#5678)
|
||||
+- chore: third update (#9999)`;
|
||||
|
||||
const changelogContent = `# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- feat: first feature (#1234)
|
||||
- fix: second fix (#5678)
|
||||
- chore: third update (#9999)
|
||||
|
||||
## [1.0.0] - 2024-01-01
|
||||
|
||||
### Added
|
||||
|
||||
- feat: old feature (#100)`;
|
||||
|
||||
const result = validateChangelogDiff({
|
||||
diff,
|
||||
changelogContent,
|
||||
branchName: 'feat/my-feature',
|
||||
prNumber: '1234',
|
||||
});
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('should fail if any entry is missing PR number', () => {
|
||||
const diff = `@@ -3,4 +3,8 @@
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
+
|
||||
+- feat: first feature (#1234)
|
||||
+- fix: second fix
|
||||
+- chore: third update (#9999)`;
|
||||
|
||||
const changelogContent = `# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- feat: first feature (#1234)
|
||||
- fix: second fix
|
||||
- chore: third update (#9999)
|
||||
|
||||
## [1.0.0] - 2024-01-01`;
|
||||
|
||||
const result = validateChangelogDiff({
|
||||
diff,
|
||||
changelogContent,
|
||||
branchName: 'feat/my-feature',
|
||||
prNumber: '1234',
|
||||
});
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toBe('missing_pr_number');
|
||||
expect(result.entries).toEqual(['- fix: second fix']);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user