@nomadamas/k-skill 0.9.1 → 0.10.0

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.
Files changed (58) hide show
  1. package/bin/k-skill.js +64 -43
  2. package/package.json +2 -2
  3. package/skills/biz-health-check/instruction.md +2 -0
  4. package/skills/company-analysis/instruction.md +68 -0
  5. package/skills/company-analysis/references/source-contract.md +75 -0
  6. package/skills/company-analysis/scripts/dart-rows.js +52 -0
  7. package/skills/company-analysis/scripts/dart-rows.ts +48 -0
  8. package/skills/company-analysis/scripts/evidence.js +567 -0
  9. package/skills/company-analysis/scripts/evidence.ts +520 -0
  10. package/skills/company-analysis/scripts/fetch-public.js +85 -0
  11. package/skills/company-analysis/scripts/fetch-public.ts +75 -0
  12. package/skills/company-analysis/skill.json +6 -0
  13. package/skills/express-bus-booking/scripts/kobus_express_booking.py +46 -18
  14. package/skills/foresttrip-vacancy/scripts/run_foresttrip_vacancy.py +53 -3
  15. package/skills/g2b-order-plan-search/instruction.md +17 -0
  16. package/skills/g2b-sanctioned-supplier/instruction.md +10 -1
  17. package/skills/g2b-sanctioned-supplier/scripts/g2b_sanctioned_supplier.py +8 -4
  18. package/skills/g2b-sanctioned-supplier/scripts/test_g2b_sanctioned_supplier.py +19 -0
  19. package/skills/government-bond-analysis/instruction.md +216 -0
  20. package/skills/government-bond-analysis/references/methodology.md +63 -0
  21. package/skills/government-bond-analysis/scripts/cli.mjs +268 -0
  22. package/skills/government-bond-analysis/scripts/collect.mjs +171 -0
  23. package/skills/government-bond-analysis/scripts/core.mjs +224 -0
  24. package/skills/government-bond-analysis/scripts/dual.mjs +178 -0
  25. package/skills/government-bond-analysis/scripts/harness.mjs +406 -0
  26. package/skills/government-bond-analysis/scripts/modes.mjs +136 -0
  27. package/skills/government-bond-analysis/scripts/news.mjs +113 -0
  28. package/skills/government-bond-analysis/scripts/render.mjs +120 -0
  29. package/skills/government-bond-analysis/scripts/schedule.mjs +96 -0
  30. package/skills/government-bond-analysis/scripts/ust.mjs +204 -0
  31. package/skills/government-bond-analysis/skill.json +8 -0
  32. package/skills/korean-bond-search/instruction.md +122 -0
  33. package/skills/korean-bond-search/references/fields.md +48 -0
  34. package/skills/korean-bond-search/references/source-notes.md +11 -0
  35. package/skills/korean-bond-search/scripts/bond_search.py +906 -0
  36. package/skills/korean-bond-search/skill.json +9 -0
  37. package/skills/market-event-impact/instruction.md +150 -0
  38. package/skills/market-event-impact/references/event-taxonomy.md +74 -0
  39. package/skills/market-event-impact/references/methodology.md +98 -0
  40. package/skills/market-event-impact/references/research-contract.md +120 -0
  41. package/skills/market-event-impact/references/source-priority.md +25 -0
  42. package/skills/market-event-impact/scripts/adapters.py +32 -0
  43. package/skills/market-event-impact/scripts/asset_selector.py +120 -0
  44. package/skills/market-event-impact/scripts/calendars.py +179 -0
  45. package/skills/market-event-impact/scripts/event_resolver.py +260 -0
  46. package/skills/market-event-impact/scripts/event_study.py +171 -0
  47. package/skills/market-event-impact/scripts/market_data.py +204 -0
  48. package/skills/market-event-impact/scripts/market_event.py +1065 -0
  49. package/skills/market-event-impact/scripts/research.py +389 -0
  50. package/skills/market-event-impact/skill.json +9 -0
  51. package/skills/multi-asset-morning-briefing/instruction.md +117 -0
  52. package/skills/multi-asset-morning-briefing/references/format-rules.md +236 -0
  53. package/skills/multi-asset-morning-briefing/scripts/market_data.py +1003 -0
  54. package/skills/multi-asset-morning-briefing/skill.json +9 -0
  55. package/skills/store-longevity-radar/scripts/__pycache__/store_longevity_download.cpython-312.pyc +0 -0
  56. package/src/analytics-policy.json +18 -0
  57. package/src/analytics.js +106 -0
  58. package/src/metrics.js +49 -0
@@ -0,0 +1,567 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ // Offline evidence gate and deterministic chat renderer. No runtime packages or credentials.
5
+ const node_crypto_1 = require("node:crypto");
6
+ const node_fs_1 = require("node:fs");
7
+ const node_path_1 = require("node:path");
8
+ const dart_rows_1 = require("./dart-rows");
9
+ const sections = [
10
+ ["company_snapshot", "요약"],
11
+ ["business", "주요 사업 · 산업 · 경쟁구도"],
12
+ ["recent", "최근 사업 현황"],
13
+ ["financials", "재무제표 분석"],
14
+ ["earnings", "실적발표 분석"],
15
+ ["catalyst_risk", "Catalyst & Risk"],
16
+ ];
17
+ const statements = [
18
+ ["income", "손익계산서"],
19
+ ["balance_sheet", "재무상태표"],
20
+ ["cash_flow", "현금흐름표"],
21
+ ];
22
+ const MAX_JSON = 2_000_000;
23
+ const MAX_SOURCE = 20_000_000;
24
+ const SHA = /^[0-9a-f]{64}$/;
25
+ const ID = /^[a-z][a-z0-9_-]{0,63}$/;
26
+ const DATE = /^\d{4}-\d{2}-\d{2}$/;
27
+ const SECRET_KEYS = /^(?:api[_-]?key|service[_-]?key|crtfc[_-]?key|auth(?:orization|[_-]?key)?|access[_-]?token|key|secret|token|password|passwd|session(?:id|[_-]?key)?)$/i;
28
+ function fail(message) {
29
+ throw new Error(message);
30
+ }
31
+ function requiredText(value, label, max = 500) {
32
+ if (typeof value !== "string" || !value.trim() || value.length > max || /[\u0000-\u001f]/.test(value)) {
33
+ fail(`${label}: non-empty single-line text required`);
34
+ }
35
+ return value.trim();
36
+ }
37
+ function day(value, label) {
38
+ const text = requiredText(value, label, 10);
39
+ const parsed = new Date(`${text}T00:00:00Z`);
40
+ if (!DATE.test(text) || Number.isNaN(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== text) {
41
+ fail(`${label}: canonical YYYY-MM-DD required`);
42
+ }
43
+ return text;
44
+ }
45
+ function instant(value, label) {
46
+ const text = requiredText(value, label, 24);
47
+ if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(text) ||
48
+ !Number.isFinite(Date.parse(text)) || new Date(text).toISOString() !== text) {
49
+ fail(`${label}: canonical UTC timestamp required`);
50
+ }
51
+ return text;
52
+ }
53
+ function localDay(value, zone) {
54
+ const parts = new Intl.DateTimeFormat("en-US", { timeZone: zone, year: "numeric", month: "2-digit", day: "2-digit" })
55
+ .formatToParts(new Date(value));
56
+ const field = (key) => parts.find((part) => part.type === key)?.value;
57
+ return `${field("year")}-${field("month")}-${field("day")}`;
58
+ }
59
+ function sourceDate(value, label = "source.published_date_quote") {
60
+ const quoted = requiredText(value, label, 60);
61
+ if (DATE.test(quoted))
62
+ return day(quoted, label);
63
+ const dotted = /^(\d{4})\.(\d{1,2})\.(\d{1,2})\.?$/.exec(quoted);
64
+ if (dotted)
65
+ return day(`${dotted[1]}-${dotted[2].padStart(2, "0")}-${dotted[3].padStart(2, "0")}`, label);
66
+ const english = /^(January|February|March|April|May|June|July|August|September|October|November|December|Jan\.?|Feb\.?|Mar\.?|Apr\.?|Jun\.?|Jul\.?|Aug\.?|Sep\.?|Sept\.?|Oct\.?|Nov\.?|Dec\.?) (\d{1,2}), (\d{4})$/.exec(quoted);
67
+ if (english) {
68
+ const month = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"].findIndex((name) => name === english[1] || name.slice(0, 3) === english[1].replace(".", "").slice(0, 3)) + 1;
69
+ return day(`${english[3]}-${String(month).padStart(2, "0")}-${english[2].padStart(2, "0")}`, label);
70
+ }
71
+ const dayFirst = /^(\d{1,2}) (January|February|March|April|May|June|July|August|September|October|November|December) (\d{4})$/.exec(quoted);
72
+ if (dayFirst) {
73
+ const month = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"].indexOf(dayFirst[2]) + 1;
74
+ return day(`${dayFirst[3]}-${String(month).padStart(2, "0")}-${dayFirst[1].padStart(2, "0")}`, label);
75
+ }
76
+ const korean = /^(\d{4})년\s*(\d{1,2})월\s*(\d{1,2})일$/.exec(quoted);
77
+ if (korean)
78
+ return day(`${korean[1]}-${korean[2].padStart(2, "0")}-${korean[3].padStart(2, "0")}`, label);
79
+ fail(`${label}: unsupported date format`);
80
+ }
81
+ function compactRange(phrase, label) {
82
+ const years = [...phrase.matchAll(/\b(?:19|20)\d{2}\b/g)].map((match) => match[0]);
83
+ const uniqueYears = [...new Set(years)];
84
+ const matches = [...phrase.matchAll(/\b(January|February|March|April|May|June|July|August|September|October|November|December) (\d{1,2})\s*[-–]\s*(\d{1,2})\b/g)];
85
+ if (uniqueYears.length !== 1 || matches.length !== 1)
86
+ fail(`${label}: one explicit year and one month-day range required`);
87
+ const match = matches[0];
88
+ const month = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"].indexOf(match[1]) + 1;
89
+ const start = day(`${uniqueYears[0]}-${String(month).padStart(2, "0")}-${match[2].padStart(2, "0")}`, label);
90
+ const end = day(`${uniqueYears[0]}-${String(month).padStart(2, "0")}-${match[3].padStart(2, "0")}`, label);
91
+ if (end <= start)
92
+ fail(`${label}: invalid date range`);
93
+ return [start, end];
94
+ }
95
+ function dartChildParent(child, parent) {
96
+ const childUrl = new URL(child.url), parentUrl = new URL(parent.url);
97
+ if (childUrl.hostname !== "dart.fss.or.kr" || parentUrl.hostname !== "dart.fss.or.kr" ||
98
+ childUrl.pathname !== "/report/viewer.do" || parentUrl.pathname !== "/dsaf001/main.do" ||
99
+ [...parentUrl.searchParams].length !== 1 || !parentUrl.searchParams.get("rcpNo") ||
100
+ [...childUrl.searchParams].length !== 6)
101
+ return false;
102
+ const keys = ["rcpNo", "dcmNo", "eleId", "offset", "length", "dtd"];
103
+ if (keys.some((key) => !childUrl.searchParams.get(key)) ||
104
+ childUrl.searchParams.get("rcpNo") !== parentUrl.searchParams.get("rcpNo"))
105
+ return false;
106
+ const blocks = parent.text.split(/var node\d+\s*=\s*\{\s*\};/);
107
+ return blocks.some((block) => keys.every((key) => new RegExp(`node\\d+\\['${key}'\\]\\s*=\\s*"${childUrl.searchParams.get(key).replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"`).test(block)));
108
+ }
109
+ function shortDay(value, asOf) {
110
+ const [year, month, date] = value.split("-");
111
+ return `${year === asOf.slice(0, 4) ? "" : `${year}년 `}${Number(month)}/${Number(date)}`;
112
+ }
113
+ function containsTicker(text, ticker) {
114
+ const escaped = ticker.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
115
+ return new RegExp(`(?:^|[^A-Za-z0-9])${escaped}(?:$|[^A-Za-z0-9])`, "i").test(text);
116
+ }
117
+ function url(value) {
118
+ const text = requiredText(value, "source URL", 2048);
119
+ let parsed;
120
+ try {
121
+ parsed = new URL(text);
122
+ }
123
+ catch {
124
+ return fail("source URL: invalid");
125
+ }
126
+ if (parsed.protocol !== "https:" || !parsed.hostname || parsed.username || parsed.password || parsed.hash ||
127
+ (parsed.port && parsed.port !== "443") || /[\s<>"'\\]/.test(text) ||
128
+ parsed.hostname === "localhost" || parsed.hostname.endsWith(".local") ||
129
+ /^\d+(?:\.\d+){3}$/.test(parsed.hostname) || parsed.hostname.includes(":") ||
130
+ [...parsed.searchParams.keys()].some((key) => SECRET_KEYS.test(key))) {
131
+ fail("source URL: exact public HTTPS URL without credentials, fragment or secret query required");
132
+ }
133
+ return text;
134
+ }
135
+ function fileBytes(root, name, expected) {
136
+ const rel = requiredText(name, "source file", 240);
137
+ if ((0, node_path_1.isAbsolute)(rel) || rel.includes("\\") || rel.split("/").includes(".."))
138
+ fail("source file: relative path required");
139
+ const full = (0, node_path_1.resolve)(root, rel);
140
+ if ((0, node_path_1.relative)(root, full).startsWith(".."))
141
+ fail("source file: outside snapshot");
142
+ const realRoot = (0, node_fs_1.realpathSync)(root);
143
+ const realFile = (0, node_fs_1.realpathSync)(full);
144
+ if ((0, node_path_1.relative)(realRoot, realFile).startsWith(".."))
145
+ fail("source file: symlink escapes snapshot");
146
+ const info = (0, node_fs_1.lstatSync)(full);
147
+ if (!info.isFile() || info.isSymbolicLink() || info.size > MAX_SOURCE)
148
+ fail("source file: invalid or oversized");
149
+ const raw = (0, node_fs_1.readFileSync)(full);
150
+ if (raw.length > MAX_SOURCE)
151
+ fail("source file: grew beyond size limit");
152
+ if (typeof expected !== "string" || !SHA.test(expected) || (0, node_crypto_1.createHash)("sha256").update(raw).digest("hex") !== expected) {
153
+ fail(`source file: SHA-256 mismatch (${(0, node_path_1.basename)(full)})`);
154
+ }
155
+ return raw;
156
+ }
157
+ function normalize(value) {
158
+ return value.replace(/\s+/g, " ").trim();
159
+ }
160
+ function excerpt(value, label) {
161
+ if (typeof value !== "string" || !value.trim() || value.length > 1000 || /[\u0000-\u0008\u000b\u000c\u000e-\u001f]/.test(value)) {
162
+ fail(`${label}: non-empty text excerpt required`);
163
+ }
164
+ return normalize(value);
165
+ }
166
+ function numbers(value) {
167
+ if ([...value.matchAll(/\p{Nd}/gu)].some((match) => !/[0-9]/.test(match[0]))) {
168
+ fail("numeric text: non-ASCII digits are ambiguous");
169
+ }
170
+ return [...value.matchAll(/[+\-−]?\d[\d,]*(?:\.\d+)?%?/g)].map((match) => {
171
+ const token = match[0];
172
+ if (token.includes(",") && !/^[+\-−]?\d{1,3}(?:,\d{3})+(?:\.\d+)?%?$/.test(token)) {
173
+ fail("numeric text: invalid thousands grouping");
174
+ }
175
+ return token.replaceAll(",", "").replace("−", "-");
176
+ });
177
+ }
178
+ function clean(value) {
179
+ return value.replaceAll("\\", "\\\\").replace(/[|<>\[\]*_`]/g, "\\$&");
180
+ }
181
+ function strictJson(raw) {
182
+ if (raw.subarray(0, 3).equals(Buffer.from([0xef, 0xbb, 0xbf])))
183
+ fail("JSON: UTF-8 BOM not allowed");
184
+ const text = new TextDecoder("utf-8", { fatal: true }).decode(raw);
185
+ const stack = [];
186
+ for (let i = 0; i < text.length; i++) {
187
+ const char = text[i];
188
+ if (char === '"') {
189
+ const start = i;
190
+ i++;
191
+ for (; i < text.length; i++) {
192
+ if (text[i] === "\\") {
193
+ i++;
194
+ continue;
195
+ }
196
+ if (text[i] === '"')
197
+ break;
198
+ }
199
+ if (i >= text.length)
200
+ fail("JSON: unterminated string");
201
+ const top = stack.at(-1);
202
+ if (top?.kind === "object" && top.expectingKey) {
203
+ const key = JSON.parse(text.slice(start, i + 1));
204
+ const normalized = key.normalize("NFC").toLowerCase();
205
+ if (top.keys.has(normalized))
206
+ fail(`JSON: duplicate or case-colliding key ${key}`);
207
+ top.keys.add(normalized);
208
+ top.expectingKey = false;
209
+ }
210
+ continue;
211
+ }
212
+ if (char === "{")
213
+ stack.push({ kind: "object", keys: new Set(), expectingKey: true });
214
+ else if (char === "[")
215
+ stack.push({ kind: "array", keys: new Set(), expectingKey: false });
216
+ else if (char === "}" || char === "]")
217
+ stack.pop();
218
+ else if (char === "," && stack.at(-1)?.kind === "object")
219
+ stack.at(-1).expectingKey = true;
220
+ }
221
+ return JSON.parse(text);
222
+ }
223
+ function priceLine(data, sourceMap, names, ticker, market) {
224
+ if (data.price === undefined)
225
+ return "- 조회 시점 주가: 확인 불가 (검증된 공개 시세 없음)";
226
+ if (!data.price || typeof data.price !== "object")
227
+ fail("price: object required");
228
+ const source = sourceMap.get(requiredText(data.price.source_id, "price.source_id", 64));
229
+ if (!source)
230
+ fail("price: unknown source");
231
+ const invokedAt = instant(data.invoked_at, "invoked_at");
232
+ const retrievedAt = instant(source.retrievedAt, "price capture.retrieved_at");
233
+ const delay = Date.parse(retrievedAt) - Date.parse(invokedAt);
234
+ if (delay < 0 || delay > 600_000)
235
+ fail("price: capture must be within 10 minutes after invocation");
236
+ if (source.publishedAt)
237
+ fail("price: live quote source must not have a filing publication date");
238
+ const endpoint = new URL(source.url);
239
+ const document = strictJson(source.raw);
240
+ if (!document || typeof document !== "object" || Array.isArray(document))
241
+ fail("price: JSON object required");
242
+ let detail;
243
+ if (data.price.provider === "nasdaq") {
244
+ if (!/^NASDAQ(?:-|$)/i.test(market) || endpoint.hostname !== "api.nasdaq.com" ||
245
+ endpoint.pathname !== `/api/quote/${encodeURIComponent(ticker)}/info` ||
246
+ endpoint.searchParams.get("assetclass") !== "stocks" || [...endpoint.searchParams].length !== 1) {
247
+ fail("price: Nasdaq quote endpoint or market mismatch");
248
+ }
249
+ const stock = document.data;
250
+ const quote = stock?.primaryData;
251
+ if (!stock || !quote || stock.symbol !== ticker || stock.isNasdaqListed !== true ||
252
+ !names.some((name) => requiredText(stock.companyName, "price company", 160).toLowerCase().includes(name.toLowerCase()))) {
253
+ fail("price: Nasdaq company or symbol mismatch");
254
+ }
255
+ const value = requiredText(quote.lastSalePrice, "price value", 30);
256
+ if (!/^\$(?:\d+|\d{1,3}(?:,\d{3})+)(?:\.\d{1,4})?$/.test(value) || Number(value.slice(1).replaceAll(",", "")) <= 0) {
257
+ fail("price: invalid Nasdaq last sale");
258
+ }
259
+ const marketTime = requiredText(quote.lastTradeTimestamp, "price market time", 80);
260
+ const status = requiredText(stock.marketStatus, "price market status", 60);
261
+ const timed = /^(?:Closed at )?([A-Z][a-z]{2}) (\d{1,2}), (\d{4}) (\d{1,2}):(\d{2}) (AM|PM) ET$/.exec(marketTime);
262
+ const dated = /^([A-Z][a-z]{2}) (\d{1,2}), (\d{4})$/.exec(marketTime);
263
+ const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
264
+ const match = timed ?? dated;
265
+ if (!match || !months.includes(match[1]) || (dated && status !== "Closed") ||
266
+ (timed && (Number(timed[4]) < 1 || Number(timed[4]) > 12 || Number(timed[5]) > 59))) {
267
+ fail("price: invalid Nasdaq market time");
268
+ }
269
+ const quoteDay = day(`${match[3]}-${String(months.indexOf(match[1]) + 1).padStart(2, "0")}-${match[2].padStart(2, "0")}`, "price market day");
270
+ const exchangeDay = localDay(retrievedAt, "America/New_York");
271
+ const age = Date.parse(`${exchangeDay}T00:00:00Z`) - Date.parse(`${quoteDay}T00:00:00Z`);
272
+ if (age < 0 || age > 7 * 86_400_000)
273
+ fail("price: Nasdaq quote date is future or stale");
274
+ detail = `${value} USD · ${clean(status)} · ${timed ? "거래시각" : "거래일"} ${clean(marketTime)}${timed ? "" : " (시각 미제공)"}`;
275
+ }
276
+ else if (data.price.provider === "naver-kr") {
277
+ if (!/^(?:KRX|KOSPI|KOSDAQ)$/i.test(market) || endpoint.hostname !== "m.stock.naver.com" ||
278
+ endpoint.pathname !== `/api/stock/${encodeURIComponent(ticker)}/basic` || endpoint.search) {
279
+ fail("price: Naver KR quote endpoint or market mismatch");
280
+ }
281
+ if (document.itemCode !== ticker)
282
+ fail("price: Naver stock code mismatch");
283
+ const listedName = requiredText(document.stockName, "price stock name", 120);
284
+ const value = requiredText(document.closePrice, "price value", 30);
285
+ if (!/^(?:\d+|\d{1,3}(?:,\d{3})+)(?:\.\d+)?$/.test(value) || Number(value.replaceAll(",", "")) <= 0) {
286
+ fail("price: invalid Naver displayed price");
287
+ }
288
+ const tradedAt = requiredText(document.localTradedAt, "price market time", 40);
289
+ if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?[+-]\d{2}:\d{2}$/.test(tradedAt) ||
290
+ !tradedAt.endsWith("+09:00") || !Number.isFinite(Date.parse(tradedAt)))
291
+ fail("price: invalid Naver market time");
292
+ day(tradedAt.slice(0, 10), "price market day");
293
+ const quoteAge = Date.parse(retrievedAt) - Date.parse(tradedAt);
294
+ if (quoteAge < -300_000 || quoteAge > 7 * 86_400_000)
295
+ fail("price: Naver quote time is future or stale");
296
+ const status = requiredText(document.marketStatus, "price market status", 40);
297
+ const session = requiredText(document.marketSessionType, "price session", 40);
298
+ detail = `${value} KRW · ${clean(listedName)} · ${clean(status)}/${clean(session)} · 거래표시 ${clean(tradedAt)}`;
299
+ }
300
+ else
301
+ fail("price: unsupported provider");
302
+ return `- 조회 시점 표시가: ${detail} · 수집 ${retrievedAt} [시세 출처](<${source.url}>)`;
303
+ }
304
+ function render(path) {
305
+ const inputPath = (0, node_path_1.resolve)(path);
306
+ const rawJson = (0, node_fs_1.readFileSync)(inputPath);
307
+ if (rawJson.length > MAX_JSON)
308
+ fail("input JSON: oversized");
309
+ const data = strictJson(rawJson);
310
+ if (!data || typeof data !== "object")
311
+ fail("input JSON: object required");
312
+ const asOf = day(data.as_of, "as_of");
313
+ const company = data.company;
314
+ if (!company || typeof company !== "object")
315
+ fail("company: object required");
316
+ const name = requiredText(company.name, "company.name", 120);
317
+ const aliases = company.aliases === undefined ? [] : company.aliases;
318
+ if (!Array.isArray(aliases) || aliases.length > 3)
319
+ fail("company.aliases: at most three aliases required");
320
+ const names = [name, ...aliases.map((alias) => requiredText(alias, "company.alias", 120))];
321
+ if (new Set(names.map((item) => item.toLowerCase())).size !== names.length ||
322
+ names.some((item) => item.length < 3))
323
+ fail("company aliases: distinct names of at least three characters required");
324
+ const identifiesCompany = (text) => names.some((item) => text.toLowerCase().includes(item.toLowerCase()));
325
+ const ticker = requiredText(company.ticker, "company.ticker", 32);
326
+ const market = requiredText(company.market, "company.market", 80);
327
+ const identitySourceId = requiredText(company.evidence_source_id, "company.evidence_source_id", 64);
328
+ if (!Array.isArray(data.sources) || data.sources.length > 30)
329
+ fail("sources: array of at most 30 required");
330
+ if (!data.sections || typeof data.sections !== "object" || Array.isArray(data.sections))
331
+ fail("sections: object required");
332
+ if (data.schedule !== undefined && (!Array.isArray(data.schedule) || data.schedule.length > 20)) {
333
+ fail("schedule: array of at most 20 required");
334
+ }
335
+ const sectionKeys = new Set(sections.map(([key]) => key));
336
+ if (Object.keys(data.sections).some((key) => !sectionKeys.has(key)))
337
+ fail("sections: unknown category");
338
+ const root = (0, node_path_1.dirname)(inputPath);
339
+ const sourceMap = new Map();
340
+ for (const source of data.sources) {
341
+ if (!source || typeof source !== "object" || !ID.test(source.id) || sourceMap.has(source.id))
342
+ fail("source id: canonical and unique required");
343
+ const link = url(source.url);
344
+ const publishedAt = source.published_at === undefined ? undefined : day(source.published_at, "source.published_at");
345
+ if (publishedAt && publishedAt > asOf)
346
+ fail(`${source.id}: source published after as_of`);
347
+ const original = fileBytes(root, source.file, source.sha256);
348
+ const captureBytes = fileBytes(root, source.capture_file, source.capture_sha256);
349
+ const capture = strictJson(captureBytes);
350
+ if (capture.url !== link || capture.sha256 !== source.sha256 || capture.bytes !== original.length) {
351
+ fail(`${source.id}: capture metadata differs from source`);
352
+ }
353
+ let textBytes = original;
354
+ if (source.text_file !== undefined || source.text_sha256 !== undefined) {
355
+ if (!source.text_file || !source.text_sha256)
356
+ fail(`${source.id}: text file and hash must be paired`);
357
+ textBytes = fileBytes(root, source.text_file, source.text_sha256);
358
+ const parsed = new URL(link);
359
+ if (parsed.hostname === "dart.fss.or.kr" && parsed.pathname === "/report/viewer.do" &&
360
+ !textBytes.equals(Buffer.from((0, dart_rows_1.extractDartRows)(original)))) {
361
+ fail(`${source.id}: DART text extraction differs from captured HTML`);
362
+ }
363
+ }
364
+ const text = new TextDecoder("utf-8", { fatal: true }).decode(textBytes);
365
+ if (publishedAt) {
366
+ const quote = requiredText(source.published_date_quote, "source.published_date_quote", 60);
367
+ if (!normalize(text).includes(quote) || sourceDate(quote) !== publishedAt) {
368
+ fail(`${source.id}: publication date is not supported by saved source`);
369
+ }
370
+ }
371
+ else if (source.published_date_quote !== undefined)
372
+ fail(`${source.id}: date quote without publication date`);
373
+ sourceMap.set(source.id, { url: link, text: normalize(text), raw: original, publishedAt, retrievedAt: capture.retrieved_at });
374
+ }
375
+ const identitySource = sourceMap.get(identitySourceId);
376
+ if (!identitySource || names.some((item) => !identitySource.text.toLowerCase().includes(item.toLowerCase())) ||
377
+ !containsTicker(identitySource.text, ticker))
378
+ fail("company identity: name/ticker absent from source");
379
+ for (const source of data.sources) {
380
+ if (source.filing_index_source_id === undefined)
381
+ continue;
382
+ const child = sourceMap.get(source.id);
383
+ const parentId = requiredText(source.filing_index_source_id, `${source.id}.filing_index_source_id`, 64);
384
+ const parent = sourceMap.get(parentId);
385
+ if (!parent || parentId === source.id || source.published_at !== undefined ||
386
+ !parent.publishedAt || !identifiesCompany(parent.text) || !dartChildParent(child, parent)) {
387
+ fail(`${source.id}: DART child is not bound to dated issuer filing and exact table of contents`);
388
+ }
389
+ child.publishedAt = parent.publishedAt;
390
+ }
391
+ const schedule = (data.schedule ?? []).map((event, index) => {
392
+ const label = `schedule[${index}]`;
393
+ if (!event || typeof event !== "object" || !["date", "range", "from"].includes(event.kind))
394
+ fail(`${label}: valid kind required`);
395
+ const start = day(event.start, `${label}.start`);
396
+ const end = event.kind === "range" ? day(event.end, `${label}.end`) : undefined;
397
+ if ((event.kind !== "range" && (event.end !== undefined || event.end_date_quote !== undefined)) ||
398
+ (end && (end < start || end < asOf)) || (!end && start < asOf))
399
+ fail(`${label}: inconsistent or past date`);
400
+ const dateQuote = requiredText(event.date_quote, `${label}.date_quote`, 240);
401
+ const endQuote = end === undefined || event.end_date_quote === undefined ? undefined : requiredText(event.end_date_quote, `${label}.end_date_quote`, 60);
402
+ if (end && !endQuote) {
403
+ const dates = compactRange(dateQuote, `${label}.date_quote`);
404
+ if (dates[0] !== start || dates[1] !== end)
405
+ fail(`${label}: range differs from source phrase`);
406
+ }
407
+ else {
408
+ if (sourceDate(dateQuote, `${label}.date_quote`) !== start)
409
+ fail(`${label}: start date differs from source phrase`);
410
+ if (endQuote && sourceDate(endQuote, `${label}.end_date_quote`) !== end)
411
+ fail(`${label}: end date differs from source phrase`);
412
+ }
413
+ if (end && end === start)
414
+ fail(`${label}: one-day event needs kind date`);
415
+ const labelText = requiredText(event.text, `${label}.text`, 240);
416
+ if (numbers(labelText).length)
417
+ fail(`${label}: put dates in start/end; event text must not contain numbers`);
418
+ if (!Array.isArray(event.evidence) || event.evidence.length < 1 || event.evidence.length > 4)
419
+ fail(`${label}: 1–4 evidence excerpts required`);
420
+ let dateSupported = false;
421
+ const links = [];
422
+ const sourceIds = [];
423
+ for (const item of event.evidence) {
424
+ if (!item || typeof item !== "object")
425
+ fail(`${label}: invalid evidence`);
426
+ const source = sourceMap.get(item.source_id);
427
+ if (!source || !source.publishedAt)
428
+ fail(`${label}: dated source required`);
429
+ if (!identifiesCompany(source.text) && !data.sources.find((entry) => entry.id === item.source_id)?.filing_index_source_id)
430
+ fail(`${label}: evidence source does not identify the company`);
431
+ const quote = excerpt(item.quote, `${label}.quote`);
432
+ if (!source.text.includes(quote))
433
+ fail(`${label}: excerpt absent from saved source`);
434
+ if (quote.includes(dateQuote) && (!endQuote || quote.includes(endQuote)))
435
+ dateSupported = true;
436
+ if (!links.includes(source.url))
437
+ links.push(source.url);
438
+ sourceIds.push(item.source_id);
439
+ }
440
+ if (!dateSupported)
441
+ fail(`${label}: date phrase absent from one evidence excerpt`);
442
+ return { kind: event.kind, start, end, text: labelText, links, sourceIds };
443
+ });
444
+ const eventKeys = schedule.map((event) => `${event.kind}\u0000${event.start}\u0000${event.end ?? ""}\u0000${event.text}`);
445
+ if (new Set(eventKeys).size !== eventKeys.length)
446
+ fail("schedule: duplicate event");
447
+ schedule.sort((a, b) => a.start.localeCompare(b.start) || (a.end ?? "").localeCompare(b.end ?? "") || a.text.localeCompare(b.text));
448
+ const totalClaims = sections.reduce((count, [key]) => count + (Array.isArray(data.sections[key]) ? data.sections[key].length : 0), 0);
449
+ if (totalClaims === 0 && schedule.length === 0)
450
+ fail("analysis: at least one sourced claim or schedule event required");
451
+ const lines = [`# ${clean(name)} (${clean(ticker)} · ${clean(market)}) — ${asOf}`];
452
+ const financialClaims = data.sections.financials ?? [];
453
+ const missingStatements = statements.filter(([id]) => !Array.isArray(financialClaims) ||
454
+ !financialClaims.some((claim) => claim && claim.statement === id)).map(([, label]) => label);
455
+ if (missingStatements.length)
456
+ lines.push("", `> 간이 분석 — 검증된 ${missingStatements.join("·")} 자료 없음`);
457
+ const used = new Set();
458
+ const stockPrice = priceLine(data, sourceMap, names, ticker, market);
459
+ if (data.price)
460
+ used.add(data.price.source_id);
461
+ for (const [key, title] of sections) {
462
+ const claims = data.sections[key] ?? [];
463
+ if (!Array.isArray(claims) || claims.length > 20)
464
+ fail(`${key}: array of at most 20 required`);
465
+ lines.push("", `## ${title}`, "");
466
+ if (key === "company_snapshot")
467
+ lines.push(stockPrice);
468
+ if (key !== "financials" && key !== "catalyst_risk" && claims.length === 0) {
469
+ if (key !== "company_snapshot")
470
+ lines.push("확인된 내용 없음");
471
+ continue;
472
+ }
473
+ if (key === "financials" && claims.some((claim) => !claim || typeof claim !== "object" || !statements.some(([id]) => id === claim.statement))) {
474
+ fail("financials: each claim needs a valid statement (income, balance_sheet, cash_flow)");
475
+ }
476
+ const renderClaim = (claim) => {
477
+ if (!claim || typeof claim !== "object")
478
+ fail(`${key}: claim object required`);
479
+ if (key !== "financials" && claim.statement !== undefined)
480
+ fail(`${key}: statement only belongs in financials`);
481
+ const statement = requiredText(claim.text, `${key}.text`, 600);
482
+ if (!Array.isArray(claim.evidence) || claim.evidence.length < 1 || claim.evidence.length > 4)
483
+ fail(`${key}: 1–4 evidence excerpts required`);
484
+ const quoted = [];
485
+ const links = [];
486
+ for (const item of claim.evidence) {
487
+ if (!item || typeof item !== "object")
488
+ fail(`${key}: invalid evidence`);
489
+ const source = sourceMap.get(item.source_id);
490
+ if (!source)
491
+ fail(`${key}: unknown source id`);
492
+ if (!identifiesCompany(source.text) && !data.sources.find((entry) => entry.id === item.source_id)?.filing_index_source_id) {
493
+ fail(`${key}: evidence source does not identify the company`);
494
+ }
495
+ const quote = excerpt(item.quote, `${key}.quote`);
496
+ if (!source.text.includes(quote))
497
+ fail(`${key}: excerpt absent from saved source ${item.source_id}`);
498
+ quoted.push(quote);
499
+ used.add(item.source_id);
500
+ if (!links.includes(source.url))
501
+ links.push(source.url);
502
+ }
503
+ const tokens = numbers(statement);
504
+ if (tokens.length) {
505
+ if (claim.evidence.some((item) => !sourceMap.get(item.source_id)?.publishedAt)) {
506
+ fail(`${key}: numeric evidence requires a dated source`);
507
+ }
508
+ for (const field of ["period", "unit", "scope"])
509
+ requiredText(claim[field], `${key}.${field}`, 100);
510
+ if (!quoted.some((quote) => quote.includes(normalize(statement)))) {
511
+ fail(`${key}: numeric statement must occur verbatim in one evidence excerpt`);
512
+ }
513
+ const supported = new Set(quoted.flatMap(numbers));
514
+ if (tokens.some((token) => !supported.has(token)))
515
+ fail(`${key}: number absent from evidence excerpt`);
516
+ }
517
+ const detail = tokens.length ? ` (${clean(claim.period)} · ${clean(claim.unit)} · ${clean(claim.scope)})` : "";
518
+ lines.push(`- ${clean(statement)}${detail} ${links.map((link, index) => `[출처 ${index + 1}](<${link}>)`).join(" ")}`);
519
+ };
520
+ if (key === "financials") {
521
+ for (const [id, label] of statements) {
522
+ lines.push(`### ${label}`, "");
523
+ const related = claims.filter((claim) => claim.statement === id);
524
+ if (related.length === 0)
525
+ lines.push("확인 불가 (검증된 자료 없음)");
526
+ else
527
+ related.forEach(renderClaim);
528
+ lines.push("");
529
+ }
530
+ lines.pop();
531
+ }
532
+ else {
533
+ claims.forEach(renderClaim);
534
+ }
535
+ if (key === "catalyst_risk") {
536
+ if (claims.length === 0)
537
+ lines.push("확인된 내용 없음");
538
+ lines.push("", "### 주요 일정", "");
539
+ if (schedule.length === 0)
540
+ lines.push("확정된 일정 없음");
541
+ for (const event of schedule) {
542
+ const dateText = shortDay(event.start, asOf) + (event.kind === "range" ? `~${shortDay(event.end, asOf)}` : event.kind === "from" ? "~" : "");
543
+ lines.push(`- ${dateText} · ${clean(event.text)} ${event.links.map((link, index) => `[출처 ${index + 1}](<${link}>)`).join(" ")}`);
544
+ event.sourceIds.forEach((id) => used.add(id));
545
+ }
546
+ }
547
+ }
548
+ lines.push("", "## 출처", "");
549
+ if (used.size === 0)
550
+ lines.push("확인된 출처 없음");
551
+ for (const id of used) {
552
+ const source = data.sources.find((item) => item.id === id);
553
+ lines.push(`- [${clean(id)}](<${source.url}>) · 발표 ${sourceMap.get(id)?.publishedAt ?? "미상"} · SHA-256 ${source.sha256}`);
554
+ }
555
+ return lines.join("\n") + "\n";
556
+ }
557
+ try {
558
+ if (process.argv.length !== 3)
559
+ fail("usage: node scripts/evidence.js SNAPSHOT.json");
560
+ const output = render(process.argv[2]);
561
+ process.stdout.write(output);
562
+ }
563
+ catch (error) {
564
+ const message = error instanceof Error ? error.message : "unknown error";
565
+ process.stderr.write(`EVIDENCE_ERROR: ${message}\n`);
566
+ process.exitCode = 1;
567
+ }