@apmantza/greedysearch-pi 2.1.3 → 2.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +48 -1
- package/README.md +52 -175
- package/bin/search.mjs +2 -0
- package/docs/analysis.md +233 -0
- package/docs/banner.svg +86 -0
- package/docs/development.md +63 -0
- package/docs/inspiration2.md +190 -0
- package/docs/releases.md +88 -0
- package/docs/research.md +82 -0
- package/docs/runtime.md +65 -0
- package/docs/source-fetching.md +33 -0
- package/docs/stealthbrowsermcp.md +807 -0
- package/docs/usage.md +69 -0
- package/extractors/chatgpt.mjs +127 -23
- package/extractors/common.mjs +116 -28
- package/extractors/gemini.mjs +63 -38
- package/package.json +13 -7
- package/scripts/backfill-github-releases.mjs +139 -0
- package/scripts/changelog-extract.mjs +48 -0
- package/scripts/changelog-release.mjs +65 -0
- package/scripts/check-lockfile.mjs +45 -0
- package/scripts/lib/changelog.mjs +168 -0
- package/scripts/lint.mjs +62 -0
- package/scripts/run-tests.ps1 +179 -0
- package/scripts/stealth-check.mjs +663 -0
- package/src/fetcher.mjs +9 -5
- package/src/search/constants.mjs +1 -1
- package/src/search/research.mjs +57 -11
- package/src/search/simple-research.mjs +4 -1
- package/test.mjs +16 -3
- package/skills/greedy-search/skill.md +0 -20
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@apmantza/greedysearch-pi",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.5",
|
|
4
4
|
"description": "Headless multi-engine AI search (Perplexity, Google AI, ChatGPT, Gemini) via browser automation. NO API KEYS needed. Grounded all-engine search fetches sources by default, with optional configurable synthesis and deep research.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -21,8 +21,17 @@
|
|
|
21
21
|
"test:bash": "./test.sh",
|
|
22
22
|
"test:bash:quick": "./test.sh quick",
|
|
23
23
|
"test:bash:smoke": "./test.sh smoke",
|
|
24
|
+
"stealth-check": "node scripts/stealth-check.mjs",
|
|
25
|
+
"stealth:check": "node scripts/stealth-check.mjs",
|
|
26
|
+
"stealth:strict": "node scripts/stealth-check.mjs --strict",
|
|
27
|
+
"stealth:diff": "node scripts/stealth-check.mjs --diff",
|
|
28
|
+
"stealth:baseline": "node scripts/stealth-check.mjs --diff",
|
|
24
29
|
"lint": "node scripts/lint.mjs",
|
|
25
|
-
"check:lockfile": "node scripts/check-lockfile.mjs"
|
|
30
|
+
"check:lockfile": "node scripts/check-lockfile.mjs",
|
|
31
|
+
"changelog:check": "node scripts/changelog-release.mjs --check",
|
|
32
|
+
"changelog:release": "node scripts/changelog-release.mjs",
|
|
33
|
+
"changelog:extract": "node scripts/changelog-extract.mjs",
|
|
34
|
+
"release:backfill-notes": "node scripts/backfill-github-releases.mjs"
|
|
26
35
|
},
|
|
27
36
|
"engines": {
|
|
28
37
|
"node": ">=20.11.0"
|
|
@@ -32,18 +41,15 @@
|
|
|
32
41
|
"test.mjs",
|
|
33
42
|
"bin/",
|
|
34
43
|
"src/",
|
|
35
|
-
"skills/",
|
|
36
44
|
"extractors/",
|
|
45
|
+
"scripts/",
|
|
37
46
|
"CHANGELOG.md",
|
|
38
47
|
"README.md",
|
|
39
|
-
"docs/
|
|
48
|
+
"docs/"
|
|
40
49
|
],
|
|
41
50
|
"pi": {
|
|
42
51
|
"extensions": [
|
|
43
52
|
"./index.ts"
|
|
44
|
-
],
|
|
45
|
-
"skills": [
|
|
46
|
-
"./skills"
|
|
47
53
|
]
|
|
48
54
|
},
|
|
49
55
|
"dependencies": {
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Retroactively set GitHub release bodies to their curated CHANGELOG sections.
|
|
3
|
+
//
|
|
4
|
+
// node scripts/backfill-github-releases.mjs # dry run
|
|
5
|
+
// node scripts/backfill-github-releases.mjs --apply # edit releases
|
|
6
|
+
// node scripts/backfill-github-releases.mjs --apply --full # use full prose
|
|
7
|
+
// node scripts/backfill-github-releases.mjs --only v2.1.3,v2.1.2 --apply
|
|
8
|
+
// node scripts/backfill-github-releases.mjs --repo owner/repo --apply
|
|
9
|
+
//
|
|
10
|
+
// Requires the `gh` CLI authenticated with permission to edit releases.
|
|
11
|
+
|
|
12
|
+
import { readFileSync, writeFileSync, mkdtempSync, existsSync } from "node:fs";
|
|
13
|
+
import { execFileSync } from "node:child_process";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
import { dirname, join } from "node:path";
|
|
16
|
+
import { platform, tmpdir } from "node:os";
|
|
17
|
+
import {
|
|
18
|
+
extractSection,
|
|
19
|
+
normalizeVersion,
|
|
20
|
+
summarizeSection,
|
|
21
|
+
} from "./lib/changelog.mjs";
|
|
22
|
+
|
|
23
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
24
|
+
const CHANGELOG_PATH = join(__dirname, "..", "CHANGELOG.md");
|
|
25
|
+
|
|
26
|
+
function parseArgs(argv) {
|
|
27
|
+
const args = { apply: false, full: false, repo: undefined, only: undefined };
|
|
28
|
+
for (let i = 0; i < argv.length; i++) {
|
|
29
|
+
const a = argv[i];
|
|
30
|
+
if (a === "--apply") args.apply = true;
|
|
31
|
+
else if (a === "--full") args.full = true;
|
|
32
|
+
else if (a === "--repo") args.repo = argv[++i];
|
|
33
|
+
else if (a === "--only") {
|
|
34
|
+
args.only = new Set(
|
|
35
|
+
argv[++i].split(",").map((s) => normalizeVersion(s.trim())),
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return args;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function resolveGh() {
|
|
43
|
+
const candidates =
|
|
44
|
+
platform() === "win32"
|
|
45
|
+
? [
|
|
46
|
+
"C:\\Program Files\\GitHub CLI\\gh.exe",
|
|
47
|
+
"C:\\Program Files (x86)\\GitHub CLI\\gh.exe",
|
|
48
|
+
]
|
|
49
|
+
: ["/usr/bin/gh", "/usr/local/bin/gh", "/opt/homebrew/bin/gh"];
|
|
50
|
+
for (const candidate of candidates) {
|
|
51
|
+
if (existsSync(candidate)) return candidate;
|
|
52
|
+
}
|
|
53
|
+
throw new Error("GitHub CLI not found in trusted install locations");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const GH = resolveGh();
|
|
57
|
+
|
|
58
|
+
function gh(args) {
|
|
59
|
+
return execFileSync(GH, args, { encoding: "utf8" });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function listReleases(repo) {
|
|
63
|
+
const args = ["release", "list", "--limit", "200", "--json", "tagName"];
|
|
64
|
+
if (repo) args.push("--repo", repo);
|
|
65
|
+
const raw = gh(args);
|
|
66
|
+
try {
|
|
67
|
+
return JSON.parse(raw).map((r) => r.tagName);
|
|
68
|
+
} catch (err) {
|
|
69
|
+
throw new Error(`Invalid JSON from gh release list: ${err.message || err}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function main() {
|
|
74
|
+
const args = parseArgs(process.argv.slice(2));
|
|
75
|
+
const changelog = readFileSync(CHANGELOG_PATH, "utf8");
|
|
76
|
+
|
|
77
|
+
let tags;
|
|
78
|
+
try {
|
|
79
|
+
tags = listReleases(args.repo);
|
|
80
|
+
} catch (err) {
|
|
81
|
+
console.error(
|
|
82
|
+
"Failed to list releases via `gh`. Is it installed and authenticated?",
|
|
83
|
+
);
|
|
84
|
+
console.error(String(err.message || err));
|
|
85
|
+
process.exit(1);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const tmp = mkdtempSync(join(tmpdir(), "greedysearch-relnotes-"));
|
|
89
|
+
const plan = [];
|
|
90
|
+
for (const tag of tags) {
|
|
91
|
+
if (args.only && !args.only.has(normalizeVersion(tag))) continue;
|
|
92
|
+
const full = extractSection(changelog, tag);
|
|
93
|
+
if (full === null || full.trim().length === 0) {
|
|
94
|
+
plan.push({ tag, action: "skip", reason: "no CHANGELOG section" });
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
const body = args.full ? full : summarizeSection(full);
|
|
98
|
+
if (!body.trim()) {
|
|
99
|
+
plan.push({ tag, action: "skip", reason: "empty summarized notes" });
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
plan.push({ tag, action: "update", body });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const updates = plan.filter((p) => p.action === "update");
|
|
106
|
+
const skips = plan.filter((p) => p.action === "skip");
|
|
107
|
+
|
|
108
|
+
console.log(
|
|
109
|
+
`${args.apply ? "APPLYING" : "DRY RUN"} — ${updates.length} release(s) to update, ${skips.length} skipped.\n`,
|
|
110
|
+
);
|
|
111
|
+
for (const p of skips) console.log(` skip ${p.tag} (${p.reason})`);
|
|
112
|
+
for (const p of updates) {
|
|
113
|
+
const firstLine = p.body.split("\n").find((l) => l.trim()) ?? "";
|
|
114
|
+
console.log(` update ${p.tag} ${firstLine.slice(0, 70)}`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (!args.apply) {
|
|
118
|
+
console.log("\nRe-run with --apply to write these release bodies.");
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
let ok = 0;
|
|
123
|
+
for (const p of updates) {
|
|
124
|
+
const notesFile = join(tmp, `${normalizeVersion(p.tag)}.md`);
|
|
125
|
+
writeFileSync(notesFile, `${p.body}\n`, "utf8");
|
|
126
|
+
const editArgs = ["release", "edit", p.tag, "--notes-file", notesFile];
|
|
127
|
+
if (args.repo) editArgs.push("--repo", args.repo);
|
|
128
|
+
try {
|
|
129
|
+
gh(editArgs);
|
|
130
|
+
ok++;
|
|
131
|
+
console.log(` ok ${p.tag}`);
|
|
132
|
+
} catch (err) {
|
|
133
|
+
console.error(` FAIL ${p.tag}: ${String(err.message || err)}`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
console.log(`\nUpdated ${ok}/${updates.length} release bodies.`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
main();
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Print the curated CHANGELOG.md section body for a version.
|
|
3
|
+
//
|
|
4
|
+
// node scripts/changelog-extract.mjs 2.1.3
|
|
5
|
+
// node scripts/changelog-extract.mjs v2.1.3 --summary -o RELEASE_NOTES.md
|
|
6
|
+
//
|
|
7
|
+
// The release workflow uses this to make GitHub release notes come from the
|
|
8
|
+
// curated changelog instead of auto-generated commit/PR titles.
|
|
9
|
+
|
|
10
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { dirname, join } from "node:path";
|
|
13
|
+
import { extractSection, summarizeSection } from "./lib/changelog.mjs";
|
|
14
|
+
|
|
15
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
const CHANGELOG_PATH = join(__dirname, "..", "CHANGELOG.md");
|
|
17
|
+
|
|
18
|
+
function parseArgs(argv) {
|
|
19
|
+
const args = { version: undefined, out: undefined, summary: false };
|
|
20
|
+
for (let i = 0; i < argv.length; i++) {
|
|
21
|
+
const a = argv[i];
|
|
22
|
+
if (a === "-o" || a === "--out") args.out = argv[++i];
|
|
23
|
+
else if (a === "--summary") args.summary = true;
|
|
24
|
+
else if (!args.version) args.version = a;
|
|
25
|
+
}
|
|
26
|
+
return args;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function main() {
|
|
30
|
+
const { version, out, summary } = parseArgs(process.argv.slice(2));
|
|
31
|
+
if (!version) {
|
|
32
|
+
console.error(
|
|
33
|
+
"usage: changelog-extract.mjs <version> [--summary] [-o <file>]",
|
|
34
|
+
);
|
|
35
|
+
process.exit(2);
|
|
36
|
+
}
|
|
37
|
+
const text = readFileSync(CHANGELOG_PATH, "utf8");
|
|
38
|
+
const full = extractSection(text, version);
|
|
39
|
+
if (full === null || full.trim().length === 0) {
|
|
40
|
+
console.error(`No CHANGELOG section for version "${version}".`);
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
const body = summary ? summarizeSection(full) : full;
|
|
44
|
+
if (out) writeFileSync(out, `${body}\n`, "utf8");
|
|
45
|
+
else process.stdout.write(`${body}\n`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
main();
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Promote the `## [Unreleased]` section to a dated version section and open a
|
|
3
|
+
// fresh empty `## [Unreleased]` above it.
|
|
4
|
+
//
|
|
5
|
+
// node scripts/changelog-release.mjs # package.json version, today
|
|
6
|
+
// node scripts/changelog-release.mjs 2.2.0 # explicit version
|
|
7
|
+
// node scripts/changelog-release.mjs 2.2.0 --date 2026-07-03
|
|
8
|
+
// node scripts/changelog-release.mjs --check # verify Unreleased has entries
|
|
9
|
+
|
|
10
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { dirname, join } from "node:path";
|
|
13
|
+
import { promoteUnreleased, unreleasedHasEntries } from "./lib/changelog.mjs";
|
|
14
|
+
|
|
15
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
const CHANGELOG_PATH = join(__dirname, "..", "CHANGELOG.md");
|
|
17
|
+
const PKG_PATH = join(__dirname, "..", "package.json");
|
|
18
|
+
|
|
19
|
+
function parseArgs(argv) {
|
|
20
|
+
const args = { version: undefined, date: undefined, check: false };
|
|
21
|
+
for (let i = 0; i < argv.length; i++) {
|
|
22
|
+
const a = argv[i];
|
|
23
|
+
if (a === "--check") args.check = true;
|
|
24
|
+
else if (a === "--date") args.date = argv[++i];
|
|
25
|
+
else if (!args.version) args.version = a;
|
|
26
|
+
}
|
|
27
|
+
return args;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function main() {
|
|
31
|
+
const args = parseArgs(process.argv.slice(2));
|
|
32
|
+
const text = readFileSync(CHANGELOG_PATH, "utf8");
|
|
33
|
+
|
|
34
|
+
if (args.check) {
|
|
35
|
+
if (!unreleasedHasEntries(text)) {
|
|
36
|
+
console.error("`## [Unreleased]` has no entries to release.");
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
console.log("[Unreleased] has entries — ready to release.");
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let version = args.version;
|
|
44
|
+
if (!version) {
|
|
45
|
+
try {
|
|
46
|
+
version = JSON.parse(readFileSync(PKG_PATH, "utf8")).version;
|
|
47
|
+
} catch (err) {
|
|
48
|
+
console.error(`Failed to read package version: ${err.message || err}`);
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const date = args.date ?? new Date().toISOString().slice(0, 10);
|
|
53
|
+
|
|
54
|
+
let next;
|
|
55
|
+
try {
|
|
56
|
+
next = promoteUnreleased(text, version, date);
|
|
57
|
+
} catch (err) {
|
|
58
|
+
console.error(String(err.message || err));
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
61
|
+
writeFileSync(CHANGELOG_PATH, next, "utf8");
|
|
62
|
+
console.log(`Promoted [Unreleased] -> [${version}] — ${date}.`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
main();
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/check-lockfile.mjs — verify package.json and package-lock.json are in sync.
|
|
3
|
+
// CI fails the lint-and-lockfile job if a dep version is listed in package.json
|
|
4
|
+
// without a matching entry in package-lock.json, or vice versa.
|
|
5
|
+
//
|
|
6
|
+
// Usage: npm run check:lockfile
|
|
7
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
8
|
+
|
|
9
|
+
const pkg = JSON.parse(readFileSync("package.json", "utf8"));
|
|
10
|
+
if (!existsSync("package-lock.json")) {
|
|
11
|
+
console.error("package-lock.json not found. Run `npm install` first.");
|
|
12
|
+
process.exit(1);
|
|
13
|
+
}
|
|
14
|
+
const lock = JSON.parse(readFileSync("package-lock.json", "utf8"));
|
|
15
|
+
const root = lock.packages?.[""] || {};
|
|
16
|
+
|
|
17
|
+
let issues = 0;
|
|
18
|
+
const check = (section, name, ver, lockVer) => {
|
|
19
|
+
if (lockVer !== ver) {
|
|
20
|
+
console.error(
|
|
21
|
+
`MISMATCH (${section}): ${name} — package.json: ${ver ?? "(absent)"}, lock: ${lockVer ?? "(absent)"}`,
|
|
22
|
+
);
|
|
23
|
+
issues++;
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
for (const [name, ver] of Object.entries(pkg.dependencies || {})) {
|
|
28
|
+
check("dep", name, ver, root.dependencies?.[name]);
|
|
29
|
+
}
|
|
30
|
+
for (const [name, ver] of Object.entries(pkg.devDependencies || {})) {
|
|
31
|
+
check("devDep", name, ver, root.devDependencies?.[name]);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (issues) {
|
|
35
|
+
console.error(
|
|
36
|
+
`\n${issues} lockfile mismatch(es). Run \`npm install\` to update.`,
|
|
37
|
+
);
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
const depCount =
|
|
41
|
+
Object.keys(pkg.dependencies || {}).length +
|
|
42
|
+
Object.keys(pkg.devDependencies || {}).length;
|
|
43
|
+
console.log(
|
|
44
|
+
`Lockfile in sync with package.json (${depCount} ${depCount === 1 ? "dep" : "deps"})`,
|
|
45
|
+
);
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// Shared CHANGELOG.md parsing/extraction helpers.
|
|
2
|
+
//
|
|
3
|
+
// The curated CHANGELOG section for a version is the source of truth for the
|
|
4
|
+
// GitHub release body. `changelog-extract.mjs` (release workflow),
|
|
5
|
+
// `changelog-release.mjs` (version-bump helper), and
|
|
6
|
+
// `backfill-github-releases.mjs` (retroactive release body sync) all use these
|
|
7
|
+
// pure helpers so parsing behavior stays identical.
|
|
8
|
+
|
|
9
|
+
const BRACKET_VERSION_HEADING = /^## \[([^\]]+)\]/;
|
|
10
|
+
const LEGACY_VERSION_HEADING = /^##\s+v?(\d+\.\d+\.\d+)\b/i;
|
|
11
|
+
|
|
12
|
+
function matchVersionHeading(line) {
|
|
13
|
+
const bracket = line.match(BRACKET_VERSION_HEADING);
|
|
14
|
+
if (bracket) return bracket[1].trim();
|
|
15
|
+
const legacy = line.match(LEGACY_VERSION_HEADING);
|
|
16
|
+
if (legacy) return legacy[1].trim();
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Split CHANGELOG.md into ordered version sections.
|
|
22
|
+
* Supports both current headings (`## [2.1.3] — 2026-06-21`) and legacy
|
|
23
|
+
* headings (`## v1.8.5 (2026-04-29)`) so old GitHub releases can be backfilled.
|
|
24
|
+
*
|
|
25
|
+
* @param {string} text
|
|
26
|
+
* @returns {Array<{ label: string, heading: string, body: string }>}
|
|
27
|
+
*/
|
|
28
|
+
export function parseSections(text) {
|
|
29
|
+
const lines = text.split(/\r?\n/);
|
|
30
|
+
const sections = [];
|
|
31
|
+
let current = null;
|
|
32
|
+
for (const line of lines) {
|
|
33
|
+
const label = matchVersionHeading(line);
|
|
34
|
+
if (label) {
|
|
35
|
+
if (current) sections.push(finalize(current));
|
|
36
|
+
current = { label, heading: line, bodyLines: [] };
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (current) current.bodyLines.push(line);
|
|
40
|
+
}
|
|
41
|
+
if (current) sections.push(finalize(current));
|
|
42
|
+
return sections;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function finalize(current) {
|
|
46
|
+
return {
|
|
47
|
+
label: current.label,
|
|
48
|
+
heading: current.heading,
|
|
49
|
+
body: current.bodyLines.join("\n").replace(/^\n+/, "").replace(/\s+$/, ""),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Condense a section into scannable GitHub release notes. Keeps headings and
|
|
55
|
+
* top-level bullets, trims verbose bullets to their first sentence when useful,
|
|
56
|
+
* and supports both `- **Title** — details` and plain legacy bullets.
|
|
57
|
+
*
|
|
58
|
+
* @param {string} body
|
|
59
|
+
* @param {{ maxGist?: number }} [opts]
|
|
60
|
+
* @returns {string}
|
|
61
|
+
*/
|
|
62
|
+
export function summarizeSection(body, opts = {}) {
|
|
63
|
+
const maxGist = opts.maxGist ?? 160;
|
|
64
|
+
const order = [];
|
|
65
|
+
const buckets = new Map();
|
|
66
|
+
let heading = null;
|
|
67
|
+
for (const raw of body.split(/\r?\n/)) {
|
|
68
|
+
const line = raw.trimEnd();
|
|
69
|
+
const h = line.match(/^#{2,4}\s+(.*)$/);
|
|
70
|
+
if (h) {
|
|
71
|
+
heading = h[1].trim();
|
|
72
|
+
if (!buckets.has(heading)) {
|
|
73
|
+
buckets.set(heading, []);
|
|
74
|
+
order.push(heading);
|
|
75
|
+
}
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (heading === null) continue;
|
|
79
|
+
const bold = line.match(/^- (\*\*.+?\*\*)\s*(.*)$/);
|
|
80
|
+
if (bold) {
|
|
81
|
+
const gist = cleanGist(bold[2], maxGist);
|
|
82
|
+
buckets.get(heading).push(gist ? `- ${bold[1]} — ${gist}` : `- ${bold[1]}`);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
const plain = line.match(/^-\s+(.+)$/);
|
|
86
|
+
if (plain) buckets.get(heading).push(`- ${cleanPlainBullet(plain[1], maxGist)}`);
|
|
87
|
+
}
|
|
88
|
+
const out = [];
|
|
89
|
+
for (const h of order) {
|
|
90
|
+
const items = buckets.get(h);
|
|
91
|
+
if (!items.length) continue;
|
|
92
|
+
out.push(`### ${h}`, "", ...items, "");
|
|
93
|
+
}
|
|
94
|
+
return out.join("\n").replace(/^\n+/, "").replace(/\s+$/, "");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function cleanGist(rest, maxGist) {
|
|
98
|
+
const text = rest
|
|
99
|
+
.replace(/^\s*\((?:refs?|closes?|fixes?)?\s*#\d+\)\s*/i, "")
|
|
100
|
+
.replace(/^\s*[—–:-]\s*/, "")
|
|
101
|
+
.trim();
|
|
102
|
+
if (!text) return "";
|
|
103
|
+
return firstSentence(text, maxGist);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function cleanPlainBullet(text, maxGist) {
|
|
107
|
+
return firstSentence(text.trim(), maxGist) || text.trim().slice(0, maxGist);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function firstSentence(text, maxGist) {
|
|
111
|
+
const period = text.search(/\.\s/);
|
|
112
|
+
const first = period >= 0 ? text.slice(0, period) : text;
|
|
113
|
+
return first.length > 0 && first.length <= maxGist ? first : "";
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function normalizeVersion(version) {
|
|
117
|
+
return String(version).trim().replace(/^v/i, "");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function extractSection(text, version) {
|
|
121
|
+
const want = normalizeVersion(version);
|
|
122
|
+
const section = parseSections(text).find(
|
|
123
|
+
(s) => normalizeVersion(s.label) === want,
|
|
124
|
+
);
|
|
125
|
+
return section ? section.body : null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function hasSection(text, version) {
|
|
129
|
+
const body = extractSection(text, version);
|
|
130
|
+
return typeof body === "string" && body.trim().length > 0;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function unreleasedHasEntries(text) {
|
|
134
|
+
const body = extractSection(text, "Unreleased");
|
|
135
|
+
return body !== null && /^\s*[-*]\s/m.test(body);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const EMPTY_UNRELEASED = [
|
|
139
|
+
"## [Unreleased]",
|
|
140
|
+
"",
|
|
141
|
+
"### Added",
|
|
142
|
+
"",
|
|
143
|
+
"### Changed",
|
|
144
|
+
"",
|
|
145
|
+
"### Fixed",
|
|
146
|
+
"",
|
|
147
|
+
].join("\n");
|
|
148
|
+
|
|
149
|
+
export function promoteUnreleased(text, version, date) {
|
|
150
|
+
if (extractSection(text, "Unreleased") === null) {
|
|
151
|
+
throw new Error("No `## [Unreleased]` section found.");
|
|
152
|
+
}
|
|
153
|
+
if (!unreleasedHasEntries(text)) {
|
|
154
|
+
throw new Error("`## [Unreleased]` has no entries to release.");
|
|
155
|
+
}
|
|
156
|
+
if (extractSection(text, version) !== null) {
|
|
157
|
+
throw new Error(`CHANGELOG already has a section for ${version}.`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const replaced = text.replace(
|
|
161
|
+
/^## \[Unreleased\][^\n]*$/m,
|
|
162
|
+
`${EMPTY_UNRELEASED}\n## [${version}] — ${date}`,
|
|
163
|
+
);
|
|
164
|
+
if (replaced === text) {
|
|
165
|
+
throw new Error("Failed to locate the `## [Unreleased]` heading.");
|
|
166
|
+
}
|
|
167
|
+
return replaced;
|
|
168
|
+
}
|
package/scripts/lint.mjs
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/lint.mjs — cross-platform syntax check for all .mjs files in the repo.
|
|
3
|
+
// Uses `node --check` (syntax-only, no module resolution) so it works on
|
|
4
|
+
// every OS without depending on `find`.
|
|
5
|
+
//
|
|
6
|
+
// Usage: npm run lint
|
|
7
|
+
import { execFileSync } from "node:child_process";
|
|
8
|
+
import { readdirSync, statSync } from "node:fs";
|
|
9
|
+
import { join, relative, sep } from "node:path";
|
|
10
|
+
|
|
11
|
+
const ROOT = process.cwd();
|
|
12
|
+
const SKIP_DIRS = new Set([
|
|
13
|
+
"node_modules",
|
|
14
|
+
"results",
|
|
15
|
+
".pi",
|
|
16
|
+
".git",
|
|
17
|
+
"coverage",
|
|
18
|
+
"dist",
|
|
19
|
+
"out",
|
|
20
|
+
]);
|
|
21
|
+
const SKIP_FILES = new Set([join("scripts", "lint.mjs")]); // self-check is redundant
|
|
22
|
+
const EXT = ".mjs";
|
|
23
|
+
|
|
24
|
+
function* walk(dir) {
|
|
25
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
26
|
+
if (SKIP_DIRS.has(entry.name)) continue;
|
|
27
|
+
const full = join(dir, entry.name);
|
|
28
|
+
if (entry.isDirectory()) {
|
|
29
|
+
yield* walk(full);
|
|
30
|
+
} else if (entry.name.endsWith(EXT) && !SKIP_FILES.has(full)) {
|
|
31
|
+
yield full;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const files = [...walk(ROOT)];
|
|
37
|
+
if (!files.length) {
|
|
38
|
+
console.log("No .mjs files found");
|
|
39
|
+
process.exit(0);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let failed = 0;
|
|
43
|
+
for (const file of files) {
|
|
44
|
+
const rel = relative(ROOT, file).split(sep).join("/");
|
|
45
|
+
try {
|
|
46
|
+
execFileSync(process.execPath, ["--check", file], { stdio: "pipe" });
|
|
47
|
+
console.log(`OK: ${rel}`);
|
|
48
|
+
} catch (err) {
|
|
49
|
+
failed++;
|
|
50
|
+
console.error(`FAIL: ${rel}`);
|
|
51
|
+
const stderr = err.stderr?.toString() || err.message;
|
|
52
|
+
for (const line of stderr.split("\n").filter(Boolean)) {
|
|
53
|
+
console.error(` ${line}`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (failed) {
|
|
59
|
+
console.error(`\n${failed} of ${files.length} file(s) failed syntax check`);
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
|
62
|
+
console.log(`\nAll ${files.length} .mjs file(s) passed syntax check`);
|