@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.
- package/lib/dist/bin/specship.js +302 -4
- package/lib/dist/bin/specship.js.map +1 -1
- package/lib/dist/commands/specship/jira.md +74 -0
- package/lib/dist/commands/specship/spec.md +61 -1
- package/lib/dist/extraction/index.d.ts +6 -0
- package/lib/dist/extraction/index.d.ts.map +1 -1
- package/lib/dist/extraction/index.js.map +1 -1
- package/lib/dist/extraction/specs/markdown-spec-extractor.d.ts.map +1 -1
- package/lib/dist/extraction/specs/markdown-spec-extractor.js +8 -1
- package/lib/dist/extraction/specs/markdown-spec-extractor.js.map +1 -1
- package/lib/dist/index.d.ts +7 -0
- package/lib/dist/index.d.ts.map +1 -1
- package/lib/dist/index.js +45 -18
- package/lib/dist/index.js.map +1 -1
- package/lib/dist/jira/auto-publish.d.ts +67 -0
- package/lib/dist/jira/auto-publish.d.ts.map +1 -0
- package/lib/dist/jira/auto-publish.js +232 -0
- package/lib/dist/jira/auto-publish.js.map +1 -0
- package/lib/dist/jira/board-first.d.ts +64 -0
- package/lib/dist/jira/board-first.d.ts.map +1 -0
- package/lib/dist/jira/board-first.js +91 -0
- package/lib/dist/jira/board-first.js.map +1 -0
- package/lib/dist/jira/client.d.ts +80 -1
- package/lib/dist/jira/client.d.ts.map +1 -1
- package/lib/dist/jira/client.js +213 -1
- package/lib/dist/jira/client.js.map +1 -1
- package/lib/dist/jira/config.d.ts +1 -1
- package/lib/dist/jira/config.d.ts.map +1 -1
- package/lib/dist/jira/config.js +14 -3
- package/lib/dist/jira/config.js.map +1 -1
- package/lib/dist/jira/coverage.d.ts +105 -0
- package/lib/dist/jira/coverage.d.ts.map +1 -0
- package/lib/dist/jira/coverage.js +160 -0
- package/lib/dist/jira/coverage.js.map +1 -0
- package/lib/dist/jira/index.d.ts +1 -0
- package/lib/dist/jira/index.d.ts.map +1 -1
- package/lib/dist/jira/index.js +1 -0
- package/lib/dist/jira/index.js.map +1 -1
- package/lib/dist/jira/milestone-comment.d.ts +95 -0
- package/lib/dist/jira/milestone-comment.d.ts.map +1 -0
- package/lib/dist/jira/milestone-comment.js +271 -0
- package/lib/dist/jira/milestone-comment.js.map +1 -0
- package/lib/dist/jira/publish.d.ts +69 -2
- package/lib/dist/jira/publish.d.ts.map +1 -1
- package/lib/dist/jira/publish.js +0 -0
- package/lib/dist/jira/publish.js.map +1 -1
- package/lib/dist/jira/published-specs.d.ts +23 -0
- package/lib/dist/jira/published-specs.d.ts.map +1 -0
- package/lib/dist/jira/published-specs.js +106 -0
- package/lib/dist/jira/published-specs.js.map +1 -0
- package/lib/dist/jira/reconcile.d.ts +81 -0
- package/lib/dist/jira/reconcile.d.ts.map +1 -0
- package/lib/dist/jira/reconcile.js +111 -0
- package/lib/dist/jira/reconcile.js.map +1 -0
- package/lib/dist/jira/regression-pack.d.ts +547 -0
- package/lib/dist/jira/regression-pack.d.ts.map +1 -0
- package/lib/dist/jira/regression-pack.js +986 -0
- package/lib/dist/jira/regression-pack.js.map +1 -0
- package/lib/dist/jira/repo-config.d.ts +59 -0
- package/lib/dist/jira/repo-config.d.ts.map +1 -0
- package/lib/dist/jira/repo-config.js +222 -0
- package/lib/dist/jira/repo-config.js.map +1 -0
- package/lib/dist/jira/spec-amend.d.ts +46 -0
- package/lib/dist/jira/spec-amend.d.ts.map +1 -0
- package/lib/dist/jira/spec-amend.js +232 -0
- package/lib/dist/jira/spec-amend.js.map +1 -0
- package/lib/dist/jira/spec-writer.d.ts +22 -0
- package/lib/dist/jira/spec-writer.d.ts.map +1 -1
- package/lib/dist/jira/spec-writer.js +113 -0
- package/lib/dist/jira/spec-writer.js.map +1 -1
- package/lib/dist/jira/types.d.ts +62 -0
- package/lib/dist/jira/types.d.ts.map +1 -1
- package/lib/dist/jira/types.js +15 -1
- package/lib/dist/jira/types.js.map +1 -1
- package/lib/dist/mcp/jira-tools.d.ts +102 -0
- package/lib/dist/mcp/jira-tools.d.ts.map +1 -1
- package/lib/dist/mcp/jira-tools.js +807 -50
- package/lib/dist/mcp/jira-tools.js.map +1 -1
- package/lib/dist/mcp/server-instructions.d.ts +1 -1
- package/lib/dist/mcp/server-instructions.d.ts.map +1 -1
- package/lib/dist/mcp/server-instructions.js +4 -0
- package/lib/dist/mcp/server-instructions.js.map +1 -1
- package/lib/dist/mcp/spec-tools.d.ts.map +1 -1
- package/lib/dist/mcp/spec-tools.js +7 -1
- package/lib/dist/mcp/spec-tools.js.map +1 -1
- package/lib/dist/mcp/tools.d.ts +3 -0
- package/lib/dist/mcp/tools.d.ts.map +1 -1
- package/lib/dist/mcp/tools.js +50 -0
- package/lib/dist/mcp/tools.js.map +1 -1
- package/lib/dist/resolution/spec-link-resolver.d.ts +2 -0
- package/lib/dist/resolution/spec-link-resolver.d.ts.map +1 -1
- package/lib/dist/resolution/spec-link-resolver.js +11 -3
- package/lib/dist/resolution/spec-link-resolver.js.map +1 -1
- package/lib/node_modules/.package-lock.json +1 -1
- package/lib/package.json +1 -1
- 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
|