@jevitate/cli 0.8.0 → 0.10.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 (192) hide show
  1. package/dist/anchor-baselines.d.ts +26 -0
  2. package/dist/anchor-baselines.js +25 -0
  3. package/dist/anchor-baselines.js.map +1 -0
  4. package/dist/approval-provenance.d.ts +48 -2
  5. package/dist/approval-provenance.js +110 -6
  6. package/dist/approval-provenance.js.map +1 -1
  7. package/dist/bin.js +25111 -17835
  8. package/dist/browser-cli.d.ts +10 -0
  9. package/dist/browser-cli.js +50 -0
  10. package/dist/browser-cli.js.map +1 -0
  11. package/dist/build-info.generated.js +2 -2
  12. package/dist/catalog-analysis.js +14 -0
  13. package/dist/catalog-analysis.js.map +1 -1
  14. package/dist/catalog-api.js +12 -4
  15. package/dist/catalog-api.js.map +1 -1
  16. package/dist/catalog-bundle-api.d.ts +73 -0
  17. package/dist/catalog-bundle-api.js +206 -0
  18. package/dist/catalog-bundle-api.js.map +1 -0
  19. package/dist/catalog-bundle-findings.d.ts +54 -0
  20. package/dist/catalog-bundle-findings.js +252 -0
  21. package/dist/catalog-bundle-findings.js.map +1 -0
  22. package/dist/catalog-bundle.d.ts +192 -0
  23. package/dist/catalog-bundle.js +578 -0
  24. package/dist/catalog-bundle.js.map +1 -0
  25. package/dist/catalog-cli.d.ts +6 -0
  26. package/dist/catalog-cli.js +180 -5
  27. package/dist/catalog-cli.js.map +1 -1
  28. package/dist/catalog-refs.d.ts +42 -0
  29. package/dist/catalog-refs.js +188 -0
  30. package/dist/catalog-refs.js.map +1 -0
  31. package/dist/catalog-review.d.ts +3 -1
  32. package/dist/catalog-review.js +61 -0
  33. package/dist/catalog-review.js.map +1 -1
  34. package/dist/catalog.d.ts +27 -3
  35. package/dist/catalog.js +46 -2
  36. package/dist/catalog.js.map +1 -1
  37. package/dist/change-context.d.ts +44 -0
  38. package/dist/change-context.js +203 -0
  39. package/dist/change-context.js.map +1 -0
  40. package/dist/check-api.d.ts +1 -1
  41. package/dist/check-api.js +206 -16
  42. package/dist/check-api.js.map +1 -1
  43. package/dist/check-cli.d.ts +2 -1
  44. package/dist/check-cli.js +117 -8
  45. package/dist/check-cli.js.map +1 -1
  46. package/dist/check-execute.d.ts +28 -1
  47. package/dist/check-execute.js +110 -27
  48. package/dist/check-execute.js.map +1 -1
  49. package/dist/check-plan.d.ts +14 -0
  50. package/dist/check-plan.js +24 -1
  51. package/dist/check-plan.js.map +1 -1
  52. package/dist/check-types.d.ts +137 -5
  53. package/dist/check-types.js +8 -0
  54. package/dist/check-types.js.map +1 -1
  55. package/dist/cli-output.d.ts +6 -0
  56. package/dist/cli-output.js +26 -1
  57. package/dist/cli-output.js.map +1 -1
  58. package/dist/cli-shared.d.ts +10 -0
  59. package/dist/cli-shared.js +0 -4
  60. package/dist/cli-shared.js.map +1 -1
  61. package/dist/exit-codes.d.ts +6 -3
  62. package/dist/exit-codes.js +24 -3
  63. package/dist/exit-codes.js.map +1 -1
  64. package/dist/explore-adversarial.js +2 -2
  65. package/dist/explore-adversarial.js.map +1 -1
  66. package/dist/explore-cli.js +3 -2
  67. package/dist/explore-cli.js.map +1 -1
  68. package/dist/explore-coverage.js +3 -3
  69. package/dist/explore-coverage.js.map +1 -1
  70. package/dist/explore-feature.js +3 -3
  71. package/dist/explore-feature.js.map +1 -1
  72. package/dist/explore-goal.js +11 -5
  73. package/dist/explore-goal.js.map +1 -1
  74. package/dist/findings-filing.d.ts +6 -0
  75. package/dist/findings-filing.js +15 -1
  76. package/dist/findings-filing.js.map +1 -1
  77. package/dist/forge-verify.d.ts +224 -0
  78. package/dist/forge-verify.js +649 -0
  79. package/dist/forge-verify.js.map +1 -0
  80. package/dist/heal-probe-guard.d.ts +9 -0
  81. package/dist/heal-probe-guard.js +113 -0
  82. package/dist/heal-probe-guard.js.map +1 -0
  83. package/dist/host-health-run.d.ts +32 -1
  84. package/dist/host-health-run.js +38 -1
  85. package/dist/host-health-run.js.map +1 -1
  86. package/dist/init-codeowners.d.ts +19 -0
  87. package/dist/init-codeowners.js +66 -0
  88. package/dist/init-codeowners.js.map +1 -1
  89. package/dist/job-draft-api.d.ts +52 -0
  90. package/dist/job-draft-api.js +110 -0
  91. package/dist/job-draft-api.js.map +1 -0
  92. package/dist/journeeze-api.d.ts +71 -0
  93. package/dist/journeeze-api.js +285 -0
  94. package/dist/journeeze-api.js.map +1 -0
  95. package/dist/journeeze-connect.d.ts +156 -0
  96. package/dist/journeeze-connect.js +357 -0
  97. package/dist/journeeze-connect.js.map +1 -0
  98. package/dist/journey-anchor-gate.d.ts +52 -0
  99. package/dist/journey-anchor-gate.js +101 -0
  100. package/dist/journey-anchor-gate.js.map +1 -0
  101. package/dist/journey-api.d.ts +43 -3
  102. package/dist/journey-api.js +226 -21
  103. package/dist/journey-api.js.map +1 -1
  104. package/dist/journey-cli.js +162 -20
  105. package/dist/journey-cli.js.map +1 -1
  106. package/dist/journey-heal.d.ts +74 -0
  107. package/dist/journey-heal.js +180 -0
  108. package/dist/journey-heal.js.map +1 -0
  109. package/dist/journey-lint-sarif.d.ts +3 -2
  110. package/dist/journey-lint-sarif.js +9 -2
  111. package/dist/journey-lint-sarif.js.map +1 -1
  112. package/dist/journey-migrate-api.d.ts +54 -0
  113. package/dist/journey-migrate-api.js +64 -0
  114. package/dist/journey-migrate-api.js.map +1 -0
  115. package/dist/journey-network-checks.d.ts +5 -1
  116. package/dist/journey-network-checks.js +15 -4
  117. package/dist/journey-network-checks.js.map +1 -1
  118. package/dist/journey-prefix.js +2 -2
  119. package/dist/journey-prefix.js.map +1 -1
  120. package/dist/journey-proposal-store.d.ts +109 -0
  121. package/dist/journey-proposal-store.js +365 -0
  122. package/dist/journey-proposal-store.js.map +1 -0
  123. package/dist/journey-result-record.d.ts +42 -0
  124. package/dist/journey-result-record.js +33 -0
  125. package/dist/journey-result-record.js.map +1 -0
  126. package/dist/journey-review-api.js +16 -1
  127. package/dist/journey-review-api.js.map +1 -1
  128. package/dist/journey-review.d.ts +18 -1
  129. package/dist/journey-review.js +116 -5
  130. package/dist/journey-review.js.map +1 -1
  131. package/dist/journey-stale-api.d.ts +40 -0
  132. package/dist/journey-stale-api.js +57 -0
  133. package/dist/journey-stale-api.js.map +1 -0
  134. package/dist/launch-armed.js +5 -1
  135. package/dist/launch-armed.js.map +1 -1
  136. package/dist/locator-health-api.d.ts +101 -0
  137. package/dist/locator-health-api.js +203 -0
  138. package/dist/locator-health-api.js.map +1 -0
  139. package/dist/locator-health.d.ts +195 -0
  140. package/dist/locator-health.js +419 -0
  141. package/dist/locator-health.js.map +1 -0
  142. package/dist/login-cli.d.ts +2 -0
  143. package/dist/login-cli.js +56 -11
  144. package/dist/login-cli.js.map +1 -1
  145. package/dist/mcp-api.d.ts +5 -1
  146. package/dist/mcp-api.js +53 -8
  147. package/dist/mcp-api.js.map +1 -1
  148. package/dist/mcp-cli-tools.d.ts +3 -1
  149. package/dist/mcp-cli-tools.js +80 -10
  150. package/dist/mcp-cli-tools.js.map +1 -1
  151. package/dist/multi-run.d.ts +7 -6
  152. package/dist/multi-run.js +5 -4
  153. package/dist/multi-run.js.map +1 -1
  154. package/dist/persona-login.d.ts +60 -3
  155. package/dist/persona-login.js +341 -31
  156. package/dist/persona-login.js.map +1 -1
  157. package/dist/program.js +2 -0
  158. package/dist/program.js.map +1 -1
  159. package/dist/project-config.d.ts +29 -0
  160. package/dist/project-config.js +76 -0
  161. package/dist/project-config.js.map +1 -0
  162. package/dist/report-api.d.ts +1 -0
  163. package/dist/report-api.js +1 -0
  164. package/dist/report-api.js.map +1 -1
  165. package/dist/resource-preflight.js +5 -3
  166. package/dist/resource-preflight.js.map +1 -1
  167. package/dist/run-metadata.d.ts +5 -2
  168. package/dist/run-metadata.js +10 -3
  169. package/dist/run-metadata.js.map +1 -1
  170. package/dist/run-tags-cli.d.ts +2 -0
  171. package/dist/run-tags-cli.js +8 -2
  172. package/dist/run-tags-cli.js.map +1 -1
  173. package/dist/self-heal-adapter.d.ts +43 -27
  174. package/dist/self-heal-adapter.js +196 -62
  175. package/dist/self-heal-adapter.js.map +1 -1
  176. package/dist/source-cli.js +5 -3
  177. package/dist/source-cli.js.map +1 -1
  178. package/dist/suite-explore-options.js +1 -0
  179. package/dist/suite-explore-options.js.map +1 -1
  180. package/dist/sweep-api.d.ts +23 -0
  181. package/dist/sweep-api.js +39 -8
  182. package/dist/sweep-api.js.map +1 -1
  183. package/dist/sweep-cli.js +3 -1
  184. package/dist/sweep-cli.js.map +1 -1
  185. package/dist/ux-usability.js +3 -2
  186. package/dist/ux-usability.js.map +1 -1
  187. package/package.json +17 -15
  188. package/skills/jevitate-ci-check/SKILL.md +25 -1
  189. package/skills/jevitate-getting-started/SKILL.md +9 -3
  190. package/skills/jevitate-mission-scope/SKILL.md +2 -2
  191. package/skills/jevitate-run-journey/SKILL.md +36 -5
  192. package/skills/jevitate-test-campaign/SKILL.md +13 -0
@@ -0,0 +1,203 @@
1
+ /**
2
+ * #453 change-aware self-heal: read a git range read-only and build a `ChangeScope`.
3
+ * The only IO is `git rev-parse` / `git merge-base` / `git diff`, always via an argv array, never a
4
+ * shell, and only resolved 40-hex SHAs ever reach `diff`. Raw diff text leaves only as `ChangeEvidence`.
5
+ */
6
+ import { execFile } from "node:child_process";
7
+ import { promisify } from "node:util";
8
+ import { extractChangeEvidence, isSkippedChangePath } from "@jevitate/runtime";
9
+ const execFileAsync = promisify(execFile);
10
+ const GIT_MAX_BUFFER = 8 * 1024 * 1024;
11
+ const DEFAULT_MAX_FILES = 400;
12
+ const DEFAULT_MAX_BYTES = 4_000_000;
13
+ const MAX_NOTES = 20;
14
+ const MAX_NOTE_LENGTH = 2000;
15
+ const SHA_RE = /^[0-9a-f]{40}$/;
16
+ // The whole range must be ref-safe characters; the separator, if any, is exactly `..` or `...`.
17
+ const RANGE_RE = /^([A-Za-z0-9._\/~^@{}-]{1,200})(?:(\.\.\.?)([A-Za-z0-9._\/~^@{}-]{1,200}))?$/;
18
+ /** The caller supplied a syntactically invalid range or note set. */
19
+ export class ChangesArgsError extends Error {
20
+ code = "E_CHANGES_ARGS";
21
+ constructor(message) {
22
+ super(message);
23
+ this.name = "ChangesArgsError";
24
+ }
25
+ }
26
+ /** Git could not resolve the range, or the diff is too large to surface. */
27
+ export class ChangesInputError extends Error {
28
+ code = "E_CHANGES_INPUT";
29
+ constructor(message) {
30
+ super(message);
31
+ this.name = "ChangesInputError";
32
+ }
33
+ }
34
+ /** The production git runner: argv only, no shell, optional locks and prompts disabled. */
35
+ export const execGitReadOnly = async (args, opts) => {
36
+ const result = await execFileAsync("git", args, {
37
+ cwd: opts.cwd,
38
+ env: { ...opts.env, GIT_OPTIONAL_LOCKS: "0", GIT_TERMINAL_PROMPT: "0" },
39
+ maxBuffer: GIT_MAX_BUFFER,
40
+ });
41
+ return { stdout: result.stdout };
42
+ };
43
+ /** Parse `a..b`, `a...b` (symmetric) or a single `X` (meaning `X..HEAD`) into its resolved ends. */
44
+ export function parseChangeRange(s) {
45
+ if (typeof s !== "string" || !RANGE_RE.test(s))
46
+ throw new ChangesArgsError(`invalid change range: ${JSON.stringify(s)}`);
47
+ const triple = s.indexOf("...");
48
+ const double = triple >= 0 ? -1 : s.indexOf("..");
49
+ let from;
50
+ let to;
51
+ let symmetric;
52
+ if (triple >= 0) {
53
+ from = s.slice(0, triple);
54
+ to = s.slice(triple + 3);
55
+ symmetric = true;
56
+ }
57
+ else if (double >= 0) {
58
+ from = s.slice(0, double);
59
+ to = s.slice(double + 2);
60
+ symmetric = false;
61
+ }
62
+ else {
63
+ from = s;
64
+ to = "HEAD";
65
+ symmetric = false;
66
+ }
67
+ for (const side of [from, to]) {
68
+ if (side === "" || side.startsWith("-") || side.includes("..") || side.includes("@{-")) {
69
+ throw new ChangesArgsError(`unsafe change range: ${JSON.stringify(s)}`);
70
+ }
71
+ }
72
+ return { from, to, symmetric };
73
+ }
74
+ /** Validate human change notes: at most 20, each 1-2000 chars, no NUL. Returns a copy. */
75
+ export function validateChangeNotes(notes) {
76
+ if (notes.length > MAX_NOTES)
77
+ throw new ChangesArgsError(`at most ${MAX_NOTES} change notes are allowed`);
78
+ for (const note of notes) {
79
+ if (note.length < 1 || note.length > MAX_NOTE_LENGTH || note.includes("\u0000")) {
80
+ throw new ChangesArgsError(`each change note must be 1-${MAX_NOTE_LENGTH} characters and contain no NUL`);
81
+ }
82
+ }
83
+ return [...notes];
84
+ }
85
+ /** Read a git range (and/or notes) and build the `ChangeScope` the healer consumes. */
86
+ export async function readChangeScope(o) {
87
+ const exec = o.exec ?? execGitReadOnly;
88
+ const notes = validateChangeNotes(o.notes);
89
+ const maxFiles = o.limits?.maxFiles ?? DEFAULT_MAX_FILES;
90
+ const maxBytes = o.limits?.maxBytes ?? DEFAULT_MAX_BYTES;
91
+ if (o.range === undefined) {
92
+ return { evidence: extractChangeEvidence("", notes), scanned: { files: 0, hunks: 0, skipped: [] } };
93
+ }
94
+ const { from, to, symmetric } = parseChangeRange(o.range);
95
+ const fromSha = await resolveCommit(exec, o.cwd, from);
96
+ const toSha = await resolveCommit(exec, o.cwd, to);
97
+ const baseSha = symmetric ? await mergeBase(exec, o.cwd, fromSha, toSha) : fromSha;
98
+ const headSha = toSha;
99
+ assertSha(baseSha);
100
+ assertSha(headSha);
101
+ const diffArgs = [
102
+ "-c",
103
+ "core.fsmonitor=",
104
+ "-c",
105
+ "diff.external=",
106
+ "diff",
107
+ "--no-ext-diff",
108
+ "--no-textconv",
109
+ "--no-color",
110
+ "-M",
111
+ "--unified=0",
112
+ `${baseSha}..${headSha}`,
113
+ "--",
114
+ ];
115
+ let stdout;
116
+ try {
117
+ ({ stdout } = await exec(diffArgs, { cwd: o.cwd, env: process.env }));
118
+ }
119
+ catch (error) {
120
+ throw new ChangesInputError(`git diff failed: ${messageOf(error)}`);
121
+ }
122
+ if (Buffer.byteLength(stdout, "utf8") > maxBytes) {
123
+ throw new ChangesInputError(`change diff exceeds ${maxBytes} bytes`);
124
+ }
125
+ const files = countDiffSections(stdout);
126
+ if (files > maxFiles) {
127
+ throw new ChangesInputError(`change diff touches ${files} files (max ${maxFiles})`);
128
+ }
129
+ return {
130
+ range: o.range,
131
+ baseSha,
132
+ headSha,
133
+ evidence: extractChangeEvidence(stdout, notes),
134
+ scanned: { files, hunks: countHunks(stdout), skipped: skippedPaths(stdout) },
135
+ };
136
+ }
137
+ async function resolveCommit(exec, cwd, rev) {
138
+ const args = ["rev-parse", "--verify", "--quiet", "--end-of-options", `${rev}^{commit}`];
139
+ let stdout;
140
+ try {
141
+ ({ stdout } = await exec(args, { cwd, env: process.env }));
142
+ }
143
+ catch (error) {
144
+ throw new ChangesInputError(`cannot resolve change revision ${JSON.stringify(rev)}: ${messageOf(error)}`);
145
+ }
146
+ const sha = stdout.trim();
147
+ if (!SHA_RE.test(sha))
148
+ throw new ChangesInputError(`cannot resolve change revision ${JSON.stringify(rev)}`);
149
+ return sha;
150
+ }
151
+ async function mergeBase(exec, cwd, shaA, shaB) {
152
+ let stdout;
153
+ try {
154
+ ({ stdout } = await exec(["merge-base", shaA, shaB], { cwd, env: process.env }));
155
+ }
156
+ catch (error) {
157
+ throw new ChangesInputError(`cannot find merge base: ${messageOf(error)}`);
158
+ }
159
+ const sha = stdout.trim();
160
+ if (!SHA_RE.test(sha))
161
+ throw new ChangesInputError(`cannot find merge base for ${shaA}...${shaB}`);
162
+ return sha;
163
+ }
164
+ function assertSha(sha) {
165
+ if (!SHA_RE.test(sha))
166
+ throw new ChangesInputError(`resolved revision is not a commit SHA: ${JSON.stringify(sha)}`);
167
+ }
168
+ function countDiffSections(diff) {
169
+ let count = 0;
170
+ for (const line of diff.split(/\r?\n/))
171
+ if (line.startsWith("diff --git "))
172
+ count++;
173
+ return count;
174
+ }
175
+ function countHunks(diff) {
176
+ let count = 0;
177
+ for (const line of diff.split(/\r?\n/))
178
+ if (line.startsWith("@@ "))
179
+ count++;
180
+ return count;
181
+ }
182
+ function skippedPaths(diff) {
183
+ const paths = [];
184
+ for (const line of diff.split(/\r?\n/)) {
185
+ const header = /^diff --git a\/(.*) b\/(.*)$/.exec(line);
186
+ if (!header)
187
+ continue;
188
+ const newPath = unquotePath(header[2]);
189
+ if (isSkippedChangePath(newPath))
190
+ paths.push(newPath);
191
+ }
192
+ return paths;
193
+ }
194
+ function unquotePath(value) {
195
+ const trimmed = value.trim();
196
+ if (trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"'))
197
+ return trimmed.slice(1, -1);
198
+ return trimmed;
199
+ }
200
+ function messageOf(error) {
201
+ return error instanceof Error ? error.message : String(error);
202
+ }
203
+ //# sourceMappingURL=change-context.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"change-context.js","sourceRoot":"","sources":["../src/change-context.ts"],"names":[],"mappings":"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"}
@@ -1,5 +1,5 @@
1
1
  import { type CheckResult, type RunCheckOptions } from "./check-types.js";
2
2
  export { affectedBy } from "./check-plan.js";
3
3
  export { BudgetMeter } from "./check-budget.js";
4
- export { type BudgetReport, CheckAiSetupError, type CheckFinding, type CheckGateways, type CheckItemReport, CheckPreflightError, type CheckResult, type CheckRunners, type ItemKind, type RunCheckOptions } from "./check-types.js";
4
+ export { type BudgetReport, CheckAiSetupError, CheckArgsError, type CheckFinding, type CheckGateways, type CheckItemReport, CheckPreflightError, type CheckResult, type CheckRunners, type ItemKind, type RunCheckOptions } from "./check-types.js";
5
5
  export declare function runCheck(opts: RunCheckOptions): Promise<CheckResult>;
package/dist/check-api.js CHANGED
@@ -1,8 +1,9 @@
1
+ import { readFileSync } from "node:fs";
1
2
  import { mkdir, writeFile } from "node:fs/promises";
2
3
  import { join, resolve } from "node:path";
3
4
  import { aggregateOf, formatUsageLine } from "@jevitate/ai-core";
4
5
  import { consolidate, diffRuns, findingKey, renderJUnit, renderReportMarkdown, renderSarif } from "@jevitate/findings";
5
- import { approvalsReport } from "./approval-provenance.js";
6
+ import { approvalsReportVerified } from "./approval-provenance.js";
6
7
  import { loadCatalog } from "./catalog-api.js";
7
8
  import { runAdversarialCliMission, runCoverageMission, runExploration, runFeatureCliMission } from "./explore-api.js";
8
9
  import { runJourneyProgrammatically } from "./journey-api.js";
@@ -10,14 +11,18 @@ import { runUsabilityMission } from "./ux-api.js";
10
11
  import { runVerifyFix } from "./verify-fix-api.js";
11
12
  import { currentEngineInfo } from "./engine.js";
12
13
  import { loadRunFile, resolveBaseline, scanRuns, summarizeRun } from "./report-api.js";
13
- import { CheckAiSetupError } from "./check-types.js";
14
+ import { CheckAiSetupError, CheckArgsError, CheckPreflightError } from "./check-types.js";
15
+ import { brittleStepGate, testIdAttributesOf, testIdAttributesOrDefault } from "./locator-health-api.js";
16
+ import { combineHealth, compareLocatorHealth, exceedsBrittleSteps, trendLine } from "./locator-health.js";
17
+ import { findProjectDir } from "./project-dir.js";
14
18
  import { BudgetMeter } from "./check-budget.js";
15
- import { errorMessage, plan, prepareTarget } from "./check-plan.js";
19
+ import { errorMessage, plan, prepareSelfHeal, prepareTarget } from "./check-plan.js";
20
+ import { makeEvidenceSelfHealer } from "./self-heal-adapter.js";
16
21
  import { caseDetail, execute } from "./check-execute.js";
17
22
  import { clock } from "@jevitate/domain";
18
23
  export { affectedBy } from "./check-plan.js";
19
24
  export { BudgetMeter } from "./check-budget.js";
20
- export { CheckAiSetupError, CheckPreflightError } from "./check-types.js";
25
+ export { CheckAiSetupError, CheckArgsError, CheckPreflightError } from "./check-types.js";
21
26
  const REAL_RUNNERS = {
22
27
  journey: runJourneyProgrammatically,
23
28
  goal: runExploration,
@@ -33,7 +38,8 @@ async function approvalDefects(opts, req) {
33
38
  const catalogs = [];
34
39
  for (const d of dirs)
35
40
  catalogs.push(await loadCatalog(req.catalogDir, d));
36
- const report = approvalsReport(catalogs, req.allowedChannels);
41
+ // #469: a recorded pr-review approval counts only when the forge re-confirms it.
42
+ const report = await approvalsReportVerified(catalogs, req.allowedChannels, { catalogDir: req.catalogDir, ...(opts.approvalVerification ?? {}) });
37
43
  const defects = (report.requirement?.violations ?? []).map((v) => {
38
44
  const identity = { category: "approval", signal: `approval-${v.problem}`, control: `${v.kind} ${v.id}` };
39
45
  const key = findingKey(identity);
@@ -41,6 +47,90 @@ async function approvalDefects(opts, req) {
41
47
  });
42
48
  return { defects };
43
49
  }
50
+ /**
51
+ * #470: the project's test-id convention for a check's Journey items. With the opt-in gate a malformed
52
+ * project config refuses the check (it would gate on a guessed convention); without it the defaults
53
+ * apply, as a run's result.json does.
54
+ */
55
+ function checkTestIdAttributes(opts) {
56
+ const projectDir = opts.projectDir === undefined ? findProjectDir() : opts.projectDir;
57
+ if (opts.maxBrittleSteps === undefined)
58
+ return testIdAttributesOrDefault(projectDir);
59
+ try {
60
+ return testIdAttributesOf(projectDir);
61
+ }
62
+ catch (e) {
63
+ throw new CheckPreflightError(`--max-brittle-steps needs the project's testIdAttributes convention: ${errorMessage(e)}`);
64
+ }
65
+ }
66
+ /** #470: the gate's finding for one Journey item past `--max-brittle-steps` (hard: it fails the check). */
67
+ function brittleDefect(journeyId, health, maxBrittleSteps) {
68
+ const identity = { category: "locator-health", signal: "brittle-steps", control: `journey ${journeyId}` };
69
+ const key = findingKey(identity);
70
+ const title = `Journey '${journeyId}': ${health.brittle} brittle locator step(s), more than --max-brittle-steps ${maxBrittleSteps}${health.line === undefined ? "" : ` (${health.line})`}`;
71
+ return { key, keys: [key], identity, category: "locator-health", severity: "hard", title, reproduce: `jevitate locator-health --journey ${journeyId}`, fingerprints: [], modes: [], occurrences: 1, runCount: 0, evidence: [], intermittent: false };
72
+ }
73
+ /** #470: the locator health a baseline run's result.json recorded, per Journey (the latest run of each wins). */
74
+ function baselineLocatorHealths(runs) {
75
+ const out = new Map();
76
+ for (const run of runs) {
77
+ if (run.mode !== "journey")
78
+ continue;
79
+ try {
80
+ const json = JSON.parse(readFileSync(run.path, "utf8"));
81
+ const h = json.result?.locatorHealth;
82
+ if (typeof json.result?.journeyId === "string" && typeof h?.brittle === "number" && Array.isArray(h.steps))
83
+ out.set(json.result.journeyId, h);
84
+ }
85
+ catch {
86
+ // An unreadable baseline run has no locator health to compare against.
87
+ }
88
+ }
89
+ return out;
90
+ }
91
+ /** #470: one trend over several Journeys (the counts summed). */
92
+ function sumTrends(trends) {
93
+ const sum = (f) => trends.reduce((n, t) => n + f(t), 0);
94
+ return {
95
+ improved: sum((t) => t.improved),
96
+ regressed: sum((t) => t.regressed),
97
+ unchanged: sum((t) => t.unchanged),
98
+ added: sum((t) => t.added),
99
+ removed: sum((t) => t.removed),
100
+ brittleDelta: sum((t) => t.brittleDelta),
101
+ changes: trends.flatMap((t) => t.changes),
102
+ };
103
+ }
104
+ const LOCATOR_WARNING_RULE = "jevitate/locator-health/brittle-locator";
105
+ /**
106
+ * #470: advisory locator fixes as SARIF WARNINGS (never errors, never gating) — one result per element
107
+ * to fix, located at the suite file with the route as its logical location.
108
+ */
109
+ function withLocatorWarnings(sarif, suggestions, suiteUri) {
110
+ if (suggestions.length === 0)
111
+ return sarif;
112
+ const [run, ...rest] = sarif.runs;
113
+ if (run === undefined)
114
+ return sarif;
115
+ const rule = { id: LOCATOR_WARNING_RULE, name: LOCATOR_WARNING_RULE.replace(/[^A-Za-z0-9]+/g, "_"), shortDescription: { text: "locator-health: a step's target misses the project's test-id convention" }, defaultConfiguration: { level: "warning" } };
116
+ const results = suggestions.map((s) => {
117
+ const journeys = [...new Set(s.occurrences.flatMap((o) => (o.journeyId === undefined ? [] : [o.journeyId])))];
118
+ return {
119
+ ruleId: LOCATOR_WARNING_RULE,
120
+ level: "warning",
121
+ message: { text: s.fix },
122
+ locations: [
123
+ {
124
+ physicalLocation: { artifactLocation: { uri: suiteUri }, region: { startLine: 1 } },
125
+ ...(s.route === undefined ? {} : { logicalLocations: [{ name: s.route, kind: "module" }] }),
126
+ },
127
+ ],
128
+ partialFingerprints: { jevitateFindingKey: `locator-health:${s.key}` },
129
+ properties: { category: "locator-health", severity: "advisory", gating: false, element: s.element, reasons: s.reasons, journeys, steps: s.occurrences.length },
130
+ };
131
+ });
132
+ return { ...sarif, runs: [{ ...run, tool: { ...run.tool, driver: { ...run.tool.driver, rules: [...run.tool.driver.rules, rule] } }, results: [...run.results, ...results] }, ...rest] };
133
+ }
44
134
  export async function runCheck(opts) {
45
135
  const nowIso = opts.nowIso ?? (() => clock.nowIso());
46
136
  const startedAt = nowIso();
@@ -53,6 +143,18 @@ export async function runCheck(opts) {
53
143
  for (const t of opts.suite.targets)
54
144
  prepared.push(await prepareTarget(t, opts));
55
145
  const items = plan(prepared, opts.changedRoutes, opts);
146
+ // #453: refuse a bad self-heal request and read the change scope ONCE, before the first browser opens.
147
+ const heal = await prepareSelfHeal(opts);
148
+ // #470: the opt-in locator gate and the convention it measures against, before anything runs.
149
+ if (opts.maxBrittleSteps !== undefined) {
150
+ try {
151
+ brittleStepGate(opts.maxBrittleSteps);
152
+ }
153
+ catch (e) {
154
+ throw new CheckArgsError(errorMessage(e));
155
+ }
156
+ }
157
+ const testIdAttributes = items.some((i) => i.kind === "journey" && i.skipped === undefined) ? checkTestIdAttributes(opts) : undefined;
56
158
  let gw;
57
159
  const gateways = () => {
58
160
  if (opts.gateways === undefined)
@@ -67,7 +169,17 @@ export async function runCheck(opts) {
67
169
  const meter = new BudgetMeter(opts.suite.budget, { ...(opts.now === undefined ? {} : { now: opts.now }), costKnownZero: opts.aiMode === "fake" });
68
170
  const usage = async () => (gw === undefined ? undefined : (await gw).usage.snapshot());
69
171
  let n = 0;
70
- const ctx = { opts, runners, resultsDir, gateways, engine, seq: () => String(++n) };
172
+ let healer;
173
+ const ctx = {
174
+ opts,
175
+ runners,
176
+ resultsDir,
177
+ gateways,
178
+ engine,
179
+ seq: () => String(++n),
180
+ ...(testIdAttributes === undefined ? {} : { testIdAttributes }),
181
+ ...(heal === undefined ? {} : { heal, healer: () => (healer ??= gateways().then((g) => makeEvidenceSelfHealer(g.gen, { usage: g.usage }))) }),
182
+ };
71
183
  const now = opts.now ?? clock.now;
72
184
  let exceeded;
73
185
  const executed = [];
@@ -109,7 +221,17 @@ export async function runCheck(opts) {
109
221
  }
110
222
  // #437 --require-approvals: each violation is a hard `approval` finding on the `approvals` item.
111
223
  const approvals = opts.requireApprovals === undefined ? undefined : await approvalDefects(opts, opts.requireApprovals);
112
- const defects = [...consolidate(runs), ...(approvals?.defects ?? [])];
224
+ // #470 --max-brittle-steps: a Journey item past the threshold is a hard `locator-health` finding on that item.
225
+ const brittleOf = new Map();
226
+ if (opts.maxBrittleSteps !== undefined) {
227
+ executed.forEach(({ item, ex }, idx) => {
228
+ const health = ex?.locatorHealth;
229
+ if (item.kind === "journey" && health !== undefined && exceedsBrittleSteps(health, opts.maxBrittleSteps)) {
230
+ brittleOf.set(idx, brittleDefect(item.name, { brittle: health.brittle, ...(ex?.locatorDetail === undefined ? {} : { line: ex.locatorDetail.line }) }, opts.maxBrittleSteps));
231
+ }
232
+ });
233
+ }
234
+ const defects = [...consolidate(runs), ...(approvals?.defects ?? []), ...brittleOf.values()];
113
235
  let diff;
114
236
  let baselineRuns;
115
237
  if (opts.baseline !== undefined) {
@@ -123,6 +245,9 @@ export async function runCheck(opts) {
123
245
  }
124
246
  const entryOf = new Map(diff?.entries.map((e) => [e.key, e]) ?? []);
125
247
  const isGating = (d) => {
248
+ // #453: a proposed Journey revision (`pending`) is never a defect, whatever `gateAdvisory` says: it makes the check exit 5.
249
+ if (d.severity === "pending")
250
+ return false;
126
251
  if (d.severity !== "hard" && !opts.suite.gateAdvisory)
127
252
  return false;
128
253
  if (diff === undefined)
@@ -133,7 +258,7 @@ export async function runCheck(opts) {
133
258
  };
134
259
  const statusOf = (d) => d.keys.map((k) => entryOf.get(k)?.status).find((s) => s !== undefined);
135
260
  const gating = defects.filter(isGating);
136
- const itemReports = executed.map(({ item, ex, durationMs }) => {
261
+ const itemReports = executed.map(({ item, ex, durationMs }, idx) => {
137
262
  const base = {
138
263
  target: item.t.target.name,
139
264
  kind: item.kind,
@@ -144,10 +269,18 @@ export async function runCheck(opts) {
144
269
  if (ex === undefined)
145
270
  return { ...base, status: "skipped", actions: 0, verdict: "skipped", gating: [] };
146
271
  const run = ex.resultPath === undefined ? undefined : runOf.get(ex.resultPath);
147
- const own = run === undefined ? [] : gating.filter((d) => d.modes.some((m) => m.runs.some((r) => r.path === run.path)));
148
- const verdict = ex.status === "error" ? "error" : own.length > 0 ? "failed" : "passed";
272
+ const brittle = brittleOf.get(idx);
273
+ const own = [
274
+ ...(run === undefined ? [] : gating.filter((d) => d.modes.some((m) => m.runs.some((r) => r.path === run.path)))),
275
+ ...(brittle !== undefined && gating.includes(brittle) ? [brittle] : []),
276
+ ];
277
+ // #453: a self-healed Journey that only proposed a revision is pending a person (never a pass).
278
+ const pending = ex.status === "ran" && ex.outcome === "healed-pending-review" && own.length === 0;
279
+ const verdict = ex.status === "error" ? "error" : own.length > 0 ? "failed" : pending ? "pending-review" : "passed";
149
280
  return {
150
281
  ...base,
282
+ ...(pending ? { proposal: { journeyId: item.name, ...(ex.proposal ?? {}) } } : {}),
283
+ ...(ex.healAttempts === undefined ? {} : { healAttempts: ex.healAttempts }),
151
284
  status: ex.status,
152
285
  actions: ex.actions,
153
286
  verdict,
@@ -157,19 +290,60 @@ export async function runCheck(opts) {
157
290
  ...(run === undefined ? {} : { runId: run.runId }),
158
291
  ...(ex.outcome === undefined ? {} : { outcome: ex.outcome }),
159
292
  ...(ex.goalOutcome === undefined ? {} : { goalOutcome: ex.goalOutcome }),
293
+ ...(ex.journeyHash === undefined ? {} : { journeyHash: ex.journeyHash }),
294
+ // #469: a baseline only from a clean run — a completed replay with no gating finding.
295
+ ...(ex.baseline === undefined || verdict !== "passed" ? {} : { baseline: ex.baseline }),
296
+ ...(ex.locatorHealth === undefined ? {} : { locatorHealth: ex.locatorHealth }),
160
297
  };
161
298
  });
299
+ // #470: locator health over the Journey items (advisory unless the gate is set), and its trend against --baseline.
300
+ const details = executed.flatMap(({ item, ex }) => item.kind === "journey" && ex?.locatorDetail !== undefined ? [{ name: item.name, detail: ex.locatorDetail }] : []);
301
+ const combined = combineHealth(details.map((d) => d.detail));
302
+ let locatorTrend;
303
+ if (baselineRuns !== undefined && details.length > 0) {
304
+ const before = baselineLocatorHealths(baselineRuns);
305
+ const trends = executed.flatMap(({ item, ex }) => {
306
+ const b = item.kind === "journey" && ex?.locatorHealth !== undefined ? before.get(item.name) : undefined;
307
+ return b === undefined || ex?.locatorHealth === undefined ? [] : [compareLocatorHealth(ex.locatorHealth, b)];
308
+ });
309
+ if (trends.length > 0) {
310
+ const t = sumTrends(trends);
311
+ locatorTrend = { improved: t.improved, regressed: t.regressed, unchanged: t.unchanged, added: t.added, removed: t.removed, brittleDelta: t.brittleDelta, line: trendLine(t) };
312
+ }
313
+ }
314
+ const locatorHealth = details.length === 0 && opts.maxBrittleSteps === undefined
315
+ ? undefined
316
+ : {
317
+ journeys: details.length,
318
+ stable: combined.stable,
319
+ brittle: combined.brittle,
320
+ ...(testIdAttributes === undefined ? {} : { testIdAttributes }),
321
+ ...(opts.maxBrittleSteps === undefined ? {} : { maxBrittleSteps: opts.maxBrittleSteps, exceeded: brittleOf.size > 0 }),
322
+ ...(details.length === 0 ? {} : { line: combined.line }),
323
+ ...(locatorTrend === undefined ? {} : { trend: locatorTrend }),
324
+ };
325
+ // Without the gate, each Journey item's fixes are JUnit/SARIF warnings (never failures).
326
+ const warningsOf = (i, idx) => {
327
+ if (opts.maxBrittleSteps !== undefined || i.kind !== "journey")
328
+ return [];
329
+ const ex = executed[idx]?.ex;
330
+ return (ex?.locatorDetail?.suggestions ?? []).map((s) => s.fix);
331
+ };
162
332
  if (approvals !== undefined) {
163
333
  const own = gating.filter((d) => approvals.defects.includes(d)).map((d) => d.key);
164
334
  itemReports.push({ target: "approvals", kind: "approvals", name: "require-approvals", status: "ran", actions: 0, durationMs: 0, verdict: own.length > 0 ? "failed" : "passed", gating: own });
165
335
  }
166
336
  const errors = itemReports.filter((i) => i.verdict === "error").length;
167
- const exitCode = gating.length > 0 ? 1 : errors > 0 || exceeded !== undefined ? 2 : 0;
337
+ const pendingItems = itemReports.filter((i) => i.verdict === "pending-review");
338
+ const proposals = pendingItems.flatMap((i) => (i.proposal === undefined ? [] : [i.proposal]));
339
+ const gatingFindings = gating.length;
340
+ // Precedence 1 > 2 > 5 > 0: a defect or exhausted heal, then an item error / budget overrun, then a proposal awaiting review.
341
+ const exitCode = gatingFindings > 0 ? 1 : errors > 0 || exceeded !== undefined ? 2 : pendingItems.length > 0 ? 5 : 0;
168
342
  const junitPath = resolve(opts.junitPath ?? join(outDir, "junit.xml"));
169
343
  const sarifPath = resolve(opts.sarifPath ?? join(outDir, "jevitate.sarif"));
170
344
  const jsonPath = resolve(opts.jsonPath ?? join(outDir, "check.json"));
171
345
  const reportPath = join(outDir, "report.md");
172
- const cases = itemReports.map((i) => {
346
+ const cases = itemReports.map((i, idx) => {
173
347
  const own = gating.filter((d) => i.gating.includes(d.key));
174
348
  const first = own[0];
175
349
  // #250: each gating finding's repro clip and screenshots, attached to the case CI shows.
@@ -184,6 +358,14 @@ export async function runCheck(opts) {
184
358
  status: i.verdict,
185
359
  ...(i.resultPath === undefined ? {} : { resultPath: i.resultPath }),
186
360
  ...(attachments.length === 0 ? {} : { attachments }),
361
+ ...(warningsOf(i, idx).length === 0 ? {} : { warnings: warningsOf(i, idx) }),
362
+ ...(i.verdict === "pending-review"
363
+ ? {
364
+ type: "healed-pending-review",
365
+ message: `proposed revision${i.proposal?.proposalId === undefined ? "" : ` ${i.proposal.proposalId}`} awaiting review`,
366
+ ...(i.proposal?.path === undefined ? {} : { detail: i.proposal.path }),
367
+ }
368
+ : {}),
187
369
  ...(i.verdict === "failed" && first !== undefined
188
370
  ? { type: first.category, message: `${own.length} gating finding(s): ${first.title}`, detail: caseDetail(own) }
189
371
  : {}),
@@ -214,7 +396,7 @@ export async function runCheck(opts) {
214
396
  kind: "jevitate-check",
215
397
  suite: opts.suite.name,
216
398
  suitePath: opts.suite.path,
217
- verdict: exitCode === 0 ? "pass" : "fail",
399
+ verdict: exitCode === 0 ? "pass" : exitCode === 5 ? "pending-review" : "fail",
218
400
  exitCode,
219
401
  engine,
220
402
  ...(opts.targetBuild === undefined ? {} : { targetBuild: opts.targetBuild }),
@@ -229,8 +411,11 @@ export async function runCheck(opts) {
229
411
  failed: itemReports.filter((i) => i.verdict === "failed").length,
230
412
  errors,
231
413
  skipped: itemReports.filter((i) => i.verdict === "skipped").length,
232
- gatingFindings: gating.length,
414
+ gatingFindings,
415
+ pendingReview: pendingItems.length,
233
416
  },
417
+ ...(locatorHealth === undefined ? {} : { locatorHealth }),
418
+ proposals,
234
419
  ...(diff === undefined || baselineRuns === undefined ? {} : { diff: { baseline: baselineRuns.map(summarizeRun), summary: diff.summary } }),
235
420
  results: runs.map((r) => r.path),
236
421
  junitPath,
@@ -239,16 +424,21 @@ export async function runCheck(opts) {
239
424
  reportPath,
240
425
  };
241
426
  await writeFile(junitPath, renderJUnit(`jevitate check: ${opts.suite.name}`, cases, startedAt), "utf8");
242
- const sarif = renderSarif({
427
+ const sarif = withLocatorWarnings(renderSarif({
243
428
  toolVersion: engine.version,
244
429
  engineCommit: engine.commit,
245
430
  ...(opts.targetBuild === undefined ? {} : { targetBuild: opts.targetBuild }),
246
431
  suiteUri: opts.suiteUri ?? opts.suite.path,
247
432
  automationId: `jevitate-check/${opts.suite.name}/`,
248
433
  findings: defects.map((d) => ({ defect: d, gating: gating.includes(d), ...(statusOf(d) === undefined ? {} : { status: statusOf(d) }) })),
249
- });
434
+ }), opts.maxBrittleSteps === undefined ? combined.suggestions : [], opts.suiteUri ?? opts.suite.path);
250
435
  await writeFile(sarifPath, `${JSON.stringify(sarif, null, 2)}\n`, "utf8");
251
436
  await writeFile(reportPath, renderReportMarkdown({ title: `jevitate check: ${opts.suite.name} — ${result.verdict}`, runs, defects, ...(diff === undefined ? {} : { diff }) }) +
437
+ (proposals.length === 0
438
+ ? ""
439
+ : `\n## Proposed Journey revisions\n\nA self-heal re-ran these failed Journeys against the change and proposed a revision. Nothing passes until a person accepts it.\n\n${proposals
440
+ .map((p) => `- \`${p.journeyId}\`${p.proposalId === undefined ? "" : ` — proposal \`${p.proposalId}\``}${p.path === undefined ? "" : ` (${p.path})`}: \`jevitate journey review ${p.journeyId}\``)
441
+ .join("\n")}\n`) +
252
442
  (suiteUsage === undefined ? "" : `\n## Model cost\n\n${formatUsageLine(suiteUsage)}\n`), "utf8");
253
443
  await writeFile(jsonPath, `${JSON.stringify({ v: 1, ok: true, data: result }, null, 2)}\n`, "utf8");
254
444
  return result;