@gfdelarue/testkit 0.3.7 → 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.
Files changed (258) hide show
  1. package/README.md +125 -0
  2. package/dist/app/bugs/areas.d.ts +38 -0
  3. package/dist/app/bugs/areas.js +1 -0
  4. package/dist/app/bugs/assessment.d.ts +9 -0
  5. package/dist/app/bugs/assessment.js +1 -0
  6. package/dist/app/bugs/categories.d.ts +41 -0
  7. package/dist/app/bugs/categories.js +1 -0
  8. package/dist/app/bugs/ci-report.d.ts +21 -0
  9. package/dist/app/bugs/ci-report.js +2 -0
  10. package/dist/app/bugs/ci-summary.d.ts +13 -0
  11. package/dist/app/bugs/ci-summary.js +5 -0
  12. package/dist/app/bugs/draft.d.ts +70 -0
  13. package/dist/app/bugs/draft.js +4 -0
  14. package/dist/app/bugs/facts.d.ts +48 -0
  15. package/dist/app/bugs/facts.js +2 -0
  16. package/dist/app/bugs/file.d.ts +45 -0
  17. package/dist/app/bugs/file.js +1 -0
  18. package/dist/app/bugs/gate.d.ts +22 -0
  19. package/dist/app/bugs/gate.js +1 -0
  20. package/dist/app/bugs/guide.d.ts +20 -0
  21. package/dist/app/bugs/guide.js +8 -0
  22. package/dist/app/bugs/issue.d.ts +18 -0
  23. package/dist/app/bugs/issue.js +6 -0
  24. package/dist/app/bugs/known-failures.d.ts +49 -0
  25. package/dist/app/bugs/known-failures.js +1 -0
  26. package/dist/app/bugs/record.d.ts +14 -0
  27. package/dist/app/bugs/record.js +2 -0
  28. package/dist/app/bugs/run-reading.d.ts +51 -0
  29. package/dist/app/bugs/run-reading.js +1 -0
  30. package/dist/app/bugs/store.d.ts +34 -0
  31. package/dist/app/bugs/store.js +2 -0
  32. package/dist/app/campaigns/brief.d.ts +7 -0
  33. package/dist/app/campaigns/brief.js +10 -0
  34. package/dist/app/campaigns/campaign.d.ts +14 -0
  35. package/dist/app/campaigns/campaign.js +1 -0
  36. package/dist/app/campaigns/index.d.ts +38 -0
  37. package/dist/app/campaigns/index.js +2 -0
  38. package/dist/app/campaigns/store.d.ts +5 -0
  39. package/dist/app/campaigns/store.js +1 -0
  40. package/dist/app/campaigns/summary.d.ts +13 -0
  41. package/dist/app/campaigns/summary.js +8 -0
  42. package/dist/app/run/engine-execution.d.ts +1 -0
  43. package/dist/app/run/engine-execution.js +1 -1
  44. package/dist/app/run/failure-details.d.ts +1 -0
  45. package/dist/app/run/failure-details.js +1 -1
  46. package/dist/app/tracker/index.d.ts +8 -0
  47. package/dist/app/tracker/index.js +1 -0
  48. package/dist/app/tracker/remote.d.ts +13 -0
  49. package/dist/app/tracker/remote.js +1 -0
  50. package/dist/app/tracker/token.d.ts +7 -0
  51. package/dist/app/tracker/token.js +5 -0
  52. package/dist/cli/command-catalog.d.ts +21 -122
  53. package/dist/cli/command-catalog.js +1 -1
  54. package/dist/cli/command-registry.d.ts +1 -0
  55. package/dist/cli/command-registry.js +1 -1
  56. package/dist/cli/commands/bug/file.d.ts +16 -0
  57. package/dist/cli/commands/bug/file.js +2 -0
  58. package/dist/cli/commands/bug/guide.d.ts +5 -0
  59. package/dist/cli/commands/bug/guide.js +1 -0
  60. package/dist/cli/commands/bug/record.d.ts +9 -0
  61. package/dist/cli/commands/bug/record.js +2 -0
  62. package/dist/cli/commands/campaign/finish.d.ts +10 -0
  63. package/dist/cli/commands/campaign/finish.js +1 -0
  64. package/dist/cli/commands/campaign/start.d.ts +7 -0
  65. package/dist/cli/commands/campaign/start.js +1 -0
  66. package/dist/cli/commands/campaign/status.d.ts +13 -0
  67. package/dist/cli/commands/campaign/status.js +2 -0
  68. package/dist/cli/commands/ci/report.d.ts +15 -0
  69. package/dist/cli/commands/ci/report.js +2 -0
  70. package/dist/cli/commands/run.d.ts +1 -1
  71. package/dist/cli/commands/run.js +2 -1
  72. package/dist/cli/entrypoint.js +1 -1
  73. package/dist/cli/framework/execute.d.ts +1 -0
  74. package/dist/cli/framework/execute.js +6 -6
  75. package/dist/cli/index.js +1 -1
  76. package/dist/cli/renderers/bug/text.d.ts +8 -0
  77. package/dist/cli/renderers/bug/text.js +1 -0
  78. package/dist/cli/renderers/campaign/text.d.ts +2 -0
  79. package/dist/cli/renderers/campaign/text.js +1 -0
  80. package/dist/cli/renderers/ci/text.d.ts +2 -0
  81. package/dist/cli/renderers/ci/text.js +1 -0
  82. package/dist/cli/version-drift.js +7 -7
  83. package/dist/config/authoring/index.d.ts +10 -5
  84. package/dist/config/loader/bugs.d.ts +15 -0
  85. package/dist/config/loader/bugs.js +1 -0
  86. package/dist/config/loader/index.d.ts +0 -1
  87. package/dist/config/loader/index.js +1 -1
  88. package/dist/engines/adapters/execution.d.ts +4 -0
  89. package/dist/engines/adapters/execution.js +1 -1
  90. package/dist/engines/k6/adapter/executor.js +1 -1
  91. package/dist/engines/k6/runtime/checks.js +1 -1
  92. package/dist/engines/k6/runtime/http-assertions.d.ts +2 -2
  93. package/dist/engines/k6/runtime/http-assertions.js +1 -1
  94. package/dist/engines/k6/runtime/types.d.ts +2 -0
  95. package/dist/engines/node-vitest/adapter/evidence.js +2 -2
  96. package/dist/engines/node-vitest/adapter/failure-details.d.ts +1 -0
  97. package/dist/engines/node-vitest/adapter/failure-details.js +2 -2
  98. package/dist/engines/playwright/adapter/adapter.js +1 -1
  99. package/dist/engines/playwright/adapter/attachments.d.ts +1 -0
  100. package/dist/engines/playwright/adapter/config.js +3 -3
  101. package/dist/engines/playwright/adapter/evidence.js +1 -1
  102. package/dist/engines/playwright/adapter/reporter.js +1 -1
  103. package/dist/evidence/bundles.d.ts +2 -3
  104. package/dist/evidence/bundles.js +1 -1
  105. package/dist/evidence/contracts.d.ts +0 -8
  106. package/dist/evidence/index.d.ts +1 -1
  107. package/dist/evidence/index.js +1 -1
  108. package/dist/kernel/redaction.d.ts +1 -0
  109. package/dist/kernel/redaction.js +1 -1
  110. package/dist/process/execa-bundle.json +1 -1
  111. package/dist/process/files/json-file.d.ts +1 -0
  112. package/dist/process/files/json-file.js +1 -1
  113. package/dist/repo/history.d.ts +17 -0
  114. package/dist/repo/history.js +4 -0
  115. package/dist/repo/tree-listing.d.ts +10 -0
  116. package/dist/repo/tree-listing.js +1 -0
  117. package/dist/tracker/gitea.d.ts +14 -0
  118. package/dist/tracker/gitea.js +1 -0
  119. package/dist/tracker/github.d.ts +14 -0
  120. package/dist/tracker/github.js +1 -0
  121. package/dist/tracker/http.d.ts +33 -0
  122. package/dist/tracker/http.js +1 -0
  123. package/dist/tracker/index.d.ts +4 -0
  124. package/dist/tracker/index.js +1 -0
  125. package/dist/tracker/redaction.d.ts +4 -0
  126. package/dist/tracker/redaction.js +2 -0
  127. package/dist/tracker/requests.d.ts +17 -0
  128. package/dist/tracker/requests.js +1 -0
  129. package/dist/tracker/responses.d.ts +24 -0
  130. package/dist/tracker/responses.js +1 -0
  131. package/dist/tracker/types.d.ts +51 -0
  132. package/docs/bug-records.md +303 -0
  133. package/package.json +4 -10
  134. package/testkit-config-schema.md +50 -23
  135. package/dist/app/cloud/campaigns.d.ts +0 -6
  136. package/dist/app/cloud/campaigns.js +0 -1
  137. package/dist/app/cloud/control-actions.d.ts +0 -12
  138. package/dist/app/cloud/control-actions.js +0 -2
  139. package/dist/app/cloud/control-links.d.ts +0 -5
  140. package/dist/app/cloud/control-links.js +0 -1
  141. package/dist/app/cloud/control-read.d.ts +0 -24
  142. package/dist/app/cloud/control-read.js +0 -1
  143. package/dist/app/cloud/control-resources.d.ts +0 -3
  144. package/dist/app/cloud/control-resources.js +0 -1
  145. package/dist/app/cloud/control.d.ts +0 -34
  146. package/dist/app/cloud/control.js +0 -5
  147. package/dist/app/cloud/credentials.d.ts +0 -11
  148. package/dist/app/cloud/credentials.js +0 -2
  149. package/dist/app/cloud/index.d.ts +0 -22
  150. package/dist/app/cloud/index.js +0 -2
  151. package/dist/app/cloud/login.d.ts +0 -63
  152. package/dist/app/cloud/login.js +0 -2
  153. package/dist/app/cloud/migration/archive.d.ts +0 -13
  154. package/dist/app/cloud/migration/archive.js +0 -1
  155. package/dist/app/cloud/migration/commands.d.ts +0 -8
  156. package/dist/app/cloud/migration/commands.js +0 -1
  157. package/dist/app/cloud/migration/evidence.d.ts +0 -13
  158. package/dist/app/cloud/migration/evidence.js +0 -2
  159. package/dist/app/cloud/migration/files.d.ts +0 -16
  160. package/dist/app/cloud/migration/files.js +0 -2
  161. package/dist/app/cloud/migration/index.d.ts +0 -6
  162. package/dist/app/cloud/migration/index.js +0 -1
  163. package/dist/app/cloud/migration/journal.d.ts +0 -64
  164. package/dist/app/cloud/migration/journal.js +0 -1
  165. package/dist/app/cloud/migration/mapping-dispositions.d.ts +0 -3
  166. package/dist/app/cloud/migration/mapping-dispositions.js +0 -1
  167. package/dist/app/cloud/migration/mapping.d.ts +0 -11
  168. package/dist/app/cloud/migration/mapping.js +0 -2
  169. package/dist/app/cloud/migration/model.d.ts +0 -175
  170. package/dist/app/cloud/migration/plan.d.ts +0 -7
  171. package/dist/app/cloud/migration/plan.js +0 -1
  172. package/dist/app/cloud/migration/progress.d.ts +0 -19
  173. package/dist/app/cloud/migration/progress.js +0 -1
  174. package/dist/app/cloud/migration/reconcile.d.ts +0 -22
  175. package/dist/app/cloud/migration/reconcile.js +0 -1
  176. package/dist/app/cloud/migration/reconciliation-spool.d.ts +0 -4
  177. package/dist/app/cloud/migration/reconciliation-spool.js +0 -1
  178. package/dist/app/cloud/migration/state.d.ts +0 -15
  179. package/dist/app/cloud/migration/state.js +0 -1
  180. package/dist/app/cloud/migration/transfer.d.ts +0 -6
  181. package/dist/app/cloud/migration/transfer.js +0 -1
  182. package/dist/cli/cloud-apply-help.d.ts +0 -1
  183. package/dist/cli/cloud-apply-help.js +0 -56
  184. package/dist/cli/cloud-selection.d.ts +0 -2
  185. package/dist/cli/cloud-selection.js +0 -3
  186. package/dist/cli/commands/cloud/apply.d.ts +0 -5
  187. package/dist/cli/commands/cloud/apply.js +0 -1
  188. package/dist/cli/commands/cloud/campaign/create.d.ts +0 -5
  189. package/dist/cli/commands/cloud/campaign/create.js +0 -1
  190. package/dist/cli/commands/cloud/download.d.ts +0 -5
  191. package/dist/cli/commands/cloud/download.js +0 -1
  192. package/dist/cli/commands/cloud/list.d.ts +0 -5
  193. package/dist/cli/commands/cloud/list.js +0 -1
  194. package/dist/cli/commands/cloud/login.d.ts +0 -13
  195. package/dist/cli/commands/cloud/login.js +0 -2
  196. package/dist/cli/commands/cloud/logout.d.ts +0 -7
  197. package/dist/cli/commands/cloud/logout.js +0 -1
  198. package/dist/cli/commands/cloud/migrate/apply.d.ts +0 -2
  199. package/dist/cli/commands/cloud/migrate/apply.js +0 -1
  200. package/dist/cli/commands/cloud/migrate/plan.d.ts +0 -7
  201. package/dist/cli/commands/cloud/migrate/plan.js +0 -1
  202. package/dist/cli/commands/cloud/migrate/progress.d.ts +0 -2
  203. package/dist/cli/commands/cloud/migrate/progress.js +0 -2
  204. package/dist/cli/commands/cloud/migrate/reconcile.d.ts +0 -2
  205. package/dist/cli/commands/cloud/migrate/reconcile.js +0 -1
  206. package/dist/cli/commands/cloud/policy/preview.d.ts +0 -5
  207. package/dist/cli/commands/cloud/policy/preview.js +0 -1
  208. package/dist/cli/commands/cloud/project/create.d.ts +0 -5
  209. package/dist/cli/commands/cloud/project/create.js +0 -1
  210. package/dist/cli/commands/cloud/publish.d.ts +0 -7
  211. package/dist/cli/commands/cloud/publish.js +0 -1
  212. package/dist/cli/commands/cloud/session/revoke.d.ts +0 -8
  213. package/dist/cli/commands/cloud/session/revoke.js +0 -1
  214. package/dist/cli/commands/cloud/show.d.ts +0 -5
  215. package/dist/cli/commands/cloud/show.js +0 -1
  216. package/dist/cli/commands/cloud/snapshot.d.ts +0 -5
  217. package/dist/cli/commands/cloud/snapshot.js +0 -1
  218. package/dist/cli/commands/cloud/status.d.ts +0 -33
  219. package/dist/cli/commands/cloud/status.js +0 -1
  220. package/dist/cloud/account.d.ts +0 -34
  221. package/dist/cloud/account.js +0 -1
  222. package/dist/cloud/archive-contract.d.ts +0 -1
  223. package/dist/cloud/archive-contract.js +0 -1
  224. package/dist/cloud/archive.d.ts +0 -12
  225. package/dist/cloud/archive.js +0 -1
  226. package/dist/cloud/campaigns.d.ts +0 -200
  227. package/dist/cloud/campaigns.js +0 -2
  228. package/dist/cloud/client.d.ts +0 -68
  229. package/dist/cloud/client.js +0 -4
  230. package/dist/cloud/download.d.ts +0 -5
  231. package/dist/cloud/download.js +0 -1
  232. package/dist/cloud/historical-contract.d.ts +0 -224
  233. package/dist/cloud/historical-contract.js +0 -1
  234. package/dist/cloud/historical-json.d.ts +0 -16
  235. package/dist/cloud/historical-json.js +0 -1
  236. package/dist/cloud/historical.d.ts +0 -69
  237. package/dist/cloud/historical.js +0 -1
  238. package/dist/cloud/pagination.d.ts +0 -5
  239. package/dist/cloud/pagination.js +0 -1
  240. package/dist/cloud/transport.d.ts +0 -18
  241. package/dist/cloud/transport.js +0 -1
  242. package/dist/config/loader/cloud.d.ts +0 -2
  243. package/dist/config/loader/cloud.js +0 -1
  244. package/docs/cloud-control.md +0 -108
  245. package/docs/testkit-cloud-boundary.md +0 -628
  246. package/node_modules/buffer-crc32/LICENSE +0 -19
  247. package/node_modules/buffer-crc32/README.md +0 -55
  248. package/node_modules/buffer-crc32/dist/index.cjs +0 -306
  249. package/node_modules/buffer-crc32/dist/index.d.cts +0 -35
  250. package/node_modules/buffer-crc32/dist/index.d.mts +0 -35
  251. package/node_modules/buffer-crc32/dist/index.mjs +0 -304
  252. package/node_modules/buffer-crc32/index.d.ts +0 -35
  253. package/node_modules/buffer-crc32/package.json +0 -54
  254. package/node_modules/yazl/LICENSE +0 -21
  255. package/node_modules/yazl/README.md +0 -457
  256. package/node_modules/yazl/index.js +0 -826
  257. package/node_modules/yazl/package.json +0 -34
  258. /package/dist/{app/cloud/migration/model.js → tracker/types.js} +0 -0
package/README.md CHANGED
@@ -49,6 +49,131 @@ Testkit writes the immutable, verifiable run bundle under
49
49
  `.testkit/results/runs/<run-id>/`. Upload `.testkit/results` with `if: always()`
50
50
  when CI evidence should survive a failed job.
51
51
 
52
+ ## Report a run to GitHub or Gitea
53
+
54
+ `testkit ci report` reads the last run in the checkout and posts it in three
55
+ places: one commit status, the job's step summary, and one pull request comment
56
+ that it updates in place.
57
+
58
+ ```yaml
59
+ permissions:
60
+ contents: read
61
+ issues: read
62
+ pull-requests: write
63
+ statuses: write
64
+
65
+ steps:
66
+ - run: npm ci
67
+ - run: npm run testkit
68
+ - run: npx testkit ci report
69
+ if: always()
70
+ env:
71
+ GITHUB_TOKEN: ${{ github.token }}
72
+ ```
73
+
74
+ - The `testkit run` step decides whether the job fails. `ci report` exits zero
75
+ when it posts the report, and that includes the report of a failed run.
76
+ - GitHub Actions passes the job's token to a `run` step only when the step sets
77
+ it, as the `env` lines above do. Gitea Actions accepts the same lines.
78
+ - Testkit reads the token from `GITHUB_TOKEN` or `GH_TOKEN` for GitHub, and
79
+ from `GITEA_TOKEN` for Gitea. In a Gitea Actions job it also reads
80
+ `GITHUB_TOKEN`. With no token set, it asks `git credential fill`. The token
81
+ that `actions/checkout` keeps in the checkout does not answer that request.
82
+ - Testkit reaches GitHub Enterprise Server only from a GitHub Actions job on
83
+ that server, so `testkit bug file` works there only in a job.
84
+ - GitHub gives a pull request from a fork a read-only token. `ci report` then
85
+ writes the step summary and fails when it posts the status. Add
86
+ `continue-on-error: true` to the step when the repository takes pull requests
87
+ from forks.
88
+ - When the checkout holds no results for its commit, `ci report` posts a
89
+ `failure` status with the description
90
+ `run failed: Testkit found no results for this commit`.
91
+ - A job that runs Testkit more than once passes `--context <name>` to each
92
+ report, so each run keeps its own status and its own comment.
93
+
94
+ ## File a bug as an issue
95
+
96
+ Testkit files a confirmed bug as an issue in the repository's GitHub or Gitea.
97
+ The issue names the regression test that fails because of the bug, the commit
98
+ that introduced the faulty lines, and the command that reproduces the failure.
99
+ [Bug records](docs/bug-records.md) gives the issue format.
100
+
101
+ 1. Write a Testkit test that fails because of the bug.
102
+ 2. Write the bug record as a JSON file. `testkit bug guide` prints the fields
103
+ and the cause, impact, and escape lists.
104
+ 3. Run `testkit bug record bug.json`. It checks the record, stores a draft under
105
+ `.testkit/bugs/`, and prints the draft id.
106
+ 4. Commit the test and push the commit.
107
+ 5. Run `testkit bug file <id>`.
108
+
109
+ `testkit bug file` runs the test at the checked-out commit and files the issue
110
+ only when the test fails. It stops when tracked files have uncommitted changes,
111
+ when no remote branch contains the commit, or when an open issue already covers
112
+ the test. The token needs permission to create issues and labels.
113
+
114
+ ### Name the product's areas
115
+
116
+ Add `areas` to `testkit.config.ts` so that each issue names the part of the
117
+ product that holds the fault:
118
+
119
+ ```ts
120
+ export default defineConfig({
121
+ areas: {
122
+ checkout: { name: "Checkout", paths: ["src/checkout/**"] },
123
+ billing: { name: "Billing", paths: ["src/billing/**", "src/invoices/**"] },
124
+ },
125
+ });
126
+ ```
127
+
128
+ `testkit bug file` puts an `area:<id>` label on the issue. If no area claims
129
+ the fault's file, it stops and names the file. Without `areas`, Testkit files
130
+ bugs with no area. See [`areas`](testkit-config-schema.md#areas).
131
+
132
+ ### Pass a run whose failures are all filed
133
+
134
+ Every failing test fails the run by default. To keep CI passing while a filed
135
+ bug waits for its fix, set `bugs.knownFailures`:
136
+
137
+ ```ts
138
+ export default defineConfig({
139
+ bugs: { knownFailures: "allow" },
140
+ });
141
+ ```
142
+
143
+ A run then passes when an open issue with the `testkit` label covers each
144
+ failure. A new failure still fails the run, and closing an issue makes its
145
+ test count again. When Testkit cannot read the issues, every failure is new.
146
+ [Known failures](docs/bug-records.md#known-failures) gives the exact rule.
147
+
148
+ With `"allow"`, the run reads the open issues. In CI, pass the job's token to
149
+ the `testkit run` step as well:
150
+
151
+ ```yaml
152
+ steps:
153
+ - run: npm run testkit
154
+ env:
155
+ GITHUB_TOKEN: ${{ github.token }}
156
+ ```
157
+
158
+ The job also needs the `issues: read` permission.
159
+
160
+ ### Run a campaign
161
+
162
+ A campaign gives a coding agent a target and the instructions to file what it
163
+ finds.
164
+
165
+ ```bash
166
+ npx testkit campaign start --target "Refunds and partial refunds"
167
+ npx testkit campaign status
168
+ npx testkit campaign finish
169
+ ```
170
+
171
+ `campaign start` prints the agent's brief: the target, the files that no area
172
+ claims, how to work, and the bug record format. Give that output to the agent.
173
+ `campaign status` lists the bugs recorded so far. `campaign finish` writes the
174
+ summary for the pull request that adds the tests to
175
+ `.testkit/campaigns/<id>/summary.md` and prints it.
176
+
52
177
  ## Execution engines
53
178
 
54
179
  Testkit has three explicit built-in engines rather than one implicit runtime:
@@ -0,0 +1,38 @@
1
+ import type { AreaDefinition } from "../../config/loader/bugs.js";
2
+ import type { CommitFile } from "../../repo/tree-listing.js";
3
+ import type { BugFactsArea } from "./facts.js";
4
+ export interface AreaCoverage {
5
+ readonly bytes: number;
6
+ readonly files: number;
7
+ readonly id: string;
8
+ readonly name: string;
9
+ readonly share: number;
10
+ }
11
+ export interface DoubleClaim {
12
+ readonly areas: readonly string[];
13
+ readonly path: string;
14
+ }
15
+ export interface AreaMapCoverage {
16
+ readonly areas: readonly AreaCoverage[];
17
+ readonly doubleClaimedShare: number;
18
+ readonly doubleClaims: readonly DoubleClaim[];
19
+ readonly sourceBytes: number;
20
+ readonly unclaimed: readonly CommitFile[];
21
+ readonly unclaimedShare: number;
22
+ }
23
+ export type FaultAreaResolution = {
24
+ readonly area: BugFactsArea;
25
+ readonly status: "resolved";
26
+ } | {
27
+ readonly areas: readonly string[];
28
+ readonly status: "double-claimed";
29
+ } | {
30
+ readonly status: "no-map";
31
+ } | {
32
+ readonly status: "unclaimed";
33
+ };
34
+ export type AreaMatcher = (path: string) => readonly AreaDefinition[];
35
+ export declare function isSourceFile(path: string): boolean;
36
+ export declare function createAreaMatcher(areas: readonly AreaDefinition[]): AreaMatcher;
37
+ export declare function measureAreaMap(areas: readonly AreaDefinition[], files: readonly CommitFile[]): AreaMapCoverage;
38
+ export declare function resolveFaultArea(areas: readonly AreaDefinition[], files: readonly CommitFile[], faultPath: string): FaultAreaResolution;
@@ -0,0 +1 @@
1
+ import{Minimatch as p}from"minimatch";const h=new Set("c cc cjs cpp cs css cts dart ex exs go h hpp html java js jsx kt kts mjs mts php py rb rs scala scss sh sql svelte swift ts tsx vue".split(" ")),f=new Set(["__testkit__","__tests__","build","dist","migrations","node_modules","test","tests","vendor"]),_=[/^\./u,/\.(?:test|spec|testkit|min)\./u,/\.d\.[cm]?ts$/u,/_test\.(?:go|py)$/u,/^test_.*\.py$/u],g=/\.config\.[a-z]+$/u,d=1e4;export function isSourceFile(a){const e=a.split("/"),i=e[e.length-1],t=/\.([a-z0-9]+)$/u.exec(i)?.[1];return t===void 0||!h.has(t)||_.some(s=>s.test(i))||e.length===1&&g.test(i)?!1:!e.slice(0,-1).some(s=>s.startsWith(".")||f.has(s))}export function createAreaMatcher(a){const e=a.map(i=>({area:i,globs:i.paths.map(t=>new p(t,{dot:!0}))}));return i=>e.filter(t=>t.globs.some(s=>s.match(i))).map(t=>t.area)}export function measureAreaMap(a,e){const i=createAreaMatcher(a),t=new Map,s=[],c=[];let r=0,m=0;for(const n of e){if(!isSourceFile(n.path))continue;r+=n.size;const o=i(n.path);if(o.length===0)c.push(n);else if(o.length>1)s.push({areas:o.map(l=>l.id),path:n.path}),m+=n.size;else{const l=t.get(o[0].id)??{bytes:0,files:0};t.set(o[0].id,{bytes:l.bytes+n.size,files:l.files+1})}}return{areas:a.map(n=>{const o=t.get(n.id)??{bytes:0,files:0};return{...o,id:n.id,name:n.name,share:u(o.bytes,r)}}),doubleClaimedShare:u(m,r),doubleClaims:s,sourceBytes:r,unclaimed:c,unclaimedShare:u(c.reduce((n,o)=>n+o.size,0),r)}}export function resolveFaultArea(a,e,i){if(a.length===0)return{status:"no-map"};const t=createAreaMatcher(a)(i);if(t.length===0)return{status:"unclaimed"};if(t.length>1)return{areas:t.map(r=>r.id),status:"double-claimed"};const[s]=t,c=measureAreaMap(a,e).areas.find(r=>r.id===s.id);return{area:{id:s.id,name:s.name,share:c?.share??0},status:"resolved"}}function u(a,e){return e===0?0:Math.round(a/e*d)/d}
@@ -0,0 +1,9 @@
1
+ import type { Tracker } from "../../tracker/types.js";
2
+ import { type RunOutcome, type UnreadableIssue } from "./known-failures.js";
3
+ import type { RunReading } from "./run-reading.js";
4
+ export interface RunAssessment {
5
+ readonly outcome: RunOutcome;
6
+ readonly trackerProblem: string | null;
7
+ readonly unreadableIssues: readonly UnreadableIssue[];
8
+ }
9
+ export declare function assessRun(reading: RunReading, resolveIssueTracker: () => Promise<Tracker>): Promise<RunAssessment>;
@@ -0,0 +1 @@
1
+ import{TESTKIT_LABEL as l}from"./issue.js";import{classifyFailures as r,readKnownTests as o}from"./known-failures.js";export async function assessRun(e,t){if(e.kind!=="tests-failed")return{outcome:e,trackerProblem:null,unreadableIssues:[]};try{const s=await t(),{tests:a,unreadable:n}=o(await s.listOpenIssues(l));return{outcome:{classification:r(e.failures,a),kind:"tests-failed"},trackerProblem:null,unreadableIssues:n}}catch(s){return{outcome:{classification:r(e.failures,null),kind:"tests-failed"},trackerProblem:s instanceof Error?s.message:String(s),unreadableIssues:[]}}}
@@ -0,0 +1,41 @@
1
+ export declare const BUG_FACTS_VERSION = 1;
2
+ export interface BugCategory<Id extends string = string> {
3
+ readonly boundaryRule: string;
4
+ readonly definition: string;
5
+ readonly id: Id;
6
+ }
7
+ export interface BugCategoryList<Id extends string = string> {
8
+ readonly assignmentRule: string;
9
+ readonly categories: readonly BugCategory<Id>[];
10
+ readonly field: string;
11
+ readonly question: string;
12
+ }
13
+ export declare const BUG_CAUSES: {
14
+ readonly field: "cause";
15
+ readonly question: "What kind of mistake is in the code?";
16
+ readonly assignmentRule: "A bug gets exactly one cause. When a bug holds several mistakes, classify the earliest one in the causal chain. Use `other` when none fits.";
17
+ readonly categories: readonly [BugCategory<"missing-validation">, BugCategory<"mishandled-error">, BugCategory<"race-condition">, BugCategory<"unbounded-or-inefficient-work">, BugCategory<"incorrect-query-or-selection">, BugCategory<"missing-access-control">, BugCategory<"null-or-default-mishandling">, BugCategory<"stale-state">, BugCategory<"interface-mismatch">, BugCategory<"omitted-step">, BugCategory<"incorrect-logic">, BugCategory<"other">];
18
+ };
19
+ export declare const BUG_IMPACTS: {
20
+ readonly field: "impact";
21
+ readonly question: "What is at stake for the business?";
22
+ readonly assignmentRule: "A bug gets exactly one impact. The list is in precedence order, and the first type that applies wins. Classify the impact the test demonstrated, not a downstream one you expect. Use `other` when none fits.";
23
+ readonly categories: readonly [BugCategory<"unauthorized-access">, BugCategory<"financial-error">, BugCategory<"audit-or-compliance-gap">, BugCategory<"data-loss-or-corruption">, BugCategory<"workflow-breakdown">, BugCategory<"availability-or-performance">, BugCategory<"blocked-operation">, BugCategory<"wrong-information-shown">, BugCategory<"other">];
24
+ };
25
+ export declare const BUG_ESCAPES: {
26
+ readonly field: "escape";
27
+ readonly question: "Why did the tests that existed before yours not catch the bug?";
28
+ readonly assignmentRule: "A bug gets exactly one reason. Judge the tests that were there before yours. Use `other` when none fits.";
29
+ readonly categories: readonly [BugCategory<"no-test">, BugCategory<"untested-input">, BugCategory<"untested-interleaving">, BugCategory<"wrong-assertion">, BugCategory<"test-double">, BugCategory<"other">];
30
+ };
31
+ export type BugCause = (typeof BUG_CAUSES.categories)[number]["id"];
32
+ export type BugImpact = (typeof BUG_IMPACTS.categories)[number]["id"];
33
+ export type BugEscape = (typeof BUG_ESCAPES.categories)[number]["id"];
34
+ export declare const BUG_SEVERITIES: readonly ["critical", "high", "medium", "low"];
35
+ export type BugSeverity = (typeof BUG_SEVERITIES)[number];
36
+ export declare const BUG_CONFIDENCES: readonly ["high", "low"];
37
+ export type BugConfidence = (typeof BUG_CONFIDENCES)[number];
38
+ export declare const BUG_CATCHERS: readonly ["campaign", "manual"];
39
+ export type BugCatcher = (typeof BUG_CATCHERS)[number];
40
+ export declare function categoryIds<Id extends string>(list: BugCategoryList<Id>): readonly Id[];
41
+ export declare function isOneOf<Value extends string>(values: readonly Value[], value: unknown): value is Value;
@@ -0,0 +1 @@
1
+ export const BUG_FACTS_VERSION=1;function e(i,t,o=""){return{boundaryRule:o,definition:t,id:i}}export const BUG_CAUSES={field:"cause",question:"What kind of mistake is in the code?",assignmentRule:"A bug gets exactly one cause. When a bug holds several mistakes, classify the earliest one in the causal chain. Use `other` when none fits.",categories:[e("missing-validation","The code never checks an input or a state precondition that the domain rules require: range, format, existence, uniqueness, cross-field consistency, or lifecycle state. Includes a validator that exists and accepts invalid input.","Earliest mistake wins. If application code never tested the condition, it is `missing-validation` even when a database constraint or the runtime later throws and the caller sees a server error. If the unchecked value is null, absent, empty, zero, or a default, use `null-or-default-mishandling`."),e("mishandled-error","A failure was detected and signalled, and the handling is wrong: not caught, translated to the wrong error, swallowed, ignored by the UI, or followed by a wrong retry or recovery.","The failure must already be signalled by something that checked. If nothing in the application checked and a lower layer failed, it is `missing-validation`."),e("race-condition","Correctness depends on how overlapping operations interleave: check-then-act without a lock or constraint, a lost update, or a late asynchronous completion that overwrites newer state.","Needs two overlapping operations to reproduce. If one sequential flow reproduces it, it is `stale-state` or `missing-validation`."),e("unbounded-or-inefficient-work","Results are correct and the cost is unbounded or needlessly repeated: unpaginated reads, N+1 queries, a missing or redundant index, serial waits, or loading everything and then slicing.","Correct results at excessive cost. If the result is wrong, it is `incorrect-query-or-selection`."),e("incorrect-query-or-selection","The code identifies the wrong set of records: a missing or wrong predicate, unstable ordering, a truncated page treated as complete, duplicate rows from a join, or a lookup key that omits a distinguishing part.","A missing predicate is `missing-access-control` only when it separates actors or tenants with different rights. The right records with wrong arithmetic on them is `incorrect-logic`."),e("missing-access-control","A permission, tenant, ownership, or separation-of-duties check is absent or wrong.","Use it when the missing check is about who the actor is. A missing check about what the data or state is belongs in `missing-validation`."),e("null-or-default-mishandling","Null, absent, empty, zero, or default values are conflated or not handled: null treated as omitted, a zero or an empty collection treated as missing, a null dereference, a wrong fallback default, or a caller that omits an argument and silently gets the default.","Wins over `missing-validation` and `incorrect-logic` whenever the triggering value is null, absent, empty, zero, or a default."),e("stale-state","Held state, such as a cache, local UI state, a derived or denormalised value, or leftover fields, is not refreshed, reset, persisted, or tied to the right identity when the thing it mirrors changes.","If the omission leaves old values visible where current ones should be, it is `stale-state`. If the missing action leaves nothing stale, it is `omitted-step`. If it reproduces only with overlapping operations, it is `race-condition`."),e("interface-mismatch","Data is lost or misread where it crosses between representations or components. One component's mapping drops, overwrites, hardcodes, or mis-assigns fields, or two components that must agree disagree on shape, type, field names, allowed values, semantics, or which copy of the data is authoritative.","Use it when the values were right before the crossing. Wrong computation on values that arrived intact is `incorrect-logic`. A held copy that goes out of date after its source changes is `stale-state`. An omitted argument that falls back to a default is `null-or-default-mishandling`."),e("omitted-step","A required follow-on action is not performed on some path: an audit record, a notification, a dispatch, a finalisation, or an alternate route that bypasses the shared routine.","If the missing thing is a check, it is `missing-validation`. If its absence leaves old values on display, it is `stale-state`."),e("incorrect-logic","The algorithm, arithmetic, date or time handling, rounding, boundary, comparison, boolean condition, parsing, or order of operations is wrong.","The default for wrong logic on valid, present values. If the trigger is a null, empty, zero, or default value, use `null-or-default-mishandling`. If the fault is which records were chosen, use `incorrect-query-or-selection`."),e("other","No category above describes the mistake.")]},BUG_IMPACTS={field:"impact",question:"What is at stake for the business?",assignmentRule:"A bug gets exactly one impact. The list is in precedence order, and the first type that applies wins. Classify the impact the test demonstrated, not a downstream one you expect. Use `other` when none fits.",categories:[e("unauthorized-access","Someone can see or change something they are not entitled to, including getting past a separation-of-duties rule.","The actor lacks the right. If an entitled actor skips a mandated control or leaves no evidence, it is `audit-or-compliance-gap`."),e("financial-error","A monetary amount, rate, currency, limit, payment or settlement state, or a money-committing decision is wrong where it is stored, charged, paid, or decided.","A money figure that is only displayed wrongly, with the stored value and the decision intact, is `wrong-information-shown`."),e("audit-or-compliance-gap","Required audit evidence or attribution is missing, duplicated, or false, or a mandated control did not run.","Use it when the harm is to evidence or to a regulatory or policy control. A skipped ordinary process step is `workflow-breakdown`."),e("data-loss-or-corruption","Stored data, or work the user entered, is destroyed, overwritten, duplicated, or left invalid or inconsistent.","If the action silently did nothing and prior stored state is intact, it is `blocked-operation`."),e("workflow-breakdown","A business process, such as a workflow, task, schedule, notification, or gate, is skipped, stalled, duplicated, or advanced wrongly.","Use it when the process goes wrong after the triggering request returns. A single user action that fails in front of the user is `blocked-operation`."),e("availability-or-performance","The service is slow, unavailable, or exhausts resources in a way that affects more than the triggering request or grows with data volume.","One request that fails for one user is `blocked-operation`."),e("blocked-operation","A legitimate action fails, returns an unusable or misleading error, or silently does not apply, with prior stored state intact.","The user was trying to do something and could not. If they were only reading and what they read was wrong, it is `wrong-information-shown`."),e("wrong-information-shown","Displayed or returned information is wrong, out of date, incomplete, or duplicated, with nothing stored wrongly.","Last in precedence. If the wrong value is also persisted, it is `data-loss-or-corruption`."),e("other","No type above describes what is at stake.")]},BUG_ESCAPES={field:"escape",question:"Why did the tests that existed before yours not catch the bug?",assignmentRule:"A bug gets exactly one reason. Judge the tests that were there before yours. Use `other` when none fits.",categories:[e("no-test","No existing test runs the faulty behaviour.","If any existing test reaches the faulty code path, use another reason."),e("untested-input","A test runs the behaviour and never with the input, state, or data volume that triggers the fault.","One sequential flow with the right input reproduces the bug. If it needs overlapping operations, it is `untested-interleaving`."),e("untested-interleaving","A test runs the behaviour in sequence, and the fault needs overlapping operations or a particular timing.","The triggering input is already covered, and only the overlap is missing."),e("wrong-assertion","A test runs the triggering case against the real code and passes, because its assertion is missing, too weak, or asserts the broken behaviour.","The faulty code ran in the test. If a stand-in ran in its place, it is `test-double`."),e("test-double","A test runs the triggering case against a test double or a fixture that hides the fault in the real component.","The real component would have failed the test."),e("other","No reason above applies.")]},BUG_SEVERITIES=["critical","high","medium","low"],BUG_CONFIDENCES=["high","low"],BUG_CATCHERS=["campaign","manual"];export function categoryIds(i){return i.categories.map(t=>t.id)}export function isOneOf(i,t){return typeof t=="string"&&i.some(o=>o===t)}
@@ -0,0 +1,21 @@
1
+ import type { TrackerCommentReference, TrackerCommitState } from "../../tracker/types.js";
2
+ import { type FailedTest, type KnownFailure, type UnreadableIssue } from "./known-failures.js";
3
+ import { type CheckoutOptions } from "./store.js";
4
+ export interface CiReportOptions extends CheckoutOptions {
5
+ readonly context?: string | undefined;
6
+ readonly env: Readonly<NodeJS.ProcessEnv>;
7
+ }
8
+ export interface CiReport {
9
+ readonly comment: TrackerCommentReference | null;
10
+ readonly commit: string;
11
+ readonly context: string;
12
+ readonly description: string;
13
+ readonly known: readonly KnownFailure[];
14
+ readonly new: readonly FailedTest[];
15
+ readonly pullRequest: number | null;
16
+ readonly state: TrackerCommitState;
17
+ readonly stepSummary: string | null;
18
+ readonly trackerProblem: string | null;
19
+ readonly unreadableIssues: readonly UnreadableIssue[];
20
+ }
21
+ export declare function reportCiRun(options: CiReportOptions): Promise<CiReport>;
@@ -0,0 +1,2 @@
1
+ import k from"node:fs";import y from"node:path";import{readBugPolicy as g}from"../../config/loader/bugs.js";import{loadTestkitConfig as v}from"../../config/loader/index.js";import{resolveCiProvenance as T}from"../../evidence/ci-provenance.js";import{loadLatestRunArtifact as h}from"../../evidence/reading.js";import{isUnknownRecord as q}from"../../kernel/unknown-values.js";import{resolveTracker as w}from"../tracker/index.js";import{assessRun as P}from"./assessment.js";import{ciReportMarker as U,readCiContext as b,renderCiSummary as x}from"./ci-summary.js";import{describeRunOutcome as D}from"./known-failures.js";import{readRun as F}from"./run-reading.js";import{resolveBugCheckout as A}from"./store.js";const R="Testkit found no results for this commit";export async function reportCiRun(e){const r=b(e.context),t=await A(e),s=g((await v(t.productDir)).config),a={env:e.env,productDir:t.productDir};let u=null;const c=()=>(u??=w(a),u),{repository:l}=s,o=await P(B(t),l===null?c:()=>w({...a,repository:l})),n=D(o.outcome,s.knownFailures==="allow"),i=t.pullRequestHeadCommit??t.headCommit,m=T(e.env).runUrl,d=x({...o,commit:i,context:r,knownFailuresAllowed:s.knownFailures==="allow",runUrl:m,status:n}),C=I(e.env,d),p=await c();await p.setCommitStatus({...n,context:r,sha:i,targetUrl:m});const S=t.pullRequest===null?null:await p.upsertPullRequestComment({body:d,marker:U(r),pullRequest:t.pullRequest}),f=o.outcome.kind==="tests-failed"?o.outcome.classification:{known:[],new:[]};return{comment:S,commit:i,context:r,description:n.description,known:f.known,new:f.new,pullRequest:t.pullRequest,state:n.state,stepSummary:C,trackerProblem:o.trackerProblem,unreadableIssues:o.unreadableIssues}}function B(e){if(!k.existsSync(y.join(e.productDir,".testkit","results","latest.json")))return{kind:"run-failed",reason:R};const r=h(e.productDir),t=r.git;return!q(t)||t.commitSha!==e.headCommit?{kind:"run-failed",reason:R}:F(r,e.productPath)}function I(e,r){const t=e.GITHUB_STEP_SUMMARY?.trim()??"";return t.length===0?null:(k.appendFileSync(t,`${r}
2
+ `),t)}
@@ -0,0 +1,13 @@
1
+ import type { RunAssessment } from "./assessment.js";
2
+ import { type RunStatus } from "./known-failures.js";
3
+ export declare const DEFAULT_CI_CONTEXT = "testkit";
4
+ export interface CiSummaryInput extends RunAssessment {
5
+ readonly commit: string;
6
+ readonly context: string;
7
+ readonly knownFailuresAllowed: boolean;
8
+ readonly runUrl: string | null;
9
+ readonly status: RunStatus;
10
+ }
11
+ export declare function readCiContext(value: string | undefined): string;
12
+ export declare function ciReportMarker(context: string): string;
13
+ export declare function renderCiSummary(input: CiSummaryInput): string;
@@ -0,0 +1,5 @@
1
+ import{describeUnnamedFailure as T}from"./known-failures.js";import{redactBugText as I,redactRunResultText as k}from"../../tracker/redaction.js";export const DEFAULT_CI_CONTEXT="testkit";const p=/^[A-Za-z0-9][A-Za-z0-9 ._/:-]{0,59}$/u,b=12,l=50,E=10,d=160,_=/^https?:\/\/[^\s()<>]+$/u;export function readCiContext(e){if(e===void 0)return DEFAULT_CI_CONTEXT;if(!p.test(e)||e!==e.trim())throw new Error(`--context must be 1 to 60 characters, start with an ASCII letter or a digit, not end with a space, and hold only ASCII letters, digits, spaces, and the characters . _ / : and -. Received ${JSON.stringify(e)}.`);return e}export function ciReportMarker(e){return e===DEFAULT_CI_CONTEXT?"<!-- testkit-ci-report -->":`<!-- testkit-ci-report:${e} -->`}export function renderCiSummary(e){const{context:n,outcome:t}=e,r=[ciReportMarker(n),n===DEFAULT_CI_CONTEXT?"## Testkit":`## Testkit: ${n}`,w(e),`Commit \`${e.commit.slice(0,b)}\`.${h("CI run",e.runUrl," ",".")}`];if(t.kind==="tests-failed"){const{known:a,new:i}=t.classification;if(i.length>0&&r.push("### New failures",f(i.map(s=>`- ${c(s.file)}: ${$(s)}`))),a.length>0&&r.push("### Known failures",f(a.map(({failure:s,issue:o})=>`- ${c(s.file)}: ${$(s)}, ${g(o.issue,o.url)}`))),e.trackerProblem!==null&&r.push(`Testkit could not read the open bugs, so every failure counts as new. ${m(e.trackerProblem,400)}`),e.unreadableIssues.length>0){const s=e.unreadableIssues.slice(0,E).map(u=>g(u.number,u.url)),o=e.unreadableIssues.length-s.length;r.push(`Testkit cannot read the facts block of ${C(e.unreadableIssues.length,"open `testkit` issue")}, so ${e.unreadableIssues.length===1?"it matches":"they match"} no test: ${s.join(", ")}${o>0?`, and ${String(o)} more`:""}.`)}}return`${r.join(`
2
+
3
+ `)}
4
+ `}function w({knownFailuresAllowed:e,outcome:n,status:t}){return n.kind==="passed"?`${t.description}.`:n.kind==="run-failed"?`The run failed outside its tests. ${c(n.reason,400)}`:n.classification.new.length>0?`${t.description}.`:e?`${t.description}. Every failure is a known bug and \`bugs.knownFailures\` is \`"allow"\`, so the run passes.`:`${t.description}. Every failure is a known bug, and \`bugs.knownFailures\` is \`"fail"\`, so the run fails.`}function f(e){const n=e.length-l;return[...e.slice(0,l),...n>0?[`- and ${String(n)} more`]:[]].join(`
5
+ `)}function h(e,n,t="",r=""){return n!==null&&_.test(n)?`${t}[${e}](${n})${r}`:""}function $(e){return e.title===null?T(e):c(e.title)}function g(e,n){const t=`#${String(e)}`;return h(t,n)||t}function C(e,n){return`${String(e)} ${n}${e===1?"":"s"}`}function c(e,n=d){return m(k(e),n)}function m(e,n=d){const t=Array.from(I(e.replace(/\s+/gu," ").trim())),r=t.length>n?`${t.slice(0,n-3).join("")}...`:t.join("");if(r.length===0)return"an empty name";const a=Math.max(0,...[...r.matchAll(/`+/gu)].map(s=>s[0].length)),i="`".repeat(a+1);return r.startsWith("`")||r.endsWith("`")?`${i} ${r} ${i}`:`${i}${r}${i}`}
@@ -0,0 +1,70 @@
1
+ import { type PublicTestType } from "../../domain/identifiers.js";
2
+ import { type BugCatcher, type BugCause, type BugConfidence, type BugEscape, type BugImpact, type BugSeverity } from "./categories.js";
3
+ export declare const BUG_DRAFT_SCHEMA_VERSION = 1;
4
+ export declare const BUG_TITLE_MAX_LENGTH = 200;
5
+ export declare const BUG_SECTION_MAX_LENGTH = 12000;
6
+ export declare const CAMPAIGN_ID_MAX_LENGTH = 60;
7
+ export interface BugReport {
8
+ readonly title: string;
9
+ readonly severity: BugSeverity;
10
+ readonly expected: string;
11
+ readonly authority: string;
12
+ readonly observed: string;
13
+ readonly rootCause: string;
14
+ readonly test: {
15
+ readonly file: string;
16
+ readonly title: string;
17
+ };
18
+ readonly fault: {
19
+ readonly lines: readonly [number, number];
20
+ readonly path: string;
21
+ };
22
+ readonly cause: BugCause;
23
+ readonly impact: BugImpact;
24
+ readonly confidence: {
25
+ readonly cause: BugConfidence;
26
+ readonly impact: BugConfidence;
27
+ };
28
+ readonly escape: BugEscape;
29
+ }
30
+ export interface BugDraftFiling {
31
+ readonly filedAt: string;
32
+ readonly foundAt: string;
33
+ readonly number: number;
34
+ readonly url: string;
35
+ }
36
+ export interface BugDraft extends BugReport {
37
+ readonly schemaVersion: typeof BUG_DRAFT_SCHEMA_VERSION;
38
+ readonly id: string;
39
+ readonly recordedAt: string;
40
+ readonly recordedCommit: string;
41
+ readonly productPath: string;
42
+ readonly caughtBy: BugCatcher;
43
+ readonly campaign?: string;
44
+ readonly testType: PublicTestType;
45
+ readonly filed?: BugDraftFiling;
46
+ }
47
+ export interface BugReportReading {
48
+ readonly redactedFields: readonly string[];
49
+ readonly report: BugReport;
50
+ }
51
+ export interface BugDraftOrigin {
52
+ readonly campaign: string | null;
53
+ readonly productPath: string;
54
+ readonly recordedAt: Date;
55
+ readonly recordedCommit: string;
56
+ }
57
+ export declare class BugRecordError extends Error {
58
+ readonly problems: readonly string[];
59
+ constructor(subject: string, problems: readonly string[]);
60
+ }
61
+ export declare function isCampaignId(value: unknown): value is string;
62
+ export declare function testTypeOfFile(file: string): PublicTestType | null;
63
+ export declare function toRepositoryPath(productPath: string, value: string): string | null;
64
+ export declare function toProductPath(productPath: string, repositoryPath: string): string;
65
+ export declare function readBugReport(value: unknown, productPath: string): BugReportReading;
66
+ export declare function createBugDraft(report: BugReport, origin: BugDraftOrigin): BugDraft;
67
+ export declare function readBugDraft(value: unknown): BugDraft;
68
+ export declare function bugDraftId(report: Pick<BugReport, "test" | "title">): string;
69
+ export declare function isBugDraftId(value: unknown): value is string;
70
+ export declare function namesSameTest(left: Pick<BugReport, "test">, right: Pick<BugReport, "test">): boolean;
@@ -0,0 +1,4 @@
1
+ import G from"node:crypto";import T from"node:path";import{isPublicTestType as P}from"../../domain/identifiers.js";import{isUnknownRecord as A}from"../../kernel/unknown-values.js";import{BUG_CATCHERS as O,BUG_CAUSES as R,BUG_CONFIDENCES as B,BUG_ESCAPES as U,BUG_IMPACTS as D,BUG_SEVERITIES as F,categoryIds as C,isOneOf as E}from"./categories.js";import{decodeFaultLines as z,isCommitId as $}from"./facts.js";import{redactBugText as S}from"../../tracker/redaction.js";export const BUG_DRAFT_SCHEMA_VERSION=1,BUG_TITLE_MAX_LENGTH=200,BUG_SECTION_MAX_LENGTH=12e3;const L=48,M=/^[a-z0-9]+(?:-[a-z0-9]+)*$/u;export const CAMPAIGN_ID_MAX_LENGTH=60;export class BugRecordError extends Error{problems;constructor(e,n){super(`${e}:
2
+ ${n.map(r=>`- ${r}`).join(`
3
+ `)}`),this.name="BugRecordError",this.problems=n}}export function isCampaignId(t){return typeof t=="string"&&t.length<=CAMPAIGN_ID_MAX_LENGTH&&M.test(t)}export function testTypeOfFile(t){const e=/\.([a-z0-9]+)\.testkit\.ts$/u.exec(t)?.[1];return P(e)?e:null}export function toRepositoryPath(t,e){const n=e.replaceAll("\\","/");if(n.startsWith("/")||/^[a-z]:/iu.test(n))return null;const r=T.posix.normalize(T.posix.join(t,n));return r==="."||r===".."||r.startsWith("../")?null:r}export function toProductPath(t,e){return t===""?e:T.posix.relative(t,e)}export function readBugReport(t,e){if(!A(t))throw new BugRecordError("The bug record is invalid",["it must be a JSON object"]);const n=[],r=[],p=I(t,n,{path:i=>toRepositoryPath(e,i),text:(i,f)=>{const u=S(i);return u!==i&&r.push(f),u}});if(n.length>0)throw new BugRecordError("The bug record is invalid",n);return{redactedFields:r,report:p}}export function createBugDraft(t,e){const n=testTypeOfFile(t.test.file);if(n===null)throw new BugRecordError("The bug record is invalid",[w("test.file")]);return{schemaVersion:BUG_DRAFT_SCHEMA_VERSION,id:bugDraftId(t),recordedAt:e.recordedAt.toISOString(),recordedCommit:e.recordedCommit,productPath:e.productPath,caughtBy:e.campaign===null?"manual":"campaign",...e.campaign===null?{}:{campaign:e.campaign},testType:n,...t}}export function readBugDraft(t){const e="The bug draft is unreadable";if(!A(t))throw new BugRecordError(e,["it must be a JSON object"]);if(t.schemaVersion!==BUG_DRAFT_SCHEMA_VERSION)throw new BugRecordError(e,[`unknown schemaVersion ${JSON.stringify(t.schemaVersion)}`]);const n=[],r=(a,b,y,l)=>b(a)?a:(n.push(l),y),p=I(t,n,{path:a=>toRepositoryPath("",a),text:a=>a}),i=testTypeOfFile(p.test.file)??"int";t.testType!==i&&n.push("testType does not match test.file");const f=r(t.caughtBy,V,"manual","caughtBy must be campaign or manual"),u=r(t.campaign,H,void 0,"campaign must be a campaign id");f==="campaign"!=(u!==void 0)&&n.push("campaign must be present when caughtBy is campaign and absent otherwise");const x={schemaVersion:BUG_DRAFT_SCHEMA_VERSION,id:r(t.id,isBugDraftId,"","id is not a draft id"),recordedAt:r(t.recordedAt,N,"","recordedAt must be a timestamp"),recordedCommit:r(t.recordedCommit,$,"","recordedCommit must be a full commit id"),productPath:r(t.productPath,k,"","productPath must be a string"),caughtBy:f,...u===void 0?{}:{campaign:u},testType:i,...p,...t.filed===void 0?{}:{filed:j(t.filed,n)}};if(n.length>0)throw new BugRecordError(e,n);return x}export function bugDraftId(t){const e=t.title.normalize("NFKD").replace(/\p{M}+/gu,"").toLowerCase().replace(/[^a-z0-9]+/gu,"-").slice(0,L).replace(/^-+|-+$/gu,""),n=G.createHash("sha256").update(`${t.test.file}
4
+ ${t.test.title}`).digest("hex");return`${e||"bug"}-${n.slice(0,8)}`}export function isBugDraftId(t){return typeof t=="string"&&/^[a-z0-9]+(?:-[a-z0-9]+)*-[0-9a-f]{8}$/u.test(t)}export function namesSameTest(t,e){return t.test.file===e.test.file&&t.test.title===e.test.title}function I(t,e,n){const r=(s,o,c,d)=>{const m=s[o];if(typeof m!="string"||m.trim().length===0)return e.push(`${c} must be a non-empty string`),"";const h=m.trim(),g=h.length>d?h:n.text(h,c);return g.length>d&&e.push(`${c} must be at most ${String(d)} characters`),g},p=(s,o,c)=>{const d=s[o],m=typeof d=="string"?d.trim():"",h=m.length>0?n.path(m):null;return h===null?e.push(`${c} must be a path inside the repository`):[m,h].some(g=>S(g)!==g)&&e.push(`${c} is a path that holds text Testkit redacts as a secret`),h??""},i=(s,o,c)=>E(s,o)?o:(e.push(`${c} must be one of ${s.join(", ")}`),s[0]),f=s=>{const o=t[s];return A(o)?o:(e.push(`${s} must be an object`),{})},u=(s,o,c)=>{const d=r(s,o,c,BUG_TITLE_MAX_LENGTH);return/[\r\n]/u.test(d)&&e.push(`${c} must be one line`),d},x=u(t,"title","title"),a=f("test"),b=f("fault"),y=f("confidence"),l=p(a,"file","test.file");l!==""&&testTypeOfFile(l)===null&&e.push(w("test.file"));let _=[1,1];try{_=z(b.lines)}catch(s){e.push(s instanceof Error?s.message:"fault.lines is invalid")}return{title:x,severity:i(F,t.severity,"severity"),expected:r(t,"expected","expected",BUG_SECTION_MAX_LENGTH),authority:r(t,"authority","authority",BUG_SECTION_MAX_LENGTH),observed:r(t,"observed","observed",BUG_SECTION_MAX_LENGTH),rootCause:r(t,"rootCause","rootCause",BUG_SECTION_MAX_LENGTH),test:{file:l,title:u(a,"title","test.title")},fault:{lines:_,path:p(b,"path","fault.path")},cause:i(C(R),t.cause,"cause"),impact:i(C(D),t.impact,"impact"),confidence:{cause:i(B,y.cause,"confidence.cause"),impact:i(B,y.impact,"confidence.impact")},escape:i(C(U),t.escape,"escape")}}function j(t,e){return A(t)&&N(t.filedAt)&&$(t.foundAt)&&typeof t.number=="number"&&Number.isSafeInteger(t.number)&&t.number>=1&&typeof t.url=="string"?{filedAt:t.filedAt,foundAt:t.foundAt,number:t.number,url:t.url}:(e.push("filed must hold filedAt, foundAt, number, and url"),{filedAt:"",foundAt:"",number:0,url:""})}function H(t){return t===void 0||isCampaignId(t)}function V(t){return E(O,t)}function k(t){return typeof t=="string"}function N(t){return typeof t=="string"&&!Number.isNaN(Date.parse(t))}function w(t){return`${t} must be a Testkit test file, such as refund.contract.testkit.ts`}
@@ -0,0 +1,48 @@
1
+ import { type PublicTestType } from "../../domain/identifiers.js";
2
+ import { BUG_FACTS_VERSION, type BugCatcher, type BugCause, type BugConfidence, type BugEscape, type BugImpact } from "./categories.js";
3
+ export declare const FACTS_START_MARKER = "<!-- testkit-facts:start -->";
4
+ export declare const FACTS_END_MARKER = "<!-- testkit-facts:end -->";
5
+ export interface BugFactsArea {
6
+ readonly id: string;
7
+ readonly name: string;
8
+ readonly share: number;
9
+ }
10
+ export interface BugFactsFault {
11
+ readonly lines?: readonly [number, number];
12
+ readonly path: string;
13
+ }
14
+ export interface BugFacts {
15
+ readonly v: typeof BUG_FACTS_VERSION;
16
+ readonly cause: BugCause;
17
+ readonly impact: BugImpact;
18
+ readonly confidence: {
19
+ readonly cause: BugConfidence;
20
+ readonly impact: BugConfidence;
21
+ };
22
+ readonly escape?: BugEscape;
23
+ readonly caughtBy: BugCatcher;
24
+ readonly campaign?: string;
25
+ readonly testType: PublicTestType;
26
+ readonly test: {
27
+ readonly file: string;
28
+ readonly title?: string;
29
+ };
30
+ readonly foundAt: string;
31
+ readonly fault?: BugFactsFault;
32
+ readonly area?: BugFactsArea;
33
+ readonly introducedBy?: string;
34
+ readonly introducedMethod?: "blame";
35
+ }
36
+ export type BugFactsReading = {
37
+ readonly status: "invalid";
38
+ readonly reason: string;
39
+ } | {
40
+ readonly status: "missing";
41
+ } | {
42
+ readonly facts: BugFacts;
43
+ readonly status: "ok";
44
+ };
45
+ export declare function renderFactsBlock(facts: BugFacts): string;
46
+ export declare function readFactsBlock(body: string): BugFactsReading;
47
+ export declare function isCommitId(value: unknown): value is string;
48
+ export declare function decodeFaultLines(value: unknown): readonly [number, number];
@@ -0,0 +1,2 @@
1
+ import{isAreaId as g}from"../../config/loader/bugs.js";import{isPublicTestType as E}from"../../domain/identifiers.js";import{isUnknownRecord as m}from"../../kernel/unknown-values.js";import{BUG_CATCHERS as b,BUG_CAUSES as T,BUG_CONFIDENCES as p,BUG_ESCAPES as A,BUG_FACTS_VERSION as h,BUG_IMPACTS as B,categoryIds as a,isOneOf as S}from"./categories.js";export const FACTS_START_MARKER="<!-- testkit-facts:start -->",FACTS_END_MARKER="<!-- testkit-facts:end -->";const k=/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/u;export function renderFactsBlock(e){return["<details>","<summary>Testkit facts</summary>","",FACTS_START_MARKER,"","```json",JSON.stringify(l(e),null,2),"```","",FACTS_END_MARKER,"","</details>"].join(`
2
+ `)}export function readFactsBlock(e){const t=e.lastIndexOf(FACTS_START_MARKER);if(t===-1)return{status:"missing"};const n=t+FACTS_START_MARKER.length,r=e.indexOf(FACTS_END_MARKER,n);if(r===-1)return{status:"invalid",reason:"the facts block has no end marker"};const d=/```[^\n]*\r?\n([\s\S]*?)\r?\n[ \t]*```/u.exec(e.slice(n,r));if(d===null)return{status:"invalid",reason:"the facts block has no fenced code block"};let u;try{u=JSON.parse(d[1])}catch{return{status:"invalid",reason:"the facts block is not valid JSON"}}try{return{facts:O(u),status:"ok"}}catch(f){return{status:"invalid",reason:f instanceof Error?f.message:"the facts block is invalid"}}}export function isCommitId(e){return typeof e=="string"&&k.test(e)}function O(e){if(!m(e))throw new Error("the facts block is not a JSON object");if(e.v!==h)throw new Error(`unknown facts version ${JSON.stringify(e.v)}`);const t=c(e.confidence,"confidence"),n=c(e.test,"test");return l({v:h,cause:o(a(T),e.cause,"cause"),impact:o(a(B),e.impact,"impact"),confidence:{cause:o(p,t.cause,"confidence.cause"),impact:o(p,t.impact,"confidence.impact")},escape:i(e.escape,r=>o(a(A),r,"escape")),caughtBy:o(b,e.caughtBy,"caughtBy"),campaign:i(e.campaign,r=>s(r,"campaign")),testType:_(e.testType),test:{file:s(n.file,"test.file"),title:i(n.title,r=>s(r,"test.title"))},foundAt:y(e.foundAt,"foundAt"),fault:i(e.fault,C),area:i(e.area,N),introducedBy:i(e.introducedBy,r=>y(r,"introducedBy")),introducedMethod:i(e.introducedMethod,r=>o(["blame"],r,"introducedMethod"))})}function C(e){const t=c(e,"fault");return{lines:i(t.lines,decodeFaultLines),path:s(t.path,"fault.path")}}export function decodeFaultLines(e){if(!Array.isArray(e)||e.length!==2)throw new Error("fault.lines must be [start, end]");const t=e,[n,r]=t;if(!w(n)||!w(r)||n>r)throw new Error("fault.lines must be two line numbers counted from 1, with start no greater than end");return[n,r]}function N(e){const t=c(e,"area");if(!g(t.id))throw new Error("area.id is not an area id");const n=t.share;if(typeof n!="number"||!Number.isFinite(n)||n<0||n>1)throw new Error("area.share must be a number from 0 to 1");return{id:t.id,name:s(t.name,"area.name"),share:n}}function l(e){return{v:e.v,cause:e.cause,impact:e.impact,confidence:{cause:e.confidence.cause,impact:e.confidence.impact},...e.escape===void 0?{}:{escape:e.escape},caughtBy:e.caughtBy,...e.campaign===void 0?{}:{campaign:e.campaign},testType:e.testType,test:{file:e.test.file,...e.test.title===void 0?{}:{title:e.test.title}},foundAt:e.foundAt,...e.fault===void 0?{}:{fault:{path:e.fault.path,...e.fault.lines===void 0?{}:{lines:e.fault.lines}}},...e.area===void 0?{}:{area:{id:e.area.id,name:e.area.name,share:e.area.share}},...e.introducedBy===void 0?{}:{introducedBy:e.introducedBy},...e.introducedMethod===void 0?{}:{introducedMethod:e.introducedMethod}}}function i(e,t){return e===void 0?void 0:t(e)}function c(e,t){if(!m(e))throw new Error(`${t} must be an object`);return e}function s(e,t){if(typeof e!="string"||e.trim().length===0)throw new Error(`${t} must be a non-empty string`);return e}function o(e,t,n){if(!S(e,t))throw new Error(`${n} must be one of ${e.join(", ")}`);return t}function _(e){if(!E(e))throw new Error("testType is not a Testkit test type");return e}function y(e,t){if(!isCommitId(e))throw new Error(`${t} must be a full lowercase commit id`);return e}function w(e){return typeof e=="number"&&Number.isSafeInteger(e)&&e>=1}
@@ -0,0 +1,45 @@
1
+ import type { Tracker, TrackerIssueDraft } from "../../tracker/types.js";
2
+ import { type BugDraft } from "./draft.js";
3
+ import { type BugFactsArea } from "./facts.js";
4
+ import { type BugCoverage, type BugFiling } from "./issue.js";
5
+ import { type FailedTest } from "./known-failures.js";
6
+ import type { RunReading } from "./run-reading.js";
7
+ import { type BugCheckout, type CheckoutOptions } from "./store.js";
8
+ export interface PrepareBugFilingOptions extends CheckoutOptions {
9
+ readonly env: Readonly<NodeJS.ProcessEnv>;
10
+ readonly id: string;
11
+ }
12
+ export interface BugIssueReference {
13
+ readonly number: number;
14
+ readonly url: string;
15
+ }
16
+ export interface ReadyBugFiling {
17
+ readonly checkout: BugCheckout;
18
+ readonly draft: BugDraft;
19
+ readonly draftFile: string;
20
+ readonly filing: BugFiling;
21
+ readonly status: "ready";
22
+ readonly testFile: string;
23
+ readonly tracker: Tracker;
24
+ }
25
+ export interface AlreadyFiledBug {
26
+ readonly draft: BugDraft;
27
+ readonly issue: BugIssueReference;
28
+ readonly status: "already-filed";
29
+ }
30
+ export type BugFilingPreparation = AlreadyFiledBug | ReadyBugFiling;
31
+ export interface FiledBug {
32
+ readonly area: BugFactsArea | null;
33
+ readonly covers: BugCoverage;
34
+ readonly draft: BugDraft;
35
+ readonly introducedBy: string | null;
36
+ readonly issue: BugIssueReference;
37
+ readonly path: string;
38
+ }
39
+ export declare function prepareBugFiling(options: PrepareBugFilingOptions): Promise<BugFilingPreparation>;
40
+ export declare function fileBug(ready: ReadyBugFiling, run: RunReading, options?: {
41
+ readonly at?: Date;
42
+ }): Promise<FiledBug>;
43
+ export declare function renderReadableBugIssue(draft: BugDraft, filing: BugFiling, covers: BugCoverage): TrackerIssueDraft;
44
+ export declare function requireNoSecretInCommand(draft: Pick<BugDraft, "productPath" | "test">): void;
45
+ export declare function requireRecordedFailure(draft: Pick<BugDraft, "productPath" | "test">, run: RunReading, commit: string): FailedTest;
@@ -0,0 +1 @@
1
+ import y from"node:path";import{readAreaMap as E,readBugPolicy as F}from"../../config/loader/bugs.js";import{loadTestkitConfig as R}from"../../config/loader/index.js";import{findIntroducingCommit as S,listRemoteRefsContaining as C,listUncommittedTrackedFiles as I}from"../../repo/history.js";import{listCommitFiles as A}from"../../repo/tree-listing.js";import{resolveTracker as x}from"../tracker/index.js";import{resolveFaultArea as B}from"./areas.js";import{toProductPath as c}from"./draft.js";import{FACTS_END_MARKER as N,FACTS_START_MARKER as D,readFactsBlock as O,renderFactsBlock as m}from"./facts.js";import{bugFacts as v,bugLabels as _,renderBugIssue as L,reproduceCommand as p,TESTKIT_LABEL as M}from"./issue.js";import{findKnownTest as j,readKnownTests as J}from"./known-failures.js";import{holdsRedaction as P,redactBugText as h}from"../../tracker/redaction.js";import{bugDraftFile as K,displayPath as g,readStoredDrafts as q,resolveBugCheckout as H,writeJsonFile as U}from"./store.js";const W=12,w=10;export async function prepareBugFiling(r){const t=await H(r),{draft:e,file:i}=G(t,r.id);if(e.filed!==void 0)return{draft:e,issue:{number:e.filed.number,url:e.filed.url},status:"already-filed"};requireNoSecretInCommand(e);const a=T(t.headCommit),n=await A(t.repoRoot,t.headCommit);for(const u of[e.test.file,e.fault.path])if(!n.some(k=>k.path===u))throw new Error(`${u} is not in commit ${a}. Commit it and push the commit, then run this again.`);const o=(await I(t.repoRoot)).filter(u=>!Q(t,u));if(o.length>0)throw new Error(`Tracked files have uncommitted changes: ${V(o)}. The issue says that the test fails at ${a}, so commit or discard the changes first.`);if((await C(t.repoRoot,t.headCommit)).length===0)throw new Error(`No remote branch contains commit ${a}. Push it, then run this again. The issue points readers to the test at that commit.`);const{config:d}=await R(t.productDir),l=z(B(E(d),n,e.fault.path),e.fault.path),f=await S(t.repoRoot,t.headCommit,e.fault.path,e.fault.lines),s=await x({env:r.env,productDir:t.productDir,repository:F(d).repository??void 0});return b(await $(s),e.test),{checkout:t,draft:e,draftFile:i,filing:{area:l,foundAt:t.headCommit,introducedBy:f.status==="found"?f.commit:null},status:"ready",testFile:y.join(t.productDir,c(t.productPath,e.test.file)),tracker:s}}export async function fileBug(r,t,e={}){const{checkout:i,draft:a,filing:n,tracker:o}=r,d=requireRecordedFailure(a,t,T(i.headCommit));b(await $(o),d);const l=d.script===!0?"file":"test",f=renderReadableBugIssue(a,n,l);await o.ensureLabels(_(a.severity,n.area));const s=await o.createIssue(f),u={...a,filed:{filedAt:(e.at??new Date).toISOString(),foundAt:n.foundAt,number:s.number,url:s.url}};return U(r.draftFile,u),{area:n.area,covers:l,draft:u,introducedBy:n.introducedBy,issue:{number:s.number,url:s.url},path:g(i,r.draftFile)}}export function renderReadableBugIssue(r,t,e){const i=L(r,t,e),a=O(i.body),n=m(v(r,t,e));if(a.status==="ok"&&m(a.facts)===n)return i;throw new Error(`Testkit cannot read back the facts of the issue it would file, so it files nothing. This happens when test.title, test.file, fault.path, or the area's name holds ${D} or ${N}. Rename the test, the file, or the area, then record the bug again.`)}export function requireNoSecretInCommand(r){const t=p(r);if(h(t)!==t)throw new Error("The command that reproduces this bug holds text that Testkit redacts as a secret, and an issue carries that command. The text is in the path of the product directory or in the test's path from that directory. Rename the directory that holds it, commit and push the change, then record the bug again.")}export function requireRecordedFailure(r,t,e){const{file:i,title:a}=r.test,n=`\`${p(r)}\``;if(t.kind==="passed")throw new Error(`${i} passed at ${e}, so Testkit has no failure to file. ${n} must fail.`);if(t.kind==="run-failed")throw new Error(`${n} failed outside its tests at ${e}: ${t.reason}. Testkit files a bug when the test itself fails.`);const o=t.failures.filter(s=>s.file===i);if(o.length===0)throw new Error(`${n} failed at ${e}, but ${i} is not among the files that failed.`);const d=o.flatMap(s=>s.title===null?[]:[s.title]),l=d.filter(s=>!/[\r\n]/u.test(s)&&!P(s)&&h(s)===s);if(l.includes(a))return{file:i,title:a};const f=o.some(s=>s.script===!0);if(d.length>0){const s=d.length-l.length;throw new Error([`${i} failed at ${e}, but not under the title ${JSON.stringify(a)}.`,...l.length===0?[]:[`Testkit reported ${l.map(u=>JSON.stringify(u)).join(", ")}. Set test.title to the title Testkit reports, then record the bug again.`],...s===0?[]:[`Testkit ${l.length===0?"":"also "}reported ${s===1?"1 title that spans lines or holds":`${String(s)} titles that span lines or hold`} text it redacts as a secret. It files no bug under such a title. Rename the test, or label the assertion, so that its title is one line and holds no such text.`],...f?["The script also failed as a whole. Testkit files a script failure only when the file reports no named failure."]:[]].join(" "))}if(f)return{file:i,script:!0,title:null};throw new Error(`${i} failed at ${e} without reaching a result, so Testkit cannot tell that ${JSON.stringify(a)} failed. A file fails this way when it crashes, times out, or does not load. Make the test fail on its assertion, then run this again.`)}async function $(r){return J(await r.listOpenIssues(M)).tests}function b(r,t){const e=j(r,t);if(e===null)return;const i=`Open issue #${String(e.issue)}`;throw new Error(e.title===null?`${i} already covers the whole file ${t.file}: ${e.url}. While it is open, Testkit files no other bug for that file.`:`${i} already covers this test: ${e.url}. Testkit keeps one open bug for each test.`)}function G(r,t){const e=q(r.productDir),i=e.drafts.find(o=>o.draft.id===t);if(i!==void 0)return i;const a=e.unreadable.find(o=>o.file===K(r.productDir,t));if(a!==void 0)throw new Error(`Testkit cannot read ${g(r,a.file)}: ${a.problem}. Record the bug again with \`testkit bug record\`.`);const n=e.drafts.filter(o=>o.draft.filed===void 0).map(o=>o.draft.id);throw new Error(n.length===0?`No bug draft has the id ${JSON.stringify(t)}, and this checkout has no unfiled draft. Record the bug with \`testkit bug record\` first.`:`No bug draft has the id ${JSON.stringify(t)}. Unfiled drafts: ${n.join(", ")}.`)}function z(r,t){if(r.status==="no-map")return null;const e="Fix `areas` in testkit.config.ts, commit and push the change, then run this again.";if(r.status==="resolved"){const{area:i}=r;if(h(i.name)===i.name)return i;throw new Error(`areas.${i.id}.name holds text that Testkit redacts as a secret, and an issue carries its area's name. ${e}`)}throw new Error(r.status==="unclaimed"?`No area claims ${t}. Every bug needs its fault in one area. ${e}`:`The areas ${r.areas.join(" and ")} both claim ${t}. A file belongs to one area. ${e}`)}function Q(r,t){return c(r.productPath,t).startsWith(".testkit/")}function V(r){const t=r.length-w;return`${r.slice(0,w).join(", ")}${t>0?`, and ${String(t)} more`:""}`}function T(r){return r.slice(0,W)}
@@ -0,0 +1,22 @@
1
+ import { type BugPolicy } from "../../config/loader/bugs.js";
2
+ import type { FailedTest, KnownFailure } from "./known-failures.js";
3
+ import { type RunDocumentReading } from "./run-reading.js";
4
+ export interface KnownFailureGateInput {
5
+ readonly env: Readonly<NodeJS.ProcessEnv>;
6
+ readonly exitCode: number;
7
+ readonly failedOutsideTests: boolean;
8
+ readonly policy: BugPolicy;
9
+ readonly productDir: string;
10
+ readonly run: RunDocumentReading;
11
+ }
12
+ export interface KnownFailureReport {
13
+ readonly known: readonly KnownFailure[];
14
+ readonly new: readonly FailedTest[];
15
+ readonly trackerProblem: string | null;
16
+ }
17
+ export interface KnownFailureGate {
18
+ readonly exitCode: number;
19
+ readonly knownFailures: KnownFailureReport | null;
20
+ }
21
+ export declare function loadBugPolicy(productDir: string): Promise<BugPolicy>;
22
+ export declare function applyKnownFailures(input: KnownFailureGateInput): Promise<KnownFailureGate>;
@@ -0,0 +1 @@
1
+ import{readBugPolicy as f}from"../../config/loader/bugs.js";import{loadTestkitConfig as m}from"../../config/loader/index.js";import{resolveTracker as p}from"../tracker/index.js";import{assessRun as w}from"./assessment.js";import{readRun as k}from"./run-reading.js";import{resolveBugCheckout as g}from"./store.js";const h=1;export async function loadBugPolicy(o){return f((await m(o)).config)}export async function applyKnownFailures(o){const{env:e,exitCode:r,policy:n,productDir:t}=o,i={exitCode:r,knownFailures:null};if(n.knownFailures!=="allow"||r!==h||o.failedOutsideTests)return i;const l=await g({dir:t,env:e}).catch(()=>null),u=k(o.run,l?.productPath??""),{outcome:s,trackerProblem:d}=await w(u,()=>p({env:e,productDir:t,repository:n.repository??void 0}));if(s.kind!=="tests-failed")return i;const{known:a,new:c}=s.classification;return{exitCode:c.length===0&&a.length>0?0:r,knownFailures:{known:a,new:c,trackerProblem:d}}}
@@ -0,0 +1,20 @@
1
+ import { type BugCategoryList, type BugSeverity } from "./categories.js";
2
+ import { type BugReport } from "./draft.js";
3
+ export interface BugGuideField {
4
+ readonly meaning: string;
5
+ readonly name: string;
6
+ }
7
+ export interface BugGuideSeverity {
8
+ readonly definition: string;
9
+ readonly id: BugSeverity;
10
+ }
11
+ export interface BugGuide {
12
+ readonly categoryLists: readonly BugCategoryList[];
13
+ readonly example: BugReport;
14
+ readonly fields: readonly BugGuideField[];
15
+ readonly rules: readonly string[];
16
+ readonly severities: readonly BugGuideSeverity[];
17
+ }
18
+ export declare const BUG_RECORD_EXAMPLE: BugReport;
19
+ export declare const BUG_GUIDE: BugGuide;
20
+ export declare function renderBugGuide(guide: BugGuide): string;
@@ -0,0 +1,8 @@
1
+ import{BUG_CAUSES as s,BUG_CONFIDENCES as c,BUG_ESCAPES as a,BUG_IMPACTS as n,BUG_SEVERITIES as o}from"./categories.js";import{BUG_TITLE_MAX_LENGTH as r}from"./draft.js";import{SEVERITY_LABELS as d}from"./issue.js";export const BUG_RECORD_EXAMPLE={title:"A refund pays out twice when two requests overlap",severity:"high",expected:"A refund pays out once.",authority:'`docs/refunds.md`, "Refunds are idempotent on the order id".',observed:"Two overlapping refund requests for one order both pay out.",rootCause:"`refund()` reads the order's refund state and writes the payout without a lock.",test:{file:"src/checkout/__testkit__/refund.contract.testkit.ts",title:"a refund pays out once"},fault:{path:"src/checkout/refunds.ts",lines:[41,58]},cause:"race-condition",impact:"financial-error",confidence:{cause:"high",impact:"high"},escape:"no-test"};const h=e=>e.map(t=>`\`${t}\``).join(", "),l=e=>`One of ${h(c)}. How sure you are of the ${e}.`,i=e=>({meaning:`One id from the ${e.field} list below.`,name:e.field});export const BUG_GUIDE={fields:[{name:"title",meaning:`One line of at most ${String(r)} characters that names the broken behaviour. It becomes the issue title.`},{name:"severity",meaning:`One of ${h(o)}. The severities are defined below.`},{name:"expected",meaning:"What the product should do."},{name:"authority",meaning:"The source that says so: documentation, a specification, an API contract, or the code's own stated intent. Name it and quote the part that applies."},{name:"observed",meaning:"What the product does instead."},{name:"rootCause",meaning:"Why the code behaves this way."},{name:"test.file",meaning:"The Testkit test file that fails because of the bug."},{name:"test.title",meaning:`The failure's title, or what the script tests when a k6 script fails as a whole. It is one line of at most ${String(r)} characters.`},{name:"fault.path",meaning:"The source file that holds the mistake."},{name:"fault.lines",meaning:"The first and last faulty line as `[start, end]`, counted from 1. One faulty line is `[41, 41]`."},i(s),i(n),{name:"confidence.cause",meaning:l("cause")},{name:"confidence.impact",meaning:l("impact")},i(a)],rules:["Write `test.file` and `fault.path` relative to the product directory, the directory that holds `testkit.config.ts`.","`test.title` must be the failure's title exactly as Testkit reports it. In a Vitest or Playwright file, that is the names of the `describe` blocks around the test and the test's own name, joined by single spaces. In a k6 file, that is the label of the failed assertion. A failed assertion on a response that has no label is named by its request and its default title, such as `GET /health > status is 201`. When `testkit bug file` rejects the title, its message lists the titles a bug record can use.","A k6 script that fails as a whole reports a script failure, which has no title. It fails that way when it calls `fail()`, throws while it runs, fails a k6 `check()`, or fails a threshold. When the script reports no named failure, write in `test.title` what the script tests. Testkit then files one issue that covers the whole file. While that issue is open, every named failure in the file and its script failure are known.","Keep credentials, tokens, and passwords out of the record. Testkit redacts the secrets it recognises before it stores the record and again before it files the issue. It files no bug under a failure's title that holds text it redacts, so rename such a test or label such an assertion first."],example:BUG_RECORD_EXAMPLE,severities:o.map(e=>({definition:d[e].description,id:e})),categoryLists:[s,n,a]};export function renderBugGuide(e){return["## Bug record","Write one JSON object for each confirmed bug. Every field is required.",e.fields.map(t=>`- \`${t.name}\`: ${t.meaning}`).join(`
2
+ `),...e.rules,"Example:",["```json",JSON.stringify(e.example,null,2),"```"].join(`
3
+ `),"## Severities",e.severities.map(t=>`- \`${t.id}\`: ${t.definition}`).join(`
4
+ `),...e.categoryLists.flatMap(u)].join(`
5
+
6
+ `)}function u(e){return[`## ${e.field.charAt(0).toUpperCase()}${e.field.slice(1)}`,`The \`${e.field}\` field answers one question. ${e.question}`,e.assignmentRule,e.categories.map(t=>[`- \`${t.id}\`: ${t.definition}`,...t.boundaryRule===""?[]:[` Boundary rule: ${t.boundaryRule}`]].join(`
7
+ `)).join(`
8
+ `)]}
@@ -0,0 +1,18 @@
1
+ import type { TrackerIssueDraft, TrackerLabel } from "../../tracker/types.js";
2
+ import type { BugSeverity } from "./categories.js";
3
+ import { type BugDraft } from "./draft.js";
4
+ import { type BugFacts, type BugFactsArea } from "./facts.js";
5
+ export declare const TESTKIT_LABEL = "testkit";
6
+ export declare const SEVERITY_LABEL_PREFIX = "severity:";
7
+ export declare const AREA_LABEL_PREFIX = "area:";
8
+ export declare const SEVERITY_LABELS: Readonly<Record<BugSeverity, Omit<TrackerLabel, "name">>>;
9
+ export interface BugFiling {
10
+ readonly area: BugFactsArea | null;
11
+ readonly foundAt: string;
12
+ readonly introducedBy: string | null;
13
+ }
14
+ export declare function bugLabels(severity: BugSeverity, area: BugFactsArea | null): readonly TrackerLabel[];
15
+ export type BugCoverage = "file" | "test";
16
+ export declare function bugFacts(draft: BugDraft, filing: BugFiling, covers?: BugCoverage): BugFacts;
17
+ export declare function renderBugIssue(recorded: BugDraft, filing: BugFiling, covers?: BugCoverage): TrackerIssueDraft;
18
+ export declare function reproduceCommand(draft: Pick<BugDraft, "productPath" | "test">): string;