@specship/specship-win32-arm64 0.21.2 → 0.22.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.
Files changed (96) hide show
  1. package/lib/dist/bin/specship.js +302 -4
  2. package/lib/dist/bin/specship.js.map +1 -1
  3. package/lib/dist/commands/specship/jira.md +74 -0
  4. package/lib/dist/commands/specship/spec.md +61 -1
  5. package/lib/dist/extraction/index.d.ts +6 -0
  6. package/lib/dist/extraction/index.d.ts.map +1 -1
  7. package/lib/dist/extraction/index.js.map +1 -1
  8. package/lib/dist/extraction/specs/markdown-spec-extractor.d.ts.map +1 -1
  9. package/lib/dist/extraction/specs/markdown-spec-extractor.js +8 -1
  10. package/lib/dist/extraction/specs/markdown-spec-extractor.js.map +1 -1
  11. package/lib/dist/index.d.ts +7 -0
  12. package/lib/dist/index.d.ts.map +1 -1
  13. package/lib/dist/index.js +45 -18
  14. package/lib/dist/index.js.map +1 -1
  15. package/lib/dist/jira/auto-publish.d.ts +67 -0
  16. package/lib/dist/jira/auto-publish.d.ts.map +1 -0
  17. package/lib/dist/jira/auto-publish.js +232 -0
  18. package/lib/dist/jira/auto-publish.js.map +1 -0
  19. package/lib/dist/jira/board-first.d.ts +64 -0
  20. package/lib/dist/jira/board-first.d.ts.map +1 -0
  21. package/lib/dist/jira/board-first.js +91 -0
  22. package/lib/dist/jira/board-first.js.map +1 -0
  23. package/lib/dist/jira/client.d.ts +80 -1
  24. package/lib/dist/jira/client.d.ts.map +1 -1
  25. package/lib/dist/jira/client.js +213 -1
  26. package/lib/dist/jira/client.js.map +1 -1
  27. package/lib/dist/jira/config.d.ts +1 -1
  28. package/lib/dist/jira/config.d.ts.map +1 -1
  29. package/lib/dist/jira/config.js +14 -3
  30. package/lib/dist/jira/config.js.map +1 -1
  31. package/lib/dist/jira/coverage.d.ts +105 -0
  32. package/lib/dist/jira/coverage.d.ts.map +1 -0
  33. package/lib/dist/jira/coverage.js +160 -0
  34. package/lib/dist/jira/coverage.js.map +1 -0
  35. package/lib/dist/jira/index.d.ts +1 -0
  36. package/lib/dist/jira/index.d.ts.map +1 -1
  37. package/lib/dist/jira/index.js +1 -0
  38. package/lib/dist/jira/index.js.map +1 -1
  39. package/lib/dist/jira/milestone-comment.d.ts +95 -0
  40. package/lib/dist/jira/milestone-comment.d.ts.map +1 -0
  41. package/lib/dist/jira/milestone-comment.js +271 -0
  42. package/lib/dist/jira/milestone-comment.js.map +1 -0
  43. package/lib/dist/jira/publish.d.ts +69 -2
  44. package/lib/dist/jira/publish.d.ts.map +1 -1
  45. package/lib/dist/jira/publish.js +0 -0
  46. package/lib/dist/jira/publish.js.map +1 -1
  47. package/lib/dist/jira/published-specs.d.ts +23 -0
  48. package/lib/dist/jira/published-specs.d.ts.map +1 -0
  49. package/lib/dist/jira/published-specs.js +106 -0
  50. package/lib/dist/jira/published-specs.js.map +1 -0
  51. package/lib/dist/jira/reconcile.d.ts +81 -0
  52. package/lib/dist/jira/reconcile.d.ts.map +1 -0
  53. package/lib/dist/jira/reconcile.js +111 -0
  54. package/lib/dist/jira/reconcile.js.map +1 -0
  55. package/lib/dist/jira/regression-pack.d.ts +547 -0
  56. package/lib/dist/jira/regression-pack.d.ts.map +1 -0
  57. package/lib/dist/jira/regression-pack.js +986 -0
  58. package/lib/dist/jira/regression-pack.js.map +1 -0
  59. package/lib/dist/jira/repo-config.d.ts +59 -0
  60. package/lib/dist/jira/repo-config.d.ts.map +1 -0
  61. package/lib/dist/jira/repo-config.js +222 -0
  62. package/lib/dist/jira/repo-config.js.map +1 -0
  63. package/lib/dist/jira/spec-amend.d.ts +46 -0
  64. package/lib/dist/jira/spec-amend.d.ts.map +1 -0
  65. package/lib/dist/jira/spec-amend.js +232 -0
  66. package/lib/dist/jira/spec-amend.js.map +1 -0
  67. package/lib/dist/jira/spec-writer.d.ts +22 -0
  68. package/lib/dist/jira/spec-writer.d.ts.map +1 -1
  69. package/lib/dist/jira/spec-writer.js +113 -0
  70. package/lib/dist/jira/spec-writer.js.map +1 -1
  71. package/lib/dist/jira/types.d.ts +62 -0
  72. package/lib/dist/jira/types.d.ts.map +1 -1
  73. package/lib/dist/jira/types.js +15 -1
  74. package/lib/dist/jira/types.js.map +1 -1
  75. package/lib/dist/mcp/jira-tools.d.ts +102 -0
  76. package/lib/dist/mcp/jira-tools.d.ts.map +1 -1
  77. package/lib/dist/mcp/jira-tools.js +807 -50
  78. package/lib/dist/mcp/jira-tools.js.map +1 -1
  79. package/lib/dist/mcp/server-instructions.d.ts +1 -1
  80. package/lib/dist/mcp/server-instructions.d.ts.map +1 -1
  81. package/lib/dist/mcp/server-instructions.js +4 -0
  82. package/lib/dist/mcp/server-instructions.js.map +1 -1
  83. package/lib/dist/mcp/spec-tools.d.ts.map +1 -1
  84. package/lib/dist/mcp/spec-tools.js +7 -1
  85. package/lib/dist/mcp/spec-tools.js.map +1 -1
  86. package/lib/dist/mcp/tools.d.ts +3 -0
  87. package/lib/dist/mcp/tools.d.ts.map +1 -1
  88. package/lib/dist/mcp/tools.js +50 -0
  89. package/lib/dist/mcp/tools.js.map +1 -1
  90. package/lib/dist/resolution/spec-link-resolver.d.ts +2 -0
  91. package/lib/dist/resolution/spec-link-resolver.d.ts.map +1 -1
  92. package/lib/dist/resolution/spec-link-resolver.js +11 -3
  93. package/lib/dist/resolution/spec-link-resolver.js.map +1 -1
  94. package/lib/node_modules/.package-lock.json +1 -1
  95. package/lib/package.json +1 -1
  96. package/package.json +1 -1
@@ -0,0 +1,986 @@
1
+ "use strict";
2
+ /**
3
+ * Regression pack builder + upsert (JIRA-REGRESSION-DOC, REQ-JIRAREG-001).
4
+ *
5
+ * Reads the loaded spec set, filters to requirements whose acceptance-side
6
+ * link roll-up counts as implemented or verified (at least one implements-kind
7
+ * link on the requirement or one of its acceptance children reads implemented
8
+ * or verified), and expands each `REQ-*.A<N>` into a regression case grouped by
9
+ * a domain area. Domain-area derivation is REQ-JIRAREG-002's concern — this
10
+ * module files every case under the single `Uncategorised` placeholder area;
11
+ * `groupCasesByDomain` is a trivial passthrough so REQ-JIRAREG-002 has one
12
+ * seam to enrich later.
13
+ *
14
+ * The upsert side is a fingerprint-gated idempotent writer, mirroring
15
+ * `publish.ts`: one watermarked epic per project (found by label, never
16
+ * created twice), one Story per domain area (found by label), one Sub-task
17
+ * per case (found by label). Fingerprint drift on the rendered body drives
18
+ * create / update / skip.
19
+ *
20
+ * SECURITY (REQ-JIRA-009): only public keys + spec-derived text ever travel
21
+ * through here — no credential is handled or echoed.
22
+ */
23
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
24
+ if (k2 === undefined) k2 = k;
25
+ var desc = Object.getOwnPropertyDescriptor(m, k);
26
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
27
+ desc = { enumerable: true, get: function() { return m[k]; } };
28
+ }
29
+ Object.defineProperty(o, k2, desc);
30
+ }) : (function(o, m, k, k2) {
31
+ if (k2 === undefined) k2 = k;
32
+ o[k2] = m[k];
33
+ }));
34
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
35
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
36
+ }) : function(o, v) {
37
+ o["default"] = v;
38
+ });
39
+ var __importStar = (this && this.__importStar) || (function () {
40
+ var ownKeys = function(o) {
41
+ ownKeys = Object.getOwnPropertyNames || function (o) {
42
+ var ar = [];
43
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
44
+ return ar;
45
+ };
46
+ return ownKeys(o);
47
+ };
48
+ return function (mod) {
49
+ if (mod && mod.__esModule) return mod;
50
+ var result = {};
51
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
52
+ __setModuleDefault(result, mod);
53
+ return result;
54
+ };
55
+ })();
56
+ Object.defineProperty(exports, "__esModule", { value: true });
57
+ exports.REG_PACK_RUN_MARKER_PREFIX = exports.REG_PACK_OBSOLETE_WATERMARK_PREFIX = exports.REG_PACK_CASE_WATERMARK = exports.REG_PACK_STORY_WATERMARK = exports.REG_PACK_EPIC_WATERMARK = exports.REG_PACK_TIER_LABEL_BACKEND = exports.REG_PACK_TIER_LABEL_UI = exports.REG_PACK_OBSOLETE_LABEL = exports.REG_PACK_CASE_MARKER_LABEL = exports.REG_PACK_XREF_LABEL_PREFIX = exports.REG_PACK_CASE_LABEL_PREFIX = exports.REG_PACK_DOMAIN_LABEL_PREFIX = exports.REG_PACK_EPIC_LABEL = void 0;
58
+ exports.regressionContentFingerprint = regressionContentFingerprint;
59
+ exports.readStoredFingerprint = readStoredFingerprint;
60
+ exports.deriveCaseTier = deriveCaseTier;
61
+ exports.classifyCriterion = classifyCriterion;
62
+ exports.renderCaseSteps = renderCaseSteps;
63
+ exports.renderCrossReferenceBody = renderCrossReferenceBody;
64
+ exports.renderRephraseReport = renderRephraseReport;
65
+ exports.renderDomainGapReport = renderDomainGapReport;
66
+ exports.computeDomainAreasByReqId = computeDomainAreasByReqId;
67
+ exports.groupCasesByDomain = groupCasesByDomain;
68
+ exports.buildRegressionPack = buildRegressionPack;
69
+ exports.upsertRegressionPack = upsertRegressionPack;
70
+ exports.findOrphanedCases = findOrphanedCases;
71
+ exports.markCaseObsolete = markCaseObsolete;
72
+ exports.recordRunResult = recordRunResult;
73
+ exports.summarizePackRun = summarizePackRun;
74
+ exports.finalizePackRun = finalizePackRun;
75
+ const crypto = __importStar(require("crypto"));
76
+ const spec_link_resolver_1 = require("../resolution/spec-link-resolver");
77
+ const behaviour_surface_1 = require("../behaviour/behaviour-surface");
78
+ const milestone_comment_1 = require("./milestone-comment");
79
+ // ---------------------------------------------------------------------------
80
+ // Labels + watermarks
81
+ // ---------------------------------------------------------------------------
82
+ exports.REG_PACK_EPIC_LABEL = 'specship-regression-pack';
83
+ exports.REG_PACK_DOMAIN_LABEL_PREFIX = 'specship-regdomain-';
84
+ exports.REG_PACK_CASE_LABEL_PREFIX = 'specship-regcase-';
85
+ /**
86
+ * Cross-reference sub-tasks (REQ-JIRAREG-002.A1). Distinct prefix from the
87
+ * executable case label so a re-run finds the same cross-ref in the same area
88
+ * (idempotent) without conflating it with the primary case.
89
+ */
90
+ exports.REG_PACK_XREF_LABEL_PREFIX = 'specship-regxref-';
91
+ /**
92
+ * Stable marker label every regression case carries alongside its per-case
93
+ * slug label (REQ-JIRAREG-003). JIRA JQL cannot wildcard `labels`, so the
94
+ * orphan scan queries this fixed label to enumerate every case in the pack
95
+ * without touching the epic or area stories (which never carry it).
96
+ */
97
+ exports.REG_PACK_CASE_MARKER_LABEL = 'specship-regcase';
98
+ /**
99
+ * Board-filterable label attached to cases whose source criterion no longer
100
+ * exists in the spec set (REQ-JIRAREG-003.A3). A comment alone would be
101
+ * invisible in a JIRA filter — this label makes `labels = "specship-reg-obsolete"`
102
+ * a valid JQL query. Never removed automatically.
103
+ */
104
+ exports.REG_PACK_OBSOLETE_LABEL = 'specship-reg-obsolete';
105
+ /**
106
+ * Board-filterable tier labels (REQ-JIRAREG-004.A2) — testers can filter a
107
+ * board to just UI or just backend cases with a single label query. Attached
108
+ * to every executable + cross-ref case; the same tier is derived once per
109
+ * requirement so both rows in a two-area case carry the same label.
110
+ */
111
+ exports.REG_PACK_TIER_LABEL_UI = 'specship-tier-ui';
112
+ exports.REG_PACK_TIER_LABEL_BACKEND = 'specship-tier-backend';
113
+ function tierLabel(tier) {
114
+ return tier === 'ui' ? exports.REG_PACK_TIER_LABEL_UI : exports.REG_PACK_TIER_LABEL_BACKEND;
115
+ }
116
+ exports.REG_PACK_EPIC_WATERMARK = '<!-- specship:regression-pack v1 -->';
117
+ exports.REG_PACK_STORY_WATERMARK = '<!-- specship:regression-pack:story v1 -->';
118
+ exports.REG_PACK_CASE_WATERMARK = '<!-- specship:regression-pack:case v1 -->';
119
+ /**
120
+ * Stable prefix of the obsolete-marker comment (REQ-JIRAREG-003.A3). The
121
+ * per-run body carries a timestamp, but the idempotency key here does NOT
122
+ * include the timestamp — a re-run with the same `reason` finds the prior
123
+ * marker via `body.startsWith(prefix + reason + " -->")` and writes nothing
124
+ * (REQ-JIRAREG-003.A4).
125
+ */
126
+ exports.REG_PACK_OBSOLETE_WATERMARK_PREFIX = '<!-- specship:regobsolete v1';
127
+ function obsoleteWatermark(reason) {
128
+ return `${exports.REG_PACK_OBSOLETE_WATERMARK_PREFIX} reason=${reason} -->`;
129
+ }
130
+ const UNCATEGORISED_AREA = 'Uncategorised';
131
+ // ---------------------------------------------------------------------------
132
+ // Builder (pure)
133
+ // ---------------------------------------------------------------------------
134
+ /** States that count as implemented+ for regression-pack inclusion. */
135
+ const IMPLEMENTED_PLUS_STATES = new Set([
136
+ 'implemented',
137
+ 'verified',
138
+ ]);
139
+ /**
140
+ * Roll up the "implemented+" bit for one requirement: is there at least one
141
+ * implements-kind link (on the requirement itself or any of its acceptance
142
+ * children) whose state reads `implemented` or `verified`? This mirrors the
143
+ * funnel/coverage roll-up SpecQueries drives (per the approval correction).
144
+ */
145
+ function isRequirementImplementedPlus(sq, reqId) {
146
+ const own = sq.getLinksBySpec(reqId);
147
+ if (own.some((lk) => lk.kind === 'implements' && IMPLEMENTED_PLUS_STATES.has(lk.state))) {
148
+ return true;
149
+ }
150
+ const children = sq.getSpecsByParent(reqId).filter((c) => c.kind === 'acceptance');
151
+ for (const child of children) {
152
+ const links = sq.getLinksBySpec(child.id);
153
+ if (links.some((lk) => lk.kind === 'implements' && IMPLEMENTED_PLUS_STATES.has(lk.state))) {
154
+ return true;
155
+ }
156
+ }
157
+ return false;
158
+ }
159
+ /** JIRA summaries cap at 255; stay well under. */
160
+ const SUMMARY_MAX = 240;
161
+ function oneLineTrim(text) {
162
+ const one = text.replace(/\s+/g, ' ').trim();
163
+ return one.length > SUMMARY_MAX ? `${one.slice(0, SUMMARY_MAX - 1)}…` : one;
164
+ }
165
+ /** `REQ-FOO-001.A2` → `A2` (criterion suffix without the requirement prefix). */
166
+ function criterionSuffix(reqId, criterionId) {
167
+ return criterionId.startsWith(`${reqId}.`)
168
+ ? criterionId.slice(reqId.length + 1)
169
+ : criterionId;
170
+ }
171
+ /** Kebab-lowercase slug, filesystem/label-safe. */
172
+ function slugify(s) {
173
+ return s
174
+ .toLowerCase()
175
+ .replace(/[^a-z0-9]+/g, '-')
176
+ .replace(/^-+|-+$/g, '')
177
+ .slice(0, 60);
178
+ }
179
+ /** Stable fingerprint of a rendered body — mirrors publish.ts. */
180
+ function regressionContentFingerprint(summary, description) {
181
+ return crypto
182
+ .createHash('sha256')
183
+ .update(summary)
184
+ .update(' ')
185
+ .update(description)
186
+ .digest('hex')
187
+ .slice(0, 16);
188
+ }
189
+ /**
190
+ * Read a stored fingerprint from an issue description, or `null` when absent.
191
+ * The fingerprint travels as `<!-- specship:regfp:<hex> -->` on its own line.
192
+ */
193
+ function readStoredFingerprint(description) {
194
+ if (!description)
195
+ return null;
196
+ const m = /<!--\s*specship:regfp:([a-f0-9]{6,32})\s*-->/i.exec(description);
197
+ return m && m[1] ? m[1] : null;
198
+ }
199
+ function fingerprintTag(fp) {
200
+ return `<!-- specship:regfp:${fp} -->`;
201
+ }
202
+ /** Strip a trailing `<!-- specship:regfp:... -->` tag (and its blank line) if present. */
203
+ function stripFingerprintTag(description) {
204
+ return description.replace(/\n*<!--\s*specship:regfp:[a-f0-9]+\s*-->\s*$/i, '');
205
+ }
206
+ /**
207
+ * Derive a case's tier from the requirement's behaviour surface
208
+ * (REQ-JIRAREG-004.A2). Delegates the UI/backend split to
209
+ * {@link computeBehaviourSurface} so the classification stays consistent
210
+ * with `/ss-behaviour`. A requirement with no linked nodes (or a
211
+ * fixture-mode sq without graph accessors) resolves to `'backend'` —
212
+ * never spurious UI.
213
+ */
214
+ function deriveCaseTier(req, sq) {
215
+ const linked = sq.getLinkedNodesForReq?.(req.id) ?? [];
216
+ if (linked.length === 0)
217
+ return 'backend';
218
+ const neighbours = sq.getNeighbourNodes?.(linked.map((n) => n.id)) ?? [];
219
+ const surface = (0, behaviour_surface_1.computeBehaviourSurface)({
220
+ requirementId: req.id,
221
+ requirementExists: true,
222
+ linkedNodes: linked,
223
+ neighbourNodes: neighbours,
224
+ });
225
+ return surface.ui.length > 0 ? 'ui' : 'backend';
226
+ }
227
+ /**
228
+ * Classify a criterion body for A3: does it name an observable outcome, and
229
+ * does it stay in black-box vocabulary? Two failure modes both flag the case
230
+ * for human rephrase (never silently vague, never leaking file paths /
231
+ * symbols into the pack):
232
+ *
233
+ * 1. **Vague** — no observable verb ("returns", "renders", "MUST", …) and
234
+ * no reference to a concrete artefact ("response", "row", …).
235
+ * 2. **Code-shaped** — the text names a file path (`src/…`), a `.ts` / `.tsx`
236
+ * / `.js` / `.jsx` extension, or a backticked identifier
237
+ * (CamelCase / snake_case). Black-box cases must not echo white-box
238
+ * detail; the flag drives a rephrase pass.
239
+ *
240
+ * Deterministic and cheap on purpose — the goal is surfacing the flags,
241
+ * not scoring prose.
242
+ */
243
+ function classifyCriterion(text) {
244
+ const t = text.trim();
245
+ if (!t)
246
+ return { observable: false, reason: 'empty criterion text' };
247
+ // Code-shape check runs first: a criterion may name an observable outcome
248
+ // AND still leak code detail — the leak is the tighter constraint.
249
+ if (/(^|[\s(`'"])src\//.test(t) ||
250
+ /\.(tsx?|jsx?|py|rb|go|rs|java|kt|swift|cs|php|css|scss|md|yml|yaml|json|sql)\b/i.test(t) ||
251
+ /`[A-Za-z_][A-Za-z0-9_]*`/.test(t) ||
252
+ /`[A-Za-z0-9_.-]+\/[A-Za-z0-9_./-]+`/.test(t)) {
253
+ return { observable: true, reason: 'criterion text contains code references' };
254
+ }
255
+ const observable = /\b(MUST|SHOULD|shows?|displays?|renders?|returns?|responds?|emits?|creates?|updates?|deletes?|rejects?|accepts?|produces?|writes?|reads?|receives?|surfaces?|logs?|records?|sends?|opens?|closes?|starts?|stops?|fails?|succeeds?|passes?|blocks?|allows?|prevents?|prompts?|matches?|contains?|equals?|includes?|excludes?)\b/i.test(t) ||
256
+ /\b(response|request|message|row|file|status|code|body|header|payload|value|count|list|table|error|log|output|result|screen|button|link|form|field|dialog|banner|toast|page|route|endpoint)\b/i.test(t);
257
+ if (!observable)
258
+ return { observable: false, reason: 'no observable outcome in criterion text' };
259
+ return { observable: true };
260
+ }
261
+ /**
262
+ * Render the executable steps + reference section for a case
263
+ * (REQ-JIRAREG-001.A3, REQ-JIRAREG-004.A1). Black-box body: Steps carry no
264
+ * file paths, no code symbols, and no `- Spec:` pointer — those are
265
+ * white-box detail belonging on the source spec, not on the tester's
266
+ * checklist. The tier lands as a header line (readable) AND as a JIRA label
267
+ * on the issue (board-filterable, see {@link tierLabel}).
268
+ */
269
+ function renderCaseSteps(rc) {
270
+ const lines = [];
271
+ lines.push(exports.REG_PACK_CASE_WATERMARK);
272
+ lines.push('');
273
+ lines.push(`Tier: ${rc.tier}`);
274
+ lines.push('');
275
+ lines.push('Steps:');
276
+ lines.push('- Given the feature is available in a bound test environment');
277
+ lines.push(`- When the tester exercises the behaviour: ${oneLineTrim(rc.criterionText)}`);
278
+ lines.push('- Then the observable outcome MUST match the criterion above');
279
+ lines.push('');
280
+ lines.push('Reference:');
281
+ lines.push(`- Source: ${rc.reqId}.${criterionSuffix(rc.reqId, rc.criterionId)} — ${oneLineTrim(rc.criterionText)}`);
282
+ lines.push(`- Domain: ${rc.domainArea}`);
283
+ if (rc.areasAll.length > 1) {
284
+ lines.push(`- Also files under: ${rc.areasAll.filter((a) => a !== rc.domainArea).join(', ')}`);
285
+ }
286
+ if (rc.needsRephrase && rc.rephraseReason) {
287
+ lines.push(`- Needs rephrase: ${rc.rephraseReason}`);
288
+ }
289
+ return lines.join('\n');
290
+ }
291
+ /**
292
+ * Render a cross-reference sub-task body (REQ-JIRAREG-002.A1). Same source
293
+ * id, no duplicate steps — just a pointer at the executable sibling so a
294
+ * tester scanning this area finds it. The label is separate so the same
295
+ * criterion produces one issue per area, and a re-run stays idempotent.
296
+ */
297
+ function renderCrossReferenceBody(rc, executableAreaLabel) {
298
+ const lines = [];
299
+ lines.push(exports.REG_PACK_CASE_WATERMARK);
300
+ lines.push('');
301
+ lines.push(`Tier: ${rc.tier}`);
302
+ lines.push('');
303
+ lines.push(`This is a cross-reference. The executable case for ${rc.reqId}.${criterionSuffix(rc.reqId, rc.criterionId)} lives under the "${executableAreaLabel}" area — run it there.`);
304
+ lines.push('');
305
+ lines.push('Reference:');
306
+ lines.push(`- Source: ${rc.reqId}.${criterionSuffix(rc.reqId, rc.criterionId)} — ${oneLineTrim(rc.criterionText)}`);
307
+ lines.push(`- Executable area: ${executableAreaLabel}`);
308
+ lines.push(`- Cross-referenced here as: ${rc.domainArea}`);
309
+ return lines.join('\n');
310
+ }
311
+ function renderStoryDescription(area, caseCount) {
312
+ return [
313
+ exports.REG_PACK_STORY_WATERMARK,
314
+ '',
315
+ `Regression cases for the "${area}" domain area.`,
316
+ `${caseCount} case${caseCount === 1 ? '' : 's'} attached as Sub-tasks.`,
317
+ ].join('\n');
318
+ }
319
+ function renderEpicDescription(totals) {
320
+ const lines = [
321
+ exports.REG_PACK_EPIC_WATERMARK,
322
+ '',
323
+ 'SpecShip Regression Pack.',
324
+ `${totals.stories} domain area${totals.stories === 1 ? '' : 's'}, ` +
325
+ `${totals.cases} case${totals.cases === 1 ? '' : 's'}.`,
326
+ ];
327
+ if (totals.domainGaps > 0) {
328
+ lines.push(`${totals.domainGaps} requirement${totals.domainGaps === 1 ? '' : 's'} filed under Uncategorised — capture the missing domain fact(s) with /specship:spec domain (REQ-JIRAREG-002.A2).`);
329
+ }
330
+ lines.push('', 'Each Sub-task under a domain-area Story is derived from one acceptance ' +
331
+ 'criterion of an implemented (or verified) requirement. Human testers ' +
332
+ 'can execute the cases directly; the agent can record run results (see ' +
333
+ 'REQ-JIRAREG-005). Re-generating the pack is idempotent — a criterion ' +
334
+ 'that has not changed produces zero JIRA writes.');
335
+ return lines.join('\n');
336
+ }
337
+ /**
338
+ * Render the domain-gap report the caller prints after upsert
339
+ * (REQ-JIRAREG-002.A2). Empty when there are no gaps.
340
+ */
341
+ /**
342
+ * Render the rephrase report (REQ-JIRAREG-004.A3). Empty when zero flags —
343
+ * the caller conditionally prints it after the pack upsert, mirroring the
344
+ * domain-gap report shape.
345
+ */
346
+ function renderRephraseReport(flags) {
347
+ if (flags.length === 0)
348
+ return '';
349
+ const lines = [];
350
+ lines.push(`Criteria needing rephrase — ${flags.length} criterion${flags.length === 1 ? '' : 'a'} flagged (REQ-JIRAREG-004.A3):`);
351
+ for (const f of flags)
352
+ lines.push(` · ${f.criterionId} (${f.specPath}): ${f.reason}`);
353
+ lines.push('Rewrite the criterion body in black-box terms (name the observable outcome, drop file paths / symbols) and re-run the pack; the case updates in place.');
354
+ return lines.join('\n');
355
+ }
356
+ function renderDomainGapReport(gaps) {
357
+ if (gaps.length === 0)
358
+ return '';
359
+ const lines = [];
360
+ lines.push(`Domain gaps — ${gaps.length} requirement${gaps.length === 1 ? '' : 's'} filed under Uncategorised (REQ-JIRAREG-002.A2):`);
361
+ for (const g of gaps)
362
+ lines.push(` · ${g.reqId} — ${g.specPath}`);
363
+ lines.push('Capture the missing domain fact(s) with `/specship:spec domain` and re-run the pack; the next update moves the affected cases into their new area without duplicating them.');
364
+ return lines.join('\n');
365
+ }
366
+ /**
367
+ * Compute the domain areas each requirement inherits transitively, keyed on
368
+ * requirement id (REQ-JIRAREG-002.A1).
369
+ *
370
+ * Direction: domain facts declare `depends_on: REQ-...` (they point AT
371
+ * requirements, per DOMAIN-KNOWLEDGE-DOC), so a requirement's own spec-tier
372
+ * chain does NOT reach the domain facts that govern it. We walk in the
373
+ * OTHER direction — for each `domain` spec, follow its parent /
374
+ * `metadata.depends_on` chain outward and register that domain fact against
375
+ * every requirement it reaches.
376
+ *
377
+ * This mirrors {@link SpecLinkResolver.getInheritedLinks} — same BFS, same
378
+ * `sourceSpecIds` traversal, same depth cap ({@link INHERITED_LINK_MAX_DEPTH}).
379
+ * `sourceSpecIds` is imported from the resolver (single source of truth for
380
+ * "the spec-tier edges a spec has"), but the walk itself is reproduced here
381
+ * to keep the regression-pack module free of a `SpecLinkResolver` dependency
382
+ * (which would require threading `QueryBuilder` all the way through). Per the
383
+ * approval note, the mirror is deliberate; `BuilderSpecQueries` exposes only
384
+ * the point-lookup surface it needs (`getAllSpecs`, `getSpecById`) so the
385
+ * builder stays trivially fixture-able.
386
+ *
387
+ * Per-domain-fact BFS is cycle-guarded and depth-capped. Ordering of the
388
+ * output list is deterministic: domain spec ids sorted lex, deduped.
389
+ */
390
+ function computeDomainAreasByReqId(sq) {
391
+ const out = new Map();
392
+ const seenPairs = new Set(); // `${reqId}\0${domainId}`
393
+ const domainFacts = sq
394
+ .getAllSpecs()
395
+ .filter((s) => s.kind === 'domain')
396
+ // Deterministic outer order — matters for the primary-area pick.
397
+ .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
398
+ for (const fact of domainFacts) {
399
+ // BFS from the fact outward through parent / depends_on. Mirrors
400
+ // SpecLinkResolver.getInheritedLinks; visited-set = string ids (we may
401
+ // not resolve every hop to an indexed Spec).
402
+ const visited = new Set([fact.id]);
403
+ const queue = (0, spec_link_resolver_1.sourceSpecIds)(fact).map((id) => ({ id, depth: 1 }));
404
+ while (queue.length > 0) {
405
+ const { id, depth } = queue.shift();
406
+ if (visited.has(id))
407
+ continue;
408
+ visited.add(id);
409
+ const src = sq.getSpecById(id);
410
+ if (!src)
411
+ continue; // gap (unresolved id) — silently skip; not our concern
412
+ if (src.kind === 'requirement') {
413
+ const pairKey = `${src.id}${fact.id}`;
414
+ if (!seenPairs.has(pairKey)) {
415
+ seenPairs.add(pairKey);
416
+ const bucket = out.get(src.id);
417
+ if (bucket)
418
+ bucket.push(fact);
419
+ else
420
+ out.set(src.id, [fact]);
421
+ }
422
+ // Continue walking — a domain fact that reaches a requirement AND its
423
+ // parent document still covers both; the requirement is the interesting
424
+ // hit for the pack, but we don't prune.
425
+ }
426
+ if (depth < spec_link_resolver_1.INHERITED_LINK_MAX_DEPTH) {
427
+ for (const nextId of (0, spec_link_resolver_1.sourceSpecIds)(src)) {
428
+ if (!visited.has(nextId))
429
+ queue.push({ id: nextId, depth: depth + 1 });
430
+ }
431
+ }
432
+ }
433
+ }
434
+ // Post-sort each bucket by domain spec id — the executable case picks
435
+ // buckets[0], and IDs are stable across renames (unlike titles).
436
+ for (const [reqId, facts] of out) {
437
+ out.set(reqId, [...facts].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)));
438
+ }
439
+ return out;
440
+ }
441
+ /**
442
+ * Group cases by their pre-computed `domainArea`. In REQ-JIRAREG-001 every
443
+ * case was `Uncategorised`; REQ-JIRAREG-002 fills in real domain-fact areas
444
+ * — same input shape, same output shape, richer bucketing.
445
+ */
446
+ function groupCasesByDomain(cases) {
447
+ const out = new Map();
448
+ for (const rc of cases) {
449
+ const bucket = out.get(rc.domainArea);
450
+ if (bucket)
451
+ bucket.push(rc);
452
+ else
453
+ out.set(rc.domainArea, [rc]);
454
+ }
455
+ return out;
456
+ }
457
+ /**
458
+ * Build a full regression pack plan from the loaded spec set (REQ-JIRAREG-001).
459
+ * Pure — no JIRA calls. Deterministic ordering: requirements sorted by id,
460
+ * criteria in the order they appear as `acceptance` children of the requirement.
461
+ */
462
+ function buildRegressionPack(sq) {
463
+ const all = sq.getAllSpecs();
464
+ const requirements = all
465
+ .filter((s) => s.kind === 'requirement')
466
+ .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
467
+ // Precompute inverse map: which domain facts each requirement inherits.
468
+ // Ordering inside each list is stable-by-id so the "first area" pick below
469
+ // is deterministic across runs.
470
+ const domainAreasByReq = computeDomainAreasByReqId(sq);
471
+ const cases = [];
472
+ const domainGaps = [];
473
+ const rephraseFlags = [];
474
+ for (const req of requirements) {
475
+ if (!isRequirementImplementedPlus(sq, req.id))
476
+ continue;
477
+ const criteria = sq
478
+ .getSpecsByParent(req.id)
479
+ .filter((c) => c.kind === 'acceptance')
480
+ .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
481
+ if (criteria.length === 0)
482
+ continue;
483
+ const domainFacts = domainAreasByReq.get(req.id) ?? [];
484
+ const areaTitles = domainFacts.length === 0
485
+ ? [UNCATEGORISED_AREA]
486
+ : domainFacts.map((f) => f.title || f.id);
487
+ const primaryArea = areaTitles[0];
488
+ if (domainFacts.length === 0) {
489
+ // Once per requirement — A2 lists the REQ, not each criterion.
490
+ domainGaps.push({ reqId: req.id, specPath: req.sourcePath });
491
+ }
492
+ // Tier derived once per requirement (REQ-JIRAREG-004.A2): both executable
493
+ // and cross-ref rows for the same criterion carry the same tier.
494
+ const tier = deriveCaseTier(req, sq);
495
+ for (const c of criteria) {
496
+ const text = (c.title || c.body || '').trim();
497
+ if (!text)
498
+ continue;
499
+ const cls = classifyCriterion(text);
500
+ const needsRephrase = !cls.observable || Boolean(cls.reason);
501
+ if (needsRephrase && cls.reason) {
502
+ rephraseFlags.push({
503
+ criterionId: c.id,
504
+ specPath: req.sourcePath,
505
+ reason: cls.reason,
506
+ });
507
+ }
508
+ const base = {
509
+ reqId: req.id,
510
+ criterionId: c.id,
511
+ criterionText: text,
512
+ specPath: req.sourcePath,
513
+ tier,
514
+ areasAll: areaTitles,
515
+ ...(needsRephrase
516
+ ? { needsRephrase: true, rephraseReason: cls.reason ?? 'unspecified' }
517
+ : {}),
518
+ };
519
+ // Executable case in the primary area.
520
+ cases.push({ ...base, domainArea: primaryArea, kind: 'executable' });
521
+ // Cross-references in every additional area.
522
+ for (const extra of areaTitles.slice(1)) {
523
+ cases.push({
524
+ ...base,
525
+ domainArea: extra,
526
+ kind: 'crossref',
527
+ crossReferenceOf: c.id,
528
+ });
529
+ }
530
+ }
531
+ }
532
+ const grouped = groupCasesByDomain(cases);
533
+ const stories = [];
534
+ const casePlans = [];
535
+ // Iterate areas in deterministic order — the map preserves insertion order,
536
+ // but a domain fact never inserted (all-crossref) shouldn't matter. Sort by
537
+ // area title so re-runs render stories in the same order regardless of
538
+ // requirement iteration order.
539
+ const areasSorted = [...grouped.keys()].sort((a, b) => a < b ? -1 : a > b ? 1 : 0);
540
+ for (const area of areasSorted) {
541
+ const group = grouped.get(area);
542
+ const areaLabel = `${exports.REG_PACK_DOMAIN_LABEL_PREFIX}${slugify(area)}`;
543
+ const areaCases = [];
544
+ for (const rc of group) {
545
+ const suffix = criterionSuffix(rc.reqId, rc.criterionId);
546
+ const isXref = rc.kind === 'crossref';
547
+ const label = isXref
548
+ ? `${exports.REG_PACK_XREF_LABEL_PREFIX}${slugify(rc.reqId)}-${slugify(suffix)}-${slugify(area)}`
549
+ : `${exports.REG_PACK_CASE_LABEL_PREFIX}${slugify(rc.reqId)}-${slugify(suffix)}`;
550
+ const summary = isXref
551
+ ? oneLineTrim(`${rc.reqId}.${suffix} (xref: ${area}) — ${rc.criterionText}`)
552
+ : oneLineTrim(`${rc.reqId}.${suffix} — ${rc.criterionText}`);
553
+ const body = isXref
554
+ ? renderCrossReferenceBody(rc, rc.areasAll[0])
555
+ : renderCaseSteps(rc);
556
+ const fp = regressionContentFingerprint(summary, body);
557
+ const description = `${body}\n\n${fingerprintTag(fp)}`;
558
+ areaCases.push({
559
+ reqId: rc.reqId,
560
+ criterionId: rc.criterionId,
561
+ summary,
562
+ description,
563
+ label,
564
+ specPath: rc.specPath,
565
+ kind: rc.kind,
566
+ tierLabel: tierLabel(rc.tier),
567
+ });
568
+ }
569
+ const storySummary = oneLineTrim(`Regression: ${area}`);
570
+ const storyDesc = renderStoryDescription(area, areaCases.length);
571
+ const storyFp = regressionContentFingerprint(storySummary, storyDesc);
572
+ stories.push({
573
+ domainArea: area,
574
+ summary: storySummary,
575
+ description: `${storyDesc}\n\n${fingerprintTag(storyFp)}`,
576
+ label: areaLabel,
577
+ cases: areaCases,
578
+ });
579
+ casePlans.push(...areaCases);
580
+ }
581
+ const epicSummary = oneLineTrim('SpecShip Regression Pack');
582
+ const epicDesc = renderEpicDescription({
583
+ stories: stories.length,
584
+ cases: casePlans.length,
585
+ domainGaps: domainGaps.length,
586
+ });
587
+ const epicFp = regressionContentFingerprint(epicSummary, epicDesc);
588
+ const epic = {
589
+ summary: epicSummary,
590
+ description: `${epicDesc}\n\n${fingerprintTag(epicFp)}`,
591
+ label: exports.REG_PACK_EPIC_LABEL,
592
+ };
593
+ return { epic, stories, cases: casePlans, domainGaps, rephraseFlags };
594
+ }
595
+ /**
596
+ * Find an existing SpecShip-owned issue by label — or create a fresh one and
597
+ * seed the fingerprint-gated body. When multiple labelled matches exist the
598
+ * OLDEST-lex (`key` sort) is picked as canonical; the rest are returned as
599
+ * orphans (single-canonical invariant, REQ-JIRAREG-001.A1).
600
+ */
601
+ async function upsertLabelledIssue(client, ctx, label, issueType, summary, description, parentKey, extraLabels = []) {
602
+ const matches = await client.searchIssuesByLabel(ctx.projectKey, label);
603
+ const sorted = [...matches].sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
604
+ const canonical = sorted[0];
605
+ const orphans = sorted.slice(1).map((m) => m.key);
606
+ if (canonical) {
607
+ // Fingerprint the WITHOUT-TAG body so a re-run compares apples-to-apples
608
+ // with the tag we previously stored (the tag itself is derived from the
609
+ // pre-tag content and would otherwise self-invalidate on rehash).
610
+ const newFp = regressionContentFingerprint(summary, stripFingerprintTag(description));
611
+ const detail = await client.getIssue(canonical.key);
612
+ const storedFp = readStoredFingerprint(detail.issue.description);
613
+ // Parent reassignment (REQ-JIRAREG-002.A3): a case can move areas without
614
+ // any body drift when its requirement gains a domain fact. We detect the
615
+ // move by comparing stored parent vs desired; only writes when they differ.
616
+ const storedParent = detail.issue.parentKey;
617
+ const parentChanged = parentKey !== undefined && storedParent !== parentKey;
618
+ if (storedFp === newFp && !parentChanged) {
619
+ // Backfill any missing extra labels on a canonical issue that pre-dates
620
+ // them (REQ-JIRAREG-003 marker label rollout). `addLabel` is idempotent —
621
+ // a no-op when already set, so a re-run stays zero-write.
622
+ if (!ctx.dryRun) {
623
+ for (const extra of extraLabels) {
624
+ await client.addLabel(canonical.key, extra);
625
+ }
626
+ }
627
+ return { outcome: { key: canonical.key, action: 'skipped' }, orphans };
628
+ }
629
+ if (!ctx.dryRun) {
630
+ const patch = {};
631
+ if (storedFp !== newFp) {
632
+ patch.summary = summary;
633
+ patch.description = description;
634
+ }
635
+ if (parentChanged)
636
+ patch.parentKey = parentKey;
637
+ await client.updateIssue(canonical.key, patch);
638
+ for (const extra of extraLabels) {
639
+ await client.addLabel(canonical.key, extra);
640
+ }
641
+ }
642
+ return { outcome: { key: canonical.key, action: 'updated' }, orphans };
643
+ }
644
+ if (ctx.dryRun) {
645
+ // Synthesize a placeholder key so downstream planning has something to
646
+ // reference; no writes performed.
647
+ return {
648
+ outcome: { key: `DRYRUN-${label}`, action: 'created' },
649
+ orphans,
650
+ };
651
+ }
652
+ const created = await client.createIssue({
653
+ projectKey: ctx.projectKey,
654
+ issueType,
655
+ summary,
656
+ description,
657
+ labels: [label, ...extraLabels],
658
+ ...(parentKey ? { parentKey } : {}),
659
+ });
660
+ return { outcome: { key: created.key, action: 'created' }, orphans };
661
+ }
662
+ /**
663
+ * Idempotent regression-pack upsert (REQ-JIRAREG-001, REQ-JIRAREG-003 skeleton).
664
+ * Walks the model epic → story → case; each level is keyed on its SpecShip
665
+ * label and its body is fingerprint-gated so a re-run without spec changes
666
+ * writes nothing new. Extra epics under the pack label are refused (single-epic
667
+ * invariant, A1) and reported as orphans; the caller decides how to surface.
668
+ */
669
+ async function upsertRegressionPack(client, model, ctx) {
670
+ const epicType = ctx.epicIssueType ?? 'Epic';
671
+ const storyType = ctx.storyIssueType ?? 'Story';
672
+ const caseType = ctx.caseIssueType ?? 'Sub-task';
673
+ const epicRes = await upsertLabelledIssue(client, ctx, model.epic.label, epicType, model.epic.summary, model.epic.description, undefined);
674
+ let storiesCreated = 0;
675
+ let storiesUpdated = 0;
676
+ let storiesSkipped = 0;
677
+ let casesCreated = 0;
678
+ let casesUpdated = 0;
679
+ let casesSkipped = 0;
680
+ let crossRefsCreated = 0;
681
+ let crossRefsUpdated = 0;
682
+ let crossRefsSkipped = 0;
683
+ const caseKeysByCriterion = {};
684
+ const crossRefKeys = [];
685
+ for (const story of model.stories) {
686
+ const storyRes = await upsertLabelledIssue(client, ctx, story.label, storyType, story.summary, story.description, epicRes.outcome.key);
687
+ if (storyRes.outcome.action === 'created')
688
+ storiesCreated++;
689
+ else if (storyRes.outcome.action === 'updated')
690
+ storiesUpdated++;
691
+ else
692
+ storiesSkipped++;
693
+ for (const c of story.cases) {
694
+ const caseRes = await upsertLabelledIssue(client, ctx, c.label, caseType, c.summary, c.description, storyRes.outcome.key, [exports.REG_PACK_CASE_MARKER_LABEL, c.tierLabel]);
695
+ if (c.kind === 'crossref') {
696
+ if (caseRes.outcome.action === 'created')
697
+ crossRefsCreated++;
698
+ else if (caseRes.outcome.action === 'updated')
699
+ crossRefsUpdated++;
700
+ else
701
+ crossRefsSkipped++;
702
+ crossRefKeys.push(caseRes.outcome.key);
703
+ }
704
+ else {
705
+ if (caseRes.outcome.action === 'created')
706
+ casesCreated++;
707
+ else if (caseRes.outcome.action === 'updated')
708
+ casesUpdated++;
709
+ else
710
+ casesSkipped++;
711
+ // The executable case's key is the traceability anchor. Cross-refs
712
+ // deliberately do NOT overwrite this — the spec-side back-link is
713
+ // one criterion → one canonical case key (the executable one).
714
+ caseKeysByCriterion[c.criterionId] = caseRes.outcome.key;
715
+ }
716
+ }
717
+ }
718
+ // Reconcile orphaned cases (REQ-JIRAREG-003.A3). Query every case in the
719
+ // pack via the fixed marker label — the epic and area stories never carry
720
+ // it, so this scan touches only cases. Any case whose key is not in the
721
+ // just-upserted set belongs to a criterion that no longer exists in the
722
+ // spec set (removed or superseded) → mark obsolete. Never deleted; run
723
+ // history stays intact.
724
+ const obsoletedCaseKeys = [];
725
+ let casesObsoleted = 0;
726
+ if (!ctx.dryRun) {
727
+ // Both executable and cross-ref sub-tasks carry the case marker label, so
728
+ // both must be in `expected` — otherwise a live cross-ref would trip the
729
+ // obsolete scan every run (`caseKeysByCriterion` records only executables).
730
+ const expected = new Set(Object.values(caseKeysByCriterion));
731
+ for (const k of crossRefKeys)
732
+ expected.add(k);
733
+ const orphans = await findOrphanedCases(client, ctx.projectKey, expected);
734
+ for (const orphan of orphans) {
735
+ const outcome = await markCaseObsolete(client, orphan.key, 'removed');
736
+ if (outcome.action === 'obsoleted') {
737
+ casesObsoleted++;
738
+ obsoletedCaseKeys.push(orphan.key);
739
+ }
740
+ }
741
+ }
742
+ return {
743
+ epicKey: epicRes.outcome.key,
744
+ epicCreated: epicRes.outcome.action === 'created',
745
+ storiesCreated,
746
+ storiesUpdated,
747
+ storiesSkipped,
748
+ casesCreated,
749
+ casesUpdated,
750
+ casesSkipped,
751
+ crossRefsCreated,
752
+ crossRefsUpdated,
753
+ crossRefsSkipped,
754
+ casesObsoleted,
755
+ obsoletedCaseKeys,
756
+ orphanedEpicKeys: epicRes.orphans,
757
+ caseKeysByCriterion,
758
+ };
759
+ }
760
+ // ---------------------------------------------------------------------------
761
+ // Obsolete reconciliation (REQ-JIRAREG-003.A3)
762
+ // ---------------------------------------------------------------------------
763
+ /**
764
+ * Enumerate case issues in the pack (via the fixed case marker label) that are
765
+ * not in `expectedKeys` — the caller's set of case keys just upserted from the
766
+ * current model. The result is deterministic (sorted lex on key) so re-runs
767
+ * report obsolete work in the same order.
768
+ */
769
+ async function findOrphanedCases(client, projectKey, expectedKeys) {
770
+ const all = await client.searchIssuesByLabel(projectKey, exports.REG_PACK_CASE_MARKER_LABEL);
771
+ return all
772
+ .filter((i) => !expectedKeys.has(i.key))
773
+ .sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
774
+ }
775
+ /**
776
+ * Mark a case obsolete on JIRA (REQ-JIRAREG-003.A3): attach a stable
777
+ * watermarked comment naming the reason AND add the board-filterable
778
+ * `specship-reg-obsolete` label. Both writes are idempotent — a re-run with
779
+ * the same reason finds the prior marker and adds nothing (A4).
780
+ *
781
+ * Never transitions the issue's workflow status (spec-driven correction:
782
+ * "no workflow transitions by default") and never deletes it — the run
783
+ * history and any prior evidence stay intact.
784
+ */
785
+ async function markCaseObsolete(client, issueKey, reason) {
786
+ const watermark = obsoleteWatermark(reason);
787
+ const comments = await client.listCommentsDetailed(issueKey);
788
+ const hasMarker = comments.some((c) => c.body.startsWith(watermark));
789
+ const labelRes = await client.addLabel(issueKey, exports.REG_PACK_OBSOLETE_LABEL);
790
+ if (hasMarker) {
791
+ // Idempotency: the comment marker is the source-of-truth signal that
792
+ // this case has already been marked obsolete for `reason`. A label-only
793
+ // add (backfill) does not count as a fresh obsoletion — the previous
794
+ // run already recorded it.
795
+ return { action: 'skipped' };
796
+ }
797
+ const body = [
798
+ watermark,
799
+ '',
800
+ `This regression case is obsolete: ${reason}.`,
801
+ `Marked ${new Date().toISOString()}.`,
802
+ 'The issue is preserved so past run history stays intact; the pack ' +
803
+ 'will no longer regenerate it.',
804
+ ].join('\n');
805
+ await client.addComment(issueKey, body);
806
+ return { action: 'obsoleted', commentAdded: true, labelAdded: labelRes.added };
807
+ }
808
+ /** Prefix of the recorder-owned comment marker. Body carries the runId + status. */
809
+ exports.REG_PACK_RUN_MARKER_PREFIX = '<!-- specship:regression-run';
810
+ function runMarker(caseKey, runId) {
811
+ return `${exports.REG_PACK_RUN_MARKER_PREFIX} case=${caseKey} run=${runId} -->`;
812
+ }
813
+ /** Rendered triage pointer surfaced on `failed` results (REQ-JIRAREG-005.A3). */
814
+ function triageHint(criterionId) {
815
+ return `Failed — triage with \`/specship:spec triage ${criterionId}\`.`;
816
+ }
817
+ function renderRunCommentBody(result, runId, timestamp) {
818
+ const lines = [];
819
+ lines.push(runMarker(result.caseKey, runId));
820
+ lines.push('');
821
+ lines.push(`SpecShip: regression run ${runId} recorded ${result.status}.`);
822
+ lines.push(`- Criterion: ${result.criterionId}`);
823
+ lines.push(`- Executor: ${result.executor}${result.harness ? ` (${result.harness})` : ''}`);
824
+ lines.push(`- Recorded: ${timestamp}`);
825
+ if (result.evidence?.logRef)
826
+ lines.push(`- Log: ${result.evidence.logRef}`);
827
+ if (result.evidence?.error)
828
+ lines.push(`- Error: ${result.evidence.error}`);
829
+ if (result.status === 'failed') {
830
+ lines.push('');
831
+ lines.push(triageHint(result.criterionId));
832
+ }
833
+ return lines.join('\n');
834
+ }
835
+ /**
836
+ * Record a pack-run result on a case issue (REQ-JIRAREG-005).
837
+ *
838
+ * Idempotent by (caseKey, runId): a second call with the same pair edits the
839
+ * prior comment in place. Pass → `Done` transition + validates-link state
840
+ * `verified`. Fail → `In Review` + `broken` + a triage hint line. Unexecuted →
841
+ * comment only; the case's workflow status and any existing validates-link
842
+ * state stay untouched (A3). External-evidence links use `jira://<CASE-KEY>`
843
+ * as the `targetFilePath` — {@link SpecLinkResolver} refuses to re-resolve
844
+ * that prefix, so a code-graph sync never flips the state to orphaned.
845
+ */
846
+ async function recordRunResult(deps, result) {
847
+ const now = deps.now ?? Date.now;
848
+ const runId = (result.runId && result.runId.trim()) || `adhoc-${now()}`;
849
+ const marker = runMarker(result.caseKey, runId);
850
+ const body = renderRunCommentBody(result, runId, new Date(now()).toISOString());
851
+ // Comment upsert — keyed on (caseKey, runId) so a re-record edits in place.
852
+ const existing = await deps.client.listCommentsDetailed(result.caseKey);
853
+ const prior = existing.find((c) => c.body.startsWith(marker));
854
+ let commentAction;
855
+ if (prior) {
856
+ if (prior.body === body) {
857
+ commentAction = 'skipped';
858
+ }
859
+ else {
860
+ await deps.client.updateComment(result.caseKey, prior.id, body);
861
+ commentAction = 'updated';
862
+ }
863
+ }
864
+ else {
865
+ await deps.client.addComment(result.caseKey, body);
866
+ commentAction = 'created';
867
+ }
868
+ // Transition — pass/fail only. Unexecuted + obsolete never touch workflow.
869
+ const transitions = deps.transitions ?? { passed: 'Done', failed: 'In Review' };
870
+ let transitionAction = 'skipped';
871
+ if (result.status === 'passed' && transitions.passed) {
872
+ const t = await deps.client.transitionIssue(result.caseKey, transitions.passed);
873
+ transitionAction = t.transitioned ? 'moved' : 'skipped';
874
+ }
875
+ else if (result.status === 'failed' && transitions.failed) {
876
+ const t = await deps.client.transitionIssue(result.caseKey, transitions.failed);
877
+ transitionAction = t.transitioned ? 'moved' : 'skipped';
878
+ }
879
+ // Validates-link upsert — jira://<CASE-KEY> is the external evidence
880
+ // pointer. Unexecuted / obsolete leave any existing link's state UNTOUCHED.
881
+ let linked = false;
882
+ if (deps.specQueries && (result.status === 'passed' || result.status === 'failed')) {
883
+ const nextState = result.status === 'passed' ? 'verified' : 'broken';
884
+ const targetFilePath = `jira://${result.caseKey}`;
885
+ const targetQualifiedName = result.caseKey;
886
+ const existingLink = deps.specQueries.findLogicalLink(result.criterionId, targetFilePath, targetQualifiedName, 'validates');
887
+ const spec = deps.specQueries.getSpecById(result.criterionId);
888
+ const specHash = spec?.contentHash ?? existingLink?.specHashAtLink ?? '';
889
+ const ts = now();
890
+ const metadata = {
891
+ runId,
892
+ status: result.status,
893
+ executor: result.executor,
894
+ };
895
+ if (result.harness)
896
+ metadata.harness = result.harness;
897
+ if (result.evidence)
898
+ metadata.evidence = result.evidence;
899
+ if (existingLink) {
900
+ // Refresh metadata + state via updateSpecLinkState — the row already
901
+ // exists on the correct logical key so we don't need to re-upsert.
902
+ deps.specQueries.updateSpecLinkState(existingLink.id, nextState, null, ts);
903
+ }
904
+ else {
905
+ deps.specQueries.upsertSpecLink({
906
+ specId: result.criterionId,
907
+ targetFilePath,
908
+ targetQualifiedName,
909
+ targetNodeKind: 'file',
910
+ kind: 'validates',
911
+ state: nextState,
912
+ driftAxis: null,
913
+ specHashAtLink: specHash,
914
+ provenance: 'agent-asserted',
915
+ confidence: 1.0,
916
+ metadata,
917
+ createdAt: ts,
918
+ updatedAt: ts,
919
+ });
920
+ }
921
+ linked = true;
922
+ }
923
+ return {
924
+ pending: false,
925
+ status: result.status,
926
+ linked,
927
+ comment: commentAction,
928
+ transition: transitionAction,
929
+ ...(result.status === 'failed' ? { triageHint: triageHint(result.criterionId) } : {}),
930
+ };
931
+ }
932
+ /** Aggregate per-result counts + triage list from a run's recorded results. */
933
+ function summarizePackRun(summary) {
934
+ let passed = 0;
935
+ let failed = 0;
936
+ let unexecuted = 0;
937
+ let obsolete = 0;
938
+ const triage = new Set();
939
+ for (const r of summary.results) {
940
+ switch (r.status) {
941
+ case 'passed':
942
+ passed++;
943
+ break;
944
+ case 'failed':
945
+ failed++;
946
+ triage.add(r.criterionId);
947
+ break;
948
+ case 'unexecuted':
949
+ unexecuted++;
950
+ break;
951
+ case 'obsolete':
952
+ obsolete++;
953
+ break;
954
+ }
955
+ }
956
+ const executed = passed + failed;
957
+ const ev = {
958
+ runId: summary.runId,
959
+ executed,
960
+ passed,
961
+ failed,
962
+ unexecuted,
963
+ obsolete,
964
+ triageCriterionIds: [...triage].sort(),
965
+ };
966
+ if (summary.startedAt)
967
+ ev.startedAt = summary.startedAt;
968
+ if (summary.finishedAt)
969
+ ev.finishedAt = summary.finishedAt;
970
+ return ev;
971
+ }
972
+ /**
973
+ * Close a pack run: post (or edit-in-place) the single summary comment on the
974
+ * pack epic. The marker keys on the epic + kind — never on the runId — so a
975
+ * subsequent run's summary edits the prior comment rather than appending
976
+ * (REQ-JIRAREG-005.A4).
977
+ */
978
+ async function finalizePackRun(client, epicKey, summary) {
979
+ const evidence = summarizePackRun(summary);
980
+ return (0, milestone_comment_1.postMilestoneComment)(client, epicKey, 'pack_run_summary', {
981
+ // The pack epic is the "spec" the milestone comment records against here.
982
+ specId: epicKey,
983
+ packRun: evidence,
984
+ });
985
+ }
986
+ //# sourceMappingURL=regression-pack.js.map