@team-semicolon/semo-cli 4.3.0 → 4.4.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/audit.d.ts +12 -4
- package/dist/commands/audit.js +219 -27
- package/dist/commands/bots.js +60 -37
- package/dist/commands/context.d.ts +2 -2
- package/dist/commands/context.js +11 -30
- package/dist/commands/get.js +31 -17
- package/dist/commands/memory.js +10 -8
- package/dist/commands/skill-sync.js +1 -1
- package/dist/commands/test.d.ts +11 -0
- package/dist/commands/test.js +520 -0
- package/dist/database.js +2 -2
- package/dist/index.js +333 -103
- package/dist/kb.d.ts +69 -0
- package/dist/kb.js +265 -16
- package/dist/slack-notify.d.ts +8 -0
- package/dist/slack-notify.js +45 -0
- package/dist/test-runners/workspace-audit.d.ts +17 -0
- package/dist/test-runners/workspace-audit.js +366 -0
- package/package.json +1 -1
package/dist/kb.js
CHANGED
|
@@ -54,9 +54,24 @@ exports.ontoShow = ontoShow;
|
|
|
54
54
|
exports.ontoListTypes = ontoListTypes;
|
|
55
55
|
exports.ontoValidate = ontoValidate;
|
|
56
56
|
exports.kbDigest = kbDigest;
|
|
57
|
+
exports.kbGet = kbGet;
|
|
58
|
+
exports.kbUpsert = kbUpsert;
|
|
59
|
+
exports.ontoListSchema = ontoListSchema;
|
|
60
|
+
exports.ontoRoutingTable = ontoRoutingTable;
|
|
61
|
+
exports.ontoListServices = ontoListServices;
|
|
62
|
+
exports.ontoListInstances = ontoListInstances;
|
|
57
63
|
exports.ontoPullToLocal = ontoPullToLocal;
|
|
58
64
|
const fs = __importStar(require("fs"));
|
|
59
65
|
const path = __importStar(require("path"));
|
|
66
|
+
function splitKey(combinedKey) {
|
|
67
|
+
const idx = combinedKey.indexOf('/');
|
|
68
|
+
if (idx === -1)
|
|
69
|
+
return { key: combinedKey, subKey: '' };
|
|
70
|
+
return { key: combinedKey.substring(0, idx), subKey: combinedKey.substring(idx + 1) };
|
|
71
|
+
}
|
|
72
|
+
function combineKey(key, subKey) {
|
|
73
|
+
return subKey ? `${key}/${subKey}` : key;
|
|
74
|
+
}
|
|
60
75
|
// ============================================================
|
|
61
76
|
// Embedding
|
|
62
77
|
// ============================================================
|
|
@@ -185,7 +200,7 @@ async function kbPull(pool, domain, cwd) {
|
|
|
185
200
|
const client = await pool.connect();
|
|
186
201
|
try {
|
|
187
202
|
let query = `
|
|
188
|
-
SELECT domain, key, content, metadata, created_by, version,
|
|
203
|
+
SELECT domain, key, sub_key, content, metadata, created_by, version,
|
|
189
204
|
created_at::text, updated_at::text
|
|
190
205
|
FROM semo.knowledge_base
|
|
191
206
|
`;
|
|
@@ -242,12 +257,13 @@ async function kbPush(pool, entries, createdBy, cwd) {
|
|
|
242
257
|
try {
|
|
243
258
|
const embedding = embeddings[i];
|
|
244
259
|
const embeddingStr = embedding ? `[${embedding.join(",")}]` : null;
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
260
|
+
const { key: flatKey, subKey } = splitKey(entry.key);
|
|
261
|
+
await client.query(`INSERT INTO semo.knowledge_base (domain, key, sub_key, content, metadata, created_by, embedding)
|
|
262
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7::vector)
|
|
263
|
+
ON CONFLICT (domain, key, sub_key) DO UPDATE SET
|
|
248
264
|
content = EXCLUDED.content,
|
|
249
265
|
metadata = EXCLUDED.metadata,
|
|
250
|
-
embedding = EXCLUDED.embedding`, [entry.domain,
|
|
266
|
+
embedding = EXCLUDED.embedding`, [entry.domain, flatKey, subKey, entry.content, JSON.stringify(entry.metadata || {}), entry.created_by || createdBy || "unknown", embeddingStr]);
|
|
251
267
|
upserted++;
|
|
252
268
|
}
|
|
253
269
|
catch (err) {
|
|
@@ -307,7 +323,7 @@ async function kbList(pool, options) {
|
|
|
307
323
|
const limit = options.limit || 50;
|
|
308
324
|
const offset = options.offset || 0;
|
|
309
325
|
try {
|
|
310
|
-
let query = "SELECT domain, key, content, metadata, created_by, version, updated_at::text FROM semo.knowledge_base";
|
|
326
|
+
let query = "SELECT domain, key, sub_key, content, metadata, created_by, version, updated_at::text FROM semo.knowledge_base";
|
|
311
327
|
const params = [];
|
|
312
328
|
let paramIdx = 1;
|
|
313
329
|
if (options.domain) {
|
|
@@ -322,7 +338,7 @@ async function kbList(pool, options) {
|
|
|
322
338
|
params.push(serviceDomains);
|
|
323
339
|
}
|
|
324
340
|
}
|
|
325
|
-
query += ` ORDER BY domain, key LIMIT $${paramIdx++} OFFSET $${paramIdx++}`;
|
|
341
|
+
query += ` ORDER BY domain, key, sub_key LIMIT $${paramIdx++} OFFSET $${paramIdx++}`;
|
|
326
342
|
params.push(limit, offset);
|
|
327
343
|
const result = await client.query(query, params);
|
|
328
344
|
return result.rows;
|
|
@@ -352,7 +368,7 @@ async function kbSearch(pool, query, options) {
|
|
|
352
368
|
const embeddingStr = `[${queryEmbedding.join(",")}]`;
|
|
353
369
|
// Vector search on shared KB
|
|
354
370
|
let sql = `
|
|
355
|
-
SELECT domain, key, content, metadata, created_by, version, updated_at::text,
|
|
371
|
+
SELECT domain, key, sub_key, content, metadata, created_by, version, updated_at::text,
|
|
356
372
|
1 - (embedding <=> $1::vector) as score
|
|
357
373
|
FROM semo.knowledge_base
|
|
358
374
|
WHERE embedding IS NOT NULL
|
|
@@ -386,7 +402,7 @@ async function kbSearch(pool, query, options) {
|
|
|
386
402
|
// Build per-token ILIKE conditions + count matching tokens for scoring
|
|
387
403
|
const tokenConditions = tokens.map(token => {
|
|
388
404
|
textParams.push(`%${token}%`);
|
|
389
|
-
return `(CASE WHEN content ILIKE $${tIdx} OR key ILIKE $${tIdx++} THEN 1 ELSE 0 END)`;
|
|
405
|
+
return `(CASE WHEN content ILIKE $${tIdx} OR key ILIKE $${tIdx} OR sub_key ILIKE $${tIdx++} THEN 1 ELSE 0 END)`;
|
|
390
406
|
});
|
|
391
407
|
// Score = 0.7 base + 0.15 * (matched_tokens / total_tokens), capped at 0.95
|
|
392
408
|
const matchCountExpr = tokenConditions.length > 0
|
|
@@ -394,12 +410,12 @@ async function kbSearch(pool, query, options) {
|
|
|
394
410
|
: "0";
|
|
395
411
|
const scoreExpr = `LEAST(0.95, 0.7 + 0.15 * (${matchCountExpr})::float / ${Math.max(tokens.length, 1)})`;
|
|
396
412
|
// WHERE: any token matches
|
|
397
|
-
const whereTokens = tokens.map((_, i) => `(content ILIKE $${i + 1} OR key ILIKE $${i + 1})`);
|
|
413
|
+
const whereTokens = tokens.map((_, i) => `(content ILIKE $${i + 1} OR key ILIKE $${i + 1} OR sub_key ILIKE $${i + 1})`);
|
|
398
414
|
const whereClause = whereTokens.length > 0
|
|
399
415
|
? whereTokens.join(" OR ")
|
|
400
416
|
: "FALSE";
|
|
401
417
|
let textSql = `
|
|
402
|
-
SELECT domain, key, content, metadata, created_by, version, updated_at::text,
|
|
418
|
+
SELECT domain, key, sub_key, content, metadata, created_by, version, updated_at::text,
|
|
403
419
|
${scoreExpr} as score
|
|
404
420
|
FROM semo.knowledge_base
|
|
405
421
|
WHERE ${whereClause}
|
|
@@ -419,10 +435,10 @@ async function kbSearch(pool, query, options) {
|
|
|
419
435
|
// Deduplicate by domain/key; if already in semantic results, boost its score
|
|
420
436
|
const resultMap = new Map();
|
|
421
437
|
for (const r of results) {
|
|
422
|
-
resultMap.set(`${r.domain}/${r.key}`, r);
|
|
438
|
+
resultMap.set(`${r.domain}/${r.key}/${r.sub_key}`, r);
|
|
423
439
|
}
|
|
424
440
|
for (const row of textResult.rows) {
|
|
425
|
-
const k = `${row.domain}/${row.key}`;
|
|
441
|
+
const k = `${row.domain}/${row.key}/${row.sub_key}`;
|
|
426
442
|
const existing = resultMap.get(k);
|
|
427
443
|
if (existing) {
|
|
428
444
|
// Boost: semantic match + text match = highest relevance
|
|
@@ -440,9 +456,9 @@ async function kbSearch(pool, query, options) {
|
|
|
440
456
|
catch {
|
|
441
457
|
// Ultimate fallback: simple ILIKE
|
|
442
458
|
let sql = `
|
|
443
|
-
SELECT domain, key, content, metadata, created_by, version, updated_at::text
|
|
459
|
+
SELECT domain, key, sub_key, content, metadata, created_by, version, updated_at::text
|
|
444
460
|
FROM semo.knowledge_base
|
|
445
|
-
WHERE content ILIKE $1 OR key ILIKE $1
|
|
461
|
+
WHERE content ILIKE $1 OR key ILIKE $1 OR sub_key ILIKE $1
|
|
446
462
|
`;
|
|
447
463
|
const params = [`%${query}%`];
|
|
448
464
|
let paramIdx = 2;
|
|
@@ -603,7 +619,7 @@ async function kbDigest(pool, since, domain) {
|
|
|
603
619
|
const generatedAt = new Date().toISOString();
|
|
604
620
|
try {
|
|
605
621
|
let sql = `
|
|
606
|
-
SELECT domain, key, content, version, updated_at::text,
|
|
622
|
+
SELECT domain, key, sub_key, content, version, updated_at::text,
|
|
607
623
|
CASE WHEN created_at > $1 THEN 'new' ELSE 'updated' END as change_type
|
|
608
624
|
FROM semo.knowledge_base
|
|
609
625
|
WHERE updated_at > $1 OR created_at > $1
|
|
@@ -622,6 +638,239 @@ async function kbDigest(pool, since, domain) {
|
|
|
622
638
|
client.release();
|
|
623
639
|
}
|
|
624
640
|
}
|
|
641
|
+
// ============================================================
|
|
642
|
+
// KB Get / Upsert (CLI counterparts of MCP kb functions)
|
|
643
|
+
// ============================================================
|
|
644
|
+
/**
|
|
645
|
+
* Get a single KB entry by domain + key + sub_key
|
|
646
|
+
*/
|
|
647
|
+
async function kbGet(pool, domain, rawKey, rawSubKey) {
|
|
648
|
+
let key;
|
|
649
|
+
let subKey;
|
|
650
|
+
if (rawSubKey !== undefined) {
|
|
651
|
+
key = rawKey;
|
|
652
|
+
subKey = rawSubKey;
|
|
653
|
+
}
|
|
654
|
+
else {
|
|
655
|
+
const split = splitKey(rawKey);
|
|
656
|
+
key = split.key;
|
|
657
|
+
subKey = split.subKey;
|
|
658
|
+
}
|
|
659
|
+
const client = await pool.connect();
|
|
660
|
+
try {
|
|
661
|
+
const result = await client.query(`SELECT domain, key, sub_key, content, metadata, created_by, version,
|
|
662
|
+
created_at::text, updated_at::text
|
|
663
|
+
FROM semo.knowledge_base
|
|
664
|
+
WHERE domain = $1 AND key = $2 AND sub_key = $3`, [domain, key, subKey]);
|
|
665
|
+
return result.rows[0] || null;
|
|
666
|
+
}
|
|
667
|
+
finally {
|
|
668
|
+
client.release();
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
/**
|
|
672
|
+
* Upsert a single KB entry with domain/key validation and embedding generation
|
|
673
|
+
*/
|
|
674
|
+
async function kbUpsert(pool, entry) {
|
|
675
|
+
let key;
|
|
676
|
+
let subKey;
|
|
677
|
+
if (entry.sub_key !== undefined) {
|
|
678
|
+
key = entry.key;
|
|
679
|
+
subKey = entry.sub_key;
|
|
680
|
+
}
|
|
681
|
+
else {
|
|
682
|
+
const split = splitKey(entry.key);
|
|
683
|
+
key = split.key;
|
|
684
|
+
subKey = split.subKey;
|
|
685
|
+
}
|
|
686
|
+
// Domain validation
|
|
687
|
+
const client = await pool.connect();
|
|
688
|
+
try {
|
|
689
|
+
const ontoCheck = await client.query("SELECT domain FROM semo.ontology WHERE domain = $1", [entry.domain]);
|
|
690
|
+
if (ontoCheck.rows.length === 0) {
|
|
691
|
+
const known = await client.query("SELECT domain FROM semo.ontology ORDER BY domain");
|
|
692
|
+
const knownDomains = known.rows.map((r) => r.domain);
|
|
693
|
+
return {
|
|
694
|
+
success: false,
|
|
695
|
+
error: `도메인 '${entry.domain}'은(는) 온톨로지에 등록되지 않았습니다. 등록된 도메인: [${knownDomains.join(", ")}]`,
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
finally {
|
|
700
|
+
client.release();
|
|
701
|
+
}
|
|
702
|
+
// Key validation against type schema
|
|
703
|
+
try {
|
|
704
|
+
const schemaClient = await pool.connect();
|
|
705
|
+
try {
|
|
706
|
+
const typeResult = await schemaClient.query("SELECT entity_type FROM semo.ontology WHERE domain = $1 AND entity_type IS NOT NULL", [entry.domain]);
|
|
707
|
+
if (typeResult.rows.length > 0) {
|
|
708
|
+
const entityType = typeResult.rows[0].entity_type;
|
|
709
|
+
const schemaResult = await schemaClient.query("SELECT scheme_key, COALESCE(key_type, 'singleton') as key_type FROM semo.kb_type_schema WHERE type_key = $1", [entityType]);
|
|
710
|
+
const schemas = schemaResult.rows;
|
|
711
|
+
if (schemas.length > 0) {
|
|
712
|
+
const match = schemas.find(s => s.scheme_key === key);
|
|
713
|
+
if (!match) {
|
|
714
|
+
const allowedKeys = schemas.map(s => s.key_type === "singleton" ? s.scheme_key : `${s.scheme_key}/{sub_key}`);
|
|
715
|
+
return {
|
|
716
|
+
success: false,
|
|
717
|
+
error: `키 '${key}'은(는) '${entityType}' 타입의 스키마에 허용되지 않습니다. 허용 키: [${allowedKeys.join(", ")}]`,
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
if (match.key_type === "singleton" && subKey !== "") {
|
|
721
|
+
return { success: false, error: `키 '${key}'은(는) singleton이므로 sub_key가 비어야 합니다.` };
|
|
722
|
+
}
|
|
723
|
+
if (match.key_type === "collection" && subKey === "") {
|
|
724
|
+
return { success: false, error: `키 '${key}'은(는) collection이므로 sub_key가 필요합니다.` };
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
finally {
|
|
730
|
+
schemaClient.release();
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
catch {
|
|
734
|
+
// Validation failure is non-fatal
|
|
735
|
+
}
|
|
736
|
+
// Generate embedding (mandatory)
|
|
737
|
+
const fullKey = combineKey(key, subKey);
|
|
738
|
+
const text = `${fullKey}: ${entry.content}`;
|
|
739
|
+
const embedding = await generateEmbedding(text);
|
|
740
|
+
if (!embedding) {
|
|
741
|
+
const reason = process.env.OPENAI_API_KEY
|
|
742
|
+
? "임베딩 생성 API 호출 실패"
|
|
743
|
+
: "OPENAI_API_KEY가 설정되지 않음";
|
|
744
|
+
return {
|
|
745
|
+
success: false,
|
|
746
|
+
error: `임베딩 생성 실패 — ${reason}. 임베딩 없이 저장하면 벡터 검색에서 누락되므로 저장이 거부됩니다.`,
|
|
747
|
+
};
|
|
748
|
+
}
|
|
749
|
+
const embeddingStr = `[${embedding.join(",")}]`;
|
|
750
|
+
const writeClient = await pool.connect();
|
|
751
|
+
try {
|
|
752
|
+
await writeClient.query(`INSERT INTO semo.knowledge_base (domain, key, sub_key, content, metadata, created_by, embedding)
|
|
753
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7::vector)
|
|
754
|
+
ON CONFLICT (domain, key, sub_key) DO UPDATE SET
|
|
755
|
+
content = EXCLUDED.content,
|
|
756
|
+
metadata = EXCLUDED.metadata,
|
|
757
|
+
embedding = EXCLUDED.embedding`, [
|
|
758
|
+
entry.domain,
|
|
759
|
+
key,
|
|
760
|
+
subKey,
|
|
761
|
+
entry.content,
|
|
762
|
+
JSON.stringify(entry.metadata || {}),
|
|
763
|
+
entry.created_by || "semo-cli",
|
|
764
|
+
embeddingStr,
|
|
765
|
+
]);
|
|
766
|
+
return { success: true };
|
|
767
|
+
}
|
|
768
|
+
catch (err) {
|
|
769
|
+
return { success: false, error: String(err) };
|
|
770
|
+
}
|
|
771
|
+
finally {
|
|
772
|
+
writeClient.release();
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
/**
|
|
776
|
+
* List type schema entries for a given entity type
|
|
777
|
+
*/
|
|
778
|
+
async function ontoListSchema(pool, typeKey) {
|
|
779
|
+
const client = await pool.connect();
|
|
780
|
+
try {
|
|
781
|
+
const result = await client.query(`SELECT type_key, scheme_key, scheme_description, required, value_hint, sort_order,
|
|
782
|
+
COALESCE(key_type, 'singleton') as key_type
|
|
783
|
+
FROM semo.kb_type_schema
|
|
784
|
+
WHERE type_key = $1
|
|
785
|
+
ORDER BY sort_order, scheme_key`, [typeKey]);
|
|
786
|
+
return result.rows;
|
|
787
|
+
}
|
|
788
|
+
catch {
|
|
789
|
+
return [];
|
|
790
|
+
}
|
|
791
|
+
finally {
|
|
792
|
+
client.release();
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
/**
|
|
796
|
+
* Full domain→key routing table for bot auto-classification
|
|
797
|
+
*/
|
|
798
|
+
async function ontoRoutingTable(pool) {
|
|
799
|
+
const client = await pool.connect();
|
|
800
|
+
try {
|
|
801
|
+
const result = await client.query(`
|
|
802
|
+
SELECT
|
|
803
|
+
o.domain,
|
|
804
|
+
o.entity_type,
|
|
805
|
+
o.service,
|
|
806
|
+
o.description AS domain_description,
|
|
807
|
+
s.scheme_key,
|
|
808
|
+
s.key_type,
|
|
809
|
+
s.scheme_description,
|
|
810
|
+
s.value_hint
|
|
811
|
+
FROM semo.ontology o
|
|
812
|
+
JOIN semo.kb_type_schema s ON s.type_key = o.entity_type
|
|
813
|
+
ORDER BY o.domain, s.sort_order
|
|
814
|
+
`);
|
|
815
|
+
return result.rows;
|
|
816
|
+
}
|
|
817
|
+
finally {
|
|
818
|
+
client.release();
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
/**
|
|
822
|
+
* List services grouped with domain counts
|
|
823
|
+
*/
|
|
824
|
+
async function ontoListServices(pool) {
|
|
825
|
+
const client = await pool.connect();
|
|
826
|
+
try {
|
|
827
|
+
const result = await client.query(`
|
|
828
|
+
SELECT service, COUNT(*)::int as domain_count,
|
|
829
|
+
ARRAY_AGG(domain ORDER BY domain) as domains
|
|
830
|
+
FROM semo.ontology
|
|
831
|
+
WHERE service IS NOT NULL
|
|
832
|
+
GROUP BY service
|
|
833
|
+
ORDER BY service
|
|
834
|
+
`);
|
|
835
|
+
return result.rows;
|
|
836
|
+
}
|
|
837
|
+
catch {
|
|
838
|
+
return [];
|
|
839
|
+
}
|
|
840
|
+
finally {
|
|
841
|
+
client.release();
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
/**
|
|
845
|
+
* List service instances (entity_type = 'service')
|
|
846
|
+
*/
|
|
847
|
+
async function ontoListInstances(pool) {
|
|
848
|
+
const client = await pool.connect();
|
|
849
|
+
try {
|
|
850
|
+
const result = await client.query(`
|
|
851
|
+
SELECT o.domain, o.description, o.service, o.tags,
|
|
852
|
+
COALESCE(
|
|
853
|
+
(SELECT ARRAY_AGG(o2.domain ORDER BY o2.domain)
|
|
854
|
+
FROM semo.ontology o2
|
|
855
|
+
WHERE o2.service = o.service AND o2.domain != o.domain),
|
|
856
|
+
'{}'
|
|
857
|
+
) as scoped_domains,
|
|
858
|
+
(SELECT COUNT(*)::int FROM semo.knowledge_base k
|
|
859
|
+
WHERE k.domain = o.domain
|
|
860
|
+
OR k.domain LIKE o.service || '.%') as entry_count
|
|
861
|
+
FROM semo.ontology o
|
|
862
|
+
WHERE o.entity_type = 'service'
|
|
863
|
+
ORDER BY o.domain
|
|
864
|
+
`);
|
|
865
|
+
return result.rows;
|
|
866
|
+
}
|
|
867
|
+
catch {
|
|
868
|
+
return [];
|
|
869
|
+
}
|
|
870
|
+
finally {
|
|
871
|
+
client.release();
|
|
872
|
+
}
|
|
873
|
+
}
|
|
625
874
|
/**
|
|
626
875
|
* Write ontology schemas to local cache
|
|
627
876
|
*/
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slack 알림 유틸리티
|
|
3
|
+
*
|
|
4
|
+
* SLACK_WEBHOOK 환경변수에서 URL을 읽어 알림 전송.
|
|
5
|
+
* ~/.semo.env에서 자동 로드됨 (database.ts loadSemoEnv).
|
|
6
|
+
*/
|
|
7
|
+
export declare function sendSlackNotification(message: string, webhookUrl?: string): Promise<boolean>;
|
|
8
|
+
export declare function formatTestFailureMessage(suiteId: string, runId: string, pass: number, fail: number, warn: number, failedLabels: string[]): string;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Slack 알림 유틸리티
|
|
4
|
+
*
|
|
5
|
+
* SLACK_WEBHOOK 환경변수에서 URL을 읽어 알림 전송.
|
|
6
|
+
* ~/.semo.env에서 자동 로드됨 (database.ts loadSemoEnv).
|
|
7
|
+
*/
|
|
8
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
+
exports.sendSlackNotification = sendSlackNotification;
|
|
10
|
+
exports.formatTestFailureMessage = formatTestFailureMessage;
|
|
11
|
+
async function sendSlackNotification(message, webhookUrl) {
|
|
12
|
+
const url = webhookUrl || process.env.SLACK_WEBHOOK;
|
|
13
|
+
if (!url)
|
|
14
|
+
return false;
|
|
15
|
+
try {
|
|
16
|
+
const res = await fetch(url, {
|
|
17
|
+
method: "POST",
|
|
18
|
+
headers: { "Content-Type": "application/json" },
|
|
19
|
+
body: JSON.stringify({ text: message }),
|
|
20
|
+
signal: AbortSignal.timeout(10000),
|
|
21
|
+
});
|
|
22
|
+
return res.ok;
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function formatTestFailureMessage(suiteId, runId, pass, fail, warn, failedLabels) {
|
|
29
|
+
const timestamp = new Date().toLocaleString("ko-KR", {
|
|
30
|
+
timeZone: "Asia/Seoul",
|
|
31
|
+
});
|
|
32
|
+
const topFailed = failedLabels.slice(0, 10);
|
|
33
|
+
const more = failedLabels.length > 10 ? `\n ... +${failedLabels.length - 10}건` : "";
|
|
34
|
+
return [
|
|
35
|
+
`🚨 *Test Suite Failed* — ${suiteId}`,
|
|
36
|
+
`Run: \`${runId.substring(0, 8)}\` | ${timestamp}`,
|
|
37
|
+
`Pass: ${pass} | Fail: ${fail} | Warn: ${warn}`,
|
|
38
|
+
``,
|
|
39
|
+
`Failed:`,
|
|
40
|
+
...topFailed.map((l) => ` • ${l}`),
|
|
41
|
+
more,
|
|
42
|
+
]
|
|
43
|
+
.filter(Boolean)
|
|
44
|
+
.join("\n");
|
|
45
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Declarative Workspace Audit Runner
|
|
3
|
+
*
|
|
4
|
+
* bot_workspace_standard 테이블에서 규칙을 로드하고,
|
|
5
|
+
* bot_status에서 봇 목록을 가져와 동적으로 TC를 생성·실행.
|
|
6
|
+
*
|
|
7
|
+
* 로컬 스크립트 의존성 없음 — DB가 SoT.
|
|
8
|
+
*/
|
|
9
|
+
import { Pool } from "pg";
|
|
10
|
+
export interface TestOutputLine {
|
|
11
|
+
type: "case" | "summary";
|
|
12
|
+
id?: string;
|
|
13
|
+
status?: "pass" | "fail" | "warn" | "skip";
|
|
14
|
+
label?: string;
|
|
15
|
+
detail?: string;
|
|
16
|
+
}
|
|
17
|
+
export declare function runDeclarativeWorkspaceAudit(pool: Pool): Promise<TestOutputLine[]>;
|