@tlog/mcp 0.1.3

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.
@@ -0,0 +1,884 @@
1
+ import { mkdir, readdir, unlink } from "node:fs/promises";
2
+ import { basename, join } from "node:path";
3
+ import { calculateBurndown, buildCaseFileName, buildDefaultCase, buildDefaultSuite, buildIdIndex, detectEntityType, extractTemplateFromDirectory, readYamlFile, resolveById, stringifyYaml, suiteSchema, testCaseSchema, validateCase, validateSuite, writeYamlFileAtomic } from "@tlog/shared";
4
+ import { normalizeCaseCandidate, normalizeSuiteCandidate } from "./normalization.js";
5
+ import { asObject, assertNoOverwrite, extractPromptMetadata, listYamlFiles, resolvePathInsideWorkspace, selectAppliedMode, summarizeDiff, toRelativePath } from "./utils.js";
6
+ function inferDescriptionFromText(value, fallbackTitle) {
7
+ if (typeof value === "string" && value.trim().length > 0) {
8
+ return value.trim();
9
+ }
10
+ return fallbackTitle;
11
+ }
12
+ function planPathForSuite(workspaceRoot, targetDir, _suite) {
13
+ const resolvedDir = resolvePathInsideWorkspace(workspaceRoot, targetDir);
14
+ return join(resolvedDir, "index.yaml");
15
+ }
16
+ const ID_PATTERN = /^[A-Za-z0-9_-]+$/;
17
+ function assertValidIdFormat(id) {
18
+ if (!ID_PATTERN.test(id)) {
19
+ throw new Error(`validation: invalid id format (${id}) expected ^[A-Za-z0-9_-]+$`);
20
+ }
21
+ }
22
+ async function assertNoDuplicateId(workspaceRoot, dir, id, currentPath) {
23
+ const resolvedDir = resolvePathInsideWorkspace(workspaceRoot, dir);
24
+ let index;
25
+ try {
26
+ index = await buildIdIndex(resolvedDir);
27
+ }
28
+ catch (error) {
29
+ if (String(error).includes("ENOENT")) {
30
+ return;
31
+ }
32
+ throw error;
33
+ }
34
+ const duplicate = index.duplicates.find((item) => item.id === id);
35
+ if (duplicate) {
36
+ const paths = duplicate.paths.map((path) => toRelativePath(workspaceRoot, path));
37
+ throw new Error(`conflict: duplicate id detected (${id}) paths=${paths.join(",")}`);
38
+ }
39
+ const existing = index.byId.get(id);
40
+ if (existing && existing.path !== currentPath) {
41
+ throw new Error(`conflict: id already exists (${id}) path=${toRelativePath(workspaceRoot, existing.path)}`);
42
+ }
43
+ }
44
+ function planPathForCase(workspaceRoot, targetDir, testCase) {
45
+ const resolvedDir = resolvePathInsideWorkspace(workspaceRoot, targetDir);
46
+ return join(resolvedDir, `${testCase.id}.testcase.yaml`);
47
+ }
48
+ async function writeEntityIfRequested(workspaceRoot, filePath, payload, write) {
49
+ const yamlText = stringifyYaml(payload);
50
+ if (!write) {
51
+ return {
52
+ yamlText,
53
+ writtenFile: null
54
+ };
55
+ }
56
+ const resolvedPath = resolvePathInsideWorkspace(workspaceRoot, filePath);
57
+ assertNoOverwrite(resolvedPath);
58
+ await writeYamlFileAtomic(resolvedPath, payload);
59
+ return {
60
+ yamlText,
61
+ writtenFile: toRelativePath(workspaceRoot, resolvedPath)
62
+ };
63
+ }
64
+ export async function createSuiteFromPromptCore(input) {
65
+ const extracted = extractPromptMetadata(input.instruction, "suite");
66
+ assertValidIdFormat(extracted.id);
67
+ await assertNoDuplicateId(input.workspaceRoot, input.targetDir, extracted.id);
68
+ const defaults = input.defaults ?? {};
69
+ const description = inferDescriptionFromText(defaults.description, extracted.title);
70
+ const normalized = normalizeSuiteCandidate({
71
+ id: extracted.id,
72
+ title: extracted.title,
73
+ description,
74
+ ...defaults
75
+ }, {
76
+ id: extracted.id,
77
+ title: extracted.title,
78
+ description,
79
+ ...defaults
80
+ });
81
+ const outputPath = planPathForSuite(input.workspaceRoot, input.targetDir, normalized.entity);
82
+ const writeResult = await writeEntityIfRequested(input.workspaceRoot, outputPath, normalized.entity, input.write ?? false);
83
+ return {
84
+ suite: normalized.entity,
85
+ yamlText: writeResult.yamlText,
86
+ writtenFile: writeResult.writtenFile,
87
+ warnings: [...extracted.warnings, ...normalized.warnings],
88
+ diffSummary: ["created suite"]
89
+ };
90
+ }
91
+ export async function createTestcaseFromPromptCore(input) {
92
+ function asStringArray(value) {
93
+ if (!Array.isArray(value)) {
94
+ return [];
95
+ }
96
+ return value.filter((item) => typeof item === "string" && item.trim().length > 0);
97
+ }
98
+ function buildTestsFromContext(context) {
99
+ if (!context) {
100
+ return [];
101
+ }
102
+ const testsRaw = Array.isArray(context.tests) ? context.tests : [];
103
+ const fromTests = [];
104
+ for (let index = 0; index < testsRaw.length; index += 1) {
105
+ const obj = asObject(testsRaw[index]);
106
+ const expected = typeof obj.expected === "string" ? obj.expected.trim() : "";
107
+ if (expected.length === 0) {
108
+ continue;
109
+ }
110
+ fromTests.push({
111
+ name: typeof obj.name === "string" && obj.name.trim().length > 0 ? obj.name : `generated-${index + 1}`,
112
+ expected,
113
+ actual: typeof obj.actual === "string" ? obj.actual : "",
114
+ trails: Array.isArray(obj.trails) ? obj.trails.filter((v) => typeof v === "string") : [],
115
+ status: obj.status ?? null
116
+ });
117
+ }
118
+ if (fromTests.length > 0) {
119
+ return fromTests;
120
+ }
121
+ const expectedRaw = context.expected;
122
+ const expectedList = typeof expectedRaw === "string" && expectedRaw.trim().length > 0
123
+ ? [expectedRaw.trim()]
124
+ : asStringArray(expectedRaw);
125
+ return expectedList.map((expected, index) => ({
126
+ name: `generated-${index + 1}`,
127
+ expected,
128
+ actual: "",
129
+ trails: [],
130
+ status: null
131
+ }));
132
+ }
133
+ function buildCaseHintsFromInstruction(instruction) {
134
+ return {
135
+ operations: [`review instruction and derive executable steps: ${instruction}`],
136
+ tests: [
137
+ {
138
+ name: "generated-1",
139
+ expected: "期待結果を context.tests[].expected で指定してください",
140
+ actual: "",
141
+ trails: [],
142
+ status: null
143
+ }
144
+ ],
145
+ warnings: []
146
+ };
147
+ }
148
+ const extracted = extractPromptMetadata(input.instruction, "case");
149
+ assertValidIdFormat(extracted.id);
150
+ await assertNoDuplicateId(input.workspaceRoot, input.suiteDir, extracted.id);
151
+ const contextOperations = asStringArray(input.context?.operations);
152
+ const contextTests = buildTestsFromContext(input.context);
153
+ const inferred = buildCaseHintsFromInstruction(input.instruction);
154
+ const operations = contextOperations.length > 0 ? contextOperations : inferred.operations;
155
+ const tests = contextTests.length > 0 ? contextTests : inferred.tests;
156
+ const description = inferDescriptionFromText(typeof input.context?.description === "string" ? input.context.description : input.instruction, extracted.title);
157
+ const merged = {
158
+ ...input.context,
159
+ id: extracted.id,
160
+ title: extracted.title,
161
+ description,
162
+ operations,
163
+ tests
164
+ };
165
+ const normalized = normalizeCaseCandidate(merged, { id: extracted.id, title: extracted.title });
166
+ const outputPath = planPathForCase(input.workspaceRoot, input.suiteDir, normalized.entity);
167
+ const writeResult = await writeEntityIfRequested(input.workspaceRoot, outputPath, normalized.entity, input.write ?? false);
168
+ return {
169
+ testcase: normalized.entity,
170
+ yamlText: writeResult.yamlText,
171
+ writtenFile: writeResult.writtenFile,
172
+ warnings: [...extracted.warnings, ...inferred.warnings, ...normalized.warnings],
173
+ diffSummary: ["created testcase"]
174
+ };
175
+ }
176
+ export async function expandTestcaseCore(input) {
177
+ const path = resolvePathInsideWorkspace(input.workspaceRoot, input.testcasePath);
178
+ const before = await readYamlFile(path);
179
+ const beforeObj = asObject(before);
180
+ const afterDraft = { ...beforeObj };
181
+ const preserveFields = new Set(input.preserveFields ?? []);
182
+ if (!preserveFields.has("description")) {
183
+ afterDraft.description = `${String(beforeObj.description ?? "").trim()}\n${input.instruction}`.trim();
184
+ }
185
+ if (!preserveFields.has("operations")) {
186
+ const operations = Array.isArray(beforeObj.operations)
187
+ ? beforeObj.operations.filter((v) => typeof v === "string")
188
+ : [];
189
+ operations.push(`review: ${input.instruction}`);
190
+ afterDraft.operations = Array.from(new Set(operations));
191
+ }
192
+ if (!preserveFields.has("tests")) {
193
+ const existing = Array.isArray(beforeObj.tests) ? beforeObj.tests.map((v) => asObject(v)) : [];
194
+ existing.push({
195
+ name: `generated-${existing.length + 1}`,
196
+ expected: "to be confirmed",
197
+ actual: "",
198
+ trails: [],
199
+ status: null
200
+ });
201
+ afterDraft.tests = existing;
202
+ }
203
+ const normalized = normalizeCaseCandidate(afterDraft, {
204
+ id: typeof beforeObj.id === "string" ? beforeObj.id : "case-expanded",
205
+ title: typeof beforeObj.title === "string" ? beforeObj.title : "Expanded Case"
206
+ });
207
+ const changes = summarizeDiff(beforeObj, normalized.entity);
208
+ if (input.write) {
209
+ await writeYamlFileAtomic(path, normalized.entity);
210
+ }
211
+ return {
212
+ before: beforeObj,
213
+ after: normalized.entity,
214
+ diffSummary: changes,
215
+ warnings: normalized.warnings,
216
+ writtenFile: input.write ? toRelativePath(input.workspaceRoot, path) : null
217
+ };
218
+ }
219
+ export async function organizeExecutionTargetsCore(input) {
220
+ const loaded = input.testcasePath
221
+ ? await readYamlFile(resolvePathInsideWorkspace(input.workspaceRoot, input.testcasePath))
222
+ : input.testcase;
223
+ if (!loaded) {
224
+ throw new Error("validation: testcasePath or testcase is required");
225
+ }
226
+ const normalized = normalizeCaseCandidate(loaded, {
227
+ id: "organized-case",
228
+ title: "Organized Case"
229
+ });
230
+ const generatedTests = [
231
+ {
232
+ name: `${input.strategy}-critical-path`,
233
+ expected: "critical behaviors are covered",
234
+ actual: "",
235
+ trails: [],
236
+ status: null
237
+ },
238
+ {
239
+ name: `${input.strategy}-edge-cases`,
240
+ expected: "edge cases are covered",
241
+ actual: "",
242
+ trails: [],
243
+ status: null
244
+ }
245
+ ];
246
+ const appliedMode = selectAppliedMode(input.mode, JSON.stringify(loaded));
247
+ const existingNames = new Set(normalized.entity.tests.map((test) => test.name));
248
+ const appended = generatedTests.filter((test) => !existingNames.has(test.name));
249
+ const proposedTests = appliedMode === "replace" ? generatedTests : [...normalized.entity.tests, ...appended];
250
+ const afterCase = {
251
+ ...normalized.entity,
252
+ tests: proposedTests
253
+ };
254
+ const final = normalizeCaseCandidate(afterCase, { id: afterCase.id, title: afterCase.title });
255
+ if (input.write && input.testcasePath) {
256
+ const filePath = resolvePathInsideWorkspace(input.workspaceRoot, input.testcasePath);
257
+ await writeYamlFileAtomic(filePath, final.entity);
258
+ }
259
+ return {
260
+ proposedTests: final.entity.tests,
261
+ rationale: [`strategy=${input.strategy}`],
262
+ coverageGaps: ["non-functional test scope not inferred"],
263
+ appliedMode,
264
+ warnings: final.warnings,
265
+ diffSummary: summarizeDiff(normalized.entity, final.entity),
266
+ writtenFile: input.write && input.testcasePath ? input.testcasePath : null
267
+ };
268
+ }
269
+ export async function initTestsDirectoryCore(input) {
270
+ const outputDir = input.outputDir ?? "./tests";
271
+ const targetDir = resolvePathInsideWorkspace(input.workspaceRoot, outputDir);
272
+ const plannedFiles = [];
273
+ const writtenFiles = [];
274
+ const baseSuite = buildDefaultSuite({ id: "default", title: "Default Suite" });
275
+ let suite = baseSuite;
276
+ let sampleCase = buildDefaultCase({ id: "default-case", title: "Default Case" });
277
+ if (input.templateDir) {
278
+ const templatePath = resolvePathInsideWorkspace(input.workspaceRoot, input.templateDir);
279
+ const extracted = await extractTemplateFromDirectory(templatePath);
280
+ suite = normalizeSuiteCandidate({ ...baseSuite, ...extracted.suite }, baseSuite).entity;
281
+ sampleCase = normalizeCaseCandidate({ ...sampleCase, ...extracted.testCase }, sampleCase).entity;
282
+ }
283
+ const suitePath = join(targetDir, "index.yaml");
284
+ const casePath = join(targetDir, buildCaseFileName(sampleCase.id, sampleCase.title));
285
+ plannedFiles.push(toRelativePath(input.workspaceRoot, suitePath), toRelativePath(input.workspaceRoot, casePath));
286
+ if (input.write) {
287
+ await mkdir(targetDir, { recursive: true });
288
+ assertNoOverwrite(suitePath);
289
+ assertNoOverwrite(casePath);
290
+ await writeYamlFileAtomic(suitePath, suite);
291
+ await writeYamlFileAtomic(casePath, sampleCase);
292
+ writtenFiles.push(...plannedFiles);
293
+ }
294
+ return {
295
+ plannedFiles,
296
+ writtenFiles,
297
+ warnings: []
298
+ };
299
+ }
300
+ export async function createTemplateDirectoryCore(input) {
301
+ const outputDir = resolvePathInsideWorkspace(input.workspaceRoot, input.outputDir);
302
+ const suiteTemplatePath = join(outputDir, "index.yaml");
303
+ const caseTemplatePath = join(outputDir, "template.testcase.yaml");
304
+ let suite = buildDefaultSuite({ id: "template-suite", title: "Template Suite" });
305
+ let testCase = buildDefaultCase({ id: "template-case", title: "Template Case" });
306
+ if (input.fromDir) {
307
+ const from = resolvePathInsideWorkspace(input.workspaceRoot, input.fromDir);
308
+ const extracted = await extractTemplateFromDirectory(from);
309
+ suite = normalizeSuiteCandidate(extracted.suite, suite).entity;
310
+ testCase = normalizeCaseCandidate(extracted.testCase, testCase).entity;
311
+ }
312
+ const plannedFiles = [
313
+ toRelativePath(input.workspaceRoot, suiteTemplatePath),
314
+ toRelativePath(input.workspaceRoot, caseTemplatePath)
315
+ ];
316
+ const writtenFiles = [];
317
+ if (input.write) {
318
+ await mkdir(outputDir, { recursive: true });
319
+ assertNoOverwrite(suiteTemplatePath);
320
+ assertNoOverwrite(caseTemplatePath);
321
+ await writeYamlFileAtomic(suiteTemplatePath, suite);
322
+ await writeYamlFileAtomic(caseTemplatePath, testCase);
323
+ writtenFiles.push(...plannedFiles);
324
+ }
325
+ return {
326
+ templatePath: toRelativePath(input.workspaceRoot, outputDir),
327
+ plannedFiles,
328
+ writtenFiles
329
+ };
330
+ }
331
+ export async function createSuiteFileCore(input) {
332
+ assertValidIdFormat(input.id);
333
+ await assertNoDuplicateId(input.workspaceRoot, input.dir, input.id);
334
+ const description = inferDescriptionFromText(typeof input.fields?.description === "string" ? input.fields.description : undefined, input.title);
335
+ const normalized = normalizeSuiteCandidate({ id: input.id, title: input.title, description, ...input.fields }, { id: input.id, title: input.title, description });
336
+ const filePath = planPathForSuite(input.workspaceRoot, input.dir, normalized.entity);
337
+ const writeResult = await writeEntityIfRequested(input.workspaceRoot, filePath, normalized.entity, input.write ?? false);
338
+ return {
339
+ entity: normalized.entity,
340
+ yamlText: writeResult.yamlText,
341
+ writtenFile: writeResult.writtenFile,
342
+ warnings: normalized.warnings,
343
+ diffSummary: ["created suite file"]
344
+ };
345
+ }
346
+ export async function createCaseFileCore(input) {
347
+ assertValidIdFormat(input.id);
348
+ await assertNoDuplicateId(input.workspaceRoot, input.dir, input.id);
349
+ const description = inferDescriptionFromText(typeof input.fields?.description === "string" ? input.fields.description : undefined, input.title);
350
+ const normalized = normalizeCaseCandidate({ id: input.id, title: input.title, description, ...input.fields }, { id: input.id, title: input.title, description });
351
+ const filePath = planPathForCase(input.workspaceRoot, input.dir, normalized.entity);
352
+ const writeResult = await writeEntityIfRequested(input.workspaceRoot, filePath, normalized.entity, input.write ?? false);
353
+ return {
354
+ entity: normalized.entity,
355
+ yamlText: writeResult.yamlText,
356
+ writtenFile: writeResult.writtenFile,
357
+ warnings: normalized.warnings,
358
+ diffSummary: ["created case file"]
359
+ };
360
+ }
361
+ export async function updateSuiteCore(input) {
362
+ const dir = resolvePathInsideWorkspace(input.workspaceRoot, input.dir);
363
+ assertValidIdFormat(input.id);
364
+ const index = await buildIdIndex(dir);
365
+ const found = resolveById(index, input.id);
366
+ if (!found || found.type !== "suite") {
367
+ throw new Error(`validation: suite id not found (${input.id})`);
368
+ }
369
+ await assertNoDuplicateId(input.workspaceRoot, input.dir, input.id, found.path);
370
+ const before = await readYamlFile(found.path);
371
+ const beforeObj = asObject(before);
372
+ if (typeof input.patch.id === "string" && input.patch.id !== input.id) {
373
+ throw new Error("validation: id cannot be changed by update_suite");
374
+ }
375
+ const merged = {
376
+ ...beforeObj,
377
+ ...input.patch,
378
+ id: input.id
379
+ };
380
+ const normalized = normalizeSuiteCandidate(merged, {
381
+ id: input.id,
382
+ title: typeof beforeObj.title === "string" ? beforeObj.title : "Updated Suite"
383
+ });
384
+ const validation = validateSuite(normalized.entity);
385
+ if (!validation.ok || !validation.data) {
386
+ return {
387
+ updated: false,
388
+ id: input.id,
389
+ path: toRelativePath(input.workspaceRoot, found.path),
390
+ errors: validation.errors,
391
+ warnings: [...normalized.warnings, ...validation.warnings.map((warning) => `${warning.path}: ${warning.message}`)]
392
+ };
393
+ }
394
+ if (input.write) {
395
+ await writeYamlFileAtomic(found.path, validation.data);
396
+ }
397
+ return {
398
+ updated: true,
399
+ id: input.id,
400
+ path: toRelativePath(input.workspaceRoot, found.path),
401
+ before: beforeObj,
402
+ after: validation.data,
403
+ warnings: [...normalized.warnings, ...validation.warnings.map((warning) => `${warning.path}: ${warning.message}`)],
404
+ diffSummary: summarizeDiff(beforeObj, validation.data),
405
+ writtenFile: input.write ? toRelativePath(input.workspaceRoot, found.path) : null
406
+ };
407
+ }
408
+ export async function updateCaseCore(input) {
409
+ const dir = resolvePathInsideWorkspace(input.workspaceRoot, input.dir);
410
+ assertValidIdFormat(input.id);
411
+ const index = await buildIdIndex(dir);
412
+ const found = resolveById(index, input.id);
413
+ if (!found || found.type !== "case") {
414
+ throw new Error(`validation: case id not found (${input.id})`);
415
+ }
416
+ await assertNoDuplicateId(input.workspaceRoot, input.dir, input.id, found.path);
417
+ const before = await readYamlFile(found.path);
418
+ const beforeObj = asObject(before);
419
+ if (typeof input.patch.id === "string" && input.patch.id !== input.id) {
420
+ throw new Error("validation: id cannot be changed by update_case");
421
+ }
422
+ const merged = {
423
+ ...beforeObj,
424
+ ...input.patch,
425
+ id: input.id
426
+ };
427
+ const normalized = normalizeCaseCandidate(merged, {
428
+ id: input.id,
429
+ title: typeof beforeObj.title === "string" ? beforeObj.title : "Updated Case"
430
+ });
431
+ const validation = validateCase(normalized.entity);
432
+ if (!validation.ok || !validation.data) {
433
+ return {
434
+ updated: false,
435
+ id: input.id,
436
+ path: toRelativePath(input.workspaceRoot, found.path),
437
+ errors: validation.errors,
438
+ warnings: [...normalized.warnings, ...validation.warnings.map((warning) => `${warning.path}: ${warning.message}`)]
439
+ };
440
+ }
441
+ if (input.write) {
442
+ await writeYamlFileAtomic(found.path, validation.data);
443
+ }
444
+ return {
445
+ updated: true,
446
+ id: input.id,
447
+ path: toRelativePath(input.workspaceRoot, found.path),
448
+ before: beforeObj,
449
+ after: validation.data,
450
+ warnings: [...normalized.warnings, ...validation.warnings.map((warning) => `${warning.path}: ${warning.message}`)],
451
+ diffSummary: summarizeDiff(beforeObj, validation.data),
452
+ writtenFile: input.write ? toRelativePath(input.workspaceRoot, found.path) : null
453
+ };
454
+ }
455
+ export async function validateTestsDirectoryCore(input) {
456
+ const dir = resolvePathInsideWorkspace(input.workspaceRoot, input.dir);
457
+ const files = await listYamlFiles(dir);
458
+ const errors = [];
459
+ const warnings = [];
460
+ for (const file of files) {
461
+ const loaded = await readYamlFile(file);
462
+ const type = detectEntityType(file);
463
+ if (type === "suite") {
464
+ const result = validateSuite(loaded);
465
+ if (!result.ok) {
466
+ errors.push({ path: toRelativePath(input.workspaceRoot, file), diagnostics: result.errors });
467
+ }
468
+ if (result.warnings.length > 0) {
469
+ warnings.push({ path: toRelativePath(input.workspaceRoot, file), diagnostics: result.warnings });
470
+ }
471
+ continue;
472
+ }
473
+ const result = validateCase(loaded);
474
+ if (!result.ok) {
475
+ errors.push({ path: toRelativePath(input.workspaceRoot, file), diagnostics: result.errors });
476
+ }
477
+ if (result.warnings.length > 0) {
478
+ warnings.push({ path: toRelativePath(input.workspaceRoot, file), diagnostics: result.warnings });
479
+ }
480
+ }
481
+ return {
482
+ errors,
483
+ warnings,
484
+ summary: {
485
+ totalFiles: files.length,
486
+ errorFiles: errors.length,
487
+ warningFiles: warnings.length
488
+ }
489
+ };
490
+ }
491
+ export async function listTemplatesCore(input) {
492
+ const root = resolvePathInsideWorkspace(input.workspaceRoot, input.dir);
493
+ const entries = await readdir(root, { withFileTypes: true });
494
+ const templates = [];
495
+ for (const entry of entries) {
496
+ if (!entry.isDirectory()) {
497
+ continue;
498
+ }
499
+ const path = join(root, entry.name);
500
+ const files = await readdir(path, { withFileTypes: true });
501
+ const hasIndex = files.some((file) => file.isFile() && file.name === "index.yaml");
502
+ if (!hasIndex) {
503
+ continue;
504
+ }
505
+ templates.push({
506
+ name: entry.name,
507
+ path: toRelativePath(input.workspaceRoot, path)
508
+ });
509
+ }
510
+ return { templates };
511
+ }
512
+ export async function listSuitesCore(input) {
513
+ const root = resolvePathInsideWorkspace(input.workspaceRoot, input.dir);
514
+ const files = await listYamlFiles(root);
515
+ const suites = [];
516
+ for (const file of files) {
517
+ if (basename(file) !== "index.yaml" && !file.endsWith(".suite.yaml")) {
518
+ continue;
519
+ }
520
+ const loaded = await readYamlFile(file);
521
+ const parsed = suiteSchema.safeParse(loaded);
522
+ if (!parsed.success) {
523
+ continue;
524
+ }
525
+ if (input.filters?.id && parsed.data.id !== input.filters.id) {
526
+ continue;
527
+ }
528
+ if (input.filters?.tag && !parsed.data.tags.includes(input.filters.tag)) {
529
+ continue;
530
+ }
531
+ suites.push({
532
+ id: parsed.data.id,
533
+ path: toRelativePath(input.workspaceRoot, file)
534
+ });
535
+ }
536
+ return { suites };
537
+ }
538
+ export async function listCasesCore(input) {
539
+ const root = resolvePathInsideWorkspace(input.workspaceRoot, input.dir);
540
+ const files = await listYamlFiles(root);
541
+ const cases = [];
542
+ for (const file of files) {
543
+ if (!file.endsWith(".testcase.yaml") && basename(file) === "index.yaml") {
544
+ continue;
545
+ }
546
+ const loaded = await readYamlFile(file);
547
+ const parsed = testCaseSchema.safeParse(loaded);
548
+ if (!parsed.success) {
549
+ continue;
550
+ }
551
+ if (input.filters?.id && parsed.data.id !== input.filters.id) {
552
+ continue;
553
+ }
554
+ if (input.filters?.status && parsed.data.status !== input.filters.status) {
555
+ continue;
556
+ }
557
+ if (input.filters?.tags && input.filters.tags.length > 0) {
558
+ const matchedTags = input.filters.tags.some((tag) => parsed.data.tags.includes(tag));
559
+ if (!matchedTags) {
560
+ continue;
561
+ }
562
+ }
563
+ if (input.filters?.scopedOnly === true && parsed.data.scoped !== true) {
564
+ continue;
565
+ }
566
+ if (input.filters?.owners && input.filters.owners.length > 0) {
567
+ const issueOwners = parsed.data.issues.flatMap((issue) => issue.owners);
568
+ const matchedOwners = input.filters.owners.some((owner) => issueOwners.includes(owner));
569
+ if (!matchedOwners) {
570
+ continue;
571
+ }
572
+ }
573
+ if (input.filters?.issueStatus) {
574
+ const hasIssueStatus = parsed.data.issues.some((issue) => issue.status === input.filters?.issueStatus);
575
+ if (!hasIssueStatus) {
576
+ continue;
577
+ }
578
+ }
579
+ if (input.filters?.issueHas && input.filters.issueHas.trim().length > 0) {
580
+ const keyword = input.filters.issueHas.trim().toLowerCase();
581
+ const hasKeyword = parsed.data.issues.some((issue) => {
582
+ const haystack = [
583
+ issue.incident,
584
+ ...issue.owners,
585
+ ...issue.causes,
586
+ ...issue.solutions,
587
+ ...issue.remarks
588
+ ]
589
+ .join("\n")
590
+ .toLowerCase();
591
+ return haystack.includes(keyword);
592
+ });
593
+ if (!hasKeyword) {
594
+ continue;
595
+ }
596
+ }
597
+ cases.push({
598
+ id: parsed.data.id,
599
+ status: parsed.data.status,
600
+ path: toRelativePath(input.workspaceRoot, file)
601
+ });
602
+ }
603
+ return { cases };
604
+ }
605
+ export async function resolveEntityPathByIdCore(input) {
606
+ const dir = resolvePathInsideWorkspace(input.workspaceRoot, input.dir);
607
+ const index = await buildIdIndex(dir);
608
+ const found = resolveById(index, input.id);
609
+ return {
610
+ id: input.id,
611
+ found: found
612
+ ? {
613
+ path: toRelativePath(input.workspaceRoot, found.path),
614
+ type: found.type,
615
+ title: found.title
616
+ }
617
+ : null,
618
+ duplicates: index.duplicates.map((dup) => ({
619
+ id: dup.id,
620
+ paths: dup.paths.map((path) => toRelativePath(input.workspaceRoot, path))
621
+ }))
622
+ };
623
+ }
624
+ export async function resolveRelatedTargetsCore(input) {
625
+ const dir = resolvePathInsideWorkspace(input.workspaceRoot, input.dir);
626
+ const index = await buildIdIndex(dir);
627
+ const source = resolveById(index, input.sourceId);
628
+ if (!source) {
629
+ throw new Error(`validation: source id not found (${input.sourceId})`);
630
+ }
631
+ const targetIds = input.relatedIds ?? source.related;
632
+ const resolved = [];
633
+ const missing = [];
634
+ for (const id of targetIds) {
635
+ const entity = resolveById(index, id);
636
+ if (!entity) {
637
+ missing.push(id);
638
+ continue;
639
+ }
640
+ resolved.push({
641
+ id: entity.id,
642
+ type: entity.type,
643
+ title: entity.title,
644
+ path: toRelativePath(input.workspaceRoot, entity.path)
645
+ });
646
+ }
647
+ return {
648
+ source: {
649
+ id: source.id,
650
+ type: source.type,
651
+ path: toRelativePath(input.workspaceRoot, source.path)
652
+ },
653
+ resolved,
654
+ missing,
655
+ warnings: missing.map((id) => `related id not found: ${id}`)
656
+ };
657
+ }
658
+ export async function syncRelatedCore(input) {
659
+ const dir = resolvePathInsideWorkspace(input.workspaceRoot, input.dir);
660
+ const index = await buildIdIndex(dir);
661
+ const source = resolveById(index, input.sourceId);
662
+ if (!source) {
663
+ throw new Error(`validation: source id not found (${input.sourceId})`);
664
+ }
665
+ const targetIds = Array.from(new Set(input.relatedIds ?? source.related));
666
+ const resolvedTargets = targetIds
667
+ .map((id) => resolveById(index, id))
668
+ .filter((entity) => Boolean(entity));
669
+ const missing = targetIds.filter((id) => !resolvedTargets.some((entity) => entity.id === id));
670
+ const sourceBefore = asObject(await readYamlFile(source.path));
671
+ const sourceNext = {
672
+ ...sourceBefore,
673
+ related: targetIds
674
+ };
675
+ const normalizedSource = source.type === "suite"
676
+ ? normalizeSuiteCandidate(sourceNext, {
677
+ id: source.id,
678
+ title: typeof sourceBefore.title === "string" ? sourceBefore.title : "Synced Suite"
679
+ })
680
+ : normalizeCaseCandidate(sourceNext, {
681
+ id: source.id,
682
+ title: typeof sourceBefore.title === "string" ? sourceBefore.title : "Synced Case"
683
+ });
684
+ const sourceValidation = source.type === "suite" ? validateSuite(normalizedSource.entity) : validateCase(normalizedSource.entity);
685
+ if (!sourceValidation.ok || !sourceValidation.data) {
686
+ return {
687
+ synced: false,
688
+ mode: input.mode,
689
+ sourceId: input.sourceId,
690
+ missing,
691
+ warnings: [...normalizedSource.warnings],
692
+ errors: sourceValidation.errors
693
+ };
694
+ }
695
+ const writePlans = [
696
+ {
697
+ path: source.path,
698
+ before: sourceBefore,
699
+ after: sourceValidation.data
700
+ }
701
+ ];
702
+ if (input.mode === "two-way") {
703
+ for (const target of resolvedTargets) {
704
+ const targetBefore = asObject(await readYamlFile(target.path));
705
+ const targetRelated = Array.isArray(targetBefore.related)
706
+ ? targetBefore.related.filter((item) => typeof item === "string")
707
+ : [];
708
+ const targetNext = {
709
+ ...targetBefore,
710
+ related: Array.from(new Set([...targetRelated, source.id]))
711
+ };
712
+ const normalizedTarget = target.type === "suite"
713
+ ? normalizeSuiteCandidate(targetNext, {
714
+ id: target.id,
715
+ title: typeof targetBefore.title === "string" ? targetBefore.title : "Synced Suite"
716
+ })
717
+ : normalizeCaseCandidate(targetNext, {
718
+ id: target.id,
719
+ title: typeof targetBefore.title === "string" ? targetBefore.title : "Synced Case"
720
+ });
721
+ const targetValidation = target.type === "suite" ? validateSuite(normalizedTarget.entity) : validateCase(normalizedTarget.entity);
722
+ if (!targetValidation.ok || !targetValidation.data) {
723
+ return {
724
+ synced: false,
725
+ mode: input.mode,
726
+ sourceId: input.sourceId,
727
+ targetId: target.id,
728
+ missing,
729
+ warnings: [...normalizedTarget.warnings],
730
+ errors: targetValidation.errors
731
+ };
732
+ }
733
+ writePlans.push({
734
+ path: target.path,
735
+ before: targetBefore,
736
+ after: targetValidation.data
737
+ });
738
+ }
739
+ }
740
+ if (input.write) {
741
+ for (const plan of writePlans) {
742
+ await writeYamlFileAtomic(plan.path, plan.after);
743
+ }
744
+ }
745
+ return {
746
+ synced: true,
747
+ mode: input.mode,
748
+ sourceId: source.id,
749
+ targetIds: resolvedTargets.map((target) => target.id),
750
+ missing,
751
+ warnings: missing.map((id) => `related id not found: ${id}`),
752
+ changed: writePlans.map((plan) => ({
753
+ path: toRelativePath(input.workspaceRoot, plan.path),
754
+ diffSummary: summarizeDiff(plan.before, plan.after)
755
+ })),
756
+ writtenFiles: input.write ? writePlans.map((plan) => toRelativePath(input.workspaceRoot, plan.path)) : []
757
+ };
758
+ }
759
+ export async function getWorkspaceSnapshotCore(input) {
760
+ const suitesResult = await listSuitesCore({ workspaceRoot: input.workspaceRoot, dir: input.dir });
761
+ const casesResult = await listCasesCore({ workspaceRoot: input.workspaceRoot, dir: input.dir });
762
+ const suites = suitesResult.suites;
763
+ const casesRaw = casesResult.cases;
764
+ let cases = casesRaw;
765
+ if (input.excludeUnscoped) {
766
+ const root = resolvePathInsideWorkspace(input.workspaceRoot, input.dir);
767
+ const filtered = [];
768
+ for (const item of casesRaw) {
769
+ const abs = resolvePathInsideWorkspace(input.workspaceRoot, item.path);
770
+ const loaded = await readYamlFile(abs);
771
+ const parsed = testCaseSchema.safeParse(loaded);
772
+ if (!parsed.success || parsed.data.scoped !== false) {
773
+ filtered.push(item);
774
+ }
775
+ }
776
+ cases = filtered;
777
+ void root;
778
+ }
779
+ return {
780
+ snapshot: {
781
+ suites,
782
+ cases
783
+ },
784
+ summary: {
785
+ suiteCount: suites.length,
786
+ caseCount: cases.length
787
+ }
788
+ };
789
+ }
790
+ export async function suiteStatsCore(input) {
791
+ const dir = resolvePathInsideWorkspace(input.workspaceRoot, input.dir);
792
+ const index = await buildIdIndex(dir);
793
+ const suiteEntity = resolveById(index, input.suiteId);
794
+ if (!suiteEntity || suiteEntity.type !== "suite") {
795
+ throw new Error(`validation: suite id not found (${input.suiteId})`);
796
+ }
797
+ const suiteLoaded = await readYamlFile(suiteEntity.path);
798
+ const suiteParsed = suiteSchema.safeParse(suiteLoaded);
799
+ if (!suiteParsed.success) {
800
+ throw new Error(`validation: invalid suite schema (${input.suiteId})`);
801
+ }
802
+ const files = await listYamlFiles(dir);
803
+ const suiteDir = suiteEntity.path.slice(0, Math.max(0, suiteEntity.path.lastIndexOf("/")));
804
+ const allowScopedAggregation = suiteParsed.data.scoped === true;
805
+ const cases = [];
806
+ for (const file of files) {
807
+ if (!file.endsWith(".testcase.yaml")) {
808
+ continue;
809
+ }
810
+ const rel = toRelativePath(input.workspaceRoot, file);
811
+ if (suiteDir.length > 0 && !rel.startsWith(suiteDir)) {
812
+ continue;
813
+ }
814
+ const loaded = await readYamlFile(file);
815
+ const parsed = testCaseSchema.safeParse(loaded);
816
+ if (!parsed.success) {
817
+ continue;
818
+ }
819
+ if (!allowScopedAggregation) {
820
+ continue;
821
+ }
822
+ if (parsed.data.scoped !== true) {
823
+ continue;
824
+ }
825
+ cases.push(parsed.data);
826
+ }
827
+ const burndown = calculateBurndown(cases, suiteParsed.data.duration.scheduled.start, suiteParsed.data.duration.scheduled.end);
828
+ return {
829
+ suite: {
830
+ id: suiteParsed.data.id,
831
+ title: suiteParsed.data.title,
832
+ path: toRelativePath(input.workspaceRoot, suiteEntity.path)
833
+ },
834
+ summary: burndown.summary,
835
+ burndown: burndown.buckets,
836
+ anomalies: burndown.anomalies
837
+ };
838
+ }
839
+ async function deleteEntityById(input) {
840
+ const dir = resolvePathInsideWorkspace(input.workspaceRoot, input.dir);
841
+ const index = await buildIdIndex(dir);
842
+ const found = resolveById(index, input.id);
843
+ if (!found || found.type !== input.expectedType) {
844
+ throw new Error(`validation: ${input.expectedType} id not found (${input.id})`);
845
+ }
846
+ const impacted = index.entities
847
+ .filter((entity) => entity.related.includes(input.id) && entity.id !== input.id)
848
+ .map((entity) => ({
849
+ id: entity.id,
850
+ type: entity.type,
851
+ path: toRelativePath(input.workspaceRoot, entity.path)
852
+ }));
853
+ const plan = {
854
+ id: found.id,
855
+ type: found.type,
856
+ path: toRelativePath(input.workspaceRoot, found.path)
857
+ };
858
+ if (input.dryRun) {
859
+ return {
860
+ deleted: false,
861
+ dryRun: true,
862
+ plan,
863
+ impacted
864
+ };
865
+ }
866
+ if (input.confirm !== true) {
867
+ throw new Error("validation: confirm=true is required when dryRun=false");
868
+ }
869
+ await unlink(found.path);
870
+ return {
871
+ deleted: true,
872
+ dryRun: false,
873
+ plan,
874
+ impacted,
875
+ writtenFiles: [toRelativePath(input.workspaceRoot, found.path)]
876
+ };
877
+ }
878
+ export async function deleteSuiteCore(input) {
879
+ return deleteEntityById({ ...input, expectedType: "suite" });
880
+ }
881
+ export async function deleteCaseCore(input) {
882
+ return deleteEntityById({ ...input, expectedType: "case" });
883
+ }
884
+ //# sourceMappingURL=core-tools.js.map