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
|
@@ -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
|
|
205
|
-
//
|
|
206
|
-
//
|
|
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
|
-
//
|
|
218
|
-
// `defaults
|
|
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')
|
package/src/core/git-root.ts
CHANGED
|
@@ -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 = {
|
|
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
|
package/src/core/one-lib.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/core/read-set.ts
CHANGED
|
@@ -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
|
-
|
|
132
|
-
|
|
133
|
-
if (
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
}
|
package/src/core/run-ci.ts
CHANGED
|
@@ -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)
|
|
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
|
package/src/core/run-fixers.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
|