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.
- package/CHANGELOG.md +66 -0
- package/package.json +1 -1
- package/src/assets/gen/comfyts-gen.ts +31 -14
- package/src/assets/gen/generate-roll.ts +20 -7
- package/src/assets/kit-config.ts +9 -0
- package/src/assets/kits/identity-kit.ts +7 -1
- package/src/assets/run-assets.ts +3 -3
- package/src/assets/server/studio-server.ts +3 -3
- package/src/check.ts +5 -2
- package/src/checks/SK001-package-json-shape.check.ts +47 -12
- package/src/checks/SK013-one-way-in-policy.check.ts +8 -7
- package/src/checks/SK017-vscode-hub-task.check.ts +109 -106
- package/src/checks/SK019-tests-run-in-units.check.ts +2 -1
- package/src/checks/SK027-agent-wiki-links.check.ts +5 -2
- package/src/checks/SK029-explicit-check-files.check.ts +25 -8
- package/src/checks/SK034-commit-gates-armed.check.ts +90 -0
- package/src/cli/cmd_Ci.ts +6 -5
- package/src/cli/cmd_Commands.ts +21 -5
- package/src/cli/cmd_Hooks.ts +6 -2
- package/src/cli/cmd_Init.ts +1 -1
- package/src/cli/cmd_Serve.ts +1 -2
- package/src/cli/cmd_Start.ts +1 -1
- package/src/cli.ts +2 -4
- package/src/collections/providers.ts +1 -1
- package/src/core/budget.ts +6 -8
- package/src/core/bun-builtins.ts +36 -0
- package/src/core/bun-tool-cli.ts +23 -0
- package/src/core/canonical-scripts.ts +6 -4
- package/src/core/check-ctx.ts +104 -10
- package/src/core/check-file-names.ts +2 -3
- package/src/core/check-pass.ts +17 -1
- package/src/core/check-runner.ts +32 -10
- package/src/core/config-rewrite.ts +27 -6
- package/src/core/discover-checks.ts +64 -15
- package/src/core/fix-scope.ts +5 -0
- package/src/core/floor-resolve.ts +18 -8
- package/src/core/git-root.ts +22 -12
- package/src/core/job-run.ts +2 -2
- package/src/core/layout.ts +4 -1
- package/src/core/legacy-shapes.ts +40 -0
- package/src/core/one-lib.ts +5 -3
- package/src/core/one-way-in-policy.ts +106 -13
- package/src/core/pre-commit-discovery.ts +106 -0
- package/src/core/retired-checks.ts +2 -0
- package/src/core/run-ci.ts +23 -16
- package/src/core/run-fix.ts +9 -0
- package/src/core/run-fixers.ts +91 -35
- package/src/core/runs-matrix.ts +7 -0
- package/src/core/slots.ts +36 -6
- package/src/core/vendored-read.ts +39 -0
- package/src/core/vendored.ts +82 -0
- package/src/core/waivers.ts +125 -0
- package/src/core/when-block.ts +25 -185
- package/src/core/when.ts +30 -13
- package/src/core/wire.ts +17 -1
- package/src/git-bootstrap.ts +1 -1
- package/src/home/attach.ts +3 -5
- package/src/home/gui-app/ShipkitGui.swift +96 -0
- package/src/home/insights.ts +6 -1
- package/src/home/pm-config.ts +2 -2
- package/src/home/resources.ts +3 -0
- package/src/home/run-daemon.ts +2 -2
- package/src/home/run-unit-action.ts +2 -2
- package/src/hooks/commit-message.ts +2 -14
- package/src/hooks/hook-gates.ts +38 -0
- package/src/hooks/leak-guard.ts +8 -3
- package/src/hooks/one-way-in-guard.ts +8 -2
- package/src/hooks/root-identity.ts +9 -6
- package/src/hooks/run-hooks.ts +72 -41
- package/src/hooks/staged.ts +49 -0
- package/src/hooks/tree-shrink.ts +41 -0
- package/src/init-repo.ts +0 -42
- package/src/ledger/check-table.ts +1 -1
- package/src/ledger/gate-checks.ts +45 -9
- package/src/ledger/ledger.ts +6 -1
- package/src/ledger/repo-view.ts +14 -4
- package/src/ledger/toolchain-identity.ts +4 -2
- package/src/mv/mv-md.ts +6 -1
- package/src/plan/planner.ts +21 -4
- package/src/plan/repo-source.ts +7 -3
- package/src/problems/extract.ts +27 -5
- package/src/problems/problem-line.ts +2 -3
- package/src/release/run-release.ts +30 -16
- package/src/release/unit-gate.ts +12 -5
- package/src/repo-config.ts +51 -27
- package/src/scaffold/files/bunfig.toml +11 -0
- package/src/scaffold/files/vscode-settings.json +1 -2
- package/src/scaffold/jsonc-spans.ts +16 -0
- package/src/scaffold/project-scaffold.ts +43 -77
- package/src/serve/hub-app/check-view.tsx +4 -4
- package/src/serve/hub-app/check-words.ts +1 -0
- package/src/serve/hub-app/checks-svc.ts +1 -2
- package/src/serve/hub-app/home.html +0 -1
- package/src/serve/hub-app/hub-types.ts +0 -2
- package/src/serve/hub-app/index.html +0 -1
- package/src/serve/hub-app/project-list.css +1 -2
- package/src/serve/hub-app/tree-build.ts +3 -2
- package/src/serve/hub-app/tree-svc.ts +2 -2
- package/src/serve/hub-port.ts +3 -3
- package/src/serve/hub-runtime.ts +3 -5
- package/src/serve/project-gutter.ts +5 -2
- package/src/serve/run-lanes.ts +7 -3
- package/src/serve/serve-hub.ts +1 -16
- package/src/serve/tracked-watch.ts +1 -1
- package/src/smoke/run-smoke.ts +13 -6
- package/src/start-repo.ts +15 -3
- package/src/suite/judge-suites.ts +81 -32
- package/src/suite/select.ts +2 -0
- package/src/suite/suite-cli.ts +27 -16
- package/src/suite/suite-cpu-preload.ts +7 -11
- package/src/suite/test-units.ts +7 -4
- package/src/ts/config.ts +12 -5
- package/src/ts/imports.ts +3 -1
- package/src/ts/typecheck-cache.ts +11 -0
- package/src/ts/typecheck-cli.ts +2 -1
- package/src/tui/build-steps-panel.ts +1 -1
- package/src/wiki/scan-md.ts +16 -9
- package/src/checks/SK009-commit-ai-tells.check.ts +0 -131
- package/src/checks/SK014-prose-ste.check.ts +0 -78
- package/src/cli/cmd_Prose.ts +0 -74
- package/src/prose/prose-view.ts +0 -90
- package/src/prose/run-prose.ts +0 -187
- package/src/prose/ste.ts +0 -552
- package/src/scaffold/files/vscode-tasks.json +0 -24
- package/src/serve/hub-app/prose-hits-st.ts +0 -125
- package/src/serve/hub-app/prose-hits-view.tsx +0 -176
- package/src/serve/hub-app/prose-hits.css +0 -21
package/src/cli/cmd_Hooks.ts
CHANGED
|
@@ -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
|
-
|
|
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 {
|
package/src/cli/cmd_Init.ts
CHANGED
|
@@ -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
|
|
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'],
|
package/src/cli/cmd_Serve.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
// shipkit serve — the ONE hub server per repo (
|
|
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 {
|
package/src/cli/cmd_Start.ts
CHANGED
|
@@ -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
|
|
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 {
|
|
129
|
-
|
|
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),
|
package/src/core/budget.ts
CHANGED
|
@@ -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
|
|
10
|
-
*
|
|
11
|
-
|
|
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
|
-
*
|
|
17
|
-
|
|
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
|
|
40
|
-
// (nobody reaches for them by reflex)
|
|
41
|
-
//
|
|
42
|
-
|
|
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
|
|
package/src/core/check-ctx.ts
CHANGED
|
@@ -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
|
|
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,
|
|
92
|
+
unreachable(msg: string, more?: FindingMore): void {
|
|
77
93
|
this.unmeasured = true
|
|
78
|
-
this.warn(msg,
|
|
94
|
+
this.warn(msg, more)
|
|
79
95
|
}
|
|
80
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
|
87
|
-
//
|
|
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
|
package/src/core/check-pass.ts
CHANGED
|
@@ -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
|
package/src/core/check-runner.ts
CHANGED
|
@@ -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}
|
|
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(
|
|
64
|
+
if (Object.keys(off).length > 0) {
|
|
53
65
|
try {
|
|
54
|
-
await writeGateBlock(root, { off
|
|
66
|
+
await writeGateBlock(root, { off })
|
|
55
67
|
offWritten = true
|
|
56
|
-
did.push(`${Object.keys(
|
|
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(
|
|
61
|
-
hand.push(`the managed block refused the rewrite (${String(e).slice(0, 160)}) — write off: ${JSON.stringify(
|
|
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) }
|