rvlib-shipkit 1.4.0 → 1.6.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 (199) hide show
  1. package/CHANGELOG.md +114 -0
  2. package/README.md +17 -17
  3. package/package.json +3 -2
  4. package/src/assets/gen/comfyts-gen.ts +12 -8
  5. package/src/assets/server/studio-server.ts +14 -2
  6. package/src/check.ts +73 -103
  7. package/src/checks/SK001-package-json-shape.check.ts +11 -6
  8. package/src/checks/SK002-contracts-fulfillness.check.ts +4 -0
  9. package/src/checks/SK003-claude-md-shape.check.ts +7 -3
  10. package/src/checks/SK004-gitignore-policy.check.ts +7 -4
  11. package/src/checks/SK005-rvlib-versions.check.ts +53 -21
  12. package/src/checks/SK006-npm-publish-safety.check.ts +4 -0
  13. package/src/checks/SK007-github-visibility.check.ts +4 -2
  14. package/src/checks/SK008-reviewer-agent.check.ts +6 -4
  15. package/src/checks/SK009-commit-ai-tells.check.ts +4 -2
  16. package/src/checks/SK010-undeclared-unit.check.ts +4 -0
  17. package/src/checks/SK011-sibling-unit-ranges.check.ts +4 -0
  18. package/src/checks/SK012-config-barrel-import.check.ts +5 -1
  19. package/src/checks/SK013-one-way-in-policy.check.ts +7 -3
  20. package/src/checks/SK014-prose-ste.check.ts +4 -0
  21. package/src/checks/SK015-nested-repo.check.ts +4 -2
  22. package/src/checks/SK016-legacy-check-shapes.check.ts +13 -12
  23. package/src/checks/SK017-vscode-hub-task.check.ts +22 -7
  24. package/src/checks/SK018-test-files-run.check.ts +6 -2
  25. package/src/checks/SK019-tests-run-in-units.check.ts +8 -4
  26. package/src/checks/SK020-self-spawning-check.check.ts +4 -0
  27. package/src/checks/SK022-machine-runs-the-pin.check.ts +6 -4
  28. package/src/checks/SK024-identity-colors.check.ts +5 -3
  29. package/src/checks/SK025-release-drift.check.ts +4 -5
  30. package/src/checks/SK026-asks-autoclose.check.ts +4 -4
  31. package/src/checks/SK027-agent-wiki-links.check.ts +4 -1
  32. package/src/checks/SK028-readme.check.ts +5 -1
  33. package/src/checks/SK029-explicit-check-files.check.ts +95 -0
  34. package/src/checks/SK030-cache-audit.check.ts +34 -0
  35. package/src/checks/SK031-trace-audit.check.ts +39 -0
  36. package/src/checks/SK032-ignored-inputs.check.ts +79 -0
  37. package/src/cli/cmd_Asks.ts +5 -5
  38. package/src/cli/cmd_Assets.ts +17 -13
  39. package/src/cli/cmd_Bump.ts +12 -11
  40. package/src/cli/cmd_CacheAudit.ts +43 -0
  41. package/src/cli/cmd_Check.ts +8 -7
  42. package/src/cli/cmd_Checks.ts +7 -6
  43. package/src/cli/cmd_Ci.ts +12 -11
  44. package/src/cli/cmd_Commands.ts +16 -13
  45. package/src/cli/cmd_ConfigKeys.ts +7 -6
  46. package/src/cli/cmd_Contracts.ts +3 -3
  47. package/src/cli/cmd_CrossRepoActivity.ts +8 -6
  48. package/src/cli/cmd_Fleet.ts +9 -7
  49. package/src/cli/cmd_GlobalProcesses.ts +120 -7
  50. package/src/cli/cmd_Home.ts +26 -19
  51. package/src/cli/cmd_Hooks.ts +6 -5
  52. package/src/cli/cmd_Init.ts +5 -4
  53. package/src/cli/cmd_Keys.ts +20 -8
  54. package/src/cli/cmd_Mv.ts +7 -6
  55. package/src/cli/cmd_Needs.ts +4 -3
  56. package/src/cli/cmd_Overview.ts +5 -4
  57. package/src/cli/cmd_Pb.ts +2 -2
  58. package/src/cli/cmd_Planned.ts +6 -5
  59. package/src/cli/cmd_Prose.ts +5 -5
  60. package/src/cli/cmd_Reflect.ts +7 -7
  61. package/src/cli/cmd_Release.ts +6 -5
  62. package/src/cli/cmd_Releases.ts +5 -4
  63. package/src/cli/cmd_Serve.ts +6 -4
  64. package/src/cli/cmd_Smoke.ts +5 -4
  65. package/src/cli/cmd_Start.ts +7 -6
  66. package/src/cli/cmd_Status.ts +4 -3
  67. package/src/cli/cmd_TraceAudit.ts +62 -0
  68. package/src/cli/cmd_Tui.ts +3 -2
  69. package/src/cli/cmd_When.ts +71 -0
  70. package/src/cli/cmd_Wiki.ts +4 -4
  71. package/src/cli.ts +11 -2
  72. package/src/collections/checks.ts +2 -0
  73. package/src/collections/collection-types.ts +12 -4
  74. package/src/collections/providers.ts +5 -3
  75. package/src/core/app-icon.ts +154 -0
  76. package/src/core/audit-report.ts +21 -0
  77. package/src/core/cache-audit.ts +72 -0
  78. package/src/core/canonical-scripts.ts +2 -2
  79. package/src/core/check-ctx.ts +14 -5
  80. package/src/core/check-runner.ts +43 -23
  81. package/src/core/config-rewrite.ts +37 -24
  82. package/src/core/discover-checks.ts +23 -2
  83. package/src/core/floor-resolve.ts +5 -7
  84. package/src/core/git-root.ts +13 -0
  85. package/src/core/ignored-block.ts +94 -0
  86. package/src/core/ignored-inputs.ts +47 -0
  87. package/src/core/jobs.ts +9 -2
  88. package/src/core/legacy-shapes.ts +39 -10
  89. package/src/core/one-lib.ts +59 -4
  90. package/src/core/one-way-in-policy.ts +2 -2
  91. package/src/core/outcome-words.ts +18 -2
  92. package/src/core/port-check-cli.ts +52 -0
  93. package/src/core/port-check-file.ts +295 -0
  94. package/src/core/port-check-files.ts +31 -0
  95. package/src/core/read-set.ts +97 -9
  96. package/src/core/retention.ts +0 -14
  97. package/src/core/run-check.ts +12 -9
  98. package/src/core/run-ci.ts +14 -5
  99. package/src/core/run-fix.ts +30 -23
  100. package/src/core/run-fixers.ts +48 -5
  101. package/src/core/runs-matrix.ts +179 -0
  102. package/src/core/runs.ts +242 -0
  103. package/src/core/trace-audit.ts +120 -0
  104. package/src/core/update-policy.ts +20 -0
  105. package/src/core/when-block.ts +29 -30
  106. package/src/core/when.ts +41 -103
  107. package/src/core/wire.ts +10 -14
  108. package/src/fleet/scan-fleet.ts +20 -4
  109. package/src/fleet/update-fleet.ts +6 -6
  110. package/src/home/gui-app.ts +24 -27
  111. package/src/home/home-launchd.ts +20 -4
  112. package/src/home/home-routes.ts +12 -1
  113. package/src/home/home-start.ts +11 -5
  114. package/src/home/hubs.ts +5 -5
  115. package/src/home/open-app.ts +2 -2
  116. package/src/home/pm.ts +8 -0
  117. package/src/home/run-pm.ts +1 -0
  118. package/src/home/serve-web.ts +2 -2
  119. package/src/home/supervise.ts +24 -1
  120. package/src/home/via-hub.ts +14 -0
  121. package/src/hooks/run-hooks.ts +48 -4
  122. package/src/init-repo.ts +6 -6
  123. package/src/ledger/db.ts +2 -13
  124. package/src/ledger/gate-checks.ts +133 -61
  125. package/src/ledger/git-key.ts +153 -31
  126. package/src/ledger/git-scan.ts +5 -2
  127. package/src/ledger/ledger.ts +136 -21
  128. package/src/ledger/store.ts +14 -0
  129. package/src/ledger/toolchain-identity.ts +248 -45
  130. package/src/plan/repo-source.ts +18 -39
  131. package/src/problems/extract.ts +3 -0
  132. package/src/release/run-release.ts +5 -0
  133. package/src/repo-config.ts +46 -54
  134. package/src/rules/rule.ts +2 -2
  135. package/src/scaffold/files/agent-coding.md +1 -1
  136. package/src/scaffold/files/agent-index.md +1 -1
  137. package/src/scaffold/files/verify-skill.md +2 -2
  138. package/src/serve/hub-app/activity-st.ts +5 -4
  139. package/src/serve/hub-app/activity-view.tsx +1 -1
  140. package/src/serve/hub-app/admin-svc.ts +36 -3
  141. package/src/serve/hub-app/admin-view.tsx +46 -12
  142. package/src/serve/hub-app/check-view.tsx +66 -46
  143. package/src/serve/hub-app/check-words.ts +1 -11
  144. package/src/serve/hub-app/checks-svc.ts +14 -35
  145. package/src/serve/hub-app/col-card.tsx +1 -44
  146. package/src/serve/hub-app/col-tables-view.tsx +84 -11
  147. package/src/serve/hub-app/col-tables.ts +27 -0
  148. package/src/serve/hub-app/cols-svc.ts +5 -4
  149. package/src/serve/hub-app/dock-st.ts +3 -2
  150. package/src/serve/hub-app/dock-view.tsx +36 -0
  151. package/src/serve/hub-app/dock.css +6 -0
  152. package/src/serve/hub-app/float-cards.tsx +30 -0
  153. package/src/serve/hub-app/floating.tsx +41 -4
  154. package/src/serve/hub-app/git-st.ts +42 -0
  155. package/src/serve/hub-app/git-view.tsx +61 -0
  156. package/src/serve/hub-app/hash-route.ts +45 -1
  157. package/src/serve/hub-app/header-bar.tsx +62 -72
  158. package/src/serve/hub-app/health-svc.ts +3 -2
  159. package/src/serve/hub-app/home-st.ts +14 -2
  160. package/src/serve/hub-app/home-strip.css +43 -12
  161. package/src/serve/hub-app/home-strip.tsx +115 -64
  162. package/src/serve/hub-app/home.html +1 -0
  163. package/src/serve/hub-app/hub-app.tsx +32 -8
  164. package/src/serve/hub-app/hub-types.ts +5 -2
  165. package/src/serve/hub-app/hub.css +89 -63
  166. package/src/serve/hub-app/icons.tsx +11 -0
  167. package/src/serve/hub-app/index.html +1 -0
  168. package/src/serve/hub-app/keys.ts +15 -4
  169. package/src/serve/hub-app/live-svc.ts +3 -2
  170. package/src/serve/hub-app/machine-st.ts +18 -22
  171. package/src/serve/hub-app/plan-view.tsx +51 -0
  172. package/src/serve/hub-app/planned-st.ts +9 -4
  173. package/src/serve/hub-app/processes-view.tsx +45 -0
  174. package/src/serve/hub-app/project-list.css +4 -1
  175. package/src/serve/hub-app/project-list.tsx +6 -3
  176. package/src/serve/hub-app/projects-svc.ts +25 -10
  177. package/src/serve/hub-app/prose-hits-st.ts +3 -2
  178. package/src/serve/hub-app/reach.ts +36 -0
  179. package/src/serve/hub-app/readme-svc.ts +3 -2
  180. package/src/serve/hub-app/repo-sections.ts +131 -0
  181. package/src/serve/hub-app/repo-st.ts +70 -14
  182. package/src/serve/hub-app/sidebar.tsx +17 -42
  183. package/src/serve/hub-app/timeline-svc.ts +3 -2
  184. package/src/serve/hub-app/tree-build.ts +151 -155
  185. package/src/serve/hub-app/tree-row.tsx +55 -12
  186. package/src/serve/hub-app/tree-svc.ts +97 -56
  187. package/src/serve/hub-app/when-view.tsx +89 -0
  188. package/src/serve/hub-app/when.css +17 -0
  189. package/src/serve/hub-runtime.ts +7 -18
  190. package/src/serve/project-gutter-route.ts +22 -1
  191. package/src/serve/run-serve.ts +1 -1
  192. package/src/serve/serve-hub.ts +4 -14
  193. package/src/suite/judge-suites.ts +6 -0
  194. package/src/trace/interpose.c +655 -0
  195. package/src/trace/read-trace.ts +485 -0
  196. package/src/trace/tracer.ts +282 -0
  197. package/src/serve/hub-app/processes-svc.ts +0 -63
  198. package/src/serve/hub-app/repo-sections.tsx +0 -61
  199. package/src/serve/repo-processes.ts +0 -89
@@ -110,7 +110,7 @@ export function isLegacyCheckFile(path: string): boolean {
110
110
  /** every built-in id, from the file names alone. Sync and import free, so
111
111
  * `defineRepo` can read the namespace at config load. Read once per
112
112
  * process: the toolkit's own files cannot appear mid run, and `defineRepo`
113
- * asks for the list once per `when` entry. */
113
+ * asks for the list once per `off` entry. */
114
114
  let builtinIds: string[] | null = null
115
115
  export function builtinCheckIds(): string[] {
116
116
  if (builtinIds != null) return builtinIds
@@ -144,6 +144,20 @@ async function load(path: string): Promise<CheckDef | null> {
144
144
  }
145
145
  }
146
146
 
147
+ /** the stand in for a check file that does not load: it fails in every
148
+ * context with the load error, so the file cannot drop out of a gate unseen */
149
+ function unloadable(path: string, message: string): CheckDef {
150
+ return {
151
+ description: 'the check file loads',
152
+ blocking: true,
153
+ runs: { onPreCommit: false, onCi: true, onFull: true, onRelease: true, onHealth: true, onSave: false, onClock: false },
154
+ cache: { by: 'never', why: 'the file does not load' },
155
+ fix: 'none',
156
+ applies: 'always',
157
+ run: (c) => c.fail(`${basename(path)} does not load: ${message.replace(`${path}: `, '')}`, 'shipkit check --fix SK029 ports a retired check file with the values it ran on, and shipkit ci does it in its fix pass'),
158
+ }
159
+ }
160
+
147
161
  /** every check this repo can run: its own files, plus the toolkit's. Throws
148
162
  * on a registration nobody can act on (a duplicate id, a repo check squatting
149
163
  * the reserved prefix, a built-in with no owner page), so a broken
@@ -170,7 +184,14 @@ export async function discoverChecks(root: string): Promise<DiscoveredCheck[]> {
170
184
  const ids = new Set<string>()
171
185
  for (const f of [...builtins, ...own, ...units]) {
172
186
  const isBuiltin = builtins.includes(f)
173
- const def = await load(f)
187
+ let def: CheckDef | null
188
+ try {
189
+ def = await load(f)
190
+ } catch (e) {
191
+ // a file that does not load is ONE red row, never a dead inventory:
192
+ // every other check still runs, and this one says why in every run
193
+ def = unloadable(f, (e as Error).message)
194
+ }
174
195
  if (def == null) continue
175
196
  const rel = isBuiltin ? null : relative(root, f).replace(/\\/g, '/')
176
197
  const fallback = isBuiltin
@@ -201,9 +201,9 @@ function builtinFloorTask(id: string, root: string, io: FloorReader): { task: Ta
201
201
  return `no package.json '${id}' script`
202
202
  }
203
203
 
204
- // NOTHING here declares a read set on a repo's behalf, and that is a decision
205
- // rather than an omission. A floor slot with no `inputs` is measured every run,
206
- // which costs speed and never correctness (ledger/git-key.ts owns that default).
204
+ // NOTHING here declares a read set on a repo's behalf. The composer keys a
205
+ // floor slot with no `inputs` on the whole work tree plus the repo's
206
+ // `ignoredInputs` (ledger/gate-checks.ts), and the traced audit fills those.
207
207
  //
208
208
  // The tempting shortcut is `['.']`, on the reasoning that the whole tree cannot
209
209
  // under declare. It can. `['.']` is the TRACKED tree: git-scan force-adds only a
@@ -214,10 +214,8 @@ function builtinFloorTask(id: string, root: string, io: FloorReader): { task: Ta
214
214
  // gate print `ok: test (replayed)` over a suite that exits 1
215
215
  // (tests/floor-cache-safety.test.ts holds the repro).
216
216
  //
217
- // Speed here is bought by a REPO saying what its own tasks read, in
218
- // `defaults`, which is the only declaration with a real hit rate anyway.
219
- // `shipkit init` scaffolds the line, `shipkit keys --burn-down` names what is
220
- // still undeclared, and the gate prints the cost of it once per run.
217
+ // A narrower key is bought by a REPO saying what its own tasks read, in
218
+ // `defaults`.
221
219
 
222
220
  export function resolveFloor(c: RepoConfig, root: string, io: FloorReader): FloorResolution {
223
221
  const pkg = io.text('package.json')
@@ -8,6 +8,7 @@
8
8
  import { existsSync, lstatSync, readFileSync, realpathSync, statSync } from 'fs'
9
9
  import { dirname, join, resolve } from 'path'
10
10
  import { gitBin } from './git-bin.ts'
11
+ import { LAYOUT } from './layout.ts'
11
12
 
12
13
  export type GitRoot = {
13
14
  /** absolute git dir of this work tree (per worktree) */
@@ -130,3 +131,15 @@ export const gitRoot = (dir: string): GitRoot | null => probeGitRoot(dir).root
130
131
  export function isLinkedWorktree(root: string): boolean {
131
132
  try { return statSync(join(root, '.git')).isFile() } catch { return false }
132
133
  }
134
+
135
+ /** the one dir everything this repo remembers hangs off.
136
+ *
137
+ * `--git-common-dir` rather than `--git-dir`: a linked worktree has its own
138
+ * git dir but shares the common one, and that sharing is safe because every
139
+ * key component is content or a version string. A tree that is not a git
140
+ * repo at all still gets one, under `.shipkit/cache/`, because a scaffold in
141
+ * progress must not crash on it. */
142
+ export function shipkitRoot(root: string): string {
143
+ const dir = gitRoot(root)?.commonDir
144
+ return dir == null ? join(root, LAYOUT.cache) : join(dir, 'shipkit')
145
+ }
@@ -0,0 +1,94 @@
1
+ // THE IGNORED INPUTS BLOCK in `repo.config.ts`: the one place the traced
2
+ // audit (core/trace-audit.ts) writes the gitignored paths each task reads.
3
+ //
4
+ // Its own markers, apart from the gate block (core/when-block.ts), so a repo
5
+ // that writes `off` or `tests` by hand, with its comments, keeps them exactly
6
+ // as they are. The same paranoia as the gate block:
7
+ // 1. `ignoredInputs` declared OUTSIDE the markers is refused, because a
8
+ // second declaration is a duplicate key rather than a merge
9
+ // 2. a block that no longer renders what the config loads was hand edited,
10
+ // and is refused rather than overwritten
11
+ // 3. the result is read back through the loader the cli uses, and the
12
+ // previous bytes go back when it does not round trip
13
+ import { existsSync, readFileSync } from 'fs'
14
+ import { join } from 'path'
15
+ import { atomicWrite } from './locked-write.ts'
16
+ import { loadRepoConfig } from '../repo-config.ts'
17
+ import type { IgnoredInputs } from './audit-report.ts'
18
+
19
+ export const IGNORED_BEGIN = '// >>> shipkit ignored inputs — managed block, the traced audit writes it, edit outside the markers'
20
+ export const IGNORED_END = '// <<< shipkit ignored inputs'
21
+
22
+ const isBegin = (l: string): boolean => l.trim().startsWith('// >>> shipkit ignored inputs')
23
+ const isEnd = (l: string): boolean => l.trim().startsWith('// <<< shipkit ignored inputs')
24
+ const q = (s: string): string => `'${s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`
25
+ const key = (s: string): string => (/^[A-Za-z_$][\w$]*$/.test(s) ? s : q(s))
26
+
27
+ /** the same map, tasks and paths sorted, empty tasks dropped */
28
+ export function normalizeIgnored(ii: IgnoredInputs): IgnoredInputs {
29
+ const out: IgnoredInputs = {}
30
+ for (const t of Object.keys(ii).sort()) if ((ii[t] ?? []).length > 0) out[t] = [...new Set(ii[t])].sort()
31
+ return out
32
+ }
33
+
34
+ /** the bytes between the markers, markers included */
35
+ export function renderIgnoredBlock(ii: IgnoredInputs, pad: string): string[] {
36
+ const n = normalizeIgnored(ii)
37
+ return [
38
+ `${pad}${IGNORED_BEGIN}`,
39
+ `${pad}ignoredInputs: {`,
40
+ ...Object.entries(n).flatMap(([t, ps]) => [`${pad}${pad}${key(t)}: [`, ...ps.map((p) => `${pad}${pad}${pad}${q(p)},`), `${pad}${pad}],`]),
41
+ `${pad}},`,
42
+ `${pad}${IGNORED_END}`,
43
+ ]
44
+ }
45
+
46
+ function indentOf(lines: string[]): string {
47
+ for (const l of lines) {
48
+ const m = /^([ \t]+)\S/.exec(l)
49
+ if (m != null) return m[1]!
50
+ }
51
+ return ' '
52
+ }
53
+
54
+ export const validIgnoredInput = (p: unknown): p is string => typeof p === 'string' && p !== '' && !p.startsWith('/') && !p.split('/').includes('..')
55
+
56
+ /** add `add` (task id → paths) to the repo's ignoredInputs, through the
57
+ * managed block; the map after the write */
58
+ export async function addIgnoredInputs(root: string, add: IgnoredInputs): Promise<IgnoredInputs> {
59
+ const file = join(root, 'repo.config.ts')
60
+ if (!existsSync(file)) throw new Error(`no repo.config.ts in ${root} — run: shipkit init`)
61
+ for (const p of Object.values(add).flat()) if (!validIgnoredInput(p)) throw new Error(`ignoredInputs '${String(p)}' must be a repo-relative path inside the repo`)
62
+ const text = readFileSync(file, 'utf8')
63
+ const lines = text.split('\n')
64
+ const c = await loadRepoConfig(root, { fresh: true })
65
+ const have = c.ignoredInputs ?? {}
66
+ const b = lines.findIndex(isBegin)
67
+ const e = lines.findIndex(isEnd)
68
+ if (b === -1 || e === -1 || e < b) {
69
+ if (Object.keys(have).length > 0) throw new Error('repo.config.ts declares `ignoredInputs` outside the managed block — move it between the markers first, or the audit would write a duplicate key')
70
+ } else {
71
+ const pad = /^([ \t]*)/.exec(lines[b]!)?.[1] ?? ''
72
+ if (lines.slice(b, e + 1).join('\n') !== renderIgnoredBlock(have, pad).join('\n'))
73
+ throw new Error('the shipkit ignored inputs block in repo.config.ts no longer matches what the config loads, so it was hand edited — reconcile it by hand, then run the audit fix again')
74
+ }
75
+ const merged: IgnoredInputs = { ...have }
76
+ for (const [t, ps] of Object.entries(add)) merged[t] = [...(merged[t] ?? []), ...ps]
77
+ const next = normalizeIgnored(merged)
78
+ let out: string[]
79
+ if (b !== -1 && e > b) out = [...lines.slice(0, b), ...renderIgnoredBlock(next, /^([ \t]*)/.exec(lines[b]!)?.[1] ?? ''), ...lines.slice(e + 1)]
80
+ else {
81
+ const anchor = lines.findIndex((l) => /defineRepo\(\s*\{\s*$/.test(l))
82
+ if (anchor === -1) throw new Error(`repo.config.ts has no \`defineRepo({\` line to write into — paste this block inside the config object by hand:\n${renderIgnoredBlock(next, indentOf(lines)).join('\n')}`)
83
+ out = [...lines.slice(0, anchor + 1), ...renderIgnoredBlock(next, indentOf(lines)), ...lines.slice(anchor + 1)]
84
+ }
85
+ atomicWrite(file, out.join('\n'))
86
+ try {
87
+ const after = await loadRepoConfig(root, { fresh: true })
88
+ if (JSON.stringify(normalizeIgnored(after.ignoredInputs ?? {})) !== JSON.stringify(next)) throw new Error(`the loader read ignoredInputs ${JSON.stringify(after.ignoredInputs ?? {})}`)
89
+ } catch (err) {
90
+ atomicWrite(file, text)
91
+ throw new Error(`repo.config.ts was left as it was: ${(err as Error).message}`)
92
+ }
93
+ return next
94
+ }
@@ -0,0 +1,47 @@
1
+ // IGNORED INPUTS, the pure half: the kept audit's shape and what a task's key
2
+ // takes from it and from `repo.config.ts ignoredInputs`. No fs here, so a
3
+ // check keyed on its reads can import it (core/audit-report.ts reads the file).
4
+
5
+ export type AuditTask = {
6
+ id: string
7
+ /** `audited` when its trace had no hole, else why it could not be */
8
+ status: 'audited' | 'unaudited'
9
+ why?: string
10
+ /** ignored paths it read that its key does not cover */
11
+ uncovered: string[]
12
+ /** the fewest `ignoredInputs` entries that cover them */
13
+ propose: string[]
14
+ }
15
+
16
+ export type AuditReport = {
17
+ at: number
18
+ /** why nothing was traced on this machine, when nothing was */
19
+ skipped?: string
20
+ tasks: AuditTask[]
21
+ }
22
+
23
+ /** what the kept audit says this task reads outside its key */
24
+ export function auditedExtras(r: AuditReport | null, id: string): string[] {
25
+ if (r == null || r.skipped != null) return []
26
+ return r.tasks.find((x) => x.id === id)?.propose ?? []
27
+ }
28
+
29
+ /** the audit's entries that `have` does not carry yet, per task */
30
+ export function pendingByTask(r: AuditReport | null, have: IgnoredInputs): IgnoredInputs {
31
+ const out: IgnoredInputs = {}
32
+ if (r == null || r.skipped != null) return out
33
+ for (const t of r.tasks) {
34
+ const missing = (t.propose ?? []).filter((p) => !(have['*'] ?? []).includes(p) && !(have[t.id] ?? []).includes(p))
35
+ if (missing.length > 0) out[t.id] = missing
36
+ }
37
+ return out
38
+ }
39
+
40
+ /** `ignoredInputs`: task id → gitignored paths it reads, `'*'` for every task */
41
+ export type IgnoredInputs = Record<string, string[]>
42
+
43
+ /** the entries that join one task's key */
44
+ export const ignoredFor = (ii: IgnoredInputs | undefined, id: string): string[] => [...(ii?.['*'] ?? []), ...(ii?.[id] ?? [])]
45
+
46
+ /** `task: a, b; other: c`, for a line */
47
+ export const describeByTask = (by: IgnoredInputs): string => Object.entries(by).map(([t, ps]) => `${t}: ${ps.join(', ')}`).join('; ')
package/src/core/jobs.ts CHANGED
@@ -58,7 +58,14 @@ let seq = 0
58
58
  /** what a job the daemon starts carries beside its flags: the run's options
59
59
  * as the runner takes them, one lib copy for the repo config, and the
60
60
  * machine's one background turn for a run nobody waits on */
61
- export type JobOpts = { opts: Record<string, unknown>; oneLib?: boolean; turn?: boolean }
61
+ export type JobOpts = {
62
+ opts: Record<string, unknown>
63
+ oneLib?: boolean
64
+ turn?: boolean
65
+ /** the switches the asking terminal had set (home/via-hub.ts): the job
66
+ * starts from the daemon's env, which never saw them */
67
+ env?: Record<string, string>
68
+ }
62
69
 
63
70
  /** start `shipkit <kind> <args> --job <id>` detached from this process and
64
71
  * its terminal, and return at once */
@@ -69,7 +76,7 @@ export function startJob(root: string, o: { cli: string; kind: JobKind; args: st
69
76
  mkdirSync(dir, { recursive: true })
70
77
  if (o.job != null) atomicWrite(jobOpts(root, id), JSON.stringify(o.job))
71
78
  const out = openSync(join(dir, 'out.log'), 'a')
72
- const child = spawn(process.execPath, [o.cli, o.kind, ...o.args, '--job', id], { cwd: root, detached: true, stdio: ['ignore', out, out], env: process.env })
79
+ const child = spawn(process.execPath, [o.cli, o.kind, ...o.args, '--job', id], { cwd: root, detached: true, stdio: ['ignore', out, out], env: { ...process.env, ...o.job?.env } })
73
80
  closeSync(out)
74
81
  child.unref()
75
82
  const pid = child.pid ?? 0
@@ -22,6 +22,7 @@ import { fallbackNames, isCheckFilePath, readLegacyCheck, type LegacyCheck } fro
22
22
  import type { ScanCtx } from './check-ctx.ts'
23
23
  import type { RepoConfig } from '../repo-config.ts'
24
24
  import { descriptionProblem, type TaskDef } from '../check.ts'
25
+ import { deriveExplicit, type Derived } from './port-check-file.ts'
25
26
 
26
27
  /** the narrow module every rewritten file imports `check` from, and the ONE
27
28
  * spelling: the barrel costs 400ms of module load and discovery imports
@@ -130,16 +131,34 @@ export const PLACEHOLDER_MARK = 'TODO: state what must be true'
130
131
  * drops none of them. */
131
132
  export function scriptPort(read: LegacyCheck, fallbackBlocking: boolean | undefined): string {
132
133
  const blocking = read.blocking ?? fallbackBlocking
134
+ // what the ported `run` body resolves to: the script's own declarations,
135
+ // read as the gate read them, on a body keyed on what it reads
136
+ const d = explicitOf(deriveExplicit({
137
+ when: read.gate ? null : 'health',
138
+ build: false,
139
+ body: 'run',
140
+ ...(read.inputs != null ? { inputs: `[${read.inputs.map(q).join(', ')}]` } : {}),
141
+ ...(read.ttl != null ? { ttl: q(read.ttl) } : {}),
142
+ ...(read.never != null ? { never: q(read.never) } : {}),
143
+ repair: false,
144
+ rawAccess: null,
145
+ }, q))
133
146
  const fields = [
134
147
  blocking == null ? 'blocking: <true | false, it declares none>' : `blocking: ${blocking}`,
135
- ...(read.inputs != null ? [`inputs: [${read.inputs.map(q).join(', ')}]`] : []),
136
- ...(read.ttl != null ? [`ttl: ${q(read.ttl)}`] : []),
137
- ...(read.never != null ? [`never: ${q(read.never)}`] : []),
138
- ...(read.gate ? [] : [`when: 'health'`]),
148
+ `runs: ${d.runs}`,
149
+ `cache: ${d.cache}`,
150
+ `fix: ${q(d.fix)}`,
151
+ `applies: ${d.applies}`,
139
152
  ]
140
153
  return `a check script, run as its own process: port it by hand into \`export default check({ description, ${fields.join(', ')}, run: (c) => … })\`, each print a call on c (c.ok, c.warn, c.fail)`
141
154
  }
142
155
 
156
+ /** a derivation that cannot fail on what a script or a task can declare */
157
+ function explicitOf(d: Derived | { error: string }): Derived {
158
+ if ('error' in d) throw new Error(`the legacy declaration has no explicit form: ${d.error}`)
159
+ return d
160
+ }
161
+
143
162
  /** the check file a task's argv runs through bun, `bun <file>` or `bun run
144
163
  * <file>`, relative, `./` prefixed or absolute, resolved against the task's
145
164
  * cwd. `rest` is what the argv passes after the file: a task that passes its
@@ -159,16 +178,24 @@ export function checkFileRunBy(t: Pick<TaskDef, 'cmd' | 'cwd'>, root: string): {
159
178
  * always was: the gate runs it and the health pass leaves it out. */
160
179
  export function taskCheckSource(t: TaskDef, root: string): string {
161
180
  const own = t.description != null && descriptionProblem(t.description) == null ? t.description.trim() : null
162
- const fields = [`id: ${q(t.id)},`, `description: ${q(own ?? descriptionPlaceholder())},`, 'blocking: true,', 'build: true,', `cmd: [${t.cmd!.map(q).join(', ')}],`]
181
+ // where it ran and how it was kept, as the gate and the planner read a
182
+ // task: an optIn one ran only by name
183
+ const d = explicitOf(deriveExplicit({
184
+ when: t.optIn === true ? 'health' : null,
185
+ build: true,
186
+ body: 'cmd',
187
+ ...(t.inputs != null ? { inputs: `[${t.inputs.map(q).join(', ')}]` } : {}),
188
+ ...(t.ttl != null ? { ttl: q(t.ttl) } : {}),
189
+ ...(t.never != null ? { never: q(t.never) } : {}),
190
+ repair: false,
191
+ rawAccess: null,
192
+ }, q))
193
+ const fields = [`id: ${q(t.id)},`, `description: ${q(own ?? descriptionPlaceholder())},`, 'blocking: true,', 'build: true,', `runs: ${d.runs},`, `cache: ${d.cache},`, `fix: ${q(d.fix)},`, `applies: ${d.applies},`, `cmd: [${t.cmd!.map(q).join(', ')}],`]
163
194
  if (t.deps != null && t.deps.length > 0) fields.push(`deps: [${t.deps.map(q).join(', ')}],`)
164
- if (t.inputs != null) fields.push(`inputs: [${t.inputs.map(q).join(', ')}],`)
165
- if (t.ttl != null) fields.push(`ttl: ${q(t.ttl)},`)
166
- if (t.never != null) fields.push(`never: ${q(t.never)},`)
167
195
  if (t.group != null) fields.push(`group: ${q(t.group)},`)
168
196
  if (t.timeoutMs != null) fields.push(`timeoutMs: ${t.timeoutMs},`)
169
197
  if (t.env != null) fields.push(`env: { ${Object.entries(t.env).map(([k, v]) => `${q(k)}: ${q(v)}`).join(', ')} },`)
170
198
  if (t.cwd != null) fields.push(`cwd: ${q(t.cwd)},`)
171
- if (t.optIn === true) fields.push(`when: 'health',`)
172
199
  const w = checkWrapper(root)
173
200
  return [...w.head, w.open, ...fields.map((f) => ` ${f}`), w.close, ''].join('\n')
174
201
  }
@@ -189,6 +216,8 @@ export function dropConfigKey(text: string, key: string): string | null {
189
216
  const start = at[0]!
190
217
  const end = statementEnd(lines, start)
191
218
  let from = start
192
- while (from > 0 && lines[from - 1]!.trim().startsWith('//')) from--
219
+ // the comment block above the key goes with it, never a managed block
220
+ // marker (`// >>> shipkit …`): the key may be the first line inside one
221
+ while (from > 0 && lines[from - 1]!.trim().startsWith('//') && !/^\/\/ (>>>|<<<) shipkit/.test(lines[from - 1]!.trim())) from--
193
222
  return [...lines.slice(0, from), ...lines.slice(end + 1)].join('\n')
194
223
  }
@@ -2,11 +2,14 @@
2
2
  // (the home) makes every `rvlib-shipkit` import, from any repo config or check
3
3
  // file, land on its OWN copy. A repo's install is never loaded, so there is one
4
4
  // module instance per lib file: one mobx, one class identity, one version.
5
+ // The same holds for a package whose objects cross between a repo file and
6
+ // the toolkit (SHARED): a repo's brand draws on a canvas the toolkit made,
7
+ // and a native class only accepts instances of its own copy.
5
8
  //
6
9
  // the one constraint a reader breaks first: register it BEFORE the first repo
7
10
  // config is imported. A module already loaded keeps the copy it resolved.
8
- import { readFileSync, realpathSync } from 'fs'
9
- import { extname, join } from 'path'
11
+ import { existsSync, readFileSync, realpathSync } from 'fs'
12
+ import { dirname, extname, join } from 'path'
10
13
  import { LIB_ROOT } from './lib-root.ts'
11
14
 
12
15
  const NAME = 'rvlib-shipkit'
@@ -54,6 +57,46 @@ const real = (p: string): string => {
54
57
  }
55
58
  }
56
59
 
60
+ /** packages a repo file and the toolkit hand objects of to each other */
61
+ export const SHARED: readonly string[] = ['@napi-rs/canvas', 'comfy-ts']
62
+
63
+ /** a file of ANOTHER copy of a shared package, as the path inside that copy,
64
+ * else null. `host` is the root of the copy this process loads */
65
+ export function foreignSharedPath(path: string, name: string, host: string | null): string | null {
66
+ const at = path.lastIndexOf(`/node_modules/${name}/`)
67
+ if (at === -1) return null
68
+ const root = path.slice(0, at + `/node_modules/${name}`.length)
69
+ if (host == null || root === host || real(root) === real(host)) return null
70
+ return path.slice(root.length + 1)
71
+ }
72
+
73
+ const hostRoot = (name: string, from: string): string | null => {
74
+ try {
75
+ return dirname(Bun.resolveSync(`${name}/package.json`, from))
76
+ } catch {
77
+ return null
78
+ }
79
+ }
80
+
81
+ /** where a foreign copy's file lands in the host copy: its entry on the
82
+ * host's entry (the two can name different files), any other file on the
83
+ * same path, else on the entry */
84
+ const sharedTarget = (path: string, sub: string, name: string, host: string): string => {
85
+ const entry = Bun.resolveSync(name, host)
86
+ const foreignDir = path.slice(0, path.length - sub.length - 1)
87
+ try {
88
+ if (real(Bun.resolveSync(name, foreignDir)) === real(path)) return entry
89
+ } catch {
90
+ /* a foreign copy with no entry of its own: its files map by path */
91
+ }
92
+ return existsSync(join(host, sub)) ? join(host, sub) : entry
93
+ }
94
+
95
+ const reexport = (target: string): { contents: string; loader: 'ts' } => {
96
+ const t = JSON.stringify(target)
97
+ return { contents: `export * from ${t}\nimport * as own from ${t}\nexport default (own as { default?: unknown }).default\n`, loader: 'ts' }
98
+ }
99
+
57
100
  const LOADERS: Record<string, 'ts' | 'tsx' | 'js' | 'jsx'> = { '.ts': 'ts', '.tsx': 'tsx', '.js': 'js', '.mjs': 'js', '.jsx': 'jsx' }
58
101
 
59
102
  let installed = false
@@ -77,9 +120,21 @@ export function useOneLib(root: string = LIB_ROOT): void {
77
120
  const loader = LOADERS[extname(a.path)] ?? 'ts'
78
121
  const sub = foreignLibPath(a.path, root)
79
122
  if (sub == null) return { contents: readFileSync(a.path, 'utf8'), loader }
80
- const target = JSON.stringify(join(root, sub))
81
- return { contents: `export * from ${target}\nimport * as own from ${target}\nexport default (own as { default?: unknown }).default\n`, loader: 'ts' }
123
+ return reexport(join(root, sub))
82
124
  })
125
+ for (const name of SHARED) {
126
+ const host = hostRoot(name, root)
127
+ if (host == null) continue
128
+ // the host copy never matches: a CommonJS file read back here
129
+ // loses the named exports bun infers for it
130
+ const esc = (s: string): string => s.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')
131
+ const skip = [...new Set([host, real(host)])].map((h) => `(?!${esc(h)}/)`).join('')
132
+ build.onLoad({ filter: new RegExp(`^${skip}.*/node_modules/${esc(name)}/`) }, (a) => {
133
+ const sub = foreignSharedPath(a.path, name, host)
134
+ if (sub == null) return { contents: readFileSync(a.path, 'utf8'), loader: LOADERS[extname(a.path)] ?? 'js' }
135
+ return reexport(sharedTarget(a.path, sub, name, host))
136
+ })
137
+ }
83
138
  },
84
139
  })
85
140
  }
@@ -1,4 +1,4 @@
1
- // ONE WAY IN, the harness half (build/shipkit.md). The package.json half
1
+ // ONE WAY IN, the harness half (build/shipkit/one-way-in.md). The package.json half
2
2
  // (rule SK001) routes `bun run test` into shipkit, but `bun test` is a bun
3
3
  // BUILTIN: no script can intercept it, so a package.json alone leaves the
4
4
  // reflex wide open. Claude Code permission rules can close it.
@@ -76,7 +76,7 @@ export const denyRules = (exemptSlots: Iterable<string> = []): string[] => {
76
76
  export function refusal(r: RawRunner, segment?: string): string {
77
77
  const alt = r.iterate == null ? '' : `, or \`${r.iterate}\` to iterate on ONE suite while you fix a bug`
78
78
  const which = segment == null || segment.trim() === '' || segment.trim() === r.head ? '' : ` (refused on \`${segment.trim().slice(0, 80)}\`)`
79
- return `\`${r.head}\`${which} bypasses shipkit, so nothing is cached and no other agent's run can be attached to. Use \`${r.use}\`${alt}. Lifting this for good: delete the "Bash(${r.head}*)" line from .claude/settings.json permissions.deny (build/shipkit.md, ONE WAY IN).`
79
+ return `\`${r.head}\`${which} bypasses shipkit, so nothing is cached and no other agent's run can be attached to. Use \`${r.use}\`${alt}. Lifting this for good: delete the "Bash(${r.head}*)" line from .claude/settings.json permissions.deny (build/shipkit/one-way-in.md).`
80
80
  }
81
81
 
82
82
  /** shell wrappers that do not change what is being run */
@@ -38,8 +38,24 @@ export const checkVerdictWords = (status: 'pass' | 'warn' | 'fail' | 'skip' | nu
38
38
  : status === 'skip' ? 'skipped'
39
39
  : status === 'pass' ? (o.replayed ? `passed, ${CACHED}` : 'passed') : 'never ran here'
40
40
 
41
- /** the stamp on the first line of a cached check: `(cached, 5.6s saved)` */
42
- export const cachedStamp = (savedMs: number): string => `(${CACHED}, ${(savedMs / 1000).toFixed(1)}s saved)`
41
+ /** a span in its one biggest unit: `45s`, `12m`, `3h`, `2d` */
42
+ export function spanShort(ms: number): string {
43
+ const s = Math.max(0, Math.round(ms / 1000))
44
+ if (s < 60) return `${s}s`
45
+ if (s < 3600) return `${Math.floor(s / 60)}m`
46
+ if (s < 86400) return `${Math.floor(s / 3600)}h`
47
+ return `${Math.floor(s / 86400)}d`
48
+ }
49
+
50
+ /** what a run cost: `12ms` under a second, `6.4s` past it */
51
+ export const costShort = (ms: number): string => (ms < 1000 ? `${Math.max(0, Math.round(ms))}ms` : `${(ms / 1000).toFixed(1)}s`)
52
+
53
+ /** the stamp on the first line of a cached check: how old the answer is and
54
+ * what measuring it cost, `(cached, 3h old, 5.6s saved)` */
55
+ export const cachedStamp = (savedMs: number, ageMs: number): string => `(${CACHED}, ${spanShort(ageMs)} old, ${costShort(savedMs)} saved)`
56
+
57
+ /** the stamp on the first line of a check that ran in this run: `(ran 6.4s)` */
58
+ export const ranStamp = (ms: number): string => `(${RAN} ${costShort(ms)})`
43
59
 
44
60
  /** the stamp on the first line of an attached check */
45
61
  export const attachedStamp = (pid: number, ranMs: number): string => `(${ATTACHED} to pid ${pid}, ${RAN} there in ${(ranMs / 1000).toFixed(1)}s)`
@@ -0,0 +1,52 @@
1
+ // THE WRITE HALF of the check file port (core/port-check-file.ts), in its own
2
+ // process: whether an undeclared `run` body can be keyed on its reads is a walk
3
+ // of its import closure on disk (core/raw-access.ts), which no check context
4
+ // read can stand for, so SK029's repair reaches it through `c.exec`.
5
+ //
6
+ // bun port-check-cli.ts <root> <rel> [<rel> …] → one json line on stdout
7
+ import { readFileSync } from 'fs'
8
+ import { join } from 'path'
9
+ import { atomicWrite } from './locked-write.ts'
10
+ import { LIB_ROOT } from './lib-root.ts'
11
+ import { rawAccessInClosure } from './raw-access.ts'
12
+ import { findCheckObject, portCheckSource, retiredKeysIn } from './port-check-file.ts'
13
+ import { plainDefaultFor } from './port-check-files.ts'
14
+
15
+ export type PortRun = { did: string[]; hand: string[] }
16
+
17
+ /** why an undeclared `run` body cannot be keyed on its reads, or null */
18
+ function rawAccessOf(root: string, rel: string, src: string): string | null {
19
+ const obj = findCheckObject(src, { plainDefault: plainDefaultFor(rel) })
20
+ const keys = new Set(obj?.props.map((p) => p.key) ?? [])
21
+ if (!keys.has('run') || keys.has('inputs') || keys.has('ttl') || keys.has('never')) return null
22
+ const r = rawAccessInClosure(root, join(root, rel), { skip: LIB_ROOT })
23
+ return r == null ? null : `its imports reach the disk around c (${r.file}:${r.line}, ${r.what})`
24
+ }
25
+
26
+ export function portFiles(root: string, rels: string[]): PortRun {
27
+ const did: string[] = []
28
+ const hand: string[] = []
29
+ for (const rel of rels) {
30
+ const abs = join(root, rel)
31
+ let src: string
32
+ try { src = readFileSync(abs, 'utf8') } catch (e) { hand.push(`${rel}: unreadable (${(e as Error).message})`); continue }
33
+ const plainDefault = plainDefaultFor(rel)
34
+ const keys = retiredKeysIn(src, { plainDefault })
35
+ if (keys.length === 0) continue
36
+ const r = portCheckSource(src, { plainDefault, rawAccess: rawAccessOf(root, rel, src) })
37
+ if ('error' in r) { hand.push(`${rel}: ${r.error}`); continue }
38
+ if (!r.changed) continue
39
+ atomicWrite(abs, r.source)
40
+ did.push(`${rel} declares runs, cache, fix and applies (was: ${keys.join(', ')})${r.notes.length > 0 ? `. Note: ${r.notes.join('; ')}` : ''}`)
41
+ }
42
+ return { did, hand }
43
+ }
44
+
45
+ if (import.meta.main) {
46
+ const [root, ...rels] = process.argv.slice(2)
47
+ if (root == null) {
48
+ console.error('usage: bun port-check-cli.ts <root> <rel> [<rel> …]')
49
+ process.exit(2)
50
+ }
51
+ console.log(JSON.stringify(portFiles(root, rels)))
52
+ }