node-module-license-output 0.3.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 myooken
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,133 @@
1
+ # Third-Party License Output for node_modules
2
+
3
+ [![npm version](https://img.shields.io/npm/v/@myooken/license-output.svg)](https://www.npmjs.com/package/@myooken/license-output)
4
+ [![npm downloads](https://img.shields.io/npm/dm/@myooken/license-output.svg)](https://www.npmjs.com/package/@myooken/license-output)
5
+ [![node](https://img.shields.io/node/v/@myooken/license-output.svg)](https://www.npmjs.com/package/@myooken/license-output)
6
+
7
+ https://www.npmjs.com/package/@myooken/license-output
8
+
9
+ ### What is this?
10
+
11
+ A tool to scan `node_modules` and **output third-party licenses in Markdown**.
12
+ It generates two files: `THIRD-PARTY-LICENSE.md` (main content) and `THIRD-PARTY-LICENSE-REVIEW.md` (review checklist).
13
+
14
+ ### Highlights
15
+
16
+ - **ESM / Node.js 18+**, zero dependencies
17
+ - **Outputs full license texts** from LICENSE/NOTICE/COPYRIGHT/THIRD-PARTY-NOTICES/THIRD-PARTY-LICENSES/ThirdPartyNoticeText/ThirdPartyText/COPYING files
18
+ - **Review file** flags missing Source / license / license files
19
+ - `--fail-on-missing` supports CI enforcement
20
+
21
+ CLI command: `third-party-license`
22
+
23
+ ### Usage
24
+
25
+ #### Run without installing (recommended)
26
+
27
+ ```bash
28
+ npx --package=@myooken/license-output -- third-party-license
29
+ ```
30
+
31
+ #### Run via npm exec
32
+
33
+ ```bash
34
+ npm exec --package=@myooken/license-output -- third-party-license
35
+ ```
36
+
37
+ #### Install globally
38
+
39
+ ```bash
40
+ npm i -g @myooken/license-output
41
+ third-party-license
42
+ ```
43
+
44
+ ### Options
45
+
46
+ | Option | Description | Default |
47
+ | ---------------------- | --------------------------------------------------------------------------- | ------------------------------- |
48
+ | `--node-modules <dir>` | Path to `node_modules` | `node_modules` |
49
+ | `--review [file]` | Write review file only; optional filename | `THIRD-PARTY-LICENSE-REVIEW.md` |
50
+ | `--license [file]` | Write main file only; optional filename | `THIRD-PARTY-LICENSE.md` |
51
+ | `--recreate` | Regenerate files from current `node_modules` only (drops removed packages) | `true` (default) |
52
+ | `--update` | Merge with existing outputs, keep removed packages, and mark their presence | `false` |
53
+ | `--fail-on-missing` | Exit with code 1 if LICENSE/NOTICE/COPYRIGHT/THIRD-PARTY-NOTICES/THIRD-PARTY-LICENSES/ThirdPartyNoticeText/ThirdPartyText/COPYING are missing | `false` |
54
+ | `-h`, `--help` | Show help | - |
55
+
56
+ > If neither `--review` nor `--license` is specified, **both files are generated**.
57
+ > Packages in both files are sorted by name@version; `--update` keeps entries for packages no longer in `node_modules` and annotates their usage status.
58
+
59
+ ### Examples
60
+
61
+ ```bash
62
+ # Default (both files)
63
+ third-party-license
64
+
65
+ # Update existing files without dropping removed packages
66
+ third-party-license --update
67
+
68
+ # Custom node_modules path
69
+ third-party-license --node-modules ./path/to/node_modules
70
+
71
+ # Review-only output (optional filename)
72
+ third-party-license --review
73
+ third-party-license --review ./out/THIRD-PARTY-LICENSE-REVIEW.md
74
+
75
+ # Main-only output (optional filename)
76
+ third-party-license --license
77
+ third-party-license --license ./out/THIRD-PARTY-LICENSE.md
78
+
79
+ # Exit with code 1 when something is missing (with --fail-on-missing)
80
+ third-party-license --fail-on-missing
81
+ ```
82
+
83
+ ### Programmatic API
84
+
85
+ ```js
86
+ import { collectThirdPartyLicenses } from "@myooken/license-output";
87
+
88
+ const result = await collectThirdPartyLicenses({
89
+ nodeModules: "./node_modules",
90
+ outFile: "./THIRD-PARTY-LICENSE.md",
91
+ reviewFile: "./THIRD-PARTY-LICENSE-REVIEW.md",
92
+ failOnMissing: false,
93
+ // mode: "update", // keep packages missing from node_modules when updating files
94
+ });
95
+
96
+ console.log(result.mainContent);
97
+ console.log(result.reviewContent);
98
+ ```
99
+
100
+ Outputs are sorted by package key. Use `mode: "update"` to merge with existing files and keep packages that are no longer in `node_modules`, with their usage shown in both outputs.
101
+
102
+ ### Output overview
103
+
104
+ - **THIRD-PARTY-LICENSE.md**
105
+ - List of packages
106
+ - Source / License info
107
+ - Full LICENSE/NOTICE/COPYRIGHT/THIRD-PARTY-NOTICES/THIRD-PARTY-LICENSES/ThirdPartyNoticeText/ThirdPartyText/COPYING texts
108
+ - Usage line shows whether the package is present in the current `node_modules`
109
+ - **THIRD-PARTY-LICENSE-REVIEW.md**
110
+ - Review-oriented checklist
111
+ - Usage-aware status (present / not found) for each package
112
+ - **Missing summary** section
113
+
114
+ ### How it differs from typical npm license tools (general view)
115
+
116
+ > Examples: `license-checker`, `license-report`, `license-finder`
117
+
118
+ - **Focused on bundling full license texts into a single Markdown file**
119
+ - Many existing tools emphasize JSON/CSV reports; this tool emphasizes **ready-to-share license documents**.
120
+ - **Separate review file** to track missing metadata
121
+ - Easier to integrate into audit workflows.
122
+ - **ESM / Node.js 18+ with no dependencies**
123
+ - Simple runtime requirements.
124
+
125
+ ### Notes
126
+
127
+ - Scans all packages under `node_modules` (including nested dependencies); license files are searched only in each package root directory.
128
+ - Recognizes LICENSE, NOTICE, COPYRIGHT, THIRD-PARTY-NOTICES, THIRD-PARTY-LICENSES, ThirdPartyNoticeText/ThirdPartyText, and COPYING files (e.g., TypeScript's `ThirdPartyNoticeText.txt`).
129
+ - Exit code 0: success.
130
+ - Exit code 1: missing license files when `--fail-on-missing` is set, or `node_modules` not found.
131
+ - Throws an error if `node_modules` does not exist.
132
+ - Missing `license` or `repository` fields are flagged in the review file.
133
+ - Paths printed in outputs/logs are shown relative to the current working directory.
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "node-module-license-output",
3
+ "version": "0.3.0",
4
+ "description": "Generate third-party-license markdown by scanning licenses in node_modules.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/myooken/collect-node-modules-licenses.git"
10
+ },
11
+ "homepage": "https://github.com/myooken/collect-node-modules-licenses#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/myooken/collect-node-modules-licenses/issues"
14
+ },
15
+ "keywords": [
16
+ "license",
17
+ "third-party",
18
+ "notices",
19
+ "node_modules",
20
+ "oss",
21
+ "compliance",
22
+ "cli"
23
+ ],
24
+ "exports": {
25
+ ".": "./src/core.js"
26
+ },
27
+ "bin": {
28
+ "third-party-license": "./src/cli.js"
29
+ },
30
+ "files": [
31
+ "src",
32
+ "README.md",
33
+ "LICENSE"
34
+ ],
35
+ "engines": {
36
+ "node": ">=18"
37
+ }
38
+ }
package/src/cli.js ADDED
@@ -0,0 +1,153 @@
1
+ #!/usr/bin/env node
2
+ // CLIエントリーポイント(引数パースとファイル出力を担当)
3
+ import fsp from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { collectThirdPartyLicenses, DEFAULT_OPTIONS } from "./core.js";
7
+ import { LICENSE_FILES_LABEL } from "./constants.js";
8
+
9
+ // 引数パース: --review / --license は最後に指定されたものを優先し、直後の値があれば出力ファイル名として扱う
10
+ function parseArgs(argv) {
11
+ const args = { ...DEFAULT_OPTIONS };
12
+ let outputMode = "both"; // "both" | "review" | "license"
13
+
14
+ for (let i = 0; i < argv.length; i += 1) {
15
+ const a = argv[i];
16
+ if (a === "--node-modules" || a === "--nodeModules") {
17
+ const dir = optionalValue(argv, i + 1);
18
+ if (dir) {
19
+ args.nodeModules = dir;
20
+ i += 1;
21
+ }
22
+ } else if (a === "--review") {
23
+ outputMode = "review";
24
+ const file = optionalValue(argv, i + 1);
25
+ if (file) {
26
+ args.reviewFile = file;
27
+ i += 1;
28
+ }
29
+ } else if (a === "--license") {
30
+ outputMode = "license";
31
+ const file = optionalValue(argv, i + 1);
32
+ if (file) {
33
+ args.outFile = file;
34
+ i += 1;
35
+ }
36
+ } else if (a === "--recreate") {
37
+ args.mode = "recreate";
38
+ } else if (a === "--update") {
39
+ args.mode = "update";
40
+ } else if (a === "--fail-on-missing") {
41
+ args.failOnMissing = true;
42
+ } else if (a === "-h" || a === "--help") {
43
+ showHelp();
44
+ process.exit(0);
45
+ }
46
+ }
47
+ applyOutputMode(outputMode, args);
48
+ return args;
49
+ }
50
+
51
+ // オプションの直後にファイル名があれば取得(次のトークンが別オプションなら無視)
52
+ function optionalValue(argv, idx) {
53
+ const v = argv[idx];
54
+ if (!v) return null;
55
+ return v.startsWith("-") ? null : v;
56
+ }
57
+
58
+ // 生成対象をまとめて決定(両方/レビューのみ/ライセンスのみ)
59
+ function applyOutputMode(mode, args) {
60
+ if (mode === "review") {
61
+ args.writeMain = false;
62
+ args.writeReview = true;
63
+ } else if (mode === "license") {
64
+ args.writeMain = true;
65
+ args.writeReview = false;
66
+ } else {
67
+ args.writeMain = true;
68
+ args.writeReview = true;
69
+ }
70
+ }
71
+
72
+ function showHelp() {
73
+ console.log(`Usage:
74
+ third-party-license [--node-modules <dir>] [--review [file]] [--license [file]] [--recreate|--update] [--fail-on-missing]
75
+ `);
76
+ }
77
+
78
+ async function ensureParentDir(filePath) {
79
+ const dir = path.dirname(filePath);
80
+ await fsp.mkdir(dir, { recursive: true });
81
+ }
82
+
83
+ export async function runCli(argv = process.argv.slice(2)) {
84
+ const args = parseArgs(argv);
85
+
86
+ try {
87
+ const result = await collectThirdPartyLicenses(args);
88
+
89
+ const dirsToEnsure = [];
90
+ if (result.options.writeMain)
91
+ dirsToEnsure.push(ensureParentDir(result.options.outFile));
92
+ if (result.options.writeReview) {
93
+ dirsToEnsure.push(ensureParentDir(result.options.reviewFile));
94
+ }
95
+ await Promise.all(dirsToEnsure);
96
+
97
+ const writeTasks = [];
98
+ if (result.options.writeMain) {
99
+ writeTasks.push(
100
+ fsp.writeFile(result.options.outFile, result.mainContent, "utf8")
101
+ );
102
+ }
103
+ if (result.options.writeReview) {
104
+ writeTasks.push(
105
+ fsp.writeFile(result.options.reviewFile, result.reviewContent, "utf8")
106
+ );
107
+ }
108
+ await Promise.all(writeTasks);
109
+
110
+ if (result.options.writeMain)
111
+ console.log(
112
+ `Generated: ${result.options.outFileDisplay ?? result.options.outFile}`
113
+ );
114
+ if (result.options.writeReview)
115
+ console.log(
116
+ `Review: ${
117
+ result.options.reviewFileDisplay ?? result.options.reviewFile
118
+ }`
119
+ );
120
+ console.log(`Packages: ${result.stats.packages}`);
121
+ console.log(
122
+ `Missing ${LICENSE_FILES_LABEL}: ${result.stats.missingFiles.length}`
123
+ );
124
+
125
+ if (result.stats.missingFiles.length > 0 && result.options.failOnMissing) {
126
+ process.exitCode = 1;
127
+ }
128
+ } catch (e) {
129
+ console.error(e?.stack || String(e));
130
+ process.exit(2);
131
+ }
132
+ }
133
+
134
+ function isCliExecution() {
135
+ // npmの .bin シム経由でも動作するように、実ファイル一致と .bin 配下を許可
136
+ const argv1 = process.argv[1];
137
+ if (!argv1) return false;
138
+ const self = fileURLToPath(import.meta.url);
139
+ const resolvedArg = path.resolve(argv1);
140
+ if (resolvedArg === self) return true;
141
+
142
+ const base = path.basename(resolvedArg).toLowerCase();
143
+ if (base === "third-party-license" || base === "third-party-license.cmd") {
144
+ return true;
145
+ }
146
+ if (resolvedArg.includes(`${path.sep}.bin${path.sep}`)) return true;
147
+ return false;
148
+ }
149
+
150
+ if (isCliExecution()) {
151
+ // eslint-disable-next-line unicorn/prefer-top-level-await
152
+ runCli();
153
+ }
@@ -0,0 +1,20 @@
1
+ // デフォルト値と定数群
2
+ export const DEFAULT_OPTIONS = {
3
+ nodeModules: "node_modules",
4
+ outFile: "THIRD-PARTY-LICENSE.md",
5
+ reviewFile: "THIRD-PARTY-LICENSE-REVIEW.md",
6
+ failOnMissing: false,
7
+ mode: "recreate", // "recreate" | "update"
8
+ };
9
+
10
+ // ライセンスらしいファイル名を検出する正規表現
11
+ const LICENSE_LIKE_BASE =
12
+ "(LICEN[CS]E|COPYING|COPYRIGHT|NOTICE|THIRD[-_. ]?PARTY[-_. ]?(?:NOTICES?|NOTICE[-_. ]?TEXTS?|TEXTS?|LICENSES?))";
13
+
14
+ export const LICENSE_LIKE_RE = new RegExp(
15
+ `^${LICENSE_LIKE_BASE}(\\..*)?$|^${LICENSE_LIKE_BASE}-`,
16
+ "i"
17
+ );
18
+
19
+ export const LICENSE_FILES_LABEL =
20
+ "LICENSE/NOTICE/COPYRIGHT/THIRD-PARTY-NOTICES/THIRD-PARTY-LICENSES/ThirdPartyNoticeText/ThirdPartyText/COPYING";
package/src/core.js ADDED
@@ -0,0 +1,167 @@
1
+ import fsp from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { DEFAULT_OPTIONS } from "./constants.js";
4
+ import { gatherPackages } from "./scan.js";
5
+ import { renderMain, renderReview } from "./render.js";
6
+ import { makeAnchorId, uniqSorted } from "./fs-utils.js";
7
+ import {
8
+ parseExistingMainFile,
9
+ parseExistingReviewFile,
10
+ } from "./existing.js";
11
+
12
+ // Default warning handler (prints to console)
13
+ const defaultWarn = (msg) => {
14
+ console.warn(`warning: ${msg}`);
15
+ };
16
+
17
+ // Public API: scan licenses and render markdown
18
+ export async function collectThirdPartyLicenses(options = {}) {
19
+ const opts = normalizeOptions(options);
20
+ await assertNodeModulesExists(opts.nodeModules); // fail fast when node_modules is missing
21
+
22
+ const scanResult = await gatherPackages(opts);
23
+ const presentPackages = withPresentUsage(scanResult.packages);
24
+
25
+ const packages =
26
+ opts.mode === "update"
27
+ ? await mergeWithExistingOutputs(presentPackages, opts)
28
+ : presentPackages;
29
+
30
+ const sortedPackages = sortPackages(packages);
31
+
32
+ const mainContent = renderMain(sortedPackages, opts);
33
+ const reviewContent = renderReview(
34
+ sortedPackages,
35
+ opts,
36
+ scanResult.missingFiles,
37
+ scanResult.missingSource,
38
+ scanResult.missingLicenseField
39
+ );
40
+
41
+ return {
42
+ mainContent,
43
+ reviewContent,
44
+ options: opts,
45
+ stats: {
46
+ packages: scanResult.seenCount,
47
+ missingFiles: uniqSorted(scanResult.missingFiles),
48
+ missingSource: uniqSorted(scanResult.missingSource),
49
+ missingLicenseField: uniqSorted(scanResult.missingLicenseField),
50
+ },
51
+ };
52
+ }
53
+
54
+ export { DEFAULT_OPTIONS } from "./constants.js";
55
+
56
+ function normalizeOptions(options) {
57
+ const cwd = process.cwd();
58
+ const nodeModules = path.resolve(
59
+ options.nodeModules ?? DEFAULT_OPTIONS.nodeModules
60
+ );
61
+ const outFile = path.resolve(options.outFile ?? DEFAULT_OPTIONS.outFile);
62
+ const reviewFile = path.resolve(
63
+ options.reviewFile ?? DEFAULT_OPTIONS.reviewFile
64
+ );
65
+
66
+ return {
67
+ nodeModules,
68
+ outFile,
69
+ reviewFile,
70
+ nodeModulesDisplay: makeDisplayPath(nodeModules, cwd),
71
+ outFileDisplay: makeDisplayPath(outFile, cwd),
72
+ reviewFileDisplay: makeDisplayPath(reviewFile, cwd),
73
+ failOnMissing: Boolean(
74
+ options.failOnMissing ?? DEFAULT_OPTIONS.failOnMissing
75
+ ),
76
+ writeMain: options.writeMain ?? true,
77
+ writeReview: options.writeReview ?? true,
78
+ warn: options.onWarn ?? defaultWarn,
79
+ mode: normalizeMode(options.mode),
80
+ };
81
+ }
82
+
83
+ function normalizeMode(mode) {
84
+ const m = typeof mode === "string" ? mode.toLowerCase() : "";
85
+ return m === "update" ? "update" : DEFAULT_OPTIONS.mode;
86
+ }
87
+
88
+ function makeDisplayPath(targetPath, cwd) {
89
+ const rel = path.relative(cwd, targetPath);
90
+ return rel || ".";
91
+ }
92
+
93
+ async function assertNodeModulesExists(dir) {
94
+ const stat = await fsp.stat(dir).catch(() => null);
95
+ if (!stat || !stat.isDirectory()) {
96
+ throw new Error(`not found node_modules: ${dir}`);
97
+ }
98
+ }
99
+
100
+ function withPresentUsage(packages) {
101
+ return packages.map((pkg) => ({
102
+ ...pkg,
103
+ usage: "present",
104
+ notes: "",
105
+ }));
106
+ }
107
+
108
+ async function mergeWithExistingOutputs(currentPackages, opts) {
109
+ const [existingMain, existingReview] = await Promise.all([
110
+ parseExistingMainFile(opts.outFile),
111
+ parseExistingReviewFile(opts.reviewFile),
112
+ ]);
113
+
114
+ // Start with previously known packages as "missing" (not found in this scan)
115
+ const merged = new Map();
116
+ for (const [key, prevReview] of existingReview.entries()) {
117
+ const prevMain = existingMain.get(key);
118
+ merged.set(key, toMissingPackage(key, prevMain, prevReview));
119
+ }
120
+ for (const [key, prevMain] of existingMain.entries()) {
121
+ if (!merged.has(key)) {
122
+ merged.set(key, toMissingPackage(key, prevMain, null));
123
+ }
124
+ }
125
+
126
+ // Override with current scan (present in node_modules), keeping previous notes when available
127
+ for (const pkg of currentPackages) {
128
+ const prevReview = existingReview.get(pkg.key);
129
+ merged.set(pkg.key, toPresentPackage(pkg, prevReview));
130
+ }
131
+
132
+ return [...merged.values()];
133
+ }
134
+
135
+ function toPresentPackage(pkg, prevReview) {
136
+ return {
137
+ ...pkg,
138
+ usage: "present",
139
+ notes: prevReview?.notes ?? "",
140
+ };
141
+ }
142
+
143
+ function toMissingPackage(key, prevMain, prevReview) {
144
+ return {
145
+ key,
146
+ anchor: makeAnchorId(key),
147
+ source: prevMain?.source ?? prevReview?.source ?? null,
148
+ license: prevMain?.license ?? prevReview?.license ?? null,
149
+ fileNames: deriveFileNames(prevMain, prevReview),
150
+ flags: [],
151
+ licenseTexts: prevMain?.licenseTexts ?? [],
152
+ usage: "missing",
153
+ notes: prevReview?.notes ?? "",
154
+ };
155
+ }
156
+
157
+ function deriveFileNames(prevMain, prevReview) {
158
+ const names =
159
+ (prevMain?.fileNames && prevMain.fileNames.length > 0
160
+ ? prevMain.fileNames
161
+ : prevReview?.fileNames) ?? [];
162
+ return uniqSorted(names);
163
+ }
164
+
165
+ function sortPackages(packages) {
166
+ return [...packages].sort((a, b) => a.key.localeCompare(b.key));
167
+ }
@@ -0,0 +1,168 @@
1
+ import fsp from "node:fs/promises";
2
+ import { makeAnchorId, uniqSorted } from "./fs-utils.js";
3
+ import { LICENSE_FILES_LABEL } from "./constants.js";
4
+
5
+ export async function parseExistingMainFile(filePath) {
6
+ const content = await readFileSafe(filePath);
7
+ if (!content) return new Map();
8
+
9
+ const map = new Map();
10
+ for (const { key, body } of splitPackageBlocks(content)) {
11
+ map.set(key, parseMainBlock(key, body));
12
+ }
13
+ return map;
14
+ }
15
+
16
+ export async function parseExistingReviewFile(filePath) {
17
+ const content = await readFileSafe(filePath);
18
+ if (!content) return new Map();
19
+
20
+ const map = new Map();
21
+ for (const { key, body } of splitPackageBlocks(content)) {
22
+ map.set(key, parseReviewBlock(key, body));
23
+ }
24
+ return map;
25
+ }
26
+
27
+ async function readFileSafe(filePath) {
28
+ try {
29
+ return await fsp.readFile(filePath, "utf8");
30
+ } catch {
31
+ return null;
32
+ }
33
+ }
34
+
35
+ function splitPackageBlocks(content) {
36
+ const blocks = [];
37
+ const headingRe = /^##\s+(.+)$/gm;
38
+ let match;
39
+ let current = null;
40
+
41
+ while ((match = headingRe.exec(content))) {
42
+ if (current) {
43
+ blocks.push({
44
+ key: current.key,
45
+ body: content.slice(current.start, match.index),
46
+ });
47
+ }
48
+ current = { key: match[1].trim(), start: match.index };
49
+ }
50
+
51
+ if (current) {
52
+ blocks.push({
53
+ key: current.key,
54
+ body: content.slice(current.start),
55
+ });
56
+ }
57
+
58
+ return blocks;
59
+ }
60
+
61
+ function parseMainBlock(key, body) {
62
+ const source = pickLine(body, /^- Source:\s*(.+)$/m);
63
+ const license = pickLine(body, /^- License:\s*(.+)$/m);
64
+ const usage = pickLine(body, /^- Usage:\s*(.+)$/m);
65
+
66
+ const licenseTexts = [];
67
+ const licRe = /###\s+(.+?)\s*\r?\n```text\r?\n([\s\S]*?)```/g;
68
+ let licMatch;
69
+ while ((licMatch = licRe.exec(body))) {
70
+ licenseTexts.push({
71
+ name: licMatch[1].trim(),
72
+ text: licMatch[2],
73
+ });
74
+ }
75
+
76
+ const fileNames = [];
77
+ for (const m of body.matchAll(/^- (.+)$/gm)) {
78
+ const val = m[1].trim();
79
+ if (
80
+ val.startsWith("Source:") ||
81
+ val.startsWith("License:") ||
82
+ val.startsWith("Usage:")
83
+ ) {
84
+ continue;
85
+ }
86
+ if (
87
+ val.startsWith("(no LICENSE/NOTICE/COPYING files)") ||
88
+ val.startsWith(`(no ${LICENSE_FILES_LABEL} files)`)
89
+ ) {
90
+ continue;
91
+ }
92
+ fileNames.push(val);
93
+ }
94
+
95
+ const derivedNames =
96
+ fileNames.length > 0 ? fileNames : licenseTexts.map((x) => x.name);
97
+
98
+ return {
99
+ key,
100
+ anchor: makeAnchorId(key),
101
+ source: source || null,
102
+ license: license || null,
103
+ fileNames: uniqSorted(derivedNames),
104
+ flags: [],
105
+ licenseTexts,
106
+ usage: usage || "",
107
+ notes: "",
108
+ };
109
+ }
110
+
111
+ function parseReviewBlock(key, body) {
112
+ const source = pickLine(body, /^- Source:\s*(.+)$/m);
113
+ const license = pickLine(body, /^- License:\s*(.+)$/m);
114
+
115
+ const lines = body.split(/\r?\n/);
116
+ const fileNames = [];
117
+
118
+ for (let i = 0; i < lines.length; i += 1) {
119
+ const line = lines[i];
120
+ if (!line.startsWith("- Files:")) continue;
121
+
122
+ let j = i + 1;
123
+ while (j < lines.length && lines[j].startsWith(" -")) {
124
+ fileNames.push(lines[j].replace(/^ -\s*/, ""));
125
+ j += 1;
126
+ }
127
+ i = j - 1;
128
+ }
129
+
130
+ let notes = "";
131
+ const notesIdx = lines.findIndex((l) => l.startsWith("- Notes:"));
132
+ if (notesIdx !== -1) {
133
+ const noteLines = [];
134
+ for (let i = notesIdx + 1; i < lines.length; i += 1) {
135
+ const line = lines[i];
136
+ if (line.startsWith("## ")) break;
137
+ if (line.startsWith("- ")) break;
138
+ if (line.startsWith("---")) break;
139
+ if (line.startsWith(" ")) {
140
+ noteLines.push(line.slice(2));
141
+ continue;
142
+ }
143
+ if (line.trim() === "") {
144
+ noteLines.push("");
145
+ continue;
146
+ }
147
+ break;
148
+ }
149
+ notes = noteLines.join("\n").replace(/\s+$/, "");
150
+ }
151
+
152
+ return {
153
+ key,
154
+ anchor: makeAnchorId(key),
155
+ source: source || null,
156
+ license: license || null,
157
+ fileNames: uniqSorted(fileNames),
158
+ flags: [],
159
+ licenseTexts: [],
160
+ usage: "",
161
+ notes,
162
+ };
163
+ }
164
+
165
+ function pickLine(body, re) {
166
+ const m = body.match(re);
167
+ return m ? m[1].trim() : "";
168
+ }
@@ -0,0 +1,114 @@
1
+ import fsp from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { LICENSE_LIKE_RE } from "./constants.js";
4
+
5
+ // 文字コードを判定しつつ文字列へデコードする
6
+ export function decodeSmart(buf) {
7
+ if (buf.length >= 2) {
8
+ const b0 = buf[0];
9
+ const b1 = buf[1];
10
+ if (b0 === 0xff && b1 === 0xfe) {
11
+ return new TextDecoder("utf-16le").decode(buf.subarray(2));
12
+ }
13
+ if (b0 === 0xfe && b1 === 0xff) {
14
+ const be = buf.subarray(2);
15
+ const swapped = Buffer.allocUnsafe(be.length);
16
+ for (let i = 0; i + 1 < be.length; i += 2) {
17
+ swapped[i] = be[i + 1];
18
+ swapped[i + 1] = be[i];
19
+ }
20
+ return new TextDecoder("utf-16le").decode(swapped);
21
+ }
22
+ }
23
+ if (
24
+ buf.length >= 3 &&
25
+ buf[0] === 0xef &&
26
+ buf[1] === 0xbb &&
27
+ buf[2] === 0xbf
28
+ ) {
29
+ return new TextDecoder("utf-8").decode(buf.subarray(3));
30
+ }
31
+
32
+ try {
33
+ return new TextDecoder("utf-8", { fatal: true }).decode(buf);
34
+ } catch {
35
+ return new TextDecoder("latin1").decode(buf);
36
+ }
37
+ }
38
+
39
+ // LICENSE/NOTICE などのライセンス系ファイルを探す
40
+ export async function getLicenseLikeFilesInFolderRoot(pkgDir) {
41
+ try {
42
+ const ents = await fsp.readdir(pkgDir, { withFileTypes: true });
43
+ return ents
44
+ .filter((e) => e.isFile() && LICENSE_LIKE_RE.test(e.name))
45
+ .map((e) => path.join(pkgDir, e.name))
46
+ .sort((a, b) => path.basename(a).localeCompare(path.basename(b)));
47
+ } catch {
48
+ return [];
49
+ }
50
+ }
51
+
52
+ // package.json を深さ優先で探す(node_modules/.bin は除外)
53
+ export async function* walkForPackageJson(rootDir) {
54
+ const stack = [rootDir];
55
+
56
+ while (stack.length) {
57
+ const dir = stack.pop();
58
+ if (!dir) continue;
59
+
60
+ let ents;
61
+ try {
62
+ ents = await fsp.readdir(dir, { withFileTypes: true });
63
+ } catch {
64
+ continue;
65
+ }
66
+
67
+ for (const e of ents) {
68
+ const full = path.join(dir, e.name);
69
+ if (e.isDirectory()) {
70
+ if (e.name === ".bin") continue;
71
+ stack.push(full);
72
+ continue;
73
+ }
74
+ if (e.isFile() && e.name === "package.json") {
75
+ if (full.includes(`${path.sep}.bin${path.sep}`)) continue;
76
+ yield full;
77
+ }
78
+ }
79
+ }
80
+ }
81
+
82
+ export async function readPackageJson(pjPath) {
83
+ try {
84
+ const txt = await fsp.readFile(pjPath, "utf8");
85
+ return JSON.parse(txt);
86
+ } catch {
87
+ return null;
88
+ }
89
+ }
90
+
91
+ // BOM や UTF-16 を考慮してテキストを読み込む
92
+ export async function readTextFileSmart(filePath) {
93
+ const buf = await fsp.readFile(filePath);
94
+ return decodeSmart(buf);
95
+ }
96
+
97
+ export function mdSafeText(s) {
98
+ return String(s).replace(/```/g, "``\u200b`");
99
+ }
100
+
101
+ export function uniqSorted(arr) {
102
+ return [...new Set(arr)].sort();
103
+ }
104
+
105
+ // アンカー用の安全な ID を作る
106
+ export function makeAnchorId(key) {
107
+ return (
108
+ "pkg-" +
109
+ String(key)
110
+ .toLowerCase()
111
+ .replace(/[^a-z0-9]+/g, "-")
112
+ .replace(/^-+|-+$/g, "")
113
+ );
114
+ }
package/src/render.js ADDED
@@ -0,0 +1,128 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ import { mdSafeText, uniqSorted } from "./fs-utils.js";
4
+ import { LICENSE_FILES_LABEL } from "./constants.js";
5
+
6
+ // メインのTHIRD-PARTY-LICENSE.mdを描画する
7
+ export function renderMain(packages, opts) {
8
+ const lines = [];
9
+ const push = (s = "") => lines.push(s);
10
+
11
+ push("# Third-Party Licenses");
12
+ push("");
13
+ push(`Generated from: ${opts.nodeModulesDisplay ?? opts.nodeModules}`);
14
+ push("");
15
+
16
+ for (const pkg of packages) {
17
+ push(`<a id="${pkg.anchor}"></a>`);
18
+ push(`## ${pkg.key}`);
19
+ push(`- Source: ${pkg.source ?? "(missing)"}`);
20
+ push(`- License: ${pkg.license ?? "(missing)"}`);
21
+ push(`- Usage: ${describeUsage(pkg.usage)}`);
22
+
23
+ const fileNames = pkg.fileNames ?? [];
24
+ if (fileNames.length === 0) {
25
+ push(`- (no ${LICENSE_FILES_LABEL} files)`);
26
+ push("");
27
+ push(`_No ${LICENSE_FILES_LABEL} file found in package directory._`);
28
+ push("");
29
+ continue;
30
+ }
31
+
32
+ for (const n of fileNames) push(`- ${n}`);
33
+ push("");
34
+
35
+ for (const lic of pkg.licenseTexts ?? []) {
36
+ push(`### ${lic.name}`);
37
+ push("```text");
38
+ push(mdSafeText(lic.text).replace(/\s+$/, ""));
39
+ push("```");
40
+ push("");
41
+ }
42
+ }
43
+
44
+ return lines.join(os.EOL) + os.EOL;
45
+ }
46
+
47
+ // レビューファイルを描画する
48
+ export function renderReview(
49
+ packages,
50
+ opts,
51
+ missingFiles,
52
+ missingSource,
53
+ missingLicenseField
54
+ ) {
55
+ const lines = [];
56
+ const push = (s = "") => lines.push(s);
57
+ const mainPath = makeMainLinkPath(opts);
58
+
59
+ push("# THIRD-PARTY-LICENSE-REVIEW");
60
+ push("");
61
+ push(`Generated from: ${opts.nodeModulesDisplay ?? opts.nodeModules}`);
62
+ push(`Main file: ${mainPath}`);
63
+ push("");
64
+
65
+ for (const it of [...packages].sort((a, b) => a.key.localeCompare(b.key))) {
66
+ push(`## ${it.key}`);
67
+ push(`- Main: ${mainPath}#${it.anchor}`);
68
+ push(`- Source: ${it.source ?? "(missing)"}`);
69
+ push(`- License: ${it.license ?? "(missing)"}`);
70
+
71
+ push("- Files:");
72
+ const fileNames = it.fileNames ?? [];
73
+ if (fileNames.length === 0) {
74
+ push(" - (none)");
75
+ } else {
76
+ for (const f of fileNames) push(` - ${f}`);
77
+ }
78
+
79
+ const statusParts = [describeUsage(it.usage), ...(it.flags ?? [])].filter(
80
+ Boolean
81
+ );
82
+ const status =
83
+ statusParts.length === 0 ? "Check manually" : statusParts.join(" / ");
84
+ push(`- Status: ${status}`);
85
+
86
+ push("- Notes:");
87
+ if (it.notes && it.notes.length > 0) {
88
+ for (const line of it.notes.split(/\r?\n/)) {
89
+ push(` ${line}`);
90
+ }
91
+ }
92
+ push("");
93
+ }
94
+
95
+ push("---");
96
+ push("");
97
+ push("## Missing summary");
98
+ push("");
99
+
100
+ const writeList = (title, arr) => {
101
+ push(`### ${title}`);
102
+ push("");
103
+ const xs = uniqSorted(arr);
104
+ if (xs.length === 0) push("- (none)");
105
+ else for (const x of xs) push(`- ${x}`);
106
+ push("");
107
+ };
108
+
109
+ writeList("Missing Source", missingSource);
110
+ writeList("Missing package.json license field", missingLicenseField);
111
+ writeList(`Missing ${LICENSE_FILES_LABEL} files`, missingFiles);
112
+
113
+ return lines.join(os.EOL) + os.EOL;
114
+ }
115
+
116
+ function makeMainLinkPath(opts) {
117
+ const baseDir = path.dirname(opts.reviewFile);
118
+ const rel = path.relative(baseDir, opts.outFile);
119
+ const normalized = rel.replace(/\\/g, "/");
120
+ return normalized || path.basename(opts.outFile);
121
+ }
122
+
123
+ function describeUsage(usage) {
124
+ if (usage === "missing") {
125
+ return "Not found in node_modules (kept from previous output)";
126
+ }
127
+ return "Present in node_modules";
128
+ }
package/src/scan.js ADDED
@@ -0,0 +1,135 @@
1
+ import path from "node:path";
2
+ import {
3
+ getLicenseLikeFilesInFolderRoot,
4
+ makeAnchorId,
5
+ readPackageJson,
6
+ readTextFileSmart,
7
+ uniqSorted,
8
+ walkForPackageJson,
9
+ } from "./fs-utils.js";
10
+ import { getRepositoryUrl } from "./url.js";
11
+ import { LICENSE_FILES_LABEL } from "./constants.js";
12
+
13
+ // node_modules を走査してパッケージ情報を集約する
14
+ export async function gatherPackages(opts) {
15
+ const missingFiles = [];
16
+ const missingSource = [];
17
+ const missingLicenseField = [];
18
+ const packages = [];
19
+ const seen = new Set();
20
+
21
+ for await (const pj of walkForPackageJson(opts.nodeModules)) {
22
+ const pkgDir = path.dirname(pj);
23
+ const pkg = await readPackageJson(pj);
24
+ if (!pkg) continue;
25
+
26
+ const name =
27
+ typeof pkg.name === "string" && pkg.name.trim().length > 0
28
+ ? pkg.name.trim()
29
+ : "";
30
+ const version =
31
+ typeof pkg.version === "string" && pkg.version.trim().length > 0
32
+ ? pkg.version.trim()
33
+ : "";
34
+ if (!name || !version) continue;
35
+
36
+ const key = `${name}@${version}`;
37
+ if (seen.has(key)) continue;
38
+ seen.add(key);
39
+
40
+ const anchor = makeAnchorId(key);
41
+ const source = getRepositoryUrl(pkg);
42
+ const license = formatLicense(pkg.license); // 文字列/オブジェクト/配列すべてを受け付ける
43
+
44
+ const flags = [];
45
+ if (!source) {
46
+ missingSource.push(key);
47
+ flags.push("Missing Source");
48
+ opts.warn(`Unknown source: ${key}`);
49
+ }
50
+ if (!license) {
51
+ missingLicenseField.push(key);
52
+ flags.push("Missing package.json license");
53
+ opts.warn(`Missing license in package.json: ${key}`);
54
+ }
55
+
56
+ const licFiles = await getLicenseLikeFilesInFolderRoot(pkgDir);
57
+ const fileNames = licFiles.map((f) => path.basename(f));
58
+
59
+ if (licFiles.length === 0) {
60
+ missingFiles.push(key);
61
+ const missingMsg = `Missing ${LICENSE_FILES_LABEL} files`;
62
+ flags.push(missingMsg);
63
+ opts.warn(`Missing ${LICENSE_FILES_LABEL} in ${pkgDir} (${key})`);
64
+ }
65
+
66
+ const licenseTexts =
67
+ licFiles.length > 0
68
+ ? await Promise.all(
69
+ licFiles.map(async (filePath) => ({
70
+ name: path.basename(filePath),
71
+ text: await readTextFileSmart(filePath),
72
+ }))
73
+ )
74
+ : [];
75
+
76
+ packages.push({
77
+ key,
78
+ anchor,
79
+ source,
80
+ license,
81
+ fileNames,
82
+ flags,
83
+ licenseTexts,
84
+ });
85
+ }
86
+
87
+ return {
88
+ packages,
89
+ missingFiles: uniqSorted(missingFiles),
90
+ missingSource: uniqSorted(missingSource),
91
+ missingLicenseField: uniqSorted(missingLicenseField),
92
+ seenCount: seen.size,
93
+ };
94
+ }
95
+
96
+ // license フィールドを人間可読にまとめる(文字列/オブジェクト/配列に対応)
97
+ function formatLicense(raw) {
98
+ const parts = [];
99
+
100
+ const pushMaybe = (v) => {
101
+ if (typeof v === "string" && v.trim()) parts.push(v.trim());
102
+ };
103
+
104
+ const handleObj = (licObj) => {
105
+ if (!licObj || typeof licObj !== "object") return;
106
+ const type =
107
+ typeof licObj.type === "string" && licObj.type.trim()
108
+ ? licObj.type.trim()
109
+ : "";
110
+ const url =
111
+ typeof licObj.url === "string" && licObj.url.trim()
112
+ ? licObj.url.trim()
113
+ : "";
114
+ if (type && url) {
115
+ parts.push(`${type} (${url})`);
116
+ } else {
117
+ pushMaybe(type);
118
+ pushMaybe(url);
119
+ }
120
+ };
121
+
122
+ if (typeof raw === "string") {
123
+ pushMaybe(raw);
124
+ } else if (Array.isArray(raw)) {
125
+ for (const lic of raw) {
126
+ if (typeof lic === "string") pushMaybe(lic);
127
+ else handleObj(lic);
128
+ }
129
+ } else {
130
+ handleObj(raw);
131
+ }
132
+
133
+ if (parts.length === 0) return null;
134
+ return [...new Set(parts)].join(" | ");
135
+ }
package/src/url.js ADDED
@@ -0,0 +1,8 @@
1
+ // package.jsonのrepositoryからURLを取り出す
2
+ export function getRepositoryUrl(pkg) {
3
+ const repo = pkg?.repository;
4
+ if (!repo) return null;
5
+ if (typeof repo === "string") return repo;
6
+ if (typeof repo === "object" && typeof repo.url === "string") return repo.url;
7
+ return null;
8
+ }