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
@@ -0,0 +1,78 @@
1
+ // shipkit insights: the insights page in the console. The boxes come from
2
+ // insightsOf, the one computation the daemon's route serves the page; this
3
+ // file only renders them as text. It reads the manifests directly and needs no
4
+ // daemon.
5
+ import { homedir } from 'os'
6
+ import { Command, Option } from 'clipanion'
7
+ import { devRepos, insightsOf, placeWords, rowMatches, SCOPES, tagsOf, type InsightsAnswer, type Scope } from '../home/insights.ts'
8
+
9
+ /** the boxes as text: a heading per box, then its rows, top `top` of each */
10
+ export function renderInsights(a: InsightsAnswer, o: { tag?: string | null; query?: string; top: number; uses: boolean }): string[] {
11
+ const out: string[] = []
12
+ for (const b of a.boxes) {
13
+ if (o.tag != null && !b.tags.includes(o.tag)) continue
14
+ const rows = b.rows.filter((r) => rowMatches(b, r, o.query ?? ''))
15
+ out.push(`${b.title} [${b.tags.join(', ')}] ${rows.length} ${b.noun}${rows.length === 1 ? '' : 's'}`)
16
+ const width = Math.max(0, ...rows.slice(0, o.top).map((r) => r.key.length))
17
+ for (const r of rows.slice(0, o.top)) {
18
+ out.push(` ${r.key.padEnd(width)} ${String(r.repos).padStart(3)} repos ${r.values.slice(0, 3).join(' ')}${r.values.length > 3 ? ` +${r.values.length - 3}` : ''}`)
19
+ if (o.uses) for (const u of r.uses) out.push(` ${placeWords(u)} ${u.value}${u.dev ? ' (dev)' : ''}`)
20
+ }
21
+ if (rows.length > o.top) out.push(` … ${rows.length - o.top} more`)
22
+ out.push('')
23
+ }
24
+ out.push(`${a.repos} repos, ${a.files} package.json, read in ${a.ms}ms`)
25
+ for (const e of a.errors) out.push(`WARN ${e}`)
26
+ return out
27
+ }
28
+
29
+ export class InsightsCommand extends Command {
30
+ static override paths = [['insights']]
31
+
32
+ static override usage = Command.Usage({
33
+ category: 'Home',
34
+ description: 'the insights page as text: boxes about every repo, like which packages they use and by how many',
35
+ details: 'every package.json the house repos track (or every folder of ~/dev with --scope dev), turned into boxes: dependency usage sorted by how many repos use each package, version drift, script names. The same computation the page reads, no daemon needed',
36
+ examples: [
37
+ ['every box, the house repos', 'sk insights'],
38
+ ['every repo of ~/dev', 'sk insights --scope dev'],
39
+ ['where a package is used, and at which range', 'sk insights --scope dev --tag deps --query zod --uses'],
40
+ ['the whole list of one tag', 'sk insights --tag deps --top 1000'],
41
+ ['the route\'s exact payload', 'sk insights --json'],
42
+ ],
43
+ })
44
+
45
+ scope = Option.String('--scope', 'house', { description: 'house (the project list) or dev (every folder of ~/dev)' })
46
+ tag = Option.String('--tag', { required: false, description: 'only the boxes carrying this tag' })
47
+ query = Option.String('--query', { required: false, description: 'only the rows whose key or a value holds this text' })
48
+ top = Option.String('--top', '20', { description: 'rows shown per box' })
49
+ uses = Option.Boolean('--uses', false, { description: 'list every use under each row' })
50
+ json = Option.Boolean('--json', false, { description: 'the daemon route\'s exact payload: { now, scope, repos, files, boxes, errors, ms }' })
51
+
52
+ async execute(): Promise<number> {
53
+ if (!(SCOPES as readonly string[]).includes(this.scope)) {
54
+ this.context.stderr.write(`--scope must be one of ${SCOPES.join(', ')}, got ${JSON.stringify(this.scope)}\n`)
55
+ return 2
56
+ }
57
+ const scope = this.scope as Scope
58
+ const top = Number(this.top)
59
+ if (!(top >= 1)) {
60
+ this.context.stderr.write(`--top must be a number from 1, got ${JSON.stringify(this.top)}\n`)
61
+ return 2
62
+ }
63
+ const home = process.env.HOME || homedir()
64
+ // the hub route module draws app icons, so it loads only when this runs
65
+ const repos = scope === 'dev' ? devRepos(home) : (await import('../serve/project-gutter-route.ts')).railRepos(process.cwd(), home)
66
+ const a = await insightsOf(repos, scope)
67
+ if (this.json) {
68
+ this.context.stdout.write(`${JSON.stringify(a, null, 2)}\n`)
69
+ return 0
70
+ }
71
+ if (this.tag != null && !tagsOf(a.boxes).includes(this.tag)) {
72
+ this.context.stderr.write(`--tag must be one of ${tagsOf(a.boxes).join(', ')}, got ${JSON.stringify(this.tag)}\n`)
73
+ return 2
74
+ }
75
+ for (const l of renderInsights(a, { tag: this.tag ?? null, query: this.query, top, uses: this.uses })) this.context.stdout.write(`${l}\n`)
76
+ return 0
77
+ }
78
+ }
@@ -75,6 +75,14 @@ export class KeysCommand extends Command {
75
75
  return 0
76
76
  }
77
77
  out(` key ${k.key}`)
78
+ // a lib hash names nothing by itself: the files behind it are what
79
+ // an edit has to touch to move this key
80
+ const lib = k.libFiles ?? []
81
+ if (lib.length > 0) {
82
+ out(` toolkit files in its lib components: ${lib.length}`)
83
+ for (const f of lib.slice(0, 12)) out(` ${f}`)
84
+ if (lib.length > 12) out(` … ${lib.length - 12} more (shipkit keys --json)`)
85
+ }
78
86
  if (k.population === 'reads') {
79
87
  for (const comp of k.components) out(` ${comp}`)
80
88
  // the rest of its identity is what it READ, stored with the
@@ -89,12 +97,15 @@ export class KeysCommand extends Command {
89
97
  const lists = Object.entries(set.lists)
90
98
  const dirs = Object.entries(set.dirs ?? {})
91
99
  const links = Object.entries(set.links ?? {})
92
- out(` read set: ${files.length} file(s), ${exists.length} existence probe(s), ${lists.length} listing(s), ${dirs.length} dir read(s)${links.length > 0 ? `, ${links.length} link probe(s)` : ''}`)
100
+ // outside the repo a traced read is keyed on stat alone
101
+ const outside = Object.keys(set.sigs ?? {}).filter((p) => !(p in set.files))
102
+ out(` read set: ${files.length} file(s), ${exists.length} existence probe(s), ${lists.length} listing(s), ${dirs.length} dir read(s)${links.length > 0 ? `, ${links.length} link probe(s)` : ''}${outside.length > 0 ? `, ${outside.length} file(s) outside the repo, by stat` : ''}`)
93
103
  for (const [p, oid] of files) out(` read ${p}=${oid ?? 'absent'}`)
94
104
  for (const [p, kind] of exists) out(` exists ${p}=${kind ?? 'absent'}`)
95
105
  for (const [g, h] of lists) out(` list ${g}=${h}`)
96
106
  for (const [d, h] of dirs) out(` entries ${d}=${h ?? 'absent'}`)
97
107
  for (const [p, is] of links) out(` link ${p}=${is ? 'yes' : 'no'}`)
108
+ for (const p of outside) out(` stat ${p}=${set.sigs![p]}`)
98
109
  return 0
99
110
  }
100
111
  // the excluded block is 44 lines here and it buries the component
@@ -88,7 +88,7 @@ export class OverviewCommand extends Command {
88
88
  json = Option.Boolean('--json', false, { description: 'the answers as json: { now, pm, daemon, processes, widgets }, processes as `shipkit gp --json`, widgets the daemon route\'s payload' })
89
89
 
90
90
  async execute(): Promise<number> {
91
- const a = await overviewAnswer({ ...localProcessDeps(process.cwd()), pm: () => askPm(), daemon: () => askDaemon(), widgets: () => askWidgets(), now: Date.now() })
91
+ const a = await overviewAnswer({ ...(await localProcessDeps(process.cwd())), pm: () => askPm(), daemon: () => askDaemon(), widgets: () => askWidgets(), now: Date.now() })
92
92
  if (this.json) {
93
93
  this.context.stdout.write(`${JSON.stringify(a, null, 2)}\n`)
94
94
  return 0
@@ -1,7 +1,6 @@
1
1
  // shipkit serve — the ONE hub server per repo (one vscode folder-open task,
2
2
  // clickable URLs). Thin clipanion surface; logic in serve/run-serve.ts.
3
3
  import { Command, Option } from 'clipanion'
4
- import { runServe } from '../serve/run-serve.ts'
5
4
 
6
5
  export class ServeCommand extends Command {
7
6
  static override paths = [['serve']]
@@ -27,6 +26,8 @@ export class ServeCommand extends Command {
27
26
  return 1
28
27
  }
29
28
  }
29
+ // the hub runtime carries the image tools: loaded when it serves
30
+ const { runServe } = await import('../serve/run-serve.ts')
30
31
  return runServe(process.cwd(), {
31
32
  print: (l) => this.context.stdout.write(`${l}\n`),
32
33
  printErr: (l) => this.context.stderr.write(`${l}\n`),
@@ -0,0 +1,62 @@
1
+ // sk trace-audit — which gitignored paths each gate task reads that its key
2
+ // cannot see, found by running the task once under the tracer, and with
3
+ // --fix written into repo.config.ts (core/trace-audit.ts).
4
+ import { Command, Option } from 'clipanion'
5
+ import { loadRepoConfig } from '../repo-config.ts'
6
+ import { applyAudit, traceAudit } from '../core/trace-audit.ts'
7
+ import { describeByTask } from '../core/audit-report.ts'
8
+ import { setTraceEnabled } from '../trace/tracer.ts'
9
+
10
+ export class TraceAuditCommand extends Command {
11
+ static override paths = [['trace-audit']]
12
+
13
+ static override usage = Command.Usage({
14
+ category: 'Health',
15
+ description: 'gitignored paths a gate task reads that its key misses, found by tracing it once',
16
+ details: 'Runs each gate task once under the tracer (on by default: --disable for this machine, or SHIPKIT_TRACE=0 for one run) and lists the gitignored paths it read that neither the tracked tree nor repo.config ignoredInputs covers. The answer is kept for about a day under the git common dir, where every worktree reads it, and each finding joins the key of the task that read it at once. --fix writes the findings into the managed block of repo.config.ts, per task, without tracing again. Exit 1 when a key misses something.',
17
+ examples: [
18
+ ['audit, reusing a recent answer', 'sk trace-audit'],
19
+ ['trace again now', 'sk trace-audit --fresh'],
20
+ ['write the proposals into repo.config.ts', 'sk trace-audit --fix'],
21
+ ['machine-readable', 'sk trace-audit --json'],
22
+ ['stop tracing on this machine', 'sk trace-audit --disable'],
23
+ ['trace again, the default', 'sk trace-audit --enable'],
24
+ ],
25
+ })
26
+
27
+ fresh = Option.Boolean('--fresh', false, { description: 'trace again even when a recent answer is kept' })
28
+ fix = Option.Boolean('--fix', false, { description: 'write the kept proposals into repo.config.ts ignoredInputs' })
29
+ json = Option.Boolean('--json', false, { description: 'machine-readable report' })
30
+ enable = Option.Boolean('--enable', false, { description: 'turn tracing back on for this machine, the default: the daemon\'s health pass audits about daily (SK031)' })
31
+ disable = Option.Boolean('--disable', false, { description: 'stop tracing on this machine: nothing audits here until --enable' })
32
+
33
+ async execute(): Promise<number> {
34
+ const root = process.cwd()
35
+ const out = (l: string): void => { this.context.stdout.write(`${l}\n`) }
36
+ if (this.enable || this.disable) {
37
+ setTraceEnabled(this.enable)
38
+ out(this.enable ? 'ok: tracing is on for this machine: the health pass audits each repo about daily (SK031)' : 'ok: tracing is off for this machine')
39
+ return 0
40
+ }
41
+ if (this.fix) {
42
+ let r: Awaited<ReturnType<typeof applyAudit>>
43
+ // a config the block writer refuses (a hand edit, a key declared
44
+ // outside the markers) is an answer for a human, never a crash
45
+ try { r = await applyAudit(root) } catch (e) { out(`FAIL: ${(e as Error).message}`); return 1 }
46
+ if (r.note != null) out(`skip: ${r.note}`)
47
+ else out(Object.keys(r.added).length === 0 ? 'ok: repo.config.ts ignoredInputs already covers every path the audit found' : `ok: repo.config.ts ignoredInputs gained ${describeByTask(r.added)}`)
48
+ return 0
49
+ }
50
+ const r = await traceAudit(root, await loadRepoConfig(root), { fresh: this.fresh })
51
+ const missing = r.tasks.filter((t) => t.uncovered.length > 0)
52
+ if (this.json) { out(JSON.stringify(r)); return missing.length > 0 ? 1 : 0 }
53
+ if (r.skipped != null) { out(`skip: nothing traced: ${r.skipped}`); return 0 }
54
+ for (const t of r.tasks) {
55
+ if (t.status === 'unaudited') out(`WARN ${t.id} could not be audited: ${t.why}`)
56
+ else if (t.uncovered.length > 0) out(`FAIL: ${t.id} reads ${t.uncovered.length} gitignored path(s) its key misses: ${t.uncovered.slice(0, 5).join(', ')}${t.uncovered.length > 5 ? ', …' : ''}`)
57
+ else out(`ok: ${t.id}: its key covers every gitignored path it read`)
58
+ }
59
+ if (missing.length > 0) out(` fix: sk trace-audit --fix adds ${describeByTask(Object.fromEntries(missing.map((t) => [t.id, t.propose])))} to repo.config.ts ignoredInputs`)
60
+ return missing.length > 0 ? 1 : 0
61
+ }
62
+ }
package/src/cli.ts CHANGED
@@ -13,6 +13,7 @@ import { aliasArgs, CrossRepoActivityCommand } from './cli/cmd_CrossRepoActivity
13
13
  import { ConfigKeysCommand } from './cli/cmd_ConfigKeys.ts'
14
14
  import { GlobalProcessesCommand, GlobalProcessRestartCommand, GlobalProcessStartCommand, GlobalProcessStopCommand } from './cli/cmd_GlobalProcesses.ts'
15
15
  import { PlannedCommand } from './cli/cmd_Planned.ts'
16
+ import { InsightsCommand } from './cli/cmd_Insights.ts'
16
17
  import { OverviewCommand } from './cli/cmd_Overview.ts'
17
18
  import { CiCommand } from './cli/cmd_Ci.ts'
18
19
  import { ContractsCommand } from './cli/cmd_Contracts.ts'
@@ -30,6 +31,8 @@ import { ReleaseCommand } from './cli/cmd_Release.ts'
30
31
  import { ReleasesCommand } from './cli/cmd_Releases.ts'
31
32
  import { ChecksCommand, UnitsAliasCommand } from './cli/cmd_Checks.ts'
32
33
  import { WhenCommand } from './cli/cmd_When.ts'
34
+ import { CacheAuditCommand } from './cli/cmd_CacheAudit.ts'
35
+ import { TraceAuditCommand } from './cli/cmd_TraceAudit.ts'
33
36
  import { ServeCommand } from './cli/cmd_Serve.ts'
34
37
  import { SmokeCommand } from './cli/cmd_Smoke.ts'
35
38
  import { StatusCommand } from './cli/cmd_Status.ts'
@@ -97,9 +100,12 @@ export function buildCli(): Cli {
97
100
  cli.register(GlobalProcessRestartCommand)
98
101
  cli.register(CrossRepoActivityCommand)
99
102
  cli.register(PlannedCommand)
103
+ cli.register(InsightsCommand)
100
104
  cli.register(OverviewCommand)
101
105
  cli.register(ChecksCommand)
102
106
  cli.register(WhenCommand)
107
+ cli.register(CacheAuditCommand)
108
+ cli.register(TraceAuditCommand)
103
109
  cli.register(UnitsAliasCommand)
104
110
  cli.register(CommandsCommand)
105
111
  cli.register(ConfigKeysCommand)
@@ -228,6 +228,9 @@ export type Instance = {
228
228
  * kind, a secret present or missing, a post its venue, an asset its roll
229
229
  * count. Absent = this kind has nothing to say there */
230
230
  slot?: string
231
+ /** the group it sits in on the tree, under its section: an asset's kit
232
+ * category. Absent = directly under the section */
233
+ group?: string
231
234
  type: ResourceType
232
235
  /** a slash path. Where a truth file exists this IS its repo-relative
233
236
  * path; otherwise a VIRTUAL path under a root named for the type, and
@@ -321,6 +321,8 @@ export function mediaKitCollection(root: string, kit: ResolvedKit | null, heal:
321
321
  const base: Instance = {
322
322
  id: `media:${f.asset.id}`,
323
323
  text: f.asset.id,
324
+ label: f.asset.label ?? f.asset.id,
325
+ group: f.kind.category,
324
326
  // the roll count is the one fact a row of assets is read for
325
327
  slot: rolls.length > 0 ? `${rolls.length} roll${rolls.length === 1 ? '' : 's'}` : manual ? 'manual' : undefined,
326
328
  type: 'asset',
@@ -343,11 +345,11 @@ export function mediaKitCollection(root: string, kit: ResolvedKit | null, heal:
343
345
  const said: Partial<Instance> = manual && rolls.length === 0
344
346
  // the brief IS the paste-ready artifact ("I don't even
345
347
  // know what to paste" — the tile hands him the prompt)
346
- ? { text: `${f.asset.id} — manual, nothing uploaded`, label: f.asset.id, verbs: ['provide'], artifact: f.asset.prompt != null ? { copy: f.asset.prompt } : undefined }
348
+ ? { text: `${f.asset.id} — manual, nothing uploaded`, verbs: ['provide'], artifact: f.asset.prompt != null ? { copy: f.asset.prompt } : undefined }
347
349
  : rolls.length > 0 && !picked
348
- ? { text: `${f.asset.id} — ${rolls.length} roll(s), default in use, none picked`, label: f.asset.id, verbs: ['pick'] }
350
+ ? { text: `${f.asset.id} — ${rolls.length} roll(s), default in use, none picked`, verbs: ['pick'] }
349
351
  : stale
350
- ? { text: `${f.asset.id} — stale (a parent was re-picked)`, label: f.asset.id, verbs: ['pick'] }
352
+ ? { text: `${f.asset.id} — stale (a parent was re-picked)`, verbs: ['pick'] }
351
353
  : {}
352
354
  out.push({ ...base, ...said, ...colour })
353
355
  }
@@ -0,0 +1,154 @@
1
+ // a repo's app icon, drawn from its mark: the mark's transparent margin is
2
+ // cut away, and what is left sits on a rounded plate in the repo's identity
3
+ // colour, on the macOS icon grid (an 824 plate on a 1024 canvas). A mark that
4
+ // already fills its box (a photo, a square tile) becomes the plate itself.
5
+ // The app build and the page's preview both draw through appIconPngs.
6
+ import { createCanvas, Image, type Canvas } from '@napi-rs/canvas'
7
+ import { existsSync, readFileSync } from 'fs'
8
+ import { join } from 'path'
9
+ import { identityOfName, identityOfText, type Identity } from './identity-color.ts'
10
+ import { configName, CONFIG_FILE } from './identity.ts'
11
+ import { LAYOUT } from './layout.ts'
12
+
13
+ /** the mark an app wears: the repo's own, largest first. Rasters only */
14
+ export const APP_ICON_FILES = [`${LAYOUT.identity}/icon.png`, 'public/logo.png', 'public/favicon.png']
15
+
16
+ export function appIconOf(root: string): string | null {
17
+ for (const f of APP_ICON_FILES) if (existsSync(join(root, f))) return join(root, f)
18
+ return null
19
+ }
20
+
21
+ export const ICON_PX = 1024
22
+ const PLATE = 824
23
+ const RADIUS = 185
24
+ /** the mark's longest side, as a share of the plate */
25
+ const MARK = 0.7
26
+ /** an alpha at or under this is margin, and is dropped: a generated image
27
+ * often carries faint noise over its whole canvas, which would widen the box
28
+ * to the full image and haze the plate */
29
+ const NOISE = 48
30
+ /** a mark whose box is this full is a tile already: it fills the plate */
31
+ const TILE = 0.85
32
+
33
+ export type Box = { x: number; y: number; w: number; h: number; fill: number }
34
+
35
+ /** the box of the pixels that are not margin, and how full it is; null for a
36
+ * mark with no such pixel */
37
+ export function markBox(rgba: Uint8Array | Uint8ClampedArray, w: number, h: number): Box | null {
38
+ let x0 = w, y0 = h, x1 = -1, y1 = -1
39
+ for (let y = 0; y < h; y++)
40
+ for (let x = 0; x < w; x++)
41
+ if (rgba[(y * w + x) * 4 + 3]! > NOISE) {
42
+ if (x < x0) x0 = x
43
+ if (x > x1) x1 = x
44
+ if (y < y0) y0 = y
45
+ if (y > y1) y1 = y
46
+ }
47
+ if (x1 < 0) return null
48
+ let solid = 0
49
+ for (let y = y0; y <= y1; y++) for (let x = x0; x <= x1; x++) if (rgba[(y * w + x) * 4 + 3]! > 250) solid++
50
+ const bw = x1 - x0 + 1
51
+ const bh = y1 - y0 + 1
52
+ return { x: x0, y: y0, w: bw, h: bh, fill: solid / (bw * bh) }
53
+ }
54
+
55
+ const lum = (r: number, g: number, b: number): number => (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255
56
+
57
+ /** the mean lightness of the mark's own pixels, alpha weighted */
58
+ function markLum(rgba: Uint8Array | Uint8ClampedArray): number {
59
+ let sum = 0, weight = 0
60
+ for (let i = 0; i < rgba.length; i += 4) {
61
+ const a = rgba[i + 3]! / 255
62
+ sum += a * lum(rgba[i]!, rgba[i + 1]!, rgba[i + 2]!)
63
+ weight += a
64
+ }
65
+ return weight === 0 ? 0.5 : sum / weight
66
+ }
67
+
68
+ const hexLum = (hex: string): number => {
69
+ const n = Number.parseInt(hex.slice(1), 16)
70
+ return lum((n >> 16) & 255, (n >> 8) & 255, n & 255)
71
+ }
72
+
73
+ /** of the two identity colours, the one the mark stands out on most */
74
+ export function plateOf(id: Identity, markLightness: number): string {
75
+ return Math.abs(hexLum(id.colorLight) - markLightness) >= Math.abs(hexLum(id.colorDark) - markLightness) ? id.colorLight : id.colorDark
76
+ }
77
+
78
+ const towardWhite = (hex: string, t: number): string => {
79
+ const n = Number.parseInt(hex.slice(1), 16)
80
+ const c = [(n >> 16) & 255, (n >> 8) & 255, n & 255].map((v) => Math.round(v + (255 - v) * t))
81
+ return `#${c.map((v) => v.toString(16).padStart(2, '0')).join('')}`
82
+ }
83
+
84
+ async function decode(bytes: Uint8Array): Promise<Image> {
85
+ const img = new Image()
86
+ img.src = Buffer.from(bytes)
87
+ // the pixels land on decode: drawn before it, the image is empty
88
+ await img.decode().catch(() => {})
89
+ if (img.width === 0) throw new Error('not an image the icon renderer reads')
90
+ return img
91
+ }
92
+
93
+ function plate(ctx: ReturnType<Canvas['getContext']>, o: number): void {
94
+ ctx.beginPath()
95
+ ctx.roundRect(o, o, PLATE, PLATE, RADIUS)
96
+ }
97
+
98
+ /** the icon at 1024px, and the plate colour it used (null: the mark is the plate) */
99
+ export async function drawAppIcon(bytes: Uint8Array, id: Identity): Promise<{ canvas: Canvas; plate: string | null }> {
100
+ const img = await decode(bytes)
101
+ const src = createCanvas(img.width, img.height)
102
+ const sctx = src.getContext('2d')
103
+ sctx.drawImage(img, 0, 0)
104
+ const data = sctx.getImageData(0, 0, img.width, img.height)
105
+ const px = data.data
106
+ for (let i = 3; i < px.length; i += 4) if (px[i]! <= NOISE) px[i] = 0
107
+ sctx.putImageData(data, 0, 0)
108
+ const box = markBox(px, img.width, img.height) ?? { x: 0, y: 0, w: img.width, h: img.height, fill: 0 }
109
+ const out = createCanvas(ICON_PX, ICON_PX)
110
+ const ctx = out.getContext('2d')
111
+ ctx.imageSmoothingQuality = 'high'
112
+ const o = (ICON_PX - PLATE) / 2
113
+ if (box.fill >= TILE) {
114
+ // a tile: cover the plate with it, clipped to the plate's corners
115
+ const s = Math.max(PLATE / box.w, PLATE / box.h)
116
+ ctx.save()
117
+ plate(ctx, o)
118
+ ctx.clip()
119
+ ctx.drawImage(src, box.x, box.y, box.w, box.h, ICON_PX / 2 - (box.w * s) / 2, ICON_PX / 2 - (box.h * s) / 2, box.w * s, box.h * s)
120
+ ctx.restore()
121
+ return { canvas: out, plate: null }
122
+ }
123
+ const colour = plateOf(id, markLum(px))
124
+ const g = ctx.createLinearGradient(0, o, 0, o + PLATE)
125
+ // a lighter top edge, the way a native icon catches light
126
+ g.addColorStop(0, towardWhite(colour, 0.14))
127
+ g.addColorStop(1, colour)
128
+ ctx.fillStyle = g
129
+ plate(ctx, o)
130
+ ctx.fill()
131
+ const s = (PLATE * MARK) / Math.max(box.w, box.h)
132
+ ctx.drawImage(src, box.x, box.y, box.w, box.h, ICON_PX / 2 - (box.w * s) / 2, ICON_PX / 2 - (box.h * s) / 2, box.w * s, box.h * s)
133
+ return { canvas: out, plate: colour }
134
+ }
135
+
136
+ /** the icon as pngs, one per size asked, drawn once */
137
+ export async function appIconPngs(bytes: Uint8Array, id: Identity, sizes: number[] = [ICON_PX]): Promise<Buffer[]> {
138
+ const { canvas } = await drawAppIcon(bytes, id)
139
+ return sizes.map((px) => {
140
+ if (px === ICON_PX) return canvas.toBuffer('image/png')
141
+ const small = createCanvas(px, px)
142
+ const ctx = small.getContext('2d')
143
+ ctx.imageSmoothingQuality = 'high'
144
+ ctx.drawImage(canvas, 0, 0, px, px)
145
+ return small.toBuffer('image/png')
146
+ })
147
+ }
148
+
149
+ /** the identity a repo's config declares, else the one its name hashes to */
150
+ export function identityOfRoot(root: string): Identity {
151
+ const path = join(root, CONFIG_FILE)
152
+ const text = existsSync(path) ? readFileSync(path, 'utf8') : ''
153
+ return identityOfText(text) ?? identityOfName(configName(text, root))
154
+ }
@@ -0,0 +1,21 @@
1
+ // THE KEPT AUDIT: what the last traced audit (core/trace-audit.ts) found, per
2
+ // task. It lives under the git common dir, beside the ledger, so every
3
+ // worktree of the repo on this machine reads the same one. The keys read it
4
+ // (ledger/gate-checks.ts): a finding covers the task that read it before any
5
+ // config write lands. The shape and the pure helpers: core/ignored-inputs.ts.
6
+ import { readFileSync } from 'fs'
7
+ import { join } from 'path'
8
+ import { shipkitRoot } from './git-root.ts'
9
+ import type { AuditReport } from './ignored-inputs.ts'
10
+
11
+ export type { AuditReport, AuditTask, IgnoredInputs } from './ignored-inputs.ts'
12
+ export { auditedExtras, describeByTask, ignoredFor, pendingByTask } from './ignored-inputs.ts'
13
+
14
+ /** its name under the repo's shipkit state dir (CheckCtx.state reads it) */
15
+ export const AUDIT_FILE = 'trace-audit.json'
16
+
17
+ export const auditReportPath = (root: string): string => join(shipkitRoot(root), AUDIT_FILE)
18
+
19
+ export function readAuditReport(root: string): AuditReport | null {
20
+ try { return JSON.parse(readFileSync(auditReportPath(root), 'utf8')) as AuditReport } catch { return null }
21
+ }
@@ -0,0 +1,72 @@
1
+ // THE CACHE AUDIT: is a cached green still what a fresh run says? A check
2
+ // keyed on files is only as honest as the paths it declares: a path it reads
3
+ // and never declared cannot move its key, so an edit there serves the old
4
+ // green. Nothing sees that while it happens. This re-runs a small sample of
5
+ // the checks the cache would answer right now, fresh, and compares.
6
+ //
7
+ // the one constraint a reader breaks first: only a green is ever stored, so
8
+ // a fresh FAIL under a key that replays a green is the whole finding. A
9
+ // fresh green where the stored one warned, or the reverse, is reported too.
10
+ import { composeChecks } from '../ledger/gate-checks.ts'
11
+ import { Ledger } from '../ledger/ledger.ts'
12
+ import { gateBinary } from '../ledger/toolchain-identity.ts'
13
+ import { populationOf } from '../problems/problem-line.ts'
14
+ import { runCheckPass } from './run-check.ts'
15
+ import type { RepoConfig } from '../repo-config.ts'
16
+
17
+ export type AuditRow = {
18
+ id: string
19
+ /** what the cache holds for the current key */
20
+ cached: 'pass' | 'warn'
21
+ /** what a fresh run said */
22
+ fresh: 'pass' | 'warn' | 'fail' | 'skip'
23
+ /** how the check is keyed: files (declared paths) or reads (recorded) */
24
+ keyedOn: 'files' | 'reads'
25
+ ms: number
26
+ }
27
+
28
+ export type AuditResult = {
29
+ /** checks the cache would answer now, the pool the sample came from */
30
+ pool: number
31
+ rows: AuditRow[]
32
+ /** the rows whose fresh verdict is not the cached one */
33
+ stale: AuditRow[]
34
+ }
35
+
36
+ /** the sample: `n` checks of the pool, a different stable set each day, so
37
+ * every check is audited over time and two runs on one day agree */
38
+ export function sampleOf(ids: string[], n: number, day: number): string[] {
39
+ const rank = (id: string): bigint => BigInt(Bun.hash(`${day}:${id}`))
40
+ return [...ids].sort((a, b) => (rank(a) < rank(b) ? -1 : rank(a) > rank(b) ? 1 : 0)).slice(0, n)
41
+ }
42
+
43
+ export async function auditCache(root: string, config: RepoConfig, o: { sample: number; only?: string[]; now?: number }): Promise<AuditResult> {
44
+ const composed = await composeChecks(root, config, { tier: 'quick' })
45
+ const ledger = Ledger.open(root, composed.map((g) => g.row.decl), { cliEntry: gateBinary(), read: true })
46
+ // what the cache would answer right now, keyed on the repo's own files
47
+ const pool: { id: string; cached: 'pass' | 'warn'; keyedOn: 'files' | 'reads' }[] = []
48
+ for (const g of composed) {
49
+ if (g.row.runs.off != null) continue
50
+ const pop = populationOf(g.row.decl)
51
+ if (pop !== 'content' && pop !== 'reads') continue
52
+ if (o.only != null && !o.only.includes(g.row.decl.id)) continue
53
+ const hit = ledger.replay(g.row.decl.id)
54
+ if (hit != null) pool.push({ id: g.row.decl.id, cached: hit.status, keyedOn: pop === 'content' ? 'files' : 'reads' })
55
+ }
56
+ const day = Math.floor((o.now ?? Date.now()) / 86_400_000)
57
+ const picked = new Set(o.only != null ? pool.map((p) => p.id) : sampleOf(pool.map((p) => p.id), o.sample, day))
58
+ const rows: AuditRow[] = []
59
+ for (const p of pool.filter((x) => picked.has(x.id))) {
60
+ const t = performance.now()
61
+ // fresh: nothing comes from the cache. A green it finds is stored, a
62
+ // failure is not, which is why a stale entry is forgotten below
63
+ const run = await runCheckPass(root, { config, composed, audience: 'health', only: [p.id], fresh: true, frozen: true, status: false, print: () => {}, detail: () => {} })
64
+ const r = run.results.find((x) => x.id === p.id)
65
+ rows.push({ id: p.id, cached: p.cached, fresh: r?.status ?? 'skip', keyedOn: p.keyedOn, ms: Math.round(performance.now() - t) })
66
+ }
67
+ const stale = rows.filter((r) => r.fresh !== 'skip' && r.fresh !== r.cached)
68
+ // a fresh failure is never stored, so without this the stale green would
69
+ // keep being served under the same key
70
+ for (const r of stale) ledger.forget(r.id)
71
+ return { pool: pool.length, rows, stale }
72
+ }
@@ -22,7 +22,7 @@ export function canonicalBodies(opts: ScriptStyle = {}): Record<string, string>
22
22
  }
23
23
 
24
24
  // conditional canon (bodies stated by the vocabulary page) — keyed by the
25
- // SK001 requirement name; a requirement with no canonical body (comfy:up) is
25
+ // SK001 requirement name; a requirement with no canonical body is
26
26
  // deliberately absent: a fixer must not invent one
27
27
  export function conditionalBodies(_opts: ScriptStyle = {}): Record<string, string> {
28
28
  return {
@@ -18,12 +18,13 @@
18
18
  // A context with no pass reports and reaches the world, and a repo read there
19
19
  // throws rather than guess a root.
20
20
  import { existsSync, mkdirSync, readFileSync, rmSync } from 'fs'
21
- import { dirname, join } from 'path'
21
+ import { basename, dirname, isAbsolute, join } from 'path'
22
22
  import { atomicWrite } from './locked-write.ts'
23
23
  import { spawnWithTimeout } from './spawn-timeout.ts'
24
24
  import { blobOid, dirEntries, entriesHash, isLinkAt, kindOf, listingHash, Reads, type DirEntry } from './read-set.ts'
25
25
  import { CheckPass } from './check-pass.ts'
26
26
  import { normalizeInput, WHOLE_TREE } from '../ledger/git-scan.ts'
27
+ import { shipkitRoot } from './git-root.ts'
27
28
  import type { RepoConfig } from '../repo-config.ts'
28
29
  import type { Scan } from '../scan.ts'
29
30
  import type { Line } from './wire.ts'
@@ -123,17 +124,25 @@ export class CheckCtx {
123
124
  if (p === WHOLE_TREE) throw new Error(`'${path}' is the repo root, not a file`)
124
125
  return p
125
126
  }
126
- /** the text of a repo-relative file, null when there is none */
127
+ /** the text of a repo-relative file, null when there is none. An
128
+ * absolute path reads a file outside the work tree (the kept audit under
129
+ * the git common dir), recorded by that path */
127
130
  read(path: string): string | null {
128
131
  return this.bytes(path)?.toString('utf8') ?? null
129
132
  }
133
+ /** the text of a file under the repo's shipkit state dir (the git common
134
+ * dir, shared by every worktree), null when there is none. Recorded by
135
+ * its absolute path, like any read */
136
+ state(name: string): string | null {
137
+ return this.read(join(shipkitRoot(this.root), name))
138
+ }
130
139
  /** the raw bytes of a repo-relative file (an image, a binary), null when
131
140
  * there is none. Recorded exactly like `read` */
132
141
  bytes(path: string): Buffer | null {
133
- const p = this.rel(path)
142
+ const p = isAbsolute(path) ? path : this.rel(path)
134
143
  let bytes: Buffer
135
144
  try {
136
- bytes = readFileSync(join(this.repo('read').root, p))
145
+ bytes = readFileSync(isAbsolute(p) ? p : join(this.repo('read').root, p))
137
146
  } catch (e) {
138
147
  const code = (e as { code?: string }).code
139
148
  if (code !== 'ENOENT' && code !== 'ENOTDIR' && code !== 'EISDIR') throw e
@@ -245,7 +254,7 @@ export class CheckCtx {
245
254
  // spawn without the Bun.spawn throw-on-missing-binary gotcha; SIGTERM at
246
255
  // timeoutMs, SIGKILL 2s later (one code path: core/spawn-timeout.ts)
247
256
  async exec(cmd: string[], opts?: { env?: Record<string, string | undefined>; cwd?: string; timeoutMs?: number }): Promise<{ code: number; out: string; timedOut: boolean }> {
248
- this.world('exec')
257
+ this.world(`\`${cmd.slice(0, 3).map((a) => basename(a)).join(' ')}\``)
249
258
  // the environment as it stands NOW: left out, Bun hands the child the
250
259
  // one it started with, and a variable set since (a test seam, a flag
251
260
  // the gate set) never reaches it
@@ -442,6 +442,8 @@ type Live = RunnableCheck & CheckResult & {
442
442
  /** what an in process body read, null when it reached the world or ran
443
443
  * as a subprocess */
444
444
  reads: ReadSet | null
445
+ /** why its answer cannot be stored on its reads, in its own words */
446
+ untracked: string | null
445
447
  /** it printed nothing of its own, so its one line is the headline below */
446
448
  synthetic: boolean
447
449
  /** what the headline carries after the id: a failure's last line, or the
@@ -498,7 +500,7 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
498
500
  const runDir = openRunDir(o.logDir)
499
501
  const checks: Live[] = sel.selected.map((u) => ({
500
502
  ...u,
501
- status: 'skip', advisory: false, replayed: false, ms: 0, exitCode: null, lines: [], problems: [], lastLine: '', note: '', startedAt: 0, endedAt: 0, unmeasured: false, attached: false, reads: null, synthetic: false, tail: '', extra: [],
503
+ status: 'skip', advisory: false, replayed: false, ms: 0, exitCode: null, lines: [], problems: [], lastLine: '', note: '', startedAt: 0, endedAt: 0, unmeasured: false, attached: false, reads: null, untracked: null, synthetic: false, tail: '', extra: [],
502
504
  logPath: u.body.cmd != null ? join(runDir, `${u.id.replace(/[^a-zA-Z0-9-]/g, '_')}.log`) : null,
503
505
  }))
504
506
  const selectedIds = new Set(checks.map((u) => u.id))
@@ -629,6 +631,7 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
629
631
  // id, every line is tagged, and a failure carries a fix pointer
630
632
  await runCheckBody({ id: u.id, title: u.title, owner: u.owner, run: u.body.run, fix: u.fix }, t)
631
633
  u.reads = t.untracked == null ? t.reads.snapshot() : null
634
+ u.untracked = t.untracked
632
635
  u.lines = t.lines
633
636
  u.problems = findings(u, t.lines)
634
637
  u.unmeasured = t.unmeasured
@@ -785,7 +788,7 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
785
788
  await measureAndKeep(u)
786
789
  return { value: answerOf(u), share: !killed.has(u.id) && !u.unmeasured }
787
790
  })
788
- if (r.kind === 'attached') { o.ledger?.noteAttached(); adopt(u, r.value, r.pid) }
791
+ if (r.kind === 'attached') { o.ledger?.noteAttached(u.id); adopt(u, r.value, r.pid) }
789
792
  else if (r.kind === 'aborted') { u.status = 'skip'; u.note = bailed ? 'bailed' : 'interrupted'; u.lines = [] }
790
793
  else if (r.kind === 'overlapped') emit({ kind: 'warn', text: `${u.id} ran again beside another run: ${r.warn}` })
791
794
  }
@@ -828,7 +831,7 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
828
831
  })
829
832
  }
830
833
  if (o.ledger != null && keep)
831
- o.ledger.record(u.id, { status: u.status as 'pass' | 'warn', lines: u.lines, lastLine: u.lastLine, problems: u.problems, measuredMs: clock() - u.startedAt }, u.reads)
834
+ o.ledger.record(u.id, { status: u.status as 'pass' | 'warn', lines: u.lines, lastLine: u.lastLine, problems: u.problems, measuredMs: clock() - u.startedAt }, u.reads, u.untracked)
832
835
  }
833
836
 
834
837
  const start = (u: Live): void => {