@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,97 @@
1
+ // `linguist-generated` from the `.gitattributes` files of one tree (#333,
2
+ // review finding mvkcoxgb). The review lane reads them from the base
3
+ // revision only: never `.git/info/attributes`, a global attributes file or
4
+ // the working tree, so a change cannot mark its own code generated. What this
5
+ // parser does not understand (quoted patterns, macros, directory patterns)
6
+ // marks nothing generated, which only raises a lane.
7
+
8
+ type Rule = { regex: RegExp; generated: boolean };
9
+
10
+ /** A gitattributes pattern relative to `dir` ("" for the root) as a regex over repo paths. */
11
+ function patternRegex(dir: string, pattern: string): RegExp | null {
12
+ if (!pattern || pattern.endsWith("/") || pattern.startsWith('"') || pattern.startsWith("!"))
13
+ return null;
14
+ const anchored = pattern.includes("/");
15
+ const body = pattern.replace(/^\//u, "");
16
+ let source = "";
17
+ for (let index = 0; index < body.length; index += 1) {
18
+ const char = body[index];
19
+ if (char === "*" && body[index + 1] === "*") {
20
+ const slash = body[index + 2] === "/";
21
+ source += slash ? "(?:.*/)?" : ".*";
22
+ index += slash ? 2 : 1;
23
+ } else if (char === "*") source += "[^/]*";
24
+ else if (char === "?") source += "[^/]";
25
+ else if (char === "[") {
26
+ const end = body.indexOf("]", index + 2);
27
+ if (end < 0) return null;
28
+ const set = body
29
+ .slice(index + 1, end)
30
+ .replace(/^!/u, "^")
31
+ .replace(/\\/gu, "\\\\");
32
+ source += `[${set}]`;
33
+ index = end;
34
+ } else if (char === "\\" && index + 1 < body.length) {
35
+ source += body[index + 1].replace(/[.*+?^${}()|[\]\\/]/gu, "\\$&");
36
+ index += 1;
37
+ } else source += char.replace(/[.*+?^${}()|[\]\\/]/gu, "\\$&");
38
+ }
39
+ const prefix = dir ? `${dir.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&")}/` : "";
40
+ try {
41
+ return new RegExp(anchored ? `^${prefix}${source}$` : `^${prefix}(?:.*/)?${source}$`, "u");
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ /** The `linguist-generated` rules of one `.gitattributes` file in `dir`. */
48
+ function rulesOf(dir: string, text: string): Rule[] {
49
+ const out: Rule[] = [];
50
+ for (const raw of text.split("\n")) {
51
+ const line = raw.trim();
52
+ if (!line || line.startsWith("#") || line.startsWith("[attr]")) continue;
53
+ const [pattern, ...attributes] = line.split(/\s+/u);
54
+ let generated: boolean | null = null;
55
+ for (const attribute of attributes) {
56
+ if (attribute === "linguist-generated" || attribute === "linguist-generated=true")
57
+ generated = true;
58
+ else if (
59
+ attribute === "-linguist-generated" ||
60
+ attribute === "!linguist-generated" ||
61
+ attribute.startsWith("linguist-generated=")
62
+ )
63
+ generated = false;
64
+ }
65
+ if (generated === null) continue;
66
+ const regex = patternRegex(dir, pattern);
67
+ if (regex) out.push({ regex, generated });
68
+ }
69
+ return out;
70
+ }
71
+
72
+ /**
73
+ * Of `paths`, those the `.gitattributes` files of one tree mark
74
+ * `linguist-generated` (`files`: each file's repo path and text). A deeper
75
+ * file overrides a shallower one, and a later line an earlier one.
76
+ */
77
+ export function generatedByAttributes(
78
+ files: ReadonlyMap<string, string>,
79
+ paths: readonly string[],
80
+ ): Set<string> {
81
+ const rules = [...files]
82
+ .map(([file, text]) => {
83
+ const dir = file.includes("/") ? file.slice(0, file.lastIndexOf("/")) : "";
84
+ return {
85
+ depth: dir ? dir.split("/").length : 0,
86
+ rules: rulesOf(dir, text),
87
+ };
88
+ })
89
+ .toSorted((left, right) => left.depth - right.depth)
90
+ .flatMap((entry) => entry.rules);
91
+ const out = new Set<string>();
92
+ for (const file of paths) {
93
+ const last = rules.findLast((rule) => rule.regex.test(file));
94
+ if (last?.generated) out.add(file);
95
+ }
96
+ return out;
97
+ }
@@ -0,0 +1,119 @@
1
+ // What a verdict carry may leave out of the branch's own change (#334 follow-up):
2
+ //
3
+ // trunk-only after a rebase, the judged head replayed onto the new fork
4
+ // point (replayTree: a 3-way merge anchored at the old fork).
5
+ // What the current head changes against that replay is the
6
+ // branch's own change; the rest came from trunk.
7
+ // bookkeeping a JSON manifest whose only change is its top-level `version`
8
+ // string (a release sync), or a non-code file that a root
9
+ // package.json `write:*` script regenerates (read at the base
10
+ // revision, so a branch cannot name its own file). Changelogs
11
+ // are docs already (lane.ts).
12
+ //
13
+ // Both fail closed: a file git cannot read stays in the change.
14
+ import { spawnSync } from "node:child_process";
15
+ import { fileAt, GIT_TIMEOUTS } from "../../git/rev";
16
+
17
+ const OID = /^[0-9a-f]{40,64}$/u;
18
+ const safe = (value: string): boolean => value.length > 0 && !value.startsWith("-");
19
+
20
+ /**
21
+ * The tree of the judged head `from` replayed onto `toFork`, anchored at
22
+ * `fromFork`, conflicts included (conflicted files hold markers, so they
23
+ * never equal a resolved file). Null when git cannot build it (before 2.40).
24
+ */
25
+ export function replayTree(
26
+ cwd: string,
27
+ from: string,
28
+ fromFork: string,
29
+ toFork: string,
30
+ ): string | null {
31
+ if (![from, fromFork, toFork].every(safe)) return null;
32
+ const run = spawnSync(
33
+ "git",
34
+ ["merge-tree", "--write-tree", "--no-messages", `--merge-base=${fromFork}`, toFork, from],
35
+ {
36
+ cwd,
37
+ encoding: "utf8",
38
+ timeout: GIT_TIMEOUTS.worktree,
39
+ killSignal: "SIGKILL",
40
+ windowsHide: true,
41
+ },
42
+ );
43
+ // 0: clean, 1: conflicts (the tree is still written); anything else failed.
44
+ if (run.status !== 0 && run.status !== 1) return null;
45
+ const tree = (run.stdout ?? "").split("\n")[0]?.trim() ?? "";
46
+ return OID.test(tree) ? tree : null;
47
+ }
48
+
49
+ const SEMVER = /^v?\d+\.\d+\.\d+(?:[-+][\w.+-]*)?$/u;
50
+
51
+ const parseObject = (text: string | null): Record<string, unknown> | null => {
52
+ if (text === null) return null;
53
+ try {
54
+ const value: unknown = JSON.parse(text);
55
+ return value && typeof value === "object" && !Array.isArray(value)
56
+ ? (value as Record<string, unknown>)
57
+ : null;
58
+ } catch {
59
+ return null;
60
+ }
61
+ };
62
+
63
+ /** Both sides are JSON objects that differ only in a top-level semver `version` string. */
64
+ export function versionOnlyChange(before: string | null, after: string | null): boolean {
65
+ const a = parseObject(before);
66
+ const b = parseObject(after);
67
+ if (!a || !b) return false;
68
+ const { version: va, ...restA } = a;
69
+ const { version: vb, ...restB } = b;
70
+ if (typeof va !== "string" || typeof vb !== "string") return false;
71
+ if (!SEMVER.test(va) || !SEMVER.test(vb)) return false;
72
+ return JSON.stringify(restA) === JSON.stringify(restB);
73
+ }
74
+
75
+ const CODE = /\.(?:[cm]?[jt]sx?)$/u;
76
+ const SCRIPT_FILE = /[\w./-]+\.[cm]?[jt]s\b/gu;
77
+
78
+ /**
79
+ * Of `paths`, the non-code files a root package.json `write:*` script at
80
+ * `rev` regenerates: the script's source names the path (fanout.ts
81
+ * regeneratedFiles reads the working tree; this reads one revision).
82
+ */
83
+ function regeneratedAt(cwd: string, rev: string, paths: readonly string[]): Set<string> {
84
+ const found = new Set<string>();
85
+ const outputs = paths.filter((file) => !CODE.test(file));
86
+ if (!outputs.length) return found;
87
+ const manifest = parseObject(fileAt(cwd, rev, "package.json"));
88
+ const scripts = manifest?.scripts;
89
+ if (!scripts || typeof scripts !== "object") return found;
90
+ for (const [name, command] of Object.entries(scripts as Record<string, unknown>)) {
91
+ if (!name.startsWith("write:") || typeof command !== "string") continue;
92
+ for (const file of command.match(SCRIPT_FILE) ?? []) {
93
+ const source = fileAt(cwd, rev, file.replace(/^\.\//u, ""));
94
+ if (source === null) continue;
95
+ for (const output of outputs) if (source.includes(output)) found.add(output);
96
+ }
97
+ }
98
+ return found;
99
+ }
100
+
101
+ /**
102
+ * Of `paths` changed between the judged `from` and the current `to`, the
103
+ * bookkeeping ones: a version-only manifest change, or a file a `write:*`
104
+ * script at `base` regenerates.
105
+ */
106
+ export function bookkeepingPaths(
107
+ cwd: string,
108
+ input: { from: string; to: string; base: string; paths: readonly string[] },
109
+ ): Set<string> {
110
+ const found = regeneratedAt(cwd, input.base, input.paths);
111
+ for (const file of input.paths)
112
+ if (
113
+ !found.has(file) &&
114
+ file.endsWith(".json") &&
115
+ versionOnlyChange(fileAt(cwd, input.from, file), fileAt(cwd, input.to, file))
116
+ )
117
+ found.add(file);
118
+ return found;
119
+ }
@@ -0,0 +1,122 @@
1
+ // Verdict carry-over across a follow-up change (#334, decision mvk5q3io): a
2
+ // verdict judged head H; the branch is now at H'. The branch's own change
3
+ // since H (what came from the base left out, see lane.ts driftFiles) is
4
+ // classified with the review lane, and the verdict still applies when:
5
+ //
6
+ // rebase the branch was rebased and every file it touches is exactly
7
+ // the judged change replayed onto the new fork point: only
8
+ // trunk's commits differ (bookkeeping.ts replayTree);
9
+ // lane-none that change is lane `none`: docs (a changelog included),
10
+ // comment or formatting-only edits, generated copies (by the
11
+ // base's .gitattributes), bookkeeping (a version-only manifest
12
+ // change, a file a `write:*` script regenerates), or nothing at
13
+ // all (an empty commit);
14
+ // tests-only the verdict is kind `unit` and every changed file is a test
15
+ // file proper (`*.test.*`, `*.spec.*`, `*_test.*`; not a helper,
16
+ // fixture or config under a test folder), docs or bookkeeping,
17
+ // off risk paths, with no lockfile and no binary, whatever its
18
+ // size and whatever the PR's own lane.
19
+ //
20
+ // After a rebase, a file whose current content is the judged change replayed
21
+ // onto the new fork is left out before the lane is read: a trunk edit in a
22
+ // branch file never counts as the branch's change, and a file whose result
23
+ // differs (a hand-resolved conflict, an amend, even whitespace) always does.
24
+ //
25
+ // Everything else is stale, failing closed: a behavior or risk-path change,
26
+ // an opaque file, an unreadable diff, a judged head git no longer has. A
27
+ // stale verdict whose change is lane `self` (small, no risk path, nothing
28
+ // opaque) asks only a focused re-verify of the changed files.
29
+ import { mergeBase, fileAt } from "../../git/rev";
30
+ import { bookkeepingPaths, replayTree } from "./bookkeeping";
31
+ import {
32
+ computeLane,
33
+ driftFiles,
34
+ kindOf,
35
+ laneFiles,
36
+ parseRiskPaths,
37
+ type ChangedFile,
38
+ RISK_PATHS_FILE,
39
+ } from "./lane";
40
+
41
+ /** `rebase`: the same patch on a moved base (ledger.ts exact carry, or every branch file replays). */
42
+ export type CarryRule = "lane-none" | "tests-only" | "rebase";
43
+
44
+ export type Drift =
45
+ | { carries: true; rule: CarryRule; since: string; files: string[] }
46
+ | {
47
+ carries: false;
48
+ since: string;
49
+ /** The changed files, or null when they cannot be read. */
50
+ files: string[] | null;
51
+ /** A focused re-verify of `files` is enough (lane self). */
52
+ focused: boolean;
53
+ reason: string;
54
+ };
55
+
56
+ const TEST_ONLY_KINDS: ReadonlySet<string> = new Set(["doc", "cosmetic", "generated"]);
57
+ /**
58
+ * A test file proper, by name (`*.test.ts`, `*.spec.js` and the other JS/TS
59
+ * extensions, Go's `*_test.go`). A helper, fixture, setup or config file is
60
+ * not one, even with `.test.` mid-name (`foo.test.helper.ts`, `a.test.json`):
61
+ * product scripts import such helpers and test runners preload them.
62
+ */
63
+ const TEST_FILE = /\.(?:test|spec)\.[cm]?[jt]sx?$|_test\.[^./]+$/iu;
64
+ const testOnly = (file: ChangedFile): boolean => {
65
+ const kind = kindOf(file);
66
+ return TEST_ONLY_KINDS.has(kind) || (kind === "test" && TEST_FILE.test(file.path));
67
+ };
68
+
69
+ /**
70
+ * Whether a `kind` verdict at `from` (keyed against `fromBase`, a commit)
71
+ * still applies at `to` (against `toBase`).
72
+ */
73
+ export function verdictDrift(
74
+ cwd: string,
75
+ input: { from: string; to: string; fromBase: string; toBase: string; kind: string },
76
+ ): Drift {
77
+ const since = input.from;
78
+ const stale = (reason: string, files: string[] | null = null, focused = false): Drift => ({
79
+ carries: false,
80
+ since,
81
+ files,
82
+ focused,
83
+ reason,
84
+ });
85
+ const fromFork = mergeBase(cwd, input.fromBase, input.from);
86
+ const toFork = mergeBase(cwd, input.toBase, input.to);
87
+ const range = fromFork && toFork ? { from: input.from, to: input.to, fromFork, toFork } : null;
88
+ const drifted = range ? driftFiles(cwd, range) : null;
89
+ if (!range || !drifted) return stale(`the change since ${since.slice(0, 12)} cannot be read`);
90
+ let files = drifted;
91
+ if (range.fromFork !== range.toFork) {
92
+ // A rebase: the branch's own change is what the head changes against the
93
+ // judged head replayed onto the new fork (trunk's lines left out; a
94
+ // conflict holds markers, so its resolution counts). Unreadable: every
95
+ // drifted file stays, none cosmetic (fail closed).
96
+ const tree = replayTree(cwd, range.from, range.fromFork, range.toFork);
97
+ const own = tree ? laneFiles(cwd, tree, range.to, range.toFork) : null;
98
+ if (own && !own.length) return { carries: true, rule: "rebase", since, files: [] };
99
+ // Across a moved fork no edit counts as cosmetic (#334): only an exact
100
+ // replay is left out.
101
+ if (own) files = own.map((file) => ({ ...file, cosmetic: false }));
102
+ }
103
+ const bookkeeping = bookkeepingPaths(cwd, {
104
+ from: input.from,
105
+ to: input.to,
106
+ base: range.toFork,
107
+ paths: files.map((file) => file.path),
108
+ });
109
+ files = files.map((file) => (bookkeeping.has(file.path) ? { ...file, generated: true } : file));
110
+ // Repo risk paths from both base revisions: they only tighten.
111
+ const riskPaths = [range.fromFork, range.toFork].flatMap((fork) =>
112
+ parseRiskPaths(fileAt(cwd, fork, RISK_PATHS_FILE) ?? ""),
113
+ );
114
+ const lane = computeLane({ files, riskPaths });
115
+ const paths = files.map((file) => file.path);
116
+ if (lane.lane === "none") return { carries: true, rule: "lane-none", since, files: paths };
117
+ // Tests cannot change what the code does: a unit verdict keeps any size
118
+ // of test-only follow-up, off risk paths, with nothing opaque.
119
+ if (input.kind === "unit" && lane.riskFiles.length === 0 && files.every(testOnly))
120
+ return { carries: true, rule: "tests-only", since, files: paths };
121
+ return stale(lane.reasons[0] ?? `lane ${lane.lane}`, paths, lane.lane === "self");
122
+ }
@@ -0,0 +1,115 @@
1
+ // Which configured checks a verdict needs on a head, and whether each has
2
+ // fresh evidence there (#509): a local `workit check` run on that exact
3
+ // commit, or (where the lane lets CI cover it) a green CI check of that name.
4
+ // A focused lane lets CI cover a check; a full lane needs a local run.
5
+ import { gateCheckNames, loadCheckConfig } from "../../check-config";
6
+ import { branchLane, type ReviewLane } from "./lane";
7
+
8
+ export type CheckEvidenceStatus = "fresh" | "failing" | "missing";
9
+
10
+ export type CheckEvidence = {
11
+ name: string;
12
+ status: CheckEvidenceStatus;
13
+ /** Where the fresh (or failing) evidence came from. */
14
+ via: "local" | "ci" | null;
15
+ /** Whether green CI may stand in for a local run at this lane. */
16
+ ciCovers: boolean;
17
+ };
18
+
19
+ export type CheckEvidenceDoc = {
20
+ lane: ReviewLane;
21
+ head: string;
22
+ /** Every configured check the verdict needs; empty for a lane without a verdict. */
23
+ needed: CheckEvidence[];
24
+ /** True when every needed check has fresh evidence (vacuously true when none is needed). */
25
+ satisfied: boolean;
26
+ };
27
+
28
+ export type LocalCheckRow = { name: string; head: string; dirty: boolean; passed: boolean };
29
+ export type CiCheck = { name: string; state: "passing" | "failing" | "pending" };
30
+
31
+ /** The aggregate `check` script repeats the others; it is never asked for on its own. */
32
+ const AGGREGATE = "check";
33
+
34
+ /** The checks a lane's verdict needs, from the configured gate names. */
35
+ export function neededChecks(lane: ReviewLane, configured: readonly string[]): string[] {
36
+ if (lane !== "focused" && lane !== "full") return [];
37
+ const names = configured.filter((name) => name !== AGGREGATE);
38
+ return names.length ? names : [...configured];
39
+ }
40
+
41
+ /** The check name as a whole word of the CI job name: `lint` is not `(commitlint)`. */
42
+ const ciMatch = (ci: CiCheck, name: string): boolean =>
43
+ new RegExp(
44
+ `(^|[^a-z0-9])${name.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&")}([^a-z0-9]|$)`,
45
+ "iu",
46
+ ).test(ci.name);
47
+
48
+ /** The observed `workit check` rows of a ledger, as evidence input. */
49
+ export function localCheckRows(rows: readonly Record<string, unknown>[]): LocalCheckRow[] {
50
+ return rows
51
+ .filter((row) => row.type === "check" && row.observer === "workit_cli")
52
+ .map((row) => ({
53
+ name: String(row.name ?? ""),
54
+ head: String(row.head ?? ""),
55
+ dirty: row.dirty === true,
56
+ passed: row.result === "passed",
57
+ }));
58
+ }
59
+
60
+ /** The evidence of `head` on `branch` (local runs only; CI is read by `pr status`). */
61
+ export function verdictEvidence(
62
+ cwd: string,
63
+ input: { head: string; base?: string | null; rows: readonly Record<string, unknown>[] },
64
+ ): CheckEvidenceDoc {
65
+ const lane = branchLane(cwd, { base: input.base ?? null, head: input.head });
66
+ return checkEvidence({
67
+ lane: lane.lane,
68
+ head: input.head,
69
+ configured: gateCheckNames(loadCheckConfig(cwd), "verification"),
70
+ local: localCheckRows(input.rows),
71
+ ci: [],
72
+ });
73
+ }
74
+
75
+ export function checkEvidence(input: {
76
+ lane: ReviewLane;
77
+ head: string;
78
+ configured: readonly string[];
79
+ /** Observed `workit check` runs, oldest first. */
80
+ local: readonly LocalCheckRow[];
81
+ ci: readonly CiCheck[];
82
+ }): CheckEvidenceDoc {
83
+ const ciCovers = input.lane === "focused";
84
+ const needed = neededChecks(input.lane, input.configured).map((name): CheckEvidence => {
85
+ const last = input.local.findLast(
86
+ (row) => row.name === name && row.head === input.head && !row.dirty,
87
+ );
88
+ if (last?.passed) return { name, status: "fresh", via: "local", ciCovers };
89
+ const ci = input.ci.filter((check) => ciMatch(check, name));
90
+ if (ci.some((check) => check.state === "failing"))
91
+ return { name, status: "failing", via: "ci", ciCovers };
92
+ if (last) return { name, status: "failing", via: "local", ciCovers };
93
+ if (ciCovers && ci.some((check) => check.state === "passing"))
94
+ return { name, status: "fresh", via: "ci", ciCovers };
95
+ return { name, status: "missing", via: null, ciCovers };
96
+ });
97
+ return {
98
+ lane: input.lane,
99
+ head: input.head,
100
+ needed,
101
+ satisfied: needed.every((item) => item.status === "fresh"),
102
+ };
103
+ }
104
+
105
+ /** One line per check for a status render. */
106
+ export function evidenceLines(doc: CheckEvidenceDoc): string[] {
107
+ if (!doc.needed.length) return [`evidence: lane ${doc.lane} needs no check run`];
108
+ return [
109
+ `evidence for lane ${doc.lane} on ${doc.head.slice(0, 7)}: ${doc.satisfied ? "complete" : "incomplete"}`,
110
+ ...doc.needed.map(
111
+ (item) =>
112
+ ` ${item.status === "fresh" ? "ok" : "x"} ${item.name}: ${item.status}${item.via ? ` (${item.via})` : item.ciCovers ? " (run it, or green CI covers it)" : " (run workit check " + item.name + " on this head)"}`,
113
+ ),
114
+ ];
115
+ }
@@ -0,0 +1,20 @@
1
+ import type { Judgment } from "../task-contract";
2
+ import { TaskStore } from "../task-store";
3
+ import { latestJudgment } from "./derive";
4
+
5
+ /** The open task's latest judgment (the one policy/derive.ts reads), or null when none is recorded. Never throws. */
6
+ export function currentJudgment(cwd: string): Judgment | null {
7
+ try {
8
+ const store = new TaskStore(cwd);
9
+ const listed = store.listTaskIndex();
10
+ const key = store.currentKey();
11
+ if (!listed.ok || !key.ok) return null;
12
+ const entry = listed.data
13
+ .filter((item) => item.key === key.data.key && item.status !== "closed")
14
+ .toSorted((left, right) => right.updatedAt.localeCompare(left.updatedAt))[0];
15
+ const task = entry ? store.readTask(entry.id) : null;
16
+ return task?.ok === true ? latestJudgment(task.data) : null;
17
+ } catch {
18
+ return null;
19
+ }
20
+ }