qaa-agent 1.9.1 → 1.9.5
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/CHANGELOG.md +43 -22
- package/CLAUDE.md +170 -9
- package/README.md +384 -357
- package/VERSION +1 -0
- package/agents/qa-pipeline-orchestrator.md +336 -22
- package/agents/qaa-analyzer.md +0 -1
- package/agents/qaa-bug-detective.md +163 -4
- package/agents/qaa-codebase-mapper.md +50 -1
- package/agents/qaa-discovery.md +421 -384
- package/agents/qaa-e2e-runner.md +163 -1
- package/agents/qaa-executor.md +142 -1
- package/agents/qaa-planner.md +14 -1
- package/agents/qaa-project-researcher.md +194 -0
- package/agents/qaa-scanner.md +77 -1
- package/agents/qaa-testid-injector.md +0 -1
- package/agents/qaa-validator.md +86 -1
- package/bin/install.cjs +375 -253
- package/bin/lib/context7-cache.cjs +299 -0
- package/bin/lib/intent-detector.cjs +488 -0
- package/commands/qa-audit.md +255 -126
- package/commands/qa-create-test.md +666 -365
- package/commands/qa-fix.md +684 -513
- package/commands/qa-map.md +283 -139
- package/commands/qa-pr.md +63 -0
- package/commands/qa-research.md +181 -157
- package/commands/qa-start.md +62 -6
- package/commands/qa-test-report.md +219 -219
- package/frameworks/cypress.json +54 -0
- package/frameworks/jest.json +59 -0
- package/frameworks/playwright.json +58 -0
- package/frameworks/pytest.json +59 -0
- package/frameworks/robot-framework.json +54 -0
- package/frameworks/selenium.json +65 -0
- package/frameworks/vitest.json +57 -0
- package/package.json +7 -3
- package/workflows/qa-analyze.md +100 -4
- package/workflows/qa-from-ticket.md +50 -2
- package/workflows/qa-gap.md +50 -2
- package/workflows/qa-start.md +819 -33
- package/workflows/qa-testid.md +50 -2
- package/workflows/qa-validate.md +50 -2
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Intent Detector — Natural Language argument parser for QAA commands.
|
|
3
|
+
*
|
|
4
|
+
* Detects URLs, paths (with spaces, quoted, Windows/Unix), framework hints,
|
|
5
|
+
* tickets vs AUT URLs, code-repo URLs (GitHub/GitLab/Bitbucket), and parses
|
|
6
|
+
* --flag values robustly. Also infers semantic roles (dev-repo vs qa-repo,
|
|
7
|
+
* app-url) from NL context markers.
|
|
8
|
+
*
|
|
9
|
+
* Used by /qa-start, /qa-create-test, /qa-fix, /qa-audit, /qa-pr to support
|
|
10
|
+
* natural-language input alongside explicit flags. Flags always take
|
|
11
|
+
* precedence; NL detection only fills gaps.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
// ── Patterns ──────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
const URL_PATTERN = /https?:\/\/[^\s'"<>`]+/gi;
|
|
19
|
+
const WIN_PATH_PATTERN = /[A-Z]:[\\\/](?:[^\n\r"<>|*?]+)/g;
|
|
20
|
+
const UNIX_PATH_PATTERN = /(?:^|(?<=[\s=]))(\/[a-zA-Z0-9_][a-zA-Z0-9._\-]*(?:\/[a-zA-Z0-9._\-]+)+\/?)/g;
|
|
21
|
+
const QUOTED_PATTERN = /"([^"]+)"|'([^']+)'/g;
|
|
22
|
+
|
|
23
|
+
const FRAMEWORK_PATTERNS = [
|
|
24
|
+
['robot-framework', /\b(?:robot[\s-]*framework|robotframework)\b/i],
|
|
25
|
+
['playwright', /\bplaywright\b/i],
|
|
26
|
+
['cypress', /\bcypress\b/i],
|
|
27
|
+
['vitest', /\bvitest\b/i],
|
|
28
|
+
['pytest', /\bpytest\b/i],
|
|
29
|
+
['selenium', /\bselenium\b/i],
|
|
30
|
+
['jest', /\bjest\b/i],
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
const TICKET_PATTERNS = [
|
|
34
|
+
/github\.com\/[\w.-]+\/[\w.-]+\/(?:issues|pull)\//i,
|
|
35
|
+
/atlassian\.net\/browse\//i,
|
|
36
|
+
/linear\.app\//i,
|
|
37
|
+
/dev\.azure\.com|visualstudio\.com/i,
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
// Code repo URLs (NOT AUT). Checked AFTER tickets, so issue/PR URLs match tickets first.
|
|
41
|
+
const CODE_REPO_PATTERNS = [
|
|
42
|
+
/^https?:\/\/github\.com\/[\w.-]+\/[\w.-]+\/?($|[?#])/i,
|
|
43
|
+
/^https?:\/\/github\.com\/[\w.-]+\/[\w.-]+\/(?:tree|blob|raw|releases|tags|commits?|wiki)/i,
|
|
44
|
+
/^https?:\/\/gitlab\.com\/[\w.-]+\/[\w.-]+\/?($|[?#])/i,
|
|
45
|
+
/^https?:\/\/bitbucket\.org\/[\w.-]+\/[\w.-]+\/?($|[?#])/i,
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
// Semantic markers — phrases that precede a URL/path and tag its role.
|
|
49
|
+
const SEMANTIC_MARKERS = [
|
|
50
|
+
{ regex: /(?:save|leave|put|store|write|drop)\s+(?:every\s+|all\s+|the\s+)?(?:outputs?|results?|files?|everything)\s+(?:on|to|in|at|here|into)\b/i, role: 'qa_repo' },
|
|
51
|
+
{ regex: /(?:outputs?|results?)\s+(?:save|location|path|dir|directory|folder|here)\b/i, role: 'qa_repo' },
|
|
52
|
+
{ regex: /\bqa\s+repo(?:sitory)?\b/i, role: 'qa_repo' },
|
|
53
|
+
{ regex: /\b(?:test|qa)\s+(?:folder|directory|dir)\b/i, role: 'qa_repo' },
|
|
54
|
+
{ regex: /\bdev(?:eloper)?\s+repo(?:sitory)?\b/i, role: 'dev_repo' },
|
|
55
|
+
{ regex: /\b(?:source\s+code|source\s+repo|code\s+repo)\b/i, role: 'dev_repo' },
|
|
56
|
+
{ regex: /\b(?:the\s+)?project\s+(?:is\s+)?(?:at|in|located\s+at)\b/i, role: 'dev_repo' },
|
|
57
|
+
{ regex: /\b(?:live\s+(?:app|application|site)|production\s+(?:app|url|site)|running\s+(?:app|application))\b/i, role: 'app_url' },
|
|
58
|
+
{ regex: /\b(?:test|run)\s+against\b/i, role: 'app_url' },
|
|
59
|
+
{ regex: /\b(?:app(?:lication)?|deployed|hosted)\s+(?:url\s+)?(?:is|at)\b/i, role: 'app_url' },
|
|
60
|
+
{ regex: /\b(?:has\s+a\s+)?page\s+too\b/i, role: 'app_url' },
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
const COMMON_TRAILING_WORDS = new Set([
|
|
64
|
+
'and','or','with','the','a','an','then','to','on','in','at',
|
|
65
|
+
'for','of','but','so','if','as','by','is','are','was','were',
|
|
66
|
+
'has','have','had','be','been','use','using','used',
|
|
67
|
+
'when','where','while','before','after','because','although',
|
|
68
|
+
'this','that','these','those','my','your','their','our',
|
|
69
|
+
'i','you','he','she','we','they','them','us','me',
|
|
70
|
+
'create','make','made','add','added','run','go','do','get','set',
|
|
71
|
+
'save','configure',
|
|
72
|
+
'playwright','cypress','jest','vitest','pytest','selenium',
|
|
73
|
+
'framework','robot','robotframework',
|
|
74
|
+
'project','projects','app','apps','page','pages','test','tests','act',
|
|
75
|
+
]);
|
|
76
|
+
|
|
77
|
+
function trimTrailingEnglishWords(path) {
|
|
78
|
+
let result = path;
|
|
79
|
+
while (true) {
|
|
80
|
+
const lastSpace = result.lastIndexOf(' ');
|
|
81
|
+
if (lastSpace === -1) break;
|
|
82
|
+
const lastWord = result.slice(lastSpace + 1).toLowerCase().replace(/[.,;:!?]+$/, '');
|
|
83
|
+
if (COMMON_TRAILING_WORDS.has(lastWord)) {
|
|
84
|
+
result = result.slice(0, lastSpace).trimEnd();
|
|
85
|
+
} else { break; }
|
|
86
|
+
}
|
|
87
|
+
return result;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function stripTrailingPunctuation(s) {
|
|
91
|
+
return s.replace(/[.,;:!?)\]"'`]+$/, '').trimEnd();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function detectUrls(text) {
|
|
95
|
+
const matches = [...text.matchAll(URL_PATTERN)];
|
|
96
|
+
return matches.map(m => stripTrailingPunctuation(m[0]));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function classifyUrl(url) {
|
|
100
|
+
for (const pat of TICKET_PATTERNS) { if (pat.test(url)) return 'ticket'; }
|
|
101
|
+
for (const pat of CODE_REPO_PATTERNS) { if (pat.test(url)) return 'code-repo'; }
|
|
102
|
+
return 'aut';
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Sentence-starting words commonly used in NL prose (preceded by a space,
|
|
106
|
+
// followed by a space). When found inside a captured path, they indicate
|
|
107
|
+
// the path ended and a new sentence began. Matched against the literal
|
|
108
|
+
// space-preceded position to avoid false positives like \This\folder.
|
|
109
|
+
const SENTENCE_START_WORDS = /\s+(?:This|That|These|Those|It|There|When|Where|While|Now|Then|After|Before|Leave|Save|Put|Store|Use|Make|Create|Run|Go|Get|Set|Add|And|But|If|Else|For|With|Once|Also|Plus|Next|Finally|Please)\s+/;
|
|
110
|
+
|
|
111
|
+
function cutAtSentenceBoundary(path) {
|
|
112
|
+
if (!path) return path;
|
|
113
|
+
// Rule A: ". <space> <Capital>" → end of one sentence, start of another
|
|
114
|
+
const mA = path.match(/^(.*?)\.\s+[A-Z]/);
|
|
115
|
+
if (mA) return mA[1];
|
|
116
|
+
// Rule B: " <SentenceStartWord> " → prose sentence injected after a path
|
|
117
|
+
// (e.g. ".../foo This one is..." — no period, but clearly a new sentence)
|
|
118
|
+
const mB = path.match(SENTENCE_START_WORDS);
|
|
119
|
+
if (mB) {
|
|
120
|
+
const idx = path.indexOf(mB[0]);
|
|
121
|
+
return path.slice(0, idx);
|
|
122
|
+
}
|
|
123
|
+
return path;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function looksLikePath(s) {
|
|
127
|
+
if (!s) return false;
|
|
128
|
+
if (/^https?:/i.test(s)) return false;
|
|
129
|
+
if (/^[A-Z]:[\\\/]/.test(s)) return true;
|
|
130
|
+
if (/^\/[a-zA-Z0-9_]/.test(s) && s.includes('/')) return true;
|
|
131
|
+
if (/^\.\.?[\\\/]/.test(s)) return true;
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function detectPaths(text) {
|
|
136
|
+
const paths = new Set();
|
|
137
|
+
const textNoUrls = text.replace(URL_PATTERN, ' ');
|
|
138
|
+
|
|
139
|
+
const quotedMatches = [...textNoUrls.matchAll(QUOTED_PATTERN)];
|
|
140
|
+
for (const m of quotedMatches) {
|
|
141
|
+
const candidate = m[1] || m[2];
|
|
142
|
+
if (looksLikePath(candidate)) paths.add(candidate);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const winMatches = [...textNoUrls.matchAll(WIN_PATH_PATTERN)];
|
|
146
|
+
for (const m of winMatches) {
|
|
147
|
+
// Split the greedy match at every "whitespace + drive-letter:" boundary
|
|
148
|
+
// so multiple Windows paths in one match get extracted independently.
|
|
149
|
+
const segments = m[0].split(/(?<=\s)(?=[A-Z]:[\\\/])/);
|
|
150
|
+
for (let p of segments) {
|
|
151
|
+
p = cutAtSentenceBoundary(p);
|
|
152
|
+
p = stripTrailingPunctuation(p);
|
|
153
|
+
p = trimTrailingEnglishWords(p);
|
|
154
|
+
p = stripTrailingPunctuation(p);
|
|
155
|
+
if (looksLikePath(p)) paths.add(p);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const unixMatches = [...textNoUrls.matchAll(UNIX_PATH_PATTERN)];
|
|
160
|
+
for (const m of unixMatches) {
|
|
161
|
+
let p = m[1];
|
|
162
|
+
p = cutAtSentenceBoundary(p);
|
|
163
|
+
p = stripTrailingPunctuation(p);
|
|
164
|
+
if (looksLikePath(p)) paths.add(p);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return [...paths];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function detectFramework(text) {
|
|
171
|
+
for (const [name, pat] of FRAMEWORK_PATTERNS) {
|
|
172
|
+
if (pat.test(text)) return name;
|
|
173
|
+
}
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Ticket references in FREE TEXT (not URLs): "ticket #PROJ-123", "issue 42",
|
|
178
|
+
// "#v195-fixes". Keyword-anchored matches are most reliable; a standalone
|
|
179
|
+
// "#id" is accepted only when it contains a digit (avoids matching plain words
|
|
180
|
+
// like "#fixes"). Returns the bare id (leading "#" stripped) for branch naming.
|
|
181
|
+
function detectTicketRefs(text) {
|
|
182
|
+
const refs = [];
|
|
183
|
+
const kw = text.match(/\b(?:ticket|issue|story|bug)\s+#?([A-Za-z0-9][\w.\-]*)/i);
|
|
184
|
+
if (kw) refs.push(kw[1]);
|
|
185
|
+
for (const m of text.matchAll(/(?:^|\s)#([A-Za-z0-9][\w.\-]*)/g)) {
|
|
186
|
+
if (/\d/.test(m[1]) && !refs.includes(m[1])) refs.push(m[1]);
|
|
187
|
+
}
|
|
188
|
+
return refs;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Target/base branch from NL: "to the main branch", "against develop",
|
|
192
|
+
// "base release/1.2", "branch staging". Anchored on the word "branch" or on
|
|
193
|
+
// explicit base/target keywords so generic prose ("test against <url>") does
|
|
194
|
+
// not false-trigger. Returns the branch name.
|
|
195
|
+
function detectBaseBranch(text) {
|
|
196
|
+
// Strip URLs first so "test against https://..." never yields a branch like "https".
|
|
197
|
+
text = text.replace(URL_PATTERN, ' ');
|
|
198
|
+
let m = text.match(/\b(?:to|into|onto|against|targeting|target)\s+(?:the\s+)?([A-Za-z0-9][\w.\/\-]*)\s+branch\b/i);
|
|
199
|
+
if (m) return m[1];
|
|
200
|
+
m = text.match(/\b(?:base|target)\s+(?:branch\s+)?["']?([A-Za-z0-9][\w.\/\-]*)["']?/i);
|
|
201
|
+
if (m && m[1].toLowerCase() !== 'branch') return m[1];
|
|
202
|
+
m = text.match(/\bbranch\s+["']?([A-Za-z0-9][\w.\/\-]*)["']?/i);
|
|
203
|
+
if (m && m[1].toLowerCase() !== 'branch') return m[1];
|
|
204
|
+
// Bare "against/onto/into <branch>" (no "branch" keyword). Guarded against
|
|
205
|
+
// URLs/paths so "test against <url>" / "merge into /some/path" don't match.
|
|
206
|
+
m = text.match(/\b(?:against|onto|into|merge\s+(?:in)?to)\s+(?:the\s+)?["']?([A-Za-z0-9][\w.\/\-]*)["']?/i);
|
|
207
|
+
if (m && !/^https?:/i.test(m[1]) && !looksLikePath(m[1]) && m[1].toLowerCase() !== 'the') return m[1];
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Explicit PR/test title from NL — ONLY when there is clear naming intent:
|
|
212
|
+
// "titled X" / "called X" / "named X", or "title: X" / "title = X" / "title is X".
|
|
213
|
+
// The bare noun "title" in prose (e.g. "the path title (Foundations…") is NOT a
|
|
214
|
+
// title and must not match. Free-form descriptive titles are intentionally not
|
|
215
|
+
// inferred — the command agent's fallback handles those and pauses for confirmation.
|
|
216
|
+
function detectTitle(text) {
|
|
217
|
+
// Capture stops at punctuation, end-of-string, or a branch preposition
|
|
218
|
+
// ("onto/into/against/base/target/branch") so "named X onto develop" → "X".
|
|
219
|
+
const END = '(?:\\s*[,.]|\\s+(?:onto|into|against|targeting|target|base|branch)\\b|$)';
|
|
220
|
+
let m = text.match(new RegExp('\\b(?:titled|called|named)\\s+["\']?([^"\'\\n,.]{2,60}?)["\']?' + END, 'i'));
|
|
221
|
+
if (m) return m[1].trim();
|
|
222
|
+
m = text.match(new RegExp('\\btitle\\s*(?::|=|\\bis\\b)\\s*["\']?([^"\'\\n,.]{2,60}?)["\']?' + END, 'i'));
|
|
223
|
+
if (m) return m[1].trim();
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function detectRoleHints(text, urls, paths) {
|
|
228
|
+
const hints = { urls: {}, paths: {} };
|
|
229
|
+
const WINDOW = 80;
|
|
230
|
+
|
|
231
|
+
const findContextRole = (needle) => {
|
|
232
|
+
const idx = text.indexOf(needle);
|
|
233
|
+
if (idx === -1) return null;
|
|
234
|
+
const before = text.slice(Math.max(0, idx - WINDOW), idx);
|
|
235
|
+
let matchedRole = null;
|
|
236
|
+
let matchedDistance = Infinity;
|
|
237
|
+
for (const { regex, role } of SEMANTIC_MARKERS) {
|
|
238
|
+
const m = before.match(regex);
|
|
239
|
+
if (m) {
|
|
240
|
+
const lastIdx = before.lastIndexOf(m[0]);
|
|
241
|
+
const distance = before.length - lastIdx;
|
|
242
|
+
if (distance < matchedDistance) {
|
|
243
|
+
matchedDistance = distance;
|
|
244
|
+
matchedRole = role;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return matchedRole;
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
for (const url of urls) {
|
|
252
|
+
const r = findContextRole(url);
|
|
253
|
+
if (r) hints.urls[url] = r;
|
|
254
|
+
}
|
|
255
|
+
for (const path of paths) {
|
|
256
|
+
const r = findContextRole(path);
|
|
257
|
+
if (r) hints.paths[path] = r;
|
|
258
|
+
}
|
|
259
|
+
return hints;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function parseFlags(text) {
|
|
263
|
+
const flags = {};
|
|
264
|
+
const re = /--([\w-]+)(?:(?:=|\s+)(?:"([^"]+)"|'([^']+)'|(\S+)))?/g;
|
|
265
|
+
let m;
|
|
266
|
+
while ((m = re.exec(text)) !== null) {
|
|
267
|
+
const name = m[1];
|
|
268
|
+
let value;
|
|
269
|
+
if (m[2] !== undefined) value = m[2];
|
|
270
|
+
else if (m[3] !== undefined) value = m[3];
|
|
271
|
+
else if (m[4] !== undefined && !m[4].startsWith('--')) value = m[4];
|
|
272
|
+
else value = true;
|
|
273
|
+
flags[name] = value;
|
|
274
|
+
}
|
|
275
|
+
return flags;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function detectIntent(args) {
|
|
279
|
+
if (typeof args !== 'string') args = String(args || '');
|
|
280
|
+
|
|
281
|
+
const flags = parseFlags(args);
|
|
282
|
+
const urls = detectUrls(args);
|
|
283
|
+
const paths = detectPaths(args);
|
|
284
|
+
const framework = detectFramework(args);
|
|
285
|
+
|
|
286
|
+
const tickets = urls.filter(u => classifyUrl(u) === 'ticket');
|
|
287
|
+
const code_repo_urls = urls.filter(u => classifyUrl(u) === 'code-repo');
|
|
288
|
+
const aut_urls = urls.filter(u => classifyUrl(u) === 'aut');
|
|
289
|
+
const role_hints = detectRoleHints(args, urls, paths);
|
|
290
|
+
const ticket_refs = detectTicketRefs(args);
|
|
291
|
+
const base_branch = detectBaseBranch(args);
|
|
292
|
+
const title = detectTitle(args);
|
|
293
|
+
|
|
294
|
+
let suggested_mode = null;
|
|
295
|
+
if (flags['ticket'] || tickets.length > 0 || ticket_refs.length > 0) suggested_mode = 'from-ticket';
|
|
296
|
+
else if (flags['app-url'] || aut_urls.length > 0) suggested_mode = 'with_app_url';
|
|
297
|
+
else if (flags['ado'] || /dev\.azure\.com|visualstudio\.com/i.test(args)) suggested_mode = 'ado';
|
|
298
|
+
else if (flags['update']) suggested_mode = 'update';
|
|
299
|
+
else if (flags['pom-only']) suggested_mode = 'pom-only';
|
|
300
|
+
|
|
301
|
+
return { urls, paths, framework, tickets, ticket_refs, base_branch, title, code_repo_urls, aut_urls, role_hints, flags, suggested_mode };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function resolveInputs(args, options) {
|
|
305
|
+
options = options || {};
|
|
306
|
+
const intent = detectIntent(args);
|
|
307
|
+
const aliases = options.flagAliases || {};
|
|
308
|
+
const defaults = options.defaults || {};
|
|
309
|
+
|
|
310
|
+
const pathByRole = {};
|
|
311
|
+
const unhintedPaths = [];
|
|
312
|
+
for (const p of intent.paths) {
|
|
313
|
+
const r = intent.role_hints.paths[p];
|
|
314
|
+
if (r) { (pathByRole[r] = pathByRole[r] || []).push(p); }
|
|
315
|
+
else { unhintedPaths.push(p); }
|
|
316
|
+
}
|
|
317
|
+
const urlByRole = {};
|
|
318
|
+
const unhintedAutUrls = [];
|
|
319
|
+
for (const u of intent.aut_urls) {
|
|
320
|
+
const r = intent.role_hints.urls[u];
|
|
321
|
+
if (r === 'app_url') { (urlByRole.app_url = urlByRole.app_url || []).push(u); }
|
|
322
|
+
else { unhintedAutUrls.push(u); }
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const takePath = (role) => {
|
|
326
|
+
if (pathByRole[role] && pathByRole[role].length > 0) return pathByRole[role].shift();
|
|
327
|
+
if (unhintedPaths.length > 0) return unhintedPaths.shift();
|
|
328
|
+
return null;
|
|
329
|
+
};
|
|
330
|
+
const takeAutUrl = () => {
|
|
331
|
+
if (urlByRole.app_url && urlByRole.app_url.length > 0) return urlByRole.app_url.shift();
|
|
332
|
+
if (unhintedAutUrls.length > 0) return unhintedAutUrls.shift();
|
|
333
|
+
return null;
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
// Pre-pass: any value supplied via an EXPLICIT flag must be consumed from the
|
|
337
|
+
// NL pools, so a flag-provided path/url is never re-assigned to a DIFFERENT
|
|
338
|
+
// field. Without this, `--dev-repo <path>` resolves dev_repo from the flag but
|
|
339
|
+
// leaves <path> in the pool, and a flag-less qa_repo then grabs it — wrongly
|
|
340
|
+
// inferring qa_repo == dev_repo and flipping the pipeline into gap-fill mode.
|
|
341
|
+
for (const flagName of Object.values(aliases)) {
|
|
342
|
+
const fv = intent.flags[flagName];
|
|
343
|
+
if (typeof fv !== 'string') continue;
|
|
344
|
+
let i = unhintedPaths.indexOf(fv);
|
|
345
|
+
if (i !== -1) unhintedPaths.splice(i, 1);
|
|
346
|
+
for (const role of Object.keys(pathByRole)) {
|
|
347
|
+
const r = pathByRole[role].indexOf(fv);
|
|
348
|
+
if (r !== -1) pathByRole[role].splice(r, 1);
|
|
349
|
+
}
|
|
350
|
+
i = unhintedAutUrls.indexOf(fv);
|
|
351
|
+
if (i !== -1) unhintedAutUrls.splice(i, 1);
|
|
352
|
+
if (urlByRole.app_url) {
|
|
353
|
+
const r = urlByRole.app_url.indexOf(fv);
|
|
354
|
+
if (r !== -1) urlByRole.app_url.splice(r, 1);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const resolved = {};
|
|
359
|
+
for (const [field, flagName] of Object.entries(aliases)) {
|
|
360
|
+
if (intent.flags[flagName] !== undefined) {
|
|
361
|
+
resolved[field] = { value: intent.flags[flagName], source: 'flag' };
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
if (field === 'app_url') {
|
|
365
|
+
const v = takeAutUrl();
|
|
366
|
+
if (v) { resolved[field] = { value: v, source: 'NL' }; continue; }
|
|
367
|
+
} else if (field === 'dev_repo') {
|
|
368
|
+
const v = takePath('dev_repo');
|
|
369
|
+
if (v) { resolved[field] = { value: v, source: 'NL' }; continue; }
|
|
370
|
+
} else if (field === 'qa_repo') {
|
|
371
|
+
const v = takePath('qa_repo');
|
|
372
|
+
if (v) { resolved[field] = { value: v, source: 'NL' }; continue; }
|
|
373
|
+
} else if (field === 'framework' && intent.framework !== null) {
|
|
374
|
+
resolved[field] = { value: intent.framework, source: 'NL' };
|
|
375
|
+
continue;
|
|
376
|
+
} else if (field === 'ticket' && (intent.tickets.length > 0 || intent.ticket_refs.length > 0)) {
|
|
377
|
+
resolved[field] = { value: intent.tickets[0] || intent.ticket_refs[0], source: 'NL' };
|
|
378
|
+
continue;
|
|
379
|
+
} else if (field === 'base' && intent.base_branch) {
|
|
380
|
+
resolved[field] = { value: intent.base_branch, source: 'NL' };
|
|
381
|
+
continue;
|
|
382
|
+
} else if (field === 'title' && intent.title) {
|
|
383
|
+
resolved[field] = { value: intent.title, source: 'NL' };
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
if (defaults[field] !== undefined) {
|
|
387
|
+
resolved[field] = { value: defaults[field], source: 'default' };
|
|
388
|
+
} else {
|
|
389
|
+
resolved[field] = { value: null, source: 'default' };
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
for (const [flagName, flagValue] of Object.entries(intent.flags)) {
|
|
394
|
+
const isAliased = Object.values(aliases).includes(flagName);
|
|
395
|
+
if (!isAliased) {
|
|
396
|
+
const internalName = flagName.replace(/-/g, '_');
|
|
397
|
+
resolved[internalName] = { value: flagValue, source: 'flag' };
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
if (intent.code_repo_urls.length > 0) {
|
|
402
|
+
resolved._code_repo_urls = {
|
|
403
|
+
value: intent.code_repo_urls.join(', '),
|
|
404
|
+
source: 'NL',
|
|
405
|
+
warning: 'GitHub/GitLab/Bitbucket URL detected. --dev-repo expects a LOCAL path. Clone the repo first.',
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const banner = formatInputDetection(resolved, options);
|
|
410
|
+
return { resolved, intent, banner };
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function formatInputDetection(resolved, options) {
|
|
414
|
+
const lines = [];
|
|
415
|
+
lines.push('=== INPUT DETECTION ===');
|
|
416
|
+
|
|
417
|
+
const flagLines = [];
|
|
418
|
+
const nlLines = [];
|
|
419
|
+
const defaultLines = [];
|
|
420
|
+
const warningLines = [];
|
|
421
|
+
|
|
422
|
+
for (const [field, info] of Object.entries(resolved)) {
|
|
423
|
+
const { value, source, warning } = info;
|
|
424
|
+
if (field.startsWith('_')) {
|
|
425
|
+
if (warning) {
|
|
426
|
+
warningLines.push(' WARN: ' + warning);
|
|
427
|
+
warningLines.push(' Detected URL(s): ' + value);
|
|
428
|
+
}
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
const flagName = '--' + field.replace(/_/g, '-');
|
|
432
|
+
const display = formatValue(value);
|
|
433
|
+
const line = ' ' + flagName.padEnd(20) + ' ' + display + ' (source: ' + source + ')';
|
|
434
|
+
if (source === 'flag') flagLines.push(line);
|
|
435
|
+
else if (source === 'NL') nlLines.push(line);
|
|
436
|
+
else defaultLines.push(line);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
if (flagLines.length > 0) { lines.push('From explicit flags:'); lines.push(...flagLines); }
|
|
440
|
+
if (nlLines.length > 0) { lines.push('Detected from natural language:'); lines.push(...nlLines); }
|
|
441
|
+
if (defaultLines.length > 0) { lines.push('Defaults applied:'); lines.push(...defaultLines); }
|
|
442
|
+
if (warningLines.length > 0) { lines.push('Warnings:'); lines.push(...warningLines); }
|
|
443
|
+
|
|
444
|
+
if (flagLines.length === 0 && nlLines.length === 0 && defaultLines.length === 0 && warningLines.length === 0) {
|
|
445
|
+
lines.push(' No inputs detected. Pipeline will use cwd / interactive prompts.');
|
|
446
|
+
}
|
|
447
|
+
lines.push('=======================');
|
|
448
|
+
return lines.join('\n');
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function formatValue(value) {
|
|
452
|
+
if (value === null || value === undefined) return '(not set)';
|
|
453
|
+
if (value === true) return 'true';
|
|
454
|
+
if (value === false) return 'false';
|
|
455
|
+
if (typeof value === 'string') return value.includes(' ') ? '"' + value + '"' : value;
|
|
456
|
+
return String(value);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
module.exports = {
|
|
460
|
+
detectIntent, detectUrls, detectPaths, detectFramework, detectRoleHints,
|
|
461
|
+
cutAtSentenceBoundary, parseFlags, classifyUrl, looksLikePath,
|
|
462
|
+
resolveInputs, formatInputDetection,
|
|
463
|
+
};
|
|
464
|
+
|
|
465
|
+
if (require.main === module) {
|
|
466
|
+
const argv = process.argv.slice(2);
|
|
467
|
+
if (argv.length === 0) {
|
|
468
|
+
console.error('Usage:');
|
|
469
|
+
console.error(' node bin/lib/intent-detector.cjs "<args string>"');
|
|
470
|
+
console.error(' node bin/lib/intent-detector.cjs --resolve "<args>" [--aliases <json>]');
|
|
471
|
+
process.exit(1);
|
|
472
|
+
}
|
|
473
|
+
if (argv[0] === '--resolve') {
|
|
474
|
+
const args = argv[1] || '';
|
|
475
|
+
let aliases = {};
|
|
476
|
+
const aliasIdx = argv.indexOf('--aliases');
|
|
477
|
+
if (aliasIdx !== -1 && argv[aliasIdx + 1]) {
|
|
478
|
+
try { aliases = JSON.parse(argv[aliasIdx + 1]); }
|
|
479
|
+
catch (e) { console.error('Invalid --aliases JSON'); process.exit(1); }
|
|
480
|
+
}
|
|
481
|
+
const result = resolveInputs(args, { flagAliases: aliases });
|
|
482
|
+
console.log(JSON.stringify(result, null, 2));
|
|
483
|
+
} else {
|
|
484
|
+
const args = argv.join(' ');
|
|
485
|
+
const result = detectIntent(args);
|
|
486
|
+
console.log(JSON.stringify(result, null, 2));
|
|
487
|
+
}
|
|
488
|
+
}
|