rvlib-shipkit 1.3.0 → 1.5.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 (203) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/README.md +17 -17
  3. package/package.json +3 -2
  4. package/src/asks/asks-queue.ts +4 -2
  5. package/src/assets/kits/identity-kit.ts +15 -13
  6. package/src/assets/run-assets.ts +12 -2
  7. package/src/check.ts +73 -98
  8. package/src/checks/SK001-package-json-shape.check.ts +26 -4
  9. package/src/checks/SK002-contracts-fulfillness.check.ts +4 -0
  10. package/src/checks/SK003-claude-md-shape.check.ts +5 -1
  11. package/src/checks/SK004-gitignore-policy.check.ts +7 -4
  12. package/src/checks/SK005-rvlib-versions.check.ts +53 -21
  13. package/src/checks/SK006-npm-publish-safety.check.ts +4 -0
  14. package/src/checks/SK007-github-visibility.check.ts +4 -2
  15. package/src/checks/SK008-reviewer-agent.check.ts +6 -4
  16. package/src/checks/SK009-commit-ai-tells.check.ts +94 -74
  17. package/src/checks/SK010-undeclared-unit.check.ts +4 -0
  18. package/src/checks/SK011-sibling-unit-ranges.check.ts +4 -0
  19. package/src/checks/SK012-config-barrel-import.check.ts +5 -1
  20. package/src/checks/SK013-one-way-in-policy.check.ts +7 -3
  21. package/src/checks/SK014-prose-ste.check.ts +20 -6
  22. package/src/checks/SK015-nested-repo.check.ts +4 -2
  23. package/src/checks/SK016-legacy-check-shapes.check.ts +23 -16
  24. package/src/checks/SK017-vscode-hub-task.check.ts +22 -7
  25. package/src/checks/SK018-test-files-run.check.ts +6 -2
  26. package/src/checks/SK019-tests-run-in-units.check.ts +8 -4
  27. package/src/checks/SK020-self-spawning-check.check.ts +4 -0
  28. package/src/checks/SK022-machine-runs-the-pin.check.ts +6 -4
  29. package/src/checks/SK024-identity-colors.check.ts +5 -3
  30. package/src/checks/SK025-release-drift.check.ts +4 -5
  31. package/src/checks/SK026-asks-autoclose.check.ts +4 -4
  32. package/src/checks/SK027-agent-wiki-links.check.ts +34 -9
  33. package/src/checks/SK028-readme.check.ts +10 -2
  34. package/src/checks/SK029-explicit-check-files.check.ts +95 -0
  35. package/src/cli/cmd_Asks.ts +5 -5
  36. package/src/cli/cmd_Assets.ts +7 -7
  37. package/src/cli/cmd_Bump.ts +12 -11
  38. package/src/cli/cmd_Check.ts +8 -7
  39. package/src/cli/cmd_Checks.ts +7 -6
  40. package/src/cli/cmd_Ci.ts +12 -11
  41. package/src/cli/cmd_Commands.ts +16 -13
  42. package/src/cli/cmd_ConfigKeys.ts +7 -6
  43. package/src/cli/cmd_Contracts.ts +3 -3
  44. package/src/cli/cmd_CrossRepoActivity.ts +10 -6
  45. package/src/cli/cmd_Fleet.ts +9 -7
  46. package/src/cli/cmd_GlobalProcesses.ts +116 -4
  47. package/src/cli/cmd_Home.ts +23 -17
  48. package/src/cli/cmd_Hooks.ts +6 -5
  49. package/src/cli/cmd_Init.ts +5 -4
  50. package/src/cli/cmd_Keys.ts +8 -7
  51. package/src/cli/cmd_Mv.ts +7 -6
  52. package/src/cli/cmd_Needs.ts +4 -3
  53. package/src/cli/cmd_Overview.ts +4 -3
  54. package/src/cli/cmd_Pb.ts +2 -2
  55. package/src/cli/cmd_Planned.ts +6 -5
  56. package/src/cli/cmd_Prose.ts +5 -5
  57. package/src/cli/cmd_Reflect.ts +7 -7
  58. package/src/cli/cmd_Release.ts +6 -5
  59. package/src/cli/cmd_Releases.ts +5 -4
  60. package/src/cli/cmd_Serve.ts +4 -3
  61. package/src/cli/cmd_Smoke.ts +5 -4
  62. package/src/cli/cmd_Start.ts +7 -6
  63. package/src/cli/cmd_Status.ts +4 -3
  64. package/src/cli/cmd_Tui.ts +3 -2
  65. package/src/cli/cmd_When.ts +71 -0
  66. package/src/cli/cmd_Wiki.ts +4 -4
  67. package/src/cli.ts +7 -2
  68. package/src/collections/checks.ts +2 -0
  69. package/src/collections/collection-types.ts +24 -4
  70. package/src/collections/providers.ts +6 -2
  71. package/src/contracts/apply-contracts.ts +9 -8
  72. package/src/contracts/contract-types.ts +8 -2
  73. package/src/contracts/contracts-engine.ts +34 -13
  74. package/src/contracts/manifests/identity.contract.ts +6 -5
  75. package/src/contracts/manifests/social.contract.ts +4 -2
  76. package/src/contracts/manifests/steam.contract.ts +7 -3
  77. package/src/contracts/web-surface.ts +59 -0
  78. package/src/core/budget.ts +19 -9
  79. package/src/core/canonical-scripts.ts +1 -1
  80. package/src/core/check-runner.ts +63 -25
  81. package/src/core/config-rewrite.ts +37 -24
  82. package/src/core/discover-checks.ts +31 -7
  83. package/src/core/layout.ts +2 -0
  84. package/src/core/legacy-shapes.ts +39 -10
  85. package/src/core/motherhome.ts +88 -66
  86. package/src/core/one-way-in-policy.ts +2 -2
  87. package/src/core/outcome-words.ts +18 -2
  88. package/src/core/port-check-cli.ts +52 -0
  89. package/src/core/port-check-file.ts +295 -0
  90. package/src/core/port-check-files.ts +31 -0
  91. package/src/core/retention.ts +0 -14
  92. package/src/core/run-check.ts +14 -14
  93. package/src/core/run-ci.ts +11 -7
  94. package/src/core/run-fix.ts +30 -23
  95. package/src/core/run-fixers.ts +42 -5
  96. package/src/core/runs-matrix.ts +179 -0
  97. package/src/core/runs.ts +242 -0
  98. package/src/core/update-policy.ts +20 -0
  99. package/src/core/when-block.ts +29 -30
  100. package/src/core/when.ts +41 -103
  101. package/src/core/wire.ts +34 -9
  102. package/src/fleet/scan-fleet.ts +20 -4
  103. package/src/fleet/update-fleet.ts +6 -6
  104. package/src/home/activity-shape.ts +4 -1
  105. package/src/home/activity.ts +22 -15
  106. package/src/home/gui-app/ShipkitGui.swift +144 -25
  107. package/src/home/gui-app.ts +93 -30
  108. package/src/home/home-launchd.ts +30 -6
  109. package/src/home/home-routes.ts +14 -2
  110. package/src/home/home-start.ts +33 -5
  111. package/src/home/hubs.ts +5 -5
  112. package/src/home/machine-stores.ts +4 -2
  113. package/src/home/open-app.ts +23 -10
  114. package/src/home/pm-config.ts +15 -11
  115. package/src/home/pm-inputs.ts +76 -0
  116. package/src/home/pm.ts +11 -3
  117. package/src/home/process-rows.ts +23 -6
  118. package/src/home/resources.ts +67 -15
  119. package/src/home/run-daemon.ts +6 -1
  120. package/src/home/run-pm.ts +30 -5
  121. package/src/home/serve-web.ts +2 -2
  122. package/src/home/supervise.ts +24 -1
  123. package/src/home/widgets.ts +32 -3
  124. package/src/hooks/commit-message.ts +2 -3
  125. package/src/hooks/run-hooks.ts +60 -6
  126. package/src/init-repo.ts +6 -6
  127. package/src/ledger/gate-checks.ts +115 -61
  128. package/src/ledger/git-key.ts +5 -1
  129. package/src/ledger/ledger.ts +5 -5
  130. package/src/ledger/store.ts +11 -0
  131. package/src/ledger/toolchain-identity.ts +45 -27
  132. package/src/ledger/world-cache.ts +17 -15
  133. package/src/plan/repo-source.ts +18 -39
  134. package/src/release/run-release.ts +5 -0
  135. package/src/repo-config.ts +36 -56
  136. package/src/rules/rule.ts +2 -2
  137. package/src/scaffold/files/agent-coding.md +1 -1
  138. package/src/scaffold/files/agent-index.md +1 -1
  139. package/src/scaffold/files/verify-skill.md +2 -2
  140. package/src/serve/hub-app/activity-st.ts +5 -4
  141. package/src/serve/hub-app/activity-view.tsx +4 -4
  142. package/src/serve/hub-app/admin-view.tsx +13 -11
  143. package/src/serve/hub-app/check-view.tsx +66 -46
  144. package/src/serve/hub-app/check-words.ts +1 -11
  145. package/src/serve/hub-app/checks-svc.ts +16 -36
  146. package/src/serve/hub-app/col-tables-view.tsx +131 -0
  147. package/src/serve/hub-app/col-tables.ts +115 -0
  148. package/src/serve/hub-app/cols-svc.ts +33 -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 +7 -1
  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 +15 -3
  160. package/src/serve/hub-app/home-strip.css +69 -19
  161. package/src/serve/hub-app/home-strip.tsx +192 -118
  162. package/src/serve/hub-app/home.html +1 -0
  163. package/src/serve/hub-app/hub-app.tsx +21 -5
  164. package/src/serve/hub-app/hub-types.ts +11 -5
  165. package/src/serve/hub-app/hub.css +88 -44
  166. package/src/serve/hub-app/icons.tsx +15 -0
  167. package/src/serve/hub-app/index.html +1 -0
  168. package/src/serve/hub-app/keys.ts +11 -8
  169. package/src/serve/hub-app/live-svc.ts +3 -2
  170. package/src/serve/hub-app/machine-st.ts +34 -49
  171. package/src/serve/hub-app/palette-st.ts +5 -5
  172. package/src/serve/hub-app/palette-view.tsx +1 -1
  173. package/src/serve/hub-app/plan-view.tsx +51 -0
  174. package/src/serve/hub-app/planned-st.ts +9 -4
  175. package/src/serve/hub-app/processes-view.tsx +38 -0
  176. package/src/serve/hub-app/project-list.tsx +3 -3
  177. package/src/serve/hub-app/projects-svc.ts +15 -10
  178. package/src/serve/hub-app/prose-hits-st.ts +3 -2
  179. package/src/serve/hub-app/reach.ts +36 -0
  180. package/src/serve/hub-app/readme-svc.ts +3 -2
  181. package/src/serve/hub-app/repo-sections.ts +117 -0
  182. package/src/serve/hub-app/repo-st.ts +47 -10
  183. package/src/serve/hub-app/runs-tabs.ts +1 -1
  184. package/src/serve/hub-app/sidebar.tsx +12 -37
  185. package/src/serve/hub-app/timeline-svc.ts +3 -2
  186. package/src/serve/hub-app/timeline-view.tsx +14 -11
  187. package/src/serve/hub-app/tree-build.ts +149 -155
  188. package/src/serve/hub-app/tree-row.tsx +39 -9
  189. package/src/serve/hub-app/tree-svc.ts +70 -49
  190. package/src/serve/hub-app/when-view.tsx +89 -0
  191. package/src/serve/hub-app/when.css +17 -0
  192. package/src/serve/hub-runtime.ts +3 -16
  193. package/src/serve/project-gutter.ts +1 -2
  194. package/src/serve/run-serve.ts +1 -1
  195. package/src/serve/serve-hub.ts +4 -14
  196. package/src/serve/warm-cell.ts +49 -16
  197. package/src/suite/judge-suites.ts +42 -17
  198. package/src/suite/suite-cli.ts +5 -8
  199. package/src/wiki/lint-md.ts +2 -0
  200. package/src/wiki/scan-md.ts +37 -10
  201. package/src/serve/hub-app/processes-svc.ts +0 -63
  202. package/src/serve/hub-app/repo-sections.tsx +0 -61
  203. package/src/serve/repo-processes.ts +0 -89
@@ -1,15 +1,20 @@
1
- // Report to the motherhome — upsert this repo's row into the docsBase
2
- // wiki's `build/shipkit/local-projects.md`, when that page ALREADY EXISTS
3
- // on this disk. The lib NEVER creates the page and never writes into a
4
- // relative or URL docsBase: the page is a tracked wiki file, so a real
5
- // docs checkout has it, and a consumer that merely set docsBase as a
6
- // fix-pointer prefix must never find surprise files in its docs tree.
7
- // The whole read-modify-write runs under the shared file lock
8
- // (core/locked-write) — concurrent reporters never lose rows or tear the
9
- // file; the loser skips and self-heals on its next run. Rows whose repo
10
- // path no longer exists are pruned on every write. Churn control: a row
11
- // rewrites only when its DATA changes or its last-seen day is older than
12
- // 7 days. Never fails the calling command.
1
+ // Report to the motherhome: a repo's row in the docsBase wiki's
2
+ // `build/shipkit/local-projects.md`, in two halves.
3
+ //
4
+ // - `reportToMotherhome` (every ci/check/init) files the row in the MACHINE
5
+ // DB and nothing else. A gate answers about its own repo, so it never
6
+ // writes, let alone commits, in the docs repo.
7
+ // - `landMotherhome` (the daemon's hourly sweep) renders every filed row into
8
+ // its page and lands the page as ONE commit, at most once per LAND_GAP_MS
9
+ // per page, only when a row's data changed or its last-seen day is older
10
+ // than STALE_DAYS.
11
+ //
12
+ // The lib NEVER creates the page and never reads a relative or URL docsBase:
13
+ // the page is a tracked wiki file, so a real docs checkout has it, and a
14
+ // consumer that merely set docsBase as a fix-pointer prefix must never find
15
+ // surprise files in its docs tree. The page's read-modify-write runs under
16
+ // the shared file lock (core/locked-write). Rows whose repo path no longer
17
+ // exists are pruned from the page and from the machine db.
13
18
  import { existsSync, readFileSync } from 'fs'
14
19
  import { homedir } from 'os'
15
20
  import { join, resolve } from 'path'
@@ -17,46 +22,31 @@ import type { RepoConfig } from '../repo-config.ts'
17
22
  import { atomicWrite, withFileLock } from './locked-write.ts'
18
23
  import { isLinkedWorktree } from './git-root.ts'
19
24
  import { machineCommit } from './machine-commit.ts'
25
+ import { machineDelete, machineGet, machineList, machineSet } from './machine-db.ts'
20
26
  import { LIB_VERSION } from './lib-version.ts'
21
27
 
22
28
  const BEGIN = '<!-- >>> shipkit local-projects — machine-written, edit outside the markers -->'
23
29
  const END = '<!-- <<< shipkit local-projects -->'
24
30
  const STALE_DAYS = 7
31
+ /** the longest the page waits for a changed row: one commit a day at most */
32
+ export const LAND_GAP_MS = 24 * 60 * 60 * 1000
33
+ /** a row filed in the machine db, by its repo's path */
34
+ const ROW = 'motherhome:'
35
+ /** when a page last landed, by its path */
36
+ const LANDED = 'motherhome-landed:'
25
37
 
26
38
  type Row = { name: string; root: string; version: string; mode: string; seen: string }
39
+ type Filed = Row & { page: string }
27
40
 
28
41
  const rowLine = (r: Row): string => `- ${r.name} — \`${r.root}\` — shipkit ${r.version} (${r.mode}) — last seen ${r.seen}`
29
42
 
30
- /**
31
- * Commit the row we just wrote, and ONLY that file.
32
- *
33
- * This page is machine written and rewrites whenever a repo's shipkit version
34
- * or consumption mode changes, so left alone it dirties the docs repo from a
35
- * gate run nobody associates with it, and the dirt then rides into an unrelated
36
- * commit or blocks a release. The row is not worth a human's attention in
37
- * either direction, so the machine that writes it also lands it.
38
- *
39
- * The scoping, the refusals and the escape hatch live in `core/machine-commit`,
40
- * which every machine written path commits through. This one fires only when
41
- * the row's DATA changed, not on every gate, so the hooks it runs are affordable.
42
- */
43
- function autoCommitRow(page: string, name: string): string {
44
- const landed = machineCommit({
45
- anchor: resolve(page, '..'),
46
- message: `chore(machine): refresh local-projects row (${name})`,
47
- paths: [page],
48
- })
49
- return landed ? ' + committed' : ''
50
- }
51
-
52
43
  const parseRow = (line: string): Row | null => {
53
44
  const m = line.match(/^- (.+?) — `(.+?)` — shipkit (\S+) \((.+?)\) — last seen (\d{4}-\d{2}-\d{2})$/)
54
45
  return m == null ? null : { name: m[1]!, root: m[2]!, version: m[3]!, mode: m[4]!, seen: m[5]! }
55
46
  }
56
47
 
57
-
58
- // how this repo consumes the lib — `?` on an unreadable package.json (a
59
- // read failure must never be rendered as the fact 'path')
48
+ // how this repo consumes the lib: `?` on an unreadable package.json (a read
49
+ // failure must never be rendered as the fact 'path')
60
50
  function consumptionMode(root: string): string {
61
51
  try {
62
52
  const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { dependencies?: Record<string, string>; devDependencies?: Record<string, string> }
@@ -70,47 +60,79 @@ function consumptionMode(root: string): string {
70
60
 
71
61
  export type MotherhomeOutcome = { wrote: boolean; note?: string }
72
62
 
63
+ /** file this repo's row in the machine db. `wrote` says the filed row changed */
73
64
  export function reportToMotherhome(root: string, config: RepoConfig, today: string = new Date().toISOString().slice(0, 10)): MotherhomeOutcome {
74
65
  try {
75
66
  if (config.docsBase == null) return { wrote: false }
76
67
  const base = config.docsBase.startsWith('~/') ? join(homedir(), config.docsBase.slice(2)) : config.docsBase
77
- // absolute local paths only — a relative docsBase would resolve
68
+ // absolute local paths only: a relative docsBase would resolve
78
69
  // against CWD, a URL is a display prefix
79
70
  if (!base.startsWith('/')) return { wrote: false }
80
71
  const page = join(base, 'build/shipkit/local-projects.md')
81
72
  if (!existsSync(page)) return { wrote: false }
82
- // a gate run inside a linked worktree must not write the MAIN
83
- // checkout's tracked page: the row it would add describes a temporary
84
- // side checkout, and the write lands as unexplained dirt in a tree
85
- // somebody else is working in. The worktree's own runs are covered
86
- // when its branch merges.
73
+ // a linked worktree is a temporary side checkout: its runs are
74
+ // covered when its branch merges
87
75
  if (isLinkedWorktree(root)) return { wrote: false, note: `motherhome: skipped (${config.name} is a linked worktree — its row belongs to the main checkout)` }
88
-
89
76
  const mode = consumptionMode(root)
90
77
  // source-consumed code is not the published release its version
91
- // string names — disclose it
78
+ // string names: disclose it
92
79
  const version = mode === 'npm' ? LIB_VERSION : `${LIB_VERSION}+src`
93
- const me: Row = { name: config.name, root: resolve(root), version, mode, seen: today }
94
-
95
- const r = withFileLock(page, (): MotherhomeOutcome => {
96
- const lines = readFileSync(page, 'utf8').split('\n')
97
- const b = lines.indexOf(BEGIN)
98
- const e = lines.indexOf(END)
99
- if (b === -1 || e === -1 || e < b) return { wrote: false, note: `WARN motherhome upsert skipped — markers missing/corrupted in ${page}` }
100
- const rows = lines.slice(b + 1, e).map(parseRow).filter((row): row is Row => row != null)
101
- const cur = rows.find((row) => row.root === me.root)
102
- const dataSame = cur != null && cur.name === me.name && cur.version === me.version && cur.mode === me.mode
103
- const fresh = cur != null && (Date.parse(today) - Date.parse(cur.seen)) / 86_400_000 < STALE_DAYS
104
- if (dataSame && fresh) return { wrote: false }
105
- // prune rows for repos that no longer exist at their recorded path
106
- const next = [...rows.filter((row) => row.root !== me.root && existsSync(row.root)), me].sort((a, z) => a.name.localeCompare(z.name))
107
- atomicWrite(page, [...lines.slice(0, b + 1), ...next.map(rowLine), ...lines.slice(e)].join('\n'))
108
- const committed = autoCommitRow(page, me.name)
109
- return { wrote: true, note: `motherhome: local-projects.md ${cur == null ? 'row added' : 'row refreshed'} (${me.name})${committed}` }
110
- })
111
- // lock lost to a racing reporter — self-heals on the next run
112
- return r.ok ? r.value : { wrote: false }
80
+ const me: Filed = { page, name: config.name, root: resolve(root), version, mode, seen: today }
81
+ const key = `${ROW}${me.root}`
82
+ if (JSON.stringify(machineGet<Filed>(key)) === JSON.stringify(me)) return { wrote: false }
83
+ return machineSet(key, me) ? { wrote: true } : { wrote: false, note: 'WARN motherhome row not filed: the machine db could not be written' }
113
84
  } catch (err) {
114
- return { wrote: false, note: `WARN motherhome upsert failed (${String(err).slice(0, 120)}) — registry may be stale` }
85
+ return { wrote: false, note: `WARN motherhome row not filed (${String(err).slice(0, 120)})` }
86
+ }
87
+ }
88
+
89
+ /** a page's rows once the filed ones are merged in: a filed row replaces the
90
+ * page's only when its data changed or the page's day is STALE_DAYS old,
91
+ * and a row whose repo path is gone is pruned */
92
+ function mergeRows(onPage: Row[], filed: Row[]): Row[] {
93
+ const by = new Map(onPage.map((r) => [r.root, r]))
94
+ for (const f of filed) {
95
+ const cur = by.get(f.root)
96
+ const same = cur != null && cur.name === f.name && cur.version === f.version && cur.mode === f.mode
97
+ const fresh = cur != null && (Date.parse(f.seen) - Date.parse(cur.seen)) / 86_400_000 < STALE_DAYS
98
+ if (!(same && fresh)) by.set(f.root, { name: f.name, root: f.root, version: f.version, mode: f.mode, seen: f.seen })
99
+ }
100
+ return [...by.values()].filter((r) => existsSync(r.root)).sort((a, z) => a.name.localeCompare(z.name) || a.root.localeCompare(z.root))
101
+ }
102
+
103
+ /** render every filed row into its page and land each changed page as one
104
+ * commit, at most once per LAND_GAP_MS. Returns what to print */
105
+ export function landMotherhome(o: { now?: number; gapMs?: number } = {}): string[] {
106
+ const now = o.now ?? Date.now()
107
+ const notes: string[] = []
108
+ const filed = machineList<Filed>(ROW)
109
+ for (const f of filed) if (!existsSync(f.value.root)) machineDelete(f.name)
110
+ const pages = new Map<string, Row[]>()
111
+ for (const f of filed) if (existsSync(f.value.root)) pages.set(f.value.page, [...(pages.get(f.value.page) ?? []), f.value])
112
+ for (const [page, rows] of pages) {
113
+ try {
114
+ if (!existsSync(page)) continue
115
+ const last = machineGet<number>(`${LANDED}${page}`)
116
+ if (last != null && now - last < (o.gapMs ?? LAND_GAP_MS)) continue
117
+ const r = withFileLock(page, (): boolean => {
118
+ const text = readFileSync(page, 'utf8')
119
+ const lines = text.split('\n')
120
+ const b = lines.indexOf(BEGIN)
121
+ const e = lines.indexOf(END)
122
+ if (b === -1 || e === -1 || e < b) { notes.push(`WARN motherhome: markers missing or out of order in ${page}, rows not written`); return false }
123
+ const onPage = lines.slice(b + 1, e).map(parseRow).filter((row): row is Row => row != null)
124
+ const next = [...lines.slice(0, b + 1), ...mergeRows(onPage, rows).map(rowLine), ...lines.slice(e)].join('\n')
125
+ if (next === text) return false
126
+ atomicWrite(page, next)
127
+ return true
128
+ })
129
+ if (!r.ok || !r.value) continue
130
+ machineSet(`${LANDED}${page}`, now)
131
+ const landed = machineCommit({ anchor: resolve(page, '..'), message: 'chore(machine): refresh local-projects rows', paths: [page] })
132
+ notes.push(`motherhome: ${page} rows refreshed${landed ? ' + committed' : ', not committed'}`)
133
+ } catch (err) {
134
+ notes.push(`WARN motherhome: ${page} not refreshed (${String(err).slice(0, 120)})`)
135
+ }
115
136
  }
137
+ return notes
116
138
  }
@@ -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
+ }
@@ -0,0 +1,295 @@
1
+ // THE PORT of a check file written before every context was explicit: its
2
+ // source in, the same check out with `runs`, `cache`, `fix` and `applies`
3
+ // spelled out, carrying the values the retired keys resolved to (the gate,
4
+ // the health pass and the planner of 0.62), so a ported check runs exactly
5
+ // where it ran before. Pure: text in, text out. SK016 reports what this finds
6
+ // and its repair applies it (core/port-check-cli.ts reaches the disk).
7
+ //
8
+ // the one constraint a reader breaks first: it edits the check object by the
9
+ // RANGE of each top level property, found on a masked copy of the source
10
+ // (strings, comments and regex literals blanked), so a value, a comment or a
11
+ // multi line lambda it does not touch comes out byte for byte.
12
+
13
+ /** the keys the explicit declaration retired */
14
+ export const RETIRED_KEYS = ['when', 'inputs', 'ttl', 'never', 'every', 'cron', 'whenInactive', 'planOff', 'cacheKey'] as const
15
+
16
+ /** the range a repo row nothing keys ran on, before it declared one */
17
+ const DAILY = "['20h', '28h']"
18
+
19
+ /** the source with every string, comment, template text and regex literal
20
+ * blanked to spaces, newlines kept, so offsets line up with the original */
21
+ export function maskSource(src: string): string {
22
+ const out = src.split('')
23
+ const blank = (from: number, to: number): void => { for (let k = from; k < to; k++) if (out[k] !== '\n') out[k] = ' ' }
24
+ let i = 0
25
+ // one entry per open template: the brace depth of its current `${`
26
+ const tpl: number[] = []
27
+ let last = ''
28
+ const regexOk = (): boolean => last === '' || '(,=:[!&|?{};+-*%<>~^'.includes(last)
29
+ const readTemplateText = (): void => {
30
+ // at a character inside template text: blank until `${` or the closing backtick
31
+ const start = i
32
+ while (i < src.length && src[i] !== '`' && !(src[i] === '$' && src[i + 1] === '{')) i += src[i] === '\\' ? 2 : 1
33
+ blank(start, i)
34
+ if (src[i] === '`') { i++; tpl.pop(); last = 'x' } else { i += 2; tpl[tpl.length - 1] = 0; last = '{' }
35
+ }
36
+ while (i < src.length) {
37
+ const ch = src[i]!
38
+ const next = src[i + 1]
39
+ if (ch === '/' && next === '/') { const s = i; while (i < src.length && src[i] !== '\n') i++; blank(s, i); continue }
40
+ if (ch === '/' && next === '*') { const s = i; const e = src.indexOf('*/', i + 2); i = e < 0 ? src.length : e + 2; blank(s, i); continue }
41
+ if (ch === "'" || ch === '"') {
42
+ const s = i
43
+ i++
44
+ while (i < src.length && src[i] !== ch && src[i] !== '\n') i += src[i] === '\\' ? 2 : 1
45
+ i++
46
+ blank(s + 1, i - 1)
47
+ last = 'x'
48
+ continue
49
+ }
50
+ if (ch === '`') { tpl.push(-1); i++; readTemplateText(); continue }
51
+ if (tpl.length > 0 && tpl[tpl.length - 1]! >= 0) {
52
+ if (ch === '{') tpl[tpl.length - 1]!++
53
+ if (ch === '}') {
54
+ if (tpl[tpl.length - 1] === 0) { tpl[tpl.length - 1] = -1; i++; readTemplateText(); continue }
55
+ tpl[tpl.length - 1]!--
56
+ }
57
+ }
58
+ if (ch === '/' && regexOk()) {
59
+ const s = i
60
+ let j = i + 1
61
+ let inClass = false
62
+ while (j < src.length && src[j] !== '\n') {
63
+ if (src[j] === '\\') { j += 2; continue }
64
+ if (src[j] === '[') inClass = true
65
+ else if (src[j] === ']') inClass = false
66
+ else if (src[j] === '/' && !inClass) break
67
+ j++
68
+ }
69
+ if (src[j] === '/') { j++; while (/[a-z]/.test(src[j] ?? '')) j++; blank(s + 1, j - 1); i = j; last = 'x'; continue }
70
+ }
71
+ if (!/\s/.test(ch)) last = ch
72
+ i++
73
+ }
74
+ return out.join('')
75
+ }
76
+
77
+ /** one top level property of the check object: its whole segment (leading
78
+ * whitespace and comments included), and where its key and value sit */
79
+ export type Prop = { key: string | null; seg: [number, number]; keyAt: number; value: string }
80
+
81
+ export type CheckObject = { open: number; close: number; props: Prop[]; indent: string }
82
+
83
+ const KEY_RE = /^\s*(?:(?:async|get|set)\s+)?([A-Za-z_$][\w$]*)\s*\??\s*[:(]/
84
+
85
+ /** the check object of a file: `check({...})`, else `export default {...}`
86
+ * in a check file. Null when there is none */
87
+ export function findCheckObject(src: string, o: { plainDefault: boolean }): CheckObject | null {
88
+ const m = maskSource(src)
89
+ const call = /\bcheck\s*\(\s*\{/.exec(m)
90
+ const plain = o.plainDefault ? /\bexport\s+default\s*\{/.exec(m) : null
91
+ const hit = call ?? plain
92
+ if (hit == null) return null
93
+ const open = hit.index + hit[0].length - 1
94
+ let depth = 0
95
+ let start = open + 1
96
+ const segs: [number, number][] = []
97
+ let close = -1
98
+ for (let k = open; k < m.length; k++) {
99
+ const ch = m[k]
100
+ if (ch === '{' || ch === '(' || ch === '[') depth++
101
+ else if (ch === '}' || ch === ')' || ch === ']') {
102
+ depth--
103
+ if (depth === 0) { segs.push([start, k]); close = k; break }
104
+ } else if (ch === ',' && depth === 1) { segs.push([start, k]); start = k + 1 }
105
+ }
106
+ if (close < 0) return null
107
+ const props: Prop[] = segs.map(([a, b]) => {
108
+ const km = KEY_RE.exec(m.slice(a, b))
109
+ if (km == null) return { key: null, seg: [a, b], keyAt: -1, value: '' }
110
+ const keyAt = a + km.index + km[0].indexOf(km[1]!)
111
+ const colon = m.indexOf(':', keyAt)
112
+ const isColon = colon >= 0 && colon < b && m.slice(keyAt + km[1]!.length, colon).trim().replace('?', '') === ''
113
+ return { key: km[1]!, seg: [a, b], keyAt, value: isColon ? src.slice(colon + 1, b).trim() : '' }
114
+ })
115
+ const first = props.find((p) => p.key != null)
116
+ const indent = first == null ? ' ' : (/[ \t]*$/.exec(src.slice(0, first.keyAt))?.[0] ?? ' ')
117
+ return { open, close, props, indent }
118
+ }
119
+
120
+ /** what a file's check object lacks or still says in the retired shape: each
121
+ * retired key it declares, `fix` when it is a function, and `runs` when the
122
+ * object declares none (a file that declared nothing ran on defaults) */
123
+ export function retiredKeysIn(src: string, o: { plainDefault: boolean }): string[] {
124
+ const obj = findCheckObject(src, o)
125
+ if (obj == null) return []
126
+ const keys = new Set(obj.props.map((p) => p.key))
127
+ const out: string[] = RETIRED_KEYS.filter((k) => keys.has(k))
128
+ const fix = obj.props.find((p) => p.key === 'fix')
129
+ if (fix != null && !/^(['"])(none|ci|manual)\1$/.test(fix.value)) out.push('fix')
130
+ if (!keys.has('runs') && (keys.has('run') || keys.has('cmd'))) out.push('runs')
131
+ return out
132
+ }
133
+
134
+ /** a string literal's value, or null for any other expression */
135
+ export const literal = (v: string | undefined): string | null => {
136
+ const m = v == null ? null : /^(['"`])([^'"`$\\]*)\1$/.exec(v.trim())
137
+ return m == null ? null : m[2]!
138
+ }
139
+
140
+ /** what a check declared before, as the port reads it */
141
+ export type LegacyDecl = {
142
+ /** the `when` literal, null when absent, undefined when it is not a literal */
143
+ when: string | null | undefined
144
+ build: boolean
145
+ body: 'run' | 'cmd'
146
+ /** the expression text of each declaration, absent when not declared */
147
+ inputs?: string
148
+ ttl?: string
149
+ never?: string
150
+ cacheKey?: string
151
+ every?: string
152
+ cron?: string
153
+ whenInactive?: string
154
+ planOff?: string
155
+ /** a repair function is declared */
156
+ repair: boolean
157
+ /** why the body's imports reach the disk around `c`, for an undeclared
158
+ * `run` body (core/raw-access.ts), else null */
159
+ rawAccess: string | null
160
+ }
161
+
162
+ export type Derived = { runs: string; cache: string; fix: string; applies: string; notes: string[] }
163
+
164
+ /** the explicit declaration a legacy one resolved to, or why it cannot be
165
+ * carried across without a person */
166
+ export function deriveExplicit(d: LegacyDecl, q: (s: string) => string): Derived | { error: string } {
167
+ const notes: string[] = []
168
+ if (d.when === undefined) return { error: '`when` is not a string literal' }
169
+ const whenLit = d.when
170
+ const off = whenLit != null && (whenLit === 'off' || whenLit.startsWith('off:')) ? whenLit.slice(4).trim() || 'off' : null
171
+ const at = off != null ? 'always' : (whenLit ?? 'always')
172
+ if (!['always', 'release', 'health'].includes(at)) return { error: `when '${whenLit}' is not always, release, health or off:<why>` }
173
+ const ttlLit = d.ttl == null ? null : literal(d.ttl)
174
+ const forever = ttlLit === 'forever'
175
+ // the population the gate read it as
176
+ const keyed = d.inputs != null || (d.ttl == null && d.never == null && d.body === 'run' && d.rawAccess == null)
177
+ const world = d.ttl != null
178
+ // the gate axes: a build step never ran in the health pass
179
+ const gates = at === 'always' ? [true, true, true] : at === 'release' ? [false, true, true] : [false, false, false]
180
+ const onHealth = !d.build && true
181
+ // the planner: only an always or health row, and never a build step off the gate
182
+ const plannable = (at === 'always' || at === 'health') && !(d.build && at === 'health')
183
+ const gate = at !== 'health'
184
+ const declaredClock = d.cron != null || d.every !== undefined
185
+ const fallback = forever ? "'once'" : d.ttl != null ? d.ttl : d.build ? null : keyed ? null : DAILY
186
+ const clocked = fallback != null || declaredClock
187
+ const onChange = forever || (!world && (keyed || (gate && !clocked)))
188
+ let onSave = 'false'
189
+ let onClock = 'false'
190
+ if (plannable) {
191
+ onSave = onChange ? (keyed || forever ? "'moved'" : "'burst'") : 'false'
192
+ const every = d.cron ?? (d.every !== undefined ? d.every : fallback)
193
+ if (every != null && every.trim() !== 'null') {
194
+ if (literal(every) === 'once') onClock = "'once'"
195
+ else onClock = `{ every: ${every}, inactive: ${d.whenInactive ?? q('wait')} }`
196
+ }
197
+ }
198
+ if (onClock === "'once'" && !forever) return { error: "every 'once' without ttl 'forever': a fact asked once needs a declared value" }
199
+ if (forever && onClock !== "'once'" && plannable) return { error: "ttl 'forever' with a declared every: a fact is asked once, never on a period" }
200
+ let cache: string
201
+ if (d.inputs != null) cache = `{ by: ${q('files')}, inputs: ${d.inputs}, key: ${d.cacheKey ?? 'null'} }`
202
+ else if (forever) cache = `{ by: ${q('fact')}, key: ${d.cacheKey ?? `() => ${q('declared')}`} }`
203
+ else if (d.ttl != null) cache = `{ by: ${q('ttl')}, span: ${d.ttl}, key: ${d.cacheKey ?? 'null'} }`
204
+ else if (d.never != null) cache = `{ by: ${q('never')}, why: ${d.never} }`
205
+ else if (d.body === 'run' && d.rawAccess == null) cache = `{ by: ${q('reads')} }`
206
+ else if (d.body === 'run') cache = `{ by: ${q('never')}, why: ${q(d.rawAccess!)} }`
207
+ else cache = `{ by: ${q('never')}, why: ${q('a cmd declaring no inputs: nothing keys it')} }`
208
+ if (forever && !plannable) return { error: "ttl 'forever' on a check the planner never ran: a fact nobody asks once is never answered" }
209
+ // the ci fix pass applied every repair; a check off the quick gate now
210
+ // repairs only by name
211
+ const fix = !d.repair ? 'none' : gates[0] ? 'ci' : 'manual'
212
+ if (d.repair && !gates[0]) notes.push("its repair ran in the ci fix pass although the gate did not run it: now fix 'manual', shipkit check --fix <id> applies it")
213
+ if (d.build && d.repair) return { error: 'a build step with a repair: a tool repairs its own findings, drop one of them' }
214
+ const runs = `{ onPreCommit: false, onCi: ${gates[0]}, onFull: ${gates[1]}, onRelease: ${gates[2]}, onHealth: ${d.build ? false : onHealth}, onSave: ${onSave}, onClock: ${onClock} }`
215
+ let applies = d.planOff ?? q('always')
216
+ if (d.planOff != null) notes.push('its planOff only kept it off the planner; as applies it is n/a in every context while it gives a reason')
217
+ if (off != null) {
218
+ if (d.planOff != null) return { error: '`when` off and a planOff: say one reason' }
219
+ applies = `() => ${q(off)}`
220
+ notes.push('it was off in its own file: applies now gives the reason, so it runs nowhere')
221
+ }
222
+ return { runs, cache, fix, applies, notes }
223
+ }
224
+
225
+ export type PortResult =
226
+ | { changed: false; reason: string }
227
+ | { changed: true; source: string; notes: string[] }
228
+ | { changed: false; error: string }
229
+
230
+ /** the file ported, or why not. `rawAccess` is what the caller measured on
231
+ * the file's import closure, needed only for an undeclared `run` body */
232
+ export function portCheckSource(src: string, o: { plainDefault: boolean; rawAccess: string | null }): PortResult {
233
+ const obj = findCheckObject(src, o)
234
+ if (obj == null) return { changed: false, reason: 'no check object' }
235
+ const retired = retiredKeysIn(src, o)
236
+ if (retired.length === 0) return { changed: false, reason: 'already explicit' }
237
+ const byKey = new Map(obj.props.filter((p) => p.key != null).map((p) => [p.key!, p]))
238
+ for (const k of ['runs', 'cache', 'applies']) if (byKey.has(k)) return { changed: false, error: `it declares ${k} beside a retired shape: finish it by hand` }
239
+ const dq = /^\s*import[^\n]*"/m.test(src) && !/^\s*import[^\n]*'/m.test(src)
240
+ const q = (s: string): string => (dq ? `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"` : `'${s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`)
241
+ const val = (k: string): string | undefined => byKey.get(k)?.value
242
+ const build = val('build')?.trim() === 'true'
243
+ const body = byKey.has('cmd') ? 'cmd' : 'run'
244
+ const whenV = val('when')
245
+ const derived = deriveExplicit({
246
+ when: whenV == null ? null : (literal(whenV) ?? undefined),
247
+ build,
248
+ body,
249
+ inputs: val('inputs'),
250
+ ttl: val('ttl'),
251
+ never: val('never'),
252
+ cacheKey: val('cacheKey'),
253
+ every: val('every'),
254
+ cron: val('cron'),
255
+ whenInactive: val('whenInactive'),
256
+ planOff: val('planOff'),
257
+ repair: retired.includes('fix'),
258
+ rawAccess: o.rawAccess,
259
+ }, q)
260
+ if ('error' in derived) return { changed: false, error: derived.error }
261
+ // rebuild: drop the retired props, keep their comments above the new
262
+ // keys, rename a fix function to repair, insert before the body
263
+ const ind = obj.indent
264
+ const comments: string[] = []
265
+ const kept: string[] = []
266
+ let insertAt = -1
267
+ for (const p of obj.props) {
268
+ const text = src.slice(p.seg[0], p.seg[1])
269
+ if (p.key != null && (RETIRED_KEYS as readonly string[]).includes(p.key)) {
270
+ for (const l of src.slice(p.seg[0], p.keyAt).split('\n')) if (l.trim().startsWith('//')) comments.push(`${ind}${l.trim()}`)
271
+ continue
272
+ }
273
+ if (insertAt < 0 && (p.key === 'run' || p.key === 'cmd' || (p.key === 'fix' && retired.includes('fix')))) insertAt = kept.length
274
+ if (p.key === 'fix' && retired.includes('fix')) {
275
+ const rel = p.keyAt - p.seg[0]
276
+ kept.push(`${text.slice(0, rel)}repair${text.slice(rel + 3)}`)
277
+ continue
278
+ }
279
+ kept.push(text)
280
+ }
281
+ // a trailing comma leaves an empty last segment: keep it last
282
+ const tail = kept.length > 0 && kept[kept.length - 1]!.trim() === '' ? kept.pop()! : null
283
+ if (insertAt < 0 || insertAt > kept.length) insertAt = kept.length
284
+ // each new property is one segment shaped like the others: a newline,
285
+ // the indent, the key. The comments of the keys it replaced lead the first
286
+ const newProps = [
287
+ `\n${comments.map((c) => `${c}\n`).join('')}${ind}runs: ${derived.runs}`,
288
+ `\n${ind}cache: ${derived.cache}`,
289
+ `\n${ind}fix: ${q(derived.fix)}`,
290
+ `\n${ind}applies: ${derived.applies}`,
291
+ ]
292
+ const props = [...kept.slice(0, insertAt), ...newProps, ...kept.slice(insertAt), ...(tail == null ? [] : [tail])]
293
+ const source = `${src.slice(0, obj.open + 1)}${props.join(',')}${src.slice(obj.close)}`
294
+ return { changed: true, source, notes: derived.notes }
295
+ }
@@ -0,0 +1,31 @@
1
+ // which check files of a repo still need the port (core/port-check-file.ts):
2
+ // read through the context alone, so SK029 is cached on the listing and the
3
+ // sources. A legacy check SCRIPT is left to its own static reader.
4
+ import { CHECK_SUFFIX, isLegacyCheckSource, SKIPPED_DIRS, UNIT_SUFFIX } from './check-file-names.ts'
5
+ import type { ScanCtx } from './check-ctx.ts'
6
+ import { retiredKeysIn } from './port-check-file.ts'
7
+
8
+ /** a `.check.ts` or `.check.shipkit.ts` file may export a plain object; a
9
+ * plain unit file is a check only through a `check(...)` call */
10
+ export const plainDefaultFor = (rel: string): boolean => rel.endsWith(CHECK_SUFFIX) || rel.endsWith(`.check${UNIT_SUFFIX}`)
11
+
12
+ const walked = (rel: string): boolean => rel.split('/').slice(0, -1).every((d) => !SKIPPED_DIRS.has(d) && !d.startsWith('.'))
13
+
14
+ /** the cheap text test before the scan: a file that declares `runs` and none
15
+ * of the retired spellings is explicit already */
16
+ const RETIRED_RE = /\b(when|inputs|ttl|never|every|cron|whenInactive|planOff|cacheKey)\s*\??\s*:|\bfix\s*\??\s*:\s*(async\b|\(|function\b|[A-Za-z_$][\w$]*\s*[,\n])/
17
+
18
+ export type Unported = { rel: string; keys: string[] }
19
+
20
+ export function unportedCheckFiles(c: Pick<ScanCtx, 'list' | 'read'>): Unported[] {
21
+ const rels = [...c.list(`**/*${CHECK_SUFFIX}`), ...c.list(`**/*${UNIT_SUFFIX}`)].filter(walked)
22
+ const out: Unported[] = []
23
+ for (const rel of [...new Set(rels)].sort()) {
24
+ const src = c.read(rel)
25
+ if (src == null || isLegacyCheckSource(src)) continue
26
+ if (/\bruns\s*:/.test(src) && !RETIRED_RE.test(src)) continue
27
+ const keys = retiredKeysIn(src, { plainDefault: plainDefaultFor(rel) })
28
+ if (keys.length > 0) out.push({ rel, keys })
29
+ }
30
+ return out
31
+ }