rvlib-shipkit 0.38.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +371 -16
- package/README.md +26 -14
- package/package.json +2 -2
- package/src/assets/gen/comfyts-gen.ts +27 -10
- package/src/assets/gen/generate-roll.ts +1 -1
- package/src/assets/kit-config.ts +6 -6
- package/src/assets/run-assets.ts +14 -10
- package/src/assets/server/studio-server.ts +6 -5
- package/src/check.ts +343 -0
- package/src/{rules/SK001-package-json-shape.ts → checks/SK001-package-json-shape.check.ts} +44 -46
- package/src/checks/SK002-contracts-fulfillness.check.ts +24 -0
- package/src/checks/SK003-claude-md-shape.check.ts +66 -0
- package/src/checks/SK004-gitignore-policy.check.ts +309 -0
- package/src/{rules/SK005-rvlib-versions.ts → checks/SK005-rvlib-versions.check.ts} +46 -32
- package/src/{rules/SK006-npm-publish-safety.ts → checks/SK006-npm-publish-safety.check.ts} +25 -24
- package/src/{rules/SK007-github-visibility.ts → checks/SK007-github-visibility.check.ts} +20 -14
- package/src/{rules/SK008-reviewer-agent.ts → checks/SK008-reviewer-agent.check.ts} +29 -14
- package/src/{rules/SK009-commit-ai-tells.ts → checks/SK009-commit-ai-tells.check.ts} +24 -21
- package/src/checks/SK010-undeclared-unit.check.ts +111 -0
- package/src/{rules/SK011-sibling-unit-ranges.ts → checks/SK011-sibling-unit-ranges.check.ts} +21 -20
- package/src/{rules/SK012-config-barrel-import.ts → checks/SK012-config-barrel-import.check.ts} +18 -23
- package/src/{rules/SK013-one-way-in-policy.ts → checks/SK013-one-way-in-policy.check.ts} +58 -50
- package/src/{rules/SK014-prose-ste.ts → checks/SK014-prose-ste.check.ts} +27 -13
- package/src/{rules/SK015-nested-repo.ts → checks/SK015-nested-repo.check.ts} +15 -12
- package/src/checks/SK016-legacy-check-shapes.check.ts +182 -0
- package/src/checks/SK017-vscode-hub-task.check.ts +197 -0
- package/src/checks/SK018-test-files-run.check.ts +28 -0
- package/src/checks/SK019-tests-run-in-units.check.ts +76 -0
- package/src/checks/SK020-self-spawning-check.check.ts +147 -0
- package/src/checks/contracts.check.ts +20 -0
- package/src/cli/cmd_Asks.ts +3 -2
- package/src/cli/cmd_Assets.ts +2 -2
- package/src/cli/cmd_Bump.ts +1 -1
- package/src/cli/cmd_Check.ts +6 -6
- package/src/cli/cmd_Checks.ts +95 -0
- package/src/cli/cmd_Ci.ts +48 -13
- package/src/cli/cmd_Commands.ts +2 -6
- package/src/cli/cmd_ConfigKeys.ts +69 -0
- package/src/cli/cmd_Contracts.ts +1 -1
- package/src/cli/cmd_Fleet.ts +6 -2
- package/src/cli/cmd_Hooks.ts +1 -1
- package/src/cli/cmd_Keys.ts +43 -18
- package/src/cli/cmd_Mv.ts +2 -2
- package/src/cli/cmd_Prose.ts +10 -1
- package/src/cli/cmd_Releases.ts +2 -2
- package/src/cli/cmd_Serve.ts +1 -1
- package/src/cli/cmd_Start.ts +6 -6
- package/src/cli/cmd_Status.ts +25 -0
- package/src/cli/cmd_Tui.ts +2 -2
- package/src/cli.ts +7 -4
- package/src/collections/actions.ts +4 -4
- package/src/collections/checks.ts +258 -0
- package/src/collections/collection-types.ts +254 -1
- package/src/collections/comfy.ts +23 -0
- package/src/collections/md-body.ts +14 -0
- package/src/collections/pings.ts +38 -12
- package/src/collections/posts.ts +19 -3
- package/src/collections/providers.ts +198 -91
- package/src/collections/reflections.ts +41 -0
- package/src/collections/state-rules.ts +73 -0
- package/src/collections/test-time.ts +288 -0
- package/src/collections/tests.ts +181 -0
- package/src/collections/wikis.ts +66 -0
- package/src/comfy/comfy-panel.ts +292 -0
- package/src/contracts/apply-contracts.ts +17 -12
- package/src/contracts/contract-types.ts +16 -4
- package/src/contracts/contracts-engine.ts +78 -42
- package/src/contracts/manifests/discord.contract.ts +6 -6
- package/src/contracts/manifests/identity.contract.ts +3 -3
- package/src/contracts/manifests/itch.contract.ts +12 -15
- package/src/contracts/manifests/social.contract.ts +5 -5
- package/src/contracts/manifests/steam.contract.ts +16 -16
- package/src/contracts/run-contracts.ts +4 -3
- package/src/core/attach.ts +96 -0
- package/src/core/canonical-scripts.ts +25 -0
- package/src/core/check-ctx.ts +204 -14
- package/src/core/check-file-names.ts +112 -0
- package/src/core/check-pass.ts +84 -0
- package/src/core/check-runner.ts +796 -0
- package/src/core/config-keys.ts +256 -0
- package/src/core/config-rewrite.ts +77 -0
- package/src/core/discover-checks.ts +166 -90
- package/src/core/discover-legacy-checks.ts +26 -0
- package/src/core/doc-block.ts +13 -0
- package/src/core/floor-resolve.ts +264 -0
- package/src/core/floor.ts +20 -137
- package/src/core/git-bin.ts +24 -0
- package/src/core/git-root.ts +108 -0
- package/src/core/instance.ts +195 -0
- package/src/core/jobs.ts +118 -0
- package/src/core/layout.ts +52 -0
- package/src/core/legacy-shapes.ts +207 -0
- package/src/core/lib-root.ts +5 -0
- package/src/core/locked-write.ts +110 -39
- package/src/core/machine-commit.ts +5 -4
- package/src/core/migrate-layout.ts +557 -0
- package/src/core/motherhome.ts +2 -8
- package/src/core/nesting.ts +4 -12
- package/src/core/one-way-in-policy.ts +73 -17
- package/src/core/outcome-words.ts +45 -0
- package/src/core/package-manager.ts +86 -0
- package/src/core/parallel.ts +21 -0
- package/src/core/process-tree.ts +44 -0
- package/src/core/raw-access.ts +93 -0
- package/src/core/read-set.ts +152 -0
- package/src/core/run-check.ts +155 -213
- package/src/core/run-ci.ts +153 -189
- package/src/core/run-fix.ts +96 -0
- package/src/core/run-fixers.ts +53 -30
- package/src/core/self-update.ts +9 -3
- package/src/core/slots.ts +83 -0
- package/src/core/spawn-timeout.ts +34 -3
- package/src/core/when-block.ts +401 -0
- package/src/core/when.ts +135 -0
- package/src/core/write-status.ts +5 -2
- package/src/hooks/leak-guard.ts +10 -8
- package/src/hooks/one-way-in-guard.ts +13 -5
- package/src/hooks/root-identity.ts +2 -1
- package/src/hooks/run-hooks.ts +35 -23
- package/src/init-repo.ts +39 -23
- package/src/ledger/check-stats.ts +155 -0
- package/src/ledger/check-table.ts +179 -0
- package/src/ledger/db.ts +255 -0
- package/src/ledger/gate-checks.ts +433 -0
- package/src/ledger/git-key.ts +116 -77
- package/src/ledger/git-scan.ts +96 -35
- package/src/ledger/ledger.ts +116 -42
- package/src/ledger/live-runs.ts +111 -0
- package/src/ledger/live-timeline.ts +97 -0
- package/src/ledger/repo-view.ts +73 -0
- package/src/ledger/run-log.ts +448 -0
- package/src/ledger/run-timeline.ts +173 -0
- package/src/ledger/store.ts +23 -12
- package/src/ledger/suite-log.ts +161 -0
- package/src/ledger/toolchain-identity.ts +7 -1
- package/src/ledger/world-cache.ts +111 -0
- package/src/mv/mv-md.ts +16 -6
- package/src/problems/extract.ts +182 -0
- package/src/problems/problem-board.ts +37 -0
- package/src/problems/problem-line.ts +77 -0
- package/src/prose/prose-view.ts +90 -0
- package/src/prose/run-prose.ts +60 -19
- package/src/prose/ste.ts +140 -24
- package/src/reflections/reflections-queue.ts +81 -0
- package/src/release/git-facts.ts +15 -9
- package/src/release/run-bump.ts +6 -5
- package/src/release/run-release.ts +25 -11
- package/src/release/run-releases.ts +2 -2
- package/src/release/unit-gate.ts +7 -54
- package/src/release/unit-resolve.ts +282 -0
- package/src/release/units.ts +33 -217
- package/src/repo-config.ts +155 -85
- package/src/rules/rule.ts +36 -96
- package/src/rules/untrack-generated.ts +5 -4
- package/src/scaffold/files/vscode-tasks.json +10 -31
- package/src/scaffold/jsonc-spans.ts +113 -0
- package/src/scaffold/project-scaffold.ts +16 -43
- package/src/scan.ts +98 -0
- package/src/serve/gate-trigger.ts +148 -0
- package/src/serve/health-schedule.ts +102 -0
- package/src/serve/hub-app/chart-view.ts +179 -0
- package/src/serve/hub-app/check-view.tsx +345 -0
- package/src/serve/hub-app/check-words.ts +164 -0
- package/src/serve/hub-app/checks-svc.ts +280 -0
- package/src/serve/hub-app/clock-svc.ts +33 -0
- package/src/serve/hub-app/col-card.tsx +23 -39
- package/src/serve/hub-app/cols-svc.ts +44 -20
- package/src/serve/hub-app/comfy-svc.ts +171 -0
- package/src/serve/hub-app/comfy-view.tsx +81 -0
- package/src/serve/hub-app/dur.ts +41 -0
- package/src/serve/hub-app/hash-route.ts +121 -0
- package/src/serve/hub-app/header-bar.tsx +117 -0
- package/src/serve/hub-app/health-svc.ts +23 -28
- package/src/serve/hub-app/help.tsx +29 -0
- package/src/serve/hub-app/hub-app.tsx +122 -119
- package/src/serve/hub-app/hub-st.ts +115 -42
- package/src/serve/hub-app/hub-types.ts +117 -2
- package/src/serve/hub-app/hub.css +534 -192
- package/src/serve/hub-app/index.html +2 -0
- package/src/serve/hub-app/live-svc.ts +174 -0
- package/src/serve/hub-app/md.ts +15 -0
- package/src/serve/hub-app/media-svc.ts +4 -1
- package/src/serve/hub-app/media-view.tsx +15 -0
- package/src/serve/hub-app/next-run.ts +68 -0
- package/src/serve/hub-app/project-gutter.css +65 -0
- package/src/serve/hub-app/project-gutter.tsx +128 -0
- package/src/serve/hub-app/projects-svc.ts +226 -0
- package/src/serve/hub-app/prose-hits-st.ts +124 -0
- package/src/serve/hub-app/prose-hits-view.tsx +176 -0
- package/src/serve/hub-app/prose-hits.css +21 -0
- package/src/serve/hub-app/run-strip.ts +47 -0
- package/src/serve/hub-app/runs-tabs.ts +24 -0
- package/src/serve/hub-app/test-view.tsx +107 -0
- package/src/serve/hub-app/tests-svc.ts +62 -0
- package/src/serve/hub-app/tests-view.tsx +238 -0
- package/src/serve/hub-app/time-ago.tsx +34 -0
- package/src/serve/hub-app/timeline-chart.tsx +274 -0
- package/src/serve/hub-app/timeline-geometry.ts +314 -0
- package/src/serve/hub-app/timeline-svc.ts +62 -0
- package/src/serve/hub-app/timeline-view.tsx +245 -0
- package/src/serve/hub-app/tree-build.ts +410 -0
- package/src/serve/hub-app/tree-row.tsx +42 -0
- package/src/serve/hub-app/tree-svc.ts +174 -0
- package/src/serve/hub-app/wikis-view.tsx +53 -0
- package/src/serve/other-repos.ts +79 -0
- package/src/serve/project-gutter-route.ts +350 -0
- package/src/serve/project-gutter.ts +449 -0
- package/src/serve/run-serve.ts +245 -16
- package/src/serve/same-origin.ts +14 -0
- package/src/serve/serve-hub.ts +213 -18
- package/src/shipkit.ts +19 -12
- package/src/smoke/run-smoke.ts +3 -2
- package/src/start-repo.ts +75 -36
- package/src/suite/judge-suites.ts +216 -18
- package/src/suite/kept-timings.ts +58 -0
- package/src/suite/runner-shapes.ts +88 -0
- package/src/suite/suite-cli.ts +147 -24
- package/src/suite/suite-cpu-preload.ts +92 -0
- package/src/suite/test-units.ts +243 -0
- package/src/ts/config.ts +4 -69
- package/src/ts/jsonc.ts +69 -0
- package/src/ts/lex.ts +143 -0
- package/src/ts/rename.ts +73 -10
- package/src/ts/source-graph.ts +28 -8
- package/src/ts/tsconfig-projects.ts +6 -97
- package/src/ts/tsconfig-resolve.ts +108 -0
- package/src/tui/build-steps-panel.ts +61 -0
- package/src/tui/checks-panel.ts +24 -16
- package/src/tui/run-tui.ts +2 -2
- package/src/tui/tui-panel.ts +5 -4
- package/src/wiki/graph-colors.ts +68 -0
- package/src/wiki/graph-mount.ts +7 -9
- package/src/wiki/graph-page.ts +42 -25
- package/src/wiki/graph-positions.ts +105 -0
- package/src/wiki/run-wiki.ts +1 -1
- package/src/wiki/serve-graph.ts +33 -3
- package/src/cli/cmd_Rules.ts +0 -51
- package/src/cli/cmd_Units.ts +0 -82
- package/src/collections/graphs.ts +0 -32
- package/src/contracts/contracts-once.ts +0 -43
- package/src/core/check-suite.ts +0 -302
- package/src/core/repair-contract-dirs.ts +0 -136
- package/src/core/task-runner.ts +0 -339
- package/src/ledger/gate-units.ts +0 -138
- package/src/ledger/unit-table.ts +0 -105
- package/src/rules/SK002-contracts-fulfillness.ts +0 -24
- package/src/rules/SK003-claude-md-shape.ts +0 -70
- package/src/rules/SK004-gitignore-policy.ts +0 -238
- package/src/rules/SK010-undeclared-release-unit.ts +0 -127
- package/src/rules/rule-registry.ts +0 -64
- package/src/rules/run-fix.ts +0 -72
- package/src/tui/tasks-panel.ts +0 -60
|
@@ -108,9 +108,16 @@ const PROGRAM_WORD = /^[\w./@+-]+$/
|
|
|
108
108
|
* bodies are dense and multi-line, so this is not a corner case here, it is
|
|
109
109
|
* the most common shape of command an agent runs. */
|
|
110
110
|
export function shellSegments(command: string): string[] {
|
|
111
|
+
return shellParts(command).map((p) => p.seg)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** each segment with the operator that ENDS it: `|` for a pipe (`|&` too),
|
|
115
|
+
* `;` for every other separator, `` after the last one. A redirection's `&`
|
|
116
|
+
* (`2>&1`, `&>log`) and the `|` of `>|` stay inside their segment */
|
|
117
|
+
export function shellParts(command: string): { seg: string; op: '|' | ';' | '' }[] {
|
|
111
118
|
// everything after a heredoc opener is the document, never a command
|
|
112
119
|
const shell = command.split(/<<-?\s*['"]?\w+/)[0] ?? command
|
|
113
|
-
const out: string[] = []
|
|
120
|
+
const out: { seg: string; op: '|' | ';' | '' }[] = []
|
|
114
121
|
let cur = ''
|
|
115
122
|
let quote: '"' | "'" | null = null
|
|
116
123
|
for (let i = 0; i < shell.length; i++) {
|
|
@@ -122,19 +129,80 @@ export function shellSegments(command: string): string[] {
|
|
|
122
129
|
continue
|
|
123
130
|
}
|
|
124
131
|
if (ch === '"' || ch === "'") { quote = ch; cur += ch; continue }
|
|
132
|
+
const prev = shell[i - 1]
|
|
133
|
+
const next = shell[i + 1]
|
|
134
|
+
if ((ch === '&' && (prev === '>' || prev === '<' || next === '>')) || (ch === '|' && prev === '>')) { cur += ch; continue }
|
|
125
135
|
if (ch === '\n' || ch === ';' || ch === '|' || ch === '&') {
|
|
136
|
+
let op: '|' | ';' = ';'
|
|
126
137
|
// `&&` and `||` are one operator, and a lone `&` backgrounds
|
|
127
|
-
if ((ch === '&' || ch === '|') &&
|
|
128
|
-
|
|
138
|
+
if ((ch === '&' || ch === '|') && next === ch) i++
|
|
139
|
+
else if (ch === '|') {
|
|
140
|
+
op = '|'
|
|
141
|
+
if (next === '&') i++
|
|
142
|
+
}
|
|
143
|
+
out.push({ seg: cur, op })
|
|
129
144
|
cur = ''
|
|
130
145
|
continue
|
|
131
146
|
}
|
|
132
147
|
cur += ch
|
|
133
148
|
}
|
|
134
|
-
out.push(cur)
|
|
149
|
+
out.push({ seg: cur, op: '' })
|
|
135
150
|
return out
|
|
136
151
|
}
|
|
137
152
|
|
|
153
|
+
/** the words of a segment from its program on: leading `VAR=value`
|
|
154
|
+
* assignments, then wrapper words with their own flags and durations
|
|
155
|
+
* (`timeout 60 bun test`), dropped repeatedly */
|
|
156
|
+
function programWords(seg: string): string[] {
|
|
157
|
+
let s = seg.trim().replace(/^[(){}\s]+/, '')
|
|
158
|
+
let wrapped = false
|
|
159
|
+
for (;;) {
|
|
160
|
+
const next = s.replace(/^\w+=(?:"[^"]*"|'[^']*'|\S*)\s+/, '')
|
|
161
|
+
if (next !== s) { s = next; continue }
|
|
162
|
+
const w = /^(\S+)\s+/.exec(s)
|
|
163
|
+
if (w != null && WRAPPERS.has(w[1]!.split('/').pop() ?? '')) { s = s.slice(w[0].length); wrapped = true; continue }
|
|
164
|
+
if (wrapped && w != null && /^(?:-\S*|\d+[smhd]?)$/.test(w[1]!)) { s = s.slice(w[0].length); continue }
|
|
165
|
+
break
|
|
166
|
+
}
|
|
167
|
+
return s.split(/\s+/).filter((w) => w !== '')
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** true when the words run a GATE: `shipkit ci`, the cli by path
|
|
171
|
+
* (`bun <dir>/cli.ts ci`), or the `test` and `ci` package scripts. One suite
|
|
172
|
+
* while iterating (`bun run test:raw <file>`) is not a gate */
|
|
173
|
+
function isGate(words: string[]): boolean {
|
|
174
|
+
const [a, b, c] = words.map((w) => w.split('/').pop() ?? w)
|
|
175
|
+
if (a === 'shipkit') return b === 'ci'
|
|
176
|
+
if ((a === 'bunx' || a === 'npx') && b === 'shipkit') return c === 'ci'
|
|
177
|
+
if (a === 'cli.ts') return b === 'ci'
|
|
178
|
+
if (a === 'bun' || a === 'node') {
|
|
179
|
+
if (b === 'cli.ts') return c === 'ci'
|
|
180
|
+
if (b === 'run') return c === 'test' || c === 'ci'
|
|
181
|
+
}
|
|
182
|
+
return false
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** the segment of a command that pipes a gate into something else, or null.
|
|
186
|
+
* A pipeline exits with its LAST command's code, so `shipkit ci | tail`
|
|
187
|
+
* reports the green of tail over a red gate. A `>` redirect keeps the gate's
|
|
188
|
+
* own exit code and stays allowed, and so does a pipeline under
|
|
189
|
+
* `set -o pipefail` */
|
|
190
|
+
export function pipedGateIn(command: string): string | null {
|
|
191
|
+
const parts = shellParts(command)
|
|
192
|
+
if (parts.some((p) => /^\s*set\s.*\bpipefail\b/.test(p.seg))) return null
|
|
193
|
+
for (const p of parts) {
|
|
194
|
+
if (p.op !== '|') continue
|
|
195
|
+
const words = programWords(p.seg)
|
|
196
|
+
if (words[0] != null && PROGRAM_WORD.test(words[0]) && isGate(words)) return p.seg.trim()
|
|
197
|
+
}
|
|
198
|
+
return null
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** what a piped gate refusal says: why, what to type instead, how to lift it */
|
|
202
|
+
export function pipedGateRefusal(segment: string): string {
|
|
203
|
+
return `\`${segment.slice(0, 80)}\` pipes a gate: a pipeline exits with its LAST command's code, so a red gate reads green. Run the gate bare (a \`> file\` redirect keeps its exit code and is fine), then read its output in a separate call. Lifting this: put \`set -o pipefail;\` first, or remove the one-way-in-guard hook from .claude/settings.json (agent/process.md, never pipe a gating command).`
|
|
204
|
+
}
|
|
205
|
+
|
|
138
206
|
/** the raw runner this shell command reaches for, plus the segment that
|
|
139
207
|
* offended, or null.
|
|
140
208
|
*
|
|
@@ -156,19 +224,7 @@ export function rawRunnerHit(command: string, only?: ReadonlySet<string>): { run
|
|
|
156
224
|
.filter((r) => only == null || only.has(r.head))
|
|
157
225
|
.sort((a, b) => b.head.length - a.head.length)
|
|
158
226
|
for (const seg of shellSegments(command)) {
|
|
159
|
-
|
|
160
|
-
// leading VAR=value assignments, then a wrapper word, repeatedly. A
|
|
161
|
-
// wrapper's own flags and durations go with it (`timeout 60 bun test`)
|
|
162
|
-
let wrapped = false
|
|
163
|
-
for (;;) {
|
|
164
|
-
const next = s.replace(/^\w+=(?:"[^"]*"|'[^']*'|\S*)\s+/, '')
|
|
165
|
-
if (next !== s) { s = next; continue }
|
|
166
|
-
const w = /^(\S+)\s+/.exec(s)
|
|
167
|
-
if (w != null && WRAPPERS.has(w[1]!.split('/').pop() ?? '')) { s = s.slice(w[0].length); wrapped = true; continue }
|
|
168
|
-
if (wrapped && w != null && /^(?:-\S*|\d+[smhd]?)$/.test(w[1]!)) { s = s.slice(w[0].length); continue }
|
|
169
|
-
break
|
|
170
|
-
}
|
|
171
|
-
const words = s.split(/\s+/).filter((w) => w !== '')
|
|
227
|
+
const words = programWords(seg)
|
|
172
228
|
const first = words[0]
|
|
173
229
|
if (first == null || !PROGRAM_WORD.test(first)) continue
|
|
174
230
|
// a path-qualified binary counts: ./node_modules/.bin/tsc IS tsc
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// the words every surface prints for what a run did with a check. One word
|
|
2
|
+
// per outcome, shared, so the cli, the hub and the docs never drift apart.
|
|
3
|
+
// ran the body executed this run
|
|
4
|
+
// cached the answer came back from the cache, nothing executed
|
|
5
|
+
// never cached the check declares nothing a cache key can describe
|
|
6
|
+
// attached another run was executing the same question, this one took its answer
|
|
7
|
+
|
|
8
|
+
export const RAN = 'ran'
|
|
9
|
+
export const CACHED = 'cached'
|
|
10
|
+
export const NEVER_CACHED = 'never cached'
|
|
11
|
+
export const ATTACHED = 'attached'
|
|
12
|
+
|
|
13
|
+
export type Outcome = typeof RAN | typeof CACHED
|
|
14
|
+
|
|
15
|
+
/** the one word for a finished check: `replayed` is the run record's column */
|
|
16
|
+
export const outcomeWord = (replayed: boolean): Outcome => (replayed ? CACHED : RAN)
|
|
17
|
+
|
|
18
|
+
/** `1 check`, `2 checks` */
|
|
19
|
+
export const plural = (n: number, one: string, many = `${one}s`): string => `${n} ${n === 1 ? one : many}`
|
|
20
|
+
|
|
21
|
+
export type OutcomeCounts = { cached: number; ran: number; attached?: number; neverCached?: number }
|
|
22
|
+
|
|
23
|
+
/** `41 cached, 8 ran`, plus `2 attached to another run` and `9 never cached`
|
|
24
|
+
* when given. Attached shows only when some check attached */
|
|
25
|
+
export function outcomeCounts(c: OutcomeCounts): string {
|
|
26
|
+
const parts = [`${c.cached} ${CACHED}`, `${c.ran} ${RAN}`]
|
|
27
|
+
if ((c.attached ?? 0) > 0) parts.push(`${c.attached} ${ATTACHED} to another run`)
|
|
28
|
+
if (c.neverCached != null) parts.push(`${c.neverCached} ${NEVER_CACHED}`)
|
|
29
|
+
return parts.join(', ')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** a check's last recorded verdict in words, the same on the hub tree and in
|
|
33
|
+
* the feed line `shipkit needs` prints */
|
|
34
|
+
export const checkVerdictWords = (status: 'pass' | 'warn' | 'fail' | 'skip' | null, o: { replayed: boolean; advisory: boolean }): string =>
|
|
35
|
+
status === 'fail'
|
|
36
|
+
? o.advisory ? 'failed, does not block the gate' : 'failed'
|
|
37
|
+
: status === 'warn' ? 'warned'
|
|
38
|
+
: status === 'skip' ? 'skipped'
|
|
39
|
+
: status === 'pass' ? (o.replayed ? `passed, ${CACHED}` : 'passed') : 'never ran here'
|
|
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)`
|
|
43
|
+
|
|
44
|
+
/** the stamp on the first line of an attached check */
|
|
45
|
+
export const attachedStamp = (pid: number, ranMs: number): string => `(${ATTACHED} to pid ${pid}, ${RAN} there in ${(ranMs / 1000).toFixed(1)}s)`
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// which package manager a repo updates with, decided by its lock files. a
|
|
2
|
+
// repo whose deploy runs `npm ci` is broken by an update made with another
|
|
3
|
+
// manager: package.json moves, package-lock.json does not, and `npm ci`
|
|
4
|
+
// refuses a lock that disagrees with the manifest, so the failure lands at
|
|
5
|
+
// deploy time with every local gate green.
|
|
6
|
+
//
|
|
7
|
+
// TRACKED locks decide, because an untracked bun.lock is exactly what a wrong
|
|
8
|
+
// update leaves behind, and reading it as the repo's choice would make that
|
|
9
|
+
// first mistake permanent. Nothing tracked (a fresh tree, no git) falls back
|
|
10
|
+
// to what sits on disk. Among several, the order below IS the preference, and
|
|
11
|
+
// the loser is named so the drift is visible. No lock at all is bun, the
|
|
12
|
+
// house default.
|
|
13
|
+
import { existsSync } from 'fs'
|
|
14
|
+
import { join } from 'path'
|
|
15
|
+
import { gitBin } from './git-bin.ts'
|
|
16
|
+
|
|
17
|
+
export type PackageManagerName = 'bun' | 'npm' | 'pnpm' | 'yarn'
|
|
18
|
+
|
|
19
|
+
export type PackageManager = {
|
|
20
|
+
name: PackageManagerName
|
|
21
|
+
/** the lock files it writes, for a git dirty probe over the manifest */
|
|
22
|
+
locks: string[]
|
|
23
|
+
/** install what is already declared */
|
|
24
|
+
install: string[]
|
|
25
|
+
/** move these deps to their latest version, manifest AND lock in one call */
|
|
26
|
+
update: (names: string[]) => string[]
|
|
27
|
+
/** set when several managers own a lock here, so the pick is a guess */
|
|
28
|
+
warn?: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const BUN_LOCKS = ['bun.lock', 'bun.lockb']
|
|
32
|
+
export const LOCK_FILES = [...BUN_LOCKS, 'pnpm-lock.yaml', 'yarn.lock', 'package-lock.json']
|
|
33
|
+
|
|
34
|
+
const bun: PackageManager = {
|
|
35
|
+
name: 'bun',
|
|
36
|
+
locks: BUN_LOCKS,
|
|
37
|
+
install: ['bun', 'install'],
|
|
38
|
+
update: (names) => ['bun', 'update', '--latest', ...names],
|
|
39
|
+
}
|
|
40
|
+
const npm: PackageManager = {
|
|
41
|
+
name: 'npm',
|
|
42
|
+
locks: ['package-lock.json'],
|
|
43
|
+
install: ['npm', 'install'],
|
|
44
|
+
update: (names) => ['npm', 'install', ...names.map((n) => `${n}@latest`)],
|
|
45
|
+
}
|
|
46
|
+
const pnpm: PackageManager = {
|
|
47
|
+
name: 'pnpm',
|
|
48
|
+
locks: ['pnpm-lock.yaml'],
|
|
49
|
+
install: ['pnpm', 'install'],
|
|
50
|
+
update: (names) => ['pnpm', 'update', '--latest', ...names],
|
|
51
|
+
}
|
|
52
|
+
// berry dropped `upgrade` and classic never had `up`, and a .yarnrc.yml is
|
|
53
|
+
// the cheap separator between the two
|
|
54
|
+
const yarn = (berry: boolean): PackageManager => ({
|
|
55
|
+
name: 'yarn',
|
|
56
|
+
locks: ['yarn.lock'],
|
|
57
|
+
install: ['yarn', 'install'],
|
|
58
|
+
update: berry ? (names) => ['yarn', 'up', ...names.map((n) => `${n}@latest`)] : (names) => ['yarn', 'upgrade', '--latest', ...names],
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
/** lock files git tracks at the root, empty outside a repo */
|
|
62
|
+
export function trackedLocks(root: string): string[] {
|
|
63
|
+
try {
|
|
64
|
+
const p = Bun.spawnSync([gitBin(), 'ls-files', '--', ...LOCK_FILES], { cwd: root, stdout: 'pipe', stderr: 'ignore' })
|
|
65
|
+
if (p.exitCode !== 0) return []
|
|
66
|
+
return p.stdout.toString().split('\n').map((l) => l.trim()).filter((l) => l !== '')
|
|
67
|
+
} catch {
|
|
68
|
+
return []
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** the manager this repo installs with, read off its lock files */
|
|
73
|
+
export function detectPackageManager(root: string, tracked: string[] = trackedLocks(root)): PackageManager {
|
|
74
|
+
const present = LOCK_FILES.filter((f) => existsSync(join(root, f)))
|
|
75
|
+
const owned = present.filter((f) => tracked.includes(f))
|
|
76
|
+
const locks = owned.length > 0 ? owned : present
|
|
77
|
+
const has = (f: string): boolean => locks.includes(f)
|
|
78
|
+
let pick = bun
|
|
79
|
+
if (BUN_LOCKS.some(has)) pick = bun
|
|
80
|
+
else if (has('pnpm-lock.yaml')) pick = pnpm
|
|
81
|
+
else if (has('yarn.lock')) pick = yarn(existsSync(join(root, '.yarnrc.yml')))
|
|
82
|
+
else if (has('package-lock.json')) pick = npm
|
|
83
|
+
const others = locks.filter((f) => !pick.locks.includes(f))
|
|
84
|
+
if (others.length === 0) return pick
|
|
85
|
+
return { ...pick, warn: `several lock files at the root (${locks.join(', ')}) — updating with ${pick.name}, so ${others.join(', ')} will drift; delete the one this repo does not install with` }
|
|
86
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// THE POOL SIZE: how many checks this MACHINE measures at once, across every
|
|
2
|
+
// shipkit process (core/slots.ts owns the pool). One function, so the runner
|
|
3
|
+
// and the hub read the same number. A machine resource, not a
|
|
4
|
+
// repo fact: only the machine's environment sets it.
|
|
5
|
+
export const PARALLEL_ENV = 'SHIPKIT_PARALLEL'
|
|
6
|
+
|
|
7
|
+
/** the machine's cores, never below 2 (one slow check must not starve the
|
|
8
|
+
* rest) and never above 8 (each check is often a whole tool process) */
|
|
9
|
+
export const defaultParallel = (): number => Math.max(2, Math.min(8, navigator.hardwareConcurrency || 4))
|
|
10
|
+
|
|
11
|
+
/** the size and where it came from. A value nobody can use is named rather
|
|
12
|
+
* than guessed at, and the default holds. */
|
|
13
|
+
export function parallelCapInfo(): { cap: number; source: 'env' | 'default'; problem: string | null } {
|
|
14
|
+
const raw = process.env[PARALLEL_ENV]
|
|
15
|
+
if (raw == null || raw === '') return { cap: defaultParallel(), source: 'default', problem: null }
|
|
16
|
+
const n = Number(raw)
|
|
17
|
+
if (Number.isInteger(n) && n >= 1) return { cap: n, source: 'env', problem: null }
|
|
18
|
+
return { cap: defaultParallel(), source: 'default', problem: `${PARALLEL_ENV}=${raw} is not a whole number of at least 1, so the default ${defaultParallel()} holds` }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const parallelCap = (): number => parallelCapInfo().cap
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// THE PROCESS TREE: one `ps` read of who is whose parent, shared by the
|
|
2
|
+
// spawn timeout's tree kill and the restart reap (core/instance.ts).
|
|
3
|
+
|
|
4
|
+
/** one `ps` snapshot, `pid ppid` per line, the same invocation on darwin and
|
|
5
|
+
* linux. Unreadable means an empty tree, never a thrown kill. */
|
|
6
|
+
export function psSnapshot(): string {
|
|
7
|
+
const ps = Bun.which('ps')
|
|
8
|
+
if (ps == null) return ''
|
|
9
|
+
try {
|
|
10
|
+
const r = Bun.spawnSync([ps, '-Ao', 'pid=,ppid='])
|
|
11
|
+
return r.success ? r.stdout.toString() : ''
|
|
12
|
+
} catch {
|
|
13
|
+
return ''
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** every descendant of `root`, DEEPEST FIRST, so a child is signalled before
|
|
18
|
+
* the parent that could spawn another one. Enumerate before killing: once the
|
|
19
|
+
* parent dies its children are reparented and the link to `root` is gone. */
|
|
20
|
+
export function descendantPids(root: number, snapshot?: string): number[] {
|
|
21
|
+
const kids = new Map<number, number[]>()
|
|
22
|
+
for (const line of (snapshot ?? psSnapshot()).split('\n')) {
|
|
23
|
+
const m = /^\s*(\d+)\s+(\d+)\s*$/.exec(line)
|
|
24
|
+
if (m == null) continue
|
|
25
|
+
const pid = Number(m[1])
|
|
26
|
+
const ppid = Number(m[2])
|
|
27
|
+
if (pid === ppid || pid === 0) continue
|
|
28
|
+
const list = kids.get(ppid)
|
|
29
|
+
if (list == null) kids.set(ppid, [pid])
|
|
30
|
+
else list.push(pid)
|
|
31
|
+
}
|
|
32
|
+
const out: number[] = []
|
|
33
|
+
const seen = new Set<number>([root])
|
|
34
|
+
const walk = (pid: number): void => {
|
|
35
|
+
for (const kid of kids.get(pid) ?? []) {
|
|
36
|
+
if (seen.has(kid)) continue // a ppid loop is impossible, a stale snapshot is not
|
|
37
|
+
seen.add(kid)
|
|
38
|
+
walk(kid)
|
|
39
|
+
out.push(kid)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
walk(root)
|
|
43
|
+
return out
|
|
44
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// RAW ACCESS, found statically. A `run` check is cached on what it read through
|
|
2
|
+
// `c` only while nothing it can call reads the disk or spawns a process around
|
|
3
|
+
// `c`. So its repo-local import closure (installs excluded) is scanned for the
|
|
4
|
+
// shapes that could, and ONE hit keeps it uncached.
|
|
5
|
+
//
|
|
6
|
+
// Conservative on purpose: a module that imports `fs` only to write, or never
|
|
7
|
+
// calls the import, still counts. A false "never cached" costs speed, a missed
|
|
8
|
+
// read costs a wrong green, and the two are not symmetric.
|
|
9
|
+
import { readFileSync } from 'fs'
|
|
10
|
+
import { join, relative } from 'path'
|
|
11
|
+
import { loadConfig, type RvtConfig } from '../ts/config.ts'
|
|
12
|
+
import { importClosure } from '../ts/source-graph.ts'
|
|
13
|
+
|
|
14
|
+
/** where a module reaches the disk or a process around the check context */
|
|
15
|
+
export type RawAccess = { file: string; line: number; what: string }
|
|
16
|
+
|
|
17
|
+
const IMPORTS = { re: /\b(?:from\s*|import\s*\(\s*|require\s*\(\s*|import\s+)(['"])(?:node:)?(fs(?:\/promises)?|child_process)\1/, what: (m: RegExpMatchArray) => `imports ${m[2]}` }
|
|
18
|
+
/** the load time half: a dynamic `import()` or `require()` is left to the call that runs it */
|
|
19
|
+
const STATIC_IMPORTS = { re: /\b(?:from\s*|import\s+)(['"])(?:node:)?(fs(?:\/promises)?|child_process)\1/, what: (m: RegExpMatchArray) => `imports ${m[2]}` }
|
|
20
|
+
const CALLS = { re: /\bBun\s*\.\s*(file|spawn|spawnSync|write|\$)(?![\w$])\s*[(`]/, what: (m: RegExpMatchArray) => `calls Bun.${m[1]}` }
|
|
21
|
+
|
|
22
|
+
/** what closure a walk follows. A CHECK file's body can run anything it
|
|
23
|
+
* imports, dynamically or not, so its walk follows `import()` too. The
|
|
24
|
+
* CONFIG's value is what its module LOAD computes, so a literal `import()`
|
|
25
|
+
* inside a function it exports is not part of it: `dynamic: false` */
|
|
26
|
+
export type ClosureKind = { dynamic?: boolean }
|
|
27
|
+
|
|
28
|
+
/** the first raw access in one source text, 1-based line, comments skipped */
|
|
29
|
+
export function rawAccessInSource(src: string, o: ClosureKind = {}): { line: number; what: string } | null {
|
|
30
|
+
const memo = o.dynamic === false ? staticHits : allHits
|
|
31
|
+
const hit = memo.get(src)
|
|
32
|
+
if (hit !== undefined) return hit == null ? null : { ...hit }
|
|
33
|
+
const found = scanSource(src, o)
|
|
34
|
+
if (memo.size >= HITS_MEMO_MAX) memo.clear()
|
|
35
|
+
memo.set(src, found)
|
|
36
|
+
return found == null ? null : { ...found }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** a pure function of the text, asked of the same closures by every gate, so
|
|
40
|
+
* the answer is remembered per text */
|
|
41
|
+
const allHits = new Map<string, { line: number; what: string } | null>()
|
|
42
|
+
const staticHits = new Map<string, { line: number; what: string } | null>()
|
|
43
|
+
const HITS_MEMO_MAX = 4000
|
|
44
|
+
|
|
45
|
+
function scanSource(src: string, o: ClosureKind): { line: number; what: string } | null {
|
|
46
|
+
const SHAPES = [o.dynamic === false ? STATIC_IMPORTS : IMPORTS, CALLS]
|
|
47
|
+
const lines = src.split('\n')
|
|
48
|
+
let inBlock = false
|
|
49
|
+
for (let i = 0; i < lines.length; i++) {
|
|
50
|
+
let l = lines[i]!
|
|
51
|
+
if (inBlock) {
|
|
52
|
+
const end = l.indexOf('*/')
|
|
53
|
+
if (end < 0) continue
|
|
54
|
+
l = l.slice(end + 2)
|
|
55
|
+
inBlock = false
|
|
56
|
+
}
|
|
57
|
+
l = l.replace(/\/\*.*?\*\//g, '')
|
|
58
|
+
const open = l.indexOf('/*')
|
|
59
|
+
if (open >= 0) { inBlock = true; l = l.slice(0, open) }
|
|
60
|
+
l = l.replace(/(^|\s)\/\/.*$/, '$1')
|
|
61
|
+
if (/^\s*import\s+type\b/.test(l)) continue
|
|
62
|
+
for (const s of SHAPES) {
|
|
63
|
+
const m = l.match(s.re)
|
|
64
|
+
if (m != null) return { line: i + 1, what: s.what(m) }
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return null
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** the first raw access in the import closure of `file`, walked from `root`
|
|
71
|
+
* (the repo for a repo check, the toolkit for a built-in). Installs are never
|
|
72
|
+
* scanned, and neither is `skip`, the toolkit's own tree when it sits inside
|
|
73
|
+
* the repo: the toolchain id covers it exactly as it covers an install.
|
|
74
|
+
* Throws when the closure cannot be walked, which the caller counts as a hit */
|
|
75
|
+
export function rawAccessInClosure(root: string, file: string, o: { skip?: string; configs?: Map<string, RvtConfig> } & ClosureKind = {}): RawAccess | null {
|
|
76
|
+
// one pass over many checks under one root hands in `configs`, so the
|
|
77
|
+
// root's config (a walk of its source tree) is read once for all of them
|
|
78
|
+
let loaded = o.configs?.get(root)
|
|
79
|
+
if (loaded == null) { loaded = loadConfig(root); o.configs?.set(root, loaded) }
|
|
80
|
+
const cfg = { ...loaded, root }
|
|
81
|
+
const skipped = (f: string): boolean => f.split('/').includes('node_modules') || (o.skip != null && join(root, f).startsWith(`${o.skip}/`))
|
|
82
|
+
const files = importClosure(cfg, [relative(root, file).replace(/\\/g, '/')], o).filter((f) => !skipped(f))
|
|
83
|
+
for (const f of files) {
|
|
84
|
+
let src: string
|
|
85
|
+
try { src = readFileSync(join(root, f), 'utf8') } catch { continue }
|
|
86
|
+
const hit = rawAccessInSource(src, o)
|
|
87
|
+
if (hit != null) return { file: f, ...hit }
|
|
88
|
+
}
|
|
89
|
+
return null
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** the one line a surface prints about a hit */
|
|
93
|
+
export const rawAccessLine = (r: RawAccess): string => `raw access in ${r.file}:${r.line} (${r.what}): read through c, or declare inputs or never`
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// WHAT A CHECK READ, as it read it. The check context (core/check-ctx.ts) is
|
|
2
|
+
// the only door a `run` body has to the repo, so every file, existence probe
|
|
3
|
+
// and listing it asked for lands here with the hash of what it saw. The ledger
|
|
4
|
+
// stores that set beside the answer, and a later run replays the answer only
|
|
5
|
+
// while every entry still hashes the same.
|
|
6
|
+
//
|
|
7
|
+
// A file is hashed from the bytes the check actually read, with git's own blob
|
|
8
|
+
// formula, so the record describes what the body saw even when the file moved
|
|
9
|
+
// a millisecond later: the next run hashes the disk again, the two differ, and
|
|
10
|
+
// the check runs. Nothing here can turn a mid-run edit into a replay.
|
|
11
|
+
import { createHash } from 'crypto'
|
|
12
|
+
import { lstatSync, readdirSync, readFileSync, statSync } from 'fs'
|
|
13
|
+
import { join } from 'path'
|
|
14
|
+
|
|
15
|
+
/** `file` or `dir` when the path was there, null when it was not */
|
|
16
|
+
export type PathKind = 'file' | 'dir' | null
|
|
17
|
+
|
|
18
|
+
/** one check's reads, sorted and serialisable: what the ledger stores */
|
|
19
|
+
export type ReadSet = {
|
|
20
|
+
/** repo-relative path → git blob id of the bytes read, null = absent */
|
|
21
|
+
files: Record<string, string | null>
|
|
22
|
+
/** repo-relative path → what stood there when it was asked */
|
|
23
|
+
exists: Record<string, PathKind>
|
|
24
|
+
/** glob → hash of the sorted listing it returned */
|
|
25
|
+
lists: Record<string, string>
|
|
26
|
+
/** repo-relative dir → hash of its entries as read from the disk, null =
|
|
27
|
+
* no dir there. Absent from a set stored before it existed */
|
|
28
|
+
dirs?: Record<string, string | null>
|
|
29
|
+
/** repo-relative path → whether a symlink stood there. Absent from a set
|
|
30
|
+
* stored before it existed */
|
|
31
|
+
links?: Record<string, boolean>
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** one entry of a directory, as `c.entries` returns it */
|
|
35
|
+
export type DirEntry = { name: string; kind: 'file' | 'dir' }
|
|
36
|
+
|
|
37
|
+
/** git's blob id of these bytes, computed in process */
|
|
38
|
+
export function blobOid(bytes: Uint8Array): string {
|
|
39
|
+
return createHash('sha1').update(`blob ${bytes.length}\0`).update(bytes).digest('hex')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** the identity of one listing: the paths, sorted, as one hash */
|
|
43
|
+
export function listingHash(paths: readonly string[]): string {
|
|
44
|
+
return createHash('sha256').update(paths.join('\n')).digest('hex').slice(0, 16)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function kindOf(abs: string): PathKind {
|
|
48
|
+
try {
|
|
49
|
+
const s = statSync(abs)
|
|
50
|
+
return s.isDirectory() ? 'dir' : 'file'
|
|
51
|
+
} catch {
|
|
52
|
+
return null
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** whether a symlink stands at `abs` itself, not what it points at */
|
|
57
|
+
export function isLinkAt(abs: string): boolean {
|
|
58
|
+
try {
|
|
59
|
+
return lstatSync(abs).isSymbolicLink()
|
|
60
|
+
} catch {
|
|
61
|
+
return false
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** the entries directly inside `abs`, from the disk (ignored ones included),
|
|
66
|
+
* sorted by name, null when no dir stands there. A symlink takes the kind of
|
|
67
|
+
* what it points at, and a dangling one is left out, as nothing is there */
|
|
68
|
+
export function dirEntries(abs: string): DirEntry[] | null {
|
|
69
|
+
let names: string[]
|
|
70
|
+
try {
|
|
71
|
+
if (!statSync(abs).isDirectory()) return null
|
|
72
|
+
names = readdirSync(abs)
|
|
73
|
+
} catch {
|
|
74
|
+
return null
|
|
75
|
+
}
|
|
76
|
+
const out: DirEntry[] = []
|
|
77
|
+
for (const name of names.sort()) {
|
|
78
|
+
const kind = kindOf(join(abs, name))
|
|
79
|
+
if (kind != null) out.push({ name, kind })
|
|
80
|
+
}
|
|
81
|
+
return out
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** the identity of one dir read, null for no dir */
|
|
85
|
+
export function entriesHash(entries: readonly DirEntry[] | null): string | null {
|
|
86
|
+
return entries == null ? null : listingHash(entries.map((e) => `${e.kind === 'dir' ? 'd' : 'f'} ${e.name}`))
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** the blob id of the file on disk now, null when there is no file to read */
|
|
90
|
+
export function oidOnDisk(abs: string): string | null {
|
|
91
|
+
try {
|
|
92
|
+
return blobOid(readFileSync(abs))
|
|
93
|
+
} catch {
|
|
94
|
+
return null
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** the recorder a context writes into. Merging is how a shared scan's reads
|
|
99
|
+
* reach every check that used it. */
|
|
100
|
+
export class Reads {
|
|
101
|
+
readonly files = new Map<string, string | null>()
|
|
102
|
+
readonly exists = new Map<string, PathKind>()
|
|
103
|
+
readonly lists = new Map<string, string>()
|
|
104
|
+
readonly dirs = new Map<string, string | null>()
|
|
105
|
+
readonly links = new Map<string, boolean>()
|
|
106
|
+
|
|
107
|
+
file(path: string, oid: string | null): void { this.files.set(path, oid) }
|
|
108
|
+
exist(path: string, kind: PathKind): void { this.exists.set(path, kind) }
|
|
109
|
+
list(glob: string, hash: string): void { this.lists.set(glob, hash) }
|
|
110
|
+
dir(path: string, hash: string | null): void { this.dirs.set(path, hash) }
|
|
111
|
+
link(path: string, is: boolean): void { this.links.set(path, is) }
|
|
112
|
+
|
|
113
|
+
merge(o: Reads): void {
|
|
114
|
+
for (const [k, v] of o.files) this.files.set(k, v)
|
|
115
|
+
for (const [k, v] of o.exists) this.exists.set(k, v)
|
|
116
|
+
for (const [k, v] of o.lists) this.lists.set(k, v)
|
|
117
|
+
for (const [k, v] of o.dirs) this.dirs.set(k, v)
|
|
118
|
+
for (const [k, v] of o.links) this.links.set(k, v)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
snapshot(): ReadSet {
|
|
122
|
+
const sorted = <V>(m: Map<string, V>): Record<string, V> => Object.fromEntries([...m].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)))
|
|
123
|
+
return { files: sorted(this.files), exists: sorted(this.exists), lists: sorted(this.lists), dirs: sorted(this.dirs), links: sorted(this.links) }
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** the first entry of `set` that no longer holds, or null when every one
|
|
128
|
+
* does. `list` answers a glob the way `c.list` would right now, and returns
|
|
129
|
+
* null when it cannot answer honestly (no git scan), which counts as drift. */
|
|
130
|
+
export function readSetDrift(root: string, set: ReadSet, list: (glob: string) => readonly string[] | null): string | null {
|
|
131
|
+
for (const [p, oid] of Object.entries(set.files)) {
|
|
132
|
+
const now = oidOnDisk(join(root, p))
|
|
133
|
+
if (now !== oid) return `${p} ${oid == null ? 'appeared' : now == null ? 'is gone' : 'changed'}`
|
|
134
|
+
}
|
|
135
|
+
for (const [p, kind] of Object.entries(set.exists)) {
|
|
136
|
+
const now = kindOf(join(root, p))
|
|
137
|
+
if (now !== kind) return `${p} ${kind == null ? 'appeared' : now == null ? 'is gone' : `is a ${now} now`}`
|
|
138
|
+
}
|
|
139
|
+
for (const [g, hash] of Object.entries(set.lists)) {
|
|
140
|
+
const now = list(g)
|
|
141
|
+
if (now == null) return `${g} cannot be listed without a git scan`
|
|
142
|
+
if (listingHash(now) !== hash) return `the files matching ${g} changed`
|
|
143
|
+
}
|
|
144
|
+
for (const [d, hash] of Object.entries(set.dirs ?? {})) {
|
|
145
|
+
const now = entriesHash(dirEntries(join(root, d)))
|
|
146
|
+
if (now !== hash) return `${d}/ ${hash == null ? 'appeared' : now == null ? 'is gone' : 'changed its entries'}`
|
|
147
|
+
}
|
|
148
|
+
for (const [p, was] of Object.entries(set.links ?? {})) {
|
|
149
|
+
if (isLinkAt(join(root, p)) !== was) return `${p} ${was ? 'is no longer a link' : 'became a link'}`
|
|
150
|
+
}
|
|
151
|
+
return null
|
|
152
|
+
}
|