rvlib-shipkit 1.5.0 → 1.7.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 (94) hide show
  1. package/CHANGELOG.md +54 -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_Insights.ts +78 -0
  17. package/src/cli/cmd_Keys.ts +12 -1
  18. package/src/cli/cmd_Overview.ts +1 -1
  19. package/src/cli/cmd_Serve.ts +2 -1
  20. package/src/cli/cmd_TraceAudit.ts +62 -0
  21. package/src/cli.ts +6 -0
  22. package/src/collections/collection-types.ts +3 -0
  23. package/src/collections/providers.ts +5 -3
  24. package/src/core/app-icon.ts +154 -0
  25. package/src/core/audit-report.ts +21 -0
  26. package/src/core/cache-audit.ts +72 -0
  27. package/src/core/canonical-scripts.ts +1 -1
  28. package/src/core/check-ctx.ts +14 -5
  29. package/src/core/check-runner.ts +6 -3
  30. package/src/core/floor-resolve.ts +5 -7
  31. package/src/core/git-root.ts +13 -0
  32. package/src/core/ignored-block.ts +94 -0
  33. package/src/core/ignored-inputs.ts +47 -0
  34. package/src/core/jobs.ts +9 -2
  35. package/src/core/one-lib.ts +59 -4
  36. package/src/core/read-set.ts +97 -9
  37. package/src/core/run-ci.ts +6 -1
  38. package/src/core/run-fixers.ts +8 -2
  39. package/src/core/trace-audit.ts +129 -0
  40. package/src/home/gui-app/ShipkitGui.swift +125 -41
  41. package/src/home/gui-app.ts +24 -27
  42. package/src/home/home-routes.ts +15 -0
  43. package/src/home/home-start.ts +11 -5
  44. package/src/home/insights-shape.ts +124 -0
  45. package/src/home/insights.ts +90 -0
  46. package/src/home/open-app.ts +2 -2
  47. package/src/home/serve-web.ts +3 -3
  48. package/src/home/via-hub.ts +14 -0
  49. package/src/home/widgets.ts +12 -2
  50. package/src/ledger/db.ts +2 -13
  51. package/src/ledger/gate-checks.ts +21 -3
  52. package/src/ledger/git-key.ts +148 -30
  53. package/src/ledger/git-scan.ts +5 -2
  54. package/src/ledger/ledger.ts +131 -16
  55. package/src/ledger/store.ts +14 -0
  56. package/src/ledger/toolchain-identity.ts +210 -25
  57. package/src/problems/extract.ts +3 -0
  58. package/src/repo-config.ts +18 -1
  59. package/src/serve/hub-app/admin-svc.ts +36 -3
  60. package/src/serve/hub-app/admin-view.tsx +33 -1
  61. package/src/serve/hub-app/col-card.tsx +1 -44
  62. package/src/serve/hub-app/col-tables-view.tsx +71 -5
  63. package/src/serve/hub-app/col-tables.ts +27 -0
  64. package/src/serve/hub-app/home-st.ts +13 -6
  65. package/src/serve/hub-app/home-strip.css +7 -9
  66. package/src/serve/hub-app/home-strip.tsx +8 -17
  67. package/src/serve/hub-app/home.html +1 -0
  68. package/src/serve/hub-app/hub-app.tsx +20 -4
  69. package/src/serve/hub-app/hub.css +24 -20
  70. package/src/serve/hub-app/icons.tsx +1 -0
  71. package/src/serve/hub-app/index.html +1 -0
  72. package/src/serve/hub-app/insights-st.ts +111 -0
  73. package/src/serve/hub-app/insights-view.tsx +112 -0
  74. package/src/serve/hub-app/insights.css +24 -0
  75. package/src/serve/hub-app/keys.ts +11 -3
  76. package/src/serve/hub-app/machine-st.ts +23 -0
  77. package/src/serve/hub-app/palette-st.ts +2 -1
  78. package/src/serve/hub-app/processes-view.tsx +16 -9
  79. package/src/serve/hub-app/project-list.css +4 -1
  80. package/src/serve/hub-app/project-list.tsx +9 -5
  81. package/src/serve/hub-app/projects-svc.ts +11 -1
  82. package/src/serve/hub-app/repo-sections.ts +31 -17
  83. package/src/serve/hub-app/repo-st.ts +25 -6
  84. package/src/serve/hub-app/sidebar.tsx +5 -5
  85. package/src/serve/hub-app/tree-build.ts +2 -0
  86. package/src/serve/hub-app/tree-row.tsx +18 -5
  87. package/src/serve/hub-app/tree-svc.ts +27 -7
  88. package/src/serve/hub-runtime.ts +4 -2
  89. package/src/serve/project-gutter-route.ts +22 -1
  90. package/src/shipkit-files.ts +3 -2
  91. package/src/suite/judge-suites.ts +6 -0
  92. package/src/trace/interpose.c +655 -0
  93. package/src/trace/read-trace.ts +485 -0
  94. package/src/trace/tracer.ts +282 -0
@@ -201,9 +201,9 @@ function builtinFloorTask(id: string, root: string, io: FloorReader): { task: Ta
201
201
  return `no package.json '${id}' script`
202
202
  }
203
203
 
204
- // NOTHING here declares a read set on a repo's behalf, and that is a decision
205
- // rather than an omission. A floor slot with no `inputs` is measured every run,
206
- // which costs speed and never correctness (ledger/git-key.ts owns that default).
204
+ // NOTHING here declares a read set on a repo's behalf. The composer keys a
205
+ // floor slot with no `inputs` on the whole work tree plus the repo's
206
+ // `ignoredInputs` (ledger/gate-checks.ts), and the traced audit fills those.
207
207
  //
208
208
  // The tempting shortcut is `['.']`, on the reasoning that the whole tree cannot
209
209
  // under declare. It can. `['.']` is the TRACKED tree: git-scan force-adds only a
@@ -214,10 +214,8 @@ function builtinFloorTask(id: string, root: string, io: FloorReader): { task: Ta
214
214
  // gate print `ok: test (replayed)` over a suite that exits 1
215
215
  // (tests/floor-cache-safety.test.ts holds the repro).
216
216
  //
217
- // Speed here is bought by a REPO saying what its own tasks read, in
218
- // `defaults`, which is the only declaration with a real hit rate anyway.
219
- // `shipkit init` scaffolds the line, `shipkit keys --burn-down` names what is
220
- // still undeclared, and the gate prints the cost of it once per run.
217
+ // A narrower key is bought by a REPO saying what its own tasks read, in
218
+ // `defaults`.
221
219
 
222
220
  export function resolveFloor(c: RepoConfig, root: string, io: FloorReader): FloorResolution {
223
221
  const pkg = io.text('package.json')
@@ -8,6 +8,7 @@
8
8
  import { existsSync, lstatSync, readFileSync, realpathSync, statSync } from 'fs'
9
9
  import { dirname, join, resolve } from 'path'
10
10
  import { gitBin } from './git-bin.ts'
11
+ import { LAYOUT } from './layout.ts'
11
12
 
12
13
  export type GitRoot = {
13
14
  /** absolute git dir of this work tree (per worktree) */
@@ -130,3 +131,15 @@ export const gitRoot = (dir: string): GitRoot | null => probeGitRoot(dir).root
130
131
  export function isLinkedWorktree(root: string): boolean {
131
132
  try { return statSync(join(root, '.git')).isFile() } catch { return false }
132
133
  }
134
+
135
+ /** the one dir everything this repo remembers hangs off.
136
+ *
137
+ * `--git-common-dir` rather than `--git-dir`: a linked worktree has its own
138
+ * git dir but shares the common one, and that sharing is safe because every
139
+ * key component is content or a version string. A tree that is not a git
140
+ * repo at all still gets one, under `.shipkit/cache/`, because a scaffold in
141
+ * progress must not crash on it. */
142
+ export function shipkitRoot(root: string): string {
143
+ const dir = gitRoot(root)?.commonDir
144
+ return dir == null ? join(root, LAYOUT.cache) : join(dir, 'shipkit')
145
+ }
@@ -0,0 +1,94 @@
1
+ // THE IGNORED INPUTS BLOCK in `repo.config.ts`: the one place the traced
2
+ // audit (core/trace-audit.ts) writes the gitignored paths each task reads.
3
+ //
4
+ // Its own markers, apart from the gate block (core/when-block.ts), so a repo
5
+ // that writes `off` or `tests` by hand, with its comments, keeps them exactly
6
+ // as they are. The same paranoia as the gate block:
7
+ // 1. `ignoredInputs` declared OUTSIDE the markers is refused, because a
8
+ // second declaration is a duplicate key rather than a merge
9
+ // 2. a block that no longer renders what the config loads was hand edited,
10
+ // and is refused rather than overwritten
11
+ // 3. the result is read back through the loader the cli uses, and the
12
+ // previous bytes go back when it does not round trip
13
+ import { existsSync, readFileSync } from 'fs'
14
+ import { join } from 'path'
15
+ import { atomicWrite } from './locked-write.ts'
16
+ import { loadRepoConfig } from '../repo-config.ts'
17
+ import type { IgnoredInputs } from './audit-report.ts'
18
+
19
+ export const IGNORED_BEGIN = '// >>> shipkit ignored inputs — managed block, the traced audit writes it, edit outside the markers'
20
+ export const IGNORED_END = '// <<< shipkit ignored inputs'
21
+
22
+ const isBegin = (l: string): boolean => l.trim().startsWith('// >>> shipkit ignored inputs')
23
+ const isEnd = (l: string): boolean => l.trim().startsWith('// <<< shipkit ignored inputs')
24
+ const q = (s: string): string => `'${s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`
25
+ const key = (s: string): string => (/^[A-Za-z_$][\w$]*$/.test(s) ? s : q(s))
26
+
27
+ /** the same map, tasks and paths sorted, empty tasks dropped */
28
+ export function normalizeIgnored(ii: IgnoredInputs): IgnoredInputs {
29
+ const out: IgnoredInputs = {}
30
+ for (const t of Object.keys(ii).sort()) if ((ii[t] ?? []).length > 0) out[t] = [...new Set(ii[t])].sort()
31
+ return out
32
+ }
33
+
34
+ /** the bytes between the markers, markers included */
35
+ export function renderIgnoredBlock(ii: IgnoredInputs, pad: string): string[] {
36
+ const n = normalizeIgnored(ii)
37
+ return [
38
+ `${pad}${IGNORED_BEGIN}`,
39
+ `${pad}ignoredInputs: {`,
40
+ ...Object.entries(n).flatMap(([t, ps]) => [`${pad}${pad}${key(t)}: [`, ...ps.map((p) => `${pad}${pad}${pad}${q(p)},`), `${pad}${pad}],`]),
41
+ `${pad}},`,
42
+ `${pad}${IGNORED_END}`,
43
+ ]
44
+ }
45
+
46
+ function indentOf(lines: string[]): string {
47
+ for (const l of lines) {
48
+ const m = /^([ \t]+)\S/.exec(l)
49
+ if (m != null) return m[1]!
50
+ }
51
+ return ' '
52
+ }
53
+
54
+ export const validIgnoredInput = (p: unknown): p is string => typeof p === 'string' && p !== '' && !p.startsWith('/') && !p.split('/').includes('..')
55
+
56
+ /** add `add` (task id → paths) to the repo's ignoredInputs, through the
57
+ * managed block; the map after the write */
58
+ export async function addIgnoredInputs(root: string, add: IgnoredInputs): Promise<IgnoredInputs> {
59
+ const file = join(root, 'repo.config.ts')
60
+ if (!existsSync(file)) throw new Error(`no repo.config.ts in ${root} — run: shipkit init`)
61
+ for (const p of Object.values(add).flat()) if (!validIgnoredInput(p)) throw new Error(`ignoredInputs '${String(p)}' must be a repo-relative path inside the repo`)
62
+ const text = readFileSync(file, 'utf8')
63
+ const lines = text.split('\n')
64
+ const c = await loadRepoConfig(root, { fresh: true })
65
+ const have = c.ignoredInputs ?? {}
66
+ const b = lines.findIndex(isBegin)
67
+ const e = lines.findIndex(isEnd)
68
+ if (b === -1 || e === -1 || e < b) {
69
+ if (Object.keys(have).length > 0) throw new Error('repo.config.ts declares `ignoredInputs` outside the managed block — move it between the markers first, or the audit would write a duplicate key')
70
+ } else {
71
+ const pad = /^([ \t]*)/.exec(lines[b]!)?.[1] ?? ''
72
+ if (lines.slice(b, e + 1).join('\n') !== renderIgnoredBlock(have, pad).join('\n'))
73
+ throw new Error('the shipkit ignored inputs block in repo.config.ts no longer matches what the config loads, so it was hand edited — reconcile it by hand, then run the audit fix again')
74
+ }
75
+ const merged: IgnoredInputs = { ...have }
76
+ for (const [t, ps] of Object.entries(add)) merged[t] = [...(merged[t] ?? []), ...ps]
77
+ const next = normalizeIgnored(merged)
78
+ let out: string[]
79
+ if (b !== -1 && e > b) out = [...lines.slice(0, b), ...renderIgnoredBlock(next, /^([ \t]*)/.exec(lines[b]!)?.[1] ?? ''), ...lines.slice(e + 1)]
80
+ else {
81
+ const anchor = lines.findIndex((l) => /defineRepo\(\s*\{\s*$/.test(l))
82
+ if (anchor === -1) throw new Error(`repo.config.ts has no \`defineRepo({\` line to write into — paste this block inside the config object by hand:\n${renderIgnoredBlock(next, indentOf(lines)).join('\n')}`)
83
+ out = [...lines.slice(0, anchor + 1), ...renderIgnoredBlock(next, indentOf(lines)), ...lines.slice(anchor + 1)]
84
+ }
85
+ atomicWrite(file, out.join('\n'))
86
+ try {
87
+ const after = await loadRepoConfig(root, { fresh: true })
88
+ if (JSON.stringify(normalizeIgnored(after.ignoredInputs ?? {})) !== JSON.stringify(next)) throw new Error(`the loader read ignoredInputs ${JSON.stringify(after.ignoredInputs ?? {})}`)
89
+ } catch (err) {
90
+ atomicWrite(file, text)
91
+ throw new Error(`repo.config.ts was left as it was: ${(err as Error).message}`)
92
+ }
93
+ return next
94
+ }
@@ -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,129 @@
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 a
51
+ * dir by content once more than three files under it were read (its own
52
+ * dir, or the outermost dir the task also listed); each listed dir as
53
+ * `dir/**`, the outermost one only, unless a content entry covers it */
54
+ export function proposeInputs(content: string[], listed: string[]): string[] {
55
+ const minimal = (xs: string[]): string[] => {
56
+ const sorted = [...new Set(xs)].sort()
57
+ return sorted.filter((x) => !sorted.some((y) => y !== x && under(x, y)))
58
+ }
59
+ const tops = minimal(listed)
60
+ const byDir = new Map<string, string[]>()
61
+ for (const f of content) {
62
+ const d = tops.find((t) => under(f, t)) ?? posix.dirname(f)
63
+ byDir.set(d, [...(byDir.get(d) ?? []), f])
64
+ }
65
+ // a listed dir holding three or fewer: each file under its own dir
66
+ const regroup = [...byDir].flatMap(([dir, files]) => (tops.includes(dir) && files.length <= 3 ? files.map((f) => [posix.dirname(f), f] as const) : files.map((f) => [dir, f] as const)))
67
+ const again = new Map<string, string[]>()
68
+ for (const [d, f] of regroup) again.set(d, [...(again.get(d) ?? []), f])
69
+ const bytes = minimal([...again].flatMap(([dir, files]) => (files.length > 3 && dir !== '.' ? [dir] : files)))
70
+ return [...bytes, ...tops.filter((d) => !bytes.some((b) => under(d, b))).map((d) => `${d}/**`)].sort()
71
+ }
72
+
73
+ /** trace every gate task of the repo once and report what its key misses */
74
+ export async function traceAudit(root: string, config: RepoConfig, o: { fresh?: boolean; now?: number; env?: Record<string, string | undefined> } = {}): Promise<AuditReport> {
75
+ const now = o.now ?? Date.now()
76
+ const kept = readAuditReport(root)
77
+ if (o.fresh !== true && kept != null && kept.skipped == null && now - kept.at < AUDIT_KEEP_MS) return kept
78
+ const env = o.env ?? process.env
79
+ const off = traceUnavailable(env)
80
+ if (off != null) return { at: now, skipped: off, tasks: [] }
81
+ const tasks: AuditTask[] = []
82
+ for (const g of await composeChecks(root, config)) {
83
+ const u = g.runnable
84
+ if (u.body.cmd == null || g.row.runs.off != null || !g.row.runs.onCi || g.row.decl.inputs == null) continue
85
+ const cwd = u.body.cwd ?? root
86
+ const t = tracedCommand(u.body.cmd, cwd, env)
87
+ if ('reason' in t) { tasks.push({ id: u.id, status: 'unaudited', why: t.reason, uncovered: [], propose: [] }); continue }
88
+ try {
89
+ await spawnWithTimeout(t.cmd, { timeoutMs: u.timeoutMs, env: { ...process.env, ...u.body.env, ...t.env }, cwd })
90
+ const r = await readTrace(t.dir, root)
91
+ if (r.reads == null) {
92
+ const learned = r.blind.length > 0 ? learnBlind(r.blind) : null
93
+ tasks.push({ id: u.id, status: 'unaudited', why: [r.why, learned].filter((x) => x != null).join('; '), uncovered: [], propose: [] })
94
+ continue
95
+ }
96
+ // bytes: a file it opened or stat'ed, or found by a probe; a
97
+ // listing: a dir it read the entries of, or found by a probe
98
+ // Finder rewrites .DS_Store at will, and no tool's verdict reads it
99
+ const rel = (p: string): boolean => !p.startsWith('/') && p !== '.' && !p.split('/').includes('node_modules') && !p.startsWith('.git/') && posix.basename(p) !== '.DS_Store'
100
+ 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)
101
+ const dirs = [...new Set([...Object.keys(r.reads.dirs ?? {}), ...Object.entries(r.reads.exists).filter(([, k]) => k === 'dir').map(([p]) => p)])].filter(rel)
102
+ const ignored = gitIgnored(root, [...files, ...dirs])
103
+ const extras = ignoredFor(config.ignoredInputs, u.id)
104
+ const missF = files.filter((p) => ignored.has(p) && !covered(p, false, extras))
105
+ const missD = dirs.filter((p) => ignored.has(p) && !covered(p, true, extras))
106
+ tasks.push({ id: u.id, status: 'audited', uncovered: [...missF, ...missD].sort(), propose: proposeInputs(missF, missD) })
107
+ } finally {
108
+ dropTrace(t.dir)
109
+ }
110
+ }
111
+ const report: AuditReport = { at: now, tasks }
112
+ try {
113
+ mkdirSync(dirname(auditReportPath(root)), { recursive: true })
114
+ writeFileSync(auditReportPath(root), JSON.stringify(report, null, 2))
115
+ } catch { /* an audit that cannot be kept is measured again next time */ }
116
+ return report
117
+ }
118
+
119
+ /** write what the kept audit proposes into the managed block. Nothing is
120
+ * traced here, so the fix pass can call it on every run */
121
+ export async function applyAudit(root: string): Promise<{ added: IgnoredInputs; note: string | null }> {
122
+ const r = readAuditReport(root)
123
+ if (r == null || r.skipped != null) return { added: {}, note: r?.skipped ?? 'no audit yet: sk trace-audit runs one' }
124
+ const added = pendingByTask(r, (await loadRepoConfig(root, { fresh: true })).ignoredInputs ?? {})
125
+ if (Object.keys(added).length === 0) return { added, note: null }
126
+ await addIgnoredInputs(root, added)
127
+ return { added, note: null }
128
+ }
129
+