rvlib-shipkit 1.5.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/package.json +1 -1
  3. package/src/assets/gen/comfyts-gen.ts +12 -8
  4. package/src/assets/server/studio-server.ts +14 -2
  5. package/src/check.ts +5 -8
  6. package/src/checks/SK001-package-json-shape.check.ts +5 -4
  7. package/src/checks/SK003-claude-md-shape.check.ts +2 -2
  8. package/src/checks/SK030-cache-audit.check.ts +34 -0
  9. package/src/checks/SK031-trace-audit.check.ts +39 -0
  10. package/src/checks/SK032-ignored-inputs.check.ts +79 -0
  11. package/src/cli/cmd_Assets.ts +10 -6
  12. package/src/cli/cmd_CacheAudit.ts +43 -0
  13. package/src/cli/cmd_CrossRepoActivity.ts +2 -1
  14. package/src/cli/cmd_GlobalProcesses.ts +4 -3
  15. package/src/cli/cmd_Home.ts +3 -2
  16. package/src/cli/cmd_Keys.ts +12 -1
  17. package/src/cli/cmd_Overview.ts +1 -1
  18. package/src/cli/cmd_Serve.ts +2 -1
  19. package/src/cli/cmd_TraceAudit.ts +62 -0
  20. package/src/cli.ts +4 -0
  21. package/src/collections/collection-types.ts +3 -0
  22. package/src/collections/providers.ts +5 -3
  23. package/src/core/app-icon.ts +154 -0
  24. package/src/core/audit-report.ts +21 -0
  25. package/src/core/cache-audit.ts +72 -0
  26. package/src/core/canonical-scripts.ts +1 -1
  27. package/src/core/check-ctx.ts +14 -5
  28. package/src/core/check-runner.ts +6 -3
  29. package/src/core/floor-resolve.ts +5 -7
  30. package/src/core/git-root.ts +13 -0
  31. package/src/core/ignored-block.ts +94 -0
  32. package/src/core/ignored-inputs.ts +47 -0
  33. package/src/core/jobs.ts +9 -2
  34. package/src/core/one-lib.ts +59 -4
  35. package/src/core/read-set.ts +97 -9
  36. package/src/core/run-ci.ts +6 -1
  37. package/src/core/run-fixers.ts +8 -2
  38. package/src/core/trace-audit.ts +120 -0
  39. package/src/home/gui-app.ts +24 -27
  40. package/src/home/home-start.ts +11 -5
  41. package/src/home/open-app.ts +2 -2
  42. package/src/home/via-hub.ts +14 -0
  43. package/src/ledger/db.ts +2 -13
  44. package/src/ledger/gate-checks.ts +21 -3
  45. package/src/ledger/git-key.ts +148 -30
  46. package/src/ledger/git-scan.ts +5 -2
  47. package/src/ledger/ledger.ts +131 -16
  48. package/src/ledger/store.ts +14 -0
  49. package/src/ledger/toolchain-identity.ts +210 -25
  50. package/src/problems/extract.ts +3 -0
  51. package/src/repo-config.ts +18 -1
  52. package/src/serve/hub-app/admin-svc.ts +36 -3
  53. package/src/serve/hub-app/admin-view.tsx +33 -1
  54. package/src/serve/hub-app/col-card.tsx +1 -44
  55. package/src/serve/hub-app/col-tables-view.tsx +71 -5
  56. package/src/serve/hub-app/col-tables.ts +27 -0
  57. package/src/serve/hub-app/home-st.ts +1 -1
  58. package/src/serve/hub-app/home-strip.css +5 -9
  59. package/src/serve/hub-app/home-strip.tsx +4 -17
  60. package/src/serve/hub-app/hub-app.tsx +18 -4
  61. package/src/serve/hub-app/hub.css +24 -20
  62. package/src/serve/hub-app/keys.ts +10 -2
  63. package/src/serve/hub-app/processes-view.tsx +16 -9
  64. package/src/serve/hub-app/project-list.css +4 -1
  65. package/src/serve/hub-app/project-list.tsx +6 -3
  66. package/src/serve/hub-app/projects-svc.ts +10 -0
  67. package/src/serve/hub-app/repo-sections.ts +31 -17
  68. package/src/serve/hub-app/repo-st.ts +24 -5
  69. package/src/serve/hub-app/sidebar.tsx +5 -5
  70. package/src/serve/hub-app/tree-build.ts +2 -0
  71. package/src/serve/hub-app/tree-row.tsx +18 -5
  72. package/src/serve/hub-app/tree-svc.ts +27 -7
  73. package/src/serve/hub-runtime.ts +4 -2
  74. package/src/serve/project-gutter-route.ts +22 -1
  75. package/src/suite/judge-suites.ts +6 -0
  76. package/src/trace/interpose.c +655 -0
  77. package/src/trace/read-trace.ts +485 -0
  78. package/src/trace/tracer.ts +282 -0
@@ -0,0 +1,47 @@
1
+ // IGNORED INPUTS, the pure half: the kept audit's shape and what a task's key
2
+ // takes from it and from `repo.config.ts ignoredInputs`. No fs here, so a
3
+ // check keyed on its reads can import it (core/audit-report.ts reads the file).
4
+
5
+ export type AuditTask = {
6
+ id: string
7
+ /** `audited` when its trace had no hole, else why it could not be */
8
+ status: 'audited' | 'unaudited'
9
+ why?: string
10
+ /** ignored paths it read that its key does not cover */
11
+ uncovered: string[]
12
+ /** the fewest `ignoredInputs` entries that cover them */
13
+ propose: string[]
14
+ }
15
+
16
+ export type AuditReport = {
17
+ at: number
18
+ /** why nothing was traced on this machine, when nothing was */
19
+ skipped?: string
20
+ tasks: AuditTask[]
21
+ }
22
+
23
+ /** what the kept audit says this task reads outside its key */
24
+ export function auditedExtras(r: AuditReport | null, id: string): string[] {
25
+ if (r == null || r.skipped != null) return []
26
+ return r.tasks.find((x) => x.id === id)?.propose ?? []
27
+ }
28
+
29
+ /** the audit's entries that `have` does not carry yet, per task */
30
+ export function pendingByTask(r: AuditReport | null, have: IgnoredInputs): IgnoredInputs {
31
+ const out: IgnoredInputs = {}
32
+ if (r == null || r.skipped != null) return out
33
+ for (const t of r.tasks) {
34
+ const missing = (t.propose ?? []).filter((p) => !(have['*'] ?? []).includes(p) && !(have[t.id] ?? []).includes(p))
35
+ if (missing.length > 0) out[t.id] = missing
36
+ }
37
+ return out
38
+ }
39
+
40
+ /** `ignoredInputs`: task id → gitignored paths it reads, `'*'` for every task */
41
+ export type IgnoredInputs = Record<string, string[]>
42
+
43
+ /** the entries that join one task's key */
44
+ export const ignoredFor = (ii: IgnoredInputs | undefined, id: string): string[] => [...(ii?.['*'] ?? []), ...(ii?.[id] ?? [])]
45
+
46
+ /** `task: a, b; other: c`, for a line */
47
+ export const describeByTask = (by: IgnoredInputs): string => Object.entries(by).map(([t, ps]) => `${t}: ${ps.join(', ')}`).join('; ')
package/src/core/jobs.ts CHANGED
@@ -58,7 +58,14 @@ let seq = 0
58
58
  /** what a job the daemon starts carries beside its flags: the run's options
59
59
  * as the runner takes them, one lib copy for the repo config, and the
60
60
  * machine's one background turn for a run nobody waits on */
61
- export type JobOpts = { opts: Record<string, unknown>; oneLib?: boolean; turn?: boolean }
61
+ export type JobOpts = {
62
+ opts: Record<string, unknown>
63
+ oneLib?: boolean
64
+ turn?: boolean
65
+ /** the switches the asking terminal had set (home/via-hub.ts): the job
66
+ * starts from the daemon's env, which never saw them */
67
+ env?: Record<string, string>
68
+ }
62
69
 
63
70
  /** start `shipkit <kind> <args> --job <id>` detached from this process and
64
71
  * its terminal, and return at once */
@@ -69,7 +76,7 @@ export function startJob(root: string, o: { cli: string; kind: JobKind; args: st
69
76
  mkdirSync(dir, { recursive: true })
70
77
  if (o.job != null) atomicWrite(jobOpts(root, id), JSON.stringify(o.job))
71
78
  const out = openSync(join(dir, 'out.log'), 'a')
72
- const child = spawn(process.execPath, [o.cli, o.kind, ...o.args, '--job', id], { cwd: root, detached: true, stdio: ['ignore', out, out], env: process.env })
79
+ const child = spawn(process.execPath, [o.cli, o.kind, ...o.args, '--job', id], { cwd: root, detached: true, stdio: ['ignore', out, out], env: { ...process.env, ...o.job?.env } })
73
80
  closeSync(out)
74
81
  child.unref()
75
82
  const pid = child.pid ?? 0
@@ -2,11 +2,14 @@
2
2
  // (the home) makes every `rvlib-shipkit` import, from any repo config or check
3
3
  // file, land on its OWN copy. A repo's install is never loaded, so there is one
4
4
  // module instance per lib file: one mobx, one class identity, one version.
5
+ // The same holds for a package whose objects cross between a repo file and
6
+ // the toolkit (SHARED): a repo's brand draws on a canvas the toolkit made,
7
+ // and a native class only accepts instances of its own copy.
5
8
  //
6
9
  // the one constraint a reader breaks first: register it BEFORE the first repo
7
10
  // config is imported. A module already loaded keeps the copy it resolved.
8
- import { readFileSync, realpathSync } from 'fs'
9
- import { extname, join } from 'path'
11
+ import { existsSync, readFileSync, realpathSync } from 'fs'
12
+ import { dirname, extname, join } from 'path'
10
13
  import { LIB_ROOT } from './lib-root.ts'
11
14
 
12
15
  const NAME = 'rvlib-shipkit'
@@ -54,6 +57,46 @@ const real = (p: string): string => {
54
57
  }
55
58
  }
56
59
 
60
+ /** packages a repo file and the toolkit hand objects of to each other */
61
+ export const SHARED: readonly string[] = ['@napi-rs/canvas', 'comfy-ts']
62
+
63
+ /** a file of ANOTHER copy of a shared package, as the path inside that copy,
64
+ * else null. `host` is the root of the copy this process loads */
65
+ export function foreignSharedPath(path: string, name: string, host: string | null): string | null {
66
+ const at = path.lastIndexOf(`/node_modules/${name}/`)
67
+ if (at === -1) return null
68
+ const root = path.slice(0, at + `/node_modules/${name}`.length)
69
+ if (host == null || root === host || real(root) === real(host)) return null
70
+ return path.slice(root.length + 1)
71
+ }
72
+
73
+ const hostRoot = (name: string, from: string): string | null => {
74
+ try {
75
+ return dirname(Bun.resolveSync(`${name}/package.json`, from))
76
+ } catch {
77
+ return null
78
+ }
79
+ }
80
+
81
+ /** where a foreign copy's file lands in the host copy: its entry on the
82
+ * host's entry (the two can name different files), any other file on the
83
+ * same path, else on the entry */
84
+ const sharedTarget = (path: string, sub: string, name: string, host: string): string => {
85
+ const entry = Bun.resolveSync(name, host)
86
+ const foreignDir = path.slice(0, path.length - sub.length - 1)
87
+ try {
88
+ if (real(Bun.resolveSync(name, foreignDir)) === real(path)) return entry
89
+ } catch {
90
+ /* a foreign copy with no entry of its own: its files map by path */
91
+ }
92
+ return existsSync(join(host, sub)) ? join(host, sub) : entry
93
+ }
94
+
95
+ const reexport = (target: string): { contents: string; loader: 'ts' } => {
96
+ const t = JSON.stringify(target)
97
+ return { contents: `export * from ${t}\nimport * as own from ${t}\nexport default (own as { default?: unknown }).default\n`, loader: 'ts' }
98
+ }
99
+
57
100
  const LOADERS: Record<string, 'ts' | 'tsx' | 'js' | 'jsx'> = { '.ts': 'ts', '.tsx': 'tsx', '.js': 'js', '.mjs': 'js', '.jsx': 'jsx' }
58
101
 
59
102
  let installed = false
@@ -77,9 +120,21 @@ export function useOneLib(root: string = LIB_ROOT): void {
77
120
  const loader = LOADERS[extname(a.path)] ?? 'ts'
78
121
  const sub = foreignLibPath(a.path, root)
79
122
  if (sub == null) return { contents: readFileSync(a.path, 'utf8'), loader }
80
- const target = JSON.stringify(join(root, sub))
81
- return { contents: `export * from ${target}\nimport * as own from ${target}\nexport default (own as { default?: unknown }).default\n`, loader: 'ts' }
123
+ return reexport(join(root, sub))
82
124
  })
125
+ for (const name of SHARED) {
126
+ const host = hostRoot(name, root)
127
+ if (host == null) continue
128
+ // the host copy never matches: a CommonJS file read back here
129
+ // loses the named exports bun infers for it
130
+ const esc = (s: string): string => s.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')
131
+ const skip = [...new Set([host, real(host)])].map((h) => `(?!${esc(h)}/)`).join('')
132
+ build.onLoad({ filter: new RegExp(`^${skip}.*/node_modules/${esc(name)}/`) }, (a) => {
133
+ const sub = foreignSharedPath(a.path, name, host)
134
+ if (sub == null) return { contents: readFileSync(a.path, 'utf8'), loader: LOADERS[extname(a.path)] ?? 'js' }
135
+ return reexport(sharedTarget(a.path, sub, name, host))
136
+ })
137
+ }
83
138
  },
84
139
  })
85
140
  }
@@ -9,8 +9,8 @@
9
9
  // a millisecond later: the next run hashes the disk again, the two differ, and
10
10
  // the check runs. Nothing here can turn a mid-run edit into a replay.
11
11
  import { createHash } from 'crypto'
12
- import { lstatSync, readdirSync, readFileSync, statSync } from 'fs'
13
- import { join } from 'path'
12
+ import { lstatSync, readdirSync, readFileSync, readlinkSync, statSync } from 'fs'
13
+ import { isAbsolute, join } from 'path'
14
14
 
15
15
  /** `file` or `dir` when the path was there, null when it was not */
16
16
  export type PathKind = 'file' | 'dir' | null
@@ -29,6 +29,60 @@ export type ReadSet = {
29
29
  /** repo-relative path → whether a symlink stood there. Absent from a set
30
30
  * stored before it existed */
31
31
  links?: Record<string, boolean>
32
+ /** path → its stat signature (`statSig`). For a path in `files` it is a
33
+ * fast path: an unchanged signature skips the content hash. For an
34
+ * absolute path outside the repo it is the whole record: a traced task
35
+ * reads its runtime and its tools there, which are too big to hash on
36
+ * every replay and never shared between checkouts anyway */
37
+ sigs?: Record<string, string>
38
+ /** local port → `closed`: a traced task met a refusal there, and its
39
+ * answer holds only while nothing listens on it */
40
+ ports?: Record<string, 'closed'>
41
+ /** symlink → its target, null when no link stands there: what a traced
42
+ * task learned from readlink or lstat */
43
+ targets?: Record<string, string | null>
44
+ }
45
+
46
+ /** the target a symlink at `abs` points to, null when none stands there */
47
+ export function linkTarget(abs: string): string | null {
48
+ try {
49
+ return readlinkSync(abs)
50
+ } catch {
51
+ return null
52
+ }
53
+ }
54
+
55
+ let listenMemo: { at: number; ports: Set<string> } | null = null
56
+
57
+ /** the TCP ports something listens on right now, on any local address. One
58
+ * netstat per second at most: a replay asks for several at once */
59
+ export function listeningPorts(now = Date.now()): Set<string> {
60
+ if (listenMemo != null && now - listenMemo.at < 1_000) return listenMemo.ports
61
+ const ports = new Set<string>()
62
+ const r = Bun.spawnSync(['/usr/sbin/netstat', '-an', '-p', 'tcp'], { stdout: 'pipe', stderr: 'pipe' })
63
+ for (const line of r.stdout.toString().split('\n')) {
64
+ if (!/\bLISTEN\b/.test(line)) continue
65
+ const local = line.trim().split(/\s+/)[3] ?? ''
66
+ const port = local.slice(local.lastIndexOf('.') + 1)
67
+ if (/^\d+$/.test(port)) ports.add(port)
68
+ }
69
+ listenMemo = { at: now, ports }
70
+ return ports
71
+ }
72
+
73
+ /** a path as recorded: repo-relative, or absolute for one outside the repo */
74
+ const at = (root: string, p: string): string => (isAbsolute(p) ? p : join(root, p))
75
+
76
+ /** what stat says about a file: size, modification time to the nanosecond
77
+ * and inode, `-` when nothing is there. Content can change under an equal
78
+ * signature only when a tool restores the mtime on purpose */
79
+ export function statSig(abs: string): string {
80
+ try {
81
+ const s = statSync(abs, { bigint: true })
82
+ return `${s.size}:${s.mtimeNs}:${s.ino}`
83
+ } catch {
84
+ return '-'
85
+ }
32
86
  }
33
87
 
34
88
  /** one entry of a directory, as `c.entries` returns it */
@@ -103,6 +157,9 @@ export class Reads {
103
157
  readonly lists = new Map<string, string>()
104
158
  readonly dirs = new Map<string, string | null>()
105
159
  readonly links = new Map<string, boolean>()
160
+ readonly sigs = new Map<string, string>()
161
+ readonly ports = new Map<string, 'closed'>()
162
+ readonly targets = new Map<string, string | null>()
106
163
 
107
164
  file(path: string, oid: string | null): void { this.files.set(path, oid) }
108
165
  exist(path: string, kind: PathKind): void { this.exists.set(path, kind) }
@@ -116,11 +173,26 @@ export class Reads {
116
173
  for (const [k, v] of o.lists) this.lists.set(k, v)
117
174
  for (const [k, v] of o.dirs) this.dirs.set(k, v)
118
175
  for (const [k, v] of o.links) this.links.set(k, v)
176
+ for (const [k, v] of o.sigs) this.sigs.set(k, v)
177
+ for (const [k, v] of o.ports) this.ports.set(k, v)
178
+ for (const [k, v] of o.targets) this.targets.set(k, v)
179
+ }
180
+
181
+ /** a whole set, as a traced process read it (trace/read-trace.ts) */
182
+ mergeSet(s: ReadSet): void {
183
+ for (const [k, v] of Object.entries(s.files)) this.files.set(k, v)
184
+ for (const [k, v] of Object.entries(s.exists)) this.exists.set(k, v)
185
+ for (const [k, v] of Object.entries(s.lists)) this.lists.set(k, v)
186
+ for (const [k, v] of Object.entries(s.dirs ?? {})) this.dirs.set(k, v)
187
+ for (const [k, v] of Object.entries(s.links ?? {})) this.links.set(k, v)
188
+ for (const [k, v] of Object.entries(s.sigs ?? {})) this.sigs.set(k, v)
189
+ for (const [k, v] of Object.entries(s.ports ?? {})) this.ports.set(k, v)
190
+ for (const [k, v] of Object.entries(s.targets ?? {})) this.targets.set(k, v)
119
191
  }
120
192
 
121
193
  snapshot(): ReadSet {
122
194
  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) }
195
+ return { files: sorted(this.files), exists: sorted(this.exists), lists: sorted(this.lists), dirs: sorted(this.dirs), links: sorted(this.links), ...(this.sigs.size > 0 ? { sigs: sorted(this.sigs) } : {}), ...(this.ports.size > 0 ? { ports: sorted(this.ports) } : {}), ...(this.targets.size > 0 ? { targets: sorted(this.targets) } : {}) }
124
196
  }
125
197
  }
126
198
 
@@ -128,25 +200,41 @@ export class Reads {
128
200
  * does. `list` answers a glob the way `c.list` would right now, and returns
129
201
  * null when it cannot answer honestly (no git scan), which counts as drift. */
130
202
  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'}`
203
+ const sigs = set.sigs ?? {}
204
+ for (const [p, sig] of Object.entries(sigs)) {
205
+ if (p in set.files) continue
206
+ const now = statSig(at(root, p))
207
+ if (now !== sig) return `${p} ${sig === '-' ? 'appeared' : now === '-' ? 'is gone' : 'changed'}`
134
208
  }
135
209
  for (const [p, kind] of Object.entries(set.exists)) {
136
- const now = kindOf(join(root, p))
210
+ const now = kindOf(at(root, p))
137
211
  if (now !== kind) return `${p} ${kind == null ? 'appeared' : now == null ? 'is gone' : `is a ${now} now`}`
138
212
  }
213
+ for (const [p, oid] of Object.entries(set.files)) {
214
+ const abs = at(root, p)
215
+ if (sigs[p] != null && statSig(abs) === sigs[p]) continue
216
+ const now = oidOnDisk(abs)
217
+ if (now !== oid) return `${p} ${oid == null ? 'appeared' : now == null ? 'is gone' : 'changed'}`
218
+ }
139
219
  for (const [g, hash] of Object.entries(set.lists)) {
140
220
  const now = list(g)
141
221
  if (now == null) return `${g} cannot be listed without a git scan`
142
222
  if (listingHash(now) !== hash) return `the files matching ${g} changed`
143
223
  }
144
224
  for (const [d, hash] of Object.entries(set.dirs ?? {})) {
145
- const now = entriesHash(dirEntries(join(root, d)))
225
+ const now = entriesHash(dirEntries(at(root, d)))
146
226
  if (now !== hash) return `${d}/ ${hash == null ? 'appeared' : now == null ? 'is gone' : 'changed its entries'}`
147
227
  }
228
+ for (const [p, t] of Object.entries(set.targets ?? {})) {
229
+ if (linkTarget(at(root, p)) !== t) return `${p} points elsewhere now`
230
+ }
231
+ const closed = Object.keys(set.ports ?? {})
232
+ if (closed.length > 0) {
233
+ const open = listeningPorts()
234
+ for (const port of closed) if (open.has(port)) return `a service listens on local port ${port} now`
235
+ }
148
236
  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'}`
237
+ if (isLinkAt(at(root, p)) !== was) return `${p} ${was ? 'is no longer a link' : 'became a link'}`
150
238
  }
151
239
  return null
152
240
  }
@@ -284,7 +284,12 @@ export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
284
284
  // stored under a key that describes the file as it was before the edit.
285
285
  // the counts line folds into the verdict; what else it says (a store
286
286
  // that failed, answers thrown away) is printed as it is
287
- if (ledger != null) for (const [i, l] of ledger.header(ledger.commit()).entries()) (i === 0 ? detail : sink)(formatLine(l, false))
287
+ if (ledger != null) {
288
+ for (const [i, l] of ledger.header(ledger.commit()).entries()) (i === 0 ? detail : sink)(formatLine(l, false))
289
+ // why every check that did not replay ran, grouped: a re-run nobody
290
+ // can explain costs a reader more than the line does
291
+ for (const { line, quiet } of ledger.explain()) (quiet ? detail : sink)(formatLine(line, false))
292
+ }
288
293
  // one write for the whole gate, after everything has finished
289
294
  for (const l of runLog.flush()) sink(formatLine(l, false))
290
295
  const code = run.blocked || fixFailed || registrationFailed || advisoryFatal || run.undeclared.length > 0 || run.unknownOff.length > 0 || run.unknownBudgets.length > 0 ? 1 : 0
@@ -9,7 +9,7 @@
9
9
  // mechanism, two modes, rather than a formatter plus a format:check that
10
10
  // drift apart.
11
11
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
12
- import { dirname, join } from 'path'
12
+ import { dirname, isAbsolute, join } from 'path'
13
13
  import { fileURLToPath } from 'url'
14
14
  import { armHooks } from '../hooks/run-hooks.ts'
15
15
  import { loadRepoConfig, type RepoConfig } from '../repo-config.ts'
@@ -136,7 +136,13 @@ function fixMemo(root: string, tree: Map<string, string>, cmds: string[][]): str
136
136
  const h = new Bun.CryptoHasher('sha256')
137
137
  h.update(`head=${head.stdout.toString().trim()}\n`)
138
138
  for (const [path, hash] of [...tree].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) h.update(`${path}\0${hash}\n`)
139
- try { h.update(`toolchain=${toolchainIdentity(root, gateBinary()).id}\n`) } catch { return null }
139
+ // the runner, the install the fixer scripts run from, and the toolkit code
140
+ // this pass and its built-in fixers reach, never the toolkit as a whole
141
+ try {
142
+ const t = toolchainIdentity(root, gateBinary())
143
+ const own = [fileURLToPath(import.meta.url), ...cmds.flat().filter((a) => isAbsolute(a) && t.lib.owns(a))]
144
+ h.update(`toolchain=${t.id}:${t.install}:${t.lib.closureKey(own)}\n`)
145
+ } catch { return null }
140
146
  h.update(`cmds=${JSON.stringify(cmds)}\n`)
141
147
  return h.digest('hex')
142
148
  }
@@ -0,0 +1,120 @@
1
+ // THE TRACED AUDIT: which gitignored paths a task reads that its key cannot
2
+ // see. A task's key covers the tracked tree and the paths `ignoredInputs`
3
+ // names; a generated type or a local fixture it reads outside those is a
4
+ // stale green waiting for its first edit. Each gate task runs once under the
5
+ // tracer (trace/tracer.ts), and the ignored paths it touched that no key
6
+ // covers become that task's proposals: the keys read them at once
7
+ // (core/audit-report.ts), and `sk trace-audit --fix` writes them into their
8
+ // own managed block of repo.config.ts (core/ignored-block.ts).
9
+ //
10
+ // The tracer never decides a verdict here. A hole in the trace (a child that
11
+ // ran untraced) costs this audit that task, said out loud, and never a green.
12
+ //
13
+ // The tracing costs about one traced gate, so the answer is kept under the
14
+ // git common dir and reused while it is young: rule SK031 in
15
+ // the health pass pays for it, and the fix pass that applies it reads it.
16
+ import { mkdirSync, writeFileSync } from 'fs'
17
+ import { dirname, posix } from 'path'
18
+ import type { RepoConfig } from '../repo-config.ts'
19
+ import { composeChecks } from '../ledger/gate-checks.ts'
20
+ import { isNamesInput } from '../ledger/git-key.ts'
21
+ import { readTrace } from '../trace/read-trace.ts'
22
+ import { dropTrace, learnBlind, traceUnavailable, tracedCommand } from '../trace/tracer.ts'
23
+ import { spawnWithTimeout } from './spawn-timeout.ts'
24
+ import { gitBin } from './git-bin.ts'
25
+ import { auditReportPath, ignoredFor, pendingByTask, readAuditReport, type AuditReport, type AuditTask, type IgnoredInputs } from './audit-report.ts'
26
+ import { addIgnoredInputs } from './ignored-block.ts'
27
+ import { loadRepoConfig } from '../repo-config.ts'
28
+
29
+ /** how long an audit answers for the tree: about a day, like SK030 */
30
+ export const AUDIT_KEEP_MS = 20 * 60 * 60 * 1000
31
+
32
+ export type { AuditReport, AuditTask } from './audit-report.ts'
33
+ export { readAuditReport } from './audit-report.ts'
34
+
35
+ /** the paths of `paths` git ignores */
36
+ function gitIgnored(root: string, paths: string[]): Set<string> {
37
+ if (paths.length === 0) return new Set()
38
+ const p = Bun.spawnSync([gitBin(), 'check-ignore', '--stdin'], { cwd: root, stdin: Buffer.from(`${paths.join('\n')}\n`), stdout: 'pipe', stderr: 'pipe' })
39
+ return new Set(p.stdout.toString().split('\n').filter((l) => l !== ''))
40
+ }
41
+
42
+ const under = (p: string, dir: string): boolean => p === dir || p.startsWith(`${dir}/`)
43
+
44
+ /** is this ignored path already in a key: bytes under a content entry, or a
45
+ * listing or probe under a names entry */
46
+ function covered(p: string, listing: boolean, extras: string[]): boolean {
47
+ return extras.some((e) => (isNamesInput(e) ? listing && under(p, e.slice(0, -3)) : under(p, e)))
48
+ }
49
+
50
+ /** the fewest entries that cover what was read: each file by itself, or its
51
+ * dir once more than three files there were read; each listed dir as
52
+ * `dir/**`, the outermost one only */
53
+ export function proposeInputs(content: string[], listed: string[]): string[] {
54
+ const byDir = new Map<string, string[]>()
55
+ for (const f of content) byDir.set(posix.dirname(f), [...(byDir.get(posix.dirname(f)) ?? []), f])
56
+ const bytes = [...byDir].flatMap(([dir, files]) => (files.length > 3 && dir !== '.' ? [dir] : files))
57
+ const minimal = (xs: string[]): string[] => {
58
+ const sorted = [...new Set(xs)].sort()
59
+ return sorted.filter((x) => !sorted.some((y) => y !== x && under(x, y)))
60
+ }
61
+ return [...minimal(bytes), ...minimal(listed).map((d) => `${d}/**`)].sort()
62
+ }
63
+
64
+ /** trace every gate task of the repo once and report what its key misses */
65
+ export async function traceAudit(root: string, config: RepoConfig, o: { fresh?: boolean; now?: number; env?: Record<string, string | undefined> } = {}): Promise<AuditReport> {
66
+ const now = o.now ?? Date.now()
67
+ const kept = readAuditReport(root)
68
+ if (o.fresh !== true && kept != null && kept.skipped == null && now - kept.at < AUDIT_KEEP_MS) return kept
69
+ const env = o.env ?? process.env
70
+ const off = traceUnavailable(env)
71
+ if (off != null) return { at: now, skipped: off, tasks: [] }
72
+ const tasks: AuditTask[] = []
73
+ for (const g of await composeChecks(root, config)) {
74
+ const u = g.runnable
75
+ if (u.body.cmd == null || g.row.runs.off != null || !g.row.runs.onCi || g.row.decl.inputs == null) continue
76
+ const cwd = u.body.cwd ?? root
77
+ const t = tracedCommand(u.body.cmd, cwd, env)
78
+ if ('reason' in t) { tasks.push({ id: u.id, status: 'unaudited', why: t.reason, uncovered: [], propose: [] }); continue }
79
+ try {
80
+ await spawnWithTimeout(t.cmd, { timeoutMs: u.timeoutMs, env: { ...process.env, ...u.body.env, ...t.env }, cwd })
81
+ const r = await readTrace(t.dir, root)
82
+ if (r.reads == null) {
83
+ const learned = r.blind.length > 0 ? learnBlind(r.blind) : null
84
+ tasks.push({ id: u.id, status: 'unaudited', why: [r.why, learned].filter((x) => x != null).join('; '), uncovered: [], propose: [] })
85
+ continue
86
+ }
87
+ // bytes: a file it opened or stat'ed, or found by a probe; a
88
+ // listing: a dir it read the entries of, or found by a probe
89
+ // Finder rewrites .DS_Store at will, and no tool's verdict reads it
90
+ const rel = (p: string): boolean => !p.startsWith('/') && p !== '.' && !p.split('/').includes('node_modules') && !p.startsWith('.git/') && posix.basename(p) !== '.DS_Store'
91
+ const files = [...new Set([...Object.keys(r.reads.files), ...Object.keys(r.reads.sigs ?? {}), ...Object.entries(r.reads.exists).filter(([, k]) => k === 'file').map(([p]) => p)])].filter(rel)
92
+ const dirs = [...new Set([...Object.keys(r.reads.dirs ?? {}), ...Object.entries(r.reads.exists).filter(([, k]) => k === 'dir').map(([p]) => p)])].filter(rel)
93
+ const ignored = gitIgnored(root, [...files, ...dirs])
94
+ const extras = ignoredFor(config.ignoredInputs, u.id)
95
+ const missF = files.filter((p) => ignored.has(p) && !covered(p, false, extras))
96
+ const missD = dirs.filter((p) => ignored.has(p) && !covered(p, true, extras))
97
+ tasks.push({ id: u.id, status: 'audited', uncovered: [...missF, ...missD].sort(), propose: proposeInputs(missF, missD) })
98
+ } finally {
99
+ dropTrace(t.dir)
100
+ }
101
+ }
102
+ const report: AuditReport = { at: now, tasks }
103
+ try {
104
+ mkdirSync(dirname(auditReportPath(root)), { recursive: true })
105
+ writeFileSync(auditReportPath(root), JSON.stringify(report, null, 2))
106
+ } catch { /* an audit that cannot be kept is measured again next time */ }
107
+ return report
108
+ }
109
+
110
+ /** write what the kept audit proposes into the managed block. Nothing is
111
+ * traced here, so the fix pass can call it on every run */
112
+ export async function applyAudit(root: string): Promise<{ added: IgnoredInputs; note: string | null }> {
113
+ const r = readAuditReport(root)
114
+ if (r == null || r.skipped != null) return { added: {}, note: r?.skipped ?? 'no audit yet: sk trace-audit runs one' }
115
+ const added = pendingByTask(r, (await loadRepoConfig(root, { fresh: true })).ignoredInputs ?? {})
116
+ if (Object.keys(added).length === 0) return { added, note: null }
117
+ await addIgnoredInputs(root, added)
118
+ return { added, note: null }
119
+ }
120
+
@@ -4,12 +4,15 @@
4
4
  // and iconed after it, in its repo's `.shipkit/wrapper/`. The bundle's
5
5
  // Info.plist carries what differs (name, bundle id, start url), so a fix to
6
6
  // the window (find, the inspector, zoom) lands in every app at its next
7
- // build. Rebuilt only when the source, the icon or the start url changed (a
8
- // stamp in the bundle). No compiler, or not macOS: the caller falls back.
7
+ // build. The icon is core/app-icon.ts: the repo's mark, its margin cut, on
8
+ // a plate in the repo's colour. Rebuilt only when the source, the icon, the
9
+ // colours or the start url changed (a stamp in the bundle). No compiler, or not macOS: the caller falls back.
9
10
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs'
10
11
  import { homedir } from 'os'
11
12
  import { dirname, join } from 'path'
12
13
  import { fileURLToPath } from 'url'
14
+ import { appIconOf, appIconPngs, identityOfRoot } from '../core/app-icon.ts'
15
+ import { identityOfName, type Identity } from '../core/identity-color.ts'
13
16
  import { LAYOUT } from '../core/layout.ts'
14
17
 
15
18
  const SOURCE = fileURLToPath(new URL('./gui-app/ShipkitGui.swift', import.meta.url))
@@ -17,15 +20,6 @@ const SOURCE = fileURLToPath(new URL('./gui-app/ShipkitGui.swift', import.meta.u
17
20
  export const BUNDLE_ID = 'dev.shipkit.gui'
18
21
  export const guiAppPath = (): string => process.env.SHIPKIT_GUI_APP ?? join(homedir(), 'Applications', 'Shipkit.app')
19
22
 
20
- /** the icon the app wears: the repo's own mark, largest first. Only a
21
- * png can become an icns */
22
- export const APP_ICON_FILES = [`${LAYOUT.identity}/icon.png`, 'public/logo.png', 'public/favicon.png']
23
-
24
- export function appIconOf(root: string): string | null {
25
- for (const f of APP_ICON_FILES) if (existsSync(join(root, f))) return join(root, f)
26
- return null
27
- }
28
-
29
23
  /** what one app bundle is: the rest comes from the shared source */
30
24
  export type AppSpec = {
31
25
  /** CFBundleName: the dock, cmd-tab and the app menu say it */
@@ -34,6 +28,8 @@ export type AppSpec = {
34
28
  /** the page a launch with no url opens; null opens the home */
35
29
  url: string | null
36
30
  icon: string | null
31
+ /** the repo's colours: the plate behind the icon's mark */
32
+ identity: Identity
37
33
  /** where the bundle lands */
38
34
  app: string
39
35
  version: string
@@ -52,7 +48,7 @@ function atsOf(url: string | null): string {
52
48
  return `<dict><key>NSAllowsLocalNetworking</key><true/>${exception}</dict>`
53
49
  }
54
50
 
55
- export function infoPlist(o: Omit<AppSpec, 'app' | 'icon'>): string {
51
+ export function infoPlist(o: Omit<AppSpec, 'app' | 'icon' | 'identity'>): string {
56
52
  return `<?xml version="1.0" encoding="UTF-8"?>
57
53
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
58
54
  <plist version="1.0">
@@ -79,6 +75,9 @@ const runSync: Run = (cmd) => {
79
75
  return { code: p.exitCode ?? 1, err: p.stderr.toString().trim() }
80
76
  }
81
77
 
78
+ /** bumped when the icon drawing changes, so every built app redraws its icon */
79
+ const ICON_DRAWING = 'plate-1'
80
+
82
81
  /** the stamp a built bundle carries: what it was built from. The version is
83
82
  * left out, so a shipkit release rebuilds nothing */
84
83
  export function stampOf(source: string, icon: string | null, identity = ''): string {
@@ -89,11 +88,11 @@ export function stampOf(source: string, icon: string | null, identity = ''): str
89
88
 
90
89
  /** one app, built or rebuilt when stale. Throws with the step that failed.
91
90
  * `rebuilt` tells the caller a running copy is now the old one */
92
- export function ensureApp(spec: AppSpec, o: { run?: Run; source?: string } = {}): { app: string; rebuilt: boolean } {
91
+ export async function ensureApp(spec: AppSpec, o: { run?: Run; source?: string } = {}): Promise<{ app: string; rebuilt: boolean }> {
93
92
  const { app, icon } = spec
94
93
  const run = o.run ?? runSync
95
94
  const source = o.source ?? SOURCE
96
- const stamp = stampOf(readFileSync(source, 'utf8'), icon, JSON.stringify([spec.name, spec.bundleId, spec.url]))
95
+ const stamp = stampOf(readFileSync(source, 'utf8'), icon, JSON.stringify([spec.name, spec.bundleId, spec.url, spec.identity, ICON_DRAWING]))
97
96
  const stampFile = join(app, 'Contents', 'Resources', 'stamp')
98
97
  if (existsSync(stampFile) && readFileSync(stampFile, 'utf8') === stamp) return { app, rebuilt: false }
99
98
  const tmp = `${app}.building-${process.pid}`
@@ -108,12 +107,10 @@ export function ensureApp(spec: AppSpec, o: { run?: Run; source?: string } = {})
108
107
  if (icon != null) {
109
108
  const set = join(res, 'AppIcon.iconset')
110
109
  mkdirSync(set, { recursive: true })
111
- for (const n of [16, 32, 128, 256, 512]) {
112
- for (const [scale, px] of [['', n], ['@2x', n * 2]] as const) {
113
- const r = run(['sips', '-s', 'format', 'png', '-z', String(px), String(px), icon, '--out', join(set, `icon_${n}x${n}${scale}.png`)])
114
- if (r.code !== 0) fail(`sips could not size the icon ${icon}: ${r.err.slice(0, 200)}`)
115
- }
116
- }
110
+ const files = [16, 32, 128, 256, 512].flatMap((n) => [[`icon_${n}x${n}.png`, n], [`icon_${n}x${n}@2x.png`, n * 2]] as const)
111
+ const pngs = await appIconPngs(readFileSync(icon), spec.identity, files.map(([, px]) => px))
112
+ .catch((e: Error) => fail(`could not draw the icon from ${icon}: ${e.message}`))
113
+ files.forEach(([file], i) => writeFileSync(join(set, file), pngs[i]!))
117
114
  const r = run(['iconutil', '-c', 'icns', set, '-o', join(res, 'AppIcon.icns')])
118
115
  rmSync(set, { recursive: true, force: true })
119
116
  if (r.code !== 0) fail(`iconutil could not build the icon: ${r.err.slice(0, 200)}`)
@@ -128,15 +125,15 @@ export function ensureApp(spec: AppSpec, o: { run?: Run; source?: string } = {})
128
125
  }
129
126
 
130
127
  /** the shipkit app: the shared source on the home, the house repo's icon */
131
- export function ensureGuiApp(o: { root: string | null; version: string; app?: string; run?: Run }): { app: string; rebuilt: boolean } {
132
- return ensureApp({ name: 'shipkit', bundleId: BUNDLE_ID, url: null, icon: o.root == null ? null : appIconOf(o.root), app: o.app ?? guiAppPath(), version: o.version }, { run: o.run })
128
+ export function ensureGuiApp(o: { root: string | null; version: string; app?: string; run?: Run }): Promise<{ app: string; rebuilt: boolean }> {
129
+ return ensureApp({ name: 'shipkit', bundleId: BUNDLE_ID, url: null, icon: o.root == null ? null : appIconOf(o.root), identity: o.root == null ? identityOfName('shipkit') : identityOfRoot(o.root), app: o.app ?? guiAppPath(), version: o.version }, { run: o.run })
133
130
  }
134
131
 
135
132
  /** what a global process gives its app */
136
133
  export type WrapperOf = { id: string; label: string; url: string | null; icon: string | null; root: string }
137
134
 
138
- /** the image icons sips reads; an emoji or an svg leaves the repo's mark */
139
- const RASTER = /\.(png|jpe?g|gif|webp|bmp|tiff?)$/i
135
+ /** the images the icon renderer reads; an emoji or an svg leaves the repo's mark */
136
+ const RASTER = /\.(png|jpe?g|gif|webp)$/i
140
137
 
141
138
  export const wrapperBundleId = (id: string): string => `dev.shipkit.app.${id.replace(/[^A-Za-z0-9-]+/g, '-')}`
142
139
  export const wrapperPath = (root: string, id: string): string => join(root, LAYOUT.wrapper, `${id.replace(/[^A-Za-z0-9._-]+/g, '-')}.app`)
@@ -147,12 +144,12 @@ export const wrapperPath = (root: string, id: string): string => join(root, LAYO
147
144
  export function wrapperSpec(d: WrapperOf, version: string): AppSpec {
148
145
  if (d.url == null) throw new Error(`${d.id} declares no url: there is no page to open as an app`)
149
146
  const declared = d.icon != null && RASTER.test(d.icon) && existsSync(join(d.root, d.icon)) ? join(d.root, d.icon) : null
150
- return { name: d.label, bundleId: wrapperBundleId(d.id), url: d.url, icon: declared ?? appIconOf(d.root), app: wrapperPath(d.root, d.id), version }
147
+ return { name: d.label, bundleId: wrapperBundleId(d.id), url: d.url, icon: declared ?? appIconOf(d.root), identity: identityOfRoot(d.root), app: wrapperPath(d.root, d.id), version }
151
148
  }
152
149
 
153
- export const ensureWrapperApp = (d: WrapperOf, o: { version: string; run?: Run }): { app: string; rebuilt: boolean; bundleId: string } => {
150
+ export const ensureWrapperApp = async (d: WrapperOf, o: { version: string; run?: Run }): Promise<{ app: string; rebuilt: boolean; bundleId: string }> => {
154
151
  const spec = wrapperSpec(d, o.version)
155
- return { ...ensureApp(spec, { run: o.run }), bundleId: spec.bundleId }
152
+ return { ...(await ensureApp(spec, { run: o.run })), bundleId: spec.bundleId }
156
153
  }
157
154
 
158
155
  /** quits a running copy of an app, and launches nothing when none runs */