@mirasen/license-gate 1.0.0

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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,38 @@
1
+ # @mirasen/license-gate
2
+
3
+ ## 1.0.0
4
+
5
+ ### Major Changes
6
+
7
+ - 6169add: Initial implementation of `@mirasen/license-gate` — a strict, local, default-deny license
8
+ policy gate for npm projects (single-package, workspaces, monorepos).
9
+ - Two CLI commands: `license-gate check` (gate the installed graph against
10
+ `licenses/allowed-hard.txt` and `licenses/allowed-packages.txt`) and `license-gate
11
+ collect` (enumerate installed package/license metadata, no policy I/O).
12
+ - Project root is always `process.cwd()` — no walk-up rescue, no `--cwd`.
13
+ - Graph discovery via `@npmcli/arborist.loadActual()` + `tree.inventory`. Captures
14
+ non-hoisted workspace installs and transitive duplicates.
15
+ - `--workspace <name|path>` narrows the evaluation set via reachability over
16
+ `node.edgesOut` (never by realpath prefix, never by re-pointing Arborist).
17
+ - License detection is non-inferential: only a non-empty string `package.json#license`
18
+ is read. Object/array shapes and missing fields map to `could not determine`.
19
+ - Literal-first matching: the full license string is searched in `allowed-hard.txt`
20
+ before any SPDX parsing.
21
+ - SPDX boolean shape (OR/AND/parens) is supported via `spdx-expression-parse`. Each
22
+ leaf — including WITH-exception leaves rendered as `"<license-id> WITH <exception-id>"`
23
+ composites — is a literal allowlist lookup. No normalisation, no `spdx-correct`, no
24
+ `spdx-satisfies`.
25
+ - Two top-level violation reasons (`license-not-in-allowlist`,
26
+ `package-not-in-allowlist`) with two diagnostic detail codes
27
+ (`literal-not-allowed-and-spdx-unparseable`, `spdx-expression-not-satisfied`).
28
+ - `check --out` is rejected with exit 2; output flags are `check --json <path>` and
29
+ `collect --out <path>` / `collect --json <path>`.
30
+ - Programmatic API exported from `@mirasen/license-gate` (`runCheck`, `runCollect`).
31
+ - Engines tightened to Node ≥22.9.0.
32
+ - Public package records (`InstalledPackageRecord`, `CollectedRecord`, every JSON
33
+ decision/record emitted by `check --json` / `collect --json`) intentionally do NOT
34
+ carry a `workspace` field. Physical placement of an installed copy (hoisted vs.
35
+ workspace-local) is communicated entirely through the relative `path` field —
36
+ `node_modules/foo` for hoisted deps, `apps/web/node_modules/foo` for non-hoisted
37
+ workspace-local copies. The `--workspace <name|path>` CLI flag still narrows the
38
+ evaluation set to a workspace's reachable graph; that behavior is unchanged.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 [Contributors](https://github.com/mirasen-io/license-gate/graphs/contributors)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,261 @@
1
+ [![NPM Version](https://img.shields.io/npm/v/%40mirasen%2Flicense-gate)](https://www.npmjs.com/package/@mirasen/license-gate)
2
+ [![CI](https://github.com/mirasen-io/license-gate/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/mirasen-io/license-gate/actions/workflows/ci.yml)
3
+ [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=mirasen-io_license-gate&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=mirasen-io_license-gate)
4
+ [![Coverage](https://sonarcloud.io/api/project_badges/measure?project=mirasen-io_license-gate&metric=coverage)](https://sonarcloud.io/summary/new_code?id=mirasen-io_license-gate)
5
+ [![License](https://img.shields.io/npm/l/@mirasen/license-gate)](./LICENSE)
6
+
7
+ # @mirasen/license-gate
8
+
9
+ A strict, local, default-deny license policy gate for npm projects — single-package, npm
10
+ workspaces, and monorepos.
11
+
12
+ `license-gate` reads your installed dependency graph (via `@npmcli/arborist`), checks every
13
+ package against a literal license allowlist you control, and exits non-zero when anything
14
+ fails. It is intentionally small and refuses to guess.
15
+
16
+ It is a strict alternative to generic npm license checkers: instead of guessing licenses
17
+ or hiding exceptions, it enforces your local policy file and shows every override.
18
+
19
+ ## Install
20
+
21
+ ```bash
22
+ npm install -D @mirasen/license-gate
23
+ ```
24
+
25
+ Requires **Node ≥ 22.9.0**.
26
+
27
+ Run `license-gate` after `npm ci` (or after any install that produces the `node_modules`
28
+ layout you intend to ship). The gate evaluates what is **physically installed**.
29
+
30
+ ## Usage
31
+
32
+ ```bash
33
+ # Full project graph (project root = process.cwd())
34
+ npx license-gate check
35
+
36
+ # Run from any directory; --cwd selects the project root explicitly
37
+ npx license-gate check --cwd /path/to/project
38
+
39
+ # Narrow to one workspace
40
+ npx license-gate check --workspace apps/web
41
+
42
+ # Combine: explicit project root + workspace narrow
43
+ npx license-gate check --cwd /path/to/monorepo --workspace apps/web
44
+
45
+ # Enumerate all installed packages with their licenses (no policy I/O)
46
+ npx license-gate collect
47
+ ```
48
+
49
+ You can also invoke without local install:
50
+
51
+ ```bash
52
+ npx @mirasen/license-gate check
53
+ npx @mirasen/license-gate collect
54
+ ```
55
+
56
+ Or as an npm script:
57
+
58
+ ```json
59
+ {
60
+ "scripts": {
61
+ "lint:licenses": "license-gate check"
62
+ }
63
+ }
64
+ ```
65
+
66
+ ### Commands
67
+
68
+ ```
69
+ license-gate check [--cwd <path>] [--workspace <name|path>] [--json <path>]
70
+ license-gate collect [--cwd <path>] [--workspace <name|path>] [--out <path>] [--json <path>]
71
+ ```
72
+
73
+ `check` requires `licenses/allowed-hard.txt` at the project root. `collect` does not.
74
+
75
+ ### Project root
76
+
77
+ The project root is `--cwd <path>` if provided, otherwise `process.cwd()`. `package.json`
78
+ must exist directly at that path — `license-gate` does **not** walk upward to find a
79
+ parent project. All project-relative paths (`licenses/allowed-hard.txt`,
80
+ `licenses/allowed-packages.txt`, `--workspace` relative paths, `--out`, `--json`) resolve
81
+ against the project root.
82
+
83
+ When `--workspace` is used together with `--cwd`, Arborist still loads the graph at the
84
+ project root (never at the workspace path); narrowing is performed inside that already
85
+ loaded tree.
86
+
87
+ ### Exit codes
88
+
89
+ | Code | Meaning |
90
+ | ---- | ------------------------------------------------------ |
91
+ | `0` | clean: no violations (`check`) / completed (`collect`) |
92
+ | `1` | one or more policy violations |
93
+ | `2` | usage / config / runtime error |
94
+
95
+ `check` always collects every violation before exiting; it never fails fast.
96
+
97
+ ## Policy files
98
+
99
+ `license-gate` reads two fixed files relative to the **selected project root**
100
+ (`--cwd` if provided, otherwise `process.cwd()`):
101
+
102
+ - `licenses/allowed-hard.txt` — **required for `check`**. The literal license allowlist.
103
+ - `licenses/allowed-packages.txt` — optional. Visible package overrides.
104
+
105
+ These paths are not configurable. There is no `--allowed`, no `package.json` config, no
106
+ environment variable. A configurable hard gate is a relaxable hard gate.
107
+
108
+ ### `licenses/allowed-hard.txt`
109
+
110
+ ```
111
+ # One literal accepted license string per line.
112
+ # Blank lines and `#` comments are ignored. No regex, no glob, no normalisation.
113
+
114
+ MIT
115
+ Apache-2.0
116
+ BSD-3-Clause
117
+ ISC
118
+ 0BSD
119
+ ```
120
+
121
+ A package's `license` string must appear **verbatim** on a line above. SPDX expressions
122
+ (`(MIT OR Apache-2.0)`, `(MIT AND BSD-3-Clause)`) are evaluated literally per leaf — each
123
+ leaf must appear verbatim above.
124
+
125
+ ### `licenses/allowed-packages.txt`
126
+
127
+ ```
128
+ # Package overrides. Five forms accepted, anything else is a config error (exit 2):
129
+ #
130
+ # @scope/* trusted internal namespace
131
+ # package-name@version exact installed unscoped package version
132
+ # @scope/package@version exact installed scoped package version
133
+ # package-name@* any installed version of exact unscoped package
134
+ # @scope/package@* any installed version of exact scoped package
135
+
136
+ @mirasen/*
137
+ internal-tool@1.2.3
138
+ @types/node@22.0.0
139
+ spawndamnit@*
140
+ @scope/weird-package@*
141
+ ```
142
+
143
+ Override matches are **always visible** in the report (`matchedPackageRule`); they are
144
+ never silent excludes. When several rules match the same package, the most specific rule
145
+ wins in the audit trail, with this strict precedence: license-allow first, then exact
146
+ `package@version`, then `package@*` / `@scope/package@*`, then `@scope/*`. When a package
147
+ is in `allowed-hard.txt`, `allowed-by-license` wins — overrides are escape hatches, not
148
+ the default story.
149
+
150
+ **When to use which form** (most-precise to least-precise, prefer the narrower form):
151
+
152
+ - `package-name@version` / `@scope/package@version` — highest precision; pin exactly
153
+ one version of one package. Use this for one-off, manually-reviewed packages whose
154
+ license terms you accept at a specific version.
155
+ - `package-name@*` / `@scope/package@*` — same package, any version. Use this when you
156
+ intentionally approve the package at the package-name level rather than pinning every
157
+ installed version, for example to avoid repeated Dependabot allowlist edits after
158
+ routine version bumps. **Not** the default first choice — prefer the exact-version
159
+ form unless version bumps would routinely force allowlist edits.
160
+ - `@scope/*` — broadest. Reserve for trusted internal namespaces such as `@mirasen/*`.
161
+ Do not use for unrelated third-party packages that happen to share a namespace.
162
+
163
+ All five forms are escape hatches and remain audit-visible. The JSON report's
164
+ `matchedPackageRule` field carries the matching rule verbatim so reviewers can see at
165
+ a glance which override was applied.
166
+
167
+ ## Strict by design — what `license-gate` will NOT do
168
+
169
+ - **No license file reading.** `LICENSE`, `LICENCE`, `COPYING`, `README` are never opened.
170
+ - **No license inference or normalisation.** `Apache 2.0` (with space) is **not** the
171
+ same as `Apache-2.0`. `UNLICENSED`, `SEE LICENSE IN LICENSE.md` are literal strings.
172
+ - **No `spdx-correct`. No `spdx-satisfies`.** SPDX support is exclusively about parsing
173
+ the boolean shape of npm's `license` field — each leaf is a literal allowlist lookup.
174
+ - **No deprecated `license` shape unwrapping.** A package whose `package.json` has
175
+ `license: { type: "MIT" }` or `licenses: [...]` is treated as `could not determine`.
176
+ - **No clarification system, no checksum-based evidence, no `licenseStart`/`licenseEnd`.**
177
+ - **No project-root walk-up.** The project root is `--cwd` if provided, otherwise
178
+ `process.cwd()`. `license-gate` never walks upward to rescue a wrong working directory.
179
+ - **No `--root`, `--project`, `--allowed`, `--allowed-packages`** flags. `--cwd <path>` is
180
+ the only way to point the gate at a different project root, and it is an explicit
181
+ project root, not a search starting point.
182
+ - **No silent excludes.** Every package the gate considered is accounted for. The JSON
183
+ report (`check --json <path>`) contains a `Decision` entry for **every** evaluated
184
+ package — allowed and violating alike. The human report on stdout summarises the
185
+ allowed-by-license bucket as a count and lists every package override
186
+ (`matchedPackageRule`) and every violation in full; nothing is dropped.
187
+ - **No denylist file** — strict allowlist only.
188
+ - **No pnpm, yarn, Gradle, or Maven support in v1.** `license-gate` currently targets
189
+ npm projects and npm workspaces.
190
+ - **No bundle-level analysis.** Vite/Rollup/etc. are out of scope.
191
+ - **No markdown/tree visualisers, no SaaS upload modes, no enterprise tiers.**
192
+
193
+ ## Violation model
194
+
195
+ There are exactly **two** top-level violation reasons:
196
+
197
+ | `reason` | When |
198
+ | -------------------------- | ------------------------------------------------------------------------------------- |
199
+ | `license-not-in-allowlist` | Package has a usable `license` string that does not satisfy the allowlist |
200
+ | `package-not-in-allowlist` | Package's license is `could not determine` and no `allowed-packages.txt` rule matches |
201
+
202
+ `license-not-in-allowlist` carries an optional `detailCode`:
203
+
204
+ | `detailCode` | When |
205
+ | ------------------------------------------ | ---------------------------------------------------------------------------------------- |
206
+ | `literal-not-allowed-and-spdx-unparseable` | Literal license is not allowed, and SPDX parsing failed |
207
+ | `spdx-expression-not-satisfied` | SPDX parsed successfully but the expression was not satisfied; carries `offendingLeaves` |
208
+
209
+ ## SPDX semantics in plain words
210
+
211
+ 1. The full literal license string is checked against `allowed-hard.txt` first.
212
+ 2. If it is not literally listed, the string is parsed as an SPDX expression
213
+ (OR / AND / parens; WITH-exception leaves).
214
+ 3. Each leaf is compared literally against `allowed-hard.txt`. A WITH-exception leaf is
215
+ reduced to one composite literal `"<license-id> WITH <exception-id>"` and that whole
216
+ string must be in the allowlist; the bare licence id is not considered separately.
217
+ 4. AND requires every leaf to be allowed; OR requires any one leaf.
218
+
219
+ ## FAQ
220
+
221
+ **Why does `Apache 2.0` (with a space) fail when `Apache-2.0` is allowed?**
222
+ Because they are different strings. `license-gate` does not normalise — that's the
223
+ point. Add `Apache 2.0` to your allowlist if you intentionally accept that exact form.
224
+
225
+ **Why is `UNLICENSED` not automatically rejected?**
226
+ It is a literal string, not a parsed SPDX value. If you want to allow `UNLICENSED` for
227
+ some private dependency, add it to `allowed-packages.txt` (preferred) or, if your policy
228
+ genuinely accepts it everywhere, add `UNLICENSED` to `allowed-hard.txt`.
229
+
230
+ **How do I allow my own internal packages?**
231
+ Add your scope to `allowed-packages.txt`: `@my-co/*`. The override will be visible in
232
+ every report.
233
+
234
+ **Why does it refuse to walk up to my workspace root?**
235
+ Because doing so would silently change which project is being gated, and we want the
236
+ project under check to be exactly what the user typed. `cd` to the workspace root and
237
+ run `license-gate check --workspace apps/web`, or pass `--cwd <workspace-root>
238
+ --workspace apps/web` from anywhere.
239
+
240
+ **Why is `check --out` rejected?**
241
+ The check verdict belongs in stdout / CI logs. Use `--json <path>` for a structured
242
+ report; use shell redirection if you need a copy of the human output.
243
+
244
+ ## Programmatic API
245
+
246
+ ```ts
247
+ import { runCheck, runCollect } from '@mirasen/license-gate';
248
+
249
+ const result = await runCheck({ workspace: 'apps/web', jsonPath: 'report.json' });
250
+ if (result.exitCode === 1) {
251
+ console.error(`license-gate found ${result.violations.length} violation(s).`);
252
+ }
253
+ ```
254
+
255
+ The library never calls `process.exit`. Callers control exit. See `src/index.ts` for the
256
+ full set of exported types (`Decision`, `ViolationReason`, `InstalledPackageRecord`,
257
+ `LicenseGateConfigError`, etc.).
258
+
259
+ ## License
260
+
261
+ [MIT](./LICENSE) © Mirasen
package/dist/cli.d.ts ADDED
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `license-gate` CLI entry.
4
+ *
5
+ * Thin: parse argv with node:util.parseArgs, dispatch to command modules,
6
+ * map LicenseGateConfigError to exit code 2 with a stderr message. All
7
+ * business logic lives in src/commands/.
8
+ */
9
+ type ExitCode = 0 | 1 | 2;
10
+ export declare function main(argv: string[]): Promise<ExitCode>;
11
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,157 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `license-gate` CLI entry.
4
+ *
5
+ * Thin: parse argv with node:util.parseArgs, dispatch to command modules,
6
+ * map LicenseGateConfigError to exit code 2 with a stderr message. All
7
+ * business logic lives in src/commands/.
8
+ */
9
+ import { realpathSync } from 'node:fs';
10
+ import { parseArgs } from 'node:util';
11
+ import { runCheck } from './commands/check.js';
12
+ import { runCollect } from './commands/collect.js';
13
+ import { LicenseGateConfigError } from './types.js';
14
+ function usageString() {
15
+ return [
16
+ 'Usage:',
17
+ ' license-gate check [--cwd <path>] [--workspace <name|path>] [--json <path>]',
18
+ ' license-gate collect [--cwd <path>] [--workspace <name|path>] [--out <path>] [--json <path>]',
19
+ '',
20
+ 'Reads policy from licenses/allowed-hard.txt (required for check) and',
21
+ 'licenses/allowed-packages.txt (optional), resolved relative to the project root.',
22
+ 'The project root is --cwd if provided, otherwise process.cwd(). license-gate',
23
+ 'never walks upward — package.json must exist directly at the chosen root.',
24
+ '',
25
+ 'Exit codes: 0 clean | 1 violations | 2 usage/config error.'
26
+ ].join('\n');
27
+ }
28
+ function fail(message) {
29
+ process.stderr.write(`license-gate: ${message}\n`);
30
+ return 2;
31
+ }
32
+ function failConfig(err) {
33
+ process.stderr.write(`${err.message}\n`);
34
+ return 2;
35
+ }
36
+ async function dispatchCheck(rest) {
37
+ let parsed;
38
+ try {
39
+ parsed = parseArgs({
40
+ args: rest,
41
+ strict: true,
42
+ allowPositionals: false,
43
+ options: {
44
+ cwd: { type: 'string' },
45
+ workspace: { type: 'string' },
46
+ json: { type: 'string' }
47
+ }
48
+ });
49
+ }
50
+ catch (err) {
51
+ return fail(`${err.message}\n${usageString()}`);
52
+ }
53
+ const cwd = parsed.values.cwd ?? undefined;
54
+ const workspace = parsed.values.workspace ?? null;
55
+ const jsonPath = parsed.values.json ?? null;
56
+ try {
57
+ const result = await runCheck({ cwd, workspace, jsonPath });
58
+ process.stdout.write(result.humanReport);
59
+ return result.exitCode;
60
+ }
61
+ catch (err) {
62
+ if (err instanceof LicenseGateConfigError)
63
+ return failConfig(err);
64
+ throw err;
65
+ }
66
+ }
67
+ async function dispatchCollect(rest) {
68
+ let parsed;
69
+ try {
70
+ parsed = parseArgs({
71
+ args: rest,
72
+ strict: true,
73
+ allowPositionals: false,
74
+ options: {
75
+ cwd: { type: 'string' },
76
+ workspace: { type: 'string' },
77
+ out: { type: 'string' },
78
+ json: { type: 'string' }
79
+ }
80
+ });
81
+ }
82
+ catch (err) {
83
+ return fail(`${err.message}\n${usageString()}`);
84
+ }
85
+ const cwd = parsed.values.cwd ?? undefined;
86
+ const workspace = parsed.values.workspace ?? null;
87
+ const outPath = parsed.values.out ?? null;
88
+ const jsonPath = parsed.values.json ?? null;
89
+ try {
90
+ const result = await runCollect({ cwd, workspace, outPath, jsonPath });
91
+ if (result.stdoutSummary) {
92
+ process.stdout.write(result.stdoutSummary);
93
+ }
94
+ else {
95
+ process.stdout.write(result.humanReport);
96
+ }
97
+ return 0;
98
+ }
99
+ catch (err) {
100
+ if (err instanceof LicenseGateConfigError)
101
+ return failConfig(err);
102
+ throw err;
103
+ }
104
+ }
105
+ export async function main(argv) {
106
+ const [subcommand, ...rest] = argv;
107
+ if (!subcommand) {
108
+ process.stderr.write(`${usageString()}\n`);
109
+ return 2;
110
+ }
111
+ switch (subcommand) {
112
+ case '-h':
113
+ case '--help':
114
+ process.stdout.write(`${usageString()}\n`);
115
+ return 0;
116
+ case 'check': {
117
+ // Reject `check --out` explicitly with invalid-usage (do this before
118
+ // parseArgs, because parseArgs would simply mark `--out` as unknown).
119
+ if (rest.some((a) => a === '--out' || a.startsWith('--out='))) {
120
+ return failConfig(new LicenseGateConfigError({
121
+ kind: 'invalid-usage',
122
+ message: '`check` does not support --out. Use --json <path> for machine-readable output.'
123
+ }, 'license-gate: `check` does not support --out. Use --json <path> for machine-readable output.'));
124
+ }
125
+ return dispatchCheck(rest);
126
+ }
127
+ case 'collect':
128
+ return dispatchCollect(rest);
129
+ default:
130
+ return fail(`unknown command "${subcommand}".\n${usageString()}`);
131
+ }
132
+ }
133
+ // Entrypoint guard: only run main() when this file is the program entry,
134
+ // not when it's imported as a module by a test. We resolve `process.argv[1]`
135
+ // through `realpathSync` so that the comparison works when the binary was
136
+ // installed as a symlink in `node_modules/.bin`.
137
+ const isDirectRun = (() => {
138
+ try {
139
+ const argv1 = process.argv[1];
140
+ if (!argv1)
141
+ return false;
142
+ const resolved = realpathSync(argv1);
143
+ const url = new URL(`file://${resolved}`);
144
+ return import.meta.url === url.href;
145
+ }
146
+ catch {
147
+ return false;
148
+ }
149
+ })();
150
+ if (isDirectRun) {
151
+ main(process.argv.slice(2)).then((code) => {
152
+ process.exit(code);
153
+ }, (err) => {
154
+ process.stderr.write(`license-gate: unexpected error: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}\n`);
155
+ process.exit(2);
156
+ });
157
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Shared command-layer helpers: writing output files, resolving allowlist
3
+ * paths, and re-exporting the project-owned scope resolver.
4
+ *
5
+ * Arborist types live in the graph layer; this module never sees them.
6
+ */
7
+ import { resolveScope, type ResolvedScope } from '../graph/scope.js';
8
+ export { resolveScope };
9
+ export type { ResolvedScope };
10
+ /** Write a string to a path, raising LicenseGateConfigError on failure. */
11
+ export declare function writeOutputFile(path: string, contents: string, cwd: string): Promise<void>;
12
+ /** Resolve fixed allowlist paths relative to cwd. */
13
+ export declare function resolveAllowlistPaths(cwd: string): {
14
+ allowedHard: string;
15
+ allowedPackages: string;
16
+ };
17
+ export declare function fileExists(path: string): boolean;
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Shared command-layer helpers: writing output files, resolving allowlist
3
+ * paths, and re-exporting the project-owned scope resolver.
4
+ *
5
+ * Arborist types live in the graph layer; this module never sees them.
6
+ */
7
+ import { existsSync } from 'node:fs';
8
+ import { writeFile } from 'node:fs/promises';
9
+ import { resolve } from 'node:path';
10
+ import { resolveScope } from '../graph/scope.js';
11
+ import { LicenseGateConfigError } from '../types.js';
12
+ export { resolveScope };
13
+ /** Write a string to a path, raising LicenseGateConfigError on failure. */
14
+ export async function writeOutputFile(path, contents, cwd) {
15
+ const absolute = resolve(cwd, path);
16
+ try {
17
+ await writeFile(absolute, contents, 'utf8');
18
+ }
19
+ catch (err) {
20
+ const cause = err instanceof Error ? err.message : String(err);
21
+ throw new LicenseGateConfigError({ kind: 'output-path-unwritable', path: absolute, cause }, `license-gate: could not write to ${absolute}: ${cause}`);
22
+ }
23
+ }
24
+ /** Resolve fixed allowlist paths relative to cwd. */
25
+ export function resolveAllowlistPaths(cwd) {
26
+ return {
27
+ allowedHard: resolve(cwd, 'licenses', 'allowed-hard.txt'),
28
+ allowedPackages: resolve(cwd, 'licenses', 'allowed-packages.txt')
29
+ };
30
+ }
31
+ export function fileExists(path) {
32
+ return existsSync(path);
33
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * `check` command — orchestrates discovery → policy → reporting → exit code.
3
+ */
4
+ import { type CheckResult } from '../types.js';
5
+ export type CheckOptions = {
6
+ cwd?: string;
7
+ workspace?: string | null;
8
+ /** When provided, JSON report is written to this path (relative to cwd). */
9
+ jsonPath?: string | null;
10
+ };
11
+ export type CheckCliResult = CheckResult & {
12
+ humanReport: string;
13
+ exitCode: 0 | 1;
14
+ };
15
+ /**
16
+ * Programmatic + CLI-shared check. Returns the decisions and the rendered
17
+ * human report; the caller (CLI or library consumer) decides what to do
18
+ * with stdout and the exit code.
19
+ */
20
+ export declare function runCheck(opts?: CheckOptions): Promise<CheckCliResult>;
@@ -0,0 +1,51 @@
1
+ /**
2
+ * `check` command — orchestrates discovery → policy → reporting → exit code.
3
+ */
4
+ import { readFile } from 'node:fs/promises';
5
+ import { evaluateAll } from '../policy/evaluate.js';
6
+ import { parseAllowedHard } from '../policy/allowed-hard.js';
7
+ import { parseAllowedPackages } from '../policy/allowed-packages.js';
8
+ import { renderCheckHuman } from '../report/human.js';
9
+ import { renderCheckJson } from '../report/json.js';
10
+ import { LicenseGateConfigError } from '../types.js';
11
+ import { fileExists, resolveAllowlistPaths, resolveScope, writeOutputFile } from './_shared.js';
12
+ /**
13
+ * Programmatic + CLI-shared check. Returns the decisions and the rendered
14
+ * human report; the caller (CLI or library consumer) decides what to do
15
+ * with stdout and the exit code.
16
+ */
17
+ export async function runCheck(opts = {}) {
18
+ const cwd = opts.cwd ?? process.cwd();
19
+ const workspace = opts.workspace ?? null;
20
+ const scope = await resolveScope({ cwd, workspace });
21
+ const paths = resolveAllowlistPaths(cwd);
22
+ if (!fileExists(paths.allowedHard)) {
23
+ throw new LicenseGateConfigError({ kind: 'missing-allowed-hard-file', path: paths.allowedHard }, `license-gate: required allowlist file not found: ${paths.allowedHard}`);
24
+ }
25
+ const allowedHardContents = await readFile(paths.allowedHard, 'utf8');
26
+ const allowedHard = parseAllowedHard(allowedHardContents);
27
+ const allowedPackages = fileExists(paths.allowedPackages)
28
+ ? parseAllowedPackages(await readFile(paths.allowedPackages, 'utf8'), paths.allowedPackages)
29
+ : [];
30
+ // Collect-all-then-exit: never throw mid-evaluation.
31
+ const decisions = evaluateAll(scope.evaluationRecords, allowedHard, allowedPackages);
32
+ const violations = decisions.filter((d) => d.outcome === 'violation');
33
+ const humanReport = renderCheckHuman({
34
+ decisions,
35
+ skippedProjectRoot: scope.skippedProjectRoot
36
+ });
37
+ if (opts.jsonPath) {
38
+ const jsonReport = renderCheckJson({
39
+ decisions,
40
+ skippedProjectRoot: scope.skippedProjectRoot
41
+ });
42
+ await writeOutputFile(opts.jsonPath, jsonReport, cwd);
43
+ }
44
+ return {
45
+ decisions,
46
+ violations,
47
+ skippedProjectRoot: scope.skippedProjectRoot,
48
+ humanReport,
49
+ exitCode: violations.length > 0 ? 1 : 0
50
+ };
51
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * `collect` command — orchestrates discovery → flat record list → reporting.
3
+ *
4
+ * Performs NO policy I/O: never reads `allowed-hard.txt` or
5
+ * `allowed-packages.txt`. Output is human-readable by default; `--out <path>`
6
+ * writes to a file (and stdout shrinks to a one-line summary); `--json
7
+ * <path>` writes machine-readable JSON.
8
+ */
9
+ import type { CollectResult } from '../types.js';
10
+ export type CollectOptions = {
11
+ cwd?: string;
12
+ workspace?: string | null;
13
+ /** When provided, human report is written to this path. */
14
+ outPath?: string | null;
15
+ /** When provided, JSON report is written to this path. */
16
+ jsonPath?: string | null;
17
+ };
18
+ export type CollectCliResult = CollectResult & {
19
+ /** Human report. Always rendered; the CLI decides whether to print. */
20
+ humanReport: string;
21
+ /** When --out was provided, this is the one-line stdout summary. */
22
+ stdoutSummary: string | null;
23
+ };
24
+ export declare function runCollect(opts?: CollectOptions): Promise<CollectCliResult>;
@@ -0,0 +1,31 @@
1
+ /**
2
+ * `collect` command — orchestrates discovery → flat record list → reporting.
3
+ *
4
+ * Performs NO policy I/O: never reads `allowed-hard.txt` or
5
+ * `allowed-packages.txt`. Output is human-readable by default; `--out <path>`
6
+ * writes to a file (and stdout shrinks to a one-line summary); `--json
7
+ * <path>` writes machine-readable JSON.
8
+ */
9
+ import { renderCollectHuman } from '../report/human.js';
10
+ import { renderCollectJson } from '../report/json.js';
11
+ import { resolveScope, writeOutputFile } from './_shared.js';
12
+ export async function runCollect(opts = {}) {
13
+ const cwd = opts.cwd ?? process.cwd();
14
+ const workspace = opts.workspace ?? null;
15
+ const scope = await resolveScope({ cwd, workspace });
16
+ const records = scope.allCollectedRecords;
17
+ const humanReport = renderCollectHuman(records);
18
+ let stdoutSummary = null;
19
+ if (opts.outPath) {
20
+ await writeOutputFile(opts.outPath, humanReport, cwd);
21
+ stdoutSummary = `wrote ${records.length} records to ${opts.outPath}\n`;
22
+ }
23
+ if (opts.jsonPath) {
24
+ await writeOutputFile(opts.jsonPath, renderCollectJson(records), cwd);
25
+ }
26
+ return {
27
+ records,
28
+ humanReport,
29
+ stdoutSummary
30
+ };
31
+ }