@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 +38 -0
- package/LICENSE +21 -0
- package/README.md +261 -0
- package/dist/cli.d.ts +11 -0
- package/dist/cli.js +157 -0
- package/dist/commands/_shared.d.ts +17 -0
- package/dist/commands/_shared.js +33 -0
- package/dist/commands/check.d.ts +20 -0
- package/dist/commands/check.js +51 -0
- package/dist/commands/collect.d.ts +24 -0
- package/dist/commands/collect.js +31 -0
- package/dist/graph/load.d.ts +17 -0
- package/dist/graph/load.js +35 -0
- package/dist/graph/narrow.d.ts +24 -0
- package/dist/graph/narrow.js +114 -0
- package/dist/graph/record.d.ts +38 -0
- package/dist/graph/record.js +112 -0
- package/dist/graph/scope.d.ts +22 -0
- package/dist/graph/scope.js +88 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.js +23 -0
- package/dist/policy/allowed-hard.d.ts +17 -0
- package/dist/policy/allowed-hard.js +27 -0
- package/dist/policy/allowed-packages.d.ts +29 -0
- package/dist/policy/allowed-packages.js +137 -0
- package/dist/policy/evaluate.d.ts +30 -0
- package/dist/policy/evaluate.js +151 -0
- package/dist/policy/spdx-shape.d.ts +34 -0
- package/dist/policy/spdx-shape.js +76 -0
- package/dist/report/human.d.ts +12 -0
- package/dist/report/human.js +117 -0
- package/dist/report/json.d.ts +27 -0
- package/dist/report/json.js +53 -0
- package/dist/types.d.ts +151 -0
- package/dist/types.js +19 -0
- package/package.json +89 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Graph layer — owns filesystem and Arborist concerns.
|
|
3
|
+
* Knows nothing about policy.
|
|
4
|
+
*/
|
|
5
|
+
import Arborist from '@npmcli/arborist';
|
|
6
|
+
/**
|
|
7
|
+
* Load the installed dependency graph from the **selected project root**.
|
|
8
|
+
* The selected project root is whatever the caller passes in (typically
|
|
9
|
+
* `--cwd <path>` from the CLI, or the `cwd` option in the programmatic API);
|
|
10
|
+
* when omitted it defaults to `process.cwd()`. license-gate never walks
|
|
11
|
+
* upward to rescue a wrong working directory — `package.json` must exist
|
|
12
|
+
* directly at the selected project root, otherwise `LicenseGateConfigError`.
|
|
13
|
+
*
|
|
14
|
+
* Returns the Arborist `tree` (the root Node). Iterate `tree.inventory.values()`
|
|
15
|
+
* for the flat list of unique installed copies.
|
|
16
|
+
*/
|
|
17
|
+
export declare function loadInstalledGraph(cwd?: string): Promise<Arborist.Node>;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Graph layer — owns filesystem and Arborist concerns.
|
|
3
|
+
* Knows nothing about policy.
|
|
4
|
+
*/
|
|
5
|
+
import { existsSync, realpathSync } from 'node:fs';
|
|
6
|
+
import { resolve } from 'node:path';
|
|
7
|
+
import Arborist from '@npmcli/arborist';
|
|
8
|
+
import { LicenseGateConfigError } from '../types.js';
|
|
9
|
+
/**
|
|
10
|
+
* Load the installed dependency graph from the **selected project root**.
|
|
11
|
+
* The selected project root is whatever the caller passes in (typically
|
|
12
|
+
* `--cwd <path>` from the CLI, or the `cwd` option in the programmatic API);
|
|
13
|
+
* when omitted it defaults to `process.cwd()`. license-gate never walks
|
|
14
|
+
* upward to rescue a wrong working directory — `package.json` must exist
|
|
15
|
+
* directly at the selected project root, otherwise `LicenseGateConfigError`.
|
|
16
|
+
*
|
|
17
|
+
* Returns the Arborist `tree` (the root Node). Iterate `tree.inventory.values()`
|
|
18
|
+
* for the flat list of unique installed copies.
|
|
19
|
+
*/
|
|
20
|
+
export async function loadInstalledGraph(cwd = process.cwd()) {
|
|
21
|
+
const pkgJsonPath = resolve(cwd, 'package.json');
|
|
22
|
+
if (!existsSync(pkgJsonPath)) {
|
|
23
|
+
throw new LicenseGateConfigError({ kind: 'missing-package-json', cwd }, `license-gate: no package.json at the selected project root ${cwd}. license-gate never walks upward — pass --cwd <path> to point at a different project root, or run from the npm project root directly.`);
|
|
24
|
+
}
|
|
25
|
+
const nodeModulesPath = resolve(cwd, 'node_modules');
|
|
26
|
+
if (!existsSync(nodeModulesPath)) {
|
|
27
|
+
throw new LicenseGateConfigError({ kind: 'missing-node-modules', cwd }, `license-gate: no node_modules at the selected project root ${cwd}. Install dependencies first (e.g. \`npm ci\`).`);
|
|
28
|
+
}
|
|
29
|
+
// Canonicalise the cwd before handing it to Arborist so that subsequent
|
|
30
|
+
// realpath comparisons (workspace path matching, dedup) line up. Arborist
|
|
31
|
+
// itself returns canonicalised realpaths internally.
|
|
32
|
+
const canonical = realpathSync(cwd);
|
|
33
|
+
const arb = new Arborist({ path: canonical });
|
|
34
|
+
return arb.loadActual();
|
|
35
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace narrowing.
|
|
3
|
+
*
|
|
4
|
+
* `--workspace` always operates on a tree already loaded from the selected
|
|
5
|
+
* project root (`--cwd <path>` if provided, otherwise `process.cwd()`). We
|
|
6
|
+
* never re-invoke Arborist with a workspace path. Given the loaded tree, we
|
|
7
|
+
* resolve the workspace node by name (via `tree.workspaces`) or by path
|
|
8
|
+
* (relative paths resolved against the selected project root, then matched
|
|
9
|
+
* to `node.path`/`node.realpath`), and narrow the evaluation set to the
|
|
10
|
+
* workspace plus everything reachable via `node.edgesOut → edge.to`,
|
|
11
|
+
* deduped by realpath.
|
|
12
|
+
*
|
|
13
|
+
* We deliberately do NOT prefix-filter by realpath, because hoisted
|
|
14
|
+
* dependencies live under the project root and would be missed.
|
|
15
|
+
*/
|
|
16
|
+
import type Arborist from '@npmcli/arborist';
|
|
17
|
+
/** Result of resolving a workspace within the loaded tree, plus the narrowed
|
|
18
|
+
* evaluation set (workspace node + reachable deps, deduplicated by realpath). */
|
|
19
|
+
export type NarrowedTree = {
|
|
20
|
+
workspaceNode: Arborist.Node;
|
|
21
|
+
evaluationSet: Arborist.Node[];
|
|
22
|
+
};
|
|
23
|
+
/** Narrow the loaded tree to one workspace + its reachable graph. */
|
|
24
|
+
export declare function narrowToWorkspace(tree: Arborist.Node, query: string, cwd: string): NarrowedTree;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace narrowing.
|
|
3
|
+
*
|
|
4
|
+
* `--workspace` always operates on a tree already loaded from the selected
|
|
5
|
+
* project root (`--cwd <path>` if provided, otherwise `process.cwd()`). We
|
|
6
|
+
* never re-invoke Arborist with a workspace path. Given the loaded tree, we
|
|
7
|
+
* resolve the workspace node by name (via `tree.workspaces`) or by path
|
|
8
|
+
* (relative paths resolved against the selected project root, then matched
|
|
9
|
+
* to `node.path`/`node.realpath`), and narrow the evaluation set to the
|
|
10
|
+
* workspace plus everything reachable via `node.edgesOut → edge.to`,
|
|
11
|
+
* deduped by realpath.
|
|
12
|
+
*
|
|
13
|
+
* We deliberately do NOT prefix-filter by realpath, because hoisted
|
|
14
|
+
* dependencies live under the project root and would be missed.
|
|
15
|
+
*/
|
|
16
|
+
import { existsSync, realpathSync } from 'node:fs';
|
|
17
|
+
import { isAbsolute, resolve } from 'node:path';
|
|
18
|
+
import { LicenseGateConfigError } from '../types.js';
|
|
19
|
+
/** Walk through a Link to its target Node; non-link nodes pass through.
|
|
20
|
+
* Narrowing gap: DefinitelyTyped types `target` on the base `Node` class as
|
|
21
|
+
* required, but it is only meaningful when `isLink === true`. */
|
|
22
|
+
function followLink(node) {
|
|
23
|
+
return node.isLink && node.target ? node.target : node;
|
|
24
|
+
}
|
|
25
|
+
function canonical(p) {
|
|
26
|
+
try {
|
|
27
|
+
return existsSync(p) ? realpathSync(p) : p;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return p;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** Find a workspace node in the loaded tree.
|
|
34
|
+
* Authoritative source: `tree.workspaces` Map<name, absolutePath>. We then
|
|
35
|
+
* match the chosen path back to a Node either via inventory realpath or by
|
|
36
|
+
* walking children. */
|
|
37
|
+
function resolveWorkspaceNode(tree, query, cwd) {
|
|
38
|
+
const workspacesMap = tree.workspaces;
|
|
39
|
+
if (!workspacesMap || workspacesMap.size === 0) {
|
|
40
|
+
throw new LicenseGateConfigError({
|
|
41
|
+
kind: 'invalid-workspace',
|
|
42
|
+
query,
|
|
43
|
+
reason: 'project does not declare npm workspaces'
|
|
44
|
+
}, `license-gate: --workspace was used but the project at ${cwd} does not declare any workspaces in package.json.`);
|
|
45
|
+
}
|
|
46
|
+
// Build `name -> canonical path` for matching.
|
|
47
|
+
const byName = new Map();
|
|
48
|
+
for (const [name, path] of workspacesMap.entries()) {
|
|
49
|
+
byName.set(name, canonical(path));
|
|
50
|
+
}
|
|
51
|
+
// Resolve query → target absolute (canonical) path.
|
|
52
|
+
let targetPath;
|
|
53
|
+
if (byName.has(query)) {
|
|
54
|
+
targetPath = byName.get(query);
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
const cwdCanon = canonical(cwd);
|
|
58
|
+
const queryAbs = canonical(isAbsolute(query) ? query : resolve(cwdCanon, query));
|
|
59
|
+
for (const [, p] of byName.entries()) {
|
|
60
|
+
if (p === queryAbs) {
|
|
61
|
+
targetPath = p;
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (!targetPath) {
|
|
67
|
+
throw new LicenseGateConfigError({
|
|
68
|
+
kind: 'invalid-workspace',
|
|
69
|
+
query,
|
|
70
|
+
reason: 'workspace not found by name or path'
|
|
71
|
+
}, `license-gate: workspace "${query}" was not found in the project at ${cwd}.`);
|
|
72
|
+
}
|
|
73
|
+
// Find the matching Node — first try inventory; fall back to children.
|
|
74
|
+
for (const node of tree.inventory.values()) {
|
|
75
|
+
if (node.realpath === targetPath)
|
|
76
|
+
return node;
|
|
77
|
+
}
|
|
78
|
+
for (const child of tree.children.values()) {
|
|
79
|
+
const target = followLink(child);
|
|
80
|
+
if (target.realpath === targetPath)
|
|
81
|
+
return target;
|
|
82
|
+
}
|
|
83
|
+
throw new LicenseGateConfigError({
|
|
84
|
+
kind: 'invalid-workspace',
|
|
85
|
+
query,
|
|
86
|
+
reason: 'workspace path resolved but no matching tree node found'
|
|
87
|
+
}, `license-gate: workspace "${query}" resolves to ${targetPath} but no installed node matches.`);
|
|
88
|
+
}
|
|
89
|
+
/** BFS from a workspace node along edgesOut, collecting reachable nodes
|
|
90
|
+
* (deduplicated by realpath). */
|
|
91
|
+
function reachableFrom(start) {
|
|
92
|
+
const seen = new Map();
|
|
93
|
+
const queue = [start];
|
|
94
|
+
while (queue.length > 0) {
|
|
95
|
+
const node = queue.shift();
|
|
96
|
+
const target = followLink(node);
|
|
97
|
+
if (seen.has(target.realpath))
|
|
98
|
+
continue;
|
|
99
|
+
seen.set(target.realpath, target);
|
|
100
|
+
if (target.edgesOut) {
|
|
101
|
+
for (const edge of target.edgesOut.values()) {
|
|
102
|
+
if (edge.to)
|
|
103
|
+
queue.push(edge.to);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return Array.from(seen.values());
|
|
108
|
+
}
|
|
109
|
+
/** Narrow the loaded tree to one workspace + its reachable graph. */
|
|
110
|
+
export function narrowToWorkspace(tree, query, cwd) {
|
|
111
|
+
const workspaceNode = resolveWorkspaceNode(tree, query, cwd);
|
|
112
|
+
const evaluationSet = reachableFrom(workspaceNode);
|
|
113
|
+
return { workspaceNode, evaluationSet };
|
|
114
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Map an Arborist Node to a flat InstalledPackageRecord.
|
|
3
|
+
* Pure: no fs reads, no Arborist methods called here beyond field access.
|
|
4
|
+
*/
|
|
5
|
+
import type Arborist from '@npmcli/arborist';
|
|
6
|
+
import { type InstalledPackageRecord, type LicenseCouldNotDetermine } from '../types.js';
|
|
7
|
+
/** Narrowing gap: DefinitelyTyped types `Node.package` as the broad
|
|
8
|
+
* `PackageJson` shape from `@npm/types`; we narrow to the four fields we
|
|
9
|
+
* actually read (license, repository, author, plus name/version fall-through). */
|
|
10
|
+
type ArboristPackageJson = {
|
|
11
|
+
name?: string;
|
|
12
|
+
version?: string;
|
|
13
|
+
license?: unknown;
|
|
14
|
+
licenses?: unknown;
|
|
15
|
+
repository?: unknown;
|
|
16
|
+
author?: unknown;
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Convert an absolute filesystem path into the path we expose in public
|
|
20
|
+
* records and reports: relative to the selected project root, POSIX-style
|
|
21
|
+
* separators, and `"."` when the path equals the project root.
|
|
22
|
+
*
|
|
23
|
+
* Centralised here so reporters and the public API never see absolute local
|
|
24
|
+
* paths — those would leak `/Users/<name>/...` style strings into JSON
|
|
25
|
+
* artifacts, CI logs, and collected reports.
|
|
26
|
+
*/
|
|
27
|
+
export declare function toExposedPath(absolutePath: string, projectRoot: string): string;
|
|
28
|
+
/** Detect the license string from a package.json. v1 is non-inferential:
|
|
29
|
+
* only a non-empty trimmed string in `license` is accepted; everything else
|
|
30
|
+
* (missing, empty, object, array) maps to the sentinel. */
|
|
31
|
+
export declare function detectLicense(pkg: ArboristPackageJson | undefined): string | LicenseCouldNotDetermine;
|
|
32
|
+
/** Build an InstalledPackageRecord from an Arborist Node.
|
|
33
|
+
*
|
|
34
|
+
* `projectRoot` is the selected project root (canonicalised) and is used
|
|
35
|
+
* to convert the node's absolute realpath to the relative POSIX-style path
|
|
36
|
+
* that the record exposes. */
|
|
37
|
+
export declare function nodeToRecord(node: Arborist.Node, projectRoot: string): InstalledPackageRecord;
|
|
38
|
+
export {};
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Map an Arborist Node to a flat InstalledPackageRecord.
|
|
3
|
+
* Pure: no fs reads, no Arborist methods called here beyond field access.
|
|
4
|
+
*/
|
|
5
|
+
import { relative, sep } from 'node:path';
|
|
6
|
+
import { LICENSE_COULD_NOT_DETERMINE } from '../types.js';
|
|
7
|
+
/** Read the package manifest off a Node, narrowed to our four fields. */
|
|
8
|
+
function manifestOf(node) {
|
|
9
|
+
// Cast bridges the broad DefinitelyTyped manifest to our narrow shape.
|
|
10
|
+
return node.package;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Convert an absolute filesystem path into the path we expose in public
|
|
14
|
+
* records and reports: relative to the selected project root, POSIX-style
|
|
15
|
+
* separators, and `"."` when the path equals the project root.
|
|
16
|
+
*
|
|
17
|
+
* Centralised here so reporters and the public API never see absolute local
|
|
18
|
+
* paths — those would leak `/Users/<name>/...` style strings into JSON
|
|
19
|
+
* artifacts, CI logs, and collected reports.
|
|
20
|
+
*/
|
|
21
|
+
export function toExposedPath(absolutePath, projectRoot) {
|
|
22
|
+
const rel = relative(projectRoot, absolutePath);
|
|
23
|
+
if (rel === '' || rel === '.')
|
|
24
|
+
return '.';
|
|
25
|
+
// Normalise platform separators to POSIX `/` for stable cross-platform output.
|
|
26
|
+
return sep === '/' ? rel : rel.split(sep).join('/');
|
|
27
|
+
}
|
|
28
|
+
/** Detect the license string from a package.json. v1 is non-inferential:
|
|
29
|
+
* only a non-empty trimmed string in `license` is accepted; everything else
|
|
30
|
+
* (missing, empty, object, array) maps to the sentinel. */
|
|
31
|
+
export function detectLicense(pkg) {
|
|
32
|
+
if (!pkg)
|
|
33
|
+
return LICENSE_COULD_NOT_DETERMINE;
|
|
34
|
+
const raw = pkg.license;
|
|
35
|
+
if (typeof raw !== 'string')
|
|
36
|
+
return LICENSE_COULD_NOT_DETERMINE;
|
|
37
|
+
const trimmed = raw.trim();
|
|
38
|
+
if (trimmed.length === 0)
|
|
39
|
+
return LICENSE_COULD_NOT_DETERMINE;
|
|
40
|
+
return trimmed;
|
|
41
|
+
}
|
|
42
|
+
/** Extract a repository URL string when it is directly available without
|
|
43
|
+
* inference. `repository` may be a string or `{type, url}`; we keep the URL
|
|
44
|
+
* as-is when present, but never derive one from anywhere else. */
|
|
45
|
+
function extractRepository(pkg) {
|
|
46
|
+
if (!pkg)
|
|
47
|
+
return undefined;
|
|
48
|
+
const repo = pkg.repository;
|
|
49
|
+
if (typeof repo === 'string')
|
|
50
|
+
return repo;
|
|
51
|
+
if (repo && typeof repo === 'object' && 'url' in repo) {
|
|
52
|
+
const url = repo.url;
|
|
53
|
+
if (typeof url === 'string')
|
|
54
|
+
return url;
|
|
55
|
+
}
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
function extractAuthor(pkg) {
|
|
59
|
+
if (!pkg)
|
|
60
|
+
return {};
|
|
61
|
+
const author = pkg.author;
|
|
62
|
+
if (typeof author === 'string') {
|
|
63
|
+
return parseAuthorString(author);
|
|
64
|
+
}
|
|
65
|
+
if (author && typeof author === 'object') {
|
|
66
|
+
const obj = author;
|
|
67
|
+
const publisher = typeof obj.name === 'string' ? obj.name : undefined;
|
|
68
|
+
const email = typeof obj.email === 'string' ? obj.email : undefined;
|
|
69
|
+
return { publisher, email };
|
|
70
|
+
}
|
|
71
|
+
return {};
|
|
72
|
+
}
|
|
73
|
+
function parseAuthorString(author) {
|
|
74
|
+
const trimmed = author.trim();
|
|
75
|
+
if (!trimmed.endsWith('>')) {
|
|
76
|
+
return { publisher: trimmed || undefined };
|
|
77
|
+
}
|
|
78
|
+
const openIndex = trimmed.lastIndexOf('<');
|
|
79
|
+
// Require both parts to be clearly present: "Name <email@x>".
|
|
80
|
+
if (openIndex <= 0) {
|
|
81
|
+
return { publisher: trimmed || undefined };
|
|
82
|
+
}
|
|
83
|
+
const publisher = trimmed.slice(0, openIndex).trim();
|
|
84
|
+
const email = trimmed.slice(openIndex + 1, -1).trim();
|
|
85
|
+
if (!publisher || !email) {
|
|
86
|
+
return { publisher: trimmed || undefined };
|
|
87
|
+
}
|
|
88
|
+
return { publisher, email };
|
|
89
|
+
}
|
|
90
|
+
/** Build an InstalledPackageRecord from an Arborist Node.
|
|
91
|
+
*
|
|
92
|
+
* `projectRoot` is the selected project root (canonicalised) and is used
|
|
93
|
+
* to convert the node's absolute realpath to the relative POSIX-style path
|
|
94
|
+
* that the record exposes. */
|
|
95
|
+
export function nodeToRecord(node, projectRoot) {
|
|
96
|
+
const pkg = manifestOf(node);
|
|
97
|
+
const name = pkg?.name ?? node.name ?? '';
|
|
98
|
+
const version = pkg?.version ?? node.version ?? '';
|
|
99
|
+
const license = detectLicense(pkg);
|
|
100
|
+
const repository = extractRepository(pkg);
|
|
101
|
+
const { publisher, email } = extractAuthor(pkg);
|
|
102
|
+
return {
|
|
103
|
+
name,
|
|
104
|
+
version,
|
|
105
|
+
packageId: `${name}@${version}`,
|
|
106
|
+
path: toExposedPath(node.realpath, projectRoot),
|
|
107
|
+
license,
|
|
108
|
+
repository,
|
|
109
|
+
publisher,
|
|
110
|
+
email
|
|
111
|
+
};
|
|
112
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build a resolved evaluation scope from the installed graph. This module owns
|
|
3
|
+
* the BFS/dedup logic that touches Arborist Node fields, so callers in
|
|
4
|
+
* `commands/` only see project-owned `InstalledPackageRecord` values —
|
|
5
|
+
* Arborist types do not leak past this boundary.
|
|
6
|
+
*/
|
|
7
|
+
import type { CollectedRecord, InstalledPackageRecord } from '../types.js';
|
|
8
|
+
export type ResolvedScope = {
|
|
9
|
+
/** Records to evaluate (root excluded when full project mode). */
|
|
10
|
+
evaluationRecords: InstalledPackageRecord[];
|
|
11
|
+
/** All records including the project root (for collect output). */
|
|
12
|
+
allCollectedRecords: CollectedRecord[];
|
|
13
|
+
skippedProjectRoot: InstalledPackageRecord | null;
|
|
14
|
+
};
|
|
15
|
+
/** Resolve the scope for a command: load the graph, optionally narrow to a
|
|
16
|
+
* workspace, and return project-owned records (no Arborist values escape).
|
|
17
|
+
* All exposed `record.path` values are relative to the selected project root
|
|
18
|
+
* (`opts.cwd`, canonicalised) — see `toExposedPath`. */
|
|
19
|
+
export declare function resolveScope(opts: {
|
|
20
|
+
cwd: string;
|
|
21
|
+
workspace: string | null;
|
|
22
|
+
}): Promise<ResolvedScope>;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build a resolved evaluation scope from the installed graph. This module owns
|
|
3
|
+
* the BFS/dedup logic that touches Arborist Node fields, so callers in
|
|
4
|
+
* `commands/` only see project-owned `InstalledPackageRecord` values —
|
|
5
|
+
* Arborist types do not leak past this boundary.
|
|
6
|
+
*/
|
|
7
|
+
import { existsSync, realpathSync } from 'node:fs';
|
|
8
|
+
import { loadInstalledGraph } from './load.js';
|
|
9
|
+
import { narrowToWorkspace } from './narrow.js';
|
|
10
|
+
import { nodeToRecord } from './record.js';
|
|
11
|
+
function canonical(p) {
|
|
12
|
+
try {
|
|
13
|
+
return existsSync(p) ? realpathSync(p) : p;
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return p;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/** Resolve the scope for a command: load the graph, optionally narrow to a
|
|
20
|
+
* workspace, and return project-owned records (no Arborist values escape).
|
|
21
|
+
* All exposed `record.path` values are relative to the selected project root
|
|
22
|
+
* (`opts.cwd`, canonicalised) — see `toExposedPath`. */
|
|
23
|
+
export async function resolveScope(opts) {
|
|
24
|
+
const tree = await loadInstalledGraph(opts.cwd);
|
|
25
|
+
// Canonicalise the selected project root once so it lines up with
|
|
26
|
+
// Arborist's realpaths (which are themselves canonicalised). This is the
|
|
27
|
+
// base for every exposed relative path.
|
|
28
|
+
const projectRoot = canonical(opts.cwd);
|
|
29
|
+
const allInventory = Array.from(tree.inventory.values());
|
|
30
|
+
let evaluationNodes;
|
|
31
|
+
let skippedProjectRoot = null;
|
|
32
|
+
if (opts.workspace) {
|
|
33
|
+
const { evaluationSet } = narrowToWorkspace(tree, opts.workspace, opts.cwd);
|
|
34
|
+
evaluationNodes = evaluationSet;
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
// Full project: skip the root, evaluate everything else.
|
|
38
|
+
evaluationNodes = [];
|
|
39
|
+
for (const node of allInventory) {
|
|
40
|
+
if (node.isRoot) {
|
|
41
|
+
skippedProjectRoot = nodeToRecord(node, projectRoot);
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
evaluationNodes.push(node);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
// Deduplicate by realpath (in case BFS visits links + targets).
|
|
48
|
+
// Narrowing gap: DefinitelyTyped types `target` on the base Node, but it
|
|
49
|
+
// is only meaningful when `isLink === true`.
|
|
50
|
+
const seen = new Set();
|
|
51
|
+
const dedupedEvaluation = [];
|
|
52
|
+
for (const n of evaluationNodes) {
|
|
53
|
+
const target = n.isLink && n.target ? n.target : n;
|
|
54
|
+
if (target.isRoot && !opts.workspace)
|
|
55
|
+
continue;
|
|
56
|
+
if (seen.has(target.realpath))
|
|
57
|
+
continue;
|
|
58
|
+
seen.add(target.realpath);
|
|
59
|
+
dedupedEvaluation.push(target);
|
|
60
|
+
}
|
|
61
|
+
const evaluationRecords = dedupedEvaluation.map((n) => nodeToRecord(n, projectRoot));
|
|
62
|
+
// All collected records (for collect output): include the project root too.
|
|
63
|
+
// Dedup by realpath here too — `record.path` is now relative and would
|
|
64
|
+
// collide for legitimately-distinct copies hoisted in different scopes.
|
|
65
|
+
const collectedSeen = new Set();
|
|
66
|
+
const allCollectedRecords = [];
|
|
67
|
+
if (!opts.workspace) {
|
|
68
|
+
for (const node of allInventory) {
|
|
69
|
+
if (node.isRoot) {
|
|
70
|
+
const rec = nodeToRecord(node, projectRoot);
|
|
71
|
+
allCollectedRecords.push({ ...rec, isProjectRoot: true });
|
|
72
|
+
collectedSeen.add(node.realpath);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
for (let i = 0; i < dedupedEvaluation.length; i++) {
|
|
77
|
+
const realpath = dedupedEvaluation[i].realpath;
|
|
78
|
+
if (collectedSeen.has(realpath))
|
|
79
|
+
continue;
|
|
80
|
+
collectedSeen.add(realpath);
|
|
81
|
+
allCollectedRecords.push(evaluationRecords[i]);
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
evaluationRecords,
|
|
85
|
+
allCollectedRecords,
|
|
86
|
+
skippedProjectRoot
|
|
87
|
+
};
|
|
88
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public programmatic API for @mirasen/license-gate.
|
|
3
|
+
*
|
|
4
|
+
* Use these from custom CI scripts or pre-commit hooks instead of shelling
|
|
5
|
+
* out to the binary.
|
|
6
|
+
*
|
|
7
|
+
* Both `runCheck` and `runCollect` accept an options object and return a
|
|
8
|
+
* detailed result. The library never calls `process.exit`; the caller
|
|
9
|
+
* decides what to do with the exit code.
|
|
10
|
+
*
|
|
11
|
+
* Public types:
|
|
12
|
+
* - InstalledPackageRecord
|
|
13
|
+
* - Decision (closed discriminated union)
|
|
14
|
+
* - ViolationReason (closed discriminated union — exactly two top-level
|
|
15
|
+
* reasons: `license-not-in-allowlist`, `package-not-in-allowlist`)
|
|
16
|
+
* - ConfigError + LicenseGateConfigError (thrown for usage/config issues)
|
|
17
|
+
* - CheckResult / CollectResult / CollectedRecord
|
|
18
|
+
*/
|
|
19
|
+
export { runCheck } from './commands/check.js';
|
|
20
|
+
export type { CheckOptions, CheckCliResult } from './commands/check.js';
|
|
21
|
+
export { runCollect } from './commands/collect.js';
|
|
22
|
+
export type { CollectOptions, CollectCliResult } from './commands/collect.js';
|
|
23
|
+
export { LICENSE_COULD_NOT_DETERMINE, LicenseGateConfigError } from './types.js';
|
|
24
|
+
export type { AllowedHardList, AllowedPackages, AllowedPackagesRule, CheckResult, CollectedRecord, CollectResult, ConfigError, Decision, InstalledPackageRecord, LicenseCouldNotDetermine, ViolationReason } from './types.js';
|
|
25
|
+
export { renderCheckJson, renderCollectJson } from './report/json.js';
|
|
26
|
+
export type { CheckJsonReport, CollectJsonReport } from './report/json.js';
|
|
27
|
+
export { renderCheckHuman, renderCollectHuman } from './report/human.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public programmatic API for @mirasen/license-gate.
|
|
3
|
+
*
|
|
4
|
+
* Use these from custom CI scripts or pre-commit hooks instead of shelling
|
|
5
|
+
* out to the binary.
|
|
6
|
+
*
|
|
7
|
+
* Both `runCheck` and `runCollect` accept an options object and return a
|
|
8
|
+
* detailed result. The library never calls `process.exit`; the caller
|
|
9
|
+
* decides what to do with the exit code.
|
|
10
|
+
*
|
|
11
|
+
* Public types:
|
|
12
|
+
* - InstalledPackageRecord
|
|
13
|
+
* - Decision (closed discriminated union)
|
|
14
|
+
* - ViolationReason (closed discriminated union — exactly two top-level
|
|
15
|
+
* reasons: `license-not-in-allowlist`, `package-not-in-allowlist`)
|
|
16
|
+
* - ConfigError + LicenseGateConfigError (thrown for usage/config issues)
|
|
17
|
+
* - CheckResult / CollectResult / CollectedRecord
|
|
18
|
+
*/
|
|
19
|
+
export { runCheck } from './commands/check.js';
|
|
20
|
+
export { runCollect } from './commands/collect.js';
|
|
21
|
+
export { LICENSE_COULD_NOT_DETERMINE, LicenseGateConfigError } from './types.js';
|
|
22
|
+
export { renderCheckJson, renderCollectJson } from './report/json.js';
|
|
23
|
+
export { renderCheckHuman, renderCollectHuman } from './report/human.js';
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parser for `licenses/allowed-hard.txt`.
|
|
3
|
+
*
|
|
4
|
+
* Pure: takes the file contents as a string, returns an `AllowedHardList`.
|
|
5
|
+
* The actual file read happens in the command layer.
|
|
6
|
+
*
|
|
7
|
+
* Rules:
|
|
8
|
+
* - one literal accepted license string per line
|
|
9
|
+
* - blank lines ignored
|
|
10
|
+
* - lines whose first non-whitespace char is `#` are comments
|
|
11
|
+
* - leading/trailing whitespace trimmed (parsing convenience only;
|
|
12
|
+
* the trimmed value is what gets compared literally)
|
|
13
|
+
* - duplicates silently deduped
|
|
14
|
+
* - no regex, no glob, no normalisation
|
|
15
|
+
*/
|
|
16
|
+
import type { AllowedHardList } from '../types.js';
|
|
17
|
+
export declare function parseAllowedHard(contents: string): AllowedHardList;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parser for `licenses/allowed-hard.txt`.
|
|
3
|
+
*
|
|
4
|
+
* Pure: takes the file contents as a string, returns an `AllowedHardList`.
|
|
5
|
+
* The actual file read happens in the command layer.
|
|
6
|
+
*
|
|
7
|
+
* Rules:
|
|
8
|
+
* - one literal accepted license string per line
|
|
9
|
+
* - blank lines ignored
|
|
10
|
+
* - lines whose first non-whitespace char is `#` are comments
|
|
11
|
+
* - leading/trailing whitespace trimmed (parsing convenience only;
|
|
12
|
+
* the trimmed value is what gets compared literally)
|
|
13
|
+
* - duplicates silently deduped
|
|
14
|
+
* - no regex, no glob, no normalisation
|
|
15
|
+
*/
|
|
16
|
+
export function parseAllowedHard(contents) {
|
|
17
|
+
const out = new Set();
|
|
18
|
+
for (const rawLine of contents.split(/\r?\n/)) {
|
|
19
|
+
const trimmed = rawLine.trim();
|
|
20
|
+
if (trimmed.length === 0)
|
|
21
|
+
continue;
|
|
22
|
+
if (trimmed.startsWith('#'))
|
|
23
|
+
continue;
|
|
24
|
+
out.add(trimmed);
|
|
25
|
+
}
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parser for `licenses/allowed-packages.txt`.
|
|
3
|
+
*
|
|
4
|
+
* Pure: takes the file contents and a path (for error reporting), returns
|
|
5
|
+
* either a list of validated rules or throws a `LicenseGateConfigError` with
|
|
6
|
+
* `kind: 'invalid-package-override-rule'` carrying the offending line.
|
|
7
|
+
*
|
|
8
|
+
* Accepted rule forms (and ONLY these):
|
|
9
|
+
* 1. `@scope/*` trusted internal namespace
|
|
10
|
+
* 2. `package-name@version` exact installed unscoped package version
|
|
11
|
+
* 3. `@scope/package@version` exact installed scoped package version
|
|
12
|
+
* 4. `package-name@*` package-name wildcard (any version),
|
|
13
|
+
* unscoped — narrowly scoped escape hatch
|
|
14
|
+
* for manually reviewed packages whose
|
|
15
|
+
* version bumps should not require
|
|
16
|
+
* re-editing this file.
|
|
17
|
+
* 5. `@scope/package@*` package-name wildcard (any version),
|
|
18
|
+
* scoped — same intent as form 4.
|
|
19
|
+
*
|
|
20
|
+
* Everything else is rejected, including:
|
|
21
|
+
* - bare `package-name` (no version)
|
|
22
|
+
* - bare `@scope/package` (no version, no `*`)
|
|
23
|
+
* - semver ranges: `^1.2.3`, `~1.2.3`, `1.x`, `>=1.0.0`, ...
|
|
24
|
+
* - `@scope/*@*`, `*@*`
|
|
25
|
+
* - regex, generic glob, prefix wildcard, bare `*`
|
|
26
|
+
* - any whitespace inside the rule
|
|
27
|
+
*/
|
|
28
|
+
import { type AllowedPackages } from '../types.js';
|
|
29
|
+
export declare function parseAllowedPackages(contents: string, path: string): AllowedPackages;
|