@workit-dev/core 0.0.1 → 1.0.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 (234) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +22 -43
  3. package/package.json +56 -6
  4. package/schemas/config.json +74 -0
  5. package/schemas/vcs.json +79 -0
  6. package/schemas/workspaces.json +1029 -0
  7. package/schemas/youtrack.json +84 -0
  8. package/scripts/analyze-release-scope.ts +215 -0
  9. package/scripts/bundled-deps.json +131 -0
  10. package/scripts/fixtures/sample-plan.md +15 -0
  11. package/scripts/fixtures/sample-sdd/progress.md +2 -0
  12. package/scripts/install-codex-plugin.sh +28 -0
  13. package/scripts/install-cursor-plugin.sh +146 -0
  14. package/scripts/install-opencode-plugin.sh +79 -0
  15. package/scripts/install-pi-package.sh +56 -0
  16. package/scripts/lib/config-dir.sh +28 -0
  17. package/scripts/publish-changed-packages.ts +395 -0
  18. package/scripts/rewrite-workspace-deps.ts +53 -0
  19. package/scripts/run-cursor-mcp.sh +6 -0
  20. package/scripts/sync-release-manifests.ts +82 -0
  21. package/scripts/sync-runtime.sh +142 -0
  22. package/scripts/validate-cursor-marketplace.ts +171 -0
  23. package/scripts/verify-bundle-sources.ts +134 -0
  24. package/scripts/write-schemas.ts +16 -0
  25. package/skills/workit-architecture/SKILL.md +72 -0
  26. package/skills/workit-architecture/agents/openai.yaml +3 -0
  27. package/skills/workit-architecture/references/instruction-files.md +52 -0
  28. package/skills/workit-architecture/references/vocabulary.md +53 -0
  29. package/skills/workit-bdd/SKILL.md +53 -0
  30. package/skills/workit-continue/SKILL.md +54 -0
  31. package/skills/workit-debug/SKILL.md +45 -0
  32. package/skills/workit-deslop/SKILL.md +58 -0
  33. package/skills/workit-fanout/SKILL.md +71 -0
  34. package/skills/workit-fanout/references/brief.md +141 -0
  35. package/skills/workit-fanout/references/orchestration.md +169 -0
  36. package/skills/workit-implement/SKILL.md +71 -0
  37. package/skills/workit-retro/SKILL.md +72 -0
  38. package/skills/workit-retro/agents/openai.yaml +3 -0
  39. package/skills/workit-retro/references/sources.md +35 -0
  40. package/skills/workit-retro/references/steering.md +39 -0
  41. package/skills/workit-review/SKILL.md +66 -0
  42. package/skills/workit-review/references/impact.md +24 -0
  43. package/skills/workit-shape/SKILL.md +71 -0
  44. package/skills/workit-shape/references/diagrams.md +17 -0
  45. package/skills/workit-shape/references/knowledge.md +61 -0
  46. package/skills/workit-shape/references/mockups.md +15 -0
  47. package/skills/workit-shape/references/slicing.md +44 -0
  48. package/skills/workit-ship/SKILL.md +71 -0
  49. package/skills/workit-ship/references/changelog.md +44 -0
  50. package/skills/workit-ship/references/pr-body.md +32 -0
  51. package/skills/workit-ship/references/release.md +39 -0
  52. package/skills/workit-test-audit/SKILL.md +54 -0
  53. package/skills/workit-verify-app/SKILL.md +68 -0
  54. package/skills/workit-verify-app/references/template.md +51 -0
  55. package/src/autonomy.ts +910 -0
  56. package/src/check-config.ts +613 -0
  57. package/src/checks.ts +534 -0
  58. package/src/ci-waiter.ts +159 -0
  59. package/src/core/boundary.ts +55 -0
  60. package/src/core/branch-policy.ts +112 -0
  61. package/src/core/branch.ts +1192 -0
  62. package/src/core/changelog-gate.ts +349 -0
  63. package/src/core/changelog-merge.ts +214 -0
  64. package/src/core/changelog.ts +392 -0
  65. package/src/core/cleanup.ts +638 -0
  66. package/src/core/cli-locate.ts +173 -0
  67. package/src/core/commit-flavors.ts +65 -0
  68. package/src/core/config-schemas.ts +653 -0
  69. package/src/core/config.ts +351 -0
  70. package/src/core/context-read.ts +144 -0
  71. package/src/core/docs-gate.ts +69 -0
  72. package/src/core/finding-evidence.ts +89 -0
  73. package/src/core/finding-queue.ts +36 -0
  74. package/src/core/git.ts +73 -0
  75. package/src/core/gitignore.ts +52 -0
  76. package/src/core/heavy-eta.ts +170 -0
  77. package/src/core/heavy-lease.ts +290 -0
  78. package/src/core/heavy-rules.ts +373 -0
  79. package/src/core/heavy-scope.ts +131 -0
  80. package/src/core/heavy-words.ts +83 -0
  81. package/src/core/heavy.ts +535 -0
  82. package/src/core/hygiene.ts +149 -0
  83. package/src/core/init.ts +218 -0
  84. package/src/core/live-session.ts +333 -0
  85. package/src/core/logger.ts +310 -0
  86. package/src/core/methods.ts +185 -0
  87. package/src/core/model-fallback.ts +208 -0
  88. package/src/core/node-resolve.mjs +249 -0
  89. package/src/core/operation-input.ts +632 -0
  90. package/src/core/package-root.ts +28 -0
  91. package/src/core/policy/attributes.ts +97 -0
  92. package/src/core/policy/bookkeeping.ts +119 -0
  93. package/src/core/policy/carry.ts +122 -0
  94. package/src/core/policy/check-evidence.ts +115 -0
  95. package/src/core/policy/current-judgment.ts +20 -0
  96. package/src/core/policy/derive.ts +387 -0
  97. package/src/core/policy/judgment.ts +246 -0
  98. package/src/core/policy/lane.ts +562 -0
  99. package/src/core/policy/pr-lane.ts +32 -0
  100. package/src/core/policy/substance.ts +365 -0
  101. package/src/core/policy-resolver.ts +94 -0
  102. package/src/core/pr-create.ts +448 -0
  103. package/src/core/pr-template.ts +162 -0
  104. package/src/core/release-notes.ts +197 -0
  105. package/src/core/release-tracks.ts +779 -0
  106. package/src/core/remotes.ts +436 -0
  107. package/src/core/repo-context.ts +542 -0
  108. package/src/core/repo-paths.ts +29 -0
  109. package/src/core/resources.ts +1299 -0
  110. package/src/core/role-models.ts +603 -0
  111. package/src/core/route-intent.ts +305 -0
  112. package/src/core/rules.ts +65 -0
  113. package/src/core/runtime-identity.ts +63 -0
  114. package/src/core/safe-write.ts +87 -0
  115. package/src/core/scope-glob.ts +56 -0
  116. package/src/core/scripts.ts +6 -0
  117. package/src/core/skill-manifests.ts +173 -0
  118. package/src/core/store-lock.ts +374 -0
  119. package/src/core/support-matrix.ts +24 -0
  120. package/src/core/task-context.ts +247 -0
  121. package/src/core/task-contract.ts +1279 -0
  122. package/src/core/task-delivery.ts +109 -0
  123. package/src/core/task-engine.ts +2248 -0
  124. package/src/core/task-evaluation.ts +1147 -0
  125. package/src/core/task-store.ts +2649 -0
  126. package/src/core/task-view.ts +180 -0
  127. package/src/core/templates.ts +22 -0
  128. package/src/core/tracker-issues.ts +174 -0
  129. package/src/core/vcs-config.ts +540 -0
  130. package/src/core/workers.ts +62 -0
  131. package/src/core/workit-paths.mjs +242 -0
  132. package/src/core/workspace-branch-policy.ts +123 -0
  133. package/src/core/workspaces.ts +1745 -0
  134. package/src/core/youtrack-backend.ts +288 -0
  135. package/src/core/youtrack-tools.ts +260 -0
  136. package/src/core/youtrack-writes.ts +73 -0
  137. package/src/core/youtrack-ytrack.ts +217 -0
  138. package/src/core/youtrack.ts +454 -0
  139. package/src/core/ytrack-skill.ts +297 -0
  140. package/src/core.ts +171 -0
  141. package/src/deslop/lex.ts +271 -0
  142. package/src/deslop/scan.ts +232 -0
  143. package/src/duration.ts +27 -0
  144. package/src/fanout-brief.ts +486 -0
  145. package/src/fanout-check.ts +455 -0
  146. package/src/fanout-landed.ts +370 -0
  147. package/src/fanout-stack.ts +108 -0
  148. package/src/fanout-status.ts +362 -0
  149. package/src/fanout-worktree.ts +893 -0
  150. package/src/fanout.ts +1473 -0
  151. package/src/forge/exec.ts +385 -0
  152. package/src/forge/github.ts +831 -0
  153. package/src/forge/gitlab.ts +906 -0
  154. package/src/forge/issue-forge.ts +209 -0
  155. package/src/forge/issue-link.ts +468 -0
  156. package/src/forge/issue-ops.ts +314 -0
  157. package/src/forge/merge-gate-base-red.ts +72 -0
  158. package/src/forge/pr-body.ts +100 -0
  159. package/src/forge/pr-edit.ts +292 -0
  160. package/src/forge/pr-ops.ts +1109 -0
  161. package/src/forge/pr-status.ts +10 -0
  162. package/src/forge/redact.ts +195 -0
  163. package/src/forge/report.ts +994 -0
  164. package/src/forge/reruns.ts +132 -0
  165. package/src/forge/resolve.ts +509 -0
  166. package/src/forge/types.ts +242 -0
  167. package/src/forge/verify.ts +398 -0
  168. package/src/git/default-base.ts +130 -0
  169. package/src/git/env-redirect.ts +28 -0
  170. package/src/git/facts.ts +122 -0
  171. package/src/git/ops.ts +1823 -0
  172. package/src/git/refs-read.ts +332 -0
  173. package/src/git/release.ts +1020 -0
  174. package/src/git/rev.ts +1175 -0
  175. package/src/git/session-hook.ts +597 -0
  176. package/src/git/stage.ts +63 -0
  177. package/src/hooks/agent-model.ts +149 -0
  178. package/src/hooks/budget-signal.ts +284 -0
  179. package/src/hooks/context.ts +461 -0
  180. package/src/hooks/cwd-normalize.ts +79 -0
  181. package/src/hooks/deadline.ts +228 -0
  182. package/src/hooks/delivery-verbs.ts +182 -0
  183. package/src/hooks/descriptor.ts +124 -0
  184. package/src/hooks/handle.ts +433 -0
  185. package/src/hooks/heavy-gate.ts +99 -0
  186. package/src/hooks/hook-runs.ts +130 -0
  187. package/src/hooks/hosts/claude-code.ts +380 -0
  188. package/src/hooks/hosts/codex.ts +372 -0
  189. package/src/hooks/hosts/cursor.ts +419 -0
  190. package/src/hooks/hosts/fields.ts +98 -0
  191. package/src/hooks/hosts/opencode.ts +79 -0
  192. package/src/hooks/hosts/pi.ts +78 -0
  193. package/src/hooks/index.ts +60 -0
  194. package/src/hooks/model-limit.ts +378 -0
  195. package/src/hooks/model-switch.ts +35 -0
  196. package/src/hooks/policy.ts +29 -0
  197. package/src/hooks/process.ts +48 -0
  198. package/src/hooks/protocol.ts +135 -0
  199. package/src/hooks/raw-git.ts +2289 -0
  200. package/src/hooks/repeat-failure.ts +115 -0
  201. package/src/hooks/run.ts +54 -0
  202. package/src/hooks/session-marker.ts +151 -0
  203. package/src/hooks/shell-words.ts +532 -0
  204. package/src/hooks/skill-nudge.ts +295 -0
  205. package/src/hooks/spawn-gate.ts +72 -0
  206. package/src/hooks/standing-signal.ts +99 -0
  207. package/src/hooks/standing.ts +225 -0
  208. package/src/hooks/steering.ts +74 -0
  209. package/src/hooks/stop.ts +408 -0
  210. package/src/hooks/worktree-confine.ts +415 -0
  211. package/src/hooks/write-gate.ts +1174 -0
  212. package/src/host-session.ts +459 -0
  213. package/src/knowledge.ts +368 -0
  214. package/src/ledger.ts +2913 -0
  215. package/src/stack.ts +2570 -0
  216. package/src/store/event-log.ts +217 -0
  217. package/src/store/notes.ts +29 -0
  218. package/src/store/patch.ts +135 -0
  219. package/src/store/paths.ts +368 -0
  220. package/src/store/reduce.ts +203 -0
  221. package/src/test-audit-mocks.ts +309 -0
  222. package/src/verdict-cover.ts +151 -0
  223. package/src/worktree-deps.ts +142 -0
  224. package/templates/headers.md +3 -0
  225. package/templates/hygiene/.editorconfig +8 -0
  226. package/templates/hygiene/.gitattributes +3 -0
  227. package/templates/hygiene/CHANGELOG.md +14 -0
  228. package/templates/hygiene/CONTRIBUTING.md +3 -0
  229. package/templates/hygiene/LICENSE +21 -0
  230. package/templates/hygiene/README.md +3 -0
  231. package/templates/issue-update.md +5 -0
  232. package/templates/plan-template.md +26 -0
  233. package/templates/spec-template.md +64 -0
  234. package/templates/workit-contract.md +13 -0
@@ -0,0 +1,271 @@
1
+ export type CommentStyle = "slash" | "hash" | "dash";
2
+
3
+ export type LexedComment = {
4
+ line: number;
5
+ endLine: number;
6
+ /** The comment body without its delimiters. */
7
+ text: string;
8
+ block: boolean;
9
+ /** `/**`, `///` or `//!`: a doc comment. */
10
+ doc: boolean;
11
+ /** Code precedes the comment on its first line. */
12
+ trailing: boolean;
13
+ };
14
+
15
+ export type Lexed = {
16
+ comments: LexedComment[];
17
+ /** Each line's code with comments removed and string bodies blanked, 1-based. */
18
+ code: string[];
19
+ };
20
+
21
+ const SLASH = new Set([
22
+ "ts",
23
+ "tsx",
24
+ "mts",
25
+ "cts",
26
+ "js",
27
+ "jsx",
28
+ "mjs",
29
+ "cjs",
30
+ "go",
31
+ "rs",
32
+ "java",
33
+ "kt",
34
+ "kts",
35
+ "swift",
36
+ "c",
37
+ "h",
38
+ "cc",
39
+ "cpp",
40
+ "hpp",
41
+ "cs",
42
+ "scala",
43
+ "dart",
44
+ "php",
45
+ "scss",
46
+ "less",
47
+ "css",
48
+ "jsonc",
49
+ ]);
50
+ const HASH = new Set([
51
+ "py",
52
+ "sh",
53
+ "bash",
54
+ "zsh",
55
+ "fish",
56
+ "yaml",
57
+ "yml",
58
+ "toml",
59
+ "rb",
60
+ "pl",
61
+ "r",
62
+ "ps1",
63
+ "nix",
64
+ "tf",
65
+ "hcl",
66
+ ]);
67
+ const DASH = new Set(["sql", "lua", "hs"]);
68
+ const HASH_NAMES = new Set(["dockerfile", "makefile", "containerfile", "justfile"]);
69
+ const REGEX_LANGUAGES = new Set(["ts", "tsx", "mts", "cts", "js", "jsx", "mjs", "cjs"]);
70
+ const TEMPLATE_LANGUAGES = new Set([...REGEX_LANGUAGES, "go"]);
71
+
72
+ export function commentStyle(file: string): CommentStyle | null {
73
+ const base = file.split("/").at(-1)?.toLowerCase() ?? "";
74
+ if (HASH_NAMES.has(base) || base.startsWith("dockerfile.")) return "hash";
75
+ const ext = base.includes(".") ? base.slice(base.lastIndexOf(".") + 1) : "";
76
+ if (SLASH.has(ext)) return "slash";
77
+ if (HASH.has(ext)) return "hash";
78
+ if (DASH.has(ext)) return "dash";
79
+ return null;
80
+ }
81
+
82
+ const extension = (file: string): string => {
83
+ const base = file.split("/").at(-1)?.toLowerCase() ?? "";
84
+ return base.includes(".") ? base.slice(base.lastIndexOf(".") + 1) : "";
85
+ };
86
+
87
+ const REGEX_PRECEDERS = new Set("(,=:[!&|?{};+-*%<>~^".split(""));
88
+ const REGEX_KEYWORDS = /\b(?:return|typeof|case|do|else|in|of|void|yield|await)$/;
89
+
90
+ /**
91
+ * The comments of `source`, found without a parser per language. Strings,
92
+ * template literals, regex literals and Python triple quotes are skipped, so
93
+ * a `//` in a URL or a fixture string is never read as a comment. Null when
94
+ * the file's language has no known comment syntax.
95
+ */
96
+ export function lexComments(file: string, source: string): Lexed | null {
97
+ const style = commentStyle(file);
98
+ if (!style) return null;
99
+ const ext = extension(file);
100
+ const regexes = REGEX_LANGUAGES.has(ext);
101
+ const templates = TEMPLATE_LANGUAGES.has(ext);
102
+ const blocks = style === "slash";
103
+ const python = ext === "py";
104
+ const wordStartHash = style === "hash" && !python;
105
+ const comments: LexedComment[] = [];
106
+ const code: string[] = [""];
107
+ let line = 1;
108
+ let lineCode = "";
109
+ let lastSignificant = "";
110
+ const braces: number[] = [];
111
+ let index = 0;
112
+
113
+ const newline = () => {
114
+ code[line] = lineCode;
115
+ line += 1;
116
+ lineCode = "";
117
+ };
118
+ const advanceOver = (text: string) => {
119
+ for (const char of text) if (char === "\n") newline();
120
+ };
121
+
122
+ while (index < source.length) {
123
+ const char = source[index];
124
+ const next = source[index + 1] ?? "";
125
+ if (char === "\n") {
126
+ newline();
127
+ index += 1;
128
+ continue;
129
+ }
130
+ const trailing = lineCode.trim() !== "";
131
+ if (blocks && char === "/" && next === "/") {
132
+ const end = source.indexOf("\n", index);
133
+ const stop = end === -1 ? source.length : end;
134
+ const raw = source.slice(index + 2, stop);
135
+ const doc = raw.startsWith("/") || raw.startsWith("!");
136
+ comments.push({
137
+ line,
138
+ endLine: line,
139
+ text: doc ? raw.slice(1) : raw,
140
+ block: false,
141
+ doc,
142
+ trailing,
143
+ });
144
+ index = stop;
145
+ continue;
146
+ }
147
+ if (blocks && char === "/" && next === "*") {
148
+ const end = source.indexOf("*/", index + 2);
149
+ const stop = end === -1 ? source.length : end + 2;
150
+ const raw = source.slice(index + 2, end === -1 ? source.length : end);
151
+ const start = line;
152
+ advanceOver(source.slice(index, stop));
153
+ const doc = raw.startsWith("*") && !raw.startsWith("**");
154
+ comments.push({
155
+ line: start,
156
+ endLine: line,
157
+ text: raw
158
+ .replace(/^\*/, "")
159
+ .split("\n")
160
+ .map((part) => part.replace(/^\s*\*?/, ""))
161
+ .join("\n"),
162
+ block: true,
163
+ doc,
164
+ trailing,
165
+ });
166
+ index = stop;
167
+ continue;
168
+ }
169
+ const hashStart =
170
+ style === "hash" &&
171
+ char === "#" &&
172
+ !(line === 1 && index === 0 && next === "!") &&
173
+ (!wordStartHash || index === 0 || /\s/.test(source[index - 1]));
174
+ if (hashStart || (style === "dash" && char === "-" && next === "-")) {
175
+ const end = source.indexOf("\n", index);
176
+ const stop = end === -1 ? source.length : end;
177
+ comments.push({
178
+ line,
179
+ endLine: line,
180
+ text: source.slice(index + (hashStart ? 1 : 2), stop),
181
+ block: false,
182
+ doc: false,
183
+ trailing,
184
+ });
185
+ index = stop;
186
+ continue;
187
+ }
188
+ if (python && (source.startsWith('"""', index) || source.startsWith("'''", index))) {
189
+ const quote = source.slice(index, index + 3);
190
+ const end = source.indexOf(quote, index + 3);
191
+ const stop = end === -1 ? source.length : end + 3;
192
+ advanceOver(source.slice(index, stop));
193
+ lineCode += '""';
194
+ lastSignificant = '"';
195
+ index = stop;
196
+ continue;
197
+ }
198
+ if (char === '"' || char === "'" || (char === "`" && templates)) {
199
+ let cursor = index + 1;
200
+ while (cursor < source.length && source[cursor] !== char) {
201
+ if (source[cursor] === "\\") cursor += 1;
202
+ else if (char !== "`" && source[cursor] === "\n") break;
203
+ else if (char === "`" && source[cursor] === "$" && source[cursor + 1] === "{") break;
204
+ cursor += 1;
205
+ }
206
+ const opensExpression = char === "`" && source.startsWith("${", cursor);
207
+ const stop = opensExpression ? cursor + 2 : Math.min(cursor + 1, source.length);
208
+ advanceOver(source.slice(index, stop));
209
+ lineCode += `${char}${char}`;
210
+ lastSignificant = char;
211
+ if (opensExpression) braces.push(0);
212
+ index = stop;
213
+ continue;
214
+ }
215
+ if (braces.length > 0 && (char === "{" || char === "}")) {
216
+ if (char === "{") braces[braces.length - 1] += 1;
217
+ else if (braces.at(-1) === 0) {
218
+ braces.pop();
219
+ index = resumeTemplate(index + 1);
220
+ continue;
221
+ } else braces[braces.length - 1] -= 1;
222
+ }
223
+ if (
224
+ regexes &&
225
+ char === "/" &&
226
+ (lastSignificant === "" ||
227
+ REGEX_PRECEDERS.has(lastSignificant) ||
228
+ REGEX_KEYWORDS.test(lineCode.trimEnd()))
229
+ ) {
230
+ let cursor = index + 1;
231
+ let inClass = false;
232
+ while (cursor < source.length && source[cursor] !== "\n") {
233
+ const current = source[cursor];
234
+ if (current === "\\") cursor += 1;
235
+ else if (current === "[") inClass = true;
236
+ else if (current === "]") inClass = false;
237
+ else if (current === "/" && !inClass) break;
238
+ cursor += 1;
239
+ }
240
+ if (source[cursor] === "/") {
241
+ lineCode += "/regex/";
242
+ lastSignificant = "/";
243
+ index = cursor + 1;
244
+ continue;
245
+ }
246
+ }
247
+ lineCode += char;
248
+ if (!/\s/.test(char)) lastSignificant = char;
249
+ index += 1;
250
+ }
251
+ code[line] = lineCode;
252
+ return { comments, code };
253
+
254
+ function resumeTemplate(from: number): number {
255
+ let cursor = from;
256
+ while (cursor < source.length && source[cursor] !== "`") {
257
+ if (source[cursor] === "\\") cursor += 1;
258
+ else if (source[cursor] === "$" && source[cursor + 1] === "{") {
259
+ advanceOver(source.slice(from, cursor + 2));
260
+ braces.push(0);
261
+ return cursor + 2;
262
+ }
263
+ cursor += 1;
264
+ }
265
+ const stop = Math.min(cursor + 1, source.length);
266
+ advanceOver(source.slice(from, stop));
267
+ lineCode += "``";
268
+ lastSignificant = "`";
269
+ return stop;
270
+ }
271
+ }
@@ -0,0 +1,232 @@
1
+ import type { ReviewLane } from "../core/policy/lane";
2
+ import { lexComments, type LexedComment } from "./lex";
3
+
4
+ export const DESLOP_CLASSES = ["narration", "marker", "suppression", "debug", "allowed"] as const;
5
+ export type DeslopClass = (typeof DESLOP_CLASSES)[number];
6
+
7
+ export type DeslopFinding = {
8
+ file: string;
9
+ line: number;
10
+ class: DeslopClass;
11
+ /** Why it got this class: the keep-list entry for `allowed`, the directive for a suppression. */
12
+ reason: string;
13
+ text: string;
14
+ /** A marker or lint/type suppression: the change needs at least a focused review. */
15
+ raisesLane: boolean;
16
+ };
17
+
18
+ const SUPPRESSION =
19
+ /(?:^|\s)(eslint-disable(?:-next-line|-line)?|oxlint-disable(?:-next-line|-line)?|biome-ignore(?:-all|-start|-end)?|deno-lint-ignore(?:-file)?|@ts-(?:ignore|expect-error|nocheck)|noqa|type:\s*ignore|pylint:\s*disable(?:-next)?|rubocop:\s*disable|NOLINT(?:NEXTLINE)?|NOSONAR|(?:istanbul|c8|v8)\s+ignore|workit-test-audit-ignore(?:-file)?)\b/;
20
+ const FORMATTER =
21
+ /(?:^|\s)(prettier-ignore|oxfmt-ignore|dprint-ignore|biome-ignore\s+format|fmt:\s*(?:off|on|skip)|clang-format\s+(?:off|on)|rustfmt::skip)\b/;
22
+ const TOOL_DIRECTIVE =
23
+ /^\s*(?:<reference\s|@ts-check|@jsx|@flow|@vite-ignore|webpack[A-Z]|go:(?:build|generate|embed)|#?(?:end)?region\b|-\*-\s*coding|frozen_string_literal|@refresh|shellcheck\s+(?:source|shell)=)/;
24
+ const MARKER = /\b(TODO|FIXME|XXX|HACK|TBD)\b/;
25
+ const LICENCE =
26
+ /SPDX-License-Identifier|copyright\s|\(c\)\s*\d{4}|licen[cs]ed under|all rights reserved/i;
27
+ const LINK =
28
+ /https?:\/\/\S+|(?:^|[\s(])#\d+\b|\bRFC\s?\d{3,}|\bGH-\d+\b|\b(?!(?:UTF|SHA|ISO|ES|ECMA|AES|RSA|CRC|TLS|SSL|HTTP|IPV)-)[A-Z][A-Z0-9]{1,9}-\d+\b|\bCVE-\d{4}-\d+/;
29
+ const QUIRK =
30
+ /\b(?:works? around|workaround|quirk|bug|broken|regression|does not|doesn't|never|only (?:accepts?|supports?|reads?)|rejects?|ignores?|drops?|requires?|refuses?)\b/i;
31
+ const EXTERNAL =
32
+ /\b(?:upstream|vendor|third-party|library|spec|posix|windows|macos|darwin|linux|wsl|safari|chrome|chromium|firefox|webkit|node(?:\.js)?|bun|deno|npm|pnpm|yarn|git|github|gitlab|bash|zsh|powershell|cmd\.exe|glibc|musl|electron|react|babel|typescript|tsc|eslint|oxlint|prettier|jest|vitest|ink|sqlite|postgres|docker|systemd|kernel|v?\d+\.\d+(?:\.\d+)?)\b/i;
33
+ const DEBUG_CODE =
34
+ /\bconsole\.(?:log|debug|trace|dir)\s*\(|\bdebugger\b|\bdbg!\s*\(|\bbreakpoint\s*\(\s*\)|\bpdb\.set_trace\s*\(|\bbinding\.pry\b|\bvar_dump\s*\(/;
35
+ const DEBUG_TAG = /\[DEBUG-\w+\]/;
36
+ const PUBLIC_DECLARATION =
37
+ /^\s*(?:export\b|pub(?:\(crate\))?\s|public\s|module\.exports|exports\.|def\s+[a-z]|class\s+[A-Z])/;
38
+
39
+ const words = (text: string): number => text.split(/\s+/).filter((word) => /\w/.test(word)).length;
40
+
41
+ function suppressionReason(text: string, directive: string): string {
42
+ const after = text.slice(text.indexOf(directive) + directive.length);
43
+ const separated = /(?:\s--\s*|:\s+(?=\D))(.*)$/.exec(after);
44
+ if (separated && words(separated[1]) >= 1 && !/^[\w/@-]+(?:,\s*[\w/@-]+)*$/.test(separated[1]))
45
+ return separated[1].trim();
46
+ const rest = after
47
+ .replace(/^\s*[:=]?\s*(?:\[[^\]]*\])?/, "")
48
+ .replace(/^(?:\s*[\w/@.-]*[/\d_-][\w/@.-]*,?)+/, "")
49
+ .trim();
50
+ return words(rest) >= 2 ? rest : "";
51
+ }
52
+
53
+ function classifyDirective(
54
+ text: string,
55
+ ): Pick<DeslopFinding, "class" | "reason" | "raisesLane"> | null {
56
+ const formatter = FORMATTER.exec(text);
57
+ if (formatter)
58
+ return { class: "allowed", reason: `formatter directive ${formatter[1]}`, raisesLane: false };
59
+ const suppression = SUPPRESSION.exec(text);
60
+ if (suppression) {
61
+ const directive = suppression[1];
62
+ const reason = suppressionReason(text, directive);
63
+ return reason
64
+ ? {
65
+ class: "allowed",
66
+ reason: `justified suppression ${directive}: ${reason}`,
67
+ raisesLane: true,
68
+ }
69
+ : { class: "suppression", reason: `${directive} without a reason`, raisesLane: true };
70
+ }
71
+ if (TOOL_DIRECTIVE.test(text))
72
+ return { class: "allowed", reason: "tool directive", raisesLane: false };
73
+ return null;
74
+ }
75
+
76
+ function classifyProse(
77
+ text: string,
78
+ publicDoc: boolean,
79
+ ): Pick<DeslopFinding, "class" | "reason" | "raisesLane"> {
80
+ const marker = MARKER.exec(text);
81
+ if (marker) return { class: "marker", reason: marker[1], raisesLane: true };
82
+ if (LICENCE.test(text)) return { class: "allowed", reason: "licence", raisesLane: false };
83
+ if (LINK.test(text)) return { class: "allowed", reason: "issue or RFC link", raisesLane: false };
84
+ if (publicDoc) return { class: "allowed", reason: "public API docs", raisesLane: false };
85
+ if (QUIRK.test(text) && EXTERNAL.test(text))
86
+ return { class: "allowed", reason: "external quirk", raisesLane: false };
87
+ return { class: "narration", reason: "restates or narrates the code", raisesLane: false };
88
+ }
89
+
90
+ const indent = (line: string): number => /^\s*/.exec(line)?.[0].length ?? 0;
91
+
92
+ function documentsPublicApi(code: string[], comment: LexedComment): boolean {
93
+ if (!comment.doc) return false;
94
+ let next = comment.endLine + 1;
95
+ while (next < code.length && code[next].trim() === "") next += 1;
96
+ const declaration = code[next] ?? "";
97
+ if (PUBLIC_DECLARATION.test(declaration)) return true;
98
+ const depth = indent(declaration);
99
+ if (depth === 0) return false;
100
+ for (let line = comment.line - 1; line >= 1; line -= 1) {
101
+ const text = code[line];
102
+ if (text.trim() === "" || indent(text) >= depth) continue;
103
+ return PUBLIC_DECLARATION.test(text);
104
+ }
105
+ return false;
106
+ }
107
+
108
+ type Group = { comments: LexedComment[]; doc: boolean };
109
+
110
+ function groupComments(comments: LexedComment[]): Group[] {
111
+ const groups: Group[] = [];
112
+ for (const comment of comments) {
113
+ const last = groups.at(-1)?.comments.at(-1);
114
+ const joins =
115
+ last &&
116
+ !comment.block &&
117
+ !last.block &&
118
+ !comment.trailing &&
119
+ !last.trailing &&
120
+ comment.line === last.endLine + 1 &&
121
+ comment.doc === last.doc;
122
+ if (joins) groups.at(-1)!.comments.push(comment);
123
+ else groups.push({ comments: [comment], doc: comment.doc });
124
+ }
125
+ return groups;
126
+ }
127
+
128
+ const touched = (changed: Set<number> | "all", from: number, to: number): number | null => {
129
+ for (let line = from; line <= to; line += 1)
130
+ if (changed === "all" || changed.has(line)) return line;
131
+ return null;
132
+ };
133
+
134
+ const excerpt = (text: string): string => {
135
+ const flat = text.replace(/\s+/g, " ").trim();
136
+ return flat.length > 100 ? `${flat.slice(0, 97)}...` : flat;
137
+ };
138
+
139
+ /**
140
+ * Classify the comments and debug leftovers that `changed` lines of `file`
141
+ * add. Files in a language without a known comment syntax yield nothing.
142
+ */
143
+ export function scanSource(
144
+ file: string,
145
+ source: string,
146
+ changed: Set<number> | "all",
147
+ ): DeslopFinding[] {
148
+ const lexed = lexComments(file, source);
149
+ if (!lexed) return [];
150
+ const findings: DeslopFinding[] = [];
151
+ for (const group of groupComments(lexed.comments)) {
152
+ const prose: LexedComment[] = [];
153
+ for (const comment of group.comments) {
154
+ const directive = classifyDirective(comment.text);
155
+ const line = directive ? touched(changed, comment.line, comment.endLine) : null;
156
+ if (directive && line !== null)
157
+ findings.push({ file, line, ...directive, text: excerpt(comment.text) });
158
+ else if (!directive) prose.push(comment);
159
+ }
160
+ if (prose.length === 0) continue;
161
+ const line = touched(changed, prose[0].line, prose.at(-1)!.endLine);
162
+ const text = prose.map((comment) => comment.text).join("\n");
163
+ if (line === null || text.trim() === "") continue;
164
+ const publicDoc = documentsPublicApi(lexed.code, prose.at(-1)!);
165
+ findings.push({ file, line, ...classifyProse(text, publicDoc), text: excerpt(text) });
166
+ }
167
+ const rawLines = source.split("\n");
168
+ for (let line = 1; line < lexed.code.length; line += 1) {
169
+ if (touched(changed, line, line) === null) continue;
170
+ const debug =
171
+ DEBUG_CODE.exec(lexed.code[line] ?? "") ?? DEBUG_TAG.exec(rawLines[line - 1] ?? "");
172
+ if (debug)
173
+ findings.push({
174
+ file,
175
+ line,
176
+ class: "debug",
177
+ reason: `debug leftover ${debug[0].replace(/\s*\($/, "")}`,
178
+ text: excerpt(rawLines[line - 1] ?? ""),
179
+ raisesLane: false,
180
+ });
181
+ }
182
+ return findings.toSorted((a, b) => a.line - b.line);
183
+ }
184
+
185
+ const ORDER: readonly ReviewLane[] = ["none", "self", "focused", "full"];
186
+
187
+ export type LaneRaise = { from: ReviewLane; lane: ReviewLane; reasons: string[] };
188
+
189
+ /** A marker or lint/type suppression lifts a none or self lane to focused. */
190
+ export function raiseLane(lane: ReviewLane, findings: readonly DeslopFinding[]): LaneRaise {
191
+ const raising = findings.filter((finding) => finding.raisesLane);
192
+ if (raising.length === 0 || ORDER.indexOf(lane) >= ORDER.indexOf("focused"))
193
+ return { from: lane, lane, reasons: [] };
194
+ const first = raising[0];
195
+ return {
196
+ from: lane,
197
+ lane: "focused",
198
+ reasons: [
199
+ `${raising.length} new marker(s) or suppression(s), first ${first.file}:${first.line} (${first.reason})`,
200
+ ],
201
+ };
202
+ }
203
+
204
+ export const DESLOP_PASSES = {
205
+ prose: "prose: cut filler from the PR body, docs and commit messages",
206
+ scan: "scan: workit deslop scan --diff, then delete every narration, marker and debug finding",
207
+ mechanical: "mechanical: delete dead code the repo's dead-code and lint tools report",
208
+ "no-comments":
209
+ "no-comments: a fresh-eyes reviewer re-reads every added comment against the keep list",
210
+ "test-quality": "test-quality: workit test-audit --diff, then triage every finding",
211
+ mutate: "mutate: workit test-audit --mutate --diff",
212
+ architect: "architect: one sketch of the simpler shape, reported, not applied",
213
+ } as const;
214
+ export type DeslopPass = keyof typeof DESLOP_PASSES;
215
+
216
+ /** The passes deslop runs in each lane; each lane adds to the one below it. */
217
+ export function lanePasses(lane: ReviewLane): DeslopPass[] {
218
+ if (lane === "none") return ["prose"];
219
+ const self: DeslopPass[] = ["prose", "scan", "mechanical"];
220
+ if (lane === "self") return self;
221
+ const focused: DeslopPass[] = [...self, "no-comments", "test-quality"];
222
+ return lane === "focused" ? focused : [...focused, "mutate", "architect"];
223
+ }
224
+
225
+ /** The one result line a finished deslop records for its PR. */
226
+ export const deslopResultLine = (result: {
227
+ lane: ReviewLane;
228
+ removed: number;
229
+ reshapes: number;
230
+ open: number;
231
+ }): string =>
232
+ `deslop: lane ${result.lane}, -${result.removed} lines, ${result.reshapes} reshapes, ${result.open} open`;
@@ -0,0 +1,27 @@
1
+ // One duration parser for CLI flags (`--timeout 20m`, `--stuck-after 1h30m`)
2
+ // and plan fields (a slice TIMEBOX such as `45 minutes`).
3
+
4
+ const UNITS: ReadonlyArray<[RegExp, number]> = [
5
+ [/^(?:ms|milliseconds?)$/u, 1],
6
+ [/^(?:s|secs?|seconds?)$/u, 1000],
7
+ [/^(?:m|mins?|minutes?)$/u, 60_000],
8
+ [/^(?:h|hrs?|hours?)$/u, 3_600_000],
9
+ ];
10
+
11
+ /**
12
+ * `20m`, `30s`, `1500ms`, `1h`, `1h30m`, `45 minutes`, `2 hours`, or bare
13
+ * seconds, in ms; null when it does not read as a duration.
14
+ */
15
+ export function parseDuration(value: string): number | null {
16
+ const text = value.trim().toLowerCase();
17
+ if (/^\d+(?:\.\d+)?$/u.test(text)) return Math.round(Number(text) * 1000);
18
+ const parts = [...text.matchAll(/(\d+(?:\.\d+)?)\s*([a-z]+)\s*/gu)];
19
+ if (parts.length === 0 || parts.map((part) => part[0]).join("") !== text) return null;
20
+ let total = 0;
21
+ for (const [, amount, unit] of parts) {
22
+ const factor = UNITS.find(([pattern]) => pattern.test(unit))?.[1];
23
+ if (factor === undefined) return null;
24
+ total += Number(amount) * factor;
25
+ }
26
+ return Math.round(total);
27
+ }