Files
Arcrun/registry/examples/km-wiki-ingest/lib/dry-run.mjs
T
Leo d93dc4e350 feat(km-wiki-ingest): parse_card 改用通用 code 零件;刪 domain 零件 km_wiki_card_parse
Arcrun#10 裁定:一次性解析走通用逃生口,不再鑄 domain 零件。

- workflow.yaml:parse_card 由 component:km_wiki_card_parse -> component:code,
  config.code 內聯 card-to-envelope 的 planCard 邏輯(去 import/export、raw NUL
  分隔符改 u0000 escape、改用 code 沙箱注入的 sha256);下游 refs 改 parse_card.data.*;
  加 limits(timeout_ms/max_output_bytes)。已端到端驗證(YAML 解析->JS eval)與原
  planCard 輸出逐欄全等(含 content_hash)。
- 刪 registry/examples/km-wiki-ingest/component-contract.yaml(km_wiki_card_parse 契約)。
- lib/card-to-envelope.mjs:header 改述為「code 節點內聯 JS 的權威來源 + 參考實作」,
  邏輯不變(續為 inline JS 之單一真相源)。
- lib/dry-run.mjs / description.md:框架改述為 code-節點形態;部署清單更新為「部署通用
  code 零件」。dry-run-evidence.json(3 entries/15 triplets/16 nodes)不變——解析輸出等價。

不部署、不寫 live。留分支可部署狀態。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJiLCRUU2o3aSpPEzVCt2o
2026-07-06 04:50:08 +00:00

182 lines
8.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
// km-wiki-ingest 乾跑(dry-run)驗證器 — 不寫 live、不部署。
// -------------------------------------------------------------
// 註:解析在 live 由 workflow.yaml 的 parse_card = 通用 `code` 零件(sandbox inline JS)承載;
// 本驗證器直接 import card-to-envelope.mjs(=該 code 節點內聯 JS 的權威來源)跑同一份邏輯,
// 故 dry-run 的 envelope 結果與 code 節點在 live 的輸出等價(Arcrun#10 已單測證明逐欄全等)。
// 對某 repo 的 system-dev/wiki/cards/**/*.md 跑機械解析 + envelope 打包,
// 輸出「將寫入什麼」:① entry 清單(page_name / entry_type / metadata.embed / content_hash
// ② triplet envelope 清單(每段的 nodes / triplets / source.uri+anchor
// ③ 每個 graph /triplets/ingest 呼叫的 subrequest 估算(證明壓在 CF 上限下)
// ④ 冪等鍵設計(entry=page_name+content_hashtriplet=source.uri+content_hash)。
//
// 用法:node dry-run.mjs --repo-path <clone路徑> [--repo Leo/notes] [--budget 40] [--json] [--full]
// --self-test 額外跑「合成超大卡」證明分段生效。
import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
import { planCard, estimateEnvelopeSubrequests, SUBREQ_CEILING, SUBREQ_BUDGET } from './card-to-envelope.mjs';
function parseArgs(argv) {
const out = { repo: 'Leo/notes', budget: SUBREQ_BUDGET };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--repo-path') out.repoPath = argv[++i];
else if (a === '--repo') out.repo = argv[++i];
else if (a === '--budget') out.budget = Number(argv[++i]);
else if (a === '--json') out.json = true;
else if (a === '--full') out.full = true;
else if (a === '--self-test') out.selfTest = true;
}
return out;
}
function walkCards(dir) {
const out = [];
if (!existsSync(dir)) return out;
for (const e of readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, e.name);
if (e.isDirectory()) out.push(...walkCards(p));
else if (e.name.endsWith('.md') && e.name !== '.gitkeep' && !e.name.startsWith('00-INDEX')) out.push(p);
}
return out;
}
function gitCommit(repoPath) {
try {
return execFileSync('git', ['-C', repoPath, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim();
} catch { return undefined; }
}
const args = parseArgs(process.argv.slice(2));
if (!args.repoPath) {
console.error('用法: node dry-run.mjs --repo-path <clone路徑> [--repo Leo/notes] [--budget 40] [--json] [--full] [--self-test]');
process.exit(1);
}
const cardsRoot = path.join(args.repoPath, 'system-dev', 'wiki', 'cards');
const cardPaths = walkCards(cardsRoot);
const commit = gitCommit(args.repoPath);
const report = { repo: args.repo, commit, budget: args.budget, ceiling: SUBREQ_CEILING, cards: [], totals: {} };
let totEntries = 0, totEnvelopes = 0, totTriplets = 0, totNodes = 0, maxSub = 0, over = 0;
for (const p of cardPaths) {
const rel = path.relative(args.repoPath, p);
const md = readFileSync(p, 'utf8');
const plan = planCard(md, rel, args.repo, { budget: args.budget, commit });
totEntries++;
totEnvelopes += plan.envelopes.length;
const cardTriplets = plan.envelopes.reduce((s, e) => s + e.triplets.length, 0);
const cardNodes = plan.envelopes.reduce((s, e) => s + e.nodes.length, 0);
totTriplets += cardTriplets;
totNodes += cardNodes;
for (const e of plan.envelopes) {
maxSub = Math.max(maxSub, e._estSubrequests);
if (e._estSubrequests > SUBREQ_CEILING) over++;
}
report.cards.push({
relPath: rel,
canonical: plan.meta.canonical,
entry: {
page_name: plan.entry.page_name,
entry_type: plan.entry.entry_type,
'metadata.embed': plan.entry.metadata.embed,
content_hash: plan.entry.metadata.content_hash.slice(0, 12) + '…',
content_bytes: Buffer.byteLength(plan.entry.content, 'utf8'),
tags: plan.entry.tags,
},
envelopeCount: plan.envelopes.length,
envelopes: plan.envelopes.map((e) => ({
'source.uri': e.source.uri,
'source.anchor': e.source.anchor ?? null,
nodes: e.nodes.length,
triplets: e.triplets.length,
est_subrequests: e._estSubrequests,
under_ceiling: e._estSubrequests <= SUBREQ_CEILING,
sample_triplets: e.triplets.slice(0, args.full ? 999 : 4).map((t) => `${t.subject} >> ${t.predicate} >> ${t.object} (${t.confidence})`),
sample_nodes: e.nodes.slice(0, args.full ? 999 : 4).map((n) => `${n.name}${n.gloss ? ' — ' + n.gloss.slice(0, 30) + '…' : ''}`),
})),
});
}
report.totals = {
cards: totEntries,
entries_to_upsert: totEntries,
triplet_envelopes: totEnvelopes,
total_triplets: totTriplets,
total_nodes: totNodes,
max_est_subrequests_single_call: maxSub,
ceiling: SUBREQ_CEILING,
budget: args.budget,
any_envelope_over_ceiling: over,
};
// --- self-test:合成一張「超大卡」(20 邊 + 22 節點)證明單 envelope 會爆、分段後每段壓在預算下 ---
if (args.selfTest) {
const entities = [];
const edges = [];
for (let i = 0; i < 22; i++) entities.push(`- **實體${i}**(別名${i})— 這是實體 ${i} 的一句描述。`);
for (let i = 0; i < 20; i++) edges.push(`- 實體${i} >> 關聯到 >> 實體${i + 1}`);
const bigCard = `---\ntags: [壓測]\ngloss: 合成超大卡,測分段。\n---\n# 合成超大卡\n\n← [[notes/00-INDEX]]\n\n## 實體\n${entities.join('\n')}\n\n## 關聯\n### 內文知識關係\n${edges.join('\n')}\n`;
const plan = planCard(bigCard, 'system-dev/wiki/cards/notes/合成超大卡.md', args.repo, { budget: args.budget });
const single = estimateEnvelopeSubrequests(plan.tripletCount, plan.nodeCount);
report.self_test = {
note: '合成 20 邊 / 22 節點 的超大卡',
if_single_envelope_est_subrequests: single,
would_crash_single: single > SUBREQ_CEILING,
segmented_into: plan.envelopes.length,
per_segment: plan.envelopes.map((e) => ({
uri: e.source.uri, anchor: e.source.anchor, triplets: e.triplets.length, nodes: e.nodes.length, est_subrequests: e._estSubrequests, under_ceiling: e._estSubrequests <= SUBREQ_CEILING,
})),
all_segments_under_ceiling: plan.envelopes.every((e) => e._estSubrequests <= SUBREQ_CEILING),
};
}
if (args.json) {
console.log(JSON.stringify(report, null, 2));
process.exit(0);
}
// --- 人類可讀輸出 ---
const L = (s = '') => console.log(s);
L(`\n================ km-wiki-ingest DRY-RUN(不寫 live================`);
L(`repo=${report.repo} commit=${(commit || '(none)').slice(0, 12)} budget=${args.budget} CF_ceiling=${SUBREQ_CEILING}`);
L(`卡片來源根:${path.relative(args.repoPath, cardsRoot)} 找到 ${cardPaths.length} 張卡\n`);
for (const c of report.cards) {
L(`── 卡片:${c.relPath}`);
L(` ENTRYbase POST /entries 或 kbdb_upsert_block,冪等鍵 page_name):`);
L(` page_name = ${c.entry.page_name}`);
L(` entry_type = ${c.entry.entry_type}`);
L(` metadata.embed= ${c.entry['metadata.embed']} content_hash=${c.entry.content_hash} bytes=${c.entry.content_bytes}`);
L(` tags = ${JSON.stringify(c.entry.tags)}`);
L(` TRIPLET ENVELOPE(s)POST graph /triplets/ingest;分段數=${c.envelopeCount}):`);
for (const e of c.envelopes) {
L(` • uri=${e['source.uri']}${e['source.anchor'] ? ' anchor=' + e['source.anchor'] : ''}`);
L(` nodes=${e.nodes} triplets=${e.triplets} est_subrequests=${e.est_subrequests} ≤ceiling? ${e.under_ceiling ? 'YES' : 'NO ⚠️'}`);
for (const t of e.sample_triplets) L(` - ${t}`);
if (e.sample_nodes.length) L(` nodes: ${e.sample_nodes.join(' | ')}`);
}
L('');
}
L(`================ 彙總 ================`);
for (const [k, v] of Object.entries(report.totals)) L(` ${k.padEnd(34)} = ${v}`);
L(` 冪等設計:`);
L(` entry → page_name(穩定鍵)+ metadata.content_hash(比對是否改動 → 未改 skip、改動 PATCH 重嵌)`);
L(` triplet → source.uri + source.content_hashgraph 現役 per-source 冪等;同 hash 整包 no-op`);
L(` 分段 → 各段獨立 source.uri(#segNN)→ 各自獨立冪等,繞開 per-source content_hash 整包 skip`);
if (report.self_test) {
L(`\n================ SELF-TEST:超大檔分段 ================`);
const st = report.self_test;
L(` ${st.note}`);
L(` 若不分段(單 envelope)估算 subrequest = ${st.if_single_envelope_est_subrequests} → 會炸? ${st.would_crash_single ? 'YES> ' + SUBREQ_CEILING + '' : 'no'}`);
L(` 分段後段數 = ${st.segmented_into},每段:`);
for (const s of st.per_segment) L(` - ${s.anchor}: triplets=${s.triplets} nodes=${s.nodes} est=${s.est_subrequests} ≤ceiling? ${s.under_ceiling ? 'YES' : 'NO ⚠️'}`);
L(` 全部段壓在上限下? ${st.all_segments_under_ceiling ? 'YES ✅' : 'NO ⚠️'}`);
}
L('');