@ivorycanvas/qamap 0.3.4 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/CHANGELOG.md +43 -1
  2. package/README.md +19 -14
  3. package/dist/behavior-intent.d.ts +6 -0
  4. package/dist/behavior-intent.js +172 -0
  5. package/dist/behavior-intent.js.map +1 -0
  6. package/dist/behavior-manifest.d.ts +6 -0
  7. package/dist/behavior-manifest.js +221 -0
  8. package/dist/behavior-manifest.js.map +1 -0
  9. package/dist/behavior.d.ts +143 -0
  10. package/dist/behavior.js +458 -0
  11. package/dist/behavior.js.map +1 -0
  12. package/dist/change-intent.d.ts +71 -0
  13. package/dist/change-intent.js +744 -0
  14. package/dist/change-intent.js.map +1 -0
  15. package/dist/cli.js +6 -5
  16. package/dist/cli.js.map +1 -1
  17. package/dist/e2e.d.ts +16 -1
  18. package/dist/e2e.js +329 -24
  19. package/dist/e2e.js.map +1 -1
  20. package/dist/index.d.ts +9 -1
  21. package/dist/index.js +4 -0
  22. package/dist/index.js.map +1 -1
  23. package/dist/manifest.js +138 -4
  24. package/dist/manifest.js.map +1 -1
  25. package/dist/qa.d.ts +1 -0
  26. package/dist/qa.js +73 -1
  27. package/dist/qa.js.map +1 -1
  28. package/dist/terminal.js +1 -1
  29. package/dist/terminal.js.map +1 -1
  30. package/dist/version.d.ts +1 -1
  31. package/dist/version.js +1 -1
  32. package/docs/adoption.md +11 -10
  33. package/docs/agent-format.md +9 -7
  34. package/docs/agent-skill.md +5 -2
  35. package/docs/architecture.md +131 -0
  36. package/docs/benchmarking.md +26 -4
  37. package/docs/commands.md +13 -7
  38. package/docs/e2e-output-examples.md +23 -9
  39. package/docs/manifest.md +4 -0
  40. package/docs/quickstart-demo.md +9 -2
  41. package/docs/release-validation.md +50 -37
  42. package/docs/releasing.md +13 -0
  43. package/docs/roadmap.md +20 -10
  44. package/package.json +4 -2
  45. package/schema/qamap-agent.schema.json +42 -1
  46. package/schema/qamap-behavior.schema.json +307 -0
  47. package/skills/qamap-pr-qa/SKILL.md +16 -11
@@ -0,0 +1,744 @@
1
+ import { createHash } from "node:crypto";
2
+ import { execFile } from "node:child_process";
3
+ import path from "node:path";
4
+ import { promisify } from "node:util";
5
+ const execFileAsync = promisify(execFile);
6
+ const behavioralCommitTypes = new Set(["feat", "feature", "fix", "hotfix", "perf"]);
7
+ const supportingCommitTypes = new Set(["refactor"]);
8
+ const ignoredCommitTypes = new Set(["build", "chore", "ci", "docs", "release", "style", "test"]);
9
+ const maxCommits = 50;
10
+ const maxIntentFiles = 20;
11
+ const maxLifecycleStages = 12;
12
+ const maxSignals = 40;
13
+ const stopWords = new Set([
14
+ "a",
15
+ "an",
16
+ "and",
17
+ "app",
18
+ "behavior",
19
+ "change",
20
+ "create",
21
+ "export",
22
+ "for",
23
+ "from",
24
+ "implement",
25
+ "improve",
26
+ "in",
27
+ "into",
28
+ "its",
29
+ "of",
30
+ "on",
31
+ "page",
32
+ "screen",
33
+ "service",
34
+ "support",
35
+ "the",
36
+ "to",
37
+ "update",
38
+ "user",
39
+ "using",
40
+ "with",
41
+ ]);
42
+ const ignoredCallNames = new Set([
43
+ "async",
44
+ "catch",
45
+ "describe",
46
+ "expect",
47
+ "filter",
48
+ "forEach",
49
+ "if",
50
+ "it",
51
+ "map",
52
+ "reduce",
53
+ "return",
54
+ "switch",
55
+ "test",
56
+ "while",
57
+ ]);
58
+ export async function analyzeChangeIntents(rootInput, options) {
59
+ const root = path.resolve(rootInput);
60
+ const workspaceRoot = options.workspaceRoot ? path.resolve(options.workspaceRoot) : undefined;
61
+ const gitRoot = workspaceRoot ?? root;
62
+ const relativeRoot = workspaceRoot ? toPosixPath(path.relative(workspaceRoot, root)) : "";
63
+ if (workspaceRoot && (relativeRoot.startsWith("..") || path.isAbsolute(relativeRoot))) {
64
+ throw new Error(`Change intent path must be inside workspace root: ${root}`);
65
+ }
66
+ const diagnostics = [];
67
+ const commits = await collectCommitEvidence(gitRoot, options.base, options.head, relativeRoot, diagnostics);
68
+ const parsedCommits = commits.map(parseCommit);
69
+ const codeSignals = collectCodeBehaviorSignals(options.addedDiffText ?? {});
70
+ const changedFiles = options.changedFiles.map((file) => file.path);
71
+ const commitClusters = clusterBehaviorCommits(parsedCommits);
72
+ const intents = commitClusters.map((cluster, index) => buildCommitIntent(cluster, index, commitClusters.length, changedFiles, options.addedDiffText ?? {}, codeSignals));
73
+ if (intents.length === 0 && (options.includeWorkingTree ?? false)) {
74
+ const diffIntent = buildDiffOnlyIntent(changedFiles, codeSignals);
75
+ if (diffIntent) {
76
+ intents.push(diffIntent);
77
+ }
78
+ }
79
+ if (intents.length === 0) {
80
+ diagnostics.push(commits.length === 0
81
+ ? "No behavior-bearing commit or sufficiently connected working-tree signals were found."
82
+ : "Commit evidence was available, but it did not contain a behavior-bearing feat, fix, hotfix, or performance intent.");
83
+ }
84
+ return {
85
+ base: options.base,
86
+ head: options.head,
87
+ source: changeIntentSource(intents, commits, codeSignals),
88
+ commits,
89
+ intents,
90
+ diagnostics: uniqueStrings(diagnostics),
91
+ };
92
+ }
93
+ async function collectCommitEvidence(root, base, head, relativeRoot, diagnostics) {
94
+ const args = [
95
+ "log",
96
+ "--reverse",
97
+ "--no-merges",
98
+ `--max-count=${maxCommits}`,
99
+ "--format=%H%x1f%s%x1f%b%x1e",
100
+ `${base}..${head}`,
101
+ ];
102
+ if (relativeRoot) {
103
+ args.push("--", relativeRoot);
104
+ }
105
+ try {
106
+ const { stdout } = await execFileAsync("git", args, { cwd: root, maxBuffer: 4 * 1024 * 1024 });
107
+ return stdout
108
+ .split("\u001e")
109
+ .map((record) => record.trim())
110
+ .filter(Boolean)
111
+ .map(parseCommitRecord)
112
+ .filter((commit) => !/^merge\b/i.test(commit.subject));
113
+ }
114
+ catch (error) {
115
+ const message = error instanceof Error ? error.message : String(error);
116
+ diagnostics.push(`Could not read commit intent evidence: ${message}`);
117
+ return [];
118
+ }
119
+ }
120
+ function parseCommitRecord(record) {
121
+ const [sha = "", subject = "", body = ""] = record.split("\u001f");
122
+ return {
123
+ sha: sha.trim(),
124
+ subject: subject.trim(),
125
+ body: body.trim() || undefined,
126
+ statement: subject.trim(),
127
+ };
128
+ }
129
+ function parseCommit(commit) {
130
+ const match = commit.subject.match(/^([a-z][a-z0-9-]*)(?:\(([^)]+)\))?!?:\s*(.+)$/i);
131
+ const conventionalType = match?.[1]?.toLowerCase();
132
+ const scope = match?.[2]?.trim();
133
+ const statement = (match?.[3] ?? commit.subject).trim();
134
+ const actionSignals = lifecycleKeywordCount(`${statement} ${commit.body ?? ""}`);
135
+ const seed = conventionalType
136
+ ? behavioralCommitTypes.has(conventionalType)
137
+ : actionSignals >= 2 && !isLowSignalCommitStatement(statement);
138
+ const supporting = conventionalType
139
+ ? supportingCommitTypes.has(conventionalType)
140
+ : actionSignals >= 1 && !isLowSignalCommitStatement(statement);
141
+ return {
142
+ ...commit,
143
+ conventionalType,
144
+ scope,
145
+ statement,
146
+ seed,
147
+ supporting: supporting && !seed,
148
+ keywords: extractKeywords(`${scope ?? ""} ${statement} ${commit.body ?? ""}`),
149
+ };
150
+ }
151
+ function clusterBehaviorCommits(commits) {
152
+ const candidates = commits.filter((commit) => {
153
+ if (commit.conventionalType && ignoredCommitTypes.has(commit.conventionalType)) {
154
+ return false;
155
+ }
156
+ return commit.seed || commit.supporting;
157
+ });
158
+ const seedIndexes = candidates
159
+ .map((commit, index) => (commit.seed ? index : -1))
160
+ .filter((index) => index >= 0);
161
+ if (seedIndexes.length === 0) {
162
+ return [];
163
+ }
164
+ const parent = candidates.map((_, index) => index);
165
+ const find = (index) => {
166
+ if (parent[index] !== index) {
167
+ parent[index] = find(parent[index]);
168
+ }
169
+ return parent[index];
170
+ };
171
+ const join = (left, right) => {
172
+ const leftRoot = find(left);
173
+ const rightRoot = find(right);
174
+ if (leftRoot !== rightRoot) {
175
+ parent[rightRoot] = leftRoot;
176
+ }
177
+ };
178
+ for (let left = 0; left < candidates.length; left += 1) {
179
+ for (let right = left + 1; right < candidates.length; right += 1) {
180
+ if (commitsShareIntent(candidates[left], candidates[right])) {
181
+ join(left, right);
182
+ }
183
+ }
184
+ }
185
+ const components = new Map();
186
+ candidates.forEach((commit, index) => {
187
+ const root = find(index);
188
+ const group = components.get(root) ?? [];
189
+ group.push(commit);
190
+ components.set(root, group);
191
+ });
192
+ return [...components.values()]
193
+ .filter((group) => group.some((commit) => commit.seed))
194
+ .sort((left, right) => commits.indexOf(left[0]) - commits.indexOf(right[0]));
195
+ }
196
+ function commitsShareIntent(left, right) {
197
+ if (left.scope && right.scope && normalizeToken(left.scope) === normalizeToken(right.scope)) {
198
+ return true;
199
+ }
200
+ const rightKeywords = new Set(right.keywords);
201
+ return left.keywords.some((keyword) => rightKeywords.has(keyword) && keyword.length >= 4);
202
+ }
203
+ function buildCommitIntent(commits, index, clusterCount, changedFiles, addedDiffText, codeSignals) {
204
+ const keywords = uniqueStrings(commits.flatMap((commit) => commit.keywords));
205
+ const files = selectIntentFiles(keywords, changedFiles, addedDiffText, clusterCount);
206
+ const relevantSignals = codeSignals.filter((signal) => files.includes(signal.file));
207
+ const lifecycle = buildLifecycle(commits, relevantSignals);
208
+ const confidence = confidenceForIntent(commits, lifecycle, relevantSignals);
209
+ const title = sentenceTitle(commits.find((commit) => commit.seed)?.statement ?? commits[0].statement);
210
+ const evidence = uniqueEvidence([
211
+ ...commits.map((commit) => ({
212
+ kind: "commit",
213
+ value: commit.subject,
214
+ commit: commit.sha,
215
+ })),
216
+ ...relevantSignals.slice(0, 12).map((signal) => ({
217
+ kind: "diff",
218
+ value: signal.label,
219
+ file: signal.file,
220
+ symbol: signal.symbol,
221
+ })),
222
+ ]);
223
+ const id = stableId("intent", `${index}:${commits.map((commit) => commit.sha).join(":")}:${title}`);
224
+ const summary = commits
225
+ .map((commit) => stripTerminalPunctuation(commit.statement))
226
+ .filter(Boolean)
227
+ .slice(0, 4)
228
+ .join("; ");
229
+ const scenarios = buildIntentQaScenarios(id, title, lifecycle, keywords, evidence);
230
+ return {
231
+ id,
232
+ title,
233
+ summary,
234
+ confidence,
235
+ commits: commits.map(stripParsedCommitFields),
236
+ files,
237
+ keywords,
238
+ evidence,
239
+ lifecycle,
240
+ scenarios,
241
+ reviewRequired: confidence !== "high" || lifecycle.some((stage) => stage.confidence === "low"),
242
+ };
243
+ }
244
+ function buildDiffOnlyIntent(changedFiles, codeSignals) {
245
+ const lifecycle = lifecycleFromCodeSignals(codeSignals);
246
+ const stageKinds = new Set(lifecycle.map((stage) => stage.kind));
247
+ if (lifecycle.length < 3 || stageKinds.size < 3) {
248
+ return undefined;
249
+ }
250
+ const files = uniqueStrings(codeSignals.map((signal) => signal.file)).slice(0, maxIntentFiles);
251
+ const titleSubject = humanizeIdentifier(path.basename(files[0] ?? "working tree change").replace(/\.[^.]+$/, ""));
252
+ const title = `${titleSubject} working-tree behavior`;
253
+ const evidence = uniqueEvidence(codeSignals.slice(0, 12).map((signal) => ({
254
+ kind: "diff",
255
+ value: signal.label,
256
+ file: signal.file,
257
+ symbol: signal.symbol,
258
+ })));
259
+ const id = stableId("intent", `working-tree:${files.join(":")}`);
260
+ const keywords = extractKeywords(codeSignals.map((signal) => `${signal.symbol} ${signal.label}`).join(" "));
261
+ return {
262
+ id,
263
+ title,
264
+ summary: "Inferred only from connected working-tree behavior signals; no commit intent was available.",
265
+ confidence: "low",
266
+ commits: [],
267
+ files: files.length > 0 ? files : changedFiles.slice(0, maxIntentFiles),
268
+ keywords,
269
+ evidence,
270
+ lifecycle,
271
+ scenarios: buildIntentQaScenarios(id, title, lifecycle, keywords, evidence),
272
+ reviewRequired: true,
273
+ };
274
+ }
275
+ function selectIntentFiles(keywords, changedFiles, addedDiffText, clusterCount) {
276
+ const behaviorFiles = changedFiles.filter(isBehaviorBearingFile);
277
+ if (clusterCount === 1) {
278
+ return behaviorFiles.slice(0, maxIntentFiles);
279
+ }
280
+ const matched = behaviorFiles.filter((file) => {
281
+ const searchable = `${file} ${addedDiffText[file] ?? ""}`.toLowerCase();
282
+ return keywords.some((keyword) => searchable.includes(keyword));
283
+ });
284
+ return (matched.length > 0 ? matched : behaviorFiles).slice(0, maxIntentFiles);
285
+ }
286
+ function buildLifecycle(commits, signals) {
287
+ const stages = [];
288
+ for (const commit of commits) {
289
+ const evidence = [{ kind: "commit", value: commit.subject, commit: commit.sha }];
290
+ for (const trigger of extractTriggerPhrases(commit.statement)) {
291
+ stages.push(createLifecycleStage("trigger", trigger, commit.seed ? "high" : "medium", evidence, []));
292
+ }
293
+ for (const clause of splitIntentClauses(commit.statement)) {
294
+ const label = sentenceLabel(clause);
295
+ if (isImplementationOnlyLifecycleStep(label)) {
296
+ continue;
297
+ }
298
+ stages.push(createLifecycleStage(classifyLifecycleClause(clause), label, commit.seed ? "high" : "medium", evidence, []));
299
+ }
300
+ }
301
+ const existingKinds = new Set(stages.map((stage) => stage.kind));
302
+ for (const signal of signals) {
303
+ if (stages.length >= maxLifecycleStages) {
304
+ break;
305
+ }
306
+ if (isImplementationOnlyLifecycleStep(`${signal.label} ${signal.symbol}`)) {
307
+ continue;
308
+ }
309
+ const alreadyRepresented = stages.some((stage) => stage.label.toLowerCase().includes(signal.symbol.toLowerCase()) ||
310
+ (existingKinds.has(signal.kind) && normalizedWords(stage.label).some((word) => normalizedWords(signal.label).includes(word))));
311
+ if (alreadyRepresented) {
312
+ continue;
313
+ }
314
+ stages.push(createLifecycleStage(signal.kind, signal.label, "medium", [{
315
+ kind: "diff",
316
+ value: signal.label,
317
+ file: signal.file,
318
+ symbol: signal.symbol,
319
+ }], [signal.file]));
320
+ existingKinds.add(signal.kind);
321
+ }
322
+ return orderLifecycleStages(uniqueLifecycleStages(stages)).slice(0, maxLifecycleStages);
323
+ }
324
+ function lifecycleFromCodeSignals(signals) {
325
+ const stages = signals
326
+ .filter((signal) => !isImplementationOnlyLifecycleStep(`${signal.label} ${signal.symbol}`))
327
+ .map((signal) => createLifecycleStage(signal.kind, signal.label, "low", [{
328
+ kind: "diff",
329
+ value: signal.label,
330
+ file: signal.file,
331
+ symbol: signal.symbol,
332
+ }], [signal.file]));
333
+ return orderLifecycleStages(uniqueLifecycleStages(stages)).slice(0, maxLifecycleStages);
334
+ }
335
+ function createLifecycleStage(kind, label, confidence, evidence, files) {
336
+ const normalizedLabel = sentenceLabel(label);
337
+ return {
338
+ id: stableId("stage", `${kind}:${normalizedLabel}:${evidence.map((item) => item.commit ?? item.file ?? item.value).join(":")}`),
339
+ kind,
340
+ label: normalizedLabel,
341
+ confidence,
342
+ evidence: uniqueEvidence(evidence),
343
+ files: uniqueStrings(files),
344
+ };
345
+ }
346
+ function buildIntentQaScenarios(intentId, title, lifecycle, keywords, evidence) {
347
+ const conditions = lifecycle.filter((stage) => stage.kind === "condition").map((stage) => stage.label);
348
+ const actions = selectPrimaryLifecycleSteps(lifecycle);
349
+ const outcomes = lifecycle.filter((stage) => stage.kind === "observable-outcome").map((stage) => assertionForStage(stage));
350
+ const sideEffects = lifecycle.filter((stage) => stage.kind === "side-effect").map((stage) => assertionForStage(stage));
351
+ const primary = {
352
+ id: stableId("scenario", `${intentId}:primary`),
353
+ kind: "primary",
354
+ priority: "critical",
355
+ title,
356
+ rationale: "Commit and diff evidence describe this changed behavior lifecycle; verify the complete observable path before merge.",
357
+ setup: conditions.length > 0 ? conditions : ["Prepare representative pre-change and changed-branch state."],
358
+ steps: actions.length > 0 ? actions : lifecycle.map((stage) => stage.label),
359
+ assertions: outcomes.length > 0 ? outcomes : sideEffects.slice(0, 2),
360
+ edgeCases: [],
361
+ evidence: evidence.slice(0, 8),
362
+ };
363
+ if (primary.assertions.length === 0) {
364
+ primary.assertions.push("Verify the externally observable result matches the commit intent.");
365
+ }
366
+ const scenarios = [primary];
367
+ const searchable = `${title} ${keywords.join(" ")} ${lifecycle.map((stage) => stage.label).join(" ")}`.toLowerCase();
368
+ if (/schedul|reminder|notification|calendar|date|time|daily|tomorrow/.test(searchable)) {
369
+ scenarios.push(makeScenario(intentId, "calendar-boundary", "boundary", "critical", "Scheduling, calendar, and duplicate boundary", [
370
+ "Prepare records near day, month, and timezone boundaries.",
371
+ ], [
372
+ "Repeat the changed scheduling action after its source time or date changes.",
373
+ "Repeat the action without changing source data to expose duplicate side effects.",
374
+ ], [
375
+ "Verify the calculated date and time remain correct across boundaries.",
376
+ "Verify stale or duplicate schedules are replaced, preserved, or rejected intentionally.",
377
+ ], ["Timezone change", "Day rollover", "Duplicate invocation"], evidence));
378
+ }
379
+ if (/toggle|enable|disable|permission|authoriz|auth|guard/.test(searchable)) {
380
+ scenarios.push(makeScenario(intentId, "guard-state", "state-transition", "critical", "Disabled, denied, and re-enabled state", [
381
+ "Prepare allowed, disabled, and denied states for the changed condition.",
382
+ ], [
383
+ "Run the behavior while the condition is disabled or denied.",
384
+ "Enable or restore the condition and repeat the behavior.",
385
+ ], [
386
+ "Verify no protected side effect occurs while blocked.",
387
+ "Verify re-enabling produces one correct side effect without stale state.",
388
+ ], ["Permission denied", "Feature disabled", "State restored"], evidence));
389
+ }
390
+ if (/tap|open|navigat|redirect|route|deep.?link|payload|destination/.test(searchable)) {
391
+ scenarios.push(makeScenario(intentId, "entry-routing", "failure", "critical", "Entry payload and destination routing", [
392
+ "Prepare valid, missing, and stale entry payloads.",
393
+ ], [
394
+ "Enter through the changed external or internal trigger.",
395
+ "Repeat with missing or invalid destination context.",
396
+ ], [
397
+ "Verify a valid payload opens the matching destination and state.",
398
+ "Verify invalid context fails safely without opening unrelated data.",
399
+ ], ["Missing payload", "Stale identifier", "Repeated entry"], evidence));
400
+ }
401
+ if (/fetch|request|network|endpoint|api|mutation|response|timeout/.test(searchable)) {
402
+ scenarios.push(makeScenario(intentId, "network-failure", "failure", "recommended", "Failure, timeout, and retry handling", [
403
+ "Prepare success, empty, unauthorized, timeout, and server-error responses.",
404
+ ], [
405
+ "Run the changed behavior for each reachable response.",
406
+ "Retry after a transient failure when the product supports retry.",
407
+ ], [
408
+ "Verify each response produces the intended visible or persisted state.",
409
+ "Verify retries do not duplicate requests or side effects.",
410
+ ], ["Unauthorized", "Timeout", "Server error", "Duplicate retry"], evidence));
411
+ }
412
+ if (/sync|persist|storage|cache|reload|re.?entry|save|store/.test(searchable)) {
413
+ scenarios.push(makeScenario(intentId, "state-reentry", "state-transition", "recommended", "Re-entry and stale state recovery", [
414
+ "Prepare current and stale persisted state.",
415
+ ], [
416
+ "Run the changed mutation and leave the affected surface.",
417
+ "Reload or re-enter through the normal entry point.",
418
+ ], [
419
+ "Verify the latest state survives or is invalidated intentionally.",
420
+ "Verify stale state cannot overwrite the changed result.",
421
+ ], ["Stale cache", "App restart", "Repeated synchronization"], evidence));
422
+ }
423
+ return uniqueScenarios(scenarios).slice(0, 4);
424
+ }
425
+ function selectPrimaryLifecycleSteps(lifecycle) {
426
+ const limits = {
427
+ trigger: 1,
428
+ action: 1,
429
+ "state-change": 2,
430
+ "side-effect": 2,
431
+ };
432
+ const counts = new Map();
433
+ const steps = [];
434
+ for (const stage of lifecycle) {
435
+ const limit = limits[stage.kind] ?? 0;
436
+ const count = counts.get(stage.kind) ?? 0;
437
+ if (limit === 0 || count >= limit || isImplementationOnlyLifecycleStep(stage.label)) {
438
+ continue;
439
+ }
440
+ counts.set(stage.kind, count + 1);
441
+ steps.push(stage.label);
442
+ }
443
+ return steps;
444
+ }
445
+ function isImplementationOnlyLifecycleStep(label) {
446
+ const implementationNoun = "(?:helpers?|interfaces?|lookups?|modules?|types?|utilities)";
447
+ return new RegExp(`^(?:add|extract|move|refactor|rename)\\b.*\\b${implementationNoun}\\b`, "i").test(label) ||
448
+ new RegExp(`^(?:an?|the)\\b.*\\b${implementationNoun}\\.?$`, "i").test(label);
449
+ }
450
+ function makeScenario(intentId, key, kind, priority, title, setup, steps, assertions, edgeCases, evidence) {
451
+ return {
452
+ id: stableId("scenario", `${intentId}:${key}`),
453
+ kind,
454
+ priority,
455
+ title,
456
+ rationale: "Deterministic lifecycle patterns indicate this failure or boundary axis is easy to miss in review.",
457
+ setup,
458
+ steps,
459
+ assertions,
460
+ edgeCases,
461
+ evidence: evidence.slice(0, 6),
462
+ };
463
+ }
464
+ function collectCodeBehaviorSignals(addedDiffText) {
465
+ const signals = [];
466
+ for (const [file, text] of Object.entries(addedDiffText)) {
467
+ if (!isBehaviorBearingFile(file)) {
468
+ continue;
469
+ }
470
+ for (const match of text.matchAll(/\b(on[A-Z][A-Za-z0-9_]*)\b/g)) {
471
+ const symbol = match[1];
472
+ signals.push({ kind: "trigger", label: `Handle ${humanizeIdentifier(symbol)}.`, file, symbol });
473
+ }
474
+ for (const match of text.matchAll(/\b([A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)?)\s*\(/g)) {
475
+ const symbol = match[1];
476
+ const leaf = symbol.split(".").at(-1) ?? symbol;
477
+ if (ignoredCallNames.has(leaf) || leaf.length < 3) {
478
+ continue;
479
+ }
480
+ const kind = lifecycleKindForIdentifier(symbol);
481
+ if (!kind) {
482
+ continue;
483
+ }
484
+ signals.push({ kind, label: codeSignalLabel(kind, symbol), file, symbol });
485
+ }
486
+ }
487
+ return uniqueCodeSignals(signals).slice(0, maxSignals);
488
+ }
489
+ function lifecycleKindForIdentifier(identifier) {
490
+ const value = identifier.toLowerCase();
491
+ if (/^(?:on|handle)(?:press|click|submit|change|complete|open|response|message|select|toggle)/.test(value)) {
492
+ return "trigger";
493
+ }
494
+ if (/(?:navigate|redirect|router\.(?:push|replace)|openurl|openlink|show|display|preview|render)/.test(value)) {
495
+ return "observable-outcome";
496
+ }
497
+ if (/(?:schedule|notify|notification|request|fetch|mutate|post|send|emit|track|publish|upload|download)/.test(value)) {
498
+ return "side-effect";
499
+ }
500
+ if (/(?:resync|sync|persist|store|save|update|set[A-Z_]|cache|write|delete|remove|cancel|invalidate)/i.test(identifier)) {
501
+ return "state-change";
502
+ }
503
+ if (/(?:permission|authorized|authenticated|enabled|disabled|validate|guard|can[A-Z_]|should[A-Z_])/i.test(identifier)) {
504
+ return "condition";
505
+ }
506
+ return undefined;
507
+ }
508
+ function codeSignalLabel(kind, symbol) {
509
+ if (kind === "trigger") {
510
+ return `Trigger ${humanizeIdentifier(symbol)}.`;
511
+ }
512
+ if (kind === "condition") {
513
+ return `Check ${humanizeIdentifier(symbol)}.`;
514
+ }
515
+ if (kind === "state-change") {
516
+ return `Update state through ${symbol}.`;
517
+ }
518
+ if (kind === "side-effect") {
519
+ return `Invoke ${symbol}.`;
520
+ }
521
+ if (kind === "observable-outcome") {
522
+ return `Observe the result of ${symbol}.`;
523
+ }
524
+ return `Run ${symbol}.`;
525
+ }
526
+ function extractTriggerPhrases(statement) {
527
+ const triggers = [];
528
+ for (const match of statement.matchAll(/\b(after|when|once|upon|before)\s+([^,;.]+)/gi)) {
529
+ const phrase = `${match[1]} ${match[2]}`.trim().split(/\s+/).slice(0, 10).join(" ");
530
+ triggers.push(sentenceLabel(phrase));
531
+ }
532
+ const adjectiveTrigger = statement.match(/\b(?:the\s+)?(tapped|clicked|submitted|completed|received)\s+([a-z0-9-]+)\b/i);
533
+ if (adjectiveTrigger) {
534
+ triggers.push(sentenceLabel(`When the ${adjectiveTrigger[2]} is ${adjectiveTrigger[1]}`));
535
+ }
536
+ else {
537
+ const passiveTrigger = statement.match(/\b(?:the\s+)?([a-z0-9-]+)\s+is\s+(tapped|clicked|submitted|completed|received)\b/i);
538
+ if (passiveTrigger) {
539
+ triggers.push(sentenceLabel(`When the ${passiveTrigger[1]} is ${passiveTrigger[2]}`));
540
+ }
541
+ }
542
+ return uniqueStrings(triggers);
543
+ }
544
+ function splitIntentClauses(statement) {
545
+ const stripped = stripTerminalPunctuation(statement.trim());
546
+ const clauses = stripped
547
+ .split(/\s+(?:and then|then|and)\s+/i)
548
+ .map((clause) => clause.trim())
549
+ .filter((clause) => clause.length >= 4);
550
+ return clauses.length > 0 ? clauses : [stripped];
551
+ }
552
+ function classifyLifecycleClause(clause) {
553
+ const value = clause.toLowerCase();
554
+ if (/^(?:show|display|render|preview|tease|open|navigate|redirect|surface|return)\b/.test(value)) {
555
+ return "observable-outcome";
556
+ }
557
+ if (/^(?:save|persist|store|update|sync|resync|cache|set|cancel|remove|delete|invalidate|toggle)\b/.test(value)) {
558
+ return "state-change";
559
+ }
560
+ if (/^(?:schedule|fire|send|notify|request|fetch|post|emit|track|publish|export|upload)\b/.test(value)) {
561
+ return "side-effect";
562
+ }
563
+ if (/\b(?:if|only|enabled|disabled|permission|authorized|authenticated|valid|guard)\b/.test(value)) {
564
+ return "condition";
565
+ }
566
+ if (/^(?:tap|click|submit|complete|receive|start|select|press)\b/.test(value)) {
567
+ return "trigger";
568
+ }
569
+ if (/\b(?:show|display|render|preview|tease|open|navigate|redirect|surface|return)\b/.test(value)) {
570
+ return "observable-outcome";
571
+ }
572
+ if (/\b(?:save|persist|store|update|sync|resync|cache|set|cancel|remove|delete|invalidate|toggle)\b/.test(value)) {
573
+ return "state-change";
574
+ }
575
+ if (/\b(?:schedule|fire|send|notify|request|fetch|post|emit|track|publish|export|upload)\b/.test(value)) {
576
+ return "side-effect";
577
+ }
578
+ return "action";
579
+ }
580
+ function confidenceForIntent(commits, lifecycle, signals) {
581
+ const seedCount = commits.filter((commit) => commit.seed).length;
582
+ const phaseCount = new Set(lifecycle.map((stage) => stage.kind)).size;
583
+ if (phaseCount >= 3 &&
584
+ (seedCount >= 2 || (seedCount === 1 && phaseCount >= 4 && signals.length >= 2))) {
585
+ return "high";
586
+ }
587
+ if (seedCount >= 1 && lifecycle.length >= 2) {
588
+ return "medium";
589
+ }
590
+ return "low";
591
+ }
592
+ function lifecycleKeywordCount(value) {
593
+ const matches = value.match(/\b(?:cancel|click|complete|display|emit|enable|fetch|fire|navigate|notify|open|persist|preview|record|redirect|request|resync|save|schedule|send|show|submit|sync|tap|toggle|track|update)\w*/gi);
594
+ return new Set((matches ?? []).map((match) => normalizeToken(match))).size;
595
+ }
596
+ function isLowSignalCommitStatement(statement) {
597
+ return /^(?:benchmark|prepare|release|version|dependency|format|lint|cleanup|metadata)\b/i.test(statement.trim());
598
+ }
599
+ function extractKeywords(value) {
600
+ const words = normalizedWords(value)
601
+ .map(normalizeToken)
602
+ .filter((word) => word.length >= 3 && !stopWords.has(word));
603
+ return uniqueStrings(words).slice(0, 24);
604
+ }
605
+ function normalizedWords(value) {
606
+ return value
607
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
608
+ .toLowerCase()
609
+ .split(/[^a-z0-9가-힣]+/)
610
+ .filter(Boolean);
611
+ }
612
+ function normalizeToken(value) {
613
+ let token = value.toLowerCase().replace(/[^a-z0-9가-힣]/g, "");
614
+ if (/^schedul/.test(token))
615
+ return "schedule";
616
+ if (/^(?:notify|notification)/.test(token))
617
+ return "notification";
618
+ if (/^(?:resync|sync)/.test(token))
619
+ return "sync";
620
+ if (/^(?:navigate|navigation|redirect|route)/.test(token))
621
+ return "navigation";
622
+ if (/^(?:remind|reminder)/.test(token))
623
+ return "reminder";
624
+ if (/^(?:persist|storage|store)/.test(token))
625
+ return "persistence";
626
+ if (token.endsWith("ies") && token.length > 5)
627
+ token = `${token.slice(0, -3)}y`;
628
+ else if (token.endsWith("ing") && token.length > 6)
629
+ token = token.slice(0, -3);
630
+ else if (token.endsWith("ed") && token.length > 5)
631
+ token = token.slice(0, -2);
632
+ else if (token.endsWith("s") && !token.endsWith("ss") && token.length > 4)
633
+ token = token.slice(0, -1);
634
+ return token;
635
+ }
636
+ function isBehaviorBearingFile(file) {
637
+ return !(/(?:^|\/)(?:docs?|test|tests|__tests__|fixtures?|snapshots?|coverage|dist|build)\//i.test(file) ||
638
+ /(?:^|\/)(?:package-lock\.json|pnpm-lock\.yaml|yarn\.lock|CHANGELOG\.md|README\.md)$/i.test(file) ||
639
+ /\.(?:md|mdx|snap|map)$/i.test(file));
640
+ }
641
+ function stripParsedCommitFields(commit) {
642
+ const { seed: _seed, supporting: _supporting, keywords: _keywords, ...result } = commit;
643
+ return result;
644
+ }
645
+ function orderLifecycleStages(stages) {
646
+ const rank = {
647
+ trigger: 0,
648
+ condition: 1,
649
+ action: 2,
650
+ "state-change": 3,
651
+ "side-effect": 4,
652
+ "observable-outcome": 5,
653
+ };
654
+ return stages
655
+ .map((stage, index) => ({ stage, index }))
656
+ .sort((left, right) => rank[left.stage.kind] - rank[right.stage.kind] || left.index - right.index)
657
+ .map(({ stage }) => stage);
658
+ }
659
+ function uniqueLifecycleStages(stages) {
660
+ const seen = new Set();
661
+ return stages.filter((stage) => {
662
+ const key = `${stage.kind}:${stripTerminalPunctuation(stage.label).toLowerCase()}`;
663
+ if (seen.has(key))
664
+ return false;
665
+ seen.add(key);
666
+ return true;
667
+ });
668
+ }
669
+ function uniqueCodeSignals(signals) {
670
+ const seen = new Set();
671
+ return signals.filter((signal) => {
672
+ const key = `${signal.kind}:${signal.file}:${signal.symbol}`;
673
+ if (seen.has(key))
674
+ return false;
675
+ seen.add(key);
676
+ return true;
677
+ });
678
+ }
679
+ function uniqueScenarios(scenarios) {
680
+ const seen = new Set();
681
+ return scenarios.filter((scenario) => {
682
+ const key = scenario.title.toLowerCase();
683
+ if (seen.has(key))
684
+ return false;
685
+ seen.add(key);
686
+ return true;
687
+ });
688
+ }
689
+ function uniqueEvidence(evidence) {
690
+ const seen = new Set();
691
+ return evidence.filter((item) => {
692
+ const key = `${item.kind}:${item.commit ?? ""}:${item.file ?? ""}:${item.symbol ?? ""}:${item.value}`;
693
+ if (seen.has(key))
694
+ return false;
695
+ seen.add(key);
696
+ return true;
697
+ });
698
+ }
699
+ function uniqueStrings(values) {
700
+ return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
701
+ }
702
+ function sentenceTitle(value) {
703
+ const stripped = stripTerminalPunctuation(value.trim());
704
+ if (!stripped)
705
+ return "Changed behavior";
706
+ return stripped[0].toUpperCase() + stripped.slice(1);
707
+ }
708
+ function sentenceLabel(value) {
709
+ const title = sentenceTitle(value);
710
+ return /[.!?]$/.test(title) ? title : `${title}.`;
711
+ }
712
+ function stripTerminalPunctuation(value) {
713
+ return value.replace(/[.!?]+$/, "").trim();
714
+ }
715
+ function assertionForStage(stage) {
716
+ return `Verify ${lowercaseFirst(stripTerminalPunctuation(stage.label))}.`;
717
+ }
718
+ function lowercaseFirst(value) {
719
+ return value ? value[0].toLowerCase() + value.slice(1) : value;
720
+ }
721
+ function humanizeIdentifier(value) {
722
+ return value
723
+ .replaceAll(".", " ")
724
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
725
+ .replace(/[_-]+/g, " ")
726
+ .trim()
727
+ .toLowerCase();
728
+ }
729
+ function stableId(prefix, value) {
730
+ return `${prefix}:${createHash("sha256").update(value).digest("hex").slice(0, 12)}`;
731
+ }
732
+ function toPosixPath(value) {
733
+ return value.split(path.sep).join("/");
734
+ }
735
+ function changeIntentSource(intents, commits, signals) {
736
+ if (intents.length === 0)
737
+ return "none";
738
+ if (commits.length > 0 && signals.length > 0)
739
+ return "commits-and-diff";
740
+ if (commits.length > 0)
741
+ return "commits";
742
+ return "diff-only";
743
+ }
744
+ //# sourceMappingURL=change-intent.js.map