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.
- package/CHANGELOG.md +54 -0
- package/package.json +1 -1
- package/src/assets/gen/comfyts-gen.ts +12 -8
- package/src/assets/server/studio-server.ts +14 -2
- package/src/check.ts +5 -8
- package/src/checks/SK001-package-json-shape.check.ts +5 -4
- package/src/checks/SK003-claude-md-shape.check.ts +2 -2
- package/src/checks/SK030-cache-audit.check.ts +34 -0
- package/src/checks/SK031-trace-audit.check.ts +39 -0
- package/src/checks/SK032-ignored-inputs.check.ts +79 -0
- package/src/cli/cmd_Assets.ts +10 -6
- package/src/cli/cmd_CacheAudit.ts +43 -0
- package/src/cli/cmd_CrossRepoActivity.ts +2 -1
- package/src/cli/cmd_GlobalProcesses.ts +4 -3
- package/src/cli/cmd_Home.ts +3 -2
- package/src/cli/cmd_Insights.ts +78 -0
- package/src/cli/cmd_Keys.ts +12 -1
- package/src/cli/cmd_Overview.ts +1 -1
- package/src/cli/cmd_Serve.ts +2 -1
- package/src/cli/cmd_TraceAudit.ts +62 -0
- package/src/cli.ts +6 -0
- package/src/collections/collection-types.ts +3 -0
- package/src/collections/providers.ts +5 -3
- package/src/core/app-icon.ts +154 -0
- package/src/core/audit-report.ts +21 -0
- package/src/core/cache-audit.ts +72 -0
- package/src/core/canonical-scripts.ts +1 -1
- package/src/core/check-ctx.ts +14 -5
- package/src/core/check-runner.ts +6 -3
- package/src/core/floor-resolve.ts +5 -7
- package/src/core/git-root.ts +13 -0
- package/src/core/ignored-block.ts +94 -0
- package/src/core/ignored-inputs.ts +47 -0
- package/src/core/jobs.ts +9 -2
- package/src/core/one-lib.ts +59 -4
- package/src/core/read-set.ts +97 -9
- package/src/core/run-ci.ts +6 -1
- package/src/core/run-fixers.ts +8 -2
- package/src/core/trace-audit.ts +129 -0
- package/src/home/gui-app/ShipkitGui.swift +125 -41
- package/src/home/gui-app.ts +24 -27
- package/src/home/home-routes.ts +15 -0
- package/src/home/home-start.ts +11 -5
- package/src/home/insights-shape.ts +124 -0
- package/src/home/insights.ts +90 -0
- package/src/home/open-app.ts +2 -2
- package/src/home/serve-web.ts +3 -3
- package/src/home/via-hub.ts +14 -0
- package/src/home/widgets.ts +12 -2
- package/src/ledger/db.ts +2 -13
- package/src/ledger/gate-checks.ts +21 -3
- package/src/ledger/git-key.ts +148 -30
- package/src/ledger/git-scan.ts +5 -2
- package/src/ledger/ledger.ts +131 -16
- package/src/ledger/store.ts +14 -0
- package/src/ledger/toolchain-identity.ts +210 -25
- package/src/problems/extract.ts +3 -0
- package/src/repo-config.ts +18 -1
- package/src/serve/hub-app/admin-svc.ts +36 -3
- package/src/serve/hub-app/admin-view.tsx +33 -1
- package/src/serve/hub-app/col-card.tsx +1 -44
- package/src/serve/hub-app/col-tables-view.tsx +71 -5
- package/src/serve/hub-app/col-tables.ts +27 -0
- package/src/serve/hub-app/home-st.ts +13 -6
- package/src/serve/hub-app/home-strip.css +7 -9
- package/src/serve/hub-app/home-strip.tsx +8 -17
- package/src/serve/hub-app/home.html +1 -0
- package/src/serve/hub-app/hub-app.tsx +20 -4
- package/src/serve/hub-app/hub.css +24 -20
- package/src/serve/hub-app/icons.tsx +1 -0
- package/src/serve/hub-app/index.html +1 -0
- package/src/serve/hub-app/insights-st.ts +111 -0
- package/src/serve/hub-app/insights-view.tsx +112 -0
- package/src/serve/hub-app/insights.css +24 -0
- package/src/serve/hub-app/keys.ts +11 -3
- package/src/serve/hub-app/machine-st.ts +23 -0
- package/src/serve/hub-app/palette-st.ts +2 -1
- package/src/serve/hub-app/processes-view.tsx +16 -9
- package/src/serve/hub-app/project-list.css +4 -1
- package/src/serve/hub-app/project-list.tsx +9 -5
- package/src/serve/hub-app/projects-svc.ts +11 -1
- package/src/serve/hub-app/repo-sections.ts +31 -17
- package/src/serve/hub-app/repo-st.ts +25 -6
- package/src/serve/hub-app/sidebar.tsx +5 -5
- package/src/serve/hub-app/tree-build.ts +2 -0
- package/src/serve/hub-app/tree-row.tsx +18 -5
- package/src/serve/hub-app/tree-svc.ts +27 -7
- package/src/serve/hub-runtime.ts +4 -2
- package/src/serve/project-gutter-route.ts +22 -1
- package/src/shipkit-files.ts +3 -2
- package/src/suite/judge-suites.ts +6 -0
- package/src/trace/interpose.c +655 -0
- package/src/trace/read-trace.ts +485 -0
- package/src/trace/tracer.ts +282 -0
|
@@ -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
|
+
}
|
package/src/cli/cmd_Keys.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
package/src/cli/cmd_Overview.ts
CHANGED
|
@@ -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
|
package/src/cli/cmd_Serve.ts
CHANGED
|
@@ -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`,
|
|
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`,
|
|
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)`,
|
|
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
|
|
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 {
|
package/src/core/check-ctx.ts
CHANGED
|
@@ -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('
|
|
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
|
package/src/core/check-runner.ts
CHANGED
|
@@ -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 => {
|