186 lines
8.1 KiB
JavaScript
186 lines
8.1 KiB
JavaScript
#!/usr/bin/env node
|
||
import fs from "node:fs";
|
||
import { fileURLToPath } from "node:url";
|
||
|
||
export const BOOTSTRAP_LIMIT_BYTES = 2048;
|
||
export const READ_NODE_LIMIT_BYTES = 16384;
|
||
|
||
function fail(message) {
|
||
throw new Error(message);
|
||
}
|
||
export function loadTree(file) {
|
||
return JSON.parse(fs.readFileSync(file, "utf8"));
|
||
}
|
||
|
||
export function verifyTree(tree) {
|
||
const errors = [];
|
||
if (tree?.protocol !== "HLDP-v1.0") errors.push("protocol must be HLDP-v1.0");
|
||
if (!tree?.root || typeof tree.root !== "string") errors.push("root must be a non-empty path");
|
||
if (!tree?.nodes || typeof tree.nodes !== "object" || Array.isArray(tree.nodes)) errors.push("nodes must be an object");
|
||
if (errors.length) return errors;
|
||
|
||
if (!tree.nodes[tree.root]) errors.push(`root node not found: ${tree.root}`);
|
||
const required = ["path", "summary", "trigger", "emergence", "lock", "why"];
|
||
for (const [key, node] of Object.entries(tree.nodes)) {
|
||
if (!node || typeof node !== "object") {
|
||
errors.push(`${key}: node must be an object`);
|
||
continue;
|
||
}
|
||
if (node.path !== key) errors.push(`${key}: node.path must equal its address`);
|
||
for (const field of required) {
|
||
if (typeof node[field] !== "string" || !node[field].trim()) errors.push(`${key}: missing ${field}`);
|
||
}
|
||
if (!Array.isArray(node.children)) errors.push(`${key}: children must be an array`);
|
||
else {
|
||
if (node.children.length > 10) errors.push(`${key}: more than 10 children`);
|
||
if (new Set(node.children).size !== node.children.length) errors.push(`${key}: duplicate child path`);
|
||
for (const child of node.children) if (!tree.nodes[child]) errors.push(`${key}: child not found: ${child}`);
|
||
}
|
||
if (!Array.isArray(node.sources) || node.sources.length === 0) errors.push(`${key}: sources must be a non-empty array`);
|
||
if (!Array.isArray(node.rejected)) errors.push(`${key}: rejected must be an array`);
|
||
}
|
||
|
||
const visiting = new Set();
|
||
const visited = new Set();
|
||
function walk(key) {
|
||
if (visiting.has(key)) return errors.push(`${key}: cycle detected`);
|
||
if (visited.has(key) || !tree.nodes[key]) return;
|
||
visiting.add(key);
|
||
for (const child of tree.nodes[key].children ?? []) walk(child);
|
||
visiting.delete(key);
|
||
visited.add(key);
|
||
}
|
||
walk(tree.root);
|
||
for (const key of Object.keys(tree.nodes)) if (!visited.has(key)) errors.push(`${key}: unreachable from root`);
|
||
return errors;
|
||
}
|
||
|
||
export function reviewTree(tree, onlyPath = null) {
|
||
const errors = verifyTree(tree);
|
||
const warnings = [];
|
||
if (errors.length) return { ok: false, errors, warnings, reviewed: 0 };
|
||
|
||
const entries = onlyPath
|
||
? [[onlyPath, tree.nodes[onlyPath]]]
|
||
: Object.entries(tree.nodes);
|
||
if (onlyPath && !tree.nodes[onlyPath]) {
|
||
return { ok: false, errors: [`node not found: ${onlyPath}`], warnings, reviewed: 0 };
|
||
}
|
||
|
||
const transition = /(?:→|->|=>|△|从.+到|经过|转而|改为|纠正|证据|失败|冲突|不足)/u;
|
||
const causal = /(?:因为|所以|否则|为了|避免|防止|导致|依赖|必须|才能|才会|根因|保留|确保|否则会)/u;
|
||
const rejectionReason = /(?::|:|因为|否则|失败|不足|冲突|无法|不能|会|误|丢失|撑爆|缺少)/u;
|
||
|
||
for (const [key, node] of entries) {
|
||
if (node.summary.trim() === node.lock.trim()) errors.push(`${key}: summary cannot replace lock`);
|
||
if (!transition.test(node.emergence)) {
|
||
errors.push(`${key}: emergence lacks a visible evidence/correction/transition chain`);
|
||
}
|
||
if (node.why.trim().length < 4 || !causal.test(node.why)) {
|
||
errors.push(`${key}: why is too generic to restore the causal reason`);
|
||
}
|
||
if (node.rejected.length === 0) {
|
||
warnings.push(`${key}: no rejected route recorded; keep empty only when no alternative was considered`);
|
||
}
|
||
for (const [index, rejected] of node.rejected.entries()) {
|
||
if (typeof rejected !== "string" || !rejected.trim()) {
|
||
errors.push(`${key}: rejected[${index}] must be a non-empty explanation`);
|
||
} else if (!rejectionReason.test(rejected)) {
|
||
warnings.push(`${key}: rejected[${index}] names a route but may not explain why it was rejected`);
|
||
}
|
||
}
|
||
for (const [index, source] of node.sources.entries()) {
|
||
if (typeof source !== "string" || !source.trim()) {
|
||
errors.push(`${key}: sources[${index}] must be a non-empty address`);
|
||
} else if (!/(?:\/|https?:\/\/|source:\/\/|[0-9a-f]{40,64})/u.test(source)) {
|
||
warnings.push(`${key}: sources[${index}] may not be a precise retrievable address`);
|
||
}
|
||
}
|
||
}
|
||
|
||
return { ok: errors.length === 0, errors, warnings, reviewed: entries.length };
|
||
}
|
||
|
||
function terms(query) {
|
||
const raw = query.toLowerCase().match(/[\p{L}\p{N}_-]+/gu) ?? [];
|
||
const expanded = [];
|
||
for (const token of raw) {
|
||
expanded.push(token);
|
||
const characters = [...token];
|
||
if (/\p{Script=Han}/u.test(token) && characters.length > 1) {
|
||
for (let i = 0; i < characters.length - 1; i += 1) {
|
||
expanded.push(characters.slice(i, i + 2).join(""));
|
||
}
|
||
}
|
||
}
|
||
return [...new Set(expanded)];
|
||
}
|
||
|
||
export function route(tree, query, max = 3, from = tree.root) {
|
||
const current = tree.nodes[from];
|
||
if (!current) fail(`node not found: ${from}`);
|
||
const needles = terms(query);
|
||
return current.children
|
||
.map((childPath, index) => {
|
||
const child = tree.nodes[childPath];
|
||
const haystack = `${childPath} ${child?.summary ?? ""}`.toLowerCase();
|
||
const score = needles.reduce((sum, term) => sum + (haystack.includes(term) ? 1 : 0), 0);
|
||
return { path: childPath, summary: child?.summary ?? "", score, order: index };
|
||
})
|
||
.sort((a, b) => b.score - a.score || a.order - b.order)
|
||
.slice(0, Math.max(1, Math.min(3, Number(max) || 3)))
|
||
.map(({ order: _order, ...candidate }) => candidate);
|
||
}
|
||
|
||
export function readNode(tree, nodePath, limit = READ_NODE_LIMIT_BYTES) {
|
||
const node = tree.nodes[nodePath];
|
||
if (!node) fail(`node not found: ${nodePath}`);
|
||
const encoded = JSON.stringify(node, null, 2);
|
||
if (Buffer.byteLength(encoded) > limit) fail(`node exceeds ${limit} byte read limit`);
|
||
return node;
|
||
}
|
||
|
||
export function bootstrap(protocolRoot) {
|
||
const message = [
|
||
"HLDP v1 单对话恢复:只把协议根页作为导航,不自动加载记忆正文。",
|
||
`根页:${protocolRoot}`,
|
||
"边界:人格主体 != Codex宿主 != 运行系统 != 任务角色 != 执行授权。",
|
||
"方法:先看根节点摘要;按当前问题一次下钻一层;仅证据缺口时读原文。",
|
||
"保留:trigger/emergence/lock/why、转折与否决原因、失效条件、sources。",
|
||
"禁止:整读胶囊/日志;每消息或工具调用注入;恢复失败阻断用户输入。"
|
||
].join("\n");
|
||
if (Buffer.byteLength(message) > BOOTSTRAP_LIMIT_BYTES) fail("bootstrap exceeds hard byte limit");
|
||
return message;
|
||
}
|
||
|
||
function parseArgs(argv) {
|
||
const [command, ...rest] = argv;
|
||
const options = {};
|
||
for (let i = 0; i < rest.length; i += 2) options[rest[i]?.replace(/^--/, "")] = rest[i + 1];
|
||
return { command, options };
|
||
}
|
||
|
||
export function main(argv = process.argv.slice(2)) {
|
||
const { command, options } = parseArgs(argv);
|
||
if (command === "bootstrap") {
|
||
console.log(bootstrap(options["protocol-root"] ?? "hldp/HLDP-RUNTIME-ROOT-v1.0.hdlp"));
|
||
return;
|
||
}
|
||
if (!options.tree) fail("--tree is required");
|
||
const tree = loadTree(options.tree);
|
||
const errors = verifyTree(tree);
|
||
if (errors.length) fail(errors.join("\n"));
|
||
if (command === "verify") console.log(JSON.stringify({ ok: true, nodes: Object.keys(tree.nodes).length }));
|
||
else if (command === "review") {
|
||
const review = reviewTree(tree, options.path ?? null);
|
||
console.log(JSON.stringify(review, null, 2));
|
||
if (!review.ok) process.exitCode = 1;
|
||
}
|
||
else if (command === "route") console.log(JSON.stringify(route(tree, options.query ?? "", options.max, options.from), null, 2));
|
||
else if (command === "read-node") console.log(JSON.stringify(readNode(tree, options.path), null, 2));
|
||
else fail("command must be bootstrap, verify, review, route, or read-node");
|
||
}
|
||
|
||
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
||
try { main(); } catch (error) { console.error(error.message); process.exitCode = 1; }
|
||
}
|