@ivorycanvas/qamap 0.3.5 → 0.4.1
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 +49 -0
- package/README.md +37 -23
- package/dist/agent-init.js +2 -2
- package/dist/agent-init.js.map +1 -1
- package/dist/behavior-intent.d.ts +6 -0
- package/dist/behavior-intent.js +183 -0
- package/dist/behavior-intent.js.map +1 -0
- package/dist/behavior-manifest.d.ts +6 -0
- package/dist/behavior-manifest.js +221 -0
- package/dist/behavior-manifest.js.map +1 -0
- package/dist/behavior.d.ts +151 -0
- package/dist/behavior.js +458 -0
- package/dist/behavior.js.map +1 -0
- package/dist/change-intent.d.ts +81 -0
- package/dist/change-intent.js +884 -0
- package/dist/change-intent.js.map +1 -0
- package/dist/cli.js +13 -8
- package/dist/cli.js.map +1 -1
- package/dist/e2e.d.ts +16 -1
- package/dist/e2e.js +290 -25
- package/dist/e2e.js.map +1 -1
- package/dist/index.d.ts +11 -3
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/qa.d.ts +1 -0
- package/dist/qa.js +293 -88
- package/dist/qa.js.map +1 -1
- package/dist/terminal.js +1 -1
- package/dist/terminal.js.map +1 -1
- package/dist/test-plan.d.ts +18 -0
- package/dist/test-plan.js +123 -13
- package/dist/test-plan.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/docs/adoption.md +11 -10
- package/docs/agent-format.md +16 -10
- package/docs/agent-skill.md +8 -5
- package/docs/architecture.md +131 -0
- package/docs/benchmarking.md +23 -4
- package/docs/commands.md +13 -7
- package/docs/e2e-output-examples.md +29 -28
- package/docs/quickstart-demo.md +29 -17
- package/docs/release-validation.md +49 -6
- package/docs/releasing.md +26 -3
- package/docs/roadmap.md +20 -10
- package/package.json +4 -2
- package/schema/qamap-agent.schema.json +122 -3
- package/schema/qamap-behavior.schema.json +339 -0
- package/skills/qamap-pr-qa/SKILL.md +20 -14
|
@@ -0,0 +1,884 @@
|
|
|
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 ?? {}, options.addedDiffEvidence ?? {});
|
|
70
|
+
const riskEvidence = collectDiffRiskEvidence(options.addedDiffEvidence ?? {});
|
|
71
|
+
const changedFiles = options.changedFiles.map((file) => file.path);
|
|
72
|
+
const commitClusters = clusterBehaviorCommits(parsedCommits);
|
|
73
|
+
const intents = commitClusters.map((cluster, index) => buildCommitIntent(cluster, index, commitClusters.length, changedFiles, options.addedDiffText ?? {}, codeSignals, riskEvidence));
|
|
74
|
+
if (intents.length === 0 && (options.includeWorkingTree ?? false)) {
|
|
75
|
+
const diffIntent = buildDiffOnlyIntent(changedFiles, codeSignals);
|
|
76
|
+
if (diffIntent) {
|
|
77
|
+
intents.push(diffIntent);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (intents.length === 0) {
|
|
81
|
+
diagnostics.push(commits.length === 0
|
|
82
|
+
? "No behavior-bearing commit or sufficiently connected working-tree signals were found."
|
|
83
|
+
: "Commit evidence was available, but it did not contain a behavior-bearing feat, fix, hotfix, or performance intent.");
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
base: options.base,
|
|
87
|
+
head: options.head,
|
|
88
|
+
source: changeIntentSource(intents, commits, codeSignals),
|
|
89
|
+
commits,
|
|
90
|
+
intents,
|
|
91
|
+
diagnostics: uniqueStrings(diagnostics),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
async function collectCommitEvidence(root, base, head, relativeRoot, diagnostics) {
|
|
95
|
+
const args = [
|
|
96
|
+
"log",
|
|
97
|
+
"--reverse",
|
|
98
|
+
"--no-merges",
|
|
99
|
+
`--max-count=${maxCommits}`,
|
|
100
|
+
"--format=%H%x1f%s%x1f%b%x1e",
|
|
101
|
+
`${base}..${head}`,
|
|
102
|
+
];
|
|
103
|
+
if (relativeRoot) {
|
|
104
|
+
args.push("--", relativeRoot);
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
const { stdout } = await execFileAsync("git", args, { cwd: root, maxBuffer: 4 * 1024 * 1024 });
|
|
108
|
+
return stdout
|
|
109
|
+
.split("\u001e")
|
|
110
|
+
.map((record) => record.trim())
|
|
111
|
+
.filter(Boolean)
|
|
112
|
+
.map(parseCommitRecord)
|
|
113
|
+
.filter((commit) => !/^merge\b/i.test(commit.subject));
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
117
|
+
diagnostics.push(`Could not read commit intent evidence: ${message}`);
|
|
118
|
+
return [];
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
function parseCommitRecord(record) {
|
|
122
|
+
const [sha = "", subject = "", body = ""] = record.split("\u001f");
|
|
123
|
+
return {
|
|
124
|
+
sha: sha.trim(),
|
|
125
|
+
subject: subject.trim(),
|
|
126
|
+
body: body.trim() || undefined,
|
|
127
|
+
statement: subject.trim(),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
function parseCommit(commit) {
|
|
131
|
+
const match = commit.subject.match(/^([a-z][a-z0-9-]*)(?:\(([^)]+)\))?!?:\s*(.+)$/i);
|
|
132
|
+
const conventionalType = match?.[1]?.toLowerCase();
|
|
133
|
+
const scope = match?.[2]?.trim();
|
|
134
|
+
const statement = (match?.[3] ?? commit.subject).trim();
|
|
135
|
+
const actionSignals = lifecycleKeywordCount(`${statement} ${commit.body ?? ""}`);
|
|
136
|
+
const seed = conventionalType
|
|
137
|
+
? behavioralCommitTypes.has(conventionalType)
|
|
138
|
+
: actionSignals >= 2 && !isLowSignalCommitStatement(statement);
|
|
139
|
+
const supporting = conventionalType
|
|
140
|
+
? supportingCommitTypes.has(conventionalType)
|
|
141
|
+
: actionSignals >= 1 && !isLowSignalCommitStatement(statement);
|
|
142
|
+
return {
|
|
143
|
+
...commit,
|
|
144
|
+
conventionalType,
|
|
145
|
+
scope,
|
|
146
|
+
statement,
|
|
147
|
+
seed,
|
|
148
|
+
supporting: supporting && !seed,
|
|
149
|
+
keywords: extractKeywords(`${scope ?? ""} ${statement} ${commit.body ?? ""}`),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
function clusterBehaviorCommits(commits) {
|
|
153
|
+
const candidates = commits.filter((commit) => {
|
|
154
|
+
if (commit.conventionalType && ignoredCommitTypes.has(commit.conventionalType)) {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
return commit.seed || commit.supporting;
|
|
158
|
+
});
|
|
159
|
+
const seedIndexes = candidates
|
|
160
|
+
.map((commit, index) => (commit.seed ? index : -1))
|
|
161
|
+
.filter((index) => index >= 0);
|
|
162
|
+
if (seedIndexes.length === 0) {
|
|
163
|
+
return [];
|
|
164
|
+
}
|
|
165
|
+
const parent = candidates.map((_, index) => index);
|
|
166
|
+
const find = (index) => {
|
|
167
|
+
if (parent[index] !== index) {
|
|
168
|
+
parent[index] = find(parent[index]);
|
|
169
|
+
}
|
|
170
|
+
return parent[index];
|
|
171
|
+
};
|
|
172
|
+
const join = (left, right) => {
|
|
173
|
+
const leftRoot = find(left);
|
|
174
|
+
const rightRoot = find(right);
|
|
175
|
+
if (leftRoot !== rightRoot) {
|
|
176
|
+
parent[rightRoot] = leftRoot;
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
for (let left = 0; left < candidates.length; left += 1) {
|
|
180
|
+
for (let right = left + 1; right < candidates.length; right += 1) {
|
|
181
|
+
if (commitsShareIntent(candidates[left], candidates[right])) {
|
|
182
|
+
join(left, right);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
const components = new Map();
|
|
187
|
+
candidates.forEach((commit, index) => {
|
|
188
|
+
const root = find(index);
|
|
189
|
+
const group = components.get(root) ?? [];
|
|
190
|
+
group.push(commit);
|
|
191
|
+
components.set(root, group);
|
|
192
|
+
});
|
|
193
|
+
return [...components.values()]
|
|
194
|
+
.filter((group) => group.some((commit) => commit.seed))
|
|
195
|
+
.sort((left, right) => commits.indexOf(left[0]) - commits.indexOf(right[0]));
|
|
196
|
+
}
|
|
197
|
+
function commitsShareIntent(left, right) {
|
|
198
|
+
if (left.scope && right.scope && normalizeToken(left.scope) === normalizeToken(right.scope)) {
|
|
199
|
+
return true;
|
|
200
|
+
}
|
|
201
|
+
const rightKeywords = new Set(right.keywords);
|
|
202
|
+
return left.keywords.some((keyword) => rightKeywords.has(keyword) && keyword.length >= 4);
|
|
203
|
+
}
|
|
204
|
+
function buildCommitIntent(commits, index, clusterCount, changedFiles, addedDiffText, codeSignals, riskEvidence) {
|
|
205
|
+
const keywords = uniqueStrings(commits.flatMap((commit) => commit.keywords));
|
|
206
|
+
const files = selectIntentFiles(keywords, changedFiles, addedDiffText, clusterCount);
|
|
207
|
+
const relevantSignals = codeSignals.filter((signal) => files.includes(signal.file));
|
|
208
|
+
const relevantRiskEvidence = riskEvidence.filter((item) => item.file && files.includes(item.file));
|
|
209
|
+
const lifecycle = buildLifecycle(commits, relevantSignals);
|
|
210
|
+
const confidence = confidenceForIntent(commits, lifecycle, relevantSignals);
|
|
211
|
+
const title = sentenceTitle(commits.find((commit) => commit.seed)?.statement ?? commits[0].statement);
|
|
212
|
+
const evidence = uniqueEvidence([
|
|
213
|
+
...commits.map((commit) => ({
|
|
214
|
+
kind: "commit",
|
|
215
|
+
value: commit.subject,
|
|
216
|
+
commit: commit.sha,
|
|
217
|
+
relation: "contextual",
|
|
218
|
+
})),
|
|
219
|
+
...relevantSignals.slice(0, 12).map((signal) => ({
|
|
220
|
+
...signal.evidence,
|
|
221
|
+
})),
|
|
222
|
+
...relevantRiskEvidence.slice(0, 8),
|
|
223
|
+
]);
|
|
224
|
+
const id = stableId("intent", `${index}:${commits.map((commit) => commit.sha).join(":")}:${title}`);
|
|
225
|
+
const summary = commits
|
|
226
|
+
.map((commit) => stripTerminalPunctuation(commit.statement))
|
|
227
|
+
.filter(Boolean)
|
|
228
|
+
.slice(0, 4)
|
|
229
|
+
.join("; ");
|
|
230
|
+
const scenarios = buildIntentQaScenarios(id, title, lifecycle, keywords, evidence, confidence);
|
|
231
|
+
return {
|
|
232
|
+
id,
|
|
233
|
+
title,
|
|
234
|
+
summary,
|
|
235
|
+
confidence,
|
|
236
|
+
commits: commits.map(stripParsedCommitFields),
|
|
237
|
+
files,
|
|
238
|
+
keywords,
|
|
239
|
+
evidence,
|
|
240
|
+
lifecycle,
|
|
241
|
+
scenarios,
|
|
242
|
+
reviewRequired: confidence !== "high" || lifecycle.some((stage) => stage.confidence === "low"),
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
function buildDiffOnlyIntent(changedFiles, codeSignals) {
|
|
246
|
+
const lifecycle = lifecycleFromCodeSignals(codeSignals);
|
|
247
|
+
const stageKinds = new Set(lifecycle.map((stage) => stage.kind));
|
|
248
|
+
if (lifecycle.length < 3 || stageKinds.size < 3) {
|
|
249
|
+
return undefined;
|
|
250
|
+
}
|
|
251
|
+
const files = uniqueStrings(codeSignals.map((signal) => signal.file)).slice(0, maxIntentFiles);
|
|
252
|
+
const titleSubject = humanizeIdentifier(path.basename(files[0] ?? "working tree change").replace(/\.[^.]+$/, ""));
|
|
253
|
+
const title = `${titleSubject} working-tree behavior`;
|
|
254
|
+
const evidence = uniqueEvidence(codeSignals.slice(0, 12).map((signal) => signal.evidence));
|
|
255
|
+
const id = stableId("intent", `working-tree:${files.join(":")}`);
|
|
256
|
+
const keywords = extractKeywords(codeSignals.map((signal) => `${signal.symbol} ${signal.label}`).join(" "));
|
|
257
|
+
return {
|
|
258
|
+
id,
|
|
259
|
+
title,
|
|
260
|
+
summary: "Inferred only from connected working-tree behavior signals; no commit intent was available.",
|
|
261
|
+
confidence: "low",
|
|
262
|
+
commits: [],
|
|
263
|
+
files: files.length > 0 ? files : changedFiles.slice(0, maxIntentFiles),
|
|
264
|
+
keywords,
|
|
265
|
+
evidence,
|
|
266
|
+
lifecycle,
|
|
267
|
+
scenarios: buildIntentQaScenarios(id, title, lifecycle, keywords, evidence, "low"),
|
|
268
|
+
reviewRequired: true,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
function selectIntentFiles(keywords, changedFiles, addedDiffText, clusterCount) {
|
|
272
|
+
const behaviorFiles = changedFiles.filter(isBehaviorBearingFile);
|
|
273
|
+
if (clusterCount === 1) {
|
|
274
|
+
return behaviorFiles.slice(0, maxIntentFiles);
|
|
275
|
+
}
|
|
276
|
+
const matched = behaviorFiles.filter((file) => {
|
|
277
|
+
const searchable = `${file} ${addedDiffText[file] ?? ""}`.toLowerCase();
|
|
278
|
+
return keywords.some((keyword) => searchable.includes(keyword));
|
|
279
|
+
});
|
|
280
|
+
return (matched.length > 0 ? matched : behaviorFiles).slice(0, maxIntentFiles);
|
|
281
|
+
}
|
|
282
|
+
function buildLifecycle(commits, signals) {
|
|
283
|
+
const stages = [];
|
|
284
|
+
for (const commit of commits) {
|
|
285
|
+
const evidence = [{
|
|
286
|
+
kind: "commit",
|
|
287
|
+
value: commit.subject,
|
|
288
|
+
commit: commit.sha,
|
|
289
|
+
relation: "contextual",
|
|
290
|
+
}];
|
|
291
|
+
for (const trigger of extractTriggerPhrases(commit.statement)) {
|
|
292
|
+
stages.push(createLifecycleStage("trigger", trigger, commit.seed ? "high" : "medium", evidence, []));
|
|
293
|
+
}
|
|
294
|
+
for (const clause of splitIntentClauses(commit.statement)) {
|
|
295
|
+
const label = sentenceLabel(clause);
|
|
296
|
+
if (isImplementationOnlyLifecycleStep(label)) {
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
stages.push(createLifecycleStage(classifyLifecycleClause(clause), label, commit.seed ? "high" : "medium", evidence, []));
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
const existingKinds = new Set(stages.map((stage) => stage.kind));
|
|
303
|
+
for (const signal of signals) {
|
|
304
|
+
if (stages.length >= maxLifecycleStages) {
|
|
305
|
+
break;
|
|
306
|
+
}
|
|
307
|
+
if (isImplementationOnlyLifecycleStep(`${signal.label} ${signal.symbol}`)) {
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
const alreadyRepresented = stages.some((stage) => stage.label.toLowerCase().includes(signal.symbol.toLowerCase()) ||
|
|
311
|
+
(existingKinds.has(signal.kind) && normalizedWords(stage.label).some((word) => normalizedWords(signal.label).includes(word))));
|
|
312
|
+
if (alreadyRepresented) {
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
stages.push(createLifecycleStage(signal.kind, signal.label, "medium", [signal.evidence], [signal.file]));
|
|
316
|
+
existingKinds.add(signal.kind);
|
|
317
|
+
}
|
|
318
|
+
return orderLifecycleStages(uniqueLifecycleStages(stages)).slice(0, maxLifecycleStages);
|
|
319
|
+
}
|
|
320
|
+
function lifecycleFromCodeSignals(signals) {
|
|
321
|
+
const stages = signals
|
|
322
|
+
.filter((signal) => !isImplementationOnlyLifecycleStep(`${signal.label} ${signal.symbol}`))
|
|
323
|
+
.map((signal) => createLifecycleStage(signal.kind, signal.label, "low", [signal.evidence], [signal.file]));
|
|
324
|
+
return orderLifecycleStages(uniqueLifecycleStages(stages)).slice(0, maxLifecycleStages);
|
|
325
|
+
}
|
|
326
|
+
function createLifecycleStage(kind, label, confidence, evidence, files) {
|
|
327
|
+
const normalizedLabel = sentenceLabel(label);
|
|
328
|
+
return {
|
|
329
|
+
id: stableId("stage", `${kind}:${normalizedLabel}:${evidence.map((item) => item.commit ?? item.file ?? item.value).join(":")}`),
|
|
330
|
+
kind,
|
|
331
|
+
label: normalizedLabel,
|
|
332
|
+
confidence,
|
|
333
|
+
evidence: uniqueEvidence(evidence),
|
|
334
|
+
files: uniqueStrings(files),
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
function buildIntentQaScenarios(intentId, title, lifecycle, keywords, evidence, confidence) {
|
|
338
|
+
const conditions = lifecycle.filter((stage) => stage.kind === "condition").map((stage) => stage.label);
|
|
339
|
+
const actions = selectPrimaryLifecycleSteps(lifecycle);
|
|
340
|
+
const outcomes = lifecycle.filter((stage) => stage.kind === "observable-outcome").map((stage) => assertionForStage(stage));
|
|
341
|
+
const sideEffects = lifecycle.filter((stage) => stage.kind === "side-effect").map((stage) => assertionForStage(stage));
|
|
342
|
+
const primaryEvidence = lifecycleEvidence(lifecycle, evidence);
|
|
343
|
+
const primaryHasActionableEvidence = hasActionableLocatedDiffEvidence(primaryEvidence);
|
|
344
|
+
const primary = {
|
|
345
|
+
id: stableId("scenario", `${intentId}:primary`),
|
|
346
|
+
kind: "primary",
|
|
347
|
+
priority: primaryHasActionableEvidence ? "critical" : "recommended",
|
|
348
|
+
title,
|
|
349
|
+
rationale: "Commit and diff evidence describe this changed behavior lifecycle; verify the complete observable path before merge.",
|
|
350
|
+
setup: conditions.length > 0 ? conditions : ["Prepare representative pre-change and changed-branch state."],
|
|
351
|
+
steps: actions.length > 0 ? actions : lifecycle.map((stage) => stage.label),
|
|
352
|
+
assertions: outcomes.length > 0 ? outcomes : sideEffects.slice(0, 2),
|
|
353
|
+
edgeCases: [],
|
|
354
|
+
evidence: primaryEvidence.slice(0, 8),
|
|
355
|
+
confidence,
|
|
356
|
+
reviewRequired: confidence !== "high" || !primaryHasActionableEvidence,
|
|
357
|
+
};
|
|
358
|
+
if (primary.assertions.length === 0) {
|
|
359
|
+
primary.assertions.push("Verify the externally observable result matches the commit intent.");
|
|
360
|
+
}
|
|
361
|
+
const scenarios = [primary];
|
|
362
|
+
const searchable = `${title} ${keywords.join(" ")} ${lifecycle.map((stage) => stage.label).join(" ")}`.toLowerCase();
|
|
363
|
+
const removedGuardEvidence = evidence.filter((item) => item.kind === "diff" &&
|
|
364
|
+
item.side === "base" &&
|
|
365
|
+
item.relation === "direct" &&
|
|
366
|
+
/guard|validat|permission|authoriz|authent|allowed|denied|protected/i.test(`${item.symbol ?? ""} ${item.value}`));
|
|
367
|
+
const removedConfigurationGuardEvidence = removedGuardEvidence.filter(isConfigurationGuardEvidence);
|
|
368
|
+
const removedAccessGuardEvidence = removedGuardEvidence.filter((item) => !isConfigurationGuardEvidence(item));
|
|
369
|
+
if (removedConfigurationGuardEvidence.length > 0) {
|
|
370
|
+
scenarios.push(makeScenario(intentId, "changed-configuration-guard", "failure", "critical", "Changed configuration or release guard", [
|
|
371
|
+
"Prepare the supported local, development, QA, and production configuration variants.",
|
|
372
|
+
"Prepare invalid release values that the previous guard rejected.",
|
|
373
|
+
], [
|
|
374
|
+
"Build or evaluate each supported environment through the changed configuration path.",
|
|
375
|
+
"Repeat with production endpoints, channels, or identifiers in a non-production build and with invalid production values.",
|
|
376
|
+
], [
|
|
377
|
+
"Verify every supported environment resolves to its intended endpoints, channel, and application identity.",
|
|
378
|
+
"Verify invalid or unsafe release configuration remains rejected by an intentional replacement guard.",
|
|
379
|
+
], ["QA using production services", "Wrong update channel", "Missing environment value"], removedConfigurationGuardEvidence));
|
|
380
|
+
}
|
|
381
|
+
if (removedAccessGuardEvidence.length > 0) {
|
|
382
|
+
scenarios.push(makeScenario(intentId, "removed-guard", "failure", "critical", "Removed guard or validation behavior", [
|
|
383
|
+
"Prepare valid, invalid, unauthorized, and previously rejected inputs or identities.",
|
|
384
|
+
], [
|
|
385
|
+
"Repeat the changed behavior for each state that the removed guard previously handled.",
|
|
386
|
+
"Attempt the same operation through every affected entry point.",
|
|
387
|
+
], [
|
|
388
|
+
"Verify invalid or unauthorized behavior remains blocked by an intentional replacement.",
|
|
389
|
+
"Verify valid behavior still succeeds without bypassing required validation.",
|
|
390
|
+
], ["Removed validation", "Unauthorized access", "Alternative entry point"], removedAccessGuardEvidence));
|
|
391
|
+
}
|
|
392
|
+
if (/schedul|reminder|calendar|\bdate\b|\btime\b|daily|tomorrow|timezone/.test(searchable)) {
|
|
393
|
+
scenarios.push(makeScenario(intentId, "calendar-boundary", "boundary", "critical", "Scheduling, calendar, and duplicate boundary", [
|
|
394
|
+
"Prepare records near day, month, and timezone boundaries.",
|
|
395
|
+
], [
|
|
396
|
+
"Repeat the changed scheduling action after its source time or date changes.",
|
|
397
|
+
"Repeat the action without changing source data to expose duplicate side effects.",
|
|
398
|
+
], [
|
|
399
|
+
"Verify the calculated date and time remain correct across boundaries.",
|
|
400
|
+
"Verify stale or duplicate schedules are replaced, preserved, or rejected intentionally.",
|
|
401
|
+
], ["Timezone change", "Day rollover", "Duplicate invocation"], scenarioEvidenceFor(lifecycle, evidence, /schedul|reminder|calendar|\bdate\b|\btime\b|daily|tomorrow|timezone/i)));
|
|
402
|
+
}
|
|
403
|
+
if (/toggle|enable|disable|permission|authoriz|auth|guard/.test(searchable)) {
|
|
404
|
+
scenarios.push(makeScenario(intentId, "guard-state", "state-transition", "critical", "Disabled, denied, and re-enabled state", [
|
|
405
|
+
"Prepare allowed, disabled, and denied states for the changed condition.",
|
|
406
|
+
], [
|
|
407
|
+
"Run the behavior while the condition is disabled or denied.",
|
|
408
|
+
"Enable or restore the condition and repeat the behavior.",
|
|
409
|
+
], [
|
|
410
|
+
"Verify no protected side effect occurs while blocked.",
|
|
411
|
+
"Verify re-enabling produces one correct side effect without stale state.",
|
|
412
|
+
], ["Permission denied", "Feature disabled", "State restored"], scenarioEvidenceFor(lifecycle, evidence, /toggle|enable|disable|permission|authoriz|auth|guard/i)));
|
|
413
|
+
}
|
|
414
|
+
const entryRoutingSearchable = searchable.replaceAll("navigation.setoptions", "");
|
|
415
|
+
if (/tap|open|navigat|redirect|route|deep.?link|payload|destination/.test(entryRoutingSearchable)) {
|
|
416
|
+
scenarios.push(makeScenario(intentId, "entry-routing", "failure", "critical", "Entry payload and destination routing", [
|
|
417
|
+
"Prepare valid, missing, and stale entry payloads.",
|
|
418
|
+
], [
|
|
419
|
+
"Enter through the changed external or internal trigger.",
|
|
420
|
+
"Repeat with missing or invalid destination context.",
|
|
421
|
+
], [
|
|
422
|
+
"Verify a valid payload opens the matching destination and state.",
|
|
423
|
+
"Verify invalid context fails safely without opening unrelated data.",
|
|
424
|
+
], ["Missing payload", "Stale identifier", "Repeated entry"], scenarioEvidenceFor(lifecycle, evidence, /tap|open|navigat|redirect|route|deep.?link|payload|destination/i, /navigation\.setoptions/i)));
|
|
425
|
+
}
|
|
426
|
+
if (/fetch|request|network|endpoint|api|mutation|response|timeout/.test(searchable)) {
|
|
427
|
+
scenarios.push(makeScenario(intentId, "network-failure", "failure", "recommended", "Failure, timeout, and retry handling", [
|
|
428
|
+
"Prepare success, empty, unauthorized, timeout, and server-error responses.",
|
|
429
|
+
], [
|
|
430
|
+
"Run the changed behavior for each reachable response.",
|
|
431
|
+
"Retry after a transient failure when the product supports retry.",
|
|
432
|
+
], [
|
|
433
|
+
"Verify each response produces the intended visible or persisted state.",
|
|
434
|
+
"Verify retries do not duplicate requests or side effects.",
|
|
435
|
+
], ["Unauthorized", "Timeout", "Server error", "Duplicate retry"], scenarioEvidenceFor(lifecycle, evidence, /fetch|request|network|endpoint|api|mutation|response|timeout/i)));
|
|
436
|
+
}
|
|
437
|
+
if (/sync|persist|storage|cache|reload|re.?entry|save|store/.test(searchable)) {
|
|
438
|
+
scenarios.push(makeScenario(intentId, "state-reentry", "state-transition", "recommended", "Re-entry and stale state recovery", [
|
|
439
|
+
"Prepare current and stale persisted state.",
|
|
440
|
+
], [
|
|
441
|
+
"Run the changed mutation and leave the affected surface.",
|
|
442
|
+
"Reload or re-enter through the normal entry point.",
|
|
443
|
+
], [
|
|
444
|
+
"Verify the latest state survives or is invalidated intentionally.",
|
|
445
|
+
"Verify stale state cannot overwrite the changed result.",
|
|
446
|
+
], ["Stale cache", "App restart", "Repeated synchronization"], scenarioEvidenceFor(lifecycle, evidence, /sync|persist|storage|cache|reload|re.?entry|save|store/i)));
|
|
447
|
+
}
|
|
448
|
+
return uniqueScenarios(scenarios).slice(0, 4);
|
|
449
|
+
}
|
|
450
|
+
function lifecycleEvidence(lifecycle, fallback) {
|
|
451
|
+
const evidence = uniqueEvidence(lifecycle.flatMap((stage) => stage.evidence));
|
|
452
|
+
return evidence.length > 0 ? evidence : fallback;
|
|
453
|
+
}
|
|
454
|
+
function scenarioEvidenceFor(lifecycle, fallback, pattern, excludePattern) {
|
|
455
|
+
const matching = lifecycle
|
|
456
|
+
.filter((stage) => {
|
|
457
|
+
const searchable = `${stage.label} ${stage.evidence.map((item) => item.symbol ?? item.value).join(" ")}`;
|
|
458
|
+
return pattern.test(searchable) && !excludePattern?.test(searchable);
|
|
459
|
+
})
|
|
460
|
+
.flatMap((stage) => stage.evidence);
|
|
461
|
+
const matchingDiff = fallback.filter((item) => {
|
|
462
|
+
const searchable = `${item.symbol ?? ""} ${item.value}`;
|
|
463
|
+
return item.kind === "diff" && pattern.test(searchable) && !excludePattern?.test(searchable);
|
|
464
|
+
});
|
|
465
|
+
return uniqueEvidence([...matching, ...matchingDiff]).slice(0, 6);
|
|
466
|
+
}
|
|
467
|
+
function hasActionableLocatedDiffEvidence(evidence) {
|
|
468
|
+
return evidence.some((item) => item.kind === "diff" &&
|
|
469
|
+
item.file &&
|
|
470
|
+
item.startLine !== undefined &&
|
|
471
|
+
item.relation !== "contextual");
|
|
472
|
+
}
|
|
473
|
+
function isConfigurationGuardEvidence(evidence) {
|
|
474
|
+
return /(?:^|\/)(?:app|build|release|env|eas|expo|vite|webpack|rollup)?[.-]?config\.[^/]+$/i.test(evidence.file ?? "") ||
|
|
475
|
+
/release|build|environment|\benv\b|config/i.test(`${evidence.symbol ?? ""} ${evidence.value}`);
|
|
476
|
+
}
|
|
477
|
+
function selectPrimaryLifecycleSteps(lifecycle) {
|
|
478
|
+
const limits = {
|
|
479
|
+
trigger: 1,
|
|
480
|
+
action: 1,
|
|
481
|
+
"state-change": 2,
|
|
482
|
+
"side-effect": 2,
|
|
483
|
+
};
|
|
484
|
+
const counts = new Map();
|
|
485
|
+
const steps = [];
|
|
486
|
+
for (const stage of lifecycle) {
|
|
487
|
+
const limit = limits[stage.kind] ?? 0;
|
|
488
|
+
const count = counts.get(stage.kind) ?? 0;
|
|
489
|
+
if (limit === 0 || count >= limit || isImplementationOnlyLifecycleStep(stage.label)) {
|
|
490
|
+
continue;
|
|
491
|
+
}
|
|
492
|
+
counts.set(stage.kind, count + 1);
|
|
493
|
+
steps.push(stage.label);
|
|
494
|
+
}
|
|
495
|
+
return steps;
|
|
496
|
+
}
|
|
497
|
+
function isImplementationOnlyLifecycleStep(label) {
|
|
498
|
+
const implementationNoun = "(?:helpers?|interfaces?|lookups?|modules?|types?|utilities)";
|
|
499
|
+
return new RegExp(`^(?:add|extract|move|refactor|rename)\\b.*\\b${implementationNoun}\\b`, "i").test(label) ||
|
|
500
|
+
new RegExp(`^(?:an?|the)\\b.*\\b${implementationNoun}\\.?$`, "i").test(label);
|
|
501
|
+
}
|
|
502
|
+
function makeScenario(intentId, key, kind, priority, title, setup, steps, assertions, edgeCases, evidence) {
|
|
503
|
+
const preciseEvidence = hasActionableLocatedDiffEvidence(evidence);
|
|
504
|
+
return {
|
|
505
|
+
id: stableId("scenario", `${intentId}:${key}`),
|
|
506
|
+
kind,
|
|
507
|
+
priority: preciseEvidence ? priority : "recommended",
|
|
508
|
+
title,
|
|
509
|
+
rationale: "Deterministic lifecycle patterns indicate this failure or boundary axis is easy to miss in review.",
|
|
510
|
+
setup,
|
|
511
|
+
steps,
|
|
512
|
+
assertions,
|
|
513
|
+
edgeCases,
|
|
514
|
+
evidence: evidence.slice(0, 6),
|
|
515
|
+
confidence: preciseEvidence ? "medium" : "low",
|
|
516
|
+
reviewRequired: true,
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
function collectCodeBehaviorSignals(addedDiffText, addedDiffEvidence) {
|
|
520
|
+
const signals = [];
|
|
521
|
+
const locatedFiles = new Set();
|
|
522
|
+
for (const [file, hunks] of Object.entries(addedDiffEvidence)) {
|
|
523
|
+
if (!isBehaviorBearingFile(file)) {
|
|
524
|
+
continue;
|
|
525
|
+
}
|
|
526
|
+
locatedFiles.add(file);
|
|
527
|
+
for (const hunk of hunks) {
|
|
528
|
+
for (const line of hunk.lines) {
|
|
529
|
+
collectCodeBehaviorSignalsFromText(signals, file, line.text, hunk, line.line);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
for (const [file, text] of Object.entries(addedDiffText)) {
|
|
534
|
+
if (!isBehaviorBearingFile(file) || locatedFiles.has(file)) {
|
|
535
|
+
continue;
|
|
536
|
+
}
|
|
537
|
+
collectCodeBehaviorSignalsFromText(signals, file, text);
|
|
538
|
+
}
|
|
539
|
+
return uniqueCodeSignals(signals).slice(0, maxSignals);
|
|
540
|
+
}
|
|
541
|
+
function collectDiffRiskEvidence(addedDiffEvidence) {
|
|
542
|
+
const evidence = [];
|
|
543
|
+
for (const [file, hunks] of Object.entries(addedDiffEvidence)) {
|
|
544
|
+
if (!isBehaviorBearingFile(file)) {
|
|
545
|
+
continue;
|
|
546
|
+
}
|
|
547
|
+
for (const hunk of hunks) {
|
|
548
|
+
for (const [side, lines] of [["head", hunk.lines], ["base", hunk.removedLines ?? []]]) {
|
|
549
|
+
for (const line of lines) {
|
|
550
|
+
const calendarMatch = line.text.match(/(timezone|scheduledAt|\bschedule\w*\b|\breminder\w*\b|\bcalendar\b|\btomorrow\b|\bdaily\b)/i);
|
|
551
|
+
if (calendarMatch) {
|
|
552
|
+
evidence.push(diffRiskEvidence(file, hunk, line.line, calendarMatch[1], `${side === "base" ? "Removed" : "Changed"} line contains calendar or scheduling evidence for ${calendarMatch[1]}.`, side));
|
|
553
|
+
}
|
|
554
|
+
const routingMatch = line.text.match(/(payload|deep.?link|destination|redirect|router\.push|navigate\w*)/i);
|
|
555
|
+
if (routingMatch && !/navigation\.setoptions/i.test(line.text)) {
|
|
556
|
+
evidence.push(diffRiskEvidence(file, hunk, line.line, routingMatch[1], `${side === "base" ? "Removed" : "Changed"} line contains entry or routing evidence for ${routingMatch[1]}.`, side));
|
|
557
|
+
}
|
|
558
|
+
const guardMatch = line.text.match(/(guard\w*|validat\w*|permission\w*|authoriz\w*|authenticat\w*|isAllowed|isDenied|protected)/i);
|
|
559
|
+
if (side === "base" && guardMatch) {
|
|
560
|
+
evidence.push(diffRiskEvidence(file, hunk, line.line, guardMatch[1], `Removed line contains guard or validation evidence for ${guardMatch[1]}.`, side));
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
return uniqueEvidence(evidence);
|
|
567
|
+
}
|
|
568
|
+
function diffRiskEvidence(file, hunk, line, symbol, value, side) {
|
|
569
|
+
return {
|
|
570
|
+
kind: "diff",
|
|
571
|
+
value,
|
|
572
|
+
file,
|
|
573
|
+
previousFile: hunk.previousFile,
|
|
574
|
+
symbol,
|
|
575
|
+
relation: "direct",
|
|
576
|
+
side,
|
|
577
|
+
startLine: line,
|
|
578
|
+
endLine: line,
|
|
579
|
+
hunkHeader: hunk.hunkHeader,
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
function collectCodeBehaviorSignalsFromText(signals, file, text, hunk, line) {
|
|
583
|
+
for (const match of text.matchAll(/\b(on[A-Z][A-Za-z0-9_]*)\b/g)) {
|
|
584
|
+
const symbol = match[1];
|
|
585
|
+
const label = `Handle ${humanizeIdentifier(symbol)}.`;
|
|
586
|
+
signals.push({
|
|
587
|
+
kind: "trigger",
|
|
588
|
+
label,
|
|
589
|
+
file,
|
|
590
|
+
symbol,
|
|
591
|
+
evidence: codeSignalEvidence(label, file, symbol, hunk, line),
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
for (const match of text.matchAll(/\b([A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)?)\s*\(/g)) {
|
|
595
|
+
const symbol = match[1];
|
|
596
|
+
const leaf = symbol.split(".").at(-1) ?? symbol;
|
|
597
|
+
if (ignoredCallNames.has(leaf) || leaf.length < 3) {
|
|
598
|
+
continue;
|
|
599
|
+
}
|
|
600
|
+
const kind = lifecycleKindForIdentifier(symbol);
|
|
601
|
+
if (!kind) {
|
|
602
|
+
continue;
|
|
603
|
+
}
|
|
604
|
+
const label = codeSignalLabel(kind, symbol);
|
|
605
|
+
signals.push({
|
|
606
|
+
kind,
|
|
607
|
+
label,
|
|
608
|
+
file,
|
|
609
|
+
symbol,
|
|
610
|
+
evidence: codeSignalEvidence(label, file, symbol, hunk, line),
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
function codeSignalEvidence(value, file, symbol, hunk, line) {
|
|
615
|
+
return {
|
|
616
|
+
kind: "diff",
|
|
617
|
+
value,
|
|
618
|
+
file,
|
|
619
|
+
previousFile: hunk?.previousFile,
|
|
620
|
+
symbol,
|
|
621
|
+
relation: "supporting",
|
|
622
|
+
side: hunk ? "head" : undefined,
|
|
623
|
+
startLine: line,
|
|
624
|
+
endLine: line,
|
|
625
|
+
hunkHeader: hunk?.hunkHeader,
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
function lifecycleKindForIdentifier(identifier) {
|
|
629
|
+
const value = identifier.toLowerCase();
|
|
630
|
+
if (/^(?:on|handle)(?:press|click|submit|change|complete|open|response|message|select|toggle)/.test(value)) {
|
|
631
|
+
return "trigger";
|
|
632
|
+
}
|
|
633
|
+
if (/(?:navigate|redirect|router\.(?:push|replace)|openurl|openlink|show|display|preview|render)/.test(value)) {
|
|
634
|
+
return "observable-outcome";
|
|
635
|
+
}
|
|
636
|
+
if (/(?:schedule|notify|notification|request|fetch|mutate|post|send|emit|track|publish|upload|download)/.test(value)) {
|
|
637
|
+
return "side-effect";
|
|
638
|
+
}
|
|
639
|
+
if (/(?:resync|sync|persist|store|save|update|set[A-Z_]|cache|write|delete|remove|cancel|invalidate)/i.test(identifier)) {
|
|
640
|
+
return "state-change";
|
|
641
|
+
}
|
|
642
|
+
if (/(?:permission|authorized|authenticated|enabled|disabled|validate|guard|can[A-Z_]|should[A-Z_])/i.test(identifier)) {
|
|
643
|
+
return "condition";
|
|
644
|
+
}
|
|
645
|
+
return undefined;
|
|
646
|
+
}
|
|
647
|
+
function codeSignalLabel(kind, symbol) {
|
|
648
|
+
if (kind === "trigger") {
|
|
649
|
+
return `Trigger ${humanizeIdentifier(symbol)}.`;
|
|
650
|
+
}
|
|
651
|
+
if (kind === "condition") {
|
|
652
|
+
return `Check ${humanizeIdentifier(symbol)}.`;
|
|
653
|
+
}
|
|
654
|
+
if (kind === "state-change") {
|
|
655
|
+
return `Update state through ${symbol}.`;
|
|
656
|
+
}
|
|
657
|
+
if (kind === "side-effect") {
|
|
658
|
+
return `Invoke ${symbol}.`;
|
|
659
|
+
}
|
|
660
|
+
if (kind === "observable-outcome") {
|
|
661
|
+
return `Observe the result of ${symbol}.`;
|
|
662
|
+
}
|
|
663
|
+
return `Run ${symbol}.`;
|
|
664
|
+
}
|
|
665
|
+
function extractTriggerPhrases(statement) {
|
|
666
|
+
const triggers = [];
|
|
667
|
+
for (const match of statement.matchAll(/\b(after|when|once|upon|before)\s+([^,;.]+)/gi)) {
|
|
668
|
+
const phrase = `${match[1]} ${match[2]}`.trim().split(/\s+/).slice(0, 10).join(" ");
|
|
669
|
+
triggers.push(sentenceLabel(phrase));
|
|
670
|
+
}
|
|
671
|
+
const adjectiveTrigger = statement.match(/\b(?:the\s+)?(tapped|clicked|submitted|completed|received)\s+([a-z0-9-]+)\b/i);
|
|
672
|
+
if (adjectiveTrigger) {
|
|
673
|
+
triggers.push(sentenceLabel(`When the ${adjectiveTrigger[2]} is ${adjectiveTrigger[1]}`));
|
|
674
|
+
}
|
|
675
|
+
else {
|
|
676
|
+
const passiveTrigger = statement.match(/\b(?:the\s+)?([a-z0-9-]+)\s+is\s+(tapped|clicked|submitted|completed|received)\b/i);
|
|
677
|
+
if (passiveTrigger) {
|
|
678
|
+
triggers.push(sentenceLabel(`When the ${passiveTrigger[1]} is ${passiveTrigger[2]}`));
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
return uniqueStrings(triggers);
|
|
682
|
+
}
|
|
683
|
+
function splitIntentClauses(statement) {
|
|
684
|
+
const stripped = stripTerminalPunctuation(statement.trim());
|
|
685
|
+
const clauses = stripped
|
|
686
|
+
.split(/\s+(?:and then|then|and)\s+/i)
|
|
687
|
+
.map((clause) => clause.trim())
|
|
688
|
+
.filter((clause) => clause.length >= 4);
|
|
689
|
+
return clauses.length > 0 ? clauses : [stripped];
|
|
690
|
+
}
|
|
691
|
+
function classifyLifecycleClause(clause) {
|
|
692
|
+
const value = clause.toLowerCase();
|
|
693
|
+
if (/^(?:show|display|render|preview|tease|open|navigate|redirect|surface|return)\b/.test(value)) {
|
|
694
|
+
return "observable-outcome";
|
|
695
|
+
}
|
|
696
|
+
if (/^(?:save|persist|store|update|sync|resync|cache|set|cancel|remove|delete|invalidate|toggle)\b/.test(value)) {
|
|
697
|
+
return "state-change";
|
|
698
|
+
}
|
|
699
|
+
if (/^(?:schedule|fire|send|notify|request|fetch|post|emit|track|publish|export|upload)\b/.test(value)) {
|
|
700
|
+
return "side-effect";
|
|
701
|
+
}
|
|
702
|
+
if (/\b(?:if|only|enabled|disabled|permission|authorized|authenticated|valid|guard)\b/.test(value)) {
|
|
703
|
+
return "condition";
|
|
704
|
+
}
|
|
705
|
+
if (/^(?:tap|click|submit|complete|receive|start|select|press)\b/.test(value)) {
|
|
706
|
+
return "trigger";
|
|
707
|
+
}
|
|
708
|
+
if (/\b(?:show|display|render|preview|tease|open|navigate|redirect|surface|return)\b/.test(value)) {
|
|
709
|
+
return "observable-outcome";
|
|
710
|
+
}
|
|
711
|
+
if (/\b(?:save|persist|store|update|sync|resync|cache|set|cancel|remove|delete|invalidate|toggle)\b/.test(value)) {
|
|
712
|
+
return "state-change";
|
|
713
|
+
}
|
|
714
|
+
if (/\b(?:schedule|fire|send|notify|request|fetch|post|emit|track|publish|export|upload)\b/.test(value)) {
|
|
715
|
+
return "side-effect";
|
|
716
|
+
}
|
|
717
|
+
return "action";
|
|
718
|
+
}
|
|
719
|
+
function confidenceForIntent(commits, lifecycle, signals) {
|
|
720
|
+
const seedCount = commits.filter((commit) => commit.seed).length;
|
|
721
|
+
const phaseCount = new Set(lifecycle.map((stage) => stage.kind)).size;
|
|
722
|
+
if (phaseCount >= 3 &&
|
|
723
|
+
signals.length >= 1 &&
|
|
724
|
+
(seedCount >= 2 || (seedCount === 1 && phaseCount >= 4 && signals.length >= 2))) {
|
|
725
|
+
return "high";
|
|
726
|
+
}
|
|
727
|
+
if (seedCount >= 1 && lifecycle.length >= 2) {
|
|
728
|
+
return "medium";
|
|
729
|
+
}
|
|
730
|
+
return "low";
|
|
731
|
+
}
|
|
732
|
+
function lifecycleKeywordCount(value) {
|
|
733
|
+
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);
|
|
734
|
+
return new Set((matches ?? []).map((match) => normalizeToken(match))).size;
|
|
735
|
+
}
|
|
736
|
+
function isLowSignalCommitStatement(statement) {
|
|
737
|
+
return /^(?:benchmark|prepare|release|version|dependency|format|lint|cleanup|metadata)\b/i.test(statement.trim());
|
|
738
|
+
}
|
|
739
|
+
function extractKeywords(value) {
|
|
740
|
+
const words = normalizedWords(value)
|
|
741
|
+
.map(normalizeToken)
|
|
742
|
+
.filter((word) => word.length >= 3 && !stopWords.has(word));
|
|
743
|
+
return uniqueStrings(words).slice(0, 24);
|
|
744
|
+
}
|
|
745
|
+
function normalizedWords(value) {
|
|
746
|
+
return value
|
|
747
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
748
|
+
.toLowerCase()
|
|
749
|
+
.split(/[^a-z0-9가-힣]+/)
|
|
750
|
+
.filter(Boolean);
|
|
751
|
+
}
|
|
752
|
+
function normalizeToken(value) {
|
|
753
|
+
let token = value.toLowerCase().replace(/[^a-z0-9가-힣]/g, "");
|
|
754
|
+
if (/^schedul/.test(token))
|
|
755
|
+
return "schedule";
|
|
756
|
+
if (/^(?:notify|notification)/.test(token))
|
|
757
|
+
return "notification";
|
|
758
|
+
if (/^(?:resync|sync)/.test(token))
|
|
759
|
+
return "sync";
|
|
760
|
+
if (/^(?:navigate|navigation|redirect|route)/.test(token))
|
|
761
|
+
return "navigation";
|
|
762
|
+
if (/^(?:remind|reminder)/.test(token))
|
|
763
|
+
return "reminder";
|
|
764
|
+
if (/^(?:persist|storage|store)/.test(token))
|
|
765
|
+
return "persistence";
|
|
766
|
+
if (token.endsWith("ies") && token.length > 5)
|
|
767
|
+
token = `${token.slice(0, -3)}y`;
|
|
768
|
+
else if (token.endsWith("ing") && token.length > 6)
|
|
769
|
+
token = token.slice(0, -3);
|
|
770
|
+
else if (token.endsWith("ed") && token.length > 5)
|
|
771
|
+
token = token.slice(0, -2);
|
|
772
|
+
else if (token.endsWith("s") && !token.endsWith("ss") && token.length > 4)
|
|
773
|
+
token = token.slice(0, -1);
|
|
774
|
+
return token;
|
|
775
|
+
}
|
|
776
|
+
function isBehaviorBearingFile(file) {
|
|
777
|
+
return !(/(?:^|\/)(?:docs?|test|tests|__tests__|fixtures?|snapshots?|coverage|dist|build)\//i.test(file) ||
|
|
778
|
+
/(?:^|\/)(?:package-lock\.json|pnpm-lock\.yaml|yarn\.lock|CHANGELOG\.md|README\.md)$/i.test(file) ||
|
|
779
|
+
/\.(?:md|mdx|snap|map)$/i.test(file));
|
|
780
|
+
}
|
|
781
|
+
function stripParsedCommitFields(commit) {
|
|
782
|
+
const { seed: _seed, supporting: _supporting, keywords: _keywords, ...result } = commit;
|
|
783
|
+
return result;
|
|
784
|
+
}
|
|
785
|
+
function orderLifecycleStages(stages) {
|
|
786
|
+
const rank = {
|
|
787
|
+
trigger: 0,
|
|
788
|
+
condition: 1,
|
|
789
|
+
action: 2,
|
|
790
|
+
"state-change": 3,
|
|
791
|
+
"side-effect": 4,
|
|
792
|
+
"observable-outcome": 5,
|
|
793
|
+
};
|
|
794
|
+
return stages
|
|
795
|
+
.map((stage, index) => ({ stage, index }))
|
|
796
|
+
.sort((left, right) => rank[left.stage.kind] - rank[right.stage.kind] || left.index - right.index)
|
|
797
|
+
.map(({ stage }) => stage);
|
|
798
|
+
}
|
|
799
|
+
function uniqueLifecycleStages(stages) {
|
|
800
|
+
const seen = new Set();
|
|
801
|
+
return stages.filter((stage) => {
|
|
802
|
+
const key = `${stage.kind}:${stripTerminalPunctuation(stage.label).toLowerCase()}`;
|
|
803
|
+
if (seen.has(key))
|
|
804
|
+
return false;
|
|
805
|
+
seen.add(key);
|
|
806
|
+
return true;
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
function uniqueCodeSignals(signals) {
|
|
810
|
+
const seen = new Set();
|
|
811
|
+
return signals.filter((signal) => {
|
|
812
|
+
const key = `${signal.kind}:${signal.file}:${signal.symbol}`;
|
|
813
|
+
if (seen.has(key))
|
|
814
|
+
return false;
|
|
815
|
+
seen.add(key);
|
|
816
|
+
return true;
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
function uniqueScenarios(scenarios) {
|
|
820
|
+
const seen = new Set();
|
|
821
|
+
return scenarios.filter((scenario) => {
|
|
822
|
+
const key = scenario.title.toLowerCase();
|
|
823
|
+
if (seen.has(key))
|
|
824
|
+
return false;
|
|
825
|
+
seen.add(key);
|
|
826
|
+
return true;
|
|
827
|
+
});
|
|
828
|
+
}
|
|
829
|
+
function uniqueEvidence(evidence) {
|
|
830
|
+
const seen = new Set();
|
|
831
|
+
return evidence.filter((item) => {
|
|
832
|
+
const key = `${item.kind}:${item.relation ?? ""}:${item.side ?? ""}:${item.commit ?? ""}:${item.file ?? ""}:${item.startLine ?? ""}:${item.endLine ?? ""}:${item.symbol ?? ""}:${item.value}`;
|
|
833
|
+
if (seen.has(key))
|
|
834
|
+
return false;
|
|
835
|
+
seen.add(key);
|
|
836
|
+
return true;
|
|
837
|
+
});
|
|
838
|
+
}
|
|
839
|
+
function uniqueStrings(values) {
|
|
840
|
+
return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
|
|
841
|
+
}
|
|
842
|
+
function sentenceTitle(value) {
|
|
843
|
+
const stripped = stripTerminalPunctuation(value.trim());
|
|
844
|
+
if (!stripped)
|
|
845
|
+
return "Changed behavior";
|
|
846
|
+
return stripped[0].toUpperCase() + stripped.slice(1);
|
|
847
|
+
}
|
|
848
|
+
function sentenceLabel(value) {
|
|
849
|
+
const title = sentenceTitle(value);
|
|
850
|
+
return /[.!?]$/.test(title) ? title : `${title}.`;
|
|
851
|
+
}
|
|
852
|
+
function stripTerminalPunctuation(value) {
|
|
853
|
+
return value.replace(/[.!?]+$/, "").trim();
|
|
854
|
+
}
|
|
855
|
+
function assertionForStage(stage) {
|
|
856
|
+
return `Verify ${lowercaseFirst(stripTerminalPunctuation(stage.label))}.`;
|
|
857
|
+
}
|
|
858
|
+
function lowercaseFirst(value) {
|
|
859
|
+
return value ? value[0].toLowerCase() + value.slice(1) : value;
|
|
860
|
+
}
|
|
861
|
+
function humanizeIdentifier(value) {
|
|
862
|
+
return value
|
|
863
|
+
.replaceAll(".", " ")
|
|
864
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
865
|
+
.replace(/[_-]+/g, " ")
|
|
866
|
+
.trim()
|
|
867
|
+
.toLowerCase();
|
|
868
|
+
}
|
|
869
|
+
function stableId(prefix, value) {
|
|
870
|
+
return `${prefix}:${createHash("sha256").update(value).digest("hex").slice(0, 12)}`;
|
|
871
|
+
}
|
|
872
|
+
function toPosixPath(value) {
|
|
873
|
+
return value.split(path.sep).join("/");
|
|
874
|
+
}
|
|
875
|
+
function changeIntentSource(intents, commits, signals) {
|
|
876
|
+
if (intents.length === 0)
|
|
877
|
+
return "none";
|
|
878
|
+
if (commits.length > 0 && signals.length > 0)
|
|
879
|
+
return "commits-and-diff";
|
|
880
|
+
if (commits.length > 0)
|
|
881
|
+
return "commits";
|
|
882
|
+
return "diff-only";
|
|
883
|
+
}
|
|
884
|
+
//# sourceMappingURL=change-intent.js.map
|