@wei840222/qmd 2026.8.23
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 +1373 -0
- package/LICENSE +45 -0
- package/README.md +1439 -0
- package/THIRD_PARTY_NOTICES.md +31 -0
- package/bin/qmd +192 -0
- package/dist/ast.d.ts +65 -0
- package/dist/ast.js +334 -0
- package/dist/bench/bench.d.ts +35 -0
- package/dist/bench/bench.js +338 -0
- package/dist/bench/cjk-baseline.d.ts +36 -0
- package/dist/bench/cjk-baseline.js +111 -0
- package/dist/bench/fixture.d.ts +2 -0
- package/dist/bench/fixture.js +84 -0
- package/dist/bench/score.d.ts +38 -0
- package/dist/bench/score.js +107 -0
- package/dist/bench/types.d.ts +110 -0
- package/dist/bench/types.js +8 -0
- package/dist/cli/build-info.json +4 -0
- package/dist/cli/embed-lock.d.ts +24 -0
- package/dist/cli/embed-lock.js +94 -0
- package/dist/cli/embedding-owner.d.ts +10 -0
- package/dist/cli/embedding-owner.js +20 -0
- package/dist/cli/formatter.d.ts +120 -0
- package/dist/cli/formatter.js +355 -0
- package/dist/cli/mcp-pid.d.ts +25 -0
- package/dist/cli/mcp-pid.js +86 -0
- package/dist/cli/qmd.d.ts +72 -0
- package/dist/cli/qmd.js +4806 -0
- package/dist/cli/version.d.ts +42 -0
- package/dist/cli/version.js +80 -0
- package/dist/collections.d.ts +200 -0
- package/dist/collections.js +433 -0
- package/dist/db.d.ts +65 -0
- package/dist/db.js +143 -0
- package/dist/diagnostics.d.ts +62 -0
- package/dist/diagnostics.js +260 -0
- package/dist/embedding/config.d.ts +52 -0
- package/dist/embedding/config.js +229 -0
- package/dist/embedding/identity.d.ts +58 -0
- package/dist/embedding/identity.js +321 -0
- package/dist/embedding/local-identity.d.ts +1 -0
- package/dist/embedding/local-identity.js +15 -0
- package/dist/embedding/local.d.ts +34 -0
- package/dist/embedding/local.js +290 -0
- package/dist/embedding/openai.d.ts +79 -0
- package/dist/embedding/openai.js +477 -0
- package/dist/embedding/owner.d.ts +13 -0
- package/dist/embedding/owner.js +36 -0
- package/dist/embedding/provider.d.ts +68 -0
- package/dist/embedding/provider.js +16 -0
- package/dist/embedding/remote-chunking.d.ts +22 -0
- package/dist/embedding/remote-chunking.js +83 -0
- package/dist/embedding/remote-embedding.d.ts +15 -0
- package/dist/embedding/remote-embedding.js +77 -0
- package/dist/hybrid-llm.d.ts +18 -0
- package/dist/hybrid-llm.js +53 -0
- package/dist/index.d.ts +244 -0
- package/dist/index.js +418 -0
- package/dist/llm.d.ts +566 -0
- package/dist/llm.js +1847 -0
- package/dist/maintenance.d.ts +33 -0
- package/dist/maintenance.js +52 -0
- package/dist/mcp/origin-guard.d.ts +67 -0
- package/dist/mcp/origin-guard.js +137 -0
- package/dist/mcp/server.d.ts +116 -0
- package/dist/mcp/server.js +919 -0
- package/dist/paths.d.ts +1 -0
- package/dist/paths.js +4 -0
- package/dist/remote-llm.d.ts +52 -0
- package/dist/remote-llm.js +464 -0
- package/dist/search/cjk-analyzer.d.ts +33 -0
- package/dist/search/cjk-analyzer.js +158 -0
- package/dist/search/cjk-index.d.ts +104 -0
- package/dist/search/cjk-index.js +1031 -0
- package/dist/search/jieba-loader.d.ts +23 -0
- package/dist/search/jieba-loader.js +79 -0
- package/dist/search/query-expansion.d.ts +23 -0
- package/dist/search/query-expansion.js +43 -0
- package/dist/search/zh-dict.txt +624013 -0
- package/dist/store.d.ts +1218 -0
- package/dist/store.js +6076 -0
- package/dist/trust.d.ts +152 -0
- package/dist/trust.js +249 -0
- package/package.json +139 -0
- package/scripts/build.mjs +83 -0
- package/scripts/check-package-grammars.mjs +29 -0
- package/scripts/package-smoke.mjs +205 -0
- package/scripts/sync-zh-dict.mjs +187 -0
- package/scripts/test-all.mjs +45 -0
- package/skills/qmd/SKILL.md +324 -0
- package/skills/qmd/references/mcp-setup.md +119 -0
- package/skills/release/SKILL.md +141 -0
- package/skills/release/scripts/install-hooks.sh +38 -0
- package/skills/release/scripts/release-context.sh +129 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import {
|
|
4
|
+
existsSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
mkdtempSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
rmSync,
|
|
9
|
+
statSync,
|
|
10
|
+
writeFileSync,
|
|
11
|
+
} from "node:fs";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
14
|
+
|
|
15
|
+
const root = fileURLToPath(new URL("..", import.meta.url));
|
|
16
|
+
const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
|
|
17
|
+
|
|
18
|
+
class PackageSmokeError extends Error {
|
|
19
|
+
constructor(message, exitCode = 1) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.exitCode = exitCode;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function run(label, command, args, options = {}) {
|
|
26
|
+
console.log(`==> ${label}`);
|
|
27
|
+
const { quiet, ...spawnOptions } = options;
|
|
28
|
+
const result = spawnSync(command, args, {
|
|
29
|
+
cwd: root,
|
|
30
|
+
stdio: quiet ? "pipe" : "inherit",
|
|
31
|
+
shell: process.platform === "win32",
|
|
32
|
+
...spawnOptions,
|
|
33
|
+
});
|
|
34
|
+
if (result.status !== 0) {
|
|
35
|
+
if (quiet) {
|
|
36
|
+
if (result.stdout) process.stderr.write(result.stdout);
|
|
37
|
+
if (result.stderr) process.stderr.write(result.stderr);
|
|
38
|
+
}
|
|
39
|
+
throw new PackageSmokeError(`Package smoke failed: ${label}`, result.status ?? 1);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function capture(label, command, args, options = {}) {
|
|
44
|
+
console.log(`==> ${label}`);
|
|
45
|
+
const result = spawnSync(command, args, {
|
|
46
|
+
cwd: root,
|
|
47
|
+
encoding: "utf8",
|
|
48
|
+
shell: process.platform === "win32",
|
|
49
|
+
...options,
|
|
50
|
+
});
|
|
51
|
+
if (result.status !== 0) {
|
|
52
|
+
if (result.stdout) process.stderr.write(result.stdout);
|
|
53
|
+
if (result.stderr) process.stderr.write(result.stderr);
|
|
54
|
+
throw new PackageSmokeError(`Package smoke failed: ${label}`, result.status ?? 1);
|
|
55
|
+
}
|
|
56
|
+
return result.stdout;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function assertPath(path, label = path) {
|
|
60
|
+
const full = join(root, path);
|
|
61
|
+
if (!existsSync(full)) {
|
|
62
|
+
throw new PackageSmokeError(`Package smoke failed: missing ${label} (${path})`);
|
|
63
|
+
}
|
|
64
|
+
return full;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function assertMissing(path, label = path) {
|
|
68
|
+
if (existsSync(join(root, path))) {
|
|
69
|
+
throw new PackageSmokeError(`Package smoke failed: unexpected ${label} (${path})`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function main() {
|
|
74
|
+
run("build compiled package", process.execPath, ["scripts/build.mjs"]);
|
|
75
|
+
run("AST grammar runtime packages", process.execPath, ["scripts/check-package-grammars.mjs"]);
|
|
76
|
+
|
|
77
|
+
for (const entry of pkg.files ?? []) {
|
|
78
|
+
assertPath(entry.replace(/\/$/, ""), `package.json files[] entry ${entry}`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
for (const [name, binPath] of Object.entries(pkg.bin ?? {})) {
|
|
82
|
+
const full = assertPath(binPath, `bin ${name}`);
|
|
83
|
+
const mode = statSync(full).mode;
|
|
84
|
+
if ((mode & 0o111) === 0) {
|
|
85
|
+
throw new PackageSmokeError(`Package smoke failed: bin ${name} is not executable (${binPath})`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
assertPath("dist/index.js", "compiled main export");
|
|
90
|
+
assertPath("dist/index.d.ts", "compiled type export");
|
|
91
|
+
assertPath("dist/cli/qmd.js", "compiled CLI");
|
|
92
|
+
assertPath("dist/search/jieba-loader.js", "compiled jieba capability loader");
|
|
93
|
+
assertPath("dist/search/cjk-analyzer.js", "compiled CJK analyzer");
|
|
94
|
+
assertPath("dist/search/zh-dict.txt", "compiled Chinese dictionary asset");
|
|
95
|
+
assertMissing("dist/search/zh-tw-dictionary.txt", "renamed Chinese dictionary asset");
|
|
96
|
+
assertMissing("dist/search/zh-tw-tech-dictionary.js", "legacy zh-TW dictionary module");
|
|
97
|
+
assertPath("THIRD_PARTY_NOTICES.md", "third-party notices");
|
|
98
|
+
|
|
99
|
+
run("compiled CLI under Node", process.execPath, ["dist/cli/qmd.js", "--help"], { quiet: true });
|
|
100
|
+
run("package wrapper", "sh", ["bin/qmd", "--help"], { quiet: true });
|
|
101
|
+
|
|
102
|
+
if (process.env.QMD_SKIP_BUN_SMOKE === "1") {
|
|
103
|
+
console.log("==> compiled CLI under Bun (skipped by QMD_SKIP_BUN_SMOKE=1)");
|
|
104
|
+
} else {
|
|
105
|
+
run("compiled CLI under Bun", "bun", ["dist/cli/qmd.js", "--help"], { quiet: true });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const packageSmokeRoot = process.env.QMD_PACKAGE_SMOKE_TMPDIR || join(root, ".tmp");
|
|
109
|
+
mkdirSync(packageSmokeRoot, { recursive: true });
|
|
110
|
+
const packageSmokeDir = mkdtempSync(join(packageSmokeRoot, "qmd-package-smoke-"));
|
|
111
|
+
try {
|
|
112
|
+
const packOutput = capture("pack npm tarball", "npm", [
|
|
113
|
+
"pack",
|
|
114
|
+
"--ignore-scripts",
|
|
115
|
+
"--json",
|
|
116
|
+
"--pack-destination",
|
|
117
|
+
packageSmokeDir,
|
|
118
|
+
]);
|
|
119
|
+
const packResult = JSON.parse(packOutput);
|
|
120
|
+
const tarballName = packResult[0]?.filename;
|
|
121
|
+
if (typeof tarballName !== "string") {
|
|
122
|
+
throw new PackageSmokeError("Package smoke failed: npm pack did not report a tarball filename");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const consumerDir = join(packageSmokeDir, "consumer");
|
|
126
|
+
mkdirSync(consumerDir);
|
|
127
|
+
writeFileSync(
|
|
128
|
+
join(consumerDir, "package.json"),
|
|
129
|
+
`${JSON.stringify({ private: true, type: "module" }, null, 2)}\n`,
|
|
130
|
+
);
|
|
131
|
+
run(
|
|
132
|
+
"install packed tarball with Bun",
|
|
133
|
+
"bun",
|
|
134
|
+
["add", "--ignore-scripts", join(packageSmokeDir, tarballName)],
|
|
135
|
+
{ cwd: consumerDir, quiet: true },
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
const installedScopeAndName = pkg.name.split("/");
|
|
139
|
+
const installedRoot = join(consumerDir, "node_modules", ...installedScopeAndName);
|
|
140
|
+
const loaderUrl = pathToFileURL(
|
|
141
|
+
join(installedRoot, "dist", "search", "jieba-loader.js"),
|
|
142
|
+
).href;
|
|
143
|
+
const analyzerUrl = pathToFileURL(
|
|
144
|
+
join(installedRoot, "dist", "search", "cjk-analyzer.js"),
|
|
145
|
+
).href;
|
|
146
|
+
for (const path of [
|
|
147
|
+
"THIRD_PARTY_NOTICES.md",
|
|
148
|
+
"dist/search/zh-dict.txt",
|
|
149
|
+
]) {
|
|
150
|
+
if (!existsSync(join(installedRoot, path))) {
|
|
151
|
+
throw new Error(`Packed package is missing ${path}`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const jiebaSmoke = `
|
|
155
|
+
const { loadJiebaCapability } = await import(process.env.QMD_JIEBA_LOADER_URL);
|
|
156
|
+
const capability = await loadJiebaCapability();
|
|
157
|
+
if (!capability.available) throw new Error(capability.diagnostic.code);
|
|
158
|
+
const actual = capability.cut("我們使用記憶體快取資料", false);
|
|
159
|
+
const expected = ["我們", "使用", "記憶體", "快取", "資料"];
|
|
160
|
+
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
|
161
|
+
throw new Error("Unexpected jieba golden tokens");
|
|
162
|
+
}
|
|
163
|
+
const { analyzeCjk } = await import(process.env.QMD_CJK_ANALYZER_URL);
|
|
164
|
+
const analyzed = await analyzeCjk("相依性雜湊佇列");
|
|
165
|
+
if (analyzed.word !== "相依性 雜湊 佇列") throw new Error("Unexpected analyzer dictionary tokens");
|
|
166
|
+
if (JSON.stringify(capability.cut("CC霜", false)) !== JSON.stringify(["CC霜"])) {
|
|
167
|
+
throw new Error("Bundled Chinese dictionary is incomplete");
|
|
168
|
+
}
|
|
169
|
+
`;
|
|
170
|
+
const smokeEnv = {
|
|
171
|
+
...process.env,
|
|
172
|
+
QMD_JIEBA_LOADER_URL: loaderUrl,
|
|
173
|
+
QMD_CJK_ANALYZER_URL: analyzerUrl,
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
run(
|
|
177
|
+
"packed jieba capability under Node",
|
|
178
|
+
process.execPath,
|
|
179
|
+
["--input-type=module", "--eval", jiebaSmoke],
|
|
180
|
+
{ cwd: consumerDir, env: smokeEnv, quiet: true },
|
|
181
|
+
);
|
|
182
|
+
if (process.env.QMD_SKIP_BUN_SMOKE !== "1") {
|
|
183
|
+
run(
|
|
184
|
+
"packed jieba capability under Bun",
|
|
185
|
+
"bun",
|
|
186
|
+
["--eval", jiebaSmoke],
|
|
187
|
+
{ cwd: consumerDir, env: smokeEnv, quiet: true },
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
} finally {
|
|
191
|
+
rmSync(packageSmokeDir, { recursive: true, force: true });
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
try {
|
|
196
|
+
main();
|
|
197
|
+
} catch (error) {
|
|
198
|
+
if (error instanceof PackageSmokeError) {
|
|
199
|
+
console.error(error.message);
|
|
200
|
+
process.exitCode = error.exitCode;
|
|
201
|
+
} else {
|
|
202
|
+
console.error(error);
|
|
203
|
+
process.exitCode = 1;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
7
|
+
|
|
8
|
+
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
9
|
+
const sourcesPath = join(root, "src", "search", "zh-dict.sources.json");
|
|
10
|
+
const outputPath = join(root, "src", "search", "zh-dict.txt");
|
|
11
|
+
const updatePins = process.argv.includes("--update-pins");
|
|
12
|
+
const require = createRequire(import.meta.url);
|
|
13
|
+
|
|
14
|
+
function fail(message) {
|
|
15
|
+
throw new Error(`Invalid Chinese dictionary source: ${message}`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function readJson(path) {
|
|
19
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function sha256(bytes) {
|
|
23
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function gitBlobSha(bytes) {
|
|
27
|
+
return createHash("sha1")
|
|
28
|
+
.update(Buffer.from(`blob ${bytes.byteLength}\0`))
|
|
29
|
+
.update(bytes)
|
|
30
|
+
.digest("hex");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function compareTerms(left, right) {
|
|
34
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function assertHash(value, label, length) {
|
|
38
|
+
if (typeof value !== "string" || !new RegExp(`^[a-f0-9]{${length}}$`, "u").test(value)) {
|
|
39
|
+
fail(`${label} must be a lowercase hexadecimal hash`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function assertRemoteSource(source, label) {
|
|
44
|
+
if (!source || typeof source !== "object") fail(`${label} is missing`);
|
|
45
|
+
if (typeof source.repository !== "string" || typeof source.branch !== "string"
|
|
46
|
+
|| typeof source.commit !== "string" || typeof source.path !== "string") {
|
|
47
|
+
fail(`${label} has invalid repository metadata`);
|
|
48
|
+
}
|
|
49
|
+
const url = new URL(source.repository);
|
|
50
|
+
if (url.hostname !== "github.com" || url.pathname.split("/").filter(Boolean).length !== 2) {
|
|
51
|
+
fail(`${label} repository must be a canonical GitHub URL`);
|
|
52
|
+
}
|
|
53
|
+
assertHash(source.commit, `${label}.commit`, 40);
|
|
54
|
+
assertHash(source.gitBlobSha, `${label}.gitBlobSha`, 40);
|
|
55
|
+
assertHash(source.sha256, `${label}.sha256`, 64);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function rawUrl(source) {
|
|
59
|
+
const [owner, repository] = new URL(source.repository).pathname.split("/").filter(Boolean);
|
|
60
|
+
return `https://raw.githubusercontent.com/${owner}/${repository}/${source.commit}/${source.path}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function apiUrl(source) {
|
|
64
|
+
const [owner, repository] = new URL(source.repository).pathname.split("/").filter(Boolean);
|
|
65
|
+
return `https://api.github.com/repos/${owner}/${repository}/commits/${encodeURIComponent(source.branch)}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function downloadPinnedSource(source, label) {
|
|
69
|
+
const response = await fetch(rawUrl(source), { redirect: "error" });
|
|
70
|
+
if (!response.ok) throw new Error(`${label} download failed with HTTP ${response.status}.`);
|
|
71
|
+
const bytes = Buffer.from(await response.arrayBuffer());
|
|
72
|
+
if (sha256(bytes) !== source.sha256 || gitBlobSha(bytes) !== source.gitBlobSha) {
|
|
73
|
+
throw new Error(`${label} hash mismatch.`);
|
|
74
|
+
}
|
|
75
|
+
return bytes;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function updateRemotePin(source, label) {
|
|
79
|
+
const response = await fetch(apiUrl(source), {
|
|
80
|
+
headers: { Accept: "application/vnd.github+json" },
|
|
81
|
+
redirect: "error",
|
|
82
|
+
});
|
|
83
|
+
if (!response.ok) throw new Error(`${label} commit lookup failed with HTTP ${response.status}.`);
|
|
84
|
+
const payload = await response.json();
|
|
85
|
+
if (!payload || typeof payload.sha !== "string" || !/^[a-f0-9]{40}$/u.test(payload.sha)) {
|
|
86
|
+
throw new Error(`${label} commit lookup returned an invalid SHA.`);
|
|
87
|
+
}
|
|
88
|
+
source.commit = payload.sha;
|
|
89
|
+
const responseWithNewPin = await fetch(rawUrl(source), { redirect: "error" });
|
|
90
|
+
if (!responseWithNewPin.ok) throw new Error(`${label} download failed with HTTP ${responseWithNewPin.status}.`);
|
|
91
|
+
const bytes = Buffer.from(await responseWithNewPin.arrayBuffer());
|
|
92
|
+
source.sha256 = sha256(bytes);
|
|
93
|
+
source.gitBlobSha = gitBlobSha(bytes);
|
|
94
|
+
return bytes;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function loadNodeRsDictionary(source) {
|
|
98
|
+
if (!source || typeof source !== "object") fail("nodeRs is missing");
|
|
99
|
+
if (typeof source.package !== "string" || typeof source.version !== "string" || typeof source.path !== "string") {
|
|
100
|
+
fail("nodeRs has invalid package metadata");
|
|
101
|
+
}
|
|
102
|
+
assertHash(source.gitBlobSha, "nodeRs.gitBlobSha", 40);
|
|
103
|
+
assertHash(source.sha256, "nodeRs.sha256", 64);
|
|
104
|
+
const packageJsonPath = require.resolve(`${source.package}/package.json`);
|
|
105
|
+
const packageJson = readJson(packageJsonPath);
|
|
106
|
+
if (packageJson.version !== source.version) {
|
|
107
|
+
throw new Error(`Expected ${source.package}@${source.version}, found ${packageJson.version}.`);
|
|
108
|
+
}
|
|
109
|
+
const bytes = readFileSync(join(dirname(packageJsonPath), source.path));
|
|
110
|
+
if (sha256(bytes) !== source.sha256 || gitBlobSha(bytes) !== source.gitBlobSha) {
|
|
111
|
+
throw new Error("@node-rs/jieba dictionary hash mismatch.");
|
|
112
|
+
}
|
|
113
|
+
return bytes;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function parseDictionary(bytes, label) {
|
|
117
|
+
const entries = new Map();
|
|
118
|
+
const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
119
|
+
for (const [index, line] of text.split("\n").entries()) {
|
|
120
|
+
if (!line) continue;
|
|
121
|
+
const match = /^(\S+) (\d+) (\S+)$/u.exec(line);
|
|
122
|
+
if (!match) fail(`${label} line ${index + 1} is not a jieba dictionary entry`);
|
|
123
|
+
const [, term, frequency, tag] = match;
|
|
124
|
+
if (term.normalize("NFC") !== term) fail(`${label} line ${index + 1} is not NFC normalized`);
|
|
125
|
+
entries.set(term, `${term} ${frequency} ${tag}`);
|
|
126
|
+
}
|
|
127
|
+
if (entries.size === 0) fail(`${label} has no dictionary entries`);
|
|
128
|
+
return entries;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function selectTechnicalTerms(bytes, settings) {
|
|
132
|
+
if (!settings || typeof settings !== "object" || !Array.isArray(settings.domains)
|
|
133
|
+
|| !Number.isSafeInteger(settings.frequency) || settings.frequency <= 0
|
|
134
|
+
|| typeof settings.tag !== "string" || !settings.tag || /\s/u.test(settings.tag)) {
|
|
135
|
+
fail("technicalTerms has invalid selection settings");
|
|
136
|
+
}
|
|
137
|
+
const domains = settings.domains;
|
|
138
|
+
if (!domains.every(domain => typeof domain === "string" && domain)) {
|
|
139
|
+
fail("technicalTerms.domains must contain non-empty strings");
|
|
140
|
+
}
|
|
141
|
+
const ruleset = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
142
|
+
if (!Array.isArray(ruleset.spelling_rules)) fail("zhtwMcp ruleset is missing spelling_rules");
|
|
143
|
+
const terms = new Set();
|
|
144
|
+
for (const rule of ruleset.spelling_rules) {
|
|
145
|
+
const domain = /@domain ([^。;]+)/u.exec(rule?.context ?? "")?.[1]?.trim();
|
|
146
|
+
if (
|
|
147
|
+
rule?.type !== "cross_strait"
|
|
148
|
+
|| !domain
|
|
149
|
+
|| !domains.some(candidate => domain.includes(candidate))
|
|
150
|
+
|| !Array.isArray(rule.to)
|
|
151
|
+
) continue;
|
|
152
|
+
for (const term of rule.to) {
|
|
153
|
+
if (typeof term === "string" && term.length >= 2 && !/\s/u.test(term) && term.normalize("NFC") === term) {
|
|
154
|
+
terms.add(term);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return [...terms].sort(compareTerms).map(term => `${term} ${settings.frequency} ${settings.tag}`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const sources = readJson(sourcesPath);
|
|
162
|
+
if (sources.schemaVersion !== 1 || !sources.output || typeof sources.output !== "object") {
|
|
163
|
+
fail("unsupported source schema");
|
|
164
|
+
}
|
|
165
|
+
assertRemoteSource(sources.apclab, "apclab");
|
|
166
|
+
assertRemoteSource(sources.zhtwMcp, "zhtwMcp");
|
|
167
|
+
|
|
168
|
+
const nodeRsBytes = loadNodeRsDictionary(sources.nodeRs);
|
|
169
|
+
const apclabBytes = updatePins
|
|
170
|
+
? await updateRemotePin(sources.apclab, "APCLab dictionary")
|
|
171
|
+
: await downloadPinnedSource(sources.apclab, "APCLab dictionary");
|
|
172
|
+
const zhtwMcpBytes = updatePins
|
|
173
|
+
? await updateRemotePin(sources.zhtwMcp, "zhtw-mcp ruleset")
|
|
174
|
+
: await downloadPinnedSource(sources.zhtwMcp, "zhtw-mcp ruleset");
|
|
175
|
+
|
|
176
|
+
const entries = parseDictionary(nodeRsBytes, "@node-rs/jieba dictionary");
|
|
177
|
+
for (const [term, entry] of parseDictionary(apclabBytes, "APCLab dictionary")) entries.set(term, entry);
|
|
178
|
+
for (const entry of selectTechnicalTerms(zhtwMcpBytes, sources.technicalTerms)) {
|
|
179
|
+
entries.set(entry.split(" ", 1)[0], entry);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const dictionaryText = `${[...entries.keys()].sort(compareTerms).map(term => entries.get(term)).join("\n")}\n`;
|
|
183
|
+
const dictionaryBytes = Buffer.from(dictionaryText, "utf8");
|
|
184
|
+
sources.output.sha256 = sha256(dictionaryBytes);
|
|
185
|
+
writeFileSync(outputPath, dictionaryBytes);
|
|
186
|
+
writeFileSync(sourcesPath, `${JSON.stringify(sources, null, 2)}\n`);
|
|
187
|
+
console.log(`Generated ${entries.size} Chinese dictionary entries at ${outputPath}.`);
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
const root = fileURLToPath(new URL("..", import.meta.url));
|
|
7
|
+
|
|
8
|
+
// Mirror bin/qmd's darwin Metal residency mitigation for test subprocesses.
|
|
9
|
+
// libggml-metal asserts on a non-empty residency set during its static
|
|
10
|
+
// destructor (ggml-org/llama.cpp#22593, fix open as #22595) and dumps a
|
|
11
|
+
// multi-kB backtrace at process exit even when tests pass. The env var must
|
|
12
|
+
// be set BEFORE the subprocess starts because libggml-metal reads it via
|
|
13
|
+
// libc getenv at module-load time. Opt out with QMD_METAL_KEEP_RESIDENCY=1.
|
|
14
|
+
const darwinMetalEnv =
|
|
15
|
+
process.platform === "darwin" && process.env.QMD_METAL_KEEP_RESIDENCY !== "1"
|
|
16
|
+
? { GGML_METAL_NO_RESIDENCY: "1" }
|
|
17
|
+
: {};
|
|
18
|
+
|
|
19
|
+
const cleanEnv = { ...process.env };
|
|
20
|
+
delete cleanEnv.OPENAI_BASE_URL;
|
|
21
|
+
delete cleanEnv.OPENAI_API_KEY;
|
|
22
|
+
delete cleanEnv.QMD_EMBED_MODEL;
|
|
23
|
+
delete cleanEnv.QMD_GENERATE_MODEL;
|
|
24
|
+
delete cleanEnv.QMD_RERANK_MODEL;
|
|
25
|
+
|
|
26
|
+
function run(label, command, args, options = {}) {
|
|
27
|
+
console.log(`==> ${label}`);
|
|
28
|
+
const { env: extraEnv, ...spawnOptions } = options;
|
|
29
|
+
const result = spawnSync(command, args, {
|
|
30
|
+
cwd: root,
|
|
31
|
+
stdio: "inherit",
|
|
32
|
+
shell: process.platform === "win32",
|
|
33
|
+
env: { ...cleanEnv, ...darwinMetalEnv, ...(extraEnv ?? {}) },
|
|
34
|
+
...spawnOptions,
|
|
35
|
+
});
|
|
36
|
+
if (result.status !== 0) {
|
|
37
|
+
console.error(`Test task failed: ${label}`);
|
|
38
|
+
process.exit(result.status ?? 1);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
run("TypeScript build typecheck", process.execPath, [join(root, "node_modules", "typescript", "bin", "tsc"), "-p", "tsconfig.build.json", "--noEmit"]);
|
|
43
|
+
run("Vitest suite under Node", process.execPath, [join(root, "node_modules", "vitest", "vitest.mjs"), "run", "--reporter=verbose", "--testTimeout", "60000", "test/"], { env: { CI: "true" } });
|
|
44
|
+
run("Bun test suite", "bun", ["test", "--timeout", "60000", "--preload", "./src/test-preload.ts", "test/"], { env: { CI: "true" } });
|
|
45
|
+
run("Package smoke", process.execPath, ["scripts/package-smoke.mjs"]);
|