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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ticketlens",
3
- "version": "0.33.0",
3
+ "version": "0.35.0",
4
4
  "description": "Jira CLI for developers — fetch ticket context, triage your queue, and stop tab-switching. Zero dependencies, all local.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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, report } = complianceResult;
66
+ const { coveragePercent, results } = complianceResult;
67
67
  const lines = [``, `### Requirements coverage (${coveragePercent}%)`];
68
68
 
69
- for (const entry of report) {
70
- if (entry.covered) {
71
- const loc = entry.location ? ` (${entry.location})` : '';
72
- lines.push(`- ✔ ${entry.req}${loc}`);
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 = /^\s*(given|when|then)\s+(.+)/i;
8
- const RE_MUST_ITEM = /^\s*[-*•]\s+((?:must|should|shall|ensure|verify)\b.+|.+(?:must|should|shall|ensure|verify).+)/i;
9
- const RE_NUM_MUST = /^\s*\d+\.\s+((?:must|should|shall|ensure|verify)\b.+|.+(?:must|should|shall|ensure|verify).+)/i;
10
- const RE_AC_HEADER = /^\s*(?:#+\s*|h[1-6]\.\s*)?acceptance criteria\s*:?\s*$/i;
11
- const RE_HEADING = /^\s*(?:#+|h[1-6]\.)\s+/i;
12
- const RE_BULLET = /^\s*[-*•]\s+(.+)/;
13
- const RE_NUM_ITEM = /^\s*\d+\.\s+(.+)/;
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 for ${ticketKey}.\n`);
498
+ stream.write(` No likely duplicates found.\n`);
483
499
  return { ok: true, results: [] };
484
500
  }
485
- stream.write(` Possible duplicates of ${ticketKey}:\n`);
501
+ stream.write(` Possible duplicates:\n\n`);
486
502
  for (const r of results) {
487
- stream.write(` ${r.key} (${Math.round(r.score * 100)}% match) — ${r.summary}\n`);
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) {