periapsis 1.0.2 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- name: Periapsis License Check
1
+ name: Periapsis PR Gate
2
2
 
3
3
  on:
4
4
  workflow_dispatch:
@@ -9,9 +9,29 @@ permissions:
9
9
  contents: read
10
10
 
11
11
  jobs:
12
- periapsis:
12
+ test-suite:
13
13
  if: github.event_name != 'pull_request' || github.event.pull_request.draft == false
14
14
  runs-on: ubuntu-latest
15
+ steps:
16
+ - name: Checkout
17
+ uses: actions/checkout@v4
18
+
19
+ - name: Setup Node
20
+ uses: actions/setup-node@v4
21
+ with:
22
+ node-version: 20
23
+ cache: npm
24
+
25
+ - name: Install dependencies
26
+ run: npm ci
27
+
28
+ - name: Run automated test suite
29
+ run: npm run test:ci
30
+
31
+ policy-gate:
32
+ if: github.event_name != 'pull_request' || github.event.pull_request.draft == false
33
+ needs: test-suite
34
+ runs-on: ubuntu-latest
15
35
  steps:
16
36
  - name: Checkout
17
37
  uses: actions/checkout@v4
@@ -28,7 +48,7 @@ jobs:
28
48
  - name: Run Periapsis policy check
29
49
  id: periapsis
30
50
  continue-on-error: true
31
- run: npx periapsis --violations-out sbom-violations.json
51
+ run: npm run policy:check
32
52
 
33
53
  - name: Enforce zero violations
34
54
  if: always()
@@ -0,0 +1,123 @@
1
+ name: Publish Package
2
+
3
+ on:
4
+ workflow_dispatch:
5
+ inputs:
6
+ release_type:
7
+ description: Semver increment to publish
8
+ required: true
9
+ type: choice
10
+ default: patch
11
+ options:
12
+ - patch
13
+ - minor
14
+ - major
15
+
16
+ permissions:
17
+ contents: read
18
+
19
+ concurrency:
20
+ group: publish-package
21
+ cancel-in-progress: false
22
+
23
+ jobs:
24
+ authorize-release:
25
+ name: Authorize Release
26
+ runs-on: ubuntu-latest
27
+ steps:
28
+ - name: Require default branch
29
+ env:
30
+ DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
31
+ run: |
32
+ if [ "${GITHUB_REF_NAME}" != "${DEFAULT_BRANCH}" ]; then
33
+ echo "Manual publishing is only allowed from ${DEFAULT_BRANCH}."
34
+ exit 1
35
+ fi
36
+
37
+ - name: Require repository admin permission
38
+ uses: actions/github-script@v7
39
+ with:
40
+ script: |
41
+ const owner = context.repo.owner;
42
+ const repo = context.repo.repo;
43
+ const actor = context.actor;
44
+
45
+ if (owner.toLowerCase() === actor.toLowerCase()) {
46
+ core.info(`Actor ${actor} matches the repository owner and is treated as admin.`);
47
+ return;
48
+ }
49
+
50
+ const response = await github.rest.repos.getCollaboratorPermissionLevel({
51
+ owner,
52
+ repo,
53
+ username: actor,
54
+ });
55
+
56
+ const { permission, role_name: roleName } = response.data;
57
+ core.info(`Actor ${actor} permission=${permission}, role_name=${roleName}`);
58
+
59
+ if (permission !== "admin" && roleName !== "admin") {
60
+ core.setFailed(`Only repository admins can publish packages. ${actor} has ${roleName || permission}.`);
61
+ }
62
+
63
+ publish-package:
64
+ name: Publish Package
65
+ needs: authorize-release
66
+ runs-on: ubuntu-latest
67
+ environment:
68
+ # Configure this environment in GitHub with required reviewers for an
69
+ # extra approval gate, and use the same name in npm trusted publisher
70
+ # settings if you want npm to bind publishing to this environment.
71
+ name: package-publish
72
+ permissions:
73
+ # Required because this workflow commits the bumped version, creates a
74
+ # release tag, and pushes both back to the repository.
75
+ contents: write
76
+ id-token: write
77
+ steps:
78
+ - name: Checkout
79
+ uses: actions/checkout@v4
80
+ with:
81
+ fetch-depth: 0
82
+
83
+ - name: Setup Node
84
+ uses: actions/setup-node@v4
85
+ with:
86
+ node-version: 24
87
+ cache: npm
88
+ registry-url: https://registry.npmjs.org
89
+
90
+ - name: Install dependencies
91
+ run: npm ci
92
+
93
+ - name: Run automated test suite
94
+ run: npm run test:ci
95
+
96
+ - name: Bump package version
97
+ run: npm version "${{ inputs.release_type }}" --no-git-tag-version
98
+
99
+ - name: Read new version
100
+ id: version
101
+ run: |
102
+ echo "value=$(node -p 'require(\"./package.json\").version')" >> "$GITHUB_OUTPUT"
103
+
104
+ - name: Configure git author
105
+ run: |
106
+ git config user.name "github-actions[bot]"
107
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
108
+
109
+ - name: Commit release version
110
+ env:
111
+ VERSION: ${{ steps.version.outputs.value }}
112
+ run: |
113
+ git add package.json package-lock.json
114
+ git commit -m "chore(release): v${VERSION}"
115
+ git tag "v${VERSION}"
116
+
117
+ - name: Publish to npm with trusted publishing
118
+ run: npm publish
119
+
120
+ - name: Push release commit and tag
121
+ env:
122
+ VERSION: ${{ steps.version.outputs.value }}
123
+ run: git push --atomic origin "HEAD:${GITHUB_REF_NAME}" "refs/tags/v${VERSION}"
package/README.md CHANGED
@@ -17,6 +17,22 @@ npx periapsis init --preset strict
17
17
 
18
18
  `init` now asks which dependency types should be checked by default (unless provided via flags).
19
19
 
20
+ ## Testing
21
+
22
+ Run the regression suite:
23
+
24
+ ```sh
25
+ npm test
26
+ ```
27
+
28
+ Run the repository policy gate locally:
29
+
30
+ ```sh
31
+ npm run policy:check
32
+ ```
33
+
34
+ Testing layers and PR gate guidance live in `docs/testing-strategy.md`.
35
+
20
36
  ## Commands
21
37
 
22
38
  - `periapsis`: run SBOM + license gate
package/bin/periapsis.mjs CHANGED
@@ -233,11 +233,9 @@ function loadPolicyOrThrow(policyDir) {
233
233
  return loadPolicyFromNewFiles(policyDir);
234
234
  }
235
235
 
236
- async function cmdExceptionsAdd(root, policyDirArg) {
236
+ async function cmdExceptionsAdd(root, policyDirArg, args) {
237
237
  const policyDir = path.resolve(root, policyDirArg || 'policy');
238
238
  const policy = loadPolicyOrThrow(policyDir);
239
- const args = parseArgs(process.argv.slice(2));
240
-
241
239
  const writeExceptions = (record, { editExisting = false } = {}) => {
242
240
  const sameScope = policy.exceptions.filter(
243
241
  (entry) => entry.package === record.package && scopeKey(entry.scope) === scopeKey(record.scope)
@@ -384,12 +382,10 @@ async function cmdExceptionsAdd(root, policyDirArg) {
384
382
  });
385
383
  }
386
384
 
387
- async function cmdLicensesAllowAdd(root, policyDirArg) {
385
+ async function cmdLicensesAllowAdd(root, policyDirArg, args) {
388
386
  const policyDir = path.resolve(root, policyDirArg || 'policy');
389
387
  const policy = loadPolicyOrThrow(policyDir);
390
388
  const spdx = loadSpdxCatalog(SPDX_PATH);
391
- const args = parseArgs(process.argv.slice(2));
392
-
393
389
  const writeLicenses = (record) => {
394
390
  if (policy.licenses.some((entry) => entry.identifier === record.identifier)) {
395
391
  console.warn(
@@ -656,8 +652,8 @@ function cmdCheck(root, args) {
656
652
  }
657
653
  }
658
654
 
659
- async function main() {
660
- const args = parseArgs(process.argv.slice(2));
655
+ export async function main(argv = process.argv.slice(2)) {
656
+ const args = parseArgs(argv);
661
657
  const root = resolveRoot(args);
662
658
 
663
659
  if (args.help) {
@@ -682,12 +678,12 @@ async function main() {
682
678
  }
683
679
 
684
680
  if (cmd1 === 'exceptions' && cmd2 === 'add') {
685
- await cmdExceptionsAdd(root, args['policy-dir']);
681
+ await cmdExceptionsAdd(root, args['policy-dir'], args);
686
682
  return;
687
683
  }
688
684
 
689
685
  if (cmd1 === 'licenses' && cmd2 === 'allow' && cmd3 === 'add') {
690
- await cmdLicensesAllowAdd(root, args['policy-dir']);
686
+ await cmdLicensesAllowAdd(root, args['policy-dir'], args);
691
687
  return;
692
688
  }
693
689
 
@@ -700,7 +696,9 @@ async function main() {
700
696
  cmdCheck(root, args);
701
697
  }
702
698
 
703
- main().catch((err) => {
704
- console.error(err.message);
705
- process.exit(1);
706
- });
699
+ if (process.argv[1] && path.resolve(process.argv[1]) === __filename) {
700
+ main().catch((err) => {
701
+ console.error(err.message);
702
+ process.exit(1);
703
+ });
704
+ }
@@ -0,0 +1,113 @@
1
+ # Testing Strategy
2
+
3
+ Periapsis now has an in-repo regression strategy designed to make dependency, policy, and vulnerability response work safer to ship. The goal is to catch behavior changes before they merge and to keep policy governance reviewable over time.
4
+
5
+ ## Goals
6
+
7
+ - Prevent regressions in CLI behavior, policy evaluation, and dependency-scope handling.
8
+ - Keep tests self-contained in this repository with no live registry or SaaS dependency.
9
+ - Make policy behavior explicit for common governance modes:
10
+ - permissive-only / open license defaults
11
+ - weak copyleft allowed
12
+ - all dependencies versus runtime-only dependencies
13
+ - expiring exceptions and follow-up approvals
14
+ - Turn the suite into a required PR gate through GitHub Actions branch protection.
15
+
16
+ ## Test Layers
17
+
18
+ ### 1. Core policy unit tests
19
+
20
+ File: `test/policy.test.mjs`
21
+
22
+ Covers pure library logic such as:
23
+
24
+ - SPDX expression parsing
25
+ - category mapping
26
+ - dependency-type parsing
27
+ - exception range matching
28
+ - active versus expired policy records
29
+
30
+ These are the fastest tests and should grow whenever policy logic changes.
31
+
32
+ ### 2. CLI workflow tests
33
+
34
+ Files:
35
+
36
+ - `test/cli.test.mjs`
37
+ - `test/commands.test.mjs`
38
+
39
+ Covers command behavior and file-writing flows such as:
40
+
41
+ - `licenses allow add`
42
+ - `exceptions add`
43
+ - `init`
44
+ - `policy migrate`
45
+ - policy-driven dependency-type defaults
46
+
47
+ These protect user-facing commands from accidental flag or file-format regressions.
48
+
49
+ ### 3. Fixture-driven regression gate tests
50
+
51
+ File: `test/regression-gate.test.mjs`
52
+
53
+ These tests model governed dependency scenarios end to end using temporary fixture projects:
54
+
55
+ - strict runtime-only policy should ignore disallowed dev dependencies
56
+ - all-dependency policy should fail on the same dev dependency
57
+ - standard policy should allow weak copyleft
58
+ - expired exceptions should fail
59
+ - active follow-up exceptions should pass
60
+
61
+ This is the highest-value layer for future updates to license logic, policy schemas, or dependency traversal.
62
+
63
+ ## NPM Scripts
64
+
65
+ Use the following scripts locally and in CI:
66
+
67
+ - `npm test`: full Node test suite
68
+ - `npm run test:unit`: core policy engine tests
69
+ - `npm run test:cli`: CLI and fixture-driven regression tests
70
+ - `npm run policy:check`: run Periapsis against this repository and write `sbom-violations.json`
71
+ - `npm run test:ci`: CI entrypoint, currently equivalent to `npm test`
72
+
73
+ ## CI Gate
74
+
75
+ The GitHub Action should run on `pull_request` and enforce two checks:
76
+
77
+ 1. `test-suite`
78
+ Runs the full automated test suite.
79
+
80
+ 2. `policy-gate`
81
+ Runs Periapsis against the repository itself and uploads `sbom-violations.json`.
82
+
83
+ Recommended branch protection:
84
+
85
+ 1. Require status checks to pass before merging.
86
+ 2. Mark `test-suite` as required.
87
+ 3. Mark `policy-gate` as required.
88
+ 4. Optionally require the branch to be up to date before merging.
89
+
90
+ ## Dependency Separation
91
+
92
+ Periapsis runtime code should stay in `dependencies`. Any future tooling used only for verification should stay in `devDependencies` so the shipping package remains small and auditable.
93
+
94
+ Suggested future additions, if wanted:
95
+
96
+ - coverage reporting
97
+ - linting
98
+ - JSON schema fixture validation
99
+ - scheduled policy-audit workflow for upcoming exception expirations
100
+
101
+ Those can be added later without changing the runtime surface area of the CLI.
102
+
103
+ ## Policy Maintenance Rules
104
+
105
+ When updating dependencies or handling a vulnerability:
106
+
107
+ 1. Change the dependency.
108
+ 2. Run `npm test`.
109
+ 3. Run `npm run policy:check`.
110
+ 4. If policy changes are required, update only the governed files in `policy/`.
111
+ 5. Prefer adding follow-up license or exception records instead of mutating history in place.
112
+
113
+ That keeps remediation work auditable and lowers the risk of silently loosening policy.
package/package.json CHANGED
@@ -1,10 +1,14 @@
1
1
  {
2
2
  "name": "periapsis",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "scripts": {
7
- "test": "node --test"
7
+ "test": "node --test",
8
+ "test:unit": "node --test test/policy.test.mjs",
9
+ "test:cli": "node --test test/cli.test.mjs test/commands.test.mjs test/regression-gate.test.mjs",
10
+ "test:ci": "npm run test",
11
+ "policy:check": "node ./bin/periapsis.mjs --violations-out sbom-violations.json"
8
12
  },
9
13
  "bin": {
10
14
  "periapsis": "bin/periapsis.mjs"
package/test/cli.test.mjs CHANGED
@@ -1,55 +1,18 @@
1
1
  import test from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
3
  import fs from 'fs';
4
- import os from 'os';
5
4
  import path from 'path';
6
- import { execFileSync } from 'child_process';
7
- import { fileURLToPath } from 'url';
8
-
9
- const __filename = fileURLToPath(import.meta.url);
10
- const __dirname = path.dirname(__filename);
11
- const BIN = path.resolve(__dirname, '..', 'bin', 'periapsis.mjs');
5
+ import { createTempDir, runCli, writePolicyBundle } from '../testing/helpers.mjs';
12
6
 
13
7
  function setupTempProject() {
14
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'periapsis-test-'));
15
- fs.mkdirSync(path.join(dir, 'policy'), { recursive: true });
16
- fs.writeFileSync(
17
- path.join(dir, 'policy', 'policy.json'),
18
- JSON.stringify(
19
- {
20
- allowedCategories: ['Permissive Licenses'],
21
- failOnUnknownLicense: true,
22
- timezone: 'UTC',
23
- dependencyTypes: ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies', 'bundledDependencies']
24
- },
25
- null,
26
- 2
27
- ) + '\n'
28
- );
29
- fs.writeFileSync(path.join(dir, 'policy', 'licenses.json'), '[]\n');
30
- fs.writeFileSync(path.join(dir, 'policy', 'exceptions.json'), '[]\n');
8
+ const dir = createTempDir('periapsis-test-');
9
+ writePolicyBundle(dir);
31
10
  return dir;
32
11
  }
33
12
 
34
- function runCli(cwd, args, { expectFail = false } = {}) {
35
- try {
36
- const out = execFileSync('node', [BIN, ...args], {
37
- cwd,
38
- encoding: 'utf8'
39
- });
40
- if (expectFail) {
41
- assert.fail(`Expected command to fail: ${args.join(' ')}`);
42
- }
43
- return out;
44
- } catch (err) {
45
- if (!expectFail) throw err;
46
- return `${err.stdout || ''}${err.stderr || ''}`;
47
- }
48
- }
49
-
50
- test('licenses allow add supports non-interactive mode', () => {
13
+ test('licenses allow add supports non-interactive mode', async () => {
51
14
  const cwd = setupTempProject();
52
- runCli(cwd, [
15
+ await runCli(cwd, [
53
16
  'licenses',
54
17
  'allow',
55
18
  'add',
@@ -72,9 +35,9 @@ test('licenses allow add supports non-interactive mode', () => {
72
35
  assert.equal(licenses[0].approvedBy[0], 'security');
73
36
  });
74
37
 
75
- test('exceptions add supports non-interactive mode', () => {
38
+ test('exceptions add supports non-interactive mode', async () => {
76
39
  const cwd = setupTempProject();
77
- runCli(cwd, [
40
+ await runCli(cwd, [
78
41
  'exceptions',
79
42
  'add',
80
43
  '--non-interactive',
@@ -100,9 +63,9 @@ test('exceptions add supports non-interactive mode', () => {
100
63
  assert.equal(exceptions[0].scope.type, 'exact');
101
64
  });
102
65
 
103
- test('non-interactive mode enforces required fields', () => {
66
+ test('non-interactive mode enforces required fields', async () => {
104
67
  const cwd = setupTempProject();
105
- const output = runCli(
68
+ const output = await runCli(
106
69
  cwd,
107
70
  ['licenses', 'allow', 'add', '--non-interactive', '--identifier', 'MIT'],
108
71
  { expectFail: true }
@@ -111,7 +74,7 @@ test('non-interactive mode enforces required fields', () => {
111
74
  assert.match(output, /--approved-by is required/);
112
75
  });
113
76
 
114
- test('checker respects --dep-types filter', () => {
77
+ test('checker respects --dep-types filter', async () => {
115
78
  const cwd = setupTempProject();
116
79
  fs.writeFileSync(
117
80
  path.join(cwd, 'package.json'),
@@ -150,7 +113,7 @@ test('checker respects --dep-types filter', () => {
150
113
  fs.mkdirSync(path.join(cwd, 'node_modules', 'a'), { recursive: true });
151
114
  fs.mkdirSync(path.join(cwd, 'node_modules', 'b'), { recursive: true });
152
115
 
153
- runCli(cwd, [
116
+ await runCli(cwd, [
154
117
  'licenses',
155
118
  'allow',
156
119
  'add',
@@ -167,8 +130,8 @@ test('checker respects --dep-types filter', () => {
167
130
  'JIRA-300'
168
131
  ]);
169
132
 
170
- runCli(cwd, ['--dep-types', 'dependencies', '--quiet']);
171
- const failOutput = runCli(cwd, ['--dep-types', 'devDependencies'], {
133
+ await runCli(cwd, ['--dep-types', 'dependencies', '--quiet']);
134
+ const failOutput = await runCli(cwd, ['--dep-types', 'devDependencies'], {
172
135
  expectFail: true
173
136
  });
174
137
  assert.match(failOutput, /license-not-allowed/);
@@ -0,0 +1,98 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'fs';
4
+ import path from 'path';
5
+ import { createTempDir, runCli, writeJson, writePolicyBundle } from '../testing/helpers.mjs';
6
+
7
+ test('init writes strict runtime-only policy when requested', async () => {
8
+ const cwd = createTempDir('periapsis-init-');
9
+
10
+ await runCli(cwd, ['init', '--preset', 'strict', '--production-only']);
11
+
12
+ const policy = JSON.parse(fs.readFileSync(path.join(cwd, 'policy', 'policy.json'), 'utf8'));
13
+ assert.deepEqual(policy.allowedCategories, ['Permissive Licenses']);
14
+ assert.deepEqual(policy.dependencyTypes, ['dependencies']);
15
+ });
16
+
17
+ test('init writes standard policy with explicit dependency types', async () => {
18
+ const cwd = createTempDir('periapsis-init-deps-');
19
+
20
+ await runCli(cwd, [
21
+ 'init',
22
+ '--preset',
23
+ 'standard',
24
+ '--dep-types',
25
+ 'dependencies,peerDependencies'
26
+ ]);
27
+
28
+ const policy = JSON.parse(fs.readFileSync(path.join(cwd, 'policy', 'policy.json'), 'utf8'));
29
+ assert.deepEqual(policy.allowedCategories, [
30
+ 'Permissive Licenses',
31
+ 'Weak Copyleft Licenses'
32
+ ]);
33
+ assert.deepEqual(policy.dependencyTypes, ['dependencies', 'peerDependencies']);
34
+ });
35
+
36
+ test('policy migrate converts legacy config to governed policy files', async () => {
37
+ const cwd = createTempDir('periapsis-migrate-');
38
+ writeJson(path.join(cwd, 'allowedConfig.json'), {
39
+ allowedLicenses: ['MIT'],
40
+ allowedCategories: ['B'],
41
+ exceptions: ['left-pad@1.3.0', 'uuid']
42
+ });
43
+
44
+ await runCli(cwd, ['policy', 'migrate', '--from', 'allowedConfig.json']);
45
+
46
+ const policy = JSON.parse(fs.readFileSync(path.join(cwd, 'policy', 'policy.json'), 'utf8'));
47
+ const licenses = JSON.parse(fs.readFileSync(path.join(cwd, 'policy', 'licenses.json'), 'utf8'));
48
+ const exceptions = JSON.parse(fs.readFileSync(path.join(cwd, 'policy', 'exceptions.json'), 'utf8'));
49
+
50
+ assert.deepEqual(policy.allowedCategories, ['Weak Copyleft Licenses']);
51
+ assert.equal(licenses.length, 1);
52
+ assert.equal(licenses[0].identifier, 'MIT');
53
+ assert.equal(exceptions.length, 2);
54
+ assert.deepEqual(exceptions[0].scope, { type: 'exact', version: '1.3.0' });
55
+ assert.deepEqual(exceptions[1].scope, { type: 'any' });
56
+ });
57
+
58
+ test('checker honors policy dependencyTypes when no CLI override is provided', async () => {
59
+ const cwd = createTempDir('periapsis-policy-scope-');
60
+ writePolicyBundle(cwd, {
61
+ settings: {
62
+ allowedCategories: ['Permissive Licenses'],
63
+ failOnUnknownLicense: true,
64
+ timezone: 'UTC',
65
+ dependencyTypes: ['dependencies']
66
+ },
67
+ licenses: [],
68
+ exceptions: []
69
+ });
70
+ writeJson(path.join(cwd, 'package.json'), {
71
+ name: 'scope-app',
72
+ version: '1.0.0',
73
+ dependencies: { a: '1.0.0' },
74
+ devDependencies: { b: '1.0.0' }
75
+ });
76
+ writeJson(path.join(cwd, 'package-lock.json'), {
77
+ name: 'scope-app',
78
+ version: '1.0.0',
79
+ lockfileVersion: 3,
80
+ packages: {
81
+ '': {
82
+ name: 'scope-app',
83
+ version: '1.0.0',
84
+ dependencies: { a: '1.0.0' },
85
+ devDependencies: { b: '1.0.0' }
86
+ },
87
+ 'node_modules/a': { version: '1.0.0', license: 'MIT' },
88
+ 'node_modules/b': { version: '1.0.0', license: 'GPL-3.0', dev: true }
89
+ }
90
+ });
91
+ fs.mkdirSync(path.join(cwd, 'node_modules', 'a'), { recursive: true });
92
+ fs.mkdirSync(path.join(cwd, 'node_modules', 'b'), { recursive: true });
93
+
94
+ await runCli(cwd, ['--quiet']);
95
+
96
+ const sbom = JSON.parse(fs.readFileSync(path.join(cwd, 'sbom-licenses.json'), 'utf8'));
97
+ assert.deepEqual(sbom.map((entry) => entry.name), ['a']);
98
+ });
@@ -0,0 +1,188 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'fs';
4
+ import path from 'path';
5
+ import { createFixtureProject, runCli } from '../testing/helpers.mjs';
6
+
7
+ function readViolations(root) {
8
+ return JSON.parse(fs.readFileSync(path.join(root, 'violations.json'), 'utf8'));
9
+ }
10
+
11
+ test('runtime-only strict policy ignores disallowed dev dependency', async () => {
12
+ const cwd = createFixtureProject({
13
+ prefix: 'periapsis-runtime-only-',
14
+ packageJson: {
15
+ name: 'runtime-only-app',
16
+ version: '1.0.0',
17
+ dependencies: { runtimeok: '1.0.0' },
18
+ devDependencies: { buildgpl: '1.0.0' }
19
+ },
20
+ lockPackages: {
21
+ 'node_modules/runtimeok': { version: '1.0.0', license: 'MIT' },
22
+ 'node_modules/buildgpl': { version: '1.0.0', license: 'GPL-3.0', dev: true }
23
+ },
24
+ policy: {
25
+ settings: {
26
+ allowedCategories: ['Permissive Licenses'],
27
+ failOnUnknownLicense: true,
28
+ timezone: 'UTC',
29
+ dependencyTypes: ['dependencies']
30
+ }
31
+ }
32
+ });
33
+
34
+ await runCli(cwd, ['--violations-out', 'violations.json', '--quiet']);
35
+
36
+ assert.deepEqual(readViolations(cwd), []);
37
+ });
38
+
39
+ test('all-dependency policy blocks disallowed dev dependency regressions', async () => {
40
+ const cwd = createFixtureProject({
41
+ prefix: 'periapsis-all-deps-',
42
+ packageJson: {
43
+ name: 'all-deps-app',
44
+ version: '1.0.0',
45
+ dependencies: { runtimeok: '1.0.0' },
46
+ devDependencies: { buildgpl: '1.0.0' }
47
+ },
48
+ lockPackages: {
49
+ 'node_modules/runtimeok': { version: '1.0.0', license: 'MIT' },
50
+ 'node_modules/buildgpl': { version: '1.0.0', license: 'GPL-3.0', dev: true }
51
+ },
52
+ policy: {
53
+ settings: {
54
+ allowedCategories: ['Permissive Licenses'],
55
+ failOnUnknownLicense: true,
56
+ timezone: 'UTC',
57
+ dependencyTypes: ['dependencies', 'devDependencies']
58
+ }
59
+ }
60
+ });
61
+
62
+ await runCli(cwd, ['--violations-out', 'violations.json'], {
63
+ expectFail: true
64
+ });
65
+
66
+ const violations = readViolations(cwd);
67
+ assert.equal(violations.length, 1);
68
+ assert.equal(violations[0].name, 'buildgpl');
69
+ assert.equal(violations[0].reasonType, 'license-not-allowed');
70
+ });
71
+
72
+ test('standard policy allows weak copyleft licenses', async () => {
73
+ const cwd = createFixtureProject({
74
+ prefix: 'periapsis-weak-copyleft-',
75
+ packageJson: {
76
+ name: 'weak-copyleft-app',
77
+ version: '1.0.0',
78
+ dependencies: { liblgpl: '1.0.0' }
79
+ },
80
+ lockPackages: {
81
+ 'node_modules/liblgpl': { version: '1.0.0', license: 'LGPL-2.1-only' }
82
+ },
83
+ policy: {
84
+ settings: {
85
+ allowedCategories: ['Permissive Licenses', 'Weak Copyleft Licenses'],
86
+ failOnUnknownLicense: true,
87
+ timezone: 'UTC',
88
+ dependencyTypes: ['dependencies']
89
+ }
90
+ }
91
+ });
92
+
93
+ await runCli(cwd, ['--violations-out', 'violations.json', '--quiet']);
94
+
95
+ assert.deepEqual(readViolations(cwd), []);
96
+ });
97
+
98
+ test('expired exception is surfaced as a policy regression', async () => {
99
+ const cwd = createFixtureProject({
100
+ prefix: 'periapsis-expired-exception-',
101
+ packageJson: {
102
+ name: 'expired-exception-app',
103
+ version: '1.0.0',
104
+ dependencies: { vendorpkg: '1.4.0' }
105
+ },
106
+ lockPackages: {
107
+ 'node_modules/vendorpkg': { version: '1.4.0', license: 'LicenseRef-Vendor' }
108
+ },
109
+ policy: {
110
+ settings: {
111
+ allowedCategories: ['Permissive Licenses'],
112
+ failOnUnknownLicense: true,
113
+ timezone: 'UTC',
114
+ dependencyTypes: ['dependencies']
115
+ },
116
+ exceptions: [
117
+ {
118
+ package: 'vendorpkg',
119
+ scope: { type: 'range', range: '^1.2.0' },
120
+ detectedLicenses: ['LicenseRef-Vendor'],
121
+ reason: 'Temporary approval',
122
+ notes: null,
123
+ approvedBy: ['legal'],
124
+ approvedAt: '2025-01-01T00:00:00Z',
125
+ expiresAt: '2025-12-31T00:00:00Z',
126
+ evidenceRef: 'JIRA-EX-1'
127
+ }
128
+ ]
129
+ }
130
+ });
131
+
132
+ await runCli(cwd, ['--violations-out', 'violations.json'], {
133
+ expectFail: true
134
+ });
135
+
136
+ const violations = readViolations(cwd);
137
+ assert.equal(violations[0].reasonType, 'expired-exception');
138
+ });
139
+
140
+ test('active follow-up exception keeps previously-expired package compliant', async () => {
141
+ const cwd = createFixtureProject({
142
+ prefix: 'periapsis-followup-exception-',
143
+ packageJson: {
144
+ name: 'followup-exception-app',
145
+ version: '1.0.0',
146
+ dependencies: { vendorpkg: '1.4.0' }
147
+ },
148
+ lockPackages: {
149
+ 'node_modules/vendorpkg': { version: '1.4.0', license: 'LicenseRef-Vendor' }
150
+ },
151
+ policy: {
152
+ settings: {
153
+ allowedCategories: ['Permissive Licenses'],
154
+ failOnUnknownLicense: true,
155
+ timezone: 'UTC',
156
+ dependencyTypes: ['dependencies']
157
+ },
158
+ exceptions: [
159
+ {
160
+ package: 'vendorpkg',
161
+ scope: { type: 'range', range: '^1.2.0' },
162
+ detectedLicenses: ['LicenseRef-Vendor'],
163
+ reason: 'Temporary approval',
164
+ notes: null,
165
+ approvedBy: ['legal'],
166
+ approvedAt: '2025-01-01T00:00:00Z',
167
+ expiresAt: '2025-12-31T00:00:00Z',
168
+ evidenceRef: 'JIRA-EX-1'
169
+ },
170
+ {
171
+ package: 'vendorpkg',
172
+ scope: { type: 'range', range: '^1.2.0' },
173
+ detectedLicenses: ['LicenseRef-Vendor'],
174
+ reason: 'Renewed approval',
175
+ notes: null,
176
+ approvedBy: ['legal', 'security'],
177
+ approvedAt: '2026-01-15T00:00:00Z',
178
+ expiresAt: '2026-12-31T00:00:00Z',
179
+ evidenceRef: 'JIRA-EX-2'
180
+ }
181
+ ]
182
+ }
183
+ });
184
+
185
+ await runCli(cwd, ['--violations-out', 'violations.json', '--quiet']);
186
+
187
+ assert.deepEqual(readViolations(cwd), []);
188
+ });
@@ -0,0 +1,121 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import { fileURLToPath } from 'url';
5
+ import { main } from '../bin/periapsis.mjs';
6
+
7
+ const __filename = fileURLToPath(import.meta.url);
8
+ const __dirname = path.dirname(__filename);
9
+
10
+ export const REPO_ROOT = path.resolve(__dirname, '..');
11
+ export const BIN = path.join(REPO_ROOT, 'bin', 'periapsis.mjs');
12
+
13
+ export function createTempDir(prefix = 'periapsis-test-') {
14
+ return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
15
+ }
16
+
17
+ export function writeJson(filePath, value) {
18
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
19
+ fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`);
20
+ }
21
+
22
+ export function writePolicyBundle(
23
+ root,
24
+ {
25
+ settings = {
26
+ allowedCategories: ['Permissive Licenses'],
27
+ failOnUnknownLicense: true,
28
+ timezone: 'UTC',
29
+ dependencyTypes: [
30
+ 'dependencies',
31
+ 'devDependencies',
32
+ 'peerDependencies',
33
+ 'optionalDependencies',
34
+ 'bundledDependencies'
35
+ ]
36
+ },
37
+ licenses = [],
38
+ exceptions = []
39
+ } = {}
40
+ ) {
41
+ writeJson(path.join(root, 'policy', 'policy.json'), settings);
42
+ writeJson(path.join(root, 'policy', 'licenses.json'), licenses);
43
+ writeJson(path.join(root, 'policy', 'exceptions.json'), exceptions);
44
+ }
45
+
46
+ export function writeProject(root, { packageJson, lockPackages }) {
47
+ writeJson(path.join(root, 'package.json'), packageJson);
48
+ writeJson(path.join(root, 'package-lock.json'), {
49
+ name: packageJson.name || 'fixture-app',
50
+ version: packageJson.version || '1.0.0',
51
+ lockfileVersion: 3,
52
+ packages: {
53
+ '': {
54
+ name: packageJson.name || 'fixture-app',
55
+ version: packageJson.version || '1.0.0',
56
+ dependencies: packageJson.dependencies || {},
57
+ devDependencies: packageJson.devDependencies || {},
58
+ peerDependencies: packageJson.peerDependencies || {},
59
+ optionalDependencies: packageJson.optionalDependencies || {},
60
+ bundledDependencies: packageJson.bundledDependencies || packageJson.bundleDependencies || []
61
+ },
62
+ ...lockPackages
63
+ }
64
+ });
65
+
66
+ for (const pkgPath of Object.keys(lockPackages)) {
67
+ if (!pkgPath.startsWith('node_modules/')) continue;
68
+ fs.mkdirSync(path.join(root, pkgPath), { recursive: true });
69
+ }
70
+ }
71
+
72
+ export function createFixtureProject({
73
+ packageJson,
74
+ lockPackages,
75
+ policy,
76
+ prefix
77
+ }) {
78
+ const root = createTempDir(prefix);
79
+ writePolicyBundle(root, policy);
80
+ writeProject(root, { packageJson, lockPackages });
81
+ return root;
82
+ }
83
+
84
+ export async function runCli(cwd, args, { expectFail = false } = {}) {
85
+ const originalCwd = process.cwd();
86
+ const originalExitCode = process.exitCode;
87
+ const captured = [];
88
+ const originalLog = console.log;
89
+ const originalWarn = console.warn;
90
+ const originalError = console.error;
91
+
92
+ console.log = (...parts) => captured.push(`${parts.join(' ')}\n`);
93
+ console.warn = (...parts) => captured.push(`${parts.join(' ')}\n`);
94
+ console.error = (...parts) => captured.push(`${parts.join(' ')}\n`);
95
+
96
+ try {
97
+ process.chdir(cwd);
98
+ process.exitCode = 0;
99
+
100
+ await main(args);
101
+
102
+ const output = captured.join('');
103
+ if (!expectFail && process.exitCode && process.exitCode !== 0) {
104
+ throw new Error(output || `Command failed with exit code ${process.exitCode}`);
105
+ }
106
+ if (expectFail && process.exitCode !== 1) {
107
+ throw new Error(`Expected command to fail: ${args.join(' ')}`);
108
+ }
109
+ return output;
110
+ } catch (err) {
111
+ const output = `${captured.join('')}${err.message ? `${err.message}\n` : ''}`;
112
+ if (!expectFail) throw err;
113
+ return output;
114
+ } finally {
115
+ process.chdir(originalCwd);
116
+ process.exitCode = originalExitCode;
117
+ console.log = originalLog;
118
+ console.warn = originalWarn;
119
+ console.error = originalError;
120
+ }
121
+ }