hanoman 0.9.14 → 0.9.16

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.
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.9.14",
3
- "sha": "ca8210ed",
4
- "builtAt": "2026-10-01T23:35:24.906Z"
2
+ "version": "0.9.16",
3
+ "sha": "05adb395",
4
+ "builtAt": "2026-10-05T05:02:21.820Z"
5
5
  }
package/dist/cli.js CHANGED
@@ -7948,7 +7948,7 @@ var init_semver = __esm({
7948
7948
  });
7949
7949
 
7950
7950
  // ../shared/src/qa.ts
7951
- var QA_REPORT_STATUSES, QA_VERDICTS, QA_CASE_STATUSES, QA_SEVERITIES, QA_PRIORITIES, zQaEnvironment, zSteps, zCreateQaReport, zPatchQaReport, zCreateQaCase, zPatchQaCase, zCreateQaFinding, zPatchQaFinding;
7951
+ var QA_REPORT_STATUSES, QA_VERDICTS, QA_CASE_STATUSES, QA_SEVERITIES, QA_PRIORITIES, zQaEnvironment, zSteps, zCreateQaReport, zPatchQaReport, zQaCode, zCreateQaCase, zPatchQaCase, zCreateQaFinding, zPatchQaFinding;
7952
7952
  var init_qa = __esm({
7953
7953
  "../shared/src/qa.ts"() {
7954
7954
  "use strict";
@@ -7970,7 +7970,9 @@ var init_qa = __esm({
7970
7970
  verdict: external_exports.enum(QA_VERDICTS).nullable().default(null)
7971
7971
  });
7972
7972
  zPatchQaReport = zCreateQaReport.partial().extend({ status: external_exports.enum(QA_REPORT_STATUSES).optional() });
7973
+ zQaCode = external_exports.string().trim().max(40).refine((c) => !/[\u0000-\u001f·]/.test(c), "kode tak boleh berisi baris baru atau karakter \xB7").refine((c) => c.toLowerCase() !== "laporan", '"Laporan" dicadangkan untuk lampiran laporan').transform((c) => c || null).nullable();
7973
7974
  zCreateQaCase = external_exports.object({
7975
+ code: zQaCode.optional(),
7974
7976
  title: external_exports.string().trim().min(1).max(300),
7975
7977
  steps: external_exports.string().max(1e4).default(""),
7976
7978
  expected: external_exports.string().max(1e4).default(""),
@@ -7980,6 +7982,7 @@ var init_qa = __esm({
7980
7982
  });
7981
7983
  zPatchQaCase = zCreateQaCase.partial();
7982
7984
  zCreateQaFinding = external_exports.object({
7985
+ code: zQaCode.optional(),
7983
7986
  title: external_exports.string().trim().min(1).max(300),
7984
7987
  caseId: external_exports.string().max(120).nullable().default(null),
7985
7988
  severity: external_exports.enum(QA_SEVERITIES).default("major"),
@@ -12134,7 +12137,7 @@ var init_team2 = __esm({
12134
12137
  });
12135
12138
 
12136
12139
  // ../shared/src/mcp-catalog/qa.ts
12137
- var PROJECT3, REPORT, CASE, FINDING, base, one, parseEnv, parseSteps, nullable2, put, REPORT_FIELDS, reportBody, caseBody, findingBody, CASE_FIELDS, FINDING_FIELDS, QA_TOOLS;
12140
+ var PROJECT3, REPORT, CASE, FINDING, base, one, parseEnv, parseSteps, nullable2, put, REPORT_FIELDS, reportBody, caseBody, findingBody, CODE_FIELD, CASE_FIELDS, FINDING_FIELDS, QA_TOOLS;
12138
12141
  var init_qa2 = __esm({
12139
12142
  "../shared/src/mcp-catalog/qa.ts"() {
12140
12143
  "use strict";
@@ -12182,6 +12185,7 @@ var init_qa2 = __esm({
12182
12185
  caseBody = (a) => {
12183
12186
  const b = {};
12184
12187
  for (const k of ["title", "steps", "expected", "actual", "status"]) put(b, k, s(a[k]));
12188
+ put(b, "code", nullable2(a.code));
12185
12189
  return b;
12186
12190
  };
12187
12191
  findingBody = (a) => {
@@ -12189,9 +12193,12 @@ var init_qa2 = __esm({
12189
12193
  for (const k of ["title", "severity", "priority", "area", "expected", "actual", "status"]) put(b, k, s(a[k]));
12190
12194
  put(b, "steps", parseSteps(a.steps));
12191
12195
  put(b, "caseId", nullable2(a.testCase));
12196
+ put(b, "code", nullable2(a.code));
12192
12197
  return b;
12193
12198
  };
12199
+ CODE_FIELD = str("Kode bebas (mis. `LOGIN-01`), unik di satu laporan (test case + temuan, tak peka huruf). String KOSONG kembali ke nomor otomatis; tak menyebutnya membiarkan apa adanya.");
12194
12200
  CASE_FIELDS = {
12201
+ code: CODE_FIELD,
12195
12202
  title: str("Judul test case."),
12196
12203
  steps: str("Langkah uji (teks bebas)."),
12197
12204
  expected: str("Hasil yang diharapkan."),
@@ -12199,6 +12206,7 @@ var init_qa2 = __esm({
12199
12206
  status: enumStr(QA_CASE_STATUSES, "Status eksekusi.")
12200
12207
  };
12201
12208
  FINDING_FIELDS = {
12209
+ code: CODE_FIELD,
12202
12210
  title: str("Judul temuan \u2014 satu masalah, satu baris."),
12203
12211
  severity: enumStr(QA_SEVERITIES, "Dampak TEKNIS. Bukan prioritas."),
12204
12212
  priority: enumStr(QA_PRIORITIES, "Urutan perbaikan P0 (paling mendesak)\u2013P3. Terpisah dari severity: bug minor di halaman checkout bisa P0."),
@@ -12225,7 +12233,7 @@ var init_qa2 = __esm({
12225
12233
  {
12226
12234
  name: "hanoman_qa_report_get",
12227
12235
  title: "Detail laporan QA",
12228
- description: "Satu laporan QA lengkap: header, test case (`TC-01\u2026`), temuan (`F-01\u2026`, dengan repro bernomor, severity, prioritas, `caseCode`), dan metadata lampiran (nama, tipe, ukuran \u2014 bukan byte).",
12236
+ description: "Satu laporan QA lengkap: header, test case (kode otomatis `TC-01\u2026` atau kode bebas QA), temuan (`F-01\u2026` atau kode bebas, dengan repro bernomor, severity, prioritas, `caseCode`), dan metadata lampiran (nama, tipe, ukuran \u2014 bukan byte).",
12229
12237
  inputSchema: obj({ properties: { project: PROJECT3, report: REPORT }, required: ["project", "report"] }),
12230
12238
  mode: "read",
12231
12239
  capability: "qa:read",
package/dist/server.js CHANGED
@@ -8713,12 +8713,23 @@ var init_semver = __esm({
8713
8713
  });
8714
8714
 
8715
8715
  // ../shared/src/qa.ts
8716
- function assignCodes(rows, prefix, pad) {
8716
+ function assignCodes(rows, prefix, pad, taken = /* @__PURE__ */ new Set()) {
8717
8717
  const ts = (r) => new Date(r.createdAt).getTime();
8718
- const rank = new Map(
8719
- [...rows].sort((a, b) => ts(a) - ts(b) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)).map((r, i) => [r.id, i + 1])
8720
- );
8721
- return rows.map((r) => ({ ...r, code: `${prefix}${String(rank.get(r.id)).padStart(pad, "0")}` }));
8718
+ const ordered = [...rows].sort((a, b) => ts(a) - ts(b) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
8719
+ const out4 = /* @__PURE__ */ new Map();
8720
+ let n2 = 0;
8721
+ for (const r of ordered) {
8722
+ if (r.code) {
8723
+ out4.set(r.id, r.code);
8724
+ continue;
8725
+ }
8726
+ let code;
8727
+ do {
8728
+ code = `${prefix}${String(++n2).padStart(pad, "0")}`;
8729
+ } while (taken.has(qaCodeKey(code)));
8730
+ out4.set(r.id, code);
8731
+ }
8732
+ return rows.map((r) => ({ ...r, code: out4.get(r.id) }));
8722
8733
  }
8723
8734
  function qaStats(cases, findings) {
8724
8735
  const c = { total: cases.length, todo: 0, pass: 0, fail: 0, blocked: 0, skipped: 0 };
@@ -8737,7 +8748,7 @@ function qaSeverityToSpec(s2) {
8737
8748
  function qaPriorityToSpec(p3) {
8738
8749
  return p3 === "P0" || p3 === "P1" ? "tinggi" : p3 === "P2" ? "sedang" : "rendah";
8739
8750
  }
8740
- var QA_REPORT_STATUSES, QA_VERDICTS, QA_CASE_STATUSES, QA_SEVERITIES, QA_PRIORITIES, QA_FINDING_STATUSES, QA_OWNER_TYPES, zQaEnvironment, zSteps, zCreateQaReport, zPatchQaReport, zCreateQaCase, zPatchQaCase, zCreateQaFinding, zPatchQaFinding, QA_EXPORT_FORMATS;
8751
+ var QA_REPORT_STATUSES, QA_VERDICTS, QA_CASE_STATUSES, QA_SEVERITIES, QA_PRIORITIES, QA_FINDING_STATUSES, QA_OWNER_TYPES, zQaEnvironment, zSteps, zCreateQaReport, zPatchQaReport, zQaCode, zCreateQaCase, zPatchQaCase, zCreateQaFinding, zPatchQaFinding, qaCodeKey, QA_EXPORT_FORMATS;
8741
8752
  var init_qa = __esm({
8742
8753
  "../shared/src/qa.ts"() {
8743
8754
  "use strict";
@@ -8761,7 +8772,9 @@ var init_qa = __esm({
8761
8772
  verdict: external_exports.enum(QA_VERDICTS).nullable().default(null)
8762
8773
  });
8763
8774
  zPatchQaReport = zCreateQaReport.partial().extend({ status: external_exports.enum(QA_REPORT_STATUSES).optional() });
8775
+ zQaCode = external_exports.string().trim().max(40).refine((c) => !/[\u0000-\u001f·]/.test(c), "kode tak boleh berisi baris baru atau karakter \xB7").refine((c) => c.toLowerCase() !== "laporan", '"Laporan" dicadangkan untuk lampiran laporan').transform((c) => c || null).nullable();
8764
8776
  zCreateQaCase = external_exports.object({
8777
+ code: zQaCode.optional(),
8765
8778
  title: external_exports.string().trim().min(1).max(300),
8766
8779
  steps: external_exports.string().max(1e4).default(""),
8767
8780
  expected: external_exports.string().max(1e4).default(""),
@@ -8771,6 +8784,7 @@ var init_qa = __esm({
8771
8784
  });
8772
8785
  zPatchQaCase = zCreateQaCase.partial();
8773
8786
  zCreateQaFinding = external_exports.object({
8787
+ code: zQaCode.optional(),
8774
8788
  title: external_exports.string().trim().min(1).max(300),
8775
8789
  caseId: external_exports.string().max(120).nullable().default(null),
8776
8790
  severity: external_exports.enum(QA_SEVERITIES).default("major"),
@@ -8782,6 +8796,7 @@ var init_qa = __esm({
8782
8796
  status: external_exports.enum(["open", "wontfix"]).default("open")
8783
8797
  });
8784
8798
  zPatchQaFinding = zCreateQaFinding.partial();
8799
+ qaCodeKey = (c) => c.trim().toLowerCase();
8785
8800
  QA_EXPORT_FORMATS = ["zip", "md", "docx", "pdf", "xlsx", "csv"];
8786
8801
  }
8787
8802
  });
@@ -8915,7 +8930,8 @@ function parseQaMarkdown(text2) {
8915
8930
  if (!c[1]) throw new QaMarkdownError("judul test case kosong", abs(cs, idx));
8916
8931
  cases.push({
8917
8932
  id: c[6] || null,
8918
- code: c[0],
8933
+ code: c[0].trim(),
8934
+ customCode: c[0].trim() || null,
8919
8935
  title: c[1],
8920
8936
  steps: c[2],
8921
8937
  expected: c[3],
@@ -8930,8 +8946,8 @@ function parseQaMarkdown(text2) {
8930
8946
  if (fs2) {
8931
8947
  const heads = fs2.lines.map((l, idx) => ({ l, idx })).filter((x) => x.l.startsWith("### "));
8932
8948
  heads.forEach((h2, n2) => {
8933
- const hm = /^### (F-\d+) · \[(\w+)\/(P\d)\] (.+)$/.exec(h2.l);
8934
- if (!hm) throw new QaMarkdownError("judul temuan tak valid; format: `### F-01 \xB7 [major/P1] Judul`", abs(fs2, h2.idx));
8949
+ const hm = /^### (.+?) · \[(\w+)\/(P\d)\] (.+)$/.exec(h2.l);
8950
+ if (!hm) throw new QaMarkdownError("judul temuan tak valid; format: `### F-01 \xB7 [major/P1] Judul` (kode bebas, tanpa \xB7)", abs(fs2, h2.idx));
8935
8951
  const severity = oneOf(hm[2], QA_SEVERITIES, "severity", abs(fs2, h2.idx));
8936
8952
  const priority = oneOf(hm[3], QA_PRIORITIES, "prioritas", abs(fs2, h2.idx));
8937
8953
  const end2 = n2 + 1 < heads.length ? heads[n2 + 1].idx : fs2.lines.length;
@@ -8956,7 +8972,7 @@ function parseQaMarkdown(text2) {
8956
8972
  area = am[1].trim();
8957
8973
  continue;
8958
8974
  }
8959
- const tm = /^\*\*Test case:\*\* ?(TC-\d+)\s*$/.exec(l);
8975
+ const tm = /^\*\*Test case:\*\* ?(.+?)\s*$/.exec(l);
8960
8976
  if (tm) {
8961
8977
  caseCode = tm[1];
8962
8978
  continue;
@@ -8991,7 +9007,8 @@ function parseQaMarkdown(text2) {
8991
9007
  id: typeof meta.id === "string" && meta.id ? meta.id : null,
8992
9008
  caseId: typeof meta.caseId === "string" && meta.caseId ? meta.caseId : null,
8993
9009
  caseCode,
8994
- code: hm[1],
9010
+ code: hm[1].trim(),
9011
+ customCode: hm[1].trim(),
8995
9012
  title: hm[4].trim(),
8996
9013
  severity,
8997
9014
  priority,
@@ -9008,7 +9025,7 @@ function parseQaMarkdown(text2) {
9008
9025
  if (ls) {
9009
9026
  let code = null;
9010
9027
  for (const l of ls.lines) {
9011
- const hm = /^### (TC-\d+)\s*$/.exec(l);
9028
+ const hm = /^### (.+?)\s*$/.exec(l);
9012
9029
  if (hm) {
9013
9030
  code = hm[1];
9014
9031
  continue;
@@ -9017,6 +9034,28 @@ function parseQaMarkdown(text2) {
9017
9034
  if (c) c.attachments.push(...pathsIn([l]));
9018
9035
  }
9019
9036
  }
9037
+ const seen2 = /* @__PURE__ */ new Set();
9038
+ for (const x of [...cases, ...findings]) {
9039
+ if (!x.code) continue;
9040
+ const k = x.code.toLowerCase();
9041
+ if (k === "laporan") throw new QaMarkdownError(`kode "${x.code}" dicadangkan untuk lampiran laporan`, 1);
9042
+ if (seen2.has(k)) throw new QaMarkdownError(`kode ${x.code} dipakai lebih dari sekali; kode harus unik`, 1);
9043
+ seen2.add(k);
9044
+ }
9045
+ let tcN = 0;
9046
+ for (const c of cases) if (!c.code) {
9047
+ do {
9048
+ c.code = `TC-${String(++tcN).padStart(2, "0")}`;
9049
+ } while (seen2.has(c.code.toLowerCase()));
9050
+ seen2.add(c.code.toLowerCase());
9051
+ }
9052
+ for (const f of findings) {
9053
+ if (f.caseCode) {
9054
+ const c = cases.find((x) => x.code.toLowerCase() === f.caseCode.toLowerCase());
9055
+ if (!c) throw new QaMarkdownError(`temuan ${f.code}: test case ${f.caseCode} tidak ditemukan`, 1);
9056
+ f.caseCode = c.code;
9057
+ }
9058
+ }
9020
9059
  return {
9021
9060
  reportId: fs("reportId") || null,
9022
9061
  title: fs("title").trim(),
@@ -13460,7 +13499,7 @@ var init_team2 = __esm({
13460
13499
  });
13461
13500
 
13462
13501
  // ../shared/src/mcp-catalog/qa.ts
13463
- var PROJECT3, REPORT, CASE, FINDING, base, one, parseEnv, parseSteps, nullable2, put, REPORT_FIELDS, reportBody, caseBody, findingBody, CASE_FIELDS, FINDING_FIELDS, QA_TOOLS;
13502
+ var PROJECT3, REPORT, CASE, FINDING, base, one, parseEnv, parseSteps, nullable2, put, REPORT_FIELDS, reportBody, caseBody, findingBody, CODE_FIELD, CASE_FIELDS, FINDING_FIELDS, QA_TOOLS;
13464
13503
  var init_qa2 = __esm({
13465
13504
  "../shared/src/mcp-catalog/qa.ts"() {
13466
13505
  "use strict";
@@ -13508,6 +13547,7 @@ var init_qa2 = __esm({
13508
13547
  caseBody = (a) => {
13509
13548
  const b = {};
13510
13549
  for (const k of ["title", "steps", "expected", "actual", "status"]) put(b, k, s(a[k]));
13550
+ put(b, "code", nullable2(a.code));
13511
13551
  return b;
13512
13552
  };
13513
13553
  findingBody = (a) => {
@@ -13515,9 +13555,12 @@ var init_qa2 = __esm({
13515
13555
  for (const k of ["title", "severity", "priority", "area", "expected", "actual", "status"]) put(b, k, s(a[k]));
13516
13556
  put(b, "steps", parseSteps(a.steps));
13517
13557
  put(b, "caseId", nullable2(a.testCase));
13558
+ put(b, "code", nullable2(a.code));
13518
13559
  return b;
13519
13560
  };
13561
+ CODE_FIELD = str("Kode bebas (mis. `LOGIN-01`), unik di satu laporan (test case + temuan, tak peka huruf). String KOSONG kembali ke nomor otomatis; tak menyebutnya membiarkan apa adanya.");
13520
13562
  CASE_FIELDS = {
13563
+ code: CODE_FIELD,
13521
13564
  title: str("Judul test case."),
13522
13565
  steps: str("Langkah uji (teks bebas)."),
13523
13566
  expected: str("Hasil yang diharapkan."),
@@ -13525,6 +13568,7 @@ var init_qa2 = __esm({
13525
13568
  status: enumStr(QA_CASE_STATUSES, "Status eksekusi.")
13526
13569
  };
13527
13570
  FINDING_FIELDS = {
13571
+ code: CODE_FIELD,
13528
13572
  title: str("Judul temuan \u2014 satu masalah, satu baris."),
13529
13573
  severity: enumStr(QA_SEVERITIES, "Dampak TEKNIS. Bukan prioritas."),
13530
13574
  priority: enumStr(QA_PRIORITIES, "Urutan perbaikan P0 (paling mendesak)\u2013P3. Terpisah dari severity: bug minor di halaman checkout bisa P0."),
@@ -13551,7 +13595,7 @@ var init_qa2 = __esm({
13551
13595
  {
13552
13596
  name: "hanoman_qa_report_get",
13553
13597
  title: "Detail laporan QA",
13554
- description: "Satu laporan QA lengkap: header, test case (`TC-01\u2026`), temuan (`F-01\u2026`, dengan repro bernomor, severity, prioritas, `caseCode`), dan metadata lampiran (nama, tipe, ukuran \u2014 bukan byte).",
13598
+ description: "Satu laporan QA lengkap: header, test case (kode otomatis `TC-01\u2026` atau kode bebas QA), temuan (`F-01\u2026` atau kode bebas, dengan repro bernomor, severity, prioritas, `caseCode`), dan metadata lampiran (nama, tipe, ukuran \u2014 bukan byte).",
13555
13599
  inputSchema: obj({ properties: { project: PROJECT3, report: REPORT }, required: ["project", "report"] }),
13556
13600
  mode: "read",
13557
13601
  capability: "qa:read",
@@ -52197,8 +52241,8 @@ var init_sync = __esm({
52197
52241
  // `qaFinding.backlogId` ikut (cermin task.specId): tanpa itu mesin lain bisa mengirim ulang temuan yang
52198
52242
  // di mesin ini sudah jadi backlog.
52199
52243
  qaReport: ["projectId", "title", "buildVersion", "environment", "scope", "tester", "summary", "status", "verdict", "createdAt", "updatedAt"],
52200
- qaCase: ["reportId", "title", "steps", "expected", "actual", "status", "order", "createdAt", "updatedAt"],
52201
- qaFinding: ["reportId", "caseId", "title", "severity", "priority", "area", "steps", "expected", "actual", "status", "backlogId", "createdAt", "updatedAt"],
52244
+ qaCase: ["reportId", "code", "title", "steps", "expected", "actual", "status", "order", "createdAt", "updatedAt"],
52245
+ qaFinding: ["reportId", "caseId", "code", "title", "severity", "priority", "area", "steps", "expected", "actual", "status", "backlogId", "createdAt", "updatedAt"],
52202
52246
  qaAttachment: ["reportId", "projectId", "ownerType", "ownerId", "filename", "mimeType", "size", "sha256", "storageKey", "createdAt", "updatedAt"]
52203
52247
  };
52204
52248
  DATE_FIELDS = {
@@ -64014,8 +64058,9 @@ var attachmentView = (a) => ({
64014
64058
  syncState: a.syncState === "local-only" && !effectiveStr("SYNC_SERVER_URL") ? "available" : a.syncState,
64015
64059
  createdAt: iso3(a.createdAt)
64016
64060
  });
64017
- function caseViews(rows) {
64018
- return assignCodes(rows, "TC-", 2).map((r) => ({
64061
+ var takenCodes = (...lists) => new Set(lists.flat().filter((r) => r.code).map((r) => qaCodeKey(r.code)));
64062
+ function caseViews(rows, taken = /* @__PURE__ */ new Set()) {
64063
+ return assignCodes(rows, "TC-", 2, taken).map((r) => ({
64019
64064
  id: r.id,
64020
64065
  reportId: r.reportId,
64021
64066
  code: r.code,
@@ -64029,9 +64074,9 @@ function caseViews(rows) {
64029
64074
  updatedAt: iso3(r.updatedAt)
64030
64075
  })).sort((a, b) => a.order - b.order || (a.id < b.id ? -1 : 1));
64031
64076
  }
64032
- function findingViews(rows, cases, specs = /* @__PURE__ */ new Map()) {
64077
+ function findingViews(rows, cases, specs = /* @__PURE__ */ new Map(), taken = /* @__PURE__ */ new Set()) {
64033
64078
  const caseCode = new Map(cases.map((c) => [c.id, c.code]));
64034
- return assignCodes(rows, "F-", 2).map((r) => ({
64079
+ return assignCodes(rows, "F-", 2, taken).map((r) => ({
64035
64080
  id: r.id,
64036
64081
  reportId: r.reportId,
64037
64082
  code: r.code,
@@ -64071,7 +64116,8 @@ async function reportDetail(projectId, reportId) {
64071
64116
  });
64072
64117
  if (!row) return null;
64073
64118
  const code = (await reportCodes(projectId)).get(row.id);
64074
- const cases = caseViews(row.cases);
64119
+ const taken = takenCodes(row.cases, row.findings);
64120
+ const cases = caseViews(row.cases, taken);
64075
64121
  const specIds = [...new Set(row.findings.map((f) => f.backlogId).filter((x) => !!x))];
64076
64122
  const specs = new Map(
64077
64123
  (await prisma.spec.findMany({ where: { id: { in: specIds } }, select: { id: true, stage: true, priority: true } })).map((x) => [x.id, x])
@@ -64079,10 +64125,19 @@ async function reportDetail(projectId, reportId) {
64079
64125
  return {
64080
64126
  ...reportView(row, code, row.cases, row.findings),
64081
64127
  cases,
64082
- findings: findingViews(row.findings, cases, specs),
64128
+ findings: findingViews(row.findings, cases, specs, taken),
64083
64129
  attachments: row.attachments.map(attachmentView)
64084
64130
  };
64085
64131
  }
64132
+ async function qaCodeProblem(reportId, code, exceptId) {
64133
+ if (!code) return null;
64134
+ const [cases, findings] = await Promise.all([
64135
+ prisma.qaCase.findMany({ where: { reportId, code: { not: null } }, select: { id: true, code: true } }),
64136
+ prisma.qaFinding.findMany({ where: { reportId, code: { not: null } }, select: { id: true, code: true } })
64137
+ ]);
64138
+ const key = qaCodeKey(code);
64139
+ return [...cases, ...findings].some((r) => r.id !== exceptId && qaCodeKey(r.code) === key) ? { error: "kode sudah dipakai di laporan ini; kode test case dan temuan harus unik", code } : null;
64140
+ }
64086
64141
 
64087
64142
  // src/services/sync-delete.ts
64088
64143
  init_sync();
@@ -79444,8 +79499,11 @@ async function qa(app3) {
79444
79499
  if (!parsed.success) return bad(reply, parsed.error);
79445
79500
  const p3 = parsed.data;
79446
79501
  const last2 = await prisma.qaCase.findFirst({ where: { reportId: rid }, orderBy: { order: "desc" }, select: { order: true } });
79502
+ const clash = await qaCodeProblem(rid, p3.code);
79503
+ if (clash) return reply.code(409).send(clash);
79447
79504
  const row = await prisma.qaCase.create({ data: {
79448
79505
  reportId: rid,
79506
+ code: p3.code ?? null,
79449
79507
  title: p3.title,
79450
79508
  steps: p3.steps,
79451
79509
  expected: p3.expected,
@@ -79465,6 +79523,8 @@ async function qa(app3) {
79465
79523
  if (r.status === "closed") return locked(reply);
79466
79524
  const parsed = zPatchQaCase.safeParse(req.body ?? {});
79467
79525
  if (!parsed.success) return bad(reply, parsed.error);
79526
+ const clash = await qaCodeProblem(rid, parsed.data.code, cid);
79527
+ if (clash) return reply.code(409).send(clash);
79468
79528
  await prisma.qaCase.update({ where: { id: cid }, data: parsed.data });
79469
79529
  await notifySynced("qaCase", cid);
79470
79530
  await touch(rid);
@@ -79495,8 +79555,11 @@ async function qa(app3) {
79495
79555
  const p3 = parsed.data;
79496
79556
  const problem = await caseProblem(rid, p3.caseId);
79497
79557
  if (problem) return reply.code(400).send(problem);
79558
+ const clash = await qaCodeProblem(rid, p3.code);
79559
+ if (clash) return reply.code(409).send(clash);
79498
79560
  const row = await prisma.qaFinding.create({ data: {
79499
79561
  reportId: rid,
79562
+ code: p3.code ?? null,
79500
79563
  caseId: p3.caseId,
79501
79564
  title: p3.title,
79502
79565
  severity: p3.severity,
@@ -79522,7 +79585,10 @@ async function qa(app3) {
79522
79585
  const p3 = parsed.data;
79523
79586
  const problem = await caseProblem(rid, p3.caseId);
79524
79587
  if (problem) return reply.code(400).send(problem);
79588
+ const clash = await qaCodeProblem(rid, p3.code, fid);
79589
+ if (clash) return reply.code(409).send(clash);
79525
79590
  await prisma.qaFinding.update({ where: { id: fid }, data: {
79591
+ code: p3.code,
79526
79592
  caseId: p3.caseId,
79527
79593
  title: p3.title,
79528
79594
  severity: p3.severity,
@@ -79885,9 +79951,10 @@ var GUIDE2 = [
79885
79951
  ["Ringkasan", "Judul wajib. Kolom lain boleh kosong. Status: draft, submitted, closed. Keputusan: go (siap), no-go (belum siap), conditional (siap dengan catatan); wajib untuk submitted/closed."],
79886
79952
  ["Test case", "Satu baris = satu pengujian. Status: belum, lulus, gagal, terblokir, dilewati (atau todo, pass, fail, blocked, skipped). Langkah: satu per baris di dalam sel (Alt+Enter)."],
79887
79953
  ["Temuan", "Satu baris = satu masalah. Severity: blocker (tidak bisa dipakai), critical (kritis), major (fitur terganggu), minor (gangguan kecil), trivial (tampilan). Prioritas: P0 segera, P1 tinggi, P2 normal, P3 rendah."],
79888
- ["Lampiran", "Satu baris = satu berkas. Pemilik: Laporan, TC-01, atau F-01 sesuai Kode. Berkas: nama screenshot/log yang dipilih bersama Excel, atau attachments/nama.png dalam ZIP. Maks 10 MB/berkas, 30 berkas dan 100 MB/laporan."],
79954
+ ["Lampiran", "Satu baris = satu berkas. Pemilik: Laporan, atau Kode test case/temuan (apa pun yang Anda tulis, mis. TC-01 atau LOGIN-3). Berkas: nama screenshot/log yang dipilih bersama Excel, atau attachments/nama.png dalam ZIP. Maks 10 MB/berkas, 30 berkas dan 100 MB/laporan."],
79889
79955
  ["Impor dengan lampiran", "Pilih Excel dan berkas lampiran bersama-sama di dashboard, atau ZIP berisi report.xlsx dan folder attachments/. Gambar yang hanya ditempel di sel Excel tidak diimpor; sertakan berkas gambarnya."],
79890
- ["Ref", "Biarkan Ref laporan/test case/temuan hasil ekspor untuk memperbarui laporan asal. Kosongkan untuk entri baru. Kode harus unik; Test case pada Temuan merujuk Kode TC yang ada."],
79956
+ ["Ref", "Biarkan Ref laporan/test case/temuan hasil ekspor untuk memperbarui laporan asal. Kosongkan untuk entri baru."],
79957
+ ["Kode", "Bebas Anda tentukan (mis. LOGIN-01, AUTH.3), asal unik di seluruh laporan \u2014 test case dan temuan tidak boleh sama, huruf besar/kecil dianggap sama. Kosongkan untuk nomor otomatis (TC-01 / F-01). Test case pada Temuan merujuk Kode test case."],
79891
79958
  ["Lingkungan", "Satu per baris: os=Windows 11, browser=Chrome, device=Laptop, url=https://\u2026 ."]
79892
79959
  ];
79893
79960
  function writeQaWorkbook(d, paths2 = {}) {
@@ -79953,7 +80020,7 @@ function parseQaWorkbook(buf) {
79953
80020
  const rowOf = (k) => Math.max(1, summary.findIndex((r) => norm3(r[0] ?? "") === norm3(k)) + 1);
79954
80021
  if (!val("Judul").trim()) fail3("Ringkasan", rowOf("Judul"), "Judul wajib diisi");
79955
80022
  const environment = /* @__PURE__ */ Object.create(null);
79956
- for (const line of val("Lingkungan").split("\n").filter((l) => l.trim())) {
80023
+ for (const line of val("Lingkungan").split(/\r\n|\r|\n|;(?=\s*[^=:;\s]+\s*[=:])/).filter((l) => l.trim())) {
79957
80024
  const match = /^\s*([^=:]+)[=:]\s*(.*)$/.exec(line);
79958
80025
  if (!match) fail3("Ringkasan", rowOf("Lingkungan"), "Lingkungan: gunakan kunci=nilai, satu per baris");
79959
80026
  environment[match[1].trim()] = match[2];
@@ -79983,48 +80050,64 @@ function parseQaWorkbook(buf) {
79983
80050
  if (e instanceof QaTableError) throw new XlsxError(`Test case, ${e.message}`);
79984
80051
  throw e;
79985
80052
  }
79986
- const codes = /* @__PURE__ */ new Set();
80053
+ const rows = read2("Temuan");
80054
+ const headers = rows[0]?.map(norm3) ?? [];
80055
+ for (const h2 of ["judul", "severity", "prioritas", "langkah", "expected", "actual"]) if (!headers.includes(h2)) fail3("Temuan", 1, `kolom ${h2} wajib ada`);
80056
+ const getF = (r, k) => r[headers.indexOf(norm3(k))] ?? "";
80057
+ const findingRows = rows.slice(1).map((r, i) => ({ r, row: i + 2 })).filter(({ r }) => !r.every((v) => !v.trim()));
80058
+ const used = /* @__PURE__ */ new Map();
80059
+ const claim = (sheet, row, code) => {
80060
+ if (norm3(code) === "laporan") fail3(sheet, row, 'Kode "Laporan" dicadangkan untuk lampiran laporan');
80061
+ if (used.has(qaCodeKey(code))) fail3(sheet, row, `Kode ${code} dipakai ${used.get(qaCodeKey(code))}; kode harus unik`);
80062
+ used.set(qaCodeKey(code), `${sheet}`);
80063
+ };
80064
+ for (const c of cases) if (c.code.trim()) claim("Test case", c.row, c.code.trim());
80065
+ for (const { r, row } of findingRows) if (getF(r, "Kode").trim()) claim("Temuan", row, getF(r, "Kode").trim());
80066
+ const autoCode = (prefix, counter2) => {
80067
+ let code;
80068
+ do {
80069
+ code = `${prefix}${String(++counter2.n).padStart(2, "0")}`;
80070
+ } while (used.has(qaCodeKey(code)));
80071
+ return code;
80072
+ };
80073
+ const caseCounter = { n: 0 };
80074
+ const caseByKey = /* @__PURE__ */ new Map();
79987
80075
  const refs2 = /* @__PURE__ */ new Set();
79988
- for (const [i, c] of cases.entries()) {
79989
- const code = c.code || `TC-${String(i + 1).padStart(2, "0")}`;
79990
- if (!/^TC-\d+$/.test(code) || codes.has(code)) fail3("Test case", c.row, "Kode harus unik dan berformat TC-01");
80076
+ for (const c of cases) {
80077
+ const customCode = c.code.trim() || null;
80078
+ const code = customCode ?? autoCode("TC-", caseCounter);
79991
80079
  if (c.ref && refs2.has(c.ref)) fail3("Test case", c.row, "Ref berulang");
79992
- codes.add(code);
79993
80080
  if (c.ref) refs2.add(c.ref);
79994
80081
  const result = zCreateQaCase.safeParse({ title: c.title, steps: c.steps ?? "", expected: c.expected ?? "", actual: c.actual ?? "", status: c.status ?? "todo" });
79995
80082
  if (!result.success) fail3("Test case", c.row, result.error.issues.map((e) => `${e.path}: ${e.message}`).join("; "));
79996
- parsed.cases.push({ ...result.data, id: c.ref, code, attachments: [] });
80083
+ caseByKey.set(qaCodeKey(code), code);
80084
+ parsed.cases.push({ ...result.data, id: c.ref, code, customCode, attachments: [] });
79997
80085
  }
79998
- const rows = read2("Temuan");
79999
- const headers = rows[0]?.map(norm3) ?? [];
80000
- for (const h2 of ["judul", "severity", "prioritas", "langkah", "expected", "actual"]) if (!headers.includes(h2)) fail3("Temuan", 1, `kolom ${h2} wajib ada`);
80001
- const findingCodes = /* @__PURE__ */ new Set();
80086
+ const findingCounter = { n: 0 };
80002
80087
  const findingRefs = /* @__PURE__ */ new Set();
80003
- for (let i = 1; i < rows.length; i++) {
80004
- const r = rows[i];
80005
- if (r.every((v) => !v.trim())) continue;
80006
- const get = (k) => r[headers.indexOf(norm3(k))] ?? "";
80007
- const code = get("Kode") || `F-${String(parsed.findings.length + 1).padStart(2, "0")}`;
80008
- if (!/^F-\d+$/.test(code) || findingCodes.has(code)) fail3("Temuan", i + 1, "Kode harus unik dan berformat F-01");
80009
- findingCodes.add(code);
80088
+ for (const { r, row } of findingRows) {
80089
+ const get = (k) => getF(r, k);
80090
+ const customCode = get("Kode").trim() || null;
80091
+ const code = customCode ?? autoCode("F-", findingCounter);
80010
80092
  const id2 = get("Ref") || null;
80011
- if (id2 && findingRefs.has(id2)) fail3("Temuan", i + 1, "Ref berulang");
80093
+ if (id2 && findingRefs.has(id2)) fail3("Temuan", row, "Ref berulang");
80012
80094
  if (id2) findingRefs.add(id2);
80013
- const caseCode = get("Test case").trim() || null;
80014
- if (caseCode && !codes.has(caseCode)) fail3("Temuan", i + 1, `Test case ${caseCode} tidak ditemukan`);
80015
- const status2 = choice(get("Status") || "open", QA_FINDING_STATUSES, "Temuan", i + 1);
80095
+ const caseRef = get("Test case").trim();
80096
+ const caseCode = caseRef ? caseByKey.get(qaCodeKey(caseRef)) ?? null : null;
80097
+ if (caseRef && !caseCode) fail3("Temuan", row, `Test case ${caseRef} tidak ditemukan`);
80098
+ const status2 = choice(get("Status") || "open", QA_FINDING_STATUSES, "Temuan", row);
80016
80099
  const result = zCreateQaFinding.safeParse({
80017
80100
  title: get("Judul"),
80018
- severity: choice(get("Severity") || "major", QA_SEVERITIES, "Temuan", i + 1),
80019
- priority: choice(get("Prioritas") || "P2", QA_PRIORITIES, "Temuan", i + 1),
80101
+ severity: choice(get("Severity") || "major", QA_SEVERITIES, "Temuan", row),
80102
+ priority: choice(get("Prioritas") || "P2", QA_PRIORITIES, "Temuan", row),
80020
80103
  area: get("Area"),
80021
80104
  steps: get("Langkah").split("\n").map((s2) => s2.trim().replace(/^\d+[.)]\s+/, "")).filter(Boolean),
80022
80105
  expected: get("Expected"),
80023
80106
  actual: get("Actual"),
80024
80107
  status: status2 === "sent" ? "open" : status2
80025
80108
  });
80026
- if (!result.success) fail3("Temuan", i + 1, result.error.issues.map((e) => `${e.path}: ${e.message}`).join("; "));
80027
- parsed.findings.push({ ...result.data, status: status2, id: id2, code, caseId: null, caseCode, attachments: [] });
80109
+ if (!result.success) fail3("Temuan", row, result.error.issues.map((e) => `${e.path}: ${e.message}`).join("; "));
80110
+ parsed.findings.push({ ...result.data, status: status2, id: id2, code, customCode, caseId: null, caseCode, attachments: [] });
80028
80111
  }
80029
80112
  const attachments = read2("Lampiran");
80030
80113
  if (norm3(attachments[0]?.[0] ?? "") !== "pemilik" || norm3(attachments[0]?.[1] ?? "") !== "berkas") fail3("Lampiran", 1, "butuh kolom Pemilik dan Berkas");
@@ -80032,7 +80115,7 @@ function parseQaWorkbook(buf) {
80032
80115
  const [owner = "", path = ""] = attachments[i];
80033
80116
  if (!owner.trim() && !path.trim()) continue;
80034
80117
  if (!path.trim() || path.includes("\\") || path.startsWith("/") || path.split("/").some((p3) => p3 === ".." || p3 === "." || !p3)) fail3("Lampiran", i + 1, "Berkas harus nama berkas atau path relatif yang aman");
80035
- const target3 = norm3(owner) === "laporan" ? parsed.attachments : parsed.cases.find((c) => c.code === owner.trim())?.attachments ?? parsed.findings.find((f) => f.code === owner.trim())?.attachments;
80118
+ const target3 = norm3(owner) === "laporan" ? parsed.attachments : parsed.cases.find((c) => qaCodeKey(c.code) === qaCodeKey(owner))?.attachments ?? parsed.findings.find((f) => qaCodeKey(f.code) === qaCodeKey(owner))?.attachments;
80036
80119
  if (!target3) fail3("Lampiran", i + 1, `Pemilik "${owner}" tidak ditemukan; gunakan Laporan atau Kode TC/F yang ada`);
80037
80120
  target3.push(path.trim());
80038
80121
  }
@@ -80156,7 +80239,7 @@ async function importReport(projectId, file, companions = []) {
80156
80239
  const caseByFileId = /* @__PURE__ */ new Map();
80157
80240
  const caseByCode = /* @__PURE__ */ new Map();
80158
80241
  for (const [idx, c] of parsed.cases.entries()) {
80159
- const fields = { title: c.title, steps: c.steps, expected: c.expected, actual: c.actual, status: c.status, order: idx + 1 };
80242
+ const fields = { code: c.customCode, title: c.title, steps: c.steps, expected: c.expected, actual: c.actual, status: c.status, order: idx + 1 };
80160
80243
  const row = c.id && knownCases.has(c.id) ? await tx.qaCase.update({ where: { id: c.id }, data: fields }) : await tx.qaCase.create({ data: { reportId: report.id, ...fields, createdAt: new Date(now + 1 + idx) } });
80161
80244
  if (c.id) caseByFileId.set(c.id, row.id);
80162
80245
  caseByCode.set(c.code, row.id);
@@ -80167,6 +80250,7 @@ async function importReport(projectId, file, companions = []) {
80167
80250
  for (const [idx, f] of parsed.findings.entries()) {
80168
80251
  const caseId = f.caseId && caseByFileId.get(f.caseId) || f.caseCode && caseByCode.get(f.caseCode) || null;
80169
80252
  const fields = {
80253
+ code: f.customCode,
80170
80254
  caseId,
80171
80255
  title: f.title,
80172
80256
  severity: f.severity,
@@ -80249,6 +80333,20 @@ async function importCases(projectId, reportId, file) {
80249
80333
  const result = await prisma.$transaction(async (tx) => {
80250
80334
  const existing = new Map((await tx.qaCase.findMany({ where: { reportId } })).map((c) => [c.id, c]));
80251
80335
  let maxOrder = Math.max(0, ...[...existing.values()].map((c) => c.order));
80336
+ const findings = await tx.qaFinding.findMany({ where: { reportId }, select: { code: true } });
80337
+ const shown = new Map(caseViews([...existing.values()], takenCodes([...existing.values()], findings)).map((c) => [c.id, c.code]));
80338
+ const owner = /* @__PURE__ */ new Map();
80339
+ for (const c of existing.values()) if (c.code) owner.set(qaCodeKey(c.code), c.id);
80340
+ for (const f of findings) if (f.code) owner.set(qaCodeKey(f.code), "finding");
80341
+ const wanted = (r, id2, current) => {
80342
+ const code = r.code.trim();
80343
+ if (!code || code === current) return void 0;
80344
+ if (qaCodeKey(code) === "laporan") throw new QaImportError(400, `baris ${r.row}: kode "Laporan" dicadangkan`);
80345
+ const holder = owner.get(qaCodeKey(code));
80346
+ if (holder && holder !== id2) throw new QaImportError(400, `baris ${r.row}: kode ${code} sudah dipakai; kode harus unik`);
80347
+ owner.set(qaCodeKey(code), id2);
80348
+ return code;
80349
+ };
80252
80350
  const out4 = { updated: 0, created: 0, unchanged: 0 };
80253
80351
  for (const [i, r] of parsed.entries()) {
80254
80352
  const cur = r.ref ? existing.get(r.ref) : void 0;
@@ -80260,18 +80358,21 @@ async function importCases(projectId, reportId, file) {
80260
80358
  actual: r.actual ?? cur.actual,
80261
80359
  status: r.status ?? cur.status
80262
80360
  };
80263
- if (next.title === cur.title && next.steps === cur.steps && next.expected === cur.expected && next.actual === cur.actual && next.status === cur.status) {
80361
+ const code2 = wanted(r, cur.id, shown.get(cur.id));
80362
+ if (!code2 && next.title === cur.title && next.steps === cur.steps && next.expected === cur.expected && next.actual === cur.actual && next.status === cur.status) {
80264
80363
  out4.unchanged++;
80265
80364
  continue;
80266
80365
  }
80267
- await tx.qaCase.update({ where: { id: cur.id }, data: next });
80366
+ await tx.qaCase.update({ where: { id: cur.id }, data: code2 ? { ...next, code: code2 } : next });
80268
80367
  changed.push(cur.id);
80269
80368
  out4.updated++;
80270
80369
  continue;
80271
80370
  }
80272
80371
  if (!r.title) throw new QaImportError(400, `baris ${r.row}: judul wajib diisi (Ref "${r.ref}" tak dikenal di laporan ini)`);
80372
+ const code = wanted(r, `new-${i}`, void 0);
80273
80373
  const made = await tx.qaCase.create({ data: {
80274
80374
  reportId,
80375
+ code: code ?? null,
80275
80376
  title: r.title,
80276
80377
  steps: r.steps ?? "",
80277
80378
  expected: r.expected ?? "",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hanoman",
3
- "version": "0.9.14",
3
+ "version": "0.9.16",
4
4
  "description": "Orchestrator + dashboard workflow docs-driven untuk sesi Claude Code / Codex",
5
5
  "type": "module",
6
6
  "repository": {
@@ -0,0 +1,3 @@
1
+ -- ADR-0176 · kode test case & temuan QA bebas ketikan QA (nullable; null = nomor otomatis)
2
+ ALTER TABLE "QaCase" ADD COLUMN "code" TEXT;
3
+ ALTER TABLE "QaFinding" ADD COLUMN "code" TEXT;
@@ -1088,6 +1088,7 @@ model QaReport {
1088
1088
  model QaCase {
1089
1089
  id String @id @default(cuid())
1090
1090
  reportId String
1091
+ code String? // kode bebas ketikan QA (ADR-0176); null = nomor otomatis TC-nn. Unik per laporan (digerbang service)
1091
1092
  title String
1092
1093
  steps String @default("")
1093
1094
  expected String @default("")
@@ -1108,6 +1109,7 @@ model QaFinding {
1108
1109
  id String @id @default(cuid())
1109
1110
  reportId String
1110
1111
  caseId String?
1112
+ code String? // kode bebas ketikan QA (ADR-0176); null = nomor otomatis F-nn. Unik per laporan (digerbang service)
1111
1113
  title String
1112
1114
  severity String @default("major") // blocker | critical | major | minor | trivial
1113
1115
  priority String @default("P2") // P0..P3 — terpisah dari severity