k-skill/scripts/skill-docs.test.js
Jeffrey (Dongkyu) Kim fed0973979 Pin the delivery examples to exact schema mappings
The current delivery-tracking follow-up already describes a shared
normalized schema, but the regression test still allowed drift in the
actual field expressions. This change upgrades the docs regression to
exact key/value allowlists and aligns the 우체국 feature example with
named summary groups so the published examples stay in sync.

Constraint: Follow-up had to stay docs-first on feature/#4 / PR #12
Constraint: The published CJ and 우체국 examples must remain privacy-safe while sharing one exact schema contract
Rejected: Keep key-only checks in the regression suite | still allows silent expression drift in published examples
Rejected: Refactor the docs examples into separate reusable scripts | too broad for this bounded follow-up
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: If the delivery examples change again, update both the printed object mappings and the exact allowlist regression in the same diff
Tested: node --test scripts/skill-docs.test.js
Tested: npm run ci
Not-tested: Live carrier requests were not needed for this schema-lock follow-up because behavior remained documentation-only and prior live verification already covered the transport paths
2026-03-27 02:06:27 +09:00

331 lines
14 KiB
JavaScript

const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const repoRoot = path.join(__dirname, "..");
function read(relativePath) {
return fs.readFileSync(path.join(repoRoot, relativePath), "utf8");
}
function extractQuotedEntries(block, indent) {
return block
.split("\n")
.map((line) => line.match(new RegExp(`^ {${indent}}"([^"]+)":\\s*(.+?)(?:,)?$`)))
.filter(Boolean)
.map(([, key, value]) => [key, value.trim()]);
}
function findPrintedObjectBlock(doc, carrier) {
const block = [...doc.matchAll(/print\(json\.dumps\(\{\n([\s\S]*?)\n\}, ensure_ascii=False, indent=2\)\)/g)]
.map((match) => match[1])
.find((candidate) => candidate.includes(`"carrier": "${carrier}"`));
assert.ok(block, `expected ${carrier} normalized JSON example`);
return block;
}
function findRecentEventsBlock(doc, carrier) {
const block = [...doc.matchAll(/normalized_events = \[\n\s*\{\n([\s\S]*?)\n\s*\}\n\s*for [^\n]+ in events\n\]/g)]
.map((match) => match[1])
.find((candidate) => candidate.includes('"status_code":') === (carrier === "cj"));
assert.ok(block, `expected ${carrier} recent_events example`);
return block;
}
test("root npm test script includes the skill docs regression suite", () => {
const packageJson = JSON.parse(read("package.json"));
assert.match(packageJson.scripts.test, /node --test scripts\/skill-docs\.test\.js/);
});
test("hwp skill documents environment-aware routing and supported operations", () => {
const skillPath = path.join(repoRoot, "hwp", "SKILL.md");
assert.ok(fs.existsSync(skillPath), "expected hwp/SKILL.md to exist");
const skill = read(path.join("hwp", "SKILL.md"));
assert.match(skill, /^name: hwp$/m);
assert.match(skill, /@ohah\/hwpjs/);
assert.match(skill, /\bhwp-mcp\b/);
assert.match(skill, /Windows/i);
assert.match(skill, /JSON/i);
assert.match(skill, /Markdown/i);
assert.match(skill, /HTML/i);
assert.match(skill, /image/i);
assert.match(skill, /batch/i);
});
test("hwp skill documents inline image verification for markdown output", () => {
const skill = read(path.join("hwp", "SKILL.md"));
const featureDoc = read(path.join("docs", "features", "hwp.md"));
assert.match(skill, /hwpjs to-markdown document\.hwp -o output\.md --include-images/);
assert.match(skill, /Markdown:.*(data:|base64)/);
assert.match(skill, /--images-dir/);
assert.doesNotMatch(skill, /Markdown:.*이미지 경로 생성 여부 확인/);
assert.match(featureDoc, /--images-dir/);
});
test("repository docs advertise the hwp skill", () => {
const readme = read("README.md");
const install = read(path.join("docs", "install.md"));
const featureDocPath = path.join(repoRoot, "docs", "features", "hwp.md");
const featureDoc = read(path.join("docs", "features", "hwp.md"));
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/hwp.md to exist");
assert.match(readme, /\| HWP 문서 처리 \|/);
assert.match(readme, /\[HWP 문서 처리\]\(docs\/features\/hwp\.md\)/);
assert.match(install, /--skill hwp/);
assert.match(featureDoc, /--include-images/);
assert.match(featureDoc, /(data:|base64)/);
assert.match(featureDoc, /Markdown 출력.*(data:|base64)/);
assert.doesNotMatch(featureDoc, /Markdown 출력.*이미지 (파일 )?경로 생성 여부 확인/);
});
test("repository docs advertise the kakaotalk-mac skill", () => {
const readme = read("README.md");
const install = read(path.join("docs", "install.md"));
const featureDocPath = path.join(repoRoot, "docs", "features", "kakaotalk-mac.md");
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/kakaotalk-mac.md to exist");
assert.match(readme, /\| 카카오톡 Mac CLI \|/);
assert.match(readme, /\[카카오톡 Mac CLI\]\(docs\/features\/kakaotalk-mac\.md\)/);
assert.match(install, /--skill kakaotalk-mac/);
});
test("kakaotalk-mac skill documents safe macOS kakaocli usage", () => {
const skillPath = path.join(repoRoot, "kakaotalk-mac", "SKILL.md");
assert.ok(fs.existsSync(skillPath), "expected kakaotalk-mac/SKILL.md to exist");
const skill = read(path.join("kakaotalk-mac", "SKILL.md"));
assert.match(skill, /^name: kakaotalk-mac$/m);
assert.match(skill, /kakaocli/);
assert.match(skill, /macOS/i);
assert.match(skill, /KakaoTalk/i);
assert.match(skill, /Full Disk Access/i);
assert.match(skill, /Accessibility/i);
assert.match(skill, /--me/);
assert.match(skill, /confirm before sending/i);
});
test("repository docs advertise the zipcode-search skill across the documented surfaces", () => {
const readme = read("README.md");
const install = read(path.join("docs", "install.md"));
const roadmap = read(path.join("docs", "roadmap.md"));
const sources = read(path.join("docs", "sources.md"));
const featureDocPath = path.join(repoRoot, "docs", "features", "zipcode-search.md");
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/zipcode-search.md to exist");
assert.match(readme, /\| 우편번호 검색 \|/);
assert.match(readme, /\[우편번호 검색 가이드\]\(docs\/features\/zipcode-search\.md\)/);
assert.match(install, /--skill zipcode-search/);
assert.match(roadmap, /우편번호 검색/);
assert.match(sources, /우체국 도로명주소 검색: https:\/\/parcel\.epost\.go\.kr\/parcel\/comm\/zipcode\/comm_newzipcd_list\.jsp/);
});
test("zipcode-search docs lock the official ePost extraction flow and reliable transport example", () => {
const skillPath = path.join(repoRoot, "zipcode-search", "SKILL.md");
assert.ok(fs.existsSync(skillPath), "expected zipcode-search/SKILL.md to exist");
const skill = read(path.join("zipcode-search", "SKILL.md"));
const featureDoc = read(path.join("docs", "features", "zipcode-search.md"));
assert.match(skill, /^name: zipcode-search$/m);
for (const doc of [skill, featureDoc]) {
assert.match(doc, /parcel\.epost\.go\.kr\/parcel\/comm\/zipcode\/comm_newzipcd_list\.jsp/);
assert.match(doc, /sch_zipcode/);
assert.match(doc, /sch_address1/);
assert.match(doc, /sch_bdNm/);
assert.match(doc, /curl --http1\.1 --tls-max 1\.2/);
assert.match(doc, /--max-time/);
assert.match(doc, /"--retry",\s+"3"/);
assert.match(doc, /--retry-all-errors/);
assert.match(doc, /"--retry-delay",\s+"1"/);
assert.match(doc, /mktemp|임시 파일/);
assert.match(doc, /curl: \(23\)/);
assert.match(doc, /짧은 도로명 \+ 건물번호/);
assert.match(doc, /시\/군\/구 포함 전체 주소/);
assert.doesNotMatch(doc, /urllib\.request/);
assert.doesNotMatch(doc, /urlopen/);
}
assert.match(skill, /검색 결과가 없으면/i);
assert.doesNotMatch(skill, /timeout\s*=/);
assert.doesNotMatch(featureDoc, /timeout\s*=/);
assert.match(skill, /`curl` 자체 제한/);
assert.match(featureDoc, /프로토콜\/클라이언트 제약/i);
assert.match(featureDoc, /`curl` 자체 제한/);
});
test("repository docs advertise the delivery-tracking skill across the documented surfaces", () => {
const readme = read("README.md");
const install = read(path.join("docs", "install.md"));
const roadmap = read(path.join("docs", "roadmap.md"));
const sources = read(path.join("docs", "sources.md"));
const featureDocPath = path.join(repoRoot, "docs", "features", "delivery-tracking.md");
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/delivery-tracking.md to exist");
assert.match(readme, /\| 택배 배송조회 \|/);
assert.match(readme, /\[택배 배송조회 가이드\]\(docs\/features\/delivery-tracking\.md\)/);
assert.match(install, /--skill delivery-tracking/);
assert.match(roadmap, /택배 배송조회 스킬 출시/);
assert.match(sources, /CJ대한통운 배송조회: https:\/\/www\.cjlogistics\.com\/ko\/tool\/parcel\/tracking/);
assert.match(sources, /우체국 배송조회: https:\/\/service\.epost\.go\.kr\/trace\.RetrieveRegiPrclDeliv\.postal\?sid1=/);
});
test("delivery-tracking skill documents official CJ and ePost flows with extension guidance", () => {
const skillPath = path.join(repoRoot, "delivery-tracking", "SKILL.md");
assert.ok(fs.existsSync(skillPath), "expected delivery-tracking/SKILL.md to exist");
const skill = read(path.join("delivery-tracking", "SKILL.md"));
const featureDoc = read(path.join("docs", "features", "delivery-tracking.md"));
assert.match(skill, /^name: delivery-tracking$/m);
for (const doc of [skill, featureDoc]) {
assert.match(doc, /https:\/\/www\.cjlogistics\.com\/ko\/tool\/parcel\/tracking/);
assert.match(doc, /tracking-detail/);
assert.match(doc, /paramInvcNo/);
assert.match(doc, /_csrf/);
assert.match(doc, /10자리 또는 12자리/);
assert.match(doc, /https:\/\/service\.epost\.go\.kr\/trace\.RetrieveRegiPrclDeliv\.postal\?sid1=/);
assert.match(doc, /trace\.RetrieveDomRigiTraceList\.comm/);
assert.match(doc, /sid1/);
assert.match(doc, /13자리/);
assert.match(doc, /curl --http1\.1 --tls-max 1\.2/);
assert.match(doc, /carrier adapter/i);
assert.match(doc, /다른 택배사/);
}
assert.match(skill, /1234567890/);
assert.match(skill, /1234567890123/);
assert.match(skill, /python3/);
assert.match(featureDoc, /JSON/);
assert.match(featureDoc, /HTML/);
});
test("delivery-tracking published examples lock a shared normalized non-PII schema", () => {
const skill = read(path.join("delivery-tracking", "SKILL.md"));
const featureDoc = read(path.join("docs", "features", "delivery-tracking.md"));
const expectedTopLevelEntries = {
cj: [
["carrier", '"cj"'],
["invoice", 'payload["parcelDetailResultMap"]["paramInvcNo"]'],
["status_code", 'latest.get("crgSt")'],
["status", 'status_map.get(latest.get("crgSt"), latest.get("scanNm") or "알수없음")'],
["timestamp", 'latest.get("dTime")'],
["location", 'latest.get("regBranNm")'],
["event_count", "len(events)"],
["recent_events", "normalized_events[-min(3, len(normalized_events)):]"],
],
epost: [
["carrier", '"epost"'],
["invoice", 'clean(summary.group("tracking"))'],
["status", 'clean(summary.group("result"))'],
["timestamp", 'latest_event["timestamp"] if latest_event else None'],
["location", 'latest_event["location"] if latest_event else None'],
["event_count", "len(normalized_events)"],
["recent_events", "normalized_events[-min(3, len(normalized_events)):]"],
],
};
const expectedRecentEventEntries = {
cj: [
["timestamp", 'event.get("dTime")'],
["location", 'event.get("regBranNm")'],
["status_code", 'event.get("crgSt")'],
["status", 'status_map.get(event.get("crgSt"), event.get("scanNm") or "알수없음")'],
],
epost: [
["timestamp", 'f"{day} {time_}"'],
["location", "clean_location(location)"],
["status", "clean(status)"],
],
};
assert.doesNotMatch(skill, /"message":\s*latest\.get\("crgNm"\)/);
assert.doesNotMatch(
featureDoc,
/print\(json\.dumps\(payload\["parcelDetailResultMap"\]\["resultList"\]\[-1\],\s*ensure_ascii=False,\s*indent=2\)\)/,
);
for (const doc of [skill, featureDoc]) {
assert.match(doc, /공통 포맷/);
assert.match(doc, /공통 결과 스키마/);
assert.match(doc, /최근 이벤트/);
assert.match(doc, /`carrier`/);
assert.match(doc, /`invoice`/);
assert.match(doc, /`status`/);
assert.match(doc, /`timestamp`/);
assert.match(doc, /`location`/);
assert.match(doc, /`event_count`/);
assert.match(doc, /`recent_events`/);
assert.match(doc, /최근 최대 3개 이벤트/);
assert.doesNotMatch(doc, /최근 3~5개 이벤트/);
assert.match(doc, /"invoice":\s*payload\["parcelDetailResultMap"\]\["paramInvcNo"\]/);
assert.match(doc, /"status_code":\s*latest\.get\("crgSt"\)/);
assert.match(doc, /"status":\s*status_map\.get\(latest\.get\("crgSt"\),/);
assert.match(doc, /"timestamp":\s*latest\.get\("dTime"\)/);
assert.match(doc, /"location":\s*latest\.get\("regBranNm"\)/);
assert.match(doc, /"event_count":\s*len\(events\)/);
assert.match(doc, /"recent_events":/);
assert.match(doc, /"invoice":\s*clean\(summary\.group/);
assert.match(doc, /"timestamp":\s*latest_event\["timestamp"\] if latest_event else None/);
assert.match(doc, /"location":\s*latest_event\["location"\] if latest_event else None/);
assert.match(doc, /"event_count":\s*len\(normalized_events\)/);
assert.match(doc, /"recent_events":\s*normalized_events\[-min\(3,\s*len\(normalized_events\)\):\]/);
assert.match(doc, /def clean_location\(raw: str\) -> str:/);
assert.match(doc, /TEL/);
assert.match(doc, /\\d\{2,4\}/);
assert.match(doc, /"location":\s*clean_location\(location\)/);
assert.doesNotMatch(doc, /"tracking_no":/);
assert.doesNotMatch(doc, /"latest_event_date":/);
assert.doesNotMatch(doc, /"latest_event_time":/);
assert.doesNotMatch(doc, /"latest_event_location":/);
assert.doesNotMatch(doc, /"delivered_to":/);
assert.doesNotMatch(doc, /"delivery_result":/);
}
for (const [label, doc] of [
["skill doc", skill],
["feature doc", featureDoc],
]) {
assert.deepEqual(
extractQuotedEntries(findPrintedObjectBlock(doc, "cj"), 4),
expectedTopLevelEntries.cj,
`${label} CJ example must keep the exact normalized top-level mapping`,
);
assert.deepEqual(
extractQuotedEntries(findPrintedObjectBlock(doc, "epost"), 4),
expectedTopLevelEntries.epost,
`${label} ePost example must keep the exact normalized top-level mapping`,
);
assert.deepEqual(
extractQuotedEntries(
findRecentEventsBlock(doc, "cj"),
8,
),
expectedRecentEventEntries.cj,
`${label} CJ recent_events entries must keep the exact normalized mapping`,
);
assert.deepEqual(
extractQuotedEntries(
findRecentEventsBlock(doc, "epost"),
8,
),
expectedRecentEventEntries.epost,
`${label} ePost recent_events entries must keep the exact normalized mapping`,
);
}
assert.doesNotMatch(skill, /"message":\s*latest\.get\("crgNm"\)/);
assert.doesNotMatch(featureDoc, /print\(\{\s*"tracking_no"/);
});