periapsis 1.0.3 → 1.0.5

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,168 @@
1
+ name: Prepare Package Release
2
+
3
+ on:
4
+ workflow_dispatch:
5
+ inputs:
6
+ release_type:
7
+ description: Semver increment to prepare
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: prepare-package-release
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 main branch
29
+ run: |
30
+ if [ "${GITHUB_REF_NAME}" != "main" ]; then
31
+ echo "Manual release preparation is only allowed from main."
32
+ exit 1
33
+ fi
34
+
35
+ - name: Require repository admin permission
36
+ uses: actions/github-script@v7
37
+ with:
38
+ script: |
39
+ const owner = context.repo.owner;
40
+ const repo = context.repo.repo;
41
+ const actor = context.actor;
42
+
43
+ if (owner.toLowerCase() === actor.toLowerCase()) {
44
+ core.info(`Actor ${actor} matches the repository owner and is treated as admin.`);
45
+ return;
46
+ }
47
+
48
+ const response = await github.rest.repos.getCollaboratorPermissionLevel({
49
+ owner,
50
+ repo,
51
+ username: actor,
52
+ });
53
+
54
+ const { permission, role_name: roleName } = response.data;
55
+ core.info(`Actor ${actor} permission=${permission}, role_name=${roleName}`);
56
+
57
+ if (permission !== "admin" && roleName !== "admin") {
58
+ core.setFailed(`Only repository admins can prepare package releases. ${actor} has ${roleName || permission}.`);
59
+ }
60
+
61
+ prepare-release:
62
+ name: Prepare Release Pull Request
63
+ needs: authorize-release
64
+ runs-on: ubuntu-latest
65
+ permissions:
66
+ # Required because this workflow pushes a release branch and opens a PR.
67
+ contents: write
68
+ pull-requests: write
69
+ steps:
70
+ - name: Checkout
71
+ uses: actions/checkout@v4
72
+ with:
73
+ fetch-depth: 0
74
+
75
+ - name: Setup Node
76
+ uses: actions/setup-node@v4
77
+ with:
78
+ node-version: 24
79
+ cache: npm
80
+
81
+ - name: Install dependencies
82
+ run: npm ci
83
+
84
+ - name: Run automated test suite
85
+ run: npm run test:ci
86
+
87
+ - name: Bump package version
88
+ run: npm version "${{ inputs.release_type }}" --no-git-tag-version
89
+
90
+ - name: Read new version
91
+ id: version
92
+ run: |
93
+ VERSION="$(node -e 'process.stdout.write(require("./package.json").version)')"
94
+ echo "value=${VERSION}" >> "$GITHUB_OUTPUT"
95
+ echo "branch=release/v${VERSION}" >> "$GITHUB_OUTPUT"
96
+
97
+ - name: Ensure release branch is not already open
98
+ env:
99
+ RELEASE_BRANCH: ${{ steps.version.outputs.branch }}
100
+ run: |
101
+ if git ls-remote --exit-code --heads origin "${RELEASE_BRANCH}" >/dev/null 2>&1; then
102
+ echo "Release branch ${RELEASE_BRANCH} already exists on origin."
103
+ exit 1
104
+ fi
105
+
106
+ - name: Create release branch
107
+ env:
108
+ RELEASE_BRANCH: ${{ steps.version.outputs.branch }}
109
+ run: git switch -c "${RELEASE_BRANCH}"
110
+
111
+ - name: Configure git author
112
+ run: |
113
+ git config user.name "github-actions[bot]"
114
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
115
+
116
+ - name: Commit release version
117
+ env:
118
+ VERSION: ${{ steps.version.outputs.value }}
119
+ run: |
120
+ git add package.json package-lock.json
121
+ git commit -m "chore(release): v${VERSION}"
122
+
123
+ - name: Push release branch
124
+ env:
125
+ RELEASE_BRANCH: ${{ steps.version.outputs.branch }}
126
+ run: git push --set-upstream origin "${RELEASE_BRANCH}"
127
+
128
+ - name: Open release pull request
129
+ id: pull-request
130
+ uses: actions/github-script@v7
131
+ env:
132
+ RELEASE_BRANCH: ${{ steps.version.outputs.branch }}
133
+ VERSION: ${{ steps.version.outputs.value }}
134
+ with:
135
+ script: |
136
+ const owner = context.repo.owner;
137
+ const repo = context.repo.repo;
138
+ const head = process.env.RELEASE_BRANCH;
139
+ const version = process.env.VERSION;
140
+
141
+ const pr = await github.rest.pulls.create({
142
+ owner,
143
+ repo,
144
+ title: `chore(release): v${version}`,
145
+ head,
146
+ base: "main",
147
+ body: [
148
+ `Prepares \`v${version}\` for npm publication.`,
149
+ "",
150
+ "After this pull request merges into `main`, the publish workflow will release the package to npm.",
151
+ ].join("\n"),
152
+ });
153
+
154
+ core.setOutput("url", pr.data.html_url);
155
+
156
+ - name: Write job summary
157
+ env:
158
+ VERSION: ${{ steps.version.outputs.value }}
159
+ RELEASE_BRANCH: ${{ steps.version.outputs.branch }}
160
+ PR_URL: ${{ steps.pull-request.outputs.url }}
161
+ run: |
162
+ {
163
+ echo "## Release PR created"
164
+ echo
165
+ echo "- Version: \`${VERSION}\`"
166
+ echo "- Release branch: \`${RELEASE_BRANCH}\`"
167
+ echo "- Pull request: ${PR_URL}"
168
+ } >> "$GITHUB_STEP_SUMMARY"
@@ -0,0 +1,133 @@
1
+ name: Publish Package
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+ paths:
8
+ - package.json
9
+ - package-lock.json
10
+ workflow_dispatch:
11
+
12
+ permissions:
13
+ contents: write
14
+ id-token: write
15
+
16
+ concurrency:
17
+ group: publish-package
18
+ cancel-in-progress: false
19
+
20
+ jobs:
21
+ publish-package:
22
+ name: Publish Package
23
+ runs-on: ubuntu-latest
24
+ environment:
25
+ # Use the same environment name in npm trusted publisher settings if you
26
+ # want npm to bind publishing to this protected environment.
27
+ name: package-publish
28
+ steps:
29
+ - name: Checkout
30
+ uses: actions/checkout@v4
31
+ with:
32
+ fetch-depth: 0
33
+
34
+ - name: Detect release version
35
+ id: release
36
+ env:
37
+ BEFORE_SHA: ${{ github.event.before }}
38
+ EVENT_NAME: ${{ github.event_name }}
39
+ REF_NAME: ${{ github.ref_name }}
40
+ run: |
41
+ VERSION="$(node -e 'process.stdout.write(require("./package.json").version)')"
42
+ PACKAGE_NAME="$(node -e 'process.stdout.write(require("./package.json").name)')"
43
+ echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
44
+ echo "package_name=${PACKAGE_NAME}" >> "$GITHUB_OUTPUT"
45
+
46
+ if [ "${EVENT_NAME}" = "workflow_dispatch" ]; then
47
+ if [ "${REF_NAME}" != "main" ]; then
48
+ echo "Manual publishing is only allowed from main."
49
+ exit 1
50
+ fi
51
+
52
+ echo "should_publish=true" >> "$GITHUB_OUTPUT"
53
+ exit 0
54
+ fi
55
+
56
+ if [ -z "${BEFORE_SHA}" ] || [ "${BEFORE_SHA}" = "0000000000000000000000000000000000000000" ]; then
57
+ echo "should_publish=true" >> "$GITHUB_OUTPUT"
58
+ exit 0
59
+ fi
60
+
61
+ PREVIOUS_VERSION="$(git show "${BEFORE_SHA}:package.json" | node -e 'let data=""; process.stdin.on("data", chunk => data += chunk); process.stdin.on("end", () => process.stdout.write(JSON.parse(data).version));')"
62
+
63
+ if [ "${PREVIOUS_VERSION}" = "${VERSION}" ]; then
64
+ echo "Version is unchanged at ${VERSION}; skipping publish."
65
+ echo "should_publish=false" >> "$GITHUB_OUTPUT"
66
+ else
67
+ echo "Version changed from ${PREVIOUS_VERSION} to ${VERSION}."
68
+ echo "should_publish=true" >> "$GITHUB_OUTPUT"
69
+ fi
70
+
71
+ - name: Setup Node
72
+ if: steps.release.outputs.should_publish == 'true'
73
+ uses: actions/setup-node@v4
74
+ with:
75
+ node-version: 24
76
+ cache: npm
77
+ registry-url: https://registry.npmjs.org
78
+
79
+ - name: Install dependencies
80
+ if: steps.release.outputs.should_publish == 'true'
81
+ run: npm ci
82
+
83
+ - name: Run automated test suite
84
+ if: steps.release.outputs.should_publish == 'true'
85
+ run: npm run test:ci
86
+
87
+ - name: Check whether version already exists on npm
88
+ if: steps.release.outputs.should_publish == 'true'
89
+ id: npm-check
90
+ env:
91
+ PACKAGE_NAME: ${{ steps.release.outputs.package_name }}
92
+ VERSION: ${{ steps.release.outputs.version }}
93
+ run: |
94
+ if npm view "${PACKAGE_NAME}@${VERSION}" version >/dev/null 2>&1; then
95
+ echo "should_publish=false" >> "$GITHUB_OUTPUT"
96
+ echo "Version ${VERSION} is already published to npm."
97
+ else
98
+ echo "should_publish=true" >> "$GITHUB_OUTPUT"
99
+ echo "Version ${VERSION} is not yet published to npm."
100
+ fi
101
+
102
+ - name: Publish to npm with trusted publishing
103
+ if: steps.release.outputs.should_publish == 'true' && steps.npm-check.outputs.should_publish == 'true'
104
+ run: npm publish
105
+
106
+ - name: Tag published version
107
+ if: steps.release.outputs.should_publish == 'true' && steps.npm-check.outputs.should_publish == 'true'
108
+ env:
109
+ VERSION: ${{ steps.release.outputs.version }}
110
+ run: |
111
+ if git rev-parse "v${VERSION}" >/dev/null 2>&1; then
112
+ echo "Tag v${VERSION} already exists locally."
113
+ exit 0
114
+ fi
115
+
116
+ git config user.name "github-actions[bot]"
117
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
118
+ git tag "v${VERSION}"
119
+ git push origin "refs/tags/v${VERSION}"
120
+
121
+ - name: Write job summary
122
+ env:
123
+ VERSION: ${{ steps.release.outputs.version }}
124
+ SHOULD_PUBLISH: ${{ steps.release.outputs.should_publish }}
125
+ NPM_PUBLISH: ${{ steps.npm-check.outputs.should_publish }}
126
+ run: |
127
+ {
128
+ echo "## Publish summary"
129
+ echo
130
+ echo "- Version: \`${VERSION}\`"
131
+ echo "- Trigger requested publish: \`${SHOULD_PUBLISH:-false}\`"
132
+ echo "- npm publish executed: \`${NPM_PUBLISH:-false}\`"
133
+ } >> "$GITHUB_STEP_SUMMARY"
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.3",
3
+ "version": "1.0.5",
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
+ }