rvlib-shipkit 1.8.0 → 2.0.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 (127) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/package.json +1 -1
  3. package/src/assets/gen/comfyts-gen.ts +31 -14
  4. package/src/assets/gen/generate-roll.ts +20 -7
  5. package/src/assets/kit-config.ts +9 -0
  6. package/src/assets/kits/identity-kit.ts +7 -1
  7. package/src/assets/run-assets.ts +3 -3
  8. package/src/assets/server/studio-server.ts +3 -3
  9. package/src/check.ts +5 -2
  10. package/src/checks/SK001-package-json-shape.check.ts +47 -12
  11. package/src/checks/SK013-one-way-in-policy.check.ts +8 -7
  12. package/src/checks/SK017-vscode-hub-task.check.ts +109 -106
  13. package/src/checks/SK019-tests-run-in-units.check.ts +2 -1
  14. package/src/checks/SK027-agent-wiki-links.check.ts +5 -2
  15. package/src/checks/SK029-explicit-check-files.check.ts +25 -8
  16. package/src/checks/SK034-commit-gates-armed.check.ts +90 -0
  17. package/src/cli/cmd_Ci.ts +6 -5
  18. package/src/cli/cmd_Commands.ts +21 -5
  19. package/src/cli/cmd_Hooks.ts +6 -2
  20. package/src/cli/cmd_Init.ts +1 -1
  21. package/src/cli/cmd_Serve.ts +1 -2
  22. package/src/cli/cmd_Start.ts +1 -1
  23. package/src/cli.ts +2 -4
  24. package/src/collections/providers.ts +1 -1
  25. package/src/core/budget.ts +6 -8
  26. package/src/core/bun-builtins.ts +36 -0
  27. package/src/core/bun-tool-cli.ts +23 -0
  28. package/src/core/canonical-scripts.ts +6 -4
  29. package/src/core/check-ctx.ts +104 -10
  30. package/src/core/check-file-names.ts +2 -3
  31. package/src/core/check-pass.ts +17 -1
  32. package/src/core/check-runner.ts +32 -10
  33. package/src/core/config-rewrite.ts +27 -6
  34. package/src/core/discover-checks.ts +64 -15
  35. package/src/core/fix-scope.ts +5 -0
  36. package/src/core/floor-resolve.ts +18 -8
  37. package/src/core/git-root.ts +22 -12
  38. package/src/core/job-run.ts +2 -2
  39. package/src/core/layout.ts +4 -1
  40. package/src/core/legacy-shapes.ts +40 -0
  41. package/src/core/one-lib.ts +5 -3
  42. package/src/core/one-way-in-policy.ts +106 -13
  43. package/src/core/pre-commit-discovery.ts +106 -0
  44. package/src/core/retired-checks.ts +2 -0
  45. package/src/core/run-ci.ts +23 -16
  46. package/src/core/run-fix.ts +9 -0
  47. package/src/core/run-fixers.ts +91 -35
  48. package/src/core/runs-matrix.ts +7 -0
  49. package/src/core/slots.ts +36 -6
  50. package/src/core/vendored-read.ts +39 -0
  51. package/src/core/vendored.ts +82 -0
  52. package/src/core/waivers.ts +125 -0
  53. package/src/core/when-block.ts +25 -185
  54. package/src/core/when.ts +30 -13
  55. package/src/core/wire.ts +17 -1
  56. package/src/git-bootstrap.ts +1 -1
  57. package/src/home/attach.ts +3 -5
  58. package/src/home/gui-app/ShipkitGui.swift +96 -0
  59. package/src/home/insights.ts +6 -1
  60. package/src/home/pm-config.ts +2 -2
  61. package/src/home/resources.ts +3 -0
  62. package/src/home/run-daemon.ts +2 -2
  63. package/src/home/run-unit-action.ts +2 -2
  64. package/src/hooks/commit-message.ts +2 -14
  65. package/src/hooks/hook-gates.ts +38 -0
  66. package/src/hooks/leak-guard.ts +8 -3
  67. package/src/hooks/one-way-in-guard.ts +8 -2
  68. package/src/hooks/root-identity.ts +9 -6
  69. package/src/hooks/run-hooks.ts +72 -41
  70. package/src/hooks/staged.ts +49 -0
  71. package/src/hooks/tree-shrink.ts +41 -0
  72. package/src/init-repo.ts +0 -42
  73. package/src/ledger/check-table.ts +1 -1
  74. package/src/ledger/gate-checks.ts +45 -9
  75. package/src/ledger/ledger.ts +6 -1
  76. package/src/ledger/repo-view.ts +14 -4
  77. package/src/ledger/toolchain-identity.ts +4 -2
  78. package/src/mv/mv-md.ts +6 -1
  79. package/src/plan/planner.ts +21 -4
  80. package/src/plan/repo-source.ts +7 -3
  81. package/src/problems/extract.ts +27 -5
  82. package/src/problems/problem-line.ts +2 -3
  83. package/src/release/run-release.ts +30 -16
  84. package/src/release/unit-gate.ts +12 -5
  85. package/src/repo-config.ts +51 -27
  86. package/src/scaffold/files/bunfig.toml +11 -0
  87. package/src/scaffold/files/vscode-settings.json +1 -2
  88. package/src/scaffold/jsonc-spans.ts +16 -0
  89. package/src/scaffold/project-scaffold.ts +43 -77
  90. package/src/serve/hub-app/check-view.tsx +4 -4
  91. package/src/serve/hub-app/check-words.ts +1 -0
  92. package/src/serve/hub-app/checks-svc.ts +1 -2
  93. package/src/serve/hub-app/home.html +0 -1
  94. package/src/serve/hub-app/hub-types.ts +0 -2
  95. package/src/serve/hub-app/index.html +0 -1
  96. package/src/serve/hub-app/project-list.css +1 -2
  97. package/src/serve/hub-app/tree-build.ts +3 -2
  98. package/src/serve/hub-app/tree-svc.ts +2 -2
  99. package/src/serve/hub-port.ts +3 -3
  100. package/src/serve/hub-runtime.ts +3 -5
  101. package/src/serve/project-gutter.ts +5 -2
  102. package/src/serve/run-lanes.ts +7 -3
  103. package/src/serve/serve-hub.ts +1 -16
  104. package/src/serve/tracked-watch.ts +1 -1
  105. package/src/smoke/run-smoke.ts +13 -6
  106. package/src/start-repo.ts +15 -3
  107. package/src/suite/judge-suites.ts +81 -32
  108. package/src/suite/select.ts +2 -0
  109. package/src/suite/suite-cli.ts +27 -16
  110. package/src/suite/suite-cpu-preload.ts +7 -11
  111. package/src/suite/test-units.ts +7 -4
  112. package/src/ts/config.ts +12 -5
  113. package/src/ts/imports.ts +3 -1
  114. package/src/ts/typecheck-cache.ts +11 -0
  115. package/src/ts/typecheck-cli.ts +2 -1
  116. package/src/tui/build-steps-panel.ts +1 -1
  117. package/src/wiki/scan-md.ts +16 -9
  118. package/src/checks/SK009-commit-ai-tells.check.ts +0 -131
  119. package/src/checks/SK014-prose-ste.check.ts +0 -78
  120. package/src/cli/cmd_Prose.ts +0 -74
  121. package/src/prose/prose-view.ts +0 -90
  122. package/src/prose/run-prose.ts +0 -187
  123. package/src/prose/ste.ts +0 -552
  124. package/src/scaffold/files/vscode-tasks.json +0 -24
  125. package/src/serve/hub-app/prose-hits-st.ts +0 -125
  126. package/src/serve/hub-app/prose-hits-view.tsx +0 -176
  127. package/src/serve/hub-app/prose-hits.css +0 -21
@@ -4,9 +4,13 @@ import { Command, Option } from 'clipanion'
4
4
  import { hookBinInvocation, installHooks, listGates, preCommitChecks, runCommitMsg, runPreCommit, type GateOutcome } from '../hooks/run-hooks.ts'
5
5
  import { loadRepoConfig } from '../repo-config.ts'
6
6
 
7
+ // a gate that passed prints only what still needs a reader (a WARN, a waived
8
+ // hit), so a clean commit costs one line, not one per gate
7
9
  const report = (out: GateOutcome[], write: (s: string) => void): number => {
8
- for (const g of out) for (const l of g.lines) write(`${l}\n`)
9
- return out.every((g) => g.ok) ? 0 : 1
10
+ for (const g of out) for (const l of g.lines) if (!g.ok || !l.startsWith('ok: ')) write(`${l}\n`)
11
+ const ok = out.every((g) => g.ok)
12
+ if (ok) write(`ok: ${out.length} commit gate(s) clean\n`)
13
+ return ok ? 0 : 1
10
14
  }
11
15
 
12
16
  export class HooksInstallCommand extends Command {
@@ -8,7 +8,7 @@ export class InitCommand extends Command {
8
8
 
9
9
  static override usage = Command.Usage({
10
10
  category: 'Setup',
11
- description: 'wire the current directory as a shipkit repo, like git init',
11
+ description: 'wire this dir as a shipkit repo, like git init: a private github repo and a push unless --no-github',
12
12
  details: 'wire THE CURRENT DIRECTORY as a shipkit repo, like git init: cwd only, never creates a project dir. scaffolds repo.config.ts + ci/check/contracts package.json scripts + the create-only .vscode/ that starts the watchers and the hub on folder open, prints how the floor resolved, and runs the bootstrap chain around it: git init, then first commit + PRIVATE github repo + push once the scaffold is in. Idempotent on an already-wired repo. To create or adopt ~/dev/<name> from anywhere: `shipkit start <name>`',
13
13
  examples: [
14
14
  ['wire the repo you are standing in', 'sk init'],
@@ -1,5 +1,4 @@
1
- // shipkit serve — the ONE hub server per repo (one vscode folder-open task,
2
- // clickable URLs). Thin clipanion surface; logic in serve/run-serve.ts.
1
+ // shipkit serve — the ONE hub server per repo (clickable URLs). Thin clipanion surface; logic in serve/run-serve.ts.
3
2
  import { Command, Option } from 'clipanion'
4
3
 
5
4
  export class ServeCommand extends Command {
@@ -9,7 +9,7 @@ export class StartCommand extends Command {
9
9
 
10
10
  static override usage = Command.Usage({
11
11
  category: 'Setup',
12
- description: 'create or enter ~/dev/<name> from anywhere, ready to work in',
12
+ description: 'create or enter ~/dev/<name>, ready to work in: a private github repo and a push unless --no-github',
13
13
  details: 'create (or enter) ~/dev/<name> from ANY cwd, READY to work in. On any dir with no commit history it writes the day-one shape (package.json + src/index.ts + lint/fmt/typecheck, tsconfig, .vscode tasks + settings, the verify skill, an agent/ wiki, .shipkit/reflections), installs the toolchain, wires shipkit (repo.config.ts via init), and runs the whole bootstrap chain: git init, first commit, PRIVATE github repo, push. Loose files ride in that first commit, and only a repo with real history is left as it is. Writes are create-only for files, additive for package.json scripts and .gitignore. CLAUDE.md is the two-line house template on its own (HOUSE_INCLUDE in src/start-repo.ts: the base wiki include, then `@./agent/index.md`, and --include overrides it), applied only on a machine where the house index exists, and the agent route lands last whenever the repo has agent/index.md. The house lines also opt the repo into the .claude/ policy files (a pre-approved shell allowlist + the attribution silencer), so a start with no house widens no agent permissions. Prints the dir on stdout so a shell alias can cd there. cwd-only sibling: `shipkit init`',
14
14
  examples: [
15
15
  ['create or enter ~/dev/pong: dir, package.json, shipkit wiring, git, first commit, private github repo, push, the two-line CLAUDE.md', 'sk start pong'],
package/src/cli.ts CHANGED
@@ -26,7 +26,6 @@ import { KeysCommand } from './cli/cmd_Keys.ts'
26
26
  import { MvCommand } from './cli/cmd_Mv.ts'
27
27
  import { NeedsCommand } from './cli/cmd_Needs.ts'
28
28
  import { PbCollectionCommand } from './cli/cmd_Pb.ts'
29
- import { ProseCommand } from './cli/cmd_Prose.ts'
30
29
  import { ReflectCommand } from './cli/cmd_Reflect.ts'
31
30
  import { ReleaseCommand } from './cli/cmd_Release.ts'
32
31
  import { ReleasesCommand } from './cli/cmd_Releases.ts'
@@ -91,7 +90,6 @@ export function buildCli(): Cli {
91
90
  cli.register(NeedsCommand)
92
91
  cli.register(FleetCommand)
93
92
  cli.register(PbCollectionCommand)
94
- cli.register(ProseCommand)
95
93
  cli.register(SmokeCommand)
96
94
  cli.register(ReflectCommand)
97
95
  cli.register(KeysCommand)
@@ -125,8 +123,8 @@ if (import.meta.main) {
125
123
  const argv = aliasArgs(process.argv.slice(2))
126
124
  const { handoff } = await import('./home/handoff.ts')
127
125
  handoff(argv)
128
- const { useOneLib } = await import('./core/one-lib.ts')
129
- useOneLib()
126
+ const { installOneLib } = await import('./core/one-lib.ts')
127
+ installOneLib()
130
128
  const { autostart } = await import('./home/autostart.ts')
131
129
  await autostart(argv)
132
130
  void buildCli().runExit(argv)
@@ -413,7 +413,7 @@ export function buildCollections(root: string, c: RepoConfig, kit: ResolvedKit |
413
413
  contractsCollection(root, c),
414
414
  asksCollection(root, c),
415
415
  credsCollection(root, c, { verdicts: () => verdictsOf(checks) }),
416
- mediaKitCollection(root, kit),
416
+ mediaKitCollection(root, c.serve?.assets === false ? null : kit),
417
417
  socialPostsCollection(root),
418
418
  reflectionsCollection(root),
419
419
  testsCollection(root, c),
@@ -6,16 +6,14 @@
6
6
  import { availableParallelism, loadavg } from 'os'
7
7
  import type { MachineLoad } from '../suite/judge-suites.ts'
8
8
 
9
- /** an in process body, a rule or a page check. Read off a run record of 64
10
- * such checks: the median rule answers in under 50ms, 55 of them stay under
11
- * 2s at their 90th percentile, and the few past it shell out to a tool */
12
- export const IN_PROCESS_BUDGET_MS = 2_000
9
+ /** an in process body, a rule or a page check: the house bar is under 1 s
10
+ * (identity/how-we-work.md, Velocity). The median rule answers in under 50ms */
11
+ export const IN_PROCESS_BUDGET_MS = 1_000
13
12
 
14
13
  /** a subprocess (a build step, a test suite, a `cmd` check) or a world probe
15
- * (a `ttl` check, or a `never:` one, whose answer lives outside the repo).
16
- * Read off the same record: every typecheck stays under 6s at its 90th
17
- * percentile, and the quick test suites under 6s but one */
18
- export const SUBPROCESS_BUDGET_MS = 10_000
14
+ * (a `ttl` check, or a `never:` one, whose answer lives outside the repo):
15
+ * the house ceiling is 5 s, and one over it is a defect to fix */
16
+ export const SUBPROCESS_BUDGET_MS = 5_000
19
17
 
20
18
  /** what a check declares about itself that picks its default. `world`: it
21
19
  * declares `ttl` or `never`, so it reads the world rather than the repo */
@@ -0,0 +1,36 @@
1
+ // bun's own linter as the default lint slot, and bun's formatter as the
2
+ // manual `bun fmt` (never a gate step). A repo opts in by declaring the tool's
3
+ // section in bunfig.toml: the section is where its config lives.
4
+ //
5
+ // bun before 1.4.4 has neither command, and there `bun lint` falls through to
6
+ // the package.json `lint` script. A script that calls `bun lint` then calls
7
+ // itself forever, so an old bun gets a loud refusal instead of a spawn. The
8
+ // lint slot runs through core/bun-tool-cli.ts, which keeps a same-named script out.
9
+ import { fileURLToPath } from 'url'
10
+
11
+ export type BunTool = 'lint' | 'fmt'
12
+
13
+ export const BUN_TOOLS_SINCE = '1.4.4'
14
+
15
+ /** does the bun running shipkit ship `bun lint` and `bun fmt` */
16
+ export const bunHasTools = (version: string = Bun.version): boolean => Bun.semver.order(version.replace(/-.*$/, ''), BUN_TOOLS_SINCE) >= 0
17
+
18
+ const header = (tool: BunTool): RegExp => new RegExp(`^\\s*\\[${tool}(?:\\.[^\\]]+)?\\]\\s*(?:#.*)?$`)
19
+
20
+ /** does this bunfig.toml text declare `[lint]` / `[fmt]` (or a subtable of it) */
21
+ export function bunfigDeclares(bunfig: string | null, tool: BunTool): boolean {
22
+ return bunfig != null && bunfig.split('\n').some((l) => header(tool).test(l))
23
+ }
24
+
25
+ /** why this bun cannot run the tool, or null when it can */
26
+ export function bunToolRefusal(tool: BunTool, version: string = Bun.version): string | null {
27
+ return bunHasTools(version) ? null : `bunfig.toml declares [${tool}] and bun ${version} has no \`bun ${tool}\` (it ships in ${BUN_TOOLS_SINCE}): run \`bun upgrade --canary\``
28
+ }
29
+
30
+ /** the argv the lint slot runs: the built-in, through the runner that keeps a
31
+ * same-named package.json script out of its way. The repo's vendored dirs
32
+ * are never walked */
33
+ export function bunLintCmd(vendored: readonly string[] = []): string[] {
34
+ const cli = fileURLToPath(import.meta.resolve('rvlib-shipkit/src/core/bun-tool-cli.ts'))
35
+ return [process.execPath, cli, 'lint', '--format', 'unix', ...vendored.map((d) => `--ignore-pattern=${d}/**`)]
36
+ }
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env bun
2
+ // runs `bun lint` as the BUILT-IN, whatever package.json holds.
3
+ // bun runs a script of the same name in its place, flags and all, unless the
4
+ // call says it is that script: `npm_lifecycle_event` names the tool, and
5
+ // `npm_package_json` is absent or names this manifest by its resolved path.
6
+ // A run started under another package's `bun run` inherits that package's
7
+ // path, so it is dropped here (measured, bun 1.4.4).
8
+ import { bunToolRefusal } from './bun-builtins.ts'
9
+
10
+ const [tool, ...args] = process.argv.slice(2)
11
+ if (tool !== 'lint') {
12
+ console.error(`bun-tool-cli: expected lint, got ${tool ?? 'nothing'}`)
13
+ process.exit(2)
14
+ }
15
+ const refused = bunToolRefusal(tool)
16
+ if (refused != null) {
17
+ console.error(refused)
18
+ process.exit(1)
19
+ }
20
+ const env: Record<string, string | undefined> = { ...process.env, npm_lifecycle_event: tool }
21
+ delete env.npm_package_json
22
+ const p = Bun.spawnSync([process.execPath, tool, ...args], { stdio: ['inherit', 'inherit', 'inherit'], env })
23
+ process.exit(p.exitCode ?? 1)
@@ -36,10 +36,12 @@ export function conditionalBodies(_opts: ScriptStyle = {}): Record<string, strin
36
36
  // used to hold moves to `<name>:raw`, which is what shipkit itself runs and
37
37
  // what an agent uses to iterate on one repro file.
38
38
  //
39
- // only these three. `imports:check` and `cycles` are shipkit's own vocabulary
40
- // (nobody reaches for them by reflex) and `fmt` is the slot shipkit's fix pass
41
- // invokes, so routing it would make the fixer call itself.
42
- export const ROUTED_SCRIPTS = ['test', 'lint', 'typecheck'] as const
39
+ // only these two. `imports:check` and `cycles` are shipkit's own vocabulary
40
+ // (nobody reaches for them by reflex), `fmt` is a manual tool no gate runs,
41
+ // and `lint` is
42
+ // `bun lint` itself: a `lint` script would shadow the built-in, which lints a
43
+ // whole repo in well under a second, with nothing for a cache to save.
44
+ export const ROUTED_SCRIPTS = ['test', 'typecheck'] as const
43
45
 
44
46
  export const RAW_SUFFIX = ':raw'
45
47
 
@@ -1,7 +1,8 @@
1
1
  // CheckCtx — the ONE parameter a check body receives, and its only door to the
2
2
  // repo. Four parts:
3
3
  //
4
- // report c.ok / note / warn / fail / unreachable, the wire lines (wire.ts)
4
+ // report c.ok / note / warn / fail / unreachable, the wire lines (wire.ts),
5
+ // a finding with its site, which an in-file directive can waive
5
6
  // where c.root, c.config, c.frozen
6
7
  // reads c.read / json / exists / isLink / list / entries, each recorded with what it saw, and
7
8
  // c.use(scan) for a scan every check of the pass shares (scan.ts)
@@ -27,7 +28,12 @@ import { normalizeInput, WHOLE_TREE } from '../ledger/git-scan.ts'
27
28
  import { shipkitRoot } from './git-root.ts'
28
29
  import type { RepoConfig } from '../repo-config.ts'
29
30
  import type { Scan } from '../scan.ts'
30
- import type { Line } from './wire.ts'
31
+ import { siteText, type Line, type Site } from './wire.ts'
32
+ import { DIRECTIVE_MARK, parseDirectives, reaches, spell, type Directive } from './waivers.ts'
33
+
34
+ /** what `c.warn` and `c.fail` take after the message: the fix pointer alone,
35
+ * or the pointer and the site the finding sits at */
36
+ export type FindingMore = string | { fix?: string; at?: Site }
31
37
 
32
38
  // thrown by t.fetch on 429: no answer, so the body runner (check.ts
33
39
  // runCheckBody) reports it as unreachable, never as a FAIL. It knows it by
@@ -56,6 +62,16 @@ export class CheckCtx {
56
62
  /** why the reads cannot describe this answer (it reached the world), null
57
63
  * while they can */
58
64
  untracked: string | null = null
65
+ /** the check this context reports for, set by the body runner. A
66
+ * directive waives a site only when it names this id */
67
+ checkId: string | null = null
68
+ /** the text of every repo file read here that carries a directive mark,
69
+ * by path: what a waiver and the unused directive scan parse */
70
+ readonly marked = new Map<string, string>()
71
+ private readonly parsed = new Map<string, Directive[]>()
72
+ private readonly siteFiles = new Set<string>()
73
+ private readonly waived: { text: string; at: Site; by: string; reason: string }[] = []
74
+ private readonly usedDirectives = new Set<string>()
59
75
 
60
76
  constructor(
61
77
  /** the runner pass this body runs in. Absent for a context that only
@@ -73,19 +89,94 @@ export class CheckCtx {
73
89
  note(msg: string): void { this.lines.push({ kind: 'note', text: msg }) }
74
90
  /** a WARN that came back from something we could not reach — no answer, so
75
91
  * nothing to remember */
76
- unreachable(msg: string, fix?: string): void {
92
+ unreachable(msg: string, more?: FindingMore): void {
77
93
  this.unmeasured = true
78
- this.warn(msg, fix)
94
+ this.warn(msg, more)
79
95
  }
80
- warn(msg: string, fix?: string): void {
96
+ /** a defect that does not stop you. `more` is the fix pointer, or
97
+ * `{ fix, at }` with the site, which a directive in that file can waive */
98
+ warn(msg: string, more?: FindingMore): void {
99
+ const f = this.finding(msg, more)
100
+ if (f == null) return
81
101
  this.warned = true
82
- this.lines.push({ kind: 'warn', text: msg })
83
- if (fix != null) this.lines.push({ kind: 'fix-warn', text: fix })
102
+ this.lines.push({ kind: 'warn', text: f.text, ...(f.at == null ? {} : { at: f.at }) })
103
+ if (f.fix != null) this.lines.push({ kind: 'fix-warn', text: f.fix })
84
104
  }
85
- fail(msg: string, fix?: string): void {
105
+ /** broken. `more` is the fix pointer, or `{ fix, at }` with the site,
106
+ * which a directive in that file can waive */
107
+ fail(msg: string, more?: FindingMore): void {
108
+ const f = this.finding(msg, more)
109
+ if (f == null) return
86
110
  this.failed = true
87
- this.lines.push({ kind: 'fail', text: msg })
88
- if (fix != null) this.lines.push({ kind: 'fix-fail', text: fix })
111
+ this.lines.push({ kind: 'fail', text: f.text, ...(f.at == null ? {} : { at: f.at }) })
112
+ if (f.fix != null) this.lines.push({ kind: 'fix-fail', text: f.fix })
113
+ }
114
+
115
+ // ── waivers (core/waivers.ts owns the grammar) ────────────────────────
116
+ /** the finding as it prints, or null when a directive waived its site.
117
+ * A message that does not name its file gets the site in front */
118
+ private finding(msg: string, more: FindingMore | undefined): { text: string; fix?: string; at?: Site } | null {
119
+ const { fix, at } = typeof more === 'object' ? more : { fix: more, at: undefined }
120
+ if (at == null) return { text: msg, fix }
121
+ const text = msg.includes(at.file) ? msg : `${siteText(at)}: ${msg}`
122
+ const by = this.waiverFor(at)
123
+ if (by == null) return { text, fix, at }
124
+ this.waived.push({ text, at, by: `${at.file}:${by.line}`, reason: by.reason ?? '' })
125
+ return null
126
+ }
127
+ /** the directive that waives this site for this check. The file is read
128
+ * through `c`, so it joins the answer's key: a directive added or
129
+ * removed afterwards asks the question again */
130
+ private waiverFor(at: Site): Directive | null {
131
+ if (this.checkId == null || this.pass == null || isAbsolute(at.file)) return null
132
+ let rel: string
133
+ try { rel = this.rel(at.file) } catch { return null }
134
+ this.siteFiles.add(rel)
135
+ this.read(rel)
136
+ for (const d of this.directivesIn(rel)) {
137
+ if (d.reason == null || !d.ids.includes(this.checkId) || !reaches(d, at.line)) continue
138
+ this.usedDirectives.add(`${rel}:${d.line}`)
139
+ return d
140
+ }
141
+ return null
142
+ }
143
+ private directivesIn(file: string): Directive[] {
144
+ const text = this.marked.get(file)
145
+ if (text == null) return []
146
+ let ds = this.parsed.get(file)
147
+ if (ds == null) this.parsed.set(file, (ds = this.pass?.directives(file, text) ?? parseDirectives(file, text)))
148
+ return ds
149
+ }
150
+ /** after the body, once: count the waived sites, and WARN on every
151
+ * directive naming this check, in a file it read, that waived nothing
152
+ * or gave no reason. A directive for a retired id, or in a file no
153
+ * check reads any more, is nobody's to report */
154
+ settleWaivers(): void {
155
+ const id = this.checkId
156
+ if (id == null) return
157
+ const loud = (at: Site, text: string, fix: string): void => {
158
+ this.warned = true
159
+ this.lines.push({ kind: 'warn', text: `${siteText(at)}: ${text}`, at }, { kind: 'fix-warn', text: fix })
160
+ }
161
+ // a site, not a finding: one line can hold several findings
162
+ const n = new Set(this.waived.map((w) => siteText(w.at))).size
163
+ if (n > 0) this.lines.push({ kind: 'skip', text: `${n === 1 ? '1 site waived in its file' : `${n} sites waived in their files`} (--verbose lists them)`, waiver: 'count' })
164
+ for (const w of this.waived) this.lines.push({ kind: 'skip', text: `waived by ${w.by} (${w.reason}): ${w.text}`, at: w.at, waiver: 'site' })
165
+ for (const file of this.marked.keys())
166
+ for (const d of this.directivesIn(file)) {
167
+ if (!d.ids.includes(id)) continue
168
+ const at = { file, line: d.line }
169
+ if (d.reason == null) loud(at, `\`${spell(d, id)}\` gives no reason after \` -- \`, so it waives nothing`, `say why the site is fine: \`${spell(d, id)} -- <reason>\``)
170
+ else if (!this.usedDirectives.has(`${file}:${d.line}`))
171
+ loud(at, `\`${spell(d, id)}\` waives nothing ${d.scope === 'file' ? 'in this file' : `on line ${d.scope === 'line' ? d.line : d.line + 1}`}`, 'delete the directive, or move it onto the site it was written for')
172
+ }
173
+ }
174
+ /** every file whose directives this answer depends on: the files of its
175
+ * findings, and the files carrying a directive that names it */
176
+ waiverFiles(): string[] {
177
+ const id = this.checkId
178
+ const named = id == null ? [] : [...this.marked.keys()].filter((f) => this.directivesIn(f).some((d) => d.ids.includes(id)))
179
+ return [...new Set([...this.siteFiles, ...named])].sort()
89
180
  }
90
181
 
91
182
  // ── where ─────────────────────────────────────────────────────────────
@@ -99,6 +190,7 @@ export class CheckCtx {
99
190
  * it and none that did not */
100
191
  get config(): RepoConfig {
101
192
  const p = this.repo('config')
193
+ if (!p.recordsConfig) return p.config
102
194
  const deps = p.configReads()
103
195
  for (const [f, oid] of deps.files) this.reads.file(f, oid)
104
196
  if (deps.untracked != null && this.untracked == null) {
@@ -150,6 +242,7 @@ export class CheckCtx {
150
242
  return null
151
243
  }
152
244
  this.reads.file(p, blobOid(bytes))
245
+ if (!isAbsolute(p) && bytes.includes(DIRECTIVE_MARK)) this.marked.set(p, bytes.toString('utf8'))
153
246
  return bytes
154
247
  }
155
248
  /** a repo-relative json file, parsed. null when absent, and a parse error
@@ -206,6 +299,7 @@ export class CheckCtx {
206
299
  const s = pass.scan(scan, () => new CheckCtx(pass))
207
300
  const value = await s.value
208
301
  this.reads.merge(s.ctx.reads)
302
+ for (const [f, text] of s.ctx.marked) this.marked.set(f, text)
209
303
  if (s.ctx.untracked != null) this.untrack(s.ctx.untracked)
210
304
  return value
211
305
  }
@@ -83,9 +83,8 @@ export type LegacyCheck = {
83
83
 
84
84
  // read without importing: checks run as subprocesses, and discovery must stay
85
85
  // pure-fs. `export const blocking = true` (or false), nothing cleverer.
86
- // the tail is the gate's OWN formatter: oxfmt terminates every statement, and
87
- // the fix pass runs it, so a reader that refuses a semicolon refuses the shape
88
- // the gate itself just wrote.
86
+ // the tail takes what a formatter on its defaults writes: it terminates every
87
+ // statement, so a reader that refuses a semicolon refuses a formatted file.
89
88
  const TAIL = String.raw`(?:\s+as\s+const)?\s*;?\s*$`
90
89
  const BLOCKING_RE = new RegExp(String.raw`^\s*export\s+const\s+blocking\s*(?::\s*boolean\s*)?=\s*(true|false)` + TAIL, 'm')
91
90
  // `export const inputs = ['a', 'b']`, single or multi line. Anything cleverer
@@ -12,9 +12,10 @@ import { importClosure } from '../ts/source-graph.ts'
12
12
  import { LIB_ROOT } from './lib-root.ts'
13
13
  import type { RepoConfig } from '../repo-config.ts'
14
14
  import type { Scan } from '../scan.ts'
15
+ import { parseDirectives, type Directive } from './waivers.ts'
15
16
 
16
17
  /** a scan's context, and what it recorded while the scan ran */
17
- type ScanRecorder = ScanCtx & { readonly reads: Reads; readonly untracked: string | null }
18
+ type ScanRecorder = ScanCtx & { readonly reads: Reads; readonly untracked: string | null; readonly marked: ReadonlyMap<string, string> }
18
19
 
19
20
  /** the config file every check that consults `c.config` depends on */
20
21
  export const CONFIG_FILE = 'repo.config.ts'
@@ -27,6 +28,7 @@ export class CheckPass {
27
28
  private readonly scans = new Map<string, { value: Promise<unknown>; ctx: ScanRecorder }>()
28
29
  private viewMemo: RepoView | null = null
29
30
  private configMemo: { files: Map<string, string | null>; untracked: string | null } | null = null
31
+ private readonly directiveMemo = new Map<string, { text: string; directives: Directive[] }>()
30
32
 
31
33
  constructor(
32
34
  readonly root: string,
@@ -34,6 +36,10 @@ export class CheckPass {
34
36
  /** where the file list comes from: the run's ledger shares its own,
35
37
  * so a listing and its validation read one snapshot */
36
38
  private readonly openView: () => RepoView = () => RepoView.open(root),
39
+ /** false for a pass whose answers nothing stores (the pre-commit
40
+ * gate): listing what the config imports is then pure cost, measured
41
+ * at 12 to 70ms on the first check that reads `c.config` */
42
+ readonly recordsConfig = true,
37
43
  ) {
38
44
  this.configOid = oidOnDisk(join(root, CONFIG_FILE))
39
45
  }
@@ -61,6 +67,16 @@ export class CheckPass {
61
67
  return this.configMemo
62
68
  }
63
69
 
70
+ /** the directives of a file as read, parsed once per pass however many
71
+ * checks read it */
72
+ directives(path: string, text: string): Directive[] {
73
+ const hit = this.directiveMemo.get(path)
74
+ if (hit != null && hit.text === text) return hit.directives
75
+ const directives = parseDirectives(path, text)
76
+ this.directiveMemo.set(path, { text, directives })
77
+ return directives
78
+ }
79
+
64
80
  view(): RepoView {
65
81
  this.viewMemo ??= this.openView()
66
82
  return this.viewMemo
@@ -248,6 +248,12 @@ export function testsRan(lastLine: string): number | null {
248
248
  return m == null ? null : Number(m[1])
249
249
  }
250
250
 
251
+ /** the command the suite runner says it ran, read off the same headline
252
+ * (`…, as \`bun test --parallel=8\` (added --parallel=8)`, judge-suites ranAs) */
253
+ export function testsCommand(lastLine: string): string | null {
254
+ return /, (as `[^`]+`(?: \(added [^)]+\))?)/.exec(lastLine)?.[1] ?? null
255
+ }
256
+
251
257
  export type Selection = {
252
258
  /** what this run measures */
253
259
  selected: RunnableCheck[]
@@ -444,6 +450,8 @@ type Live = RunnableCheck & CheckResult & {
444
450
  reads: ReadSet | null
445
451
  /** why its answer cannot be stored on its reads, in its own words */
446
452
  untracked: string | null
453
+ /** the files whose in-file directives its answer depends on */
454
+ waiverFiles: string[]
447
455
  /** it printed nothing of its own, so its one line is the headline below */
448
456
  synthetic: boolean
449
457
  /** what the headline carries after the id: a failure's last line, or the
@@ -500,7 +508,7 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
500
508
  const runDir = openRunDir(o.logDir)
501
509
  const checks: Live[] = sel.selected.map((u) => ({
502
510
  ...u,
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: [],
511
+ 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, waiverFiles: [], synthetic: false, tail: '', extra: [],
504
512
  logPath: u.body.cmd != null ? join(runDir, `${u.id.replace(/[^a-zA-Z0-9-]/g, '_')}.log`) : null,
505
513
  }))
506
514
  const selectedIds = new Set(checks.map((u) => u.id))
@@ -580,9 +588,12 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
580
588
  const ran = u.status === 'pass' || u.status === 'warn' ? testsRan(u.lastLine) : null
581
589
  const counted = ran != null && head != null && head.kind === 'ok' && (head.text === u.id || head.text.startsWith(`${u.id} (`))
582
590
  const id = counted ? `${u.id} ${ran} test${ran === 1 ? '' : 's'}` : u.id
591
+ // and the command the suite runner ran, which can differ from the script's body
592
+ const how = counted ? testsCommand(u.lastLine) : null
593
+ const as = how == null ? '' : ` ${how}`
583
594
  const composed = u.synthetic && !u.replayed && head != null
584
- ? [{ ...head, text: `${id} ${ranStamp(u.ms)}${u.tail}` }, ...u.lines.slice(1)]
585
- : counted ? [{ ...head, text: `${id}${head.text.slice(u.id.length)}` }, ...u.lines.slice(1)] : u.lines
595
+ ? [{ ...head, text: `${id} ${ranStamp(u.ms)}${u.tail}${as}` }, ...u.lines.slice(1)]
596
+ : counted ? [{ ...head, text: `${id}${head.text.slice(u.id.length)}${as}` }, ...u.lines.slice(1)] : u.lines
586
597
  // every line says where its answer came from: a replay carries its
587
598
  // cached stamp already, so one that ran here says so and what it cost
588
599
  const shown = u.synthetic || u.replayed || u.attached || u.startedAt === 0 || composed[0] == null
@@ -590,8 +601,9 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
590
601
  : [ranHere(composed[0], u.ms), ...composed.slice(1)]
591
602
  // a caller printing its own verdict wants one line per green check and
592
603
  // only the warnings of a warned one; a failure prints whole
604
+ // a waived site is detail whatever the verdict, and its count never is
593
605
  const quiet = (l: Line, i: number): boolean =>
594
- o.detail != null && (u.status === 'pass' ? i > 0 : u.status === 'warn' || u.advisory ? l.kind === 'ok' || l.kind === 'note' || l.kind === 'skip' : false)
606
+ o.detail != null && l.waiver !== 'count' && (l.waiver === 'site' || (u.status === 'pass' ? i > 0 : u.status === 'warn' || u.advisory ? l.kind === 'ok' || l.kind === 'note' || l.kind === 'skip' : false))
595
607
  for (const [i, l] of shown.entries()) if (quiet(l, i)) o.detail!(formatLine(l, color)); else emit(l)
596
608
  if (u.status === 'fail' && !u.advisory && u.logPath != null)
597
609
  emit({ kind: 'fix-fail', text: `full log: ${u.logPath}` })
@@ -632,6 +644,7 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
632
644
  await runCheckBody({ id: u.id, title: u.title, owner: u.owner, run: u.body.run, fix: u.fix }, t)
633
645
  u.reads = t.untracked == null ? t.reads.snapshot() : null
634
646
  u.untracked = t.untracked
647
+ u.waiverFiles = t.waiverFiles()
635
648
  u.lines = t.lines
636
649
  u.problems = findings(u, t.lines)
637
650
  u.unmeasured = t.unmeasured
@@ -709,6 +722,13 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
709
722
  let i = parsed.length
710
723
  while (i > 0 && (parsed[i - 1]!.kind === 'fail' || parsed[i - 1]!.kind === 'fix-fail')) i--
711
724
  u.extra = parsed.slice(i).filter((l) => !(l.kind === 'fail' && `FAIL: ${l.text}` === u.lastLine)).slice(-MAX_EXTRA)
725
+ // a tool that speaks no wire (a linter, tsc) ends on a recap, and
726
+ // its errors are the findings its output parsed into
727
+ if (!u.extra.some((l) => l.kind === 'fail'))
728
+ u.extra = u.problems
729
+ .filter((p) => p.severity === 'error' && !u.lastLine.includes(p.message))
730
+ .slice(0, MAX_EXTRA)
731
+ .map((p) => ({ kind: 'fail', text: `${p.file}:${p.line}:${p.col}: ${p.message}` }))
712
732
  }
713
733
  }
714
734
 
@@ -723,9 +743,9 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
723
743
 
724
744
  /** what an attached asker adopts: the verdict and the lines BEFORE this
725
745
  * run's own demotion, which the asker applies from its own declaration */
726
- type Answer = Pick<Live, 'status' | 'lines' | 'problems' | 'lastLine' | 'exitCode' | 'logPath' | 'synthetic' | 'tail'> & { measuredMs: number }
746
+ type Answer = Pick<Live, 'status' | 'lines' | 'problems' | 'lastLine' | 'exitCode' | 'logPath' | 'synthetic' | 'tail' | 'waiverFiles'> & { measuredMs: number }
727
747
  const answerOf = (u: Live): Answer => ({
728
- status: u.status, lines: u.lines, problems: u.problems, lastLine: u.lastLine, exitCode: u.exitCode, logPath: u.logPath,
748
+ status: u.status, lines: u.lines, problems: u.problems, lastLine: u.lastLine, exitCode: u.exitCode, logPath: u.logPath, waiverFiles: u.waiverFiles,
729
749
  synthetic: u.synthetic, tail: u.tail, measuredMs: (u.endedAt > 0 ? u.endedAt : clock()) - u.startedAt,
730
750
  })
731
751
  // only a GREEN is ever stored. A pass is one whatever the check blocks. A
@@ -768,7 +788,7 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
768
788
  u.lines = head == null ? [] : [{ ...head, text: `${head.text}${stamp}` }, ...a.lines.slice(1)]
769
789
  u.problems = feeds(u) ? a.problems ?? [] : []
770
790
  if (o.ledger != null && storable(u))
771
- o.ledger.record(u.id, { status: u.status, lines: a.lines, lastLine: a.lastLine, problems: u.problems, measuredMs: a.measuredMs })
791
+ o.ledger.record(u.id, { status: u.status, lines: a.lines, lastLine: a.lastLine, problems: u.problems, measuredMs: a.measuredMs }, undefined, undefined, a.waiverFiles)
772
792
  }
773
793
 
774
794
  /** ONE measurement per in flight question: an asker whose exact key
@@ -798,11 +818,11 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
798
818
  // attached wait runs nothing, so it holds none
799
819
  const slot = pool == null ? null : await takeSlot(pool, { repo: o.config.name, check: u.id }, {
800
820
  abort: () => interrupted || bailed,
801
- onWait: (held) => {
821
+ onWait: (held, why) => {
802
822
  if (slotWaitSaid) return
803
823
  slotWaitSaid = true
804
824
  const who = held.slice(0, 3).map((h) => `${h.repo}/${h.check} pid ${h.pid}`).join(', ')
805
- emit({ kind: 'ok', text: `waiting for a machine slot: ${held.length} of ${pool.size} held${who === '' ? '' : ` (${who}${held.length > 3 ? ', …' : ''})`}` })
825
+ emit({ kind: 'ok', text: `waiting for a machine slot: ${held.length} held, ${why}${who === '' ? '' : ` (${who}${held.length > 3 ? ', …' : ''})`}` })
806
826
  },
807
827
  })
808
828
  if (pool != null && slot == null) { u.status = 'skip'; u.note = bailed ? 'bailed' : 'interrupted'; u.lines = []; u.unmeasured = true; return }
@@ -826,12 +846,14 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
826
846
  u.lines = rememberWorld(o.cache, u.id, worldKey(u), u.ttlMs, {
827
847
  lines: u.lines,
828
848
  clean,
849
+ // a waiver count is a skip line: a world key cannot see the
850
+ // waiving file, so a waived world answer is never kept
829
851
  unmeasured: u.unmeasured || u.lines.some((l) => l.kind === 'skip'),
830
852
  keepNegative: u.blocking === false,
831
853
  })
832
854
  }
833
855
  if (o.ledger != null && keep)
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)
856
+ 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, u.waiverFiles)
835
857
  }
836
858
 
837
859
  const start = (u: Live): void => {
@@ -13,7 +13,7 @@
13
13
  import { readFileSync } from 'fs'
14
14
  import { join } from 'path'
15
15
  import { atomicWrite } from './locked-write.ts'
16
- import { dropConfigKey } from './legacy-shapes.ts'
16
+ import { dropConfigEntry, dropConfigKey } from './legacy-shapes.ts'
17
17
  import { writeGateBlock } from './when-block.ts'
18
18
  import { loadRepoConfig } from '../repo-config.ts'
19
19
 
@@ -25,6 +25,12 @@ export type ConfigPlan = {
25
25
  drop: { key: string; why: string; phase?: 'first' | 'afterOff' }[]
26
26
  /** the config's `name` before the rewrite: what the read back must still say */
27
27
  name: string
28
+ /** entries of an object key to lift into `off` (`hooks.disable`, a
29
+ * `defaults` slot set to false), dropped once the block took them. The
30
+ * comment written right above an entry becomes the reason of every id it
31
+ * carries, since it is the author's own words; `reason` is the honest
32
+ * fallback when there is none */
33
+ lift?: { parent: string; entry: string; value: string; ids: string[]; reason: Record<string, string> }[]
28
34
  }
29
35
 
30
36
  export type ConfigRewrite = {
@@ -48,17 +54,23 @@ export async function rewriteConfig(root: string, plan: ConfigPlan): Promise<Con
48
54
  let first = before
49
55
  for (const d of plan.drop.filter((x) => x.phase === 'first')) first = drop(first, d)
50
56
  if (first !== before) atomicWrite(path, first)
57
+ // each lifted entry's reason, read off the text before anything moves
58
+ const off = { ...plan.off }
59
+ for (const l of plan.lift ?? []) {
60
+ const seen = dropConfigEntry(first, l.parent, l.entry, l.value)
61
+ for (const id of l.ids) off[id] ??= seen?.comment ?? l.reason[id] ?? ''
62
+ }
51
63
  let offWritten = false
52
- if (Object.keys(plan.off).length > 0) {
64
+ if (Object.keys(off).length > 0) {
53
65
  try {
54
- await writeGateBlock(root, { off: plan.off })
66
+ await writeGateBlock(root, { off })
55
67
  offWritten = true
56
- did.push(`${Object.keys(plan.off).length} off entr(ies) written into the managed block`)
68
+ did.push(`${Object.keys(off).length} off entr(ies) written into the managed block`)
57
69
  } catch (e) {
58
70
  // a key dropped first held these entries: without the block they
59
71
  // would be gone, so the file goes back as it was
60
- if (first !== before) { atomicWrite(path, before); return { did: [], hand, restored: `the managed block refused off ${JSON.stringify(plan.off)}: ${String(e).slice(0, 160)}` } }
61
- hand.push(`the managed block refused the rewrite (${String(e).slice(0, 160)}) — write off: ${JSON.stringify(plan.off)} into it by hand`)
72
+ if (first !== before) { atomicWrite(path, before); return { did: [], hand, restored: `the managed block refused off ${JSON.stringify(off)}: ${String(e).slice(0, 160)}` } }
73
+ hand.push(`the managed block refused the rewrite (${String(e).slice(0, 160)}) — write off: ${JSON.stringify(off)} into it by hand`)
62
74
  }
63
75
  }
64
76
  const mid = readFileSync(path, 'utf8')
@@ -67,11 +79,20 @@ export async function rewriteConfig(root: string, plan: ConfigPlan): Promise<Con
67
79
  if (d.phase === 'afterOff' && !offWritten) continue
68
80
  text = drop(text, d)
69
81
  }
82
+ // a lifted entry goes only once the block holds it, so it is never lost
83
+ for (const l of offWritten ? plan.lift ?? [] : []) {
84
+ const next = dropConfigEntry(text, l.parent, l.entry, l.value)
85
+ if (next == null) { hand.push(`repo.config.ts \`${l.parent}.${l.entry}\` is \`off\` now, and the entry could not be read mechanically — delete it by hand`); continue }
86
+ did.push(`repo.config.ts drops \`${l.parent}.${l.entry}\``)
87
+ text = next.text
88
+ }
70
89
  if (text !== mid) atomicWrite(path, text)
71
90
  if (text !== before) {
72
91
  try {
73
92
  const after = await loadRepoConfig(root, { fresh: true })
74
93
  if (after.name !== plan.name) throw new Error(`the config reads as '${String(after.name)}'`)
94
+ const lost = Object.keys(off).filter((id) => after.off?.[id] == null)
95
+ if (offWritten && lost.length > 0) throw new Error(`the config no longer turns ${lost.join(', ')} off`)
75
96
  } catch (e) {
76
97
  atomicWrite(path, before)
77
98
  return { did, hand, restored: String(e).slice(0, 160) }