rvlib-shipkit 1.4.0 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +114 -0
- package/README.md +17 -17
- package/package.json +3 -2
- package/src/assets/gen/comfyts-gen.ts +12 -8
- package/src/assets/server/studio-server.ts +14 -2
- package/src/check.ts +73 -103
- package/src/checks/SK001-package-json-shape.check.ts +11 -6
- package/src/checks/SK002-contracts-fulfillness.check.ts +4 -0
- package/src/checks/SK003-claude-md-shape.check.ts +7 -3
- 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 +4 -2
- 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 +4 -0
- package/src/checks/SK015-nested-repo.check.ts +4 -2
- package/src/checks/SK016-legacy-check-shapes.check.ts +13 -12
- 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 +4 -1
- package/src/checks/SK028-readme.check.ts +5 -1
- package/src/checks/SK029-explicit-check-files.check.ts +95 -0
- package/src/checks/SK030-cache-audit.check.ts +34 -0
- package/src/checks/SK031-trace-audit.check.ts +39 -0
- package/src/checks/SK032-ignored-inputs.check.ts +79 -0
- package/src/cli/cmd_Asks.ts +5 -5
- package/src/cli/cmd_Assets.ts +17 -13
- package/src/cli/cmd_Bump.ts +12 -11
- package/src/cli/cmd_CacheAudit.ts +43 -0
- 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 +8 -6
- package/src/cli/cmd_Fleet.ts +9 -7
- package/src/cli/cmd_GlobalProcesses.ts +120 -7
- package/src/cli/cmd_Home.ts +26 -19
- package/src/cli/cmd_Hooks.ts +6 -5
- package/src/cli/cmd_Init.ts +5 -4
- package/src/cli/cmd_Keys.ts +20 -8
- package/src/cli/cmd_Mv.ts +7 -6
- package/src/cli/cmd_Needs.ts +4 -3
- package/src/cli/cmd_Overview.ts +5 -4
- 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 +6 -4
- 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_TraceAudit.ts +62 -0
- 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 +11 -2
- package/src/collections/checks.ts +2 -0
- package/src/collections/collection-types.ts +12 -4
- package/src/collections/providers.ts +5 -3
- package/src/core/app-icon.ts +154 -0
- package/src/core/audit-report.ts +21 -0
- package/src/core/cache-audit.ts +72 -0
- package/src/core/canonical-scripts.ts +2 -2
- package/src/core/check-ctx.ts +14 -5
- package/src/core/check-runner.ts +43 -23
- package/src/core/config-rewrite.ts +37 -24
- package/src/core/discover-checks.ts +23 -2
- package/src/core/floor-resolve.ts +5 -7
- package/src/core/git-root.ts +13 -0
- package/src/core/ignored-block.ts +94 -0
- package/src/core/ignored-inputs.ts +47 -0
- package/src/core/jobs.ts +9 -2
- package/src/core/legacy-shapes.ts +39 -10
- package/src/core/one-lib.ts +59 -4
- 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/read-set.ts +97 -9
- package/src/core/retention.ts +0 -14
- package/src/core/run-check.ts +12 -9
- package/src/core/run-ci.ts +14 -5
- package/src/core/run-fix.ts +30 -23
- package/src/core/run-fixers.ts +48 -5
- package/src/core/runs-matrix.ts +179 -0
- package/src/core/runs.ts +242 -0
- package/src/core/trace-audit.ts +120 -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 +10 -14
- package/src/fleet/scan-fleet.ts +20 -4
- package/src/fleet/update-fleet.ts +6 -6
- package/src/home/gui-app.ts +24 -27
- package/src/home/home-launchd.ts +20 -4
- package/src/home/home-routes.ts +12 -1
- package/src/home/home-start.ts +11 -5
- package/src/home/hubs.ts +5 -5
- package/src/home/open-app.ts +2 -2
- package/src/home/pm.ts +8 -0
- package/src/home/run-pm.ts +1 -0
- package/src/home/serve-web.ts +2 -2
- package/src/home/supervise.ts +24 -1
- package/src/home/via-hub.ts +14 -0
- package/src/hooks/run-hooks.ts +48 -4
- package/src/init-repo.ts +6 -6
- package/src/ledger/db.ts +2 -13
- package/src/ledger/gate-checks.ts +133 -61
- package/src/ledger/git-key.ts +153 -31
- package/src/ledger/git-scan.ts +5 -2
- package/src/ledger/ledger.ts +136 -21
- package/src/ledger/store.ts +14 -0
- package/src/ledger/toolchain-identity.ts +248 -45
- package/src/plan/repo-source.ts +18 -39
- package/src/problems/extract.ts +3 -0
- package/src/release/run-release.ts +5 -0
- package/src/repo-config.ts +46 -54
- 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 +1 -1
- package/src/serve/hub-app/admin-svc.ts +36 -3
- package/src/serve/hub-app/admin-view.tsx +46 -12
- 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 +14 -35
- package/src/serve/hub-app/col-card.tsx +1 -44
- package/src/serve/hub-app/col-tables-view.tsx +84 -11
- package/src/serve/hub-app/col-tables.ts +27 -0
- package/src/serve/hub-app/cols-svc.ts +5 -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 +6 -0
- 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 +14 -2
- package/src/serve/hub-app/home-strip.css +43 -12
- package/src/serve/hub-app/home-strip.tsx +115 -64
- package/src/serve/hub-app/home.html +1 -0
- package/src/serve/hub-app/hub-app.tsx +32 -8
- package/src/serve/hub-app/hub-types.ts +5 -2
- package/src/serve/hub-app/hub.css +89 -63
- package/src/serve/hub-app/icons.tsx +11 -0
- package/src/serve/hub-app/index.html +1 -0
- package/src/serve/hub-app/keys.ts +15 -4
- package/src/serve/hub-app/live-svc.ts +3 -2
- package/src/serve/hub-app/machine-st.ts +18 -22
- 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 +45 -0
- package/src/serve/hub-app/project-list.css +4 -1
- package/src/serve/hub-app/project-list.tsx +6 -3
- package/src/serve/hub-app/projects-svc.ts +25 -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 +131 -0
- package/src/serve/hub-app/repo-st.ts +70 -14
- package/src/serve/hub-app/sidebar.tsx +17 -42
- package/src/serve/hub-app/timeline-svc.ts +3 -2
- package/src/serve/hub-app/tree-build.ts +151 -155
- package/src/serve/hub-app/tree-row.tsx +55 -12
- package/src/serve/hub-app/tree-svc.ts +97 -56
- 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 +7 -18
- package/src/serve/project-gutter-route.ts +22 -1
- package/src/serve/run-serve.ts +1 -1
- package/src/serve/serve-hub.ts +4 -14
- package/src/suite/judge-suites.ts +6 -0
- package/src/trace/interpose.c +655 -0
- package/src/trace/read-trace.ts +485 -0
- package/src/trace/tracer.ts +282 -0
- 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/ledger/git-key.ts
CHANGED
|
@@ -6,10 +6,10 @@
|
|
|
6
6
|
// FOUR POPULATIONS, set at registration, never inferred from source:
|
|
7
7
|
//
|
|
8
8
|
// CONTENT declares `inputs` — the paths it reads. Keyed on their git object
|
|
9
|
-
// ids, plus the check's own code (module graph), plus the
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
9
|
+
// ids, plus the check's own code (module graph), plus the runner
|
|
10
|
+
// and the install (toolchain-identity.ts), plus argv and declared
|
|
11
|
+
// env. No TTL: a content key is either the same question or a
|
|
12
|
+
// different one.
|
|
13
13
|
// WORLD declares `ttl` — it reads the network, the keychain, $PATH, a git
|
|
14
14
|
// remote or the clock. Nothing on disk describes its answer, so it
|
|
15
15
|
// keeps the existing clean-pass TTL and its `cacheKey`, unchanged.
|
|
@@ -24,11 +24,11 @@
|
|
|
24
24
|
// check that says `never`. Never cached. For those it is the
|
|
25
25
|
// DEFAULT, and it is the whole safety story: forgetting to declare
|
|
26
26
|
// costs speed, never correctness, so the burn-down list below is a
|
|
27
|
-
// to-do rather than a hazard.
|
|
28
|
-
// the
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
27
|
+
// to-do rather than a hazard. A TASK that declares nothing is not
|
|
28
|
+
// here: the composer keys it on the whole work tree plus the repo's
|
|
29
|
+
// ignoredInputs (ledger/gate-checks.ts). Declaring BOTH is a
|
|
30
|
+
// registration failure, loud, because the two answers contradict
|
|
31
|
+
// each other.
|
|
32
32
|
//
|
|
33
33
|
// CROSS WORKTREE. Every component here is content addressed or a version
|
|
34
34
|
// string; not one of them is an absolute path. Two worktrees of one repo
|
|
@@ -39,15 +39,31 @@
|
|
|
39
39
|
// differ from main by two top-level packages. That is what makes a shared
|
|
40
40
|
// ledger safe rather than a stale green in a checkout nobody looked at.
|
|
41
41
|
import { createHash } from 'crypto'
|
|
42
|
-
import {
|
|
42
|
+
import { existsSync, readdirSync, readFileSync, type Dirent } from 'fs'
|
|
43
|
+
import { isAbsolute, join, relative } from 'path'
|
|
44
|
+
import { gitBin } from '../core/git-bin.ts'
|
|
43
45
|
import { loadConfig, type RvtConfig } from '../ts/config.ts'
|
|
44
|
-
import { importClosure } from '../ts/source-graph.ts'
|
|
46
|
+
import { extractSpecs, importClosure } from '../ts/source-graph.ts'
|
|
45
47
|
import { GitScanError, hashFiles, normalizeInput, scanPaths, WHOLE_TREE, type ScanResult } from './git-scan.ts'
|
|
46
|
-
import { installIdentity, toolchainIdentity } from './toolchain-identity.ts'
|
|
48
|
+
import { installIdentity, toolchainIdentity, type LibSource } from './toolchain-identity.ts'
|
|
47
49
|
|
|
48
50
|
/** the key format. Bumped when a component is added or its rendering changes,
|
|
49
51
|
* so an old ledger can never answer a new question. */
|
|
50
|
-
export const KEY_VERSION =
|
|
52
|
+
export const KEY_VERSION = 4
|
|
53
|
+
|
|
54
|
+
/** `dir/**` names a dir a task only LISTS: keyed on the names under it */
|
|
55
|
+
export const isNamesInput = (p: string): boolean => p.endsWith('/**')
|
|
56
|
+
|
|
57
|
+
let nodeMemo: string | null = null
|
|
58
|
+
/** the node a task's tools run under, from PATH: a runtime upgrade is a
|
|
59
|
+
* different program, like a bun upgrade */
|
|
60
|
+
function nodeVersion(): string {
|
|
61
|
+
if (nodeMemo == null) {
|
|
62
|
+
const node = Bun.which('node')
|
|
63
|
+
nodeMemo = node == null ? 'none' : Bun.spawnSync([node, '--version'], { stdout: 'pipe', stderr: 'pipe' }).stdout.toString().trim() || 'unknown'
|
|
64
|
+
}
|
|
65
|
+
return nodeMemo
|
|
66
|
+
}
|
|
51
67
|
|
|
52
68
|
export type Population = 'content' | 'world' | 'reads' | 'none'
|
|
53
69
|
|
|
@@ -63,6 +79,10 @@ export type CheckDecl = {
|
|
|
63
79
|
/** repo-relative entry files of the check's OWN code — the module-graph
|
|
64
80
|
* roots. A discovered check gets its own file for free. */
|
|
65
81
|
code?: string[]
|
|
82
|
+
/** absolute entry files of the check's own code INSIDE the toolkit: a
|
|
83
|
+
* built-in check's file. A task whose argv runs a toolkit module gets
|
|
84
|
+
* that module's closure without declaring it */
|
|
85
|
+
libCode?: string[]
|
|
66
86
|
/** the argv a subprocess check runs */
|
|
67
87
|
argv?: string[]
|
|
68
88
|
/** env var NAMES whose values belong in the key */
|
|
@@ -85,6 +105,9 @@ export type CheckDecl = {
|
|
|
85
105
|
/** why an undeclared `run` check stays uncached: the first module its body
|
|
86
106
|
* can reach that touches the disk or a process around the context */
|
|
87
107
|
uncacheable?: string
|
|
108
|
+
/** the check's cache declaration contradicts its body (`reads` on a body
|
|
109
|
+
* that touches the disk around its context): a registration failure */
|
|
110
|
+
declError?: string
|
|
88
111
|
}
|
|
89
112
|
|
|
90
113
|
export type CheckKey = {
|
|
@@ -99,10 +122,14 @@ export type CheckKey = {
|
|
|
99
122
|
components: string[]
|
|
100
123
|
/** the input paths this check was keyed on, with their object ids */
|
|
101
124
|
inputOids: [string, string][]
|
|
102
|
-
/** the
|
|
103
|
-
*
|
|
104
|
-
|
|
105
|
-
|
|
125
|
+
/** the toolkit files behind its `lib=` and `libused=` components,
|
|
126
|
+
* package-relative: what `shipkit keys --why` lists */
|
|
127
|
+
libFiles?: string[]
|
|
128
|
+
/** the identity of the check's OWN code, module graph included, in the
|
|
129
|
+
* repo and in the toolkit. Set for every check that has code, WORLD ones
|
|
130
|
+
* too: a TTL'd probe whose source changed must not replay its previous
|
|
131
|
+
* answer, and this is the value that busts it. null when the check has no
|
|
132
|
+
* code entry or its code could not be hashed. */
|
|
106
133
|
codeKey: string | null
|
|
107
134
|
}
|
|
108
135
|
|
|
@@ -121,6 +148,7 @@ export type Registration = {
|
|
|
121
148
|
* `inputs` on a `ttl` check is a registration FAIL: a check cannot both be
|
|
122
149
|
* described by files on disk and be un-describable by them. */
|
|
123
150
|
export function classify(d: CheckDecl): { population: Population; error?: string; reason?: string; decided?: boolean } {
|
|
151
|
+
if (d.declError != null) return { population: 'none', error: `check '${d.id}': ${d.declError}` }
|
|
124
152
|
const hasInputs = (d.inputs ?? []).length > 0
|
|
125
153
|
const hasTtl = d.ttl != null && d.ttl !== ''
|
|
126
154
|
const hasNever = d.never != null && d.never !== ''
|
|
@@ -192,7 +220,7 @@ export class KeyLayer {
|
|
|
192
220
|
private readonly scanError: string | null,
|
|
193
221
|
private readonly closureByCheck: Map<string, string[]>,
|
|
194
222
|
private readonly codeOids: Map<string, string>,
|
|
195
|
-
private readonly toolchain: { id: string; uncacheable: string[] },
|
|
223
|
+
private readonly toolchain: { id: string; install: string; lib: LibSource | null; uncacheable: string[] },
|
|
196
224
|
private readonly scanPathsUsed: string[],
|
|
197
225
|
private readonly scope: ReadonlySet<string> | null,
|
|
198
226
|
/** wall time of the WHOLE layer: toolchain identity, the module-graph
|
|
@@ -204,12 +232,11 @@ export class KeyLayer {
|
|
|
204
232
|
|
|
205
233
|
private readonly installIds = new Map<string, string>()
|
|
206
234
|
|
|
207
|
-
/** the
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
* different answer, and the TTL alone cannot see that. */
|
|
235
|
+
/** the runner and the install a WORLD answer is remembered under: a TTL'd
|
|
236
|
+
* answer measured by a different shipkit is a different answer, and the
|
|
237
|
+
* TTL alone cannot see that. The probe's own code rides in `codeKey` */
|
|
211
238
|
get toolchainId(): string {
|
|
212
|
-
return this.toolchain.id
|
|
239
|
+
return `${this.toolchain.id}:${this.toolchain.install}`
|
|
213
240
|
}
|
|
214
241
|
|
|
215
242
|
/** wall time the GIT half alone cost, ms — `openMs` is what a run pays */
|
|
@@ -228,11 +255,11 @@ export class KeyLayer {
|
|
|
228
255
|
// a gate binary that cannot be resolved is not a crash, it is a run
|
|
229
256
|
// whose program nobody can name — and the only safe answer to that is
|
|
230
257
|
// no key at all
|
|
231
|
-
let toolchain: { id: string; uncacheable: string[] }
|
|
258
|
+
let toolchain: { id: string; install: string; lib: LibSource | null; uncacheable: string[] }
|
|
232
259
|
try {
|
|
233
|
-
toolchain = toolchainIdentity(root, o.cliEntry)
|
|
260
|
+
toolchain = { ...toolchainIdentity(root, o.cliEntry), uncacheable: [] }
|
|
234
261
|
} catch (e) {
|
|
235
|
-
toolchain = { id: 'unresolved', uncacheable: [`gate binary unreadable at ${o.cliEntry}: ${e instanceof Error ? e.message : String(e)}`] }
|
|
262
|
+
toolchain = { id: 'unresolved', install: 'unresolved', lib: null, uncacheable: [`gate binary unreadable at ${o.cliEntry}: ${e instanceof Error ? e.message : String(e)}`] }
|
|
236
263
|
}
|
|
237
264
|
|
|
238
265
|
// the check's OWN code, resolved through the same module graph the
|
|
@@ -253,7 +280,7 @@ export class KeyLayer {
|
|
|
253
280
|
const paths = new Set<string>()
|
|
254
281
|
for (const d of scoped) {
|
|
255
282
|
if (classify(d).population !== 'content') continue
|
|
256
|
-
for (const p of d.inputs ?? []) paths.add(p)
|
|
283
|
+
for (const p of d.inputs ?? []) if (!isNamesInput(p)) paths.add(p)
|
|
257
284
|
}
|
|
258
285
|
|
|
259
286
|
// the closure is INDIVIDUAL FILES belonging to no declared path, so it
|
|
@@ -283,9 +310,12 @@ export class KeyLayer {
|
|
|
283
310
|
const own = (this.closureByCheck.get(d.id) ?? []).slice().sort()
|
|
284
311
|
const ownReadable = own.every((p) => this.codeOids.get(p) != null)
|
|
285
312
|
const codeLines = own.map((p) => `code:${p}=${this.codeOids.get(p)!}`)
|
|
313
|
+
const lib = this.libLines(d)
|
|
286
314
|
const base: CheckKey = {
|
|
287
|
-
id: d.id, population: c.population, key: null, reason: null, components: [], inputOids: [],
|
|
288
|
-
codeKey: own.length === 0 || !ownReadable
|
|
315
|
+
id: d.id, population: c.population, key: null, reason: null, components: [], inputOids: [], libFiles: this.libFilesOf(d),
|
|
316
|
+
codeKey: typeof lib === 'string' || (own.length === 0 || !ownReadable) && lib.length === 0
|
|
317
|
+
? null
|
|
318
|
+
: createHash('sha256').update([...(ownReadable ? codeLines : []), ...lib].join('\n')).digest('hex').slice(0, 16),
|
|
289
319
|
}
|
|
290
320
|
if (c.error != null) return { ...base, reason: c.error }
|
|
291
321
|
if (this.scope != null && !this.scope.has(d.id)) return { ...base, reason: 'not one of the checks this run executes, so nothing was scanned for it' }
|
|
@@ -295,10 +325,10 @@ export class KeyLayer {
|
|
|
295
325
|
const scan = this.scan!
|
|
296
326
|
if (this.toolchain.uncacheable.length > 0)
|
|
297
327
|
return { ...base, reason: `toolchain not describable: ${this.toolchain.uncacheable.join('; ')}` }
|
|
328
|
+
if (typeof lib === 'string') return { ...base, reason: lib }
|
|
298
329
|
// the question alone: what it read is stored with each answer and
|
|
299
330
|
// re-checked before a replay, so no read belongs in this key
|
|
300
331
|
if (c.population === 'reads') {
|
|
301
|
-
// a built-in has no code entry: the toolchain id hashes its source
|
|
302
332
|
if (!ownReadable) return { ...base, reason: `git could not hash this check's own source: ${own.filter((p) => this.codeOids.get(p) == null).join(', ')}` }
|
|
303
333
|
const components = [
|
|
304
334
|
`v=${KEY_VERSION}`,
|
|
@@ -308,14 +338,17 @@ export class KeyLayer {
|
|
|
308
338
|
`toolchain=${this.toolchain.id}`,
|
|
309
339
|
'reads=recorded',
|
|
310
340
|
...codeLines,
|
|
341
|
+
...lib,
|
|
311
342
|
...(d.extra != null ? [`extra=${d.extra}`] : []),
|
|
312
343
|
]
|
|
313
344
|
return { ...base, key: createHash('sha256').update(components.join('\n')).digest('hex').slice(0, 32), components }
|
|
314
345
|
}
|
|
315
346
|
|
|
316
347
|
let inputs: string[]
|
|
348
|
+
let names: string[]
|
|
317
349
|
try {
|
|
318
|
-
inputs = (d.inputs ?? []).map(normalizeInput).sort()
|
|
350
|
+
inputs = (d.inputs ?? []).filter((p) => !isNamesInput(p)).map(normalizeInput).sort()
|
|
351
|
+
names = (d.inputs ?? []).filter(isNamesInput).map((p) => normalizeInput(p.slice(0, -3))).sort()
|
|
319
352
|
} catch (e) {
|
|
320
353
|
return { ...base, reason: e instanceof Error ? e.message : String(e) }
|
|
321
354
|
}
|
|
@@ -337,10 +370,17 @@ export class KeyLayer {
|
|
|
337
370
|
// frozen run and a normal one measure it twice.
|
|
338
371
|
...(d.writes === true ? [`frozen=${process.env['SHIPKIT_FROZEN'] === '1'}`] : []),
|
|
339
372
|
`toolchain=${this.toolchain.id}`,
|
|
373
|
+
// the repo's own tools run here, and what is installed is part of
|
|
374
|
+
// what they are
|
|
375
|
+
`install=${this.toolchain.install}`,
|
|
340
376
|
`argv=${JSON.stringify(d.argv ?? [])}`,
|
|
341
377
|
...(d.env ?? []).slice().sort().map((n) => `env:${n}=${process.env[n] ?? '<unset>'}`),
|
|
342
378
|
...inputOids.map(([p, oid]) => `in:${p}=${oid}`),
|
|
379
|
+
// a dir the task only lists: the names under it, never its bytes
|
|
380
|
+
...names.map((p) => `names:${p}=${this.namesOf(p)}`),
|
|
381
|
+
`node=${nodeVersion()}`,
|
|
343
382
|
...codeLines,
|
|
383
|
+
...lib,
|
|
344
384
|
...(d.extra != null ? [`extra=${d.extra}`] : []),
|
|
345
385
|
// ignored content nobody keyed on is part of the key's MEANING:
|
|
346
386
|
// two runs that excluded different things answered different
|
|
@@ -358,6 +398,88 @@ export class KeyLayer {
|
|
|
358
398
|
return { ...base, key, components, inputOids }
|
|
359
399
|
}
|
|
360
400
|
|
|
401
|
+
/** the toolkit code a check's answer comes from, as key lines, or why it
|
|
402
|
+
* cannot be keyed. A built-in names its file, a task running a toolkit
|
|
403
|
+
* module names it in its argv. Everything else runs the REPO's code, and
|
|
404
|
+
* in a repo that does not host the toolkit that code imports it by
|
|
405
|
+
* package name, through an install the install id leaves the toolkit out
|
|
406
|
+
* of: those carry the closure of every toolkit module the repo imports */
|
|
407
|
+
private libLines(d: CheckDecl): string[] | string {
|
|
408
|
+
const lib = this.toolchain.lib
|
|
409
|
+
if (lib == null) return []
|
|
410
|
+
const entries = [...(d.libCode ?? []), ...(d.argv ?? []).filter((a) => isAbsolute(a) && lib.owns(a))]
|
|
411
|
+
try {
|
|
412
|
+
const used = d.libCode == null ? this.libUsed() : []
|
|
413
|
+
if (typeof used === 'string') return used
|
|
414
|
+
return [...(entries.length > 0 ? [`lib=${lib.closureKey(entries)}`] : []), ...used]
|
|
415
|
+
} catch (e) {
|
|
416
|
+
return `the toolkit source this check runs could not be read: ${e instanceof Error ? e.message : String(e)}`
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
private libUsedMemo: string[] | string | null = null
|
|
421
|
+
private libUsedEntries: string[] = []
|
|
422
|
+
|
|
423
|
+
/** the files behind libLines, for a reader; empty when there are none */
|
|
424
|
+
private libFilesOf(d: CheckDecl): string[] {
|
|
425
|
+
const lib = this.toolchain.lib
|
|
426
|
+
if (lib == null) return []
|
|
427
|
+
const entries = [...(d.libCode ?? []), ...(d.argv ?? []).filter((a) => isAbsolute(a) && lib.owns(a)), ...(d.libCode == null ? this.libUsedEntries : [])]
|
|
428
|
+
try { return [...new Set(entries.flatMap((e) => lib.closureOf(e)))].sort() } catch { return [] }
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** the closure of every toolkit module this repo's own files import by
|
|
432
|
+
* package name, found with one `git grep` over tracked and untracked
|
|
433
|
+
* files. Empty in the repo that hosts the toolkit, whose own resolver
|
|
434
|
+
* follows those imports into the code closure */
|
|
435
|
+
private libUsed(): string[] | string {
|
|
436
|
+
if (this.libUsedMemo != null) return this.libUsedMemo
|
|
437
|
+
const lib = this.toolchain.lib
|
|
438
|
+
if (lib == null || lib.name == null || !relative(this.root, lib.pkgRoot).startsWith('..')) return (this.libUsedMemo = [])
|
|
439
|
+
const p = Bun.spawnSync([gitBin(), 'grep', '-l', '--untracked', '-F', '-e', `${lib.name}/`, '--', '*.ts', '*.tsx', '*.mts', '*.cts', '*.js', '*.mjs'], { cwd: this.root, stdout: 'pipe', stderr: 'pipe' })
|
|
440
|
+
// exit 1 is "no match", anything above it is git failing
|
|
441
|
+
if (p.exitCode > 1) return (this.libUsedMemo = `git grep could not list the files importing ${lib.name}: ${p.stderr.toString().trim().split('\n')[0]}`)
|
|
442
|
+
const entries: string[] = []
|
|
443
|
+
for (const f of p.stdout.toString().split('\n').filter((l) => l !== '')) {
|
|
444
|
+
let text = ''
|
|
445
|
+
try { text = readFileSync(join(this.root, f), 'utf8') } catch { continue }
|
|
446
|
+
for (const spec of [...extractSpecs(text, { dynamic: true }), ...[...text.matchAll(/import\.meta\.resolve\(\s*['"]([^'"\n]+)['"]\s*\)/g)].map((m) => m[1]!)]) {
|
|
447
|
+
const abs = lib.fileOf(spec)
|
|
448
|
+
if (abs != null) entries.push(abs)
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
this.libUsedEntries = [...new Set(entries)].sort()
|
|
452
|
+
return (this.libUsedMemo = entries.length === 0 ? [] : [`libused=${lib.closureKey(this.libUsedEntries)}`])
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
private readonly namesMemo = new Map<string, string>()
|
|
456
|
+
|
|
457
|
+
/** every relative path under a dir, sorted, as one hash: what a task that
|
|
458
|
+
* lists the dir without reading its files sees. `absent` for no dir */
|
|
459
|
+
private namesOf(rel: string): string {
|
|
460
|
+
let h = this.namesMemo.get(rel)
|
|
461
|
+
if (h == null) {
|
|
462
|
+
const abs = join(this.root, rel)
|
|
463
|
+
if (!existsSync(abs)) h = 'absent'
|
|
464
|
+
else {
|
|
465
|
+
const all: string[] = []
|
|
466
|
+
const walk = (d: string, prefix: string): void => {
|
|
467
|
+
let entries: Dirent[] = []
|
|
468
|
+
try { entries = readdirSync(d, { withFileTypes: true }) } catch { return }
|
|
469
|
+
for (const e of entries) {
|
|
470
|
+
const r = `${prefix}${e.name}`
|
|
471
|
+
all.push(e.isDirectory() ? `${r}/` : r)
|
|
472
|
+
if (e.isDirectory()) walk(join(d, e.name), `${r}/`)
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
walk(abs, '')
|
|
476
|
+
h = createHash('sha256').update(all.sort().join('\n')).digest('hex').slice(0, 16)
|
|
477
|
+
}
|
|
478
|
+
this.namesMemo.set(rel, h)
|
|
479
|
+
}
|
|
480
|
+
return h
|
|
481
|
+
}
|
|
482
|
+
|
|
361
483
|
/** an excluded install's identity, once per layer: several checks can name
|
|
362
484
|
* the same one, and hashing a `node_modules` twice in a run is pure cost. */
|
|
363
485
|
private installOf(rel: string): string {
|
package/src/ledger/git-scan.ts
CHANGED
|
@@ -290,10 +290,13 @@ export function scanPaths(root: string, rawPaths: string[], prior?: ScanResult,
|
|
|
290
290
|
// force-adding that one entry stages the node_modules inside it.
|
|
291
291
|
const declared = new Set(wanted)
|
|
292
292
|
const denied = ignored.filter(underNodeModules)
|
|
293
|
-
|
|
293
|
+
// a declared path INSIDE a wholly ignored dir comes back as that dir, so
|
|
294
|
+
// it is named by the collapsed ancestor rather than by itself
|
|
295
|
+
const underIgnored = present.filter((p) => p !== WHOLE_TREE && !ignored.includes(p) && ignored.some((d) => p.startsWith(`${d}/`)))
|
|
296
|
+
const forceAdd = [...ignored, ...underIgnored].filter((p) => declared.has(p) && !underNodeModules(p) && !denied.some((d) => d.startsWith(`${p}/`)))
|
|
294
297
|
// a path git itself calls ignored must NOT get the plain add: `git add --
|
|
295
298
|
// <ignored>` exits 1 and takes the whole scan down with it
|
|
296
|
-
const ignoredSet = new Set(ignored)
|
|
299
|
+
const ignoredSet = new Set([...ignored, ...underIgnored])
|
|
297
300
|
const plainAdds = toAdd.filter((p) => !ignoredSet.has(p))
|
|
298
301
|
if (plainAdds.length > 0) gitOrThrow(root, ['add', '--', ...plainAdds], indexFile)
|
|
299
302
|
// force-adds land in their OWN index, so a path one check named can never
|
package/src/ledger/ledger.ts
CHANGED
|
@@ -9,15 +9,17 @@
|
|
|
9
9
|
//
|
|
10
10
|
// 1. DEFAULT DENY. A check that declared nothing is never replayed and never
|
|
11
11
|
// stored. Forgetting to declare costs speed, never correctness, which is
|
|
12
|
-
// what makes the burn-down a to-do rather than a hazard.
|
|
12
|
+
// what makes the burn-down a to-do rather than a hazard. A task that
|
|
13
|
+
// declares nothing is keyed on the work tree instead (gate-checks.ts).
|
|
13
14
|
// 2. POST RUN RE HASH. Results are BUFFERED while the gate runs and written
|
|
14
15
|
// only after the inputs are hashed a second time. A check whose inputs
|
|
15
16
|
// moved between its run and that second hash answered about a tree that no
|
|
16
17
|
// longer exists, so its result is discarded rather than stored under a key
|
|
17
18
|
// that describes the file as it was BEFORE the edit. Three agents in one
|
|
18
19
|
// checkout is the normal case here, so that window is not theoretical.
|
|
19
|
-
// 3. THE GATE
|
|
20
|
-
// repo
|
|
20
|
+
// 3. THE GATE CODE IS IN THE KEY. Editing the shipkit code that runs a check
|
|
21
|
+
// or stores its answer moves that key in every repo, and an edit no answer
|
|
22
|
+
// depends on moves none (ledger/toolchain-identity.ts).
|
|
21
23
|
// 4. RELEASE NEVER READS IT. `bump` and `release` pass read:false, not
|
|
22
24
|
// configurably. Every remaining hole is then capped at "an agent saw a
|
|
23
25
|
// stale local green", never "a stale green authorised a publish".
|
|
@@ -41,7 +43,7 @@
|
|
|
41
43
|
import { join } from 'path'
|
|
42
44
|
import type { RvtConfig } from '../ts/config.ts'
|
|
43
45
|
import type { Line } from '../core/wire.ts'
|
|
44
|
-
import { KeyLayer, type CheckDecl, type CheckKey } from './git-key.ts'
|
|
46
|
+
import { classify, KeyLayer, type CheckDecl, type CheckKey } from './git-key.ts'
|
|
45
47
|
import { shipkitRoot } from './db.ts'
|
|
46
48
|
import { Answers, answersDb, FRESH_ENV, type AnswerRef, type AnswerWrite } from './store.ts'
|
|
47
49
|
import type { Problem } from '../problems/problem-line.ts'
|
|
@@ -81,8 +83,20 @@ export type CheckOutcome = {
|
|
|
81
83
|
measuredMs: number
|
|
82
84
|
}
|
|
83
85
|
|
|
84
|
-
/** what
|
|
85
|
-
|
|
86
|
+
/** what an answer is stored as: the verdict, what it read (READS), and the
|
|
87
|
+
* key components it was stored under, so a later miss can say what moved */
|
|
88
|
+
type Stored = CheckOutcome & { reads?: ReadSet; components?: string[] }
|
|
89
|
+
|
|
90
|
+
/** why a check did not come from the cache this run, one per check that ran
|
|
91
|
+
* or cannot be cached. The verdict prints them grouped, so a re-run is never
|
|
92
|
+
* unexplained */
|
|
93
|
+
export type RanWhy = {
|
|
94
|
+
/** `moved`: its key or a read changed. `first`: nothing stored for it.
|
|
95
|
+
* `not kept`: it ran and its answer cannot be stored. `never`: no key can
|
|
96
|
+
* describe it. `off`: this run reads no cache */
|
|
97
|
+
group: 'moved' | 'first' | 'not kept' | 'never' | 'off'
|
|
98
|
+
why: string
|
|
99
|
+
}
|
|
86
100
|
|
|
87
101
|
export type LedgerOpts = {
|
|
88
102
|
/** the cli entry driving this run (toolchain identity needs it) */
|
|
@@ -128,6 +142,8 @@ export class Ledger {
|
|
|
128
142
|
private misses = 0
|
|
129
143
|
private unkeyed = 0
|
|
130
144
|
private joined = 0
|
|
145
|
+
private readonly ranWhy = new Map<string, RanWhy>()
|
|
146
|
+
private fresh = false
|
|
131
147
|
|
|
132
148
|
private constructor(
|
|
133
149
|
readonly layer: KeyLayer,
|
|
@@ -144,7 +160,9 @@ export class Ledger {
|
|
|
144
160
|
const layer = KeyLayer.open(root, decls, { cliEntry: o.cliEntry, cfg: o.cfg, scope: o.scope })
|
|
145
161
|
const fresh = o.fresh === true || process.env[FRESH_ENV] != null
|
|
146
162
|
const reading = (o.read ?? ledgerReadEnabled()) && !fresh
|
|
147
|
-
|
|
163
|
+
const l = new Ledger(layer, new Answers(answersDb(root)), join(shipkitRoot(root), 'inflight'), reading, o.now ?? Date.now)
|
|
164
|
+
l.fresh = fresh
|
|
165
|
+
return l
|
|
148
166
|
}
|
|
149
167
|
|
|
150
168
|
/** the key for a check, computed once per run and remembered: `keyFor`
|
|
@@ -156,7 +174,7 @@ export class Ledger {
|
|
|
156
174
|
return k
|
|
157
175
|
}
|
|
158
176
|
|
|
159
|
-
private read(k: CheckKey): { payload: Stored } | null {
|
|
177
|
+
private read(k: CheckKey): { payload: Stored; at: number } | null {
|
|
160
178
|
return this.answers.read<Stored>(NS, k.id, k.key!)
|
|
161
179
|
}
|
|
162
180
|
|
|
@@ -168,16 +186,27 @@ export class Ledger {
|
|
|
168
186
|
|
|
169
187
|
/** the stored answer for this check, or null when it must run. Counts what
|
|
170
188
|
* it was asked, so the run can report hit/miss/uncacheable honestly. */
|
|
171
|
-
|
|
189
|
+
/** drop the stored answer for a check's CURRENT key: the cache audit found
|
|
190
|
+
* that a fresh run disagrees with it, and a fresh failure is never stored
|
|
191
|
+
* in its place, so the stale green would keep being served */
|
|
192
|
+
forget(id: string): void {
|
|
193
|
+
const k = this.key(id)
|
|
194
|
+
if (k.key != null) this.answers.drop(NS, k.id, k.key)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
replay(id: string): (CheckOutcome & { at: number }) | null {
|
|
172
198
|
const k = this.key(id)
|
|
173
|
-
if (k.key == null) { this.unkeyed++; return null }
|
|
174
|
-
if (!this.reading) { this.misses++; return null }
|
|
199
|
+
if (k.key == null) { this.unkeyed++; this.ranWhy.set(id, { group: 'never', why: this.neverWhy(k) }); return null }
|
|
200
|
+
if (!this.reading) { this.misses++; this.ranWhy.set(id, { group: 'off', why: this.fresh ? '--fresh' : 'cache reads off' }); return null }
|
|
175
201
|
const e = this.read(k)
|
|
176
|
-
if (e == null || !Array.isArray(e.payload.lines)) { this.misses++; return null }
|
|
177
|
-
if (k.population === 'reads'
|
|
202
|
+
if (e == null || !Array.isArray(e.payload.lines)) { this.misses++; this.ranWhy.set(id, this.movedWhy(k)); return null }
|
|
203
|
+
if (k.population === 'reads') {
|
|
204
|
+
const drift = e.payload.reads == null ? 'its read set was not stored' : this.driftOf(e.payload.reads, () => this.view())
|
|
205
|
+
if (drift != null) { this.misses++; this.ranWhy.set(id, { group: 'moved', why: drift }); return null }
|
|
206
|
+
}
|
|
178
207
|
this.hits++
|
|
179
208
|
this.replayed.push({ ns: NS, name: k.id, key: k.key })
|
|
180
|
-
return e.payload
|
|
209
|
+
return { ...e.payload, at: e.at }
|
|
181
210
|
}
|
|
182
211
|
|
|
183
212
|
/** the stored read set of a READS check, as the last store wrote it */
|
|
@@ -212,23 +241,70 @@ export class Ledger {
|
|
|
212
241
|
|
|
213
242
|
/** a check that missed the store and then adopted another run's in
|
|
214
243
|
* flight answer: counted apart, so the header never claims it measured */
|
|
215
|
-
noteAttached(): void {
|
|
244
|
+
noteAttached(id: string): void {
|
|
216
245
|
if (this.misses > 0) this.misses--
|
|
217
246
|
this.joined++
|
|
247
|
+
this.ranWhy.delete(id)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** the lines that say why every check that did not replay ran, one per
|
|
251
|
+
* reason, so a re-run is never unexplained. A run that reads no cache
|
|
252
|
+
* says so in its header instead. `quiet`: a check its author decided is
|
|
253
|
+
* never cached, which nobody acts on (--verbose prints it) */
|
|
254
|
+
explain(): { line: Line; quiet: boolean }[] {
|
|
255
|
+
const order: RanWhy['group'][] = ['moved', 'first', 'not kept', 'never']
|
|
256
|
+
const groups = new Map<string, { w: RanWhy; ids: string[] }>()
|
|
257
|
+
for (const [id, w] of this.ranWhy) {
|
|
258
|
+
if (w.group === 'off') continue
|
|
259
|
+
const k = `${order.indexOf(w.group)}\0${w.why}`
|
|
260
|
+
const g = groups.get(k) ?? { w, ids: [] }
|
|
261
|
+
g.ids.push(id)
|
|
262
|
+
groups.set(k, g)
|
|
263
|
+
}
|
|
264
|
+
return [...groups.entries()].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)).map(([, { w, ids }]) => {
|
|
265
|
+
const who = listShort(ids, 5)
|
|
266
|
+
const text = w.group === 'never' ? `never cached: ${w.why} (${who})`
|
|
267
|
+
: w.group === 'not kept' ? `ran, not kept: ${w.why} (${who})`
|
|
268
|
+
: `ran: ${w.why} (${who})`
|
|
269
|
+
return { line: { kind: 'ok' as const, text }, quiet: w.group === 'never' && w.why.startsWith('never: ') }
|
|
270
|
+
})
|
|
218
271
|
}
|
|
219
272
|
|
|
220
273
|
/** buffer what a check just measured. Nothing reaches the store until
|
|
221
274
|
* commit() has re-hashed the inputs (rule 2). A READS check with no read
|
|
222
275
|
* set (it reached the world) is never stored (rule 7). */
|
|
223
|
-
record(id: string, outcome: CheckOutcome, reads?: ReadSet | null): void {
|
|
276
|
+
record(id: string, outcome: CheckOutcome, reads?: ReadSet | null, notKept?: string | null): void {
|
|
224
277
|
const k = this.key(id)
|
|
225
278
|
if (k.key == null) return
|
|
226
279
|
if (k.population === 'reads') {
|
|
227
|
-
if (reads == null) return
|
|
228
|
-
this.buffered.set(id, { ...outcome, reads })
|
|
280
|
+
if (reads == null) { this.ranWhy.set(id, { group: 'not kept', why: notKept ?? 'it read outside what the cache can see' }); return }
|
|
281
|
+
this.buffered.set(id, { ...outcome, reads, components: k.components })
|
|
229
282
|
return
|
|
230
283
|
}
|
|
231
|
-
this.buffered.set(id, outcome)
|
|
284
|
+
this.buffered.set(id, { ...outcome, components: k.components })
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** why a check has no key: its own words when it decided `never`, the
|
|
288
|
+
* key layer's otherwise */
|
|
289
|
+
private neverWhy(k: CheckKey): string {
|
|
290
|
+
const d = this.layer.decls.find((x) => x.id === k.id)
|
|
291
|
+
return d != null && classify(d).decided === true ? `never: ${d.never}` : k.reason ?? 'undeclared'
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** what moved since this check's last stored answer, by diffing the key
|
|
295
|
+
* components it was stored under against this run's */
|
|
296
|
+
private movedWhy(k: CheckKey): RanWhy {
|
|
297
|
+
const prev = this.answers.latest<Stored>(NS, k.id)
|
|
298
|
+
if (prev == null) return { group: 'first', why: 'nothing stored for it yet' }
|
|
299
|
+
const before = prev.payload.components
|
|
300
|
+
if (before == null) return { group: 'moved', why: 'its key changed' }
|
|
301
|
+
const moved = movedParts(before, k.components)
|
|
302
|
+
return moved.length === 0 ? { group: 'first', why: 'nothing stored for this key yet' } : { group: 'moved', why: `${listShort(moved, 3)} changed` }
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** every check that did not come from the cache, and why */
|
|
306
|
+
get why(): ReadonlyMap<string, RanWhy> {
|
|
307
|
+
return this.ranWhy
|
|
232
308
|
}
|
|
233
309
|
|
|
234
310
|
/** re-hash, then write what still describes the tree on disk. */
|
|
@@ -295,6 +371,45 @@ export class Ledger {
|
|
|
295
371
|
}
|
|
296
372
|
}
|
|
297
373
|
|
|
374
|
+
/** the key components that differ between two keys of one check, in words */
|
|
375
|
+
export function movedParts(before: string[], after: string[]): string[] {
|
|
376
|
+
const split = (c: string): [string, string] => {
|
|
377
|
+
// several excluded paths share the name: each one is its own component
|
|
378
|
+
if (c.startsWith('excluded=')) return [c, '']
|
|
379
|
+
const i = c.indexOf('=')
|
|
380
|
+
return i < 0 ? [c, ''] : [c.slice(0, i), c.slice(i + 1)]
|
|
381
|
+
}
|
|
382
|
+
const a = new Map(before.map(split))
|
|
383
|
+
const b = new Map(after.map(split))
|
|
384
|
+
const names = [...new Set([...a.keys(), ...b.keys()])].filter((n) => a.get(n) !== b.get(n))
|
|
385
|
+
return [...new Set(names.map(componentWords))]
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** one key component's name as a reader says it */
|
|
389
|
+
function componentWords(name: string): string {
|
|
390
|
+
if (name === 'v') return "shipkit's key format"
|
|
391
|
+
if (name === 'bun') return 'the bun version'
|
|
392
|
+
if (name === 'frozen') return 'frozen mode'
|
|
393
|
+
if (name === 'toolchain') return "shipkit's gate code"
|
|
394
|
+
if (name === 'install') return 'node_modules'
|
|
395
|
+
if (name === 'argv') return 'its command'
|
|
396
|
+
if (name === 'lib') return 'its shipkit code'
|
|
397
|
+
if (name === 'libused') return 'the shipkit modules this repo imports'
|
|
398
|
+
if (name === 'extra') return 'its settings'
|
|
399
|
+
if (name === 'node') return 'the node version'
|
|
400
|
+
if (name.startsWith('names:')) return `the names under ${name.slice(6)}`
|
|
401
|
+
if (name.startsWith('env:')) return `$${name.slice(4)}`
|
|
402
|
+
if (name === 'in:.') return 'the work tree'
|
|
403
|
+
if (name.startsWith('in:') || name.startsWith('code:')) return name.slice(name.indexOf(':') + 1)
|
|
404
|
+
if (name.startsWith('excluded')) return 'ignored files under its inputs'
|
|
405
|
+
return name
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** `a, b, c`, and `a, b, +3 more` past `max` */
|
|
409
|
+
function listShort(items: string[], max: number): string {
|
|
410
|
+
return items.length <= max ? items.join(', ') : `${items.slice(0, max - 1).join(', ')}, +${items.length - max + 1} more`
|
|
411
|
+
}
|
|
412
|
+
|
|
298
413
|
/** where askers of one question meet while one of them measures it: a file
|
|
299
414
|
* name per (ns, id, key), hashed so any id is a safe name */
|
|
300
415
|
export function inflightSlot(dir: string, ns: string, id: string, key: string): string {
|
|
@@ -305,6 +420,6 @@ export function inflightSlot(dir: string, ns: string, id: string, key: string):
|
|
|
305
420
|
/** stamp a cached check's lines. The FIRST line carries the word, because
|
|
306
421
|
* that is the line an agent reads, and a cached answer that renders
|
|
307
422
|
* identically to one that ran is the one thing this design refuses to ship. */
|
|
308
|
-
export function stampCached(lines: Line[], measuredMs: number): Line[] {
|
|
309
|
-
return lines.map((l, i) => (i === 0 ? { ...l, text: `${l.text} ${cachedStamp(measuredMs)}` } : l))
|
|
423
|
+
export function stampCached(lines: Line[], measuredMs: number, at: number, now: number = Date.now()): Line[] {
|
|
424
|
+
return lines.map((l, i) => (i === 0 ? { ...l, text: `${l.text} ${cachedStamp(measuredMs, now - at)}` } : l))
|
|
310
425
|
}
|
package/src/ledger/store.ts
CHANGED
|
@@ -164,6 +164,20 @@ export class Answers {
|
|
|
164
164
|
})
|
|
165
165
|
}
|
|
166
166
|
|
|
167
|
+
/** the newest answer of one check under ANY key: what a miss diffs its
|
|
168
|
+
* own key against, to say what moved */
|
|
169
|
+
latest<T>(ns: string, name: string): Stored<T> | null {
|
|
170
|
+
const row = this.with(READ_BUSY_MS, (h) => h.query('select v, key, at, keeps, payload from answers where ns = ? and name = ? order by at desc limit 1').get(ns, name) as (Row & { key: string }) | null)
|
|
171
|
+
if (row == null) return null
|
|
172
|
+
try {
|
|
173
|
+
const payload = JSON.parse(row.payload) as T
|
|
174
|
+
if (Number(row.v) !== STORE_VERSION || typeof row.at !== 'number' || payload == null) return null
|
|
175
|
+
return { v: Number(row.v), key: row.key, at: row.at, keeps: row.keeps ?? undefined, payload }
|
|
176
|
+
} catch {
|
|
177
|
+
return null
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
167
181
|
drop(ns: string, name: string, key: string): void {
|
|
168
182
|
this.with(BUSY_MS, (h) => h.query('delete from answers where ns = ? and name = ? and key = ?').run(ns, name, key))
|
|
169
183
|
}
|