@team-semicolon/semo-cli 4.12.0 → 4.15.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/commands/commitments.js +25 -3
- package/dist/commands/commitments.test.d.ts +9 -0
- package/dist/commands/commitments.test.js +223 -0
- package/dist/commands/context.js +23 -3
- package/dist/commands/harness.d.ts +8 -0
- package/dist/commands/harness.js +412 -0
- package/dist/commands/incubator.d.ts +10 -0
- package/dist/commands/incubator.js +517 -0
- package/dist/commands/service.d.ts +10 -0
- package/dist/commands/service.js +283 -0
- package/dist/commands/sessions.js +156 -0
- package/dist/commands/skill-sync.d.ts +2 -1
- package/dist/commands/skill-sync.js +88 -23
- package/dist/commands/skill-sync.test.js +78 -45
- package/dist/database.d.ts +2 -1
- package/dist/database.js +109 -27
- package/dist/global-cache.js +26 -14
- package/dist/index.js +578 -522
- package/dist/kb.d.ts +4 -4
- package/dist/kb.js +211 -101
- package/dist/semo-workspace.js +51 -0
- package/dist/service-migrate.d.ts +114 -0
- package/dist/service-migrate.js +457 -0
- package/dist/templates/harness/commit-msg +1 -0
- package/dist/templates/harness/commitlint.config.js +11 -0
- package/dist/templates/harness/eslint.config.mjs +17 -0
- package/dist/templates/harness/pr-quality-gate.yml +19 -0
- package/dist/templates/harness/pre-commit +1 -0
- package/dist/templates/harness/pre-push +1 -0
- package/dist/templates/harness/prettierignore +5 -0
- package/dist/templates/harness/prettierrc.json +7 -0
- package/package.json +8 -4
package/dist/semo-workspace.js
CHANGED
|
@@ -322,6 +322,57 @@ function generateThinRouter(kbFirstBlock) {
|
|
|
322
322
|
- 사용자가 "다른 탭에서 뭐 돌아가고 있어?" 같은 질문을 하면 cmux 명령어로 확인
|
|
323
323
|
|
|
324
324
|
${kbFirstBlock}
|
|
325
|
+
|
|
326
|
+
---
|
|
327
|
+
|
|
328
|
+
## 3자 동기화 규칙 (NON-NEGOTIABLE)
|
|
329
|
+
|
|
330
|
+
> 모든 SEMO 프로젝트의 변경은 3자 동기화 관점에서 평가되어야 한다.
|
|
331
|
+
> 3자 = **소스코드** ↔ **KB 포함 DB** ↔ **OpenClaw 봇 워크스페이스 로컬 파일**
|
|
332
|
+
|
|
333
|
+
| 축 | 위치 | 예시 |
|
|
334
|
+
|----|------|------|
|
|
335
|
+
| **소스코드** | 각 프로젝트 레포 | API 라우트, 타입, 마이그레이션 |
|
|
336
|
+
| **KB 포함 DB** | semo-kb (PostgreSQL + 벡터 임베딩) | 프로세스, 봇 역할, 온톨로지 |
|
|
337
|
+
| **봇 로컬 파일** | \`~/.openclaw-{botId}/workspace/\` + \`~/.claude/semo/\` | SOUL.md, skills/, memory/ |
|
|
338
|
+
|
|
339
|
+
**변경 시 체크리스트**:
|
|
340
|
+
1. **소스코드 변경** → KB와 봇 파일에 반영할 내용이 있는가?
|
|
341
|
+
2. **KB 변경** → 소스코드나 봇 파일과 불일치가 생기지 않는가?
|
|
342
|
+
3. **봇 파일 변경** → KB에 기록할 사항이 있는가? 소스코드와 정합성이 맞는가?
|
|
343
|
+
|
|
344
|
+
**추가 규칙**:
|
|
345
|
+
- SoT 위치: DB → DB에서 읽기 (하드코딩 금지). KB → \`semo kb get/search\` 조회.
|
|
346
|
+
- KB 쓰기: 반드시 \`semo kb upsert\` CLI 사용 (임베딩 + 스키마 검증 포함). raw SQL INSERT 금지.
|
|
347
|
+
|
|
348
|
+
---
|
|
349
|
+
|
|
350
|
+
## KB CLI 참조 가이드
|
|
351
|
+
|
|
352
|
+
| 정보 | 조회 명령 |
|
|
353
|
+
|------|-----------|
|
|
354
|
+
| 봇 프로필 | \`semo kb get {botId} identity\` |
|
|
355
|
+
| 봇 역할 | \`semo kb get {botId} role\` |
|
|
356
|
+
| SEMO 워크스페이스 규격 | \`semo kb get semo spec workspace-v2\` |
|
|
357
|
+
| 환경변수 | \`semo kb get semo infra env-config\` |
|
|
358
|
+
| 코딩 컨벤션 | \`semo kb get semo process coding-convention\` |
|
|
359
|
+
| 인시던트 기록 | \`semo kb get {service} incident {slug}\` |
|
|
360
|
+
| 의사결정 기록 | \`semo kb get semicolon decision {slug}\` |
|
|
361
|
+
| 경로 모를 때 | \`semo kb search "검색어"\` 또는 \`semo kb ontology --action routing-table\` |
|
|
362
|
+
|
|
363
|
+
---
|
|
364
|
+
|
|
365
|
+
## 공통 Enforcement Hooks
|
|
366
|
+
|
|
367
|
+
아래 훅은 \`~/.openclaw-shared/hooks/\`에 위치하며, 봇 + 로컬 세션 양쪽에서 사용:
|
|
368
|
+
|
|
369
|
+
| 훅 | 트리거 | 역할 |
|
|
370
|
+
|----|--------|------|
|
|
371
|
+
| \`context-router.sh\` | UserPromptSubmit | 메시지 키워드 → KB/GFP/INFRA 컨텍스트 힌트 주입 |
|
|
372
|
+
| \`decision-reminder.sh\` | Stop | 의사결정 패턴 감지 → KB 기록 안 했으면 차단 |
|
|
373
|
+
| \`commitment-guard.sh\` | Stop | 한국어 약속 패턴 → commitment 미등록 시 차단 |
|
|
374
|
+
| \`kb-first-guard.sh\` | Stop | KB 필요 질문에 KB 조회 없이 답변 시 차단 |
|
|
375
|
+
| \`response-length-guard.sh\` | Stop | 봇 응답 20줄 초과 시 차단 |
|
|
325
376
|
`;
|
|
326
377
|
// 기존 파일 백업 (마이그레이션)
|
|
327
378
|
if (fs.existsSync(globalClaudeMd)) {
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Service Migration — 기존 운영 서비스를 services 테이블에 이식
|
|
3
|
+
*
|
|
4
|
+
* KB 온톨로지에 service 타입으로 등록된 도메인 중 services에 미등록된 것을
|
|
5
|
+
* 자동으로 이식. KB 엔트리 전수 조사(audit) 후 매핑/비매핑 분류.
|
|
6
|
+
*/
|
|
7
|
+
import { Pool } from "pg";
|
|
8
|
+
export interface AuditEntry {
|
|
9
|
+
key: string;
|
|
10
|
+
subKey: string;
|
|
11
|
+
target: string;
|
|
12
|
+
value: string;
|
|
13
|
+
}
|
|
14
|
+
export interface AuditResult {
|
|
15
|
+
domain: string;
|
|
16
|
+
ontologyDescription: string | null;
|
|
17
|
+
ontologyCreatedAt: string | null;
|
|
18
|
+
mapped: {
|
|
19
|
+
key: string;
|
|
20
|
+
target: string;
|
|
21
|
+
value: string;
|
|
22
|
+
}[];
|
|
23
|
+
metadata: {
|
|
24
|
+
key: string;
|
|
25
|
+
value: string;
|
|
26
|
+
}[];
|
|
27
|
+
kbOnly: {
|
|
28
|
+
key: string;
|
|
29
|
+
count: number;
|
|
30
|
+
}[];
|
|
31
|
+
projection: {
|
|
32
|
+
key: string;
|
|
33
|
+
count: number;
|
|
34
|
+
}[];
|
|
35
|
+
warnings: {
|
|
36
|
+
key: string;
|
|
37
|
+
reason: string;
|
|
38
|
+
suggestion: string;
|
|
39
|
+
}[];
|
|
40
|
+
missingRequired: string[];
|
|
41
|
+
}
|
|
42
|
+
export interface MigrationRow {
|
|
43
|
+
project_name: string;
|
|
44
|
+
owner_name: string;
|
|
45
|
+
service_domain: string;
|
|
46
|
+
status: string;
|
|
47
|
+
lifecycle: string;
|
|
48
|
+
launched_at: string | null;
|
|
49
|
+
metadata: Record<string, unknown>;
|
|
50
|
+
}
|
|
51
|
+
export interface MigrationResult {
|
|
52
|
+
domain: string;
|
|
53
|
+
action: "created" | "skipped" | "error";
|
|
54
|
+
projectId?: string;
|
|
55
|
+
audit: AuditResult;
|
|
56
|
+
error?: string;
|
|
57
|
+
}
|
|
58
|
+
export declare const STATUS_MAP: Record<string, {
|
|
59
|
+
lifecycle: string;
|
|
60
|
+
status: string;
|
|
61
|
+
}>;
|
|
62
|
+
export declare function getUnregisteredServices(pool: Pool): Promise<Array<{
|
|
63
|
+
domain: string;
|
|
64
|
+
description: string | null;
|
|
65
|
+
created_at: string | null;
|
|
66
|
+
}>>;
|
|
67
|
+
export declare function getRegisteredServices(pool: Pool): Promise<string[]>;
|
|
68
|
+
export declare function auditServiceKBEntries(pool: Pool, domain: string, ontologyDescription: string | null, ontologyCreatedAt: string | null): Promise<AuditResult>;
|
|
69
|
+
export declare function buildServiceProjectRow(audit: AuditResult): MigrationRow;
|
|
70
|
+
export declare function insertServiceProject(pool: Pool, row: MigrationRow): Promise<string>;
|
|
71
|
+
export declare function writePmSummaryToKB(pool: Pool, domain: string, projectId: string, row: MigrationRow, kbEntryCount: number): Promise<void>;
|
|
72
|
+
export declare function printAuditReport(results: MigrationResult[]): void;
|
|
73
|
+
export declare function printDryRunReport(results: MigrationResult[]): void;
|
|
74
|
+
export interface ServiceProjectRow {
|
|
75
|
+
service_id: string;
|
|
76
|
+
project_name: string;
|
|
77
|
+
service_domain: string | null;
|
|
78
|
+
owner_name: string;
|
|
79
|
+
owner_contact: string | null;
|
|
80
|
+
current_phase: number;
|
|
81
|
+
infra_phase: number | null;
|
|
82
|
+
status: string;
|
|
83
|
+
lifecycle: string;
|
|
84
|
+
launched_at: string | null;
|
|
85
|
+
metadata: Record<string, unknown>;
|
|
86
|
+
created_at: string;
|
|
87
|
+
updated_at: string;
|
|
88
|
+
}
|
|
89
|
+
export declare function getServiceProjectByDomain(pool: Pool, domain: string): Promise<ServiceProjectRow | null>;
|
|
90
|
+
export interface ServiceProjectUpdate {
|
|
91
|
+
status?: string;
|
|
92
|
+
lifecycle?: string;
|
|
93
|
+
current_phase?: number;
|
|
94
|
+
project_name?: string;
|
|
95
|
+
owner_name?: string;
|
|
96
|
+
metadata?: Record<string, unknown>;
|
|
97
|
+
}
|
|
98
|
+
export declare function updateServiceProject(pool: Pool, domain: string, updates: ServiceProjectUpdate): Promise<ServiceProjectRow | null>;
|
|
99
|
+
export interface DiagnoseMismatch {
|
|
100
|
+
field: string;
|
|
101
|
+
kbValue: string;
|
|
102
|
+
spValue: string;
|
|
103
|
+
expected: string;
|
|
104
|
+
}
|
|
105
|
+
export interface DiagnoseResult {
|
|
106
|
+
domain: string;
|
|
107
|
+
verdict: "healthy" | "mismatch" | "kb-only" | "sp-only" | "missing";
|
|
108
|
+
kbEntryCount: number;
|
|
109
|
+
kbKeys: string[];
|
|
110
|
+
serviceProject: ServiceProjectRow | null;
|
|
111
|
+
mismatches: DiagnoseMismatch[];
|
|
112
|
+
missingRequired: string[];
|
|
113
|
+
}
|
|
114
|
+
export declare function diagnoseServiceStatus(pool: Pool, domain: string): Promise<DiagnoseResult>;
|
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Service Migration — 기존 운영 서비스를 services 테이블에 이식
|
|
4
|
+
*
|
|
5
|
+
* KB 온톨로지에 service 타입으로 등록된 도메인 중 services에 미등록된 것을
|
|
6
|
+
* 자동으로 이식. KB 엔트리 전수 조사(audit) 후 매핑/비매핑 분류.
|
|
7
|
+
*/
|
|
8
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
9
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
10
|
+
};
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.STATUS_MAP = void 0;
|
|
13
|
+
exports.getUnregisteredServices = getUnregisteredServices;
|
|
14
|
+
exports.getRegisteredServices = getRegisteredServices;
|
|
15
|
+
exports.auditServiceKBEntries = auditServiceKBEntries;
|
|
16
|
+
exports.buildServiceProjectRow = buildServiceProjectRow;
|
|
17
|
+
exports.insertServiceProject = insertServiceProject;
|
|
18
|
+
exports.writePmSummaryToKB = writePmSummaryToKB;
|
|
19
|
+
exports.printAuditReport = printAuditReport;
|
|
20
|
+
exports.printDryRunReport = printDryRunReport;
|
|
21
|
+
exports.getServiceProjectByDomain = getServiceProjectByDomain;
|
|
22
|
+
exports.updateServiceProject = updateServiceProject;
|
|
23
|
+
exports.diagnoseServiceStatus = diagnoseServiceStatus;
|
|
24
|
+
const chalk_1 = __importDefault(require("chalk"));
|
|
25
|
+
const kb_1 = require("./kb");
|
|
26
|
+
// ── KB Status Mapping ──
|
|
27
|
+
exports.STATUS_MAP = {
|
|
28
|
+
active: { lifecycle: "ops", status: "active" },
|
|
29
|
+
hold: { lifecycle: "ops", status: "paused" },
|
|
30
|
+
maintenance: { lifecycle: "ops", status: "active" },
|
|
31
|
+
completed: { lifecycle: "sunset", status: "completed" },
|
|
32
|
+
deprecated: { lifecycle: "sunset", status: "completed" },
|
|
33
|
+
};
|
|
34
|
+
// Keys that map directly to services columns
|
|
35
|
+
const COLUMN_KEYS = {
|
|
36
|
+
"base-information": "project_name",
|
|
37
|
+
po: "owner_name",
|
|
38
|
+
status: "status+lifecycle",
|
|
39
|
+
};
|
|
40
|
+
// Keys that go into metadata JSONB
|
|
41
|
+
const METADATA_KEYS = new Set([
|
|
42
|
+
"repo",
|
|
43
|
+
"slack-channel",
|
|
44
|
+
"tech-stack",
|
|
45
|
+
"service-url",
|
|
46
|
+
"bm",
|
|
47
|
+
]);
|
|
48
|
+
// Keys that stay in KB only (normal)
|
|
49
|
+
const KB_ONLY_KEYS = new Set([
|
|
50
|
+
"current-situation",
|
|
51
|
+
"kpi",
|
|
52
|
+
"milestone",
|
|
53
|
+
"decision",
|
|
54
|
+
"process",
|
|
55
|
+
"infra",
|
|
56
|
+
]);
|
|
57
|
+
// Projection keys (auto-managed by pm-pipeline)
|
|
58
|
+
const PROJECTION_KEYS = new Set([
|
|
59
|
+
"spec",
|
|
60
|
+
"pm-status",
|
|
61
|
+
"gfp-status",
|
|
62
|
+
"gfp-id",
|
|
63
|
+
"infra-status",
|
|
64
|
+
"pm-summary",
|
|
65
|
+
]);
|
|
66
|
+
// ── Core Functions ──
|
|
67
|
+
async function getUnregisteredServices(pool) {
|
|
68
|
+
const result = await pool.query(`SELECT o.domain, o.description, o.created_at::text
|
|
69
|
+
FROM semo.ontology o
|
|
70
|
+
WHERE o.entity_type = 'service'
|
|
71
|
+
AND o.domain NOT LIKE 'e2e-%'
|
|
72
|
+
AND NOT EXISTS (
|
|
73
|
+
SELECT 1 FROM semo.services sp WHERE sp.service_domain = o.domain
|
|
74
|
+
)
|
|
75
|
+
ORDER BY o.domain`);
|
|
76
|
+
return result.rows;
|
|
77
|
+
}
|
|
78
|
+
async function getRegisteredServices(pool) {
|
|
79
|
+
const result = await pool.query(`SELECT service_domain FROM semo.services WHERE service_domain IS NOT NULL`);
|
|
80
|
+
return result.rows.map((r) => r.service_domain);
|
|
81
|
+
}
|
|
82
|
+
async function auditServiceKBEntries(pool, domain, ontologyDescription, ontologyCreatedAt) {
|
|
83
|
+
// Fetch ALL KB entries for this domain
|
|
84
|
+
const entriesResult = await pool.query(`SELECT key, sub_key, content
|
|
85
|
+
FROM semo.knowledge_base
|
|
86
|
+
WHERE domain = $1
|
|
87
|
+
ORDER BY key, sub_key`, [domain]);
|
|
88
|
+
const entries = entriesResult.rows.map((e) => ({ ...e, content: (e.content ?? "").substring(0, 500) }));
|
|
89
|
+
// Fetch allowed keys from type schema
|
|
90
|
+
const schemaResult = await pool.query(`SELECT scheme_key, COALESCE(source, 'manual') as source
|
|
91
|
+
FROM semo.kb_type_schema WHERE type_key = 'service'`);
|
|
92
|
+
const allowedKeys = new Set(schemaResult.rows.map((r) => r.scheme_key));
|
|
93
|
+
const audit = {
|
|
94
|
+
domain,
|
|
95
|
+
ontologyDescription,
|
|
96
|
+
ontologyCreatedAt,
|
|
97
|
+
mapped: [],
|
|
98
|
+
metadata: [],
|
|
99
|
+
kbOnly: [],
|
|
100
|
+
projection: [],
|
|
101
|
+
warnings: [],
|
|
102
|
+
missingRequired: [],
|
|
103
|
+
};
|
|
104
|
+
// Group entries by key
|
|
105
|
+
const byKey = new Map();
|
|
106
|
+
for (const e of entries) {
|
|
107
|
+
const arr = byKey.get(e.key) || [];
|
|
108
|
+
arr.push({ sub_key: e.sub_key, content: e.content });
|
|
109
|
+
byKey.set(e.key, arr);
|
|
110
|
+
}
|
|
111
|
+
for (const [key, items] of byKey) {
|
|
112
|
+
if (COLUMN_KEYS[key]) {
|
|
113
|
+
// Maps to services column
|
|
114
|
+
const firstContent = items[0]?.content ?? "";
|
|
115
|
+
audit.mapped.push({
|
|
116
|
+
key,
|
|
117
|
+
target: COLUMN_KEYS[key],
|
|
118
|
+
value: firstContent.substring(0, 200),
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
else if (METADATA_KEYS.has(key)) {
|
|
122
|
+
const firstContent = items[0]?.content ?? "";
|
|
123
|
+
audit.metadata.push({ key, value: firstContent.substring(0, 200) });
|
|
124
|
+
}
|
|
125
|
+
else if (KB_ONLY_KEYS.has(key)) {
|
|
126
|
+
audit.kbOnly.push({ key, count: items.length });
|
|
127
|
+
}
|
|
128
|
+
else if (PROJECTION_KEYS.has(key)) {
|
|
129
|
+
audit.projection.push({ key, count: items.length });
|
|
130
|
+
}
|
|
131
|
+
else if (!allowedKeys.has(key)) {
|
|
132
|
+
// Unknown key — not in type schema
|
|
133
|
+
audit.warnings.push({
|
|
134
|
+
key,
|
|
135
|
+
reason: `'${key}'은(는) service 타입 스키마에 등록되지 않은 키`,
|
|
136
|
+
suggestion: `semo kb ontology --action add-key --type service --key ${key} --key-type collection`,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
// In schema but not in our classification — treat as KB-only
|
|
141
|
+
audit.kbOnly.push({ key, count: items.length });
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
// Check required keys
|
|
145
|
+
for (const reqKey of ["base-information", "po", "status"]) {
|
|
146
|
+
if (!byKey.has(reqKey)) {
|
|
147
|
+
audit.missingRequired.push(reqKey);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return audit;
|
|
151
|
+
}
|
|
152
|
+
function buildServiceProjectRow(audit) {
|
|
153
|
+
// Extract project_name from base-information
|
|
154
|
+
const baseInfo = audit.mapped.find((m) => m.key === "base-information");
|
|
155
|
+
let projectName = audit.domain; // fallback
|
|
156
|
+
if (baseInfo) {
|
|
157
|
+
// Take first line or first sentence as project name
|
|
158
|
+
const firstLine = baseInfo.value.split("\n")[0].trim();
|
|
159
|
+
// Try to extract name pattern like "AXOracle — 오라클 정보 플랫폼"
|
|
160
|
+
const match = firstLine.match(/^([^—–\-]+)/);
|
|
161
|
+
projectName = match ? match[1].trim() : firstLine.substring(0, 100);
|
|
162
|
+
}
|
|
163
|
+
else if (audit.ontologyDescription) {
|
|
164
|
+
const match = audit.ontologyDescription.match(/^([^—–\-]+)/);
|
|
165
|
+
projectName = match ? match[1].trim() : audit.ontologyDescription.substring(0, 100);
|
|
166
|
+
}
|
|
167
|
+
// Extract owner from po
|
|
168
|
+
const poEntry = audit.mapped.find((m) => m.key === "po");
|
|
169
|
+
const ownerName = poEntry ? poEntry.value.split("\n")[0].trim().substring(0, 100) : "TBD";
|
|
170
|
+
// Map status
|
|
171
|
+
const statusEntry = audit.mapped.find((m) => m.key === "status");
|
|
172
|
+
const rawStatus = statusEntry
|
|
173
|
+
? statusEntry.value.split("\n")[0].trim().toLowerCase()
|
|
174
|
+
: "active";
|
|
175
|
+
const mapping = exports.STATUS_MAP[rawStatus] ?? exports.STATUS_MAP["active"];
|
|
176
|
+
// Build metadata from repo, slack-channel, tech-stack, etc.
|
|
177
|
+
const meta = { source: "kb-migration" };
|
|
178
|
+
for (const m of audit.metadata) {
|
|
179
|
+
meta[m.key] = m.value;
|
|
180
|
+
}
|
|
181
|
+
return {
|
|
182
|
+
project_name: projectName,
|
|
183
|
+
owner_name: ownerName,
|
|
184
|
+
service_domain: audit.domain,
|
|
185
|
+
status: mapping.status,
|
|
186
|
+
lifecycle: mapping.lifecycle,
|
|
187
|
+
launched_at: audit.ontologyCreatedAt,
|
|
188
|
+
metadata: meta,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
async function insertServiceProject(pool, row) {
|
|
192
|
+
const result = await pool.query(`INSERT INTO semo.services
|
|
193
|
+
(project_name, owner_name, service_domain, status, lifecycle, launched_at, metadata)
|
|
194
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
195
|
+
RETURNING service_id`, [
|
|
196
|
+
row.project_name,
|
|
197
|
+
row.owner_name,
|
|
198
|
+
row.service_domain,
|
|
199
|
+
row.status,
|
|
200
|
+
row.lifecycle,
|
|
201
|
+
row.launched_at,
|
|
202
|
+
JSON.stringify(row.metadata),
|
|
203
|
+
]);
|
|
204
|
+
return result.rows[0].service_id;
|
|
205
|
+
}
|
|
206
|
+
async function writePmSummaryToKB(pool, domain, projectId, row, kbEntryCount) {
|
|
207
|
+
const content = [
|
|
208
|
+
`service_id: ${projectId}`,
|
|
209
|
+
`project_name: ${row.project_name}`,
|
|
210
|
+
`lifecycle: ${row.lifecycle}`,
|
|
211
|
+
`status: ${row.status}`,
|
|
212
|
+
`source: kb-migration`,
|
|
213
|
+
`registered_at: ${new Date().toISOString()}`,
|
|
214
|
+
`kb_entries: ${kbEntryCount}`,
|
|
215
|
+
`owner: ${row.owner_name}`,
|
|
216
|
+
].join("\n");
|
|
217
|
+
await (0, kb_1.kbUpsert)(pool, {
|
|
218
|
+
domain,
|
|
219
|
+
key: "pm-summary",
|
|
220
|
+
content,
|
|
221
|
+
created_by: "pm-pipeline",
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
// ── Reporting ──
|
|
225
|
+
function printAuditReport(results) {
|
|
226
|
+
const created = results.filter((r) => r.action === "created");
|
|
227
|
+
const skipped = results.filter((r) => r.action === "skipped");
|
|
228
|
+
const errors = results.filter((r) => r.action === "error");
|
|
229
|
+
console.log(chalk_1.default.cyan.bold(`\n📊 서비스 이식 결과\n`));
|
|
230
|
+
console.log(` ${chalk_1.default.green(`✓ 등록: ${created.length}`)} ${chalk_1.default.yellow(`⊘ 건너뜀: ${skipped.length}`)} ${chalk_1.default.red(`✗ 오류: ${errors.length}`)}`);
|
|
231
|
+
for (const r of results) {
|
|
232
|
+
const icon = r.action === "created" ? chalk_1.default.green("✓") :
|
|
233
|
+
r.action === "skipped" ? chalk_1.default.yellow("⊘") :
|
|
234
|
+
chalk_1.default.red("✗");
|
|
235
|
+
console.log(`\n ${icon} ${chalk_1.default.bold(r.domain)}`);
|
|
236
|
+
if (r.action === "created") {
|
|
237
|
+
console.log(chalk_1.default.gray(` service_id: ${r.projectId}\n` +
|
|
238
|
+
` mapped: ${r.audit.mapped.map((m) => m.key).join(", ") || "(없음)"}\n` +
|
|
239
|
+
` metadata: ${r.audit.metadata.map((m) => m.key).join(", ") || "(없음)"}\n` +
|
|
240
|
+
` kb-only: ${r.audit.kbOnly.map((k) => `${k.key}(${k.count})`).join(", ") || "(없음)"}`));
|
|
241
|
+
}
|
|
242
|
+
if (r.action === "error") {
|
|
243
|
+
console.log(chalk_1.default.red(` ${r.error}`));
|
|
244
|
+
}
|
|
245
|
+
// Warnings
|
|
246
|
+
if (r.audit.warnings.length > 0) {
|
|
247
|
+
console.log(chalk_1.default.yellow(" ⚠ 비표준 키:"));
|
|
248
|
+
for (const w of r.audit.warnings) {
|
|
249
|
+
console.log(chalk_1.default.yellow(` ${w.key}: ${w.reason}`));
|
|
250
|
+
console.log(chalk_1.default.gray(` → ${w.suggestion}`));
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
// Missing required
|
|
254
|
+
if (r.audit.missingRequired.length > 0) {
|
|
255
|
+
console.log(chalk_1.default.yellow(` ⚠ 필수 키 누락: ${r.audit.missingRequired.join(", ")}`));
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
console.log();
|
|
259
|
+
}
|
|
260
|
+
function printDryRunReport(results) {
|
|
261
|
+
console.log(chalk_1.default.cyan.bold(`\n🔍 서비스 이식 미리보기 (dry-run)\n`));
|
|
262
|
+
console.log(chalk_1.default.gray(` DB 변경 없이 audit 결과만 표시합니다.\n`));
|
|
263
|
+
for (const r of results) {
|
|
264
|
+
const row = buildServiceProjectRow(r.audit);
|
|
265
|
+
console.log(chalk_1.default.bold(` ${r.domain}`));
|
|
266
|
+
console.log(chalk_1.default.gray(` → project_name: ${row.project_name}\n` +
|
|
267
|
+
` → owner_name: ${row.owner_name}\n` +
|
|
268
|
+
` → lifecycle: ${row.lifecycle}, status: ${row.status}\n` +
|
|
269
|
+
` → launched_at: ${row.launched_at ?? 'NOW()'}\n` +
|
|
270
|
+
` → metadata keys: ${Object.keys(row.metadata).join(", ")}`));
|
|
271
|
+
if (r.audit.kbOnly.length > 0) {
|
|
272
|
+
console.log(chalk_1.default.gray(` → kb-only: ${r.audit.kbOnly.map((k) => `${k.key}(${k.count})`).join(", ")}`));
|
|
273
|
+
}
|
|
274
|
+
if (r.audit.warnings.length > 0) {
|
|
275
|
+
for (const w of r.audit.warnings) {
|
|
276
|
+
console.log(chalk_1.default.yellow(` ⚠ ${w.key}: ${w.reason}`));
|
|
277
|
+
console.log(chalk_1.default.gray(` → ${w.suggestion}`));
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
if (r.audit.missingRequired.length > 0) {
|
|
281
|
+
console.log(chalk_1.default.yellow(` ⚠ 필수 키 누락: ${r.audit.missingRequired.join(", ")}`));
|
|
282
|
+
}
|
|
283
|
+
console.log();
|
|
284
|
+
}
|
|
285
|
+
console.log(chalk_1.default.cyan(` 총 ${results.length}개 서비스 이식 대상\n`));
|
|
286
|
+
}
|
|
287
|
+
async function getServiceProjectByDomain(pool, domain) {
|
|
288
|
+
const result = await pool.query(`SELECT service_id, project_name, service_domain, owner_name, owner_contact,
|
|
289
|
+
current_phase, infra_phase, status, lifecycle,
|
|
290
|
+
launched_at::text, metadata,
|
|
291
|
+
created_at::text, updated_at::text
|
|
292
|
+
FROM semo.services
|
|
293
|
+
WHERE service_domain = $1`, [domain]);
|
|
294
|
+
return result.rows[0] ?? null;
|
|
295
|
+
}
|
|
296
|
+
async function updateServiceProject(pool, domain, updates) {
|
|
297
|
+
const sets = [];
|
|
298
|
+
const params = [];
|
|
299
|
+
let idx = 1;
|
|
300
|
+
if (updates.project_name !== undefined) {
|
|
301
|
+
sets.push(`project_name = $${idx++}`);
|
|
302
|
+
params.push(updates.project_name);
|
|
303
|
+
}
|
|
304
|
+
if (updates.owner_name !== undefined) {
|
|
305
|
+
sets.push(`owner_name = $${idx++}`);
|
|
306
|
+
params.push(updates.owner_name);
|
|
307
|
+
}
|
|
308
|
+
if (updates.current_phase !== undefined) {
|
|
309
|
+
sets.push(`current_phase = $${idx++}`);
|
|
310
|
+
params.push(updates.current_phase);
|
|
311
|
+
}
|
|
312
|
+
if (updates.status !== undefined) {
|
|
313
|
+
sets.push(`status = $${idx++}`);
|
|
314
|
+
params.push(updates.status);
|
|
315
|
+
}
|
|
316
|
+
if (updates.lifecycle !== undefined) {
|
|
317
|
+
sets.push(`lifecycle = $${idx++}`);
|
|
318
|
+
params.push(updates.lifecycle);
|
|
319
|
+
// ops 전환 시 launched_at 자동 설정
|
|
320
|
+
if (updates.lifecycle === "ops") {
|
|
321
|
+
sets.push(`launched_at = COALESCE(launched_at, NOW())`);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
if (updates.metadata !== undefined) {
|
|
325
|
+
sets.push(`metadata = COALESCE(metadata, '{}'::jsonb) || $${idx++}::jsonb`);
|
|
326
|
+
params.push(JSON.stringify(updates.metadata));
|
|
327
|
+
}
|
|
328
|
+
if (sets.length === 0)
|
|
329
|
+
return getServiceProjectByDomain(pool, domain);
|
|
330
|
+
params.push(domain);
|
|
331
|
+
const result = await pool.query(`UPDATE semo.services SET ${sets.join(", ")}
|
|
332
|
+
WHERE service_domain = $${idx}
|
|
333
|
+
RETURNING service_id, project_name, service_domain, owner_name, owner_contact,
|
|
334
|
+
current_phase, infra_phase, status, lifecycle,
|
|
335
|
+
launched_at::text, metadata, created_at::text, updated_at::text`, params);
|
|
336
|
+
return result.rows[0] ?? null;
|
|
337
|
+
}
|
|
338
|
+
async function diagnoseServiceStatus(pool, domain) {
|
|
339
|
+
// 1. KB 도메인 존재 확인
|
|
340
|
+
const ontoResult = await pool.query(`SELECT domain, description, created_at::text
|
|
341
|
+
FROM semo.ontology WHERE domain = $1 AND entity_type = 'service'`, [domain]);
|
|
342
|
+
const kbExists = ontoResult.rows.length > 0;
|
|
343
|
+
// 2. services 조회
|
|
344
|
+
const sp = await getServiceProjectByDomain(pool, domain);
|
|
345
|
+
// 3. 분류
|
|
346
|
+
if (!kbExists && !sp) {
|
|
347
|
+
return {
|
|
348
|
+
domain,
|
|
349
|
+
verdict: "missing",
|
|
350
|
+
kbEntryCount: 0,
|
|
351
|
+
kbKeys: [],
|
|
352
|
+
serviceProject: null,
|
|
353
|
+
mismatches: [],
|
|
354
|
+
missingRequired: [],
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
if (!kbExists && sp) {
|
|
358
|
+
return {
|
|
359
|
+
domain,
|
|
360
|
+
verdict: "sp-only",
|
|
361
|
+
kbEntryCount: 0,
|
|
362
|
+
kbKeys: [],
|
|
363
|
+
serviceProject: sp,
|
|
364
|
+
mismatches: [],
|
|
365
|
+
missingRequired: [],
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
// KB 엔트리 목록
|
|
369
|
+
const entriesResult = await pool.query(`SELECT DISTINCT key FROM semo.knowledge_base WHERE domain = $1 ORDER BY key`, [domain]);
|
|
370
|
+
const kbKeys = entriesResult.rows.map((r) => r.key);
|
|
371
|
+
const countResult = await pool.query(`SELECT COUNT(*)::int as cnt FROM semo.knowledge_base WHERE domain = $1`, [domain]);
|
|
372
|
+
const kbEntryCount = countResult.rows[0]?.cnt ?? 0;
|
|
373
|
+
if (kbExists && !sp) {
|
|
374
|
+
// 필수 키 체크
|
|
375
|
+
const missingRequired = [];
|
|
376
|
+
for (const req of ["base-information", "po", "status"]) {
|
|
377
|
+
if (!kbKeys.includes(req))
|
|
378
|
+
missingRequired.push(req);
|
|
379
|
+
}
|
|
380
|
+
return {
|
|
381
|
+
domain,
|
|
382
|
+
verdict: "kb-only",
|
|
383
|
+
kbEntryCount,
|
|
384
|
+
kbKeys,
|
|
385
|
+
serviceProject: null,
|
|
386
|
+
mismatches: [],
|
|
387
|
+
missingRequired,
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
// 4. 양쪽 다 존재 → 교차 비교
|
|
391
|
+
const mismatches = [];
|
|
392
|
+
// KB status vs services status/lifecycle
|
|
393
|
+
const statusEntry = await pool.query(`SELECT content FROM semo.knowledge_base
|
|
394
|
+
WHERE domain = $1 AND key = 'status' AND (sub_key IS NULL OR sub_key = '')
|
|
395
|
+
LIMIT 1`, [domain]);
|
|
396
|
+
if (statusEntry.rows.length > 0 && sp) {
|
|
397
|
+
const kbStatus = statusEntry.rows[0].content.split("\n")[0].trim().toLowerCase();
|
|
398
|
+
const expected = exports.STATUS_MAP[kbStatus];
|
|
399
|
+
if (expected) {
|
|
400
|
+
if (expected.lifecycle !== sp.lifecycle) {
|
|
401
|
+
mismatches.push({
|
|
402
|
+
field: "lifecycle",
|
|
403
|
+
kbValue: `status=${kbStatus} → lifecycle=${expected.lifecycle}`,
|
|
404
|
+
spValue: sp.lifecycle,
|
|
405
|
+
expected: expected.lifecycle,
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
if (expected.status !== sp.status) {
|
|
409
|
+
mismatches.push({
|
|
410
|
+
field: "status",
|
|
411
|
+
kbValue: `status=${kbStatus} → status=${expected.status}`,
|
|
412
|
+
spValue: sp.status,
|
|
413
|
+
expected: expected.status,
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
// KB po vs services owner_name
|
|
419
|
+
const poEntry = await pool.query(`SELECT content FROM semo.knowledge_base
|
|
420
|
+
WHERE domain = $1 AND key = 'po' AND (sub_key IS NULL OR sub_key = '')
|
|
421
|
+
LIMIT 1`, [domain]);
|
|
422
|
+
if (poEntry.rows.length > 0 && sp) {
|
|
423
|
+
const kbPo = poEntry.rows[0].content.split("\n")[0].trim().toLowerCase();
|
|
424
|
+
if (kbPo !== sp.owner_name.toLowerCase()) {
|
|
425
|
+
mismatches.push({
|
|
426
|
+
field: "owner",
|
|
427
|
+
kbValue: kbPo,
|
|
428
|
+
spValue: sp.owner_name,
|
|
429
|
+
expected: kbPo,
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
// section 존재 여부 vs current_phase
|
|
434
|
+
if (sp && sp.lifecycle === "build" && sp.current_phase > 0) {
|
|
435
|
+
const sectionResult = await pool.query(`SELECT COUNT(*)::int as cnt
|
|
436
|
+
FROM semo.service_sections
|
|
437
|
+
WHERE service_id = $1 AND status = 'approved'`, [sp.service_id]);
|
|
438
|
+
const approvedSections = sectionResult.rows[0]?.cnt ?? 0;
|
|
439
|
+
if (approvedSections === 0 && sp.current_phase > 0) {
|
|
440
|
+
mismatches.push({
|
|
441
|
+
field: "phase",
|
|
442
|
+
kbValue: "승인된 섹션 0개",
|
|
443
|
+
spValue: `current_phase=${sp.current_phase}`,
|
|
444
|
+
expected: "phase=0 (승인된 섹션 없음)",
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
return {
|
|
449
|
+
domain,
|
|
450
|
+
verdict: mismatches.length > 0 ? "mismatch" : "healthy",
|
|
451
|
+
kbEntryCount,
|
|
452
|
+
kbKeys,
|
|
453
|
+
serviceProject: sp,
|
|
454
|
+
mismatches,
|
|
455
|
+
missingRequired: [],
|
|
456
|
+
};
|
|
457
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
npx --no -- commitlint --edit $1
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import eslint from '@eslint/js';
|
|
2
|
+
import tseslint from 'typescript-eslint';
|
|
3
|
+
|
|
4
|
+
export default tseslint.config(
|
|
5
|
+
eslint.configs.recommended,
|
|
6
|
+
...tseslint.configs.recommended,
|
|
7
|
+
{
|
|
8
|
+
ignores: ['dist/', 'node_modules/'],
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
rules: {
|
|
12
|
+
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
|
|
13
|
+
'@typescript-eslint/no-explicit-any': 'warn',
|
|
14
|
+
'@typescript-eslint/no-require-imports': 'off',
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
name: PR Quality Gate
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
pull_request:
|
|
5
|
+
branches: [dev, main]
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
quality-check:
|
|
9
|
+
name: Lint + Type Check + Build
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
steps:
|
|
12
|
+
- uses: actions/checkout@v4
|
|
13
|
+
- uses: actions/setup-node@v4
|
|
14
|
+
with:
|
|
15
|
+
node-version: 20
|
|
16
|
+
- run: npm ci
|
|
17
|
+
- run: npm run lint
|
|
18
|
+
- run: npx tsc --noEmit
|
|
19
|
+
- run: npm run build
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
npx lint-staged
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
npm run lint && npx tsc --noEmit
|