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.
- package/CHANGELOG.md +105 -0
- package/README.md +17 -17
- package/package.json +3 -2
- package/src/asks/asks-queue.ts +4 -2
- package/src/assets/kits/identity-kit.ts +15 -13
- package/src/assets/run-assets.ts +12 -2
- package/src/check.ts +73 -98
- package/src/checks/SK001-package-json-shape.check.ts +26 -4
- package/src/checks/SK002-contracts-fulfillness.check.ts +4 -0
- package/src/checks/SK003-claude-md-shape.check.ts +5 -1
- package/src/checks/SK004-gitignore-policy.check.ts +7 -4
- package/src/checks/SK005-rvlib-versions.check.ts +53 -21
- package/src/checks/SK006-npm-publish-safety.check.ts +4 -0
- package/src/checks/SK007-github-visibility.check.ts +4 -2
- package/src/checks/SK008-reviewer-agent.check.ts +6 -4
- package/src/checks/SK009-commit-ai-tells.check.ts +94 -74
- package/src/checks/SK010-undeclared-unit.check.ts +4 -0
- package/src/checks/SK011-sibling-unit-ranges.check.ts +4 -0
- package/src/checks/SK012-config-barrel-import.check.ts +5 -1
- package/src/checks/SK013-one-way-in-policy.check.ts +7 -3
- package/src/checks/SK014-prose-ste.check.ts +20 -6
- package/src/checks/SK015-nested-repo.check.ts +4 -2
- package/src/checks/SK016-legacy-check-shapes.check.ts +23 -16
- package/src/checks/SK017-vscode-hub-task.check.ts +22 -7
- package/src/checks/SK018-test-files-run.check.ts +6 -2
- package/src/checks/SK019-tests-run-in-units.check.ts +8 -4
- package/src/checks/SK020-self-spawning-check.check.ts +4 -0
- package/src/checks/SK022-machine-runs-the-pin.check.ts +6 -4
- package/src/checks/SK024-identity-colors.check.ts +5 -3
- package/src/checks/SK025-release-drift.check.ts +4 -5
- package/src/checks/SK026-asks-autoclose.check.ts +4 -4
- package/src/checks/SK027-agent-wiki-links.check.ts +34 -9
- package/src/checks/SK028-readme.check.ts +10 -2
- package/src/checks/SK029-explicit-check-files.check.ts +95 -0
- package/src/cli/cmd_Asks.ts +5 -5
- package/src/cli/cmd_Assets.ts +7 -7
- package/src/cli/cmd_Bump.ts +12 -11
- package/src/cli/cmd_Check.ts +8 -7
- package/src/cli/cmd_Checks.ts +7 -6
- package/src/cli/cmd_Ci.ts +12 -11
- package/src/cli/cmd_Commands.ts +16 -13
- package/src/cli/cmd_ConfigKeys.ts +7 -6
- package/src/cli/cmd_Contracts.ts +3 -3
- package/src/cli/cmd_CrossRepoActivity.ts +10 -6
- package/src/cli/cmd_Fleet.ts +9 -7
- package/src/cli/cmd_GlobalProcesses.ts +116 -4
- package/src/cli/cmd_Home.ts +23 -17
- package/src/cli/cmd_Hooks.ts +6 -5
- package/src/cli/cmd_Init.ts +5 -4
- package/src/cli/cmd_Keys.ts +8 -7
- package/src/cli/cmd_Mv.ts +7 -6
- package/src/cli/cmd_Needs.ts +4 -3
- package/src/cli/cmd_Overview.ts +4 -3
- package/src/cli/cmd_Pb.ts +2 -2
- package/src/cli/cmd_Planned.ts +6 -5
- package/src/cli/cmd_Prose.ts +5 -5
- package/src/cli/cmd_Reflect.ts +7 -7
- package/src/cli/cmd_Release.ts +6 -5
- package/src/cli/cmd_Releases.ts +5 -4
- package/src/cli/cmd_Serve.ts +4 -3
- package/src/cli/cmd_Smoke.ts +5 -4
- package/src/cli/cmd_Start.ts +7 -6
- package/src/cli/cmd_Status.ts +4 -3
- package/src/cli/cmd_Tui.ts +3 -2
- package/src/cli/cmd_When.ts +71 -0
- package/src/cli/cmd_Wiki.ts +4 -4
- package/src/cli.ts +7 -2
- package/src/collections/checks.ts +2 -0
- package/src/collections/collection-types.ts +24 -4
- package/src/collections/providers.ts +6 -2
- package/src/contracts/apply-contracts.ts +9 -8
- package/src/contracts/contract-types.ts +8 -2
- package/src/contracts/contracts-engine.ts +34 -13
- package/src/contracts/manifests/identity.contract.ts +6 -5
- package/src/contracts/manifests/social.contract.ts +4 -2
- package/src/contracts/manifests/steam.contract.ts +7 -3
- package/src/contracts/web-surface.ts +59 -0
- package/src/core/budget.ts +19 -9
- package/src/core/canonical-scripts.ts +1 -1
- package/src/core/check-runner.ts +63 -25
- package/src/core/config-rewrite.ts +37 -24
- package/src/core/discover-checks.ts +31 -7
- package/src/core/layout.ts +2 -0
- package/src/core/legacy-shapes.ts +39 -10
- package/src/core/motherhome.ts +88 -66
- package/src/core/one-way-in-policy.ts +2 -2
- package/src/core/outcome-words.ts +18 -2
- package/src/core/port-check-cli.ts +52 -0
- package/src/core/port-check-file.ts +295 -0
- package/src/core/port-check-files.ts +31 -0
- package/src/core/retention.ts +0 -14
- package/src/core/run-check.ts +14 -14
- package/src/core/run-ci.ts +11 -7
- package/src/core/run-fix.ts +30 -23
- package/src/core/run-fixers.ts +42 -5
- package/src/core/runs-matrix.ts +179 -0
- package/src/core/runs.ts +242 -0
- package/src/core/update-policy.ts +20 -0
- package/src/core/when-block.ts +29 -30
- package/src/core/when.ts +41 -103
- package/src/core/wire.ts +34 -9
- package/src/fleet/scan-fleet.ts +20 -4
- package/src/fleet/update-fleet.ts +6 -6
- package/src/home/activity-shape.ts +4 -1
- package/src/home/activity.ts +22 -15
- package/src/home/gui-app/ShipkitGui.swift +144 -25
- package/src/home/gui-app.ts +93 -30
- package/src/home/home-launchd.ts +30 -6
- package/src/home/home-routes.ts +14 -2
- package/src/home/home-start.ts +33 -5
- package/src/home/hubs.ts +5 -5
- package/src/home/machine-stores.ts +4 -2
- package/src/home/open-app.ts +23 -10
- package/src/home/pm-config.ts +15 -11
- package/src/home/pm-inputs.ts +76 -0
- package/src/home/pm.ts +11 -3
- package/src/home/process-rows.ts +23 -6
- package/src/home/resources.ts +67 -15
- package/src/home/run-daemon.ts +6 -1
- package/src/home/run-pm.ts +30 -5
- package/src/home/serve-web.ts +2 -2
- package/src/home/supervise.ts +24 -1
- package/src/home/widgets.ts +32 -3
- package/src/hooks/commit-message.ts +2 -3
- package/src/hooks/run-hooks.ts +60 -6
- package/src/init-repo.ts +6 -6
- package/src/ledger/gate-checks.ts +115 -61
- package/src/ledger/git-key.ts +5 -1
- package/src/ledger/ledger.ts +5 -5
- package/src/ledger/store.ts +11 -0
- package/src/ledger/toolchain-identity.ts +45 -27
- package/src/ledger/world-cache.ts +17 -15
- package/src/plan/repo-source.ts +18 -39
- package/src/release/run-release.ts +5 -0
- package/src/repo-config.ts +36 -56
- package/src/rules/rule.ts +2 -2
- package/src/scaffold/files/agent-coding.md +1 -1
- package/src/scaffold/files/agent-index.md +1 -1
- package/src/scaffold/files/verify-skill.md +2 -2
- package/src/serve/hub-app/activity-st.ts +5 -4
- package/src/serve/hub-app/activity-view.tsx +4 -4
- package/src/serve/hub-app/admin-view.tsx +13 -11
- package/src/serve/hub-app/check-view.tsx +66 -46
- package/src/serve/hub-app/check-words.ts +1 -11
- package/src/serve/hub-app/checks-svc.ts +16 -36
- package/src/serve/hub-app/col-tables-view.tsx +131 -0
- package/src/serve/hub-app/col-tables.ts +115 -0
- package/src/serve/hub-app/cols-svc.ts +33 -4
- package/src/serve/hub-app/dock-st.ts +3 -2
- package/src/serve/hub-app/dock-view.tsx +36 -0
- package/src/serve/hub-app/dock.css +7 -1
- package/src/serve/hub-app/float-cards.tsx +30 -0
- package/src/serve/hub-app/floating.tsx +41 -4
- package/src/serve/hub-app/git-st.ts +42 -0
- package/src/serve/hub-app/git-view.tsx +61 -0
- package/src/serve/hub-app/hash-route.ts +45 -1
- package/src/serve/hub-app/header-bar.tsx +62 -72
- package/src/serve/hub-app/health-svc.ts +3 -2
- package/src/serve/hub-app/home-st.ts +15 -3
- package/src/serve/hub-app/home-strip.css +69 -19
- package/src/serve/hub-app/home-strip.tsx +192 -118
- package/src/serve/hub-app/home.html +1 -0
- package/src/serve/hub-app/hub-app.tsx +21 -5
- package/src/serve/hub-app/hub-types.ts +11 -5
- package/src/serve/hub-app/hub.css +88 -44
- package/src/serve/hub-app/icons.tsx +15 -0
- package/src/serve/hub-app/index.html +1 -0
- package/src/serve/hub-app/keys.ts +11 -8
- package/src/serve/hub-app/live-svc.ts +3 -2
- package/src/serve/hub-app/machine-st.ts +34 -49
- package/src/serve/hub-app/palette-st.ts +5 -5
- package/src/serve/hub-app/palette-view.tsx +1 -1
- package/src/serve/hub-app/plan-view.tsx +51 -0
- package/src/serve/hub-app/planned-st.ts +9 -4
- package/src/serve/hub-app/processes-view.tsx +38 -0
- package/src/serve/hub-app/project-list.tsx +3 -3
- package/src/serve/hub-app/projects-svc.ts +15 -10
- package/src/serve/hub-app/prose-hits-st.ts +3 -2
- package/src/serve/hub-app/reach.ts +36 -0
- package/src/serve/hub-app/readme-svc.ts +3 -2
- package/src/serve/hub-app/repo-sections.ts +117 -0
- package/src/serve/hub-app/repo-st.ts +47 -10
- package/src/serve/hub-app/runs-tabs.ts +1 -1
- package/src/serve/hub-app/sidebar.tsx +12 -37
- package/src/serve/hub-app/timeline-svc.ts +3 -2
- package/src/serve/hub-app/timeline-view.tsx +14 -11
- package/src/serve/hub-app/tree-build.ts +149 -155
- package/src/serve/hub-app/tree-row.tsx +39 -9
- package/src/serve/hub-app/tree-svc.ts +70 -49
- package/src/serve/hub-app/when-view.tsx +89 -0
- package/src/serve/hub-app/when.css +17 -0
- package/src/serve/hub-runtime.ts +3 -16
- package/src/serve/project-gutter.ts +1 -2
- package/src/serve/run-serve.ts +1 -1
- package/src/serve/serve-hub.ts +4 -14
- package/src/serve/warm-cell.ts +49 -16
- package/src/suite/judge-suites.ts +42 -17
- package/src/suite/suite-cli.ts +5 -8
- package/src/wiki/lint-md.ts +2 -0
- package/src/wiki/scan-md.ts +37 -10
- package/src/serve/hub-app/processes-svc.ts +0 -63
- package/src/serve/hub-app/repo-sections.tsx +0 -61
- package/src/serve/repo-processes.ts +0 -89
package/src/core/motherhome.ts
CHANGED
|
@@ -1,15 +1,20 @@
|
|
|
1
|
-
// Report to the motherhome
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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
|
|
83
|
-
//
|
|
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
|
|
78
|
+
// string names: disclose it
|
|
92
79
|
const version = mode === 'npm' ? LIB_VERSION : `${LIB_VERSION}+src`
|
|
93
|
-
const me:
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
42
|
-
export
|
|
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
|
+
}
|