@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,309 @@
1
+ type Node = { type: string; start: number; end: number; loc: Loc; [key: string]: any };
2
+ type Loc = { start: { line: number }; end: { line: number } };
3
+
4
+ /** A parsed test file: the shape `@babel/parser` returns, as `workit test-audit` passes it in. */
5
+ export type ParsedTest = { source: string; program: Node; comments: Node[] };
6
+
7
+ export const MOCK_RULES = {
8
+ "mock-call-order":
9
+ "Asserts the call count or call order of an internal collaborator: couples the test to the implementation",
10
+ "private-access": "Reaches into private state or a test-only export of the code under test",
11
+ } as const;
12
+ export type MockRuleId = keyof typeof MOCK_RULES;
13
+
14
+ export type MockFinding = {
15
+ rule: MockRuleId;
16
+ severity: "medium" | "low";
17
+ confidence: "high" | "medium" | "low";
18
+ file: string;
19
+ line: number;
20
+ test: string | null;
21
+ why: string;
22
+ suggestion: string;
23
+ snippet: string;
24
+ };
25
+
26
+ const ORDER_MATCHERS = new Set([
27
+ "toHaveBeenNthCalledWith",
28
+ "nthCalledWith",
29
+ "toHaveBeenCalledBefore",
30
+ "toHaveBeenCalledAfter",
31
+ "calledBefore",
32
+ "calledAfter",
33
+ "calledImmediatelyBefore",
34
+ "calledImmediatelyAfter",
35
+ ]);
36
+ const COUNT_MATCHERS = new Set([
37
+ "toHaveBeenCalledTimes",
38
+ "toBeCalledTimes",
39
+ "toHaveBeenCalledOnce",
40
+ "toHaveBeenCalledExactlyOnceWith",
41
+ "calledOnce",
42
+ "calledTwice",
43
+ "calledThrice",
44
+ ]);
45
+ const MOCK_FACTORIES = /^(?:mock|jest\.fn|vi\.fn|sinon\.(?:spy|stub|fake)|spy|stub|fn)$/;
46
+ const SPY_CALLS = /^(?:spyOn|jest\.spyOn|vi\.spyOn|mock\.method|sinon\.(?:spy|stub|replace))$/;
47
+ const MODULE_MOCKS =
48
+ /^(?:mock\.module|jest\.(?:mock|doMock|unstable_mockModule)|vi\.(?:mock|doMock)|td\.replace(?:Esm)?)$/;
49
+ const TEST_CALLEES = /^(?:test|it|specify)(?:\.(?:only|each|concurrent|serial|failing))?$/;
50
+ const TEST_ONLY_EXPORT = /^(?:_\w|\w*(?:ForTest(?:s|ing)?|TestOnly|_test|__test__|Internals?)$)/;
51
+ const PRIVATE_MEMBER = /^_{1,2}[A-Za-z]/;
52
+ const NOT_PRIVATE = new Set(["__proto__", "__esModule", "__dirname", "__filename"]);
53
+ const SKIP_KEYS = new Set([
54
+ "loc",
55
+ "start",
56
+ "end",
57
+ "extra",
58
+ "range",
59
+ "leadingComments",
60
+ "trailingComments",
61
+ "innerComments",
62
+ "typeAnnotation",
63
+ "typeParameters",
64
+ "typeArguments",
65
+ "returnType",
66
+ ]);
67
+
68
+ const isNode = (value: unknown): value is Node =>
69
+ typeof value === "object" && value !== null && typeof (value as Node).type === "string";
70
+
71
+ function walk(root: Node, visit: (node: Node, ancestors: readonly Node[]) => void) {
72
+ const stack: [Node, Node[]][] = [[root, []]];
73
+ while (stack.length > 0) {
74
+ const [node, ancestors] = stack.pop()!;
75
+ visit(node, ancestors);
76
+ const below = [...ancestors, node];
77
+ for (const key of Object.keys(node)) {
78
+ if (SKIP_KEYS.has(key)) continue;
79
+ const value = node[key];
80
+ if (Array.isArray(value)) {
81
+ for (const item of value.toReversed()) if (isNode(item)) stack.push([item, below]);
82
+ } else if (isNode(value)) stack.push([value, below]);
83
+ }
84
+ }
85
+ }
86
+
87
+ function dotted(node: Node | null | undefined): string | null {
88
+ if (!node) return null;
89
+ if (node.type === "Identifier") return node.name;
90
+ if (node.type === "MemberExpression" && !node.computed && node.property.type === "Identifier") {
91
+ const object = dotted(node.object);
92
+ return object ? `${object}.${node.property.name}` : null;
93
+ }
94
+ return null;
95
+ }
96
+
97
+ function unwrap(node: Node | null | undefined): Node | null {
98
+ let current = node ?? null;
99
+ while (
100
+ current &&
101
+ [
102
+ "AwaitExpression",
103
+ "TSAsExpression",
104
+ "TSNonNullExpression",
105
+ "ParenthesizedExpression",
106
+ ].includes(current.type)
107
+ )
108
+ current = current.type === "AwaitExpression" ? current.argument : current.expression;
109
+ return current;
110
+ }
111
+
112
+ const internalSpecifier = (spec: string): boolean => /^(?:\.{1,2}\/|@\/|~\/|#|src\/)/.test(spec);
113
+
114
+ const literal = (node: Node | undefined): string | null =>
115
+ node?.type === "StringLiteral" ? node.value : null;
116
+
117
+ type Doubles = {
118
+ internalImports: Map<string, string>;
119
+ internalMocks: Set<string>;
120
+ mocks: Set<string>;
121
+ };
122
+
123
+ function collectDoubles(program: Node): Doubles {
124
+ const internalImports = new Map<string, string>();
125
+ const mockedModules = new Set<string>();
126
+ const internalMocks = new Set<string>();
127
+ const mocks = new Set<string>();
128
+ for (const statement of program.body as Node[])
129
+ if (statement.type === "ImportDeclaration" && internalSpecifier(statement.source.value))
130
+ for (const spec of statement.specifiers as Node[])
131
+ internalImports.set(spec.local.name, statement.source.value);
132
+ walk(program, (node) => {
133
+ if (node.type !== "CallExpression") return;
134
+ const name = dotted(node.callee);
135
+ const spec = literal(node.arguments[0]);
136
+ if (name && MODULE_MOCKS.test(name) && spec && internalSpecifier(spec)) mockedModules.add(spec);
137
+ });
138
+ for (const [local, spec] of internalImports)
139
+ if (mockedModules.has(spec)) {
140
+ internalMocks.add(local);
141
+ mocks.add(local);
142
+ }
143
+ walk(program, (node) => {
144
+ const call = node.type === "VariableDeclarator" ? unwrap(node.init) : node;
145
+ if (call?.type !== "CallExpression") return;
146
+ const name = dotted(call.callee);
147
+ if (!name) return;
148
+ const bound =
149
+ node.type === "VariableDeclarator" && node.id.type === "Identifier" ? node.id.name : null;
150
+ if (SPY_CALLS.test(name)) {
151
+ const target = dotted(call.arguments[0]);
152
+ const method = literal(call.arguments[1]);
153
+ const internal = !!target && internalImports.has(target.split(".")[0]);
154
+ for (const key of [bound, target && method ? `${target}.${method}` : null])
155
+ if (key) {
156
+ mocks.add(key);
157
+ if (internal) internalMocks.add(key);
158
+ }
159
+ } else if (bound && MOCK_FACTORIES.test(name)) mocks.add(bound);
160
+ });
161
+ return { internalImports, internalMocks, mocks };
162
+ }
163
+
164
+ function subject(node: Node | null): string | null {
165
+ const target = unwrap(node);
166
+ if (!target) return null;
167
+ const name = dotted(target);
168
+ if (name)
169
+ return name.replace(/\.(?:mock\.(?:calls|invocationCallOrder)|callCount|args)(?:\..*)?$/, "");
170
+ if (target.type === "MemberExpression") return subject(target.object);
171
+ return null;
172
+ }
173
+
174
+ function expectation(node: Node): { actual: Node | null; matcher: string } | null {
175
+ if (node.type !== "CallExpression" || node.callee.type !== "MemberExpression") return null;
176
+ if (node.callee.property.type !== "Identifier") return null;
177
+ const matcher = node.callee.property.name;
178
+ let current: Node = node.callee.object;
179
+ while (current.type === "MemberExpression") current = current.object;
180
+ if (current.type !== "CallExpression" || dotted(current.callee) !== "expect") return null;
181
+ return { actual: current.arguments[0] ?? null, matcher };
182
+ }
183
+
184
+ function enclosingTest(ancestors: readonly Node[]): string | null {
185
+ for (const node of ancestors.toReversed()) {
186
+ if (node.type !== "CallExpression") continue;
187
+ const callee = node.callee.type === "CallExpression" ? node.callee.callee : node.callee;
188
+ const name = dotted(callee);
189
+ if (name && TEST_CALLEES.test(name)) return literal(node.arguments[0]) ?? "<anonymous>";
190
+ }
191
+ return null;
192
+ }
193
+
194
+ function mutedLines(comments: Node[]): Map<number, Set<string> | null> {
195
+ const lines = new Map<number, Set<string> | null>();
196
+ for (const comment of comments) {
197
+ const match = /workit-test-audit-ignore(?:\s+([\w\s,-]+?))?(?:\s+--|$)/.exec(comment.value);
198
+ if (!match) continue;
199
+ const rules = match[1]
200
+ ? new Set(match[1].split(/[\s,]+/).filter((rule) => rule in MOCK_RULES))
201
+ : null;
202
+ const set = rules && rules.size > 0 ? rules : null;
203
+ lines.set(comment.loc.start.line, set);
204
+ lines.set(comment.loc.end.line + 1, set);
205
+ }
206
+ return lines;
207
+ }
208
+
209
+ /**
210
+ * The `mock-call-order` and `private-access` findings of one parsed test
211
+ * file. Call order on any double, and call counts on a double that replaces
212
+ * the code under test's own module, couple a test to how the code works
213
+ * rather than what it returns.
214
+ */
215
+ export function auditMockUse(parsed: ParsedTest, file: string): MockFinding[] {
216
+ const doubles = collectDoubles(parsed.program);
217
+ const muted = mutedLines(parsed.comments);
218
+ const findings: MockFinding[] = [];
219
+ const snippet = (node: Node) =>
220
+ parsed.source.slice(node.start, node.end).replace(/\s+/g, " ").slice(0, 120);
221
+ const push = (finding: Omit<MockFinding, "file">) => {
222
+ const mute = muted.has(finding.line) ? muted.get(finding.line) : undefined;
223
+ if (mute === null || mute?.has(finding.rule)) return;
224
+ findings.push({ file, ...finding });
225
+ };
226
+
227
+ walk(parsed.program, (node, ancestors) => {
228
+ const assertion = expectation(node);
229
+ const sinonAssert =
230
+ node.type === "CallExpression"
231
+ ? /^sinon\.assert\.(callOrder|callCount)$/.exec(dotted(node.callee) ?? "")
232
+ : null;
233
+ let kind: "order" | "count" | null = null;
234
+ let target: string | null = null;
235
+ if (assertion) {
236
+ let actual = unwrap(assertion.actual);
237
+ while (actual?.type === "MemberExpression" && actual.computed) actual = actual.object;
238
+ const source = dotted(actual) ?? "";
239
+ if (ORDER_MATCHERS.has(assertion.matcher) || source.includes(".mock.invocationCallOrder"))
240
+ kind = "order";
241
+ else if (
242
+ COUNT_MATCHERS.has(assertion.matcher) ||
243
+ /\.(?:mock\.calls\.length|callCount)$/.test(source)
244
+ )
245
+ kind = "count";
246
+ target = subject(assertion.actual);
247
+ } else if (sinonAssert) {
248
+ kind = sinonAssert[1] === "callOrder" ? "order" : "count";
249
+ target = subject(node.arguments[0]);
250
+ }
251
+ if (kind && target) {
252
+ const internal = doubles.internalMocks.has(target);
253
+ if (internal || (kind === "order" && doubles.mocks.has(target)))
254
+ push({
255
+ rule: "mock-call-order",
256
+ severity: internal ? "medium" : "low",
257
+ confidence: internal ? "high" : "medium",
258
+ line: node.loc.start.line,
259
+ test: enclosingTest(ancestors),
260
+ why: internal
261
+ ? `Asserts the call ${kind} of ${target}, a double of the code under test's own module: a refactor that keeps the behavior breaks it.`
262
+ : `Asserts the order in which ${target} was called: the order between collaborators is an implementation detail unless a caller depends on it.`,
263
+ suggestion:
264
+ "Assert the observable result (return value, written state, the message a boundary received) instead; keep a call count only for a boundary contract such as retries or idempotency.",
265
+ snippet: snippet(node),
266
+ });
267
+ }
268
+
269
+ if (node.type === "MemberExpression" && node.object.type !== "ThisExpression") {
270
+ const name =
271
+ !node.computed && node.property.type === "Identifier"
272
+ ? node.property.name
273
+ : literal(node.property);
274
+ if (name && PRIVATE_MEMBER.test(name) && !NOT_PRIVATE.has(name))
275
+ push({
276
+ rule: "private-access",
277
+ severity: "low",
278
+ confidence: "medium",
279
+ line: node.loc.start.line,
280
+ test: enclosingTest(ancestors),
281
+ why: `Reads ${name}, a private member: the test breaks when the internals are renamed, though the behavior is unchanged.`,
282
+ suggestion:
283
+ "Drive the behavior through the public API and assert what a caller observes.",
284
+ snippet: snippet(node),
285
+ });
286
+ }
287
+ });
288
+
289
+ for (const statement of parsed.program.body as Node[]) {
290
+ if (statement.type !== "ImportDeclaration" || !internalSpecifier(statement.source.value))
291
+ continue;
292
+ for (const spec of statement.specifiers as Node[]) {
293
+ const imported = spec.imported?.name ?? spec.imported?.value ?? null;
294
+ if (imported && TEST_ONLY_EXPORT.test(imported))
295
+ push({
296
+ rule: "private-access",
297
+ severity: "low",
298
+ confidence: "medium",
299
+ line: spec.loc.start.line,
300
+ test: null,
301
+ why: `Imports ${imported}, a test-only or internal export: the test pins internals the public API does not promise.`,
302
+ suggestion:
303
+ "Test through the public export, or move the logic into its own module with a real public API.",
304
+ snippet: snippet(statement),
305
+ });
306
+ }
307
+ }
308
+ return findings.toSorted((a, b) => a.line - b.line);
309
+ }
@@ -0,0 +1,151 @@
1
+ // Stack-wide verdicts (#334, decision mvk5q3io): one verifier at the top of
2
+ // a stack, after its last push, covers the lower PRs it judged. A branch with
3
+ // no accepted verdict of its own is covered by a branch above it in a cached
4
+ // stack (`<store>/stacks/*.json`, S12) when:
5
+ //
6
+ // - that branch has an accepted independent verdict now (fresh or carried);
7
+ // - the verdict's session is not an author of the lower branch either;
8
+ // - every commit of the lower branch since its fork from the trunk has a
9
+ // verbatim patch-id (whitespace counts) among the commits of the judged
10
+ // head (the verdict's own head, not the upper branch's current tip): the
11
+ // lower change was judged unchanged. No merge commits, at least one
12
+ // non-empty commit;
13
+ // - the judged head holds exactly the lower branch's resulting files (no
14
+ // `<lower>..<judged>` difference in any file the lower branch changed):
15
+ // an upper commit that reverts or rewrites a lower file means the lower
16
+ // PR's own code was never judged;
17
+ // - the lower branch's own review lane is below full (decision B3): a risk
18
+ // path or a large change needs its own verdict.
19
+ //
20
+ // A lower branch that changed after the verdict (a new commit, an amend, a
21
+ // conflict resolved differently) holds a patch-id the judged head lacks and
22
+ // is not covered. When git cannot read any of this, nothing is covered. A current independent failure or a tampered ledger on the
23
+ // lower branch is never covered over. `pr merge`, `stack land`, `stack
24
+ // status`, `pr status` and `ledger check` read this; the Stop hook does not.
25
+ import fs from "node:fs";
26
+ import path from "node:path";
27
+ import { branchLane } from "./core/policy/lane";
28
+ import { commitPatchIds, diffRaw, mergeBase } from "./git/rev";
29
+ import {
30
+ authorSessions,
31
+ authoredBy,
32
+ checkVerdicts,
33
+ defaultBase,
34
+ resolveCommit,
35
+ storeRoot,
36
+ type ReadRow,
37
+ type VerdictCheck,
38
+ } from "./ledger";
39
+
40
+ /** The verdict above `branch` in a stack that covers it. */
41
+ export type StackCover = {
42
+ stack: string;
43
+ /** The upper branch the verdict was recorded on. */
44
+ branch: string;
45
+ verdictId: string;
46
+ /** The head that verdict judged. */
47
+ head: string;
48
+ };
49
+
50
+ export type CoveredCheck = VerdictCheck & { cover: StackCover | null };
51
+
52
+ type Chain = { name: string; trunk: string; above: string[] };
53
+
54
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
55
+ typeof value === "object" && value !== null && !Array.isArray(value);
56
+
57
+ /** The open branches above `branch` in each cached stack holding it, top first. */
58
+ function chainsAbove(cwd: string, branch: string): Chain[] {
59
+ const root = storeRoot(cwd);
60
+ if (!root.ok) return [];
61
+ const dir = path.join(root.value.root, "stacks");
62
+ let names: string[] = [];
63
+ try {
64
+ names = fs.readdirSync(dir).filter((name) => name.endsWith(".json"));
65
+ } catch {
66
+ return [];
67
+ }
68
+ const chains: Chain[] = [];
69
+ for (const name of names.toSorted()) {
70
+ let parsed: unknown;
71
+ try {
72
+ parsed = JSON.parse(fs.readFileSync(path.join(dir, name), "utf8"));
73
+ } catch {
74
+ continue;
75
+ }
76
+ if (!isRecord(parsed) || !Array.isArray(parsed.branches) || typeof parsed.trunk !== "string")
77
+ continue;
78
+ const entries = parsed.branches.filter(isRecord);
79
+ const index = entries.findIndex((entry) => entry.branch === branch && !entry.merged);
80
+ if (index < 0) continue;
81
+ chains.push({
82
+ name: typeof parsed.name === "string" ? parsed.name : name,
83
+ trunk: parsed.trunk,
84
+ above: entries
85
+ .slice(index + 1)
86
+ .filter((entry) => typeof entry.branch === "string" && !entry.merged)
87
+ .map((entry) => entry.branch as string)
88
+ .toReversed(),
89
+ });
90
+ }
91
+ return chains;
92
+ }
93
+
94
+ /**
95
+ * Whether the verdict on `judged` covers the lower branch at `head` (see the
96
+ * header). Anything git cannot read is not covered.
97
+ */
98
+ function covers(cwd: string, trunk: string, head: string, judged: string): boolean {
99
+ const trunkSha = [`origin/${trunk}`, trunk].map((ref) => resolveCommit(cwd, ref)).find(Boolean);
100
+ const ownFork = trunkSha ? mergeBase(cwd, trunkSha, head) : null;
101
+ const judgedFork = trunkSha ? mergeBase(cwd, trunkSha, judged) : null;
102
+ if (!ownFork || !judgedFork) return false;
103
+ const own = commitPatchIds(cwd, ownFork, head);
104
+ const top = commitPatchIds(cwd, judgedFork, judged);
105
+ if (!own || !top || own.merges > 0 || own.patchIds.length === 0) return false;
106
+ const judgedIds = new Set(top.patchIds);
107
+ if (!own.patchIds.every((id) => judgedIds.has(id))) return false;
108
+ // The judged tree holds exactly the lower branch's resulting files.
109
+ const changed = diffRaw(cwd, ownFork, head);
110
+ const between = changed ? diffRaw(cwd, head, judged) : null;
111
+ if (!changed || !between) return false;
112
+ const files = new Set(changed.map((entry) => entry.path));
113
+ if (between.some((entry) => files.has(entry.path))) return false;
114
+ return branchLane(cwd, { base: trunk, head }).lane !== "full";
115
+ }
116
+
117
+ /**
118
+ * `checkVerdicts` for `branch`, and when it has no accepted verdict of its
119
+ * own, the stack verdict that covers it (see the header): then `accepted`
120
+ * names that verdict and `cover` says where it came from.
121
+ */
122
+ export function checkVerdictsCovered(
123
+ cwd: string,
124
+ branch: string,
125
+ rows: readonly ReadRow[],
126
+ ): CoveredCheck {
127
+ const check = checkVerdicts(cwd, branch, rows);
128
+ const blocked = check.accepted.reasons.some(
129
+ (reason) => reason === "failing_verdict" || reason === "ledger_tampered",
130
+ );
131
+ if (check.accepted.accepted || blocked || !check.head) return { ...check, cover: null };
132
+ let authors: Set<string> | null = null;
133
+ for (const chain of chainsAbove(cwd, branch))
134
+ for (const upper of chain.above) {
135
+ const verdict = checkVerdicts(cwd, upper, rows).accepted;
136
+ const row = verdict.accepted ? verdict.verdict : null;
137
+ const session = row?.actor.session;
138
+ if (!row?.head || !session || typeof row.id !== "string") continue;
139
+ authors ??= authorSessions(cwd, rows, branch, { base: defaultBase(cwd), head: check.head });
140
+ if (authoredBy(authors, session)) continue;
141
+ if (!covers(cwd, chain.trunk, check.head, row.head)) continue;
142
+ return {
143
+ ...check,
144
+ accepted: { accepted: true, verdict: row, reasons: [] },
145
+ review: "verified",
146
+ selfVerdict: null,
147
+ cover: { stack: chain.name, branch: upper, verdictId: row.id, head: row.head },
148
+ };
149
+ }
150
+ return { ...check, cover: null };
151
+ }
@@ -0,0 +1,142 @@
1
+ // Dependencies for a fresh worktree (issue #310): a linked worktree has no
2
+ // node_modules, so a resolver walks up to whatever node_modules is above it
3
+ // (the main checkout's, for a worktree nested inside it) and a workspace
4
+ // package resolves to the main checkout's source: tests then run the wrong
5
+ // code. Instead of a package-manager install, the worktree gets its own
6
+ // node_modules that mirrors the main checkout's one level deep:
7
+ //
8
+ // - a symlink is copied as it is: a relative one (bun, npm and yarn link
9
+ // workspace packages as `../../packages/<name>`, and `.bin` entries as
10
+ // `../<pkg>/bin/x`) then resolves inside the worktree, which has the same
11
+ // layout; an absolute one into the main checkout (a Windows junction) is
12
+ // re-pointed at the worktree's counterpart;
13
+ // - `@scope` directories and `.bin` are mirrored the same way, one level;
14
+ // - anything else (a third-party package) is linked to the main checkout's,
15
+ // so installed packages are shared, never copied.
16
+ //
17
+ // It runs only where the worktree has no node_modules of its own and git
18
+ // ignores node_modules there (else the links would show as changes and block
19
+ // release). `git worktree remove` unlinks the links and never follows them.
20
+ import fs from "node:fs";
21
+ import path from "node:path";
22
+ import { gitRun } from "./fanout";
23
+
24
+ const MODULES = "node_modules";
25
+ /** Directories searched for node_modules below the checkout root (packages/<name>). */
26
+ const MAX_DEPTH = 2;
27
+
28
+ export type LinkedDependencies = {
29
+ /** The main checkout the links point at. */
30
+ from: string;
31
+ /** The node_modules directories made, relative to the worktree. */
32
+ dirs: string[];
33
+ /** Links made across them. */
34
+ entries: number;
35
+ ms: number;
36
+ };
37
+
38
+ const within = (child: string, parent: string): boolean => {
39
+ const relative = path.relative(parent, child);
40
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
41
+ };
42
+
43
+ /** node_modules directories of the main checkout, as paths relative to it. */
44
+ function moduleDirs(main: string): string[] {
45
+ const out: string[] = [];
46
+ const walk = (relative: string, depth: number) => {
47
+ let entries: fs.Dirent[];
48
+ try {
49
+ entries = fs.readdirSync(path.join(main, relative), { withFileTypes: true });
50
+ } catch {
51
+ return;
52
+ }
53
+ for (const entry of entries) {
54
+ if (!entry.isDirectory()) continue;
55
+ const child = path.join(relative, entry.name);
56
+ if (entry.name === MODULES) out.push(child);
57
+ else if (depth < MAX_DEPTH && !entry.name.startsWith(".")) walk(child, depth + 1);
58
+ }
59
+ };
60
+ walk("", 0);
61
+ return out;
62
+ }
63
+
64
+ function linkEntry(main: string, worktree: string, source: string, target: string): void {
65
+ const stat = fs.lstatSync(source);
66
+ if (stat.isSymbolicLink()) {
67
+ const link = fs.readlinkSync(source);
68
+ // An absolute link into the main checkout (outside its node_modules) is a
69
+ // workspace package: point it at the worktree's copy.
70
+ const outside =
71
+ path.isAbsolute(link) &&
72
+ within(link, main) &&
73
+ !path.relative(main, link).split(path.sep).includes(MODULES);
74
+ const to = outside ? path.join(worktree, path.relative(main, link)) : link;
75
+ fs.symlinkSync(to, target, process.platform === "win32" ? "junction" : undefined);
76
+ return;
77
+ }
78
+ fs.symlinkSync(
79
+ source,
80
+ target,
81
+ process.platform === "win32" ? (stat.isDirectory() ? "junction" : "file") : undefined,
82
+ );
83
+ }
84
+
85
+ /** Mirror `source` into a new directory `target`; returns the links made. */
86
+ function mirror(main: string, worktree: string, source: string, target: string, top: boolean) {
87
+ fs.mkdirSync(target);
88
+ let count = 0;
89
+ for (const entry of fs.readdirSync(source, { withFileTypes: true })) {
90
+ const from = path.join(source, entry.name);
91
+ const to = path.join(target, entry.name);
92
+ if (top && entry.isDirectory() && (entry.name.startsWith("@") || entry.name === ".bin"))
93
+ count += mirror(main, worktree, from, to, false);
94
+ else {
95
+ linkEntry(main, worktree, from, to);
96
+ count += 1;
97
+ }
98
+ }
99
+ return count;
100
+ }
101
+
102
+ /**
103
+ * Give `worktree` node_modules that resolve its own workspace packages and
104
+ * share the main checkout's installed ones. Null when there is nothing to
105
+ * link (no node_modules in the main checkout), with a note when it was
106
+ * skipped; never throws.
107
+ */
108
+ export function linkWorktreeDependencies(
109
+ main: string,
110
+ worktree: string,
111
+ ): { linked: LinkedDependencies | null; notes: string[] } {
112
+ const started = performance.now();
113
+ const notes: string[] = [];
114
+ const dirs: string[] = [];
115
+ let entries = 0;
116
+ for (const relative of moduleDirs(main)) {
117
+ const target = path.join(worktree, relative);
118
+ if (!fs.existsSync(path.dirname(target)) || fs.existsSync(target)) continue;
119
+ // The trailing slash: the directory does not exist yet, and `node_modules/` only matches one.
120
+ if (!gitRun(worktree, ["check-ignore", "-q", `${relative.split(path.sep).join("/")}/`]).ok) {
121
+ notes.push(
122
+ `${relative.split(path.sep).join("/")} is not ignored by git, so it was not linked from ${main}; install dependencies in the worktree yourself`,
123
+ );
124
+ continue;
125
+ }
126
+ try {
127
+ entries += mirror(main, worktree, path.join(main, relative), target, true);
128
+ dirs.push(relative.split(path.sep).join("/"));
129
+ } catch (error) {
130
+ fs.rmSync(target, { recursive: true, force: true });
131
+ notes.push(
132
+ `could not link ${relative} from ${main} (${(error as Error).message}); install dependencies in the worktree yourself`,
133
+ );
134
+ }
135
+ }
136
+ if (dirs.length === 0) return { linked: null, notes };
137
+ const ms = Math.round(performance.now() - started);
138
+ notes.push(
139
+ `linked ${dirs.join(", ")} from ${main} (${entries} entries, ${ms} ms): workspace packages resolve to this worktree, installed packages are shared`,
140
+ );
141
+ return { linked: { from: main, dirs, entries, ms }, notes };
142
+ }
@@ -0,0 +1,3 @@
1
+ ## Adjunto capturas
2
+
3
+ ## Archivos adjuntos
@@ -0,0 +1,8 @@
1
+ root = true
2
+
3
+ [*]
4
+ charset = utf-8
5
+ end_of_line = lf
6
+ insert_final_newline = true
7
+ indent_style = space
8
+ indent_size = 2
@@ -0,0 +1,3 @@
1
+ * text=auto
2
+ *.md text
3
+ *.bat text eol=crlf
@@ -0,0 +1,14 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ### Added
11
+
12
+ ### Changed
13
+
14
+ ### Fixed
@@ -0,0 +1,3 @@
1
+ # Contributing
2
+
3
+ <!-- How to report issues, propose changes, and the review process. -->
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) <YEAR> <HOLDER>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,3 @@
1
+ # <PROJECT>
2
+
3
+ <!-- Describe the project: what it does, how to run it, how to contribute. -->
@@ -0,0 +1,5 @@
1
+ # Update
2
+ {{projectSection}}
3
+ {{userNotesSection}}
4
+ {{progressSection}}
5
+ {{gitCommitsSection}}