ticketlens 0.33.0 → 0.35.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/package.json
CHANGED
|
@@ -7,7 +7,7 @@ import { analyzeDiff } from './diff-analyzer.mjs';
|
|
|
7
7
|
import { DEFAULT_CONFIG_DIR } from './config.mjs';
|
|
8
8
|
import { appendLedger } from './ledger.mjs';
|
|
9
9
|
|
|
10
|
-
const STATUS_ICON = { FOUND: '✔', PARTIAL: '~', NOT_FOUND: '✖' };
|
|
10
|
+
export const STATUS_ICON = { FOUND: '✔', PARTIAL: '~', NOT_FOUND: '✖' };
|
|
11
11
|
|
|
12
12
|
function formatReport({ ticketKey, requirements, analysis, usage, isPro }) {
|
|
13
13
|
const { results, coveragePercent } = analysis;
|
|
@@ -92,5 +92,5 @@ export async function runComplianceCheck({
|
|
|
92
92
|
);
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
-
return { report, coveragePercent, noCriteria: requirements.length === 0 };
|
|
95
|
+
return { report, results: analysis.results, coveragePercent, noCriteria: requirements.length === 0 };
|
|
96
96
|
}
|
|
@@ -8,7 +8,7 @@ import { spawnSync } from 'node:child_process';
|
|
|
8
8
|
import { fetchTicket } from './jira-client.mjs';
|
|
9
9
|
import { extractRequirements } from './requirement-extractor.mjs';
|
|
10
10
|
import { findLinkedCommits } from './commit-linker.mjs';
|
|
11
|
-
import { runComplianceCheck } from './compliance-checker.mjs';
|
|
11
|
+
import { runComplianceCheck, STATUS_ICON } from './compliance-checker.mjs';
|
|
12
12
|
import { DEFAULT_CONFIG_DIR } from './config.mjs';
|
|
13
13
|
|
|
14
14
|
/**
|
|
@@ -63,26 +63,13 @@ function buildCoverageSection(complianceResult, requirements) {
|
|
|
63
63
|
return lines.join('\n');
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
-
const { coveragePercent,
|
|
66
|
+
const { coveragePercent, results } = complianceResult;
|
|
67
67
|
const lines = [``, `### Requirements coverage (${coveragePercent}%)`];
|
|
68
68
|
|
|
69
|
-
for (const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
} else {
|
|
74
|
-
lines.push(`- ✖ ${entry.req}`);
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
// Include any missing reqs not already in report
|
|
79
|
-
if (complianceResult.missing && complianceResult.missing.length > 0) {
|
|
80
|
-
const reportedReqs = new Set(report.map(r => r.req));
|
|
81
|
-
for (const missed of complianceResult.missing) {
|
|
82
|
-
if (!reportedReqs.has(missed)) {
|
|
83
|
-
lines.push(`- ✖ ${missed}`);
|
|
84
|
-
}
|
|
85
|
-
}
|
|
69
|
+
for (const { requirement, status, evidence } of results) {
|
|
70
|
+
const icon = STATUS_ICON[status] ?? '?';
|
|
71
|
+
const suffix = evidence ? ` (${evidence})` : '';
|
|
72
|
+
lines.push(`- ${icon} ${requirement}${suffix}`);
|
|
86
73
|
}
|
|
87
74
|
|
|
88
75
|
return lines.join('\n');
|
|
@@ -1,25 +1,101 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Extracts acceptance criteria / requirements from Jira ticket text.
|
|
3
3
|
* Recognises: Given/When/Then, must/should/shall/ensure/verify bullets,
|
|
4
|
-
* Acceptance Criteria sections, and numbered imperative items
|
|
4
|
+
* Acceptance Criteria sections, and numbered imperative items — both
|
|
5
|
+
* newline-separated ("1. ..." / "1) ...") and inline enumerations flattened
|
|
6
|
+
* into one paragraph ("... 1) foo 2) bar 3) baz"), as produced by Jira ADF.
|
|
5
7
|
*/
|
|
6
8
|
|
|
7
|
-
const RE_GWT
|
|
8
|
-
const RE_MUST_ITEM
|
|
9
|
-
const RE_NUM_MUST
|
|
10
|
-
const RE_AC_HEADER
|
|
11
|
-
const RE_HEADING
|
|
12
|
-
const RE_BULLET
|
|
13
|
-
const RE_NUM_ITEM
|
|
9
|
+
const RE_GWT = /^\s*(given|when|then)\s+(.+)/i;
|
|
10
|
+
const RE_MUST_ITEM = /^\s*[-*•]\s+((?:must|should|shall|ensure|verify)\b.+|.+(?:must|should|shall|ensure|verify).+)/i;
|
|
11
|
+
const RE_NUM_MUST = /^\s*\d+[.)]\s+((?:must|should|shall|ensure|verify)\b.+|.+(?:must|should|shall|ensure|verify).+)/i;
|
|
12
|
+
const RE_AC_HEADER = /^\s*(?:#+\s*|h[1-6]\.\s*)?acceptance criteria\s*:?\s*$/i;
|
|
13
|
+
const RE_HEADING = /^\s*(?:#+|h[1-6]\.)\s+/i;
|
|
14
|
+
const RE_BULLET = /^\s*[-*•]\s+(.+)/;
|
|
15
|
+
const RE_NUM_ITEM = /^\s*\d+[.)]\s+(.+)/;
|
|
16
|
+
const RE_NUM_MARKER = /^\s*(\d+)[.)]/;
|
|
17
|
+
// A numbered marker preceded by start-of-line or whitespace/punctuation — excludes
|
|
18
|
+
// things like "v1." or "2.1" where the digit is glued to a letter or another digit.
|
|
19
|
+
// Known limitation: doesn't catch mid-sentence prose like "item 1. Then item 2." —
|
|
20
|
+
// narrowing further risks new false negatives on genuine short list items.
|
|
21
|
+
const RE_INLINE_MARKER = /(?:^|(?<=[\s:;,]))(\d+)[.)]\s+/g;
|
|
22
|
+
// Headings that introduce a numbered list which is NOT acceptance criteria (e.g. bug
|
|
23
|
+
// repro steps) — a plain numbered list under one of these must never be extracted.
|
|
24
|
+
const RE_NON_AC_HEADER = /^\s*(?:#+\s*|h[1-6]\.\s*)?(?:steps to reproduce|repro(?:duction)? steps|how to reproduce)\s*:?\s*$/i;
|
|
25
|
+
|
|
26
|
+
// True if numbers is a run starting at 1 with no gaps or repeats (1,2,3,...).
|
|
27
|
+
// Shared by findValidNumberedRuns and splitInlineNumbered as the one signal both
|
|
28
|
+
// use to tell a genuine enumeration apart from stray numbers elsewhere in prose.
|
|
29
|
+
function isSequentialFrom1(numbers) {
|
|
30
|
+
return numbers[0] === 1 && numbers.every((n, i) => i === 0 || n === numbers[i - 1] + 1);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Finds line indices that form a genuine numbered enumeration outside any AC/non-AC
|
|
34
|
+
// section: 2+ consecutive numbered lines, sequential starting at 1. A single numbered
|
|
35
|
+
// line, or numbers that skip/repeat, are too weak a signal on their own (they're just
|
|
36
|
+
// as likely to be a "Steps to Reproduce" list, a version note, or an unrelated aside).
|
|
37
|
+
// Known limitation: a blank line between items breaks the run (items must be on
|
|
38
|
+
// strictly consecutive lines), so a list formatted with blank lines between items
|
|
39
|
+
// and no AC header is not recognised — narrower than the AC-header path, which does
|
|
40
|
+
// tolerate that (see the "plain sentences" fallback below).
|
|
41
|
+
function findValidNumberedRuns(lines) {
|
|
42
|
+
const valid = new Set();
|
|
43
|
+
let inAc = false;
|
|
44
|
+
let inNonAc = false;
|
|
45
|
+
let run = [];
|
|
46
|
+
|
|
47
|
+
const flushRun = () => {
|
|
48
|
+
if (run.length >= 2 && isSequentialFrom1(run.map(r => r.num))) {
|
|
49
|
+
for (const r of run) valid.add(r.index);
|
|
50
|
+
}
|
|
51
|
+
run = [];
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
lines.forEach((line, index) => {
|
|
55
|
+
if (RE_AC_HEADER.test(line)) { inAc = true; inNonAc = false; flushRun(); return; }
|
|
56
|
+
if (RE_NON_AC_HEADER.test(line)) { inNonAc = true; inAc = false; flushRun(); return; }
|
|
57
|
+
|
|
58
|
+
if ((inAc || inNonAc) && RE_HEADING.test(line)) { inAc = false; inNonAc = false; }
|
|
59
|
+
|
|
60
|
+
if (inAc || inNonAc) { flushRun(); return; }
|
|
61
|
+
|
|
62
|
+
const marker = RE_NUM_MARKER.exec(line);
|
|
63
|
+
if (marker) { run.push({ index, num: Number(marker[1]) }); } else { flushRun(); }
|
|
64
|
+
});
|
|
65
|
+
flushRun();
|
|
66
|
+
|
|
67
|
+
return valid;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Splits a line on sequential inline numbered markers ("1) ... 2) ... 3) ...").
|
|
71
|
+
// Requires 2+ markers starting at 1 and incrementing by 1, so stray numbers
|
|
72
|
+
// elsewhere in prose (versions, dates, prices) don't get misread as a list.
|
|
73
|
+
function splitInlineNumbered(line) {
|
|
74
|
+
const markers = [...line.matchAll(RE_INLINE_MARKER)];
|
|
75
|
+
if (markers.length < 2) return [];
|
|
76
|
+
|
|
77
|
+
const numbers = markers.map(m => Number(m[1]));
|
|
78
|
+
if (!isSequentialFrom1(numbers)) return [];
|
|
79
|
+
|
|
80
|
+
const items = [];
|
|
81
|
+
for (let i = 0; i < markers.length; i++) {
|
|
82
|
+
const start = markers[i].index + markers[i][0].length;
|
|
83
|
+
const end = i + 1 < markers.length ? markers[i + 1].index : line.length;
|
|
84
|
+
const item = line.slice(start, end).trim();
|
|
85
|
+
if (item) items.push(item);
|
|
86
|
+
}
|
|
87
|
+
return items;
|
|
88
|
+
}
|
|
14
89
|
|
|
15
90
|
export function extractRequirements(text) {
|
|
16
91
|
if (!text) return [];
|
|
17
92
|
|
|
18
93
|
const lines = text.split('\n');
|
|
94
|
+
const validNumberedLines = findValidNumberedRuns(lines);
|
|
19
95
|
const results = [];
|
|
20
96
|
let inAcSection = false;
|
|
21
97
|
|
|
22
|
-
for (const line of lines) {
|
|
98
|
+
for (const [index, line] of lines.entries()) {
|
|
23
99
|
const gwt = RE_GWT.exec(line);
|
|
24
100
|
if (gwt) { results.push(line.trim()); continue; }
|
|
25
101
|
|
|
@@ -36,6 +112,9 @@ export function extractRequirements(text) {
|
|
|
36
112
|
const numMust = RE_NUM_MUST.exec(line);
|
|
37
113
|
if (numMust) { results.push(numMust[1].trim()); continue; }
|
|
38
114
|
|
|
115
|
+
const inlineItems = splitInlineNumbered(line);
|
|
116
|
+
if (inlineItems.length > 0) { results.push(...inlineItems); continue; }
|
|
117
|
+
|
|
39
118
|
if (inAcSection) {
|
|
40
119
|
const bullet = RE_BULLET.exec(line);
|
|
41
120
|
if (bullet) { results.push(bullet[1].trim()); continue; }
|
|
@@ -45,6 +124,13 @@ export function extractRequirements(text) {
|
|
|
45
124
|
const plain = line.trim();
|
|
46
125
|
if (plain) { results.push(plain); continue; }
|
|
47
126
|
}
|
|
127
|
+
|
|
128
|
+
// Plain numbered enumeration outside an explicit AC section, no modal verb required —
|
|
129
|
+
// only for a validated run (2+ sequential items, see findValidNumberedRuns)
|
|
130
|
+
if (!inAcSection && validNumberedLines.has(index)) {
|
|
131
|
+
const numItem = RE_NUM_ITEM.exec(line);
|
|
132
|
+
if (numItem) { results.push(numItem[1].trim()); continue; }
|
|
133
|
+
}
|
|
48
134
|
}
|
|
49
135
|
|
|
50
136
|
return [...new Set(results)];
|
|
@@ -21,6 +21,7 @@ import { detectProjectOrTypeError, enrichCreateFailure } from './ticket-create-e
|
|
|
21
21
|
import { TICKET_KEY_PATTERN } from './cli.mjs';
|
|
22
22
|
import { scoreCandidates } from './duplicate-scorer.mjs';
|
|
23
23
|
import { MAX_ATTACHMENTS } from './attachment-uploader.mjs';
|
|
24
|
+
import { createStyler } from './ansi.mjs';
|
|
24
25
|
|
|
25
26
|
function parseFlag(cmdArgs, name) {
|
|
26
27
|
return cmdArgs.find(a => a.startsWith(`--${name}=`))?.slice(name.length + 3);
|
|
@@ -123,6 +124,18 @@ function formatDuplicatesFailure(ticketKey, err) {
|
|
|
123
124
|
return formatReadFailure(ticketKey, err, 'for duplicates');
|
|
124
125
|
}
|
|
125
126
|
|
|
127
|
+
/**
|
|
128
|
+
* Match-confidence color tier for a duplicates result: ≥70% reads as a strong
|
|
129
|
+
* signal, 50-69% as worth a look, below that as a weak, low-confidence nudge.
|
|
130
|
+
* Exported standalone so the boundary values are pinned by a direct test
|
|
131
|
+
* rather than reverse-engineered from real Jaccard scores in a fixture.
|
|
132
|
+
*/
|
|
133
|
+
export function matchColor(pct, s) {
|
|
134
|
+
if (pct >= 70) return s.green;
|
|
135
|
+
if (pct >= 50) return s.yellow;
|
|
136
|
+
return s.dim;
|
|
137
|
+
}
|
|
138
|
+
|
|
126
139
|
function formatLinkListFailure(ticketKey, err) {
|
|
127
140
|
return formatReadFailure(ticketKey, err, 'for link options');
|
|
128
141
|
}
|
|
@@ -478,13 +491,18 @@ export async function runTicketDuplicates(cmdArgs, {
|
|
|
478
491
|
const scoreOpts = threshold !== undefined ? { threshold } : {};
|
|
479
492
|
const results = scoreCandidates({ key: ticketKey, summary: source.summary, description: source.description }, candidates, scoreOpts);
|
|
480
493
|
|
|
494
|
+
const s = createStyler({ isTTY: stream.isTTY });
|
|
495
|
+
stream.write(` ${s.brand(s.bold(ticketKey))}: ${s.bold(source.summary ?? '')}\n\n`);
|
|
496
|
+
|
|
481
497
|
if (results.length === 0) {
|
|
482
|
-
stream.write(` No likely duplicates found
|
|
498
|
+
stream.write(` No likely duplicates found.\n`);
|
|
483
499
|
return { ok: true, results: [] };
|
|
484
500
|
}
|
|
485
|
-
stream.write(` Possible duplicates
|
|
501
|
+
stream.write(` Possible duplicates:\n\n`);
|
|
486
502
|
for (const r of results) {
|
|
487
|
-
|
|
503
|
+
const pct = Math.round(r.score * 100);
|
|
504
|
+
const pctColor = matchColor(pct, s);
|
|
505
|
+
stream.write(` ${s.brand('●')} ${s.bold(r.key)} (${pctColor(`${pct}% match`)}) — ${r.summary}\n`);
|
|
488
506
|
}
|
|
489
507
|
return { ok: true, results };
|
|
490
508
|
} catch (err) {
|