project-librarian 0.2.1 → 0.3.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.
package/dist/modes.js CHANGED
@@ -35,6 +35,7 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.buildRefreshIndexBlock = buildRefreshIndexBlock;
37
37
  exports.runQueryMode = runQueryMode;
38
+ exports.runWikiImpactMode = runWikiImpactMode;
38
39
  exports.projectCandidatesContent = projectCandidatesContent;
39
40
  exports.appendCaptureInbox = appendCaptureInbox;
40
41
  exports.runIssueDraftMode = runIssueDraftMode;
@@ -49,6 +50,7 @@ exports.runQualityCheckMode = runQualityCheckMode;
49
50
  exports.runMigrationQualityCheckMode = runMigrationQualityCheckMode;
50
51
  exports.runMigrationLintMode = runMigrationLintMode;
51
52
  exports.runMigrationDoctorMode = runMigrationDoctorMode;
53
+ exports.collectRouterTruthDiagnostics = collectRouterTruthDiagnostics;
52
54
  exports.runDoctorMode = runDoctorMode;
53
55
  exports.runLintMode = runLintMode;
54
56
  const fs = __importStar(require("node:fs"));
@@ -60,6 +62,7 @@ const workspace_1 = require("./workspace");
60
62
  const templates_1 = require("./templates");
61
63
  const migration_1 = require("./migration");
62
64
  const wiki_files_1 = require("./wiki-files");
65
+ const wiki_graph_1 = require("./wiki-graph");
63
66
  const scopedAutoIndexThreshold = 40;
64
67
  const scopedAutoIndexMarker = "<!-- PROJECT-WIKI-SCOPED-AUTO-INDEX -->";
65
68
  function isScopedAutoIndex(file) {
@@ -167,26 +170,46 @@ This block is managed by \`--refresh-index\`. Move useful rows into a hand-writt
167
170
  | --- | --- | --- | --- |
168
171
  ${rows}<!-- PROJECT-WIKI-AUTO-INDEX:END -->`;
169
172
  }
173
+ // Answer-shaped query output (2026-06-12 method-transfer decision): first line is
174
+ // the answer, each result carries the page's TL;DR first bullet so the agent can
175
+ // pick a page without opening it, and the whole body sits under the shared hard
176
+ // cap with an explicit truncation notice.
170
177
  function runQueryMode() {
171
178
  if (!args_1.queryTerm.trim()) {
172
179
  console.error("missing query: use --query \"search terms\"");
173
180
  process.exit(1);
174
181
  }
175
182
  const terms = args_1.queryTerm.toLowerCase().split(/\s+/).filter(Boolean);
176
- const results = (0, wiki_files_1.wikiMarkdownFiles)().map((file) => {
183
+ const matches = (0, wiki_files_1.wikiMarkdownFiles)().map((file) => {
177
184
  const text = (0, workspace_1.read)(file);
178
185
  const body = (0, workspace_1.stripMetadataHeader)(text);
179
186
  const title = (0, wiki_files_1.wikiTitleForFile)(file, text);
180
187
  const meta = (0, wiki_files_1.metadataSummary)(file, text);
181
188
  const weighted = `${file}\n${title}\n${meta.scope}\n${(0, workspace_1.metadataValue)(text, "tags")}\n${body}`.toLowerCase();
182
189
  const score = terms.reduce((sum, term) => sum + (weighted.split(term).length - 1) + (file.toLowerCase().includes(term) ? 3 : 0) + (title.toLowerCase().includes(term) ? 5 : 0), 0);
183
- return { file, title, score, ...meta };
184
- }).filter((item) => item.score > 0).sort((a, b) => b.score - a.score || a.file.localeCompare(b.file)).slice(0, 10);
185
- console.log(`Project wiki query: ${args_1.queryTerm}`);
186
- if (results.length === 0)
187
- console.log("no matches");
188
- for (const item of results)
189
- console.log(`${item.score.toString().padStart(3)} ${item.file} ${item.scope} ${item.status} ${item.title}`);
190
+ return { file, title, score, tldr: (0, wiki_files_1.firstTldrBullet)(text), ...meta };
191
+ }).filter((item) => item.score > 0).sort((a, b) => b.score - a.score || a.file.localeCompare(b.file));
192
+ const results = matches.slice(0, 10);
193
+ const best = results[0];
194
+ const lines = [best
195
+ ? `Project wiki query "${args_1.queryTerm}": best match ${best.file} — ${best.title} (${matches.length} matching page${matches.length === 1 ? "" : "s"}, top ${results.length} shown).`
196
+ : `Project wiki query "${args_1.queryTerm}": no matches.`];
197
+ for (const item of results) {
198
+ lines.push(`${item.score.toString().padStart(3)} ${item.file} [${item.scope}|${item.status}|${item.budget}] ${item.title}`);
199
+ if (item.tldr)
200
+ lines.push(` tldr: ${item.tldr}`);
201
+ }
202
+ console.log((0, wiki_graph_1.finalizeWikiAnswer)(lines.join("\n")));
203
+ }
204
+ // Wiki impact mode: backlink/decision_ref/routing evidence for a page so wiki
205
+ // maintenance can find review candidates when project truth changes.
206
+ function runWikiImpactMode() {
207
+ if (!args_1.wikiImpactTarget.trim()) {
208
+ console.error("missing wiki impact target: use --wiki-impact \"page-or-term\"");
209
+ process.exit(1);
210
+ }
211
+ const pages = (0, wiki_files_1.wikiMarkdownFiles)().map((file) => ({ file, text: (0, workspace_1.read)(file) }));
212
+ console.log((0, wiki_graph_1.wikiImpactAnswer)(pages, args_1.wikiImpactTarget.trim()));
190
213
  }
191
214
  function projectCandidatesContent() {
192
215
  return `${(0, templates_1.metadata)("inbox", "on-demand", "wiki/meta/wiki-ops-v1-decisions.md", "candidates are adopted, rejected, or stale")}
@@ -456,15 +479,12 @@ function printDiagnostics(title, diagnostics, checked) {
456
479
  console.log(`passed: ${checked} wiki markdown files checked, ${warnings} warnings`);
457
480
  return true;
458
481
  }
459
- function collectWikiLinkReferences(files) {
460
- return files.flatMap((file) => (0, wiki_files_1.extractWikiLinks)(file, (0, workspace_1.read)(file)));
461
- }
462
482
  function collectLinkDiagnostics() {
463
483
  const diagnostics = [];
464
484
  const files = (0, wiki_files_1.wikiMarkdownFiles)();
465
485
  const fileSet = new Set(files);
466
- const links = collectWikiLinkReferences(files);
467
- for (const link of links) {
486
+ const graph = (0, wiki_graph_1.buildWikiGraph)(files.map((file) => ({ file, text: (0, workspace_1.read)(file) })));
487
+ for (const link of graph.links) {
468
488
  if (!fileSet.has(link.normalizedTarget)) {
469
489
  diagnostics.push({
470
490
  code: "broken-link",
@@ -474,25 +494,29 @@ function collectLinkDiagnostics() {
474
494
  });
475
495
  }
476
496
  }
477
- if ((0, workspace_1.exists)("wiki/index.md")) {
478
- const indexLinks = (0, wiki_files_1.extractWikiLinks)("wiki/index.md", (0, workspace_1.read)("wiki/index.md"));
479
- const indexTargets = new Map();
480
- for (const link of indexLinks)
481
- indexTargets.set(link.normalizedTarget, (indexTargets.get(link.normalizedTarget) ?? 0) + 1);
482
- for (const [target, count] of indexTargets) {
483
- if (count > 1) {
484
- diagnostics.push({
485
- code: "duplicate-route",
486
- severity: "warn",
487
- file: "wiki/index.md",
488
- message: `${count} index routes resolve to ${target}`,
489
- });
490
- }
497
+ const indexTargets = new Map();
498
+ for (const link of graph.outgoingLinks.get("wiki/index.md") ?? []) {
499
+ indexTargets.set(link.normalizedTarget, (indexTargets.get(link.normalizedTarget) ?? 0) + 1);
500
+ }
501
+ for (const [target, count] of indexTargets) {
502
+ if (count > 1) {
503
+ diagnostics.push({
504
+ code: "duplicate-route",
505
+ severity: "warn",
506
+ file: "wiki/index.md",
507
+ message: `${count} index routes resolve to ${target}`,
508
+ });
491
509
  }
492
510
  }
511
+ // Self-links are not connectivity: a page whose only incoming link is its own
512
+ // self-loop has no route into it and must stay an orphan-page finding, keeping
513
+ // the orphan and router-unreachable rules disjoint.
493
514
  const incoming = new Map();
494
- for (const link of links)
515
+ for (const link of graph.links) {
516
+ if (link.file === link.normalizedTarget)
517
+ continue;
495
518
  incoming.set(link.normalizedTarget, (incoming.get(link.normalizedTarget) ?? 0) + 1);
519
+ }
496
520
  const orphanExemptions = new Set(["wiki/index.md", "wiki/startup.md", "wiki/README.md"]);
497
521
  for (const file of files) {
498
522
  if (orphanExemptions.has(file))
@@ -506,6 +530,50 @@ function collectLinkDiagnostics() {
506
530
  });
507
531
  }
508
532
  }
533
+ // Bounded router reachability, promoted from the benchmark fixture A1 hard
534
+ // assert (benchmarks/lib/llm-fixtures.js assertBoundedAnswerReachability) to the
535
+ // real wiki: fixture wikis were guaranteed navigable from startup while real
536
+ // wikis were never checked for the same property. Pages with zero incoming
537
+ // links are already the orphan-page rule's finding, so reachability reports
538
+ // only the cases orphan cannot see: linked-but-disconnected islands, an index
539
+ // the startup router never links (hop 1), and routes deeper than the budget.
540
+ // When wiki/startup.md itself is missing, lint owns that as a required-file
541
+ // error and reachability has no root to check from.
542
+ if (fileSet.has(wiki_graph_1.wikiRouterRoot)) {
543
+ const depths = (0, wiki_graph_1.wikiRouterDepths)(graph);
544
+ for (const file of files) {
545
+ if (wiki_graph_1.wikiRouterExemptPages.has(file))
546
+ continue;
547
+ const depth = depths.get(file);
548
+ const isIndex = file === "wiki/index.md";
549
+ if (depth === undefined) {
550
+ if (isIndex) {
551
+ diagnostics.push({
552
+ code: "router-unreachable",
553
+ severity: "warn",
554
+ file,
555
+ message: `${wiki_graph_1.wikiRouterRoot} does not link [[index]], so the router chain never starts (hop 1 broken)`,
556
+ });
557
+ }
558
+ else if ((incoming.get(file) ?? 0) > 0) {
559
+ diagnostics.push({
560
+ code: "router-unreachable",
561
+ severity: "warn",
562
+ file,
563
+ message: `linked only from pages that never connect to ${wiki_graph_1.wikiRouterRoot}; route it from wiki/index.md or a scoped router`,
564
+ });
565
+ }
566
+ }
567
+ else if (depth > wiki_graph_1.wikiRouterDepthBudget) {
568
+ diagnostics.push({
569
+ code: "router-depth-exceeded",
570
+ severity: "warn",
571
+ file,
572
+ message: `reachable from ${wiki_graph_1.wikiRouterRoot} only at depth ${depth} (budget ${wiki_graph_1.wikiRouterDepthBudget}); add a shorter route`,
573
+ });
574
+ }
575
+ }
576
+ }
509
577
  return diagnostics.sort((a, b) => a.severity.localeCompare(b.severity) || a.file.localeCompare(b.file) || a.code.localeCompare(b.code));
510
578
  }
511
579
  function legacyWikiRoots() {
@@ -585,15 +653,19 @@ function collectMigrationQualityDiagnostics() {
585
653
  function collectMigrationLintDiagnostics() {
586
654
  if (legacyWikiRoots().length === 0)
587
655
  return [];
588
- const requiredFiles = [
656
+ const requiredCoreFiles = [
657
+ "wiki/meta/document-taxonomy.md",
589
658
  "wiki/migration/inventory.md",
659
+ "wiki/migration/unit-map.md",
660
+ "wiki/migration/split-plan.md",
590
661
  "wiki/migration/coverage.md",
591
662
  "wiki/migration/plan.md",
663
+ "wiki/migration/review.md",
592
664
  "wiki/migration/verification.md",
593
- "wiki/canonical/migration-inbox.md",
594
- "wiki/decisions/migration-inbox.md",
595
- "wiki/sources/migration-inbox.md",
665
+ "wiki/migration/bulk-review.md",
596
666
  ];
667
+ const requiredInboxFiles = (0, migration_1.migrationSemanticReviewComplete)() ? [] : [...migration_1.generatedMigrationInboxFiles];
668
+ const requiredFiles = [...requiredCoreFiles, ...requiredInboxFiles];
597
669
  const diagnostics = requiredFiles
598
670
  .filter((file) => !(0, workspace_1.exists)(file))
599
671
  .map((file) => ({
@@ -603,6 +675,8 @@ function collectMigrationLintDiagnostics() {
603
675
  message: "migration review files are missing; run --migrate or keep migration diagnostics out of normal doctor",
604
676
  }));
605
677
  diagnostics.push(...(0, migration_1.collectMigrationCoverageDiagnostics)());
678
+ diagnostics.push(...(0, migration_1.collectMigrationUnitMapDiagnostics)());
679
+ diagnostics.push(...(0, migration_1.collectMigrationSplitPlanDiagnostics)());
606
680
  return diagnostics.sort((a, b) => a.file.localeCompare(b.file) || a.code.localeCompare(b.code) || a.message.localeCompare(b.message));
607
681
  }
608
682
  function runLinkCheckMode() {
@@ -631,6 +705,73 @@ function runMigrationDoctorMode() {
631
705
  if (!lintOk || !qualityOk)
632
706
  process.exit(1);
633
707
  }
708
+ // B2 router-truth contradiction rule. A compact router that contradicts the
709
+ // decision log is worse than none: the measured 2026-06-10 run spiraled into
710
+ // post-answer verification because wiki/startup.md Recent Decisions and
711
+ // wiki/decisions/recent.md said "None yet." while wiki/decisions/log.md held the
712
+ // dated answer. This flags that exact contradiction as an error-level diagnostic.
713
+ // "None yet." is the bootstrap template marker for an empty decision surface
714
+ // (startup template "## Recent Project Decisions" and recent.md template
715
+ // "## Decisions" both seed "- None yet."), so its presence while the log carries a
716
+ // dated entry is the template-equivalent of an unmaintained router.
717
+ //
718
+ // SECTION-ANCHORED SCAN: the rule checks the relevant section body only, not the
719
+ // whole file, to avoid false-positives on other sections (e.g. an open-questions
720
+ // list that legitimately says "None yet." while Recent Decisions is maintained).
721
+ // wiki/startup.md → "## Recent Project Decisions" section body
722
+ // decisions/recent.md → "## Decisions" section body
723
+ //
724
+ // MINOR 2: the marker regex is tolerant of trailing whitespace / omitted terminal
725
+ // period ("None yet", "None yet. ") but stays anchored to the section scope above.
726
+ // Coupling: this English-only marker matches the bootstrap template text only;
727
+ // a project using a different language for these sections will not be checked.
728
+ const ROUTER_TRUTH_NONE_YET_REGEX = /\bNone yet\.?\s*$/m;
729
+ // Extract the body of a named heading section (from the heading line to the next
730
+ // same-or-higher-level heading, or end of string). Returns empty string when the
731
+ // heading is absent so the caller can decide whether to flag or skip.
732
+ function extractSectionBody(markdown, headingText) {
733
+ // Match `## <headingText>` (level-2 only, matching the template structure).
734
+ const headingRe = new RegExp(`^##\\s+${headingText.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*$`, "m");
735
+ const match = headingRe.exec(markdown);
736
+ if (!match)
737
+ return "";
738
+ const afterHeading = markdown.slice(match.index + match[0].length);
739
+ // Stop at the next ## heading (same or higher level), or end of string.
740
+ const nextHeading = /^##\s/m.exec(afterHeading);
741
+ return nextHeading ? afterHeading.slice(0, nextHeading.index) : afterHeading;
742
+ }
743
+ function collectRouterTruthDiagnostics() {
744
+ const logPath = "wiki/decisions/log.md";
745
+ if (!(0, workspace_1.exists)(logPath))
746
+ return [];
747
+ const logHasDatedEntry = /\b\d{4}-\d{2}-\d{2}\b/.test((0, workspace_1.stripMetadataHeader)((0, workspace_1.read)(logPath)));
748
+ if (!logHasDatedEntry)
749
+ return [];
750
+ const diagnostics = [];
751
+ // Each tuple: [file, headingText, surfaceLabel]
752
+ // headingText must match the bootstrap template section heading exactly so the
753
+ // section-anchored scan never accidentally reads unrelated sections.
754
+ const routers = [
755
+ ["wiki/startup.md", "Recent Project Decisions", "Recent Decisions"],
756
+ ["wiki/decisions/recent.md", "Decisions", "Decisions"],
757
+ ];
758
+ for (const [file, heading, surface] of routers) {
759
+ if (!(0, workspace_1.exists)(file))
760
+ continue;
761
+ const section = extractSectionBody((0, workspace_1.stripMetadataHeader)((0, workspace_1.read)(file)), heading);
762
+ if (section === "")
763
+ continue; // section absent — skip rather than false-positive
764
+ if (ROUTER_TRUTH_NONE_YET_REGEX.test(section)) {
765
+ diagnostics.push({
766
+ code: "router-truth-contradiction",
767
+ severity: "error",
768
+ file,
769
+ message: `${file} ${surface} still says "None yet." while ${logPath} holds a dated decision entry; update ${file} to reflect the recorded decision`,
770
+ });
771
+ }
772
+ }
773
+ return diagnostics.sort((a, b) => a.file.localeCompare(b.file) || a.code.localeCompare(b.code));
774
+ }
634
775
  function runDoctorMode(fix) {
635
776
  if (fix) {
636
777
  console.log("Project wiki doctor --fix");
@@ -645,40 +786,45 @@ function runDoctorMode(fix) {
645
786
  const files = (0, wiki_files_1.wikiMarkdownFiles)();
646
787
  const linkOk = printDiagnostics("Project wiki link-check", collectLinkDiagnostics(), files.length);
647
788
  const qualityOk = printDiagnostics("Project wiki quality-check", collectQualityDiagnostics(), files.length);
789
+ const routerTruthOk = printDiagnostics("Project wiki router-truth check", collectRouterTruthDiagnostics(), files.length);
648
790
  runLintMode();
649
- if (!linkOk || !qualityOk)
791
+ if (!linkOk || !qualityOk || !routerTruthOk)
650
792
  process.exit(1);
651
793
  }
794
+ const commonLintRequiredFiles = [
795
+ "AGENTS.md",
796
+ "wiki/AGENTS.md",
797
+ "wiki/startup.md",
798
+ "wiki/index.md",
799
+ "wiki/decisions/log.md",
800
+ "wiki/decisions/recent.md",
801
+ "wiki/meta/operating-model.md",
802
+ "wiki/meta/decision-policy.md",
803
+ "wiki/meta/wiki-ops-v1-decisions.md",
804
+ ".githooks/prepare-commit-msg",
805
+ ".githooks/wiki-commit-trailers.js",
806
+ ];
807
+ const agentLintRequiredFiles = {
808
+ codex: [".codex/hooks/wiki-session-start.js", ".codex/hooks.json"],
809
+ claude: ["CLAUDE.md", ".claude/hooks/wiki-session-start.js", ".claude/settings.json"],
810
+ cursor: [".cursor/rules/project-librarian.mdc", ".cursor/hooks/wiki-session-start.js", ".cursor/hooks.json"],
811
+ gemini: ["GEMINI.md", ".gemini/hooks/wiki-session-start.js", ".gemini/settings.json"],
812
+ };
813
+ function activeLintAgentSurfaces() {
814
+ const active = new Set();
815
+ for (const [agent, files] of Object.entries(agentLintRequiredFiles)) {
816
+ if (files.some((file) => (0, workspace_1.exists)(file)))
817
+ active.add(agent);
818
+ }
819
+ return active;
820
+ }
652
821
  function runLintMode() {
653
822
  const errors = [];
654
823
  const warnings = [];
824
+ const activeAgents = activeLintAgentSurfaces();
655
825
  const requiredFiles = [
656
- "AGENTS.md",
657
- "CLAUDE.md",
658
- "GEMINI.md",
659
- "wiki/AGENTS.md",
660
- "wiki/startup.md",
661
- "wiki/index.md",
662
- "wiki/canonical/project-brief.md",
663
- "wiki/canonical/open-questions.md",
664
- "wiki/canonical/assumptions.md",
665
- "wiki/canonical/risks.md",
666
- "wiki/decisions/log.md",
667
- "wiki/decisions/recent.md",
668
- "wiki/meta/operating-model.md",
669
- "wiki/meta/decision-policy.md",
670
- "wiki/meta/wiki-ops-v1-decisions.md",
671
- ".githooks/prepare-commit-msg",
672
- ".githooks/wiki-commit-trailers.js",
673
- ".codex/hooks/wiki-session-start.js",
674
- ".codex/hooks.json",
675
- ".claude/hooks/wiki-session-start.js",
676
- ".claude/settings.json",
677
- ".cursor/rules/project-librarian.mdc",
678
- ".cursor/hooks/wiki-session-start.js",
679
- ".cursor/hooks.json",
680
- ".gemini/hooks/wiki-session-start.js",
681
- ".gemini/settings.json",
826
+ ...commonLintRequiredFiles,
827
+ ...Array.from(activeAgents).flatMap((agent) => agentLintRequiredFiles[agent]),
682
828
  ];
683
829
  for (const file of requiredFiles) {
684
830
  if (!(0, workspace_1.exists)(file))
@@ -0,0 +1,193 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.classifyMigrationUnit = classifyMigrationUnit;
4
+ exports.storageToMigrationKind = storageToMigrationKind;
5
+ const taxonomyAreas = [
6
+ {
7
+ id: "research-sources",
8
+ label: "Research / Source Evidence",
9
+ storage: "sources",
10
+ targetSlug: "research-sources",
11
+ keywords: ["source", "sources", "reference", "references", "citation", "bibliography", "research", "paper", "article", "link", "출처", "참고", "자료", "근거", "링크", "리서치", "논문"],
12
+ },
13
+ {
14
+ id: "decision-record",
15
+ label: "Decision Record",
16
+ storage: "decisions",
17
+ targetSlug: "decision-records",
18
+ keywords: ["adr", "decision", "decisions", "rationale", "tradeoff", "trade-off", "alternative", "rejected", "accepted", "decided", "결정", "의사결정", "기각", "대안", "트레이드오프", "선택", "채택"],
19
+ },
20
+ {
21
+ id: "strategy",
22
+ label: "Strategy / Business Context",
23
+ storage: "canonical",
24
+ targetSlug: "strategy-context",
25
+ keywords: ["vision", "mission", "strategy", "business model", "market", "positioning", "competitive", "gtm", "pricing", "roadmap", "비전", "미션", "전략", "시장", "포지셔닝", "경쟁", "사업", "가격", "로드맵"],
26
+ },
27
+ {
28
+ id: "product-requirements",
29
+ label: "Product Requirements",
30
+ storage: "canonical",
31
+ targetSlug: "product-requirements",
32
+ keywords: ["prd", "requirement", "requirements", "feature", "features", "function", "functional", "scope", "goal", "goals", "acceptance", "use case", "기능", "기능명세", "요구사항", "요건", "범위", "목표", "수용기준", "유스케이스", "서비스기획", "기획"],
33
+ },
34
+ {
35
+ id: "user-flow",
36
+ label: "User Flow / Journey",
37
+ storage: "canonical",
38
+ targetSlug: "user-flows",
39
+ keywords: ["user flow", "flow", "journey", "scenario", "persona", "story", "navigation", "screen flow", "유저플로우", "사용자 흐름", "사용자 여정", "시나리오", "페르소나", "스토리", "화면흐름", "동선"],
40
+ },
41
+ {
42
+ id: "ux-content",
43
+ label: "UX / Content",
44
+ storage: "canonical",
45
+ targetSlug: "ux-content",
46
+ keywords: ["ux", "interaction", "wireframe", "copy", "content", "microcopy", "empty state", "error state", "accessibility", "i18n", "사용성", "인터랙션", "와이어프레임", "카피", "콘텐츠", "빈상태", "오류상태", "접근성", "문구"],
47
+ },
48
+ {
49
+ id: "design-system",
50
+ label: "Design System / UI",
51
+ storage: "canonical",
52
+ targetSlug: "design-system",
53
+ keywords: ["design", "ui", "component", "prototype", "figma", "style guide", "brand", "visual", "layout", "디자인", "컴포넌트", "프로토타입", "스타일가이드", "브랜드", "시각", "레이아웃"],
54
+ },
55
+ {
56
+ id: "data-analytics",
57
+ label: "Data / Analytics",
58
+ storage: "canonical",
59
+ targetSlug: "data-analytics",
60
+ keywords: ["data", "schema", "database", "db", "erd", "entity", "event", "metric", "analytics", "kpi", "tracking", "데이터", "스키마", "디비", "엔티티", "이벤트", "지표", "분석", "트래킹"],
61
+ },
62
+ {
63
+ id: "api-contract",
64
+ label: "API Contract",
65
+ storage: "canonical",
66
+ targetSlug: "api-contracts",
67
+ keywords: ["api", "endpoint", "request", "response", "rest", "graphql", "webhook", "sdk", "openapi", "swagger", "인증", "토큰", "요청", "응답", "엔드포인트", "웹훅", "api명세", "api 명세"],
68
+ },
69
+ {
70
+ id: "engineering-architecture",
71
+ label: "Engineering / Architecture",
72
+ storage: "canonical",
73
+ targetSlug: "engineering-architecture",
74
+ keywords: ["architecture", "system", "service", "module", "implementation", "tech stack", "dependency", "infra", "deployment", "queue", "cache", "아키텍처", "시스템", "서비스", "모듈", "구현", "기술스택", "인프라", "배포", "큐", "캐시"],
75
+ },
76
+ {
77
+ id: "security-legal",
78
+ label: "Security / Legal",
79
+ storage: "canonical",
80
+ targetSlug: "security-legal",
81
+ keywords: ["security", "privacy", "permission", "role", "auth", "authorization", "compliance", "legal", "terms", "consent", "보안", "개인정보", "권한", "역할", "인가", "인증", "준수", "법무", "약관", "동의"],
82
+ },
83
+ {
84
+ id: "policy",
85
+ label: "Policy / Governance",
86
+ storage: "canonical",
87
+ targetSlug: "policy-governance",
88
+ keywords: ["policy", "rule", "moderation", "governance", "sla", "retention", "eligibility", "refund", "정책", "규칙", "운영정책", "거버넌스", "보관", "자격", "환불"],
89
+ },
90
+ {
91
+ id: "qa",
92
+ label: "QA / Test",
93
+ storage: "canonical",
94
+ targetSlug: "qa-test-plan",
95
+ keywords: ["qa", "test", "testing", "validation", "verification", "bug", "edge case", "regression", "acceptance test", "테스트", "검증", "품질", "버그", "엣지케이스", "회귀", "테스트케이스"],
96
+ },
97
+ {
98
+ id: "release-ops",
99
+ label: "Release / Operations",
100
+ storage: "canonical",
101
+ targetSlug: "release-operations",
102
+ keywords: ["release", "launch", "operation", "runbook", "monitoring", "alert", "incident", "support", "cs", "migration", "rollout", "릴리즈", "출시", "운영", "런북", "모니터링", "알림", "장애", "고객지원", "마이그레이션"],
103
+ },
104
+ {
105
+ id: "business-ops",
106
+ label: "Business Operations",
107
+ storage: "canonical",
108
+ targetSlug: "business-operations",
109
+ keywords: ["sales", "marketing", "crm", "customer success", "finance", "billing", "invoice", "contract", "영업", "마케팅", "고객성공", "재무", "청구", "계약", "정산"],
110
+ },
111
+ {
112
+ id: "wiki-meta",
113
+ label: "Wiki Operations / Meta",
114
+ storage: "meta",
115
+ targetSlug: "wiki-operations",
116
+ keywords: ["wiki", "documentation", "document taxonomy", "canonical", "source of truth", "lint", "doctor", "위키", "문서화", "문서체계", "정본", "정보구조", "린트", "닥터"],
117
+ },
118
+ ];
119
+ function slugPart(value, maxLength = 36) {
120
+ return value.toLowerCase().replace(/[^a-z0-9가-힣ぁ-んァ-ン一-龥]+/g, "-").replace(/^-|-$/g, "").slice(0, maxLength) || "migration";
121
+ }
122
+ function legacyStem(legacyPath) {
123
+ return slugPart(legacyPath.replace(/\.(md|mdx)$/i, "").split(/[\\/]+/).pop() ?? legacyPath);
124
+ }
125
+ function fallbackStorageFromLegacyPath(legacyPath) {
126
+ if (/^meta\//.test(legacyPath))
127
+ return "meta";
128
+ if (/^decisions\//.test(legacyPath))
129
+ return "decisions";
130
+ if (/^sources\//.test(legacyPath))
131
+ return "sources";
132
+ return "canonical";
133
+ }
134
+ function keywordScore(haystack, keyword) {
135
+ const normalized = keyword.toLowerCase();
136
+ const escaped = normalized.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
137
+ if (/^[a-z0-9 -]+$/.test(normalized)) {
138
+ const matches = haystack.match(new RegExp(`\\b${escaped}\\b`, "g"));
139
+ return matches ? matches.length : 0;
140
+ }
141
+ return haystack.includes(normalized) ? 1 : 0;
142
+ }
143
+ function areaScore(area, haystack) {
144
+ return area.keywords.reduce((sum, keyword) => sum + keywordScore(haystack, keyword), 0);
145
+ }
146
+ function targetPath(storage, base, targetSlug) {
147
+ return `wiki/${storage}/${slugPart(base, 32)}-${targetSlug}.md`;
148
+ }
149
+ function classifyMigrationUnit(input) {
150
+ const haystack = [
151
+ legacyStem(input.legacyPath),
152
+ input.heading,
153
+ input.headingPath.join(" "),
154
+ input.summary,
155
+ input.content,
156
+ ].join("\n").toLowerCase();
157
+ const scored = taxonomyAreas
158
+ .map((area) => ({ area, score: areaScore(area, haystack) }))
159
+ .filter((item) => item.score > 0)
160
+ .sort((left, right) => right.score - left.score || left.area.id.localeCompare(right.area.id));
161
+ const fallbackStorage = fallbackStorageFromLegacyPath(input.legacyPath);
162
+ const best = scored[0]?.area ?? {
163
+ id: "unclassified",
164
+ label: "Needs Human Review",
165
+ storage: fallbackStorage,
166
+ targetSlug: "migration-review",
167
+ keywords: [],
168
+ };
169
+ const bestScore = scored[0]?.score ?? 0;
170
+ const secondScore = scored[1]?.score ?? 0;
171
+ const confidence = bestScore >= 3 && bestScore > secondScore ? "high" : bestScore >= 1 && bestScore >= secondScore ? "medium" : "low";
172
+ const base = legacyStem(input.legacyPath);
173
+ const reason = confidence === "low"
174
+ ? "no strong taxonomy signal; human review required"
175
+ : `matched ${best.label}${secondScore > 0 && bestScore === secondScore ? " with overlapping signals" : ""}`;
176
+ return {
177
+ area: best.id,
178
+ label: best.label,
179
+ storage: best.storage,
180
+ target: targetPath(best.storage, base, best.targetSlug),
181
+ confidence,
182
+ reason,
183
+ };
184
+ }
185
+ function storageToMigrationKind(storage) {
186
+ if (storage === "decisions")
187
+ return "decision";
188
+ if (storage === "sources")
189
+ return "source";
190
+ if (storage === "meta")
191
+ return "meta";
192
+ return "canonical";
193
+ }