rvlib-shipkit 1.9.0 → 2.0.1

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 (109) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/package.json +1 -1
  3. package/src/check.ts +5 -2
  4. package/src/checks/SK001-package-json-shape.check.ts +47 -12
  5. package/src/checks/SK013-one-way-in-policy.check.ts +8 -7
  6. package/src/checks/SK017-vscode-hub-task.check.ts +109 -106
  7. package/src/checks/SK019-tests-run-in-units.check.ts +2 -1
  8. package/src/checks/SK027-agent-wiki-links.check.ts +5 -2
  9. package/src/checks/SK029-explicit-check-files.check.ts +25 -8
  10. package/src/checks/SK034-commit-gates-armed.check.ts +90 -0
  11. package/src/cli/cmd_Ci.ts +3 -3
  12. package/src/cli/cmd_Commands.ts +21 -5
  13. package/src/cli/cmd_Hooks.ts +6 -2
  14. package/src/cli/cmd_Init.ts +1 -1
  15. package/src/cli/cmd_Serve.ts +1 -2
  16. package/src/cli/cmd_Start.ts +1 -1
  17. package/src/cli.ts +2 -4
  18. package/src/core/budget.ts +6 -8
  19. package/src/core/bun-builtins.ts +36 -0
  20. package/src/core/bun-tool-cli.ts +23 -0
  21. package/src/core/canonical-scripts.ts +6 -4
  22. package/src/core/check-ctx.ts +104 -10
  23. package/src/core/check-file-names.ts +2 -3
  24. package/src/core/check-pass.ts +17 -1
  25. package/src/core/check-runner.ts +19 -6
  26. package/src/core/config-rewrite.ts +27 -6
  27. package/src/core/discover-checks.ts +30 -10
  28. package/src/core/floor-resolve.ts +16 -7
  29. package/src/core/job-run.ts +2 -2
  30. package/src/core/layout.ts +4 -1
  31. package/src/core/legacy-shapes.ts +40 -0
  32. package/src/core/one-lib.ts +1 -1
  33. package/src/core/one-way-in-policy.ts +106 -13
  34. package/src/core/pre-commit-discovery.ts +106 -0
  35. package/src/core/retired-checks.ts +2 -0
  36. package/src/core/run-ci.ts +16 -10
  37. package/src/core/run-fix.ts +9 -0
  38. package/src/core/run-fixers.ts +91 -35
  39. package/src/core/runs-matrix.ts +7 -0
  40. package/src/core/slots.ts +1 -1
  41. package/src/core/vendored-read.ts +39 -0
  42. package/src/core/vendored.ts +82 -0
  43. package/src/core/waivers.ts +125 -0
  44. package/src/core/when-block.ts +25 -185
  45. package/src/core/when.ts +30 -13
  46. package/src/core/wire.ts +17 -1
  47. package/src/fleet/update-fleet.ts +11 -2
  48. package/src/git-bootstrap.ts +1 -1
  49. package/src/home/attach.ts +3 -5
  50. package/src/home/insights.ts +6 -1
  51. package/src/home/pm-config.ts +2 -2
  52. package/src/home/run-daemon.ts +2 -2
  53. package/src/home/run-unit-action.ts +2 -2
  54. package/src/hooks/commit-message.ts +2 -14
  55. package/src/hooks/hook-gates.ts +38 -0
  56. package/src/hooks/leak-guard.ts +8 -3
  57. package/src/hooks/one-way-in-guard.ts +8 -2
  58. package/src/hooks/root-identity.ts +9 -6
  59. package/src/hooks/run-hooks.ts +72 -41
  60. package/src/hooks/staged.ts +49 -0
  61. package/src/hooks/tree-shrink.ts +41 -0
  62. package/src/init-repo.ts +0 -42
  63. package/src/ledger/check-table.ts +1 -1
  64. package/src/ledger/gate-checks.ts +45 -9
  65. package/src/ledger/ledger.ts +6 -1
  66. package/src/ledger/repo-view.ts +14 -4
  67. package/src/ledger/toolchain-identity.ts +4 -2
  68. package/src/mv/mv-md.ts +6 -1
  69. package/src/problems/extract.ts +27 -5
  70. package/src/problems/problem-line.ts +2 -3
  71. package/src/release/run-release.ts +13 -8
  72. package/src/repo-config.ts +44 -27
  73. package/src/scaffold/files/bunfig.toml +11 -0
  74. package/src/scaffold/files/vscode-settings.json +1 -2
  75. package/src/scaffold/jsonc-spans.ts +16 -0
  76. package/src/scaffold/project-scaffold.ts +43 -77
  77. package/src/serve/hub-app/check-view.tsx +4 -4
  78. package/src/serve/hub-app/check-words.ts +1 -0
  79. package/src/serve/hub-app/checks-svc.ts +1 -2
  80. package/src/serve/hub-app/home.html +0 -1
  81. package/src/serve/hub-app/hub-types.ts +0 -2
  82. package/src/serve/hub-app/index.html +0 -1
  83. package/src/serve/hub-app/project-list.css +1 -2
  84. package/src/serve/hub-app/tree-build.ts +3 -2
  85. package/src/serve/hub-app/tree-svc.ts +2 -2
  86. package/src/serve/hub-port.ts +3 -3
  87. package/src/serve/hub-runtime.ts +0 -3
  88. package/src/serve/serve-hub.ts +1 -16
  89. package/src/serve/tracked-watch.ts +1 -1
  90. package/src/start-repo.ts +15 -3
  91. package/src/suite/judge-suites.ts +29 -28
  92. package/src/suite/select.ts +2 -0
  93. package/src/suite/suite-cli.ts +6 -3
  94. package/src/suite/suite-cpu-preload.ts +7 -11
  95. package/src/suite/test-units.ts +7 -4
  96. package/src/ts/config.ts +12 -5
  97. package/src/ts/imports.ts +3 -1
  98. package/src/tui/build-steps-panel.ts +1 -1
  99. package/src/wiki/scan-md.ts +16 -9
  100. package/src/checks/SK009-commit-ai-tells.check.ts +0 -131
  101. package/src/checks/SK014-prose-ste.check.ts +0 -78
  102. package/src/cli/cmd_Prose.ts +0 -74
  103. package/src/prose/prose-view.ts +0 -90
  104. package/src/prose/run-prose.ts +0 -187
  105. package/src/prose/ste.ts +0 -552
  106. package/src/scaffold/files/vscode-tasks.json +0 -24
  107. package/src/serve/hub-app/prose-hits-st.ts +0 -125
  108. package/src/serve/hub-app/prose-hits-view.tsx +0 -176
  109. package/src/serve/hub-app/prose-hits.css +0 -21
package/CHANGELOG.md CHANGED
@@ -1,3 +1,38 @@
1
+ ## 2.0.1
2
+
3
+ - **`sk fleet --update --major` crosses the major again.** Each repo's guarded run now stays in its own process (`SHIPKIT_NO_HOME`), because a check the home answered never saw `SHIPKIT_ALLOW_MAJOR` and refused the breaking jump, naming the very command that was running.
4
+
5
+ ## 2.0.0
6
+
7
+ - **A clean commit prints one line**, `ok: N commit gate(s) clean`. A gate that fails still prints whole, and a WARN or a waived hit inside a passing gate still prints.
8
+ - **A green `sk ci` prints one line, the verdict.** Each check's `ok:` and `skip:` line and the `ran:` reasons go behind `--verbose`, and STATUS.md still holds them. A WARN, a FAIL with its fix, what the fix pass rewrote and a wait for a machine slot always print. A gate of 65 checks went from 86 lines to 2.
9
+ - **A check's default time budget is 1 s in process and 5 s for a build step, a `cmd` check or a world probe** (it was 2 s and 10 s). A check that passes over it still only warns. The per suite test budget is unchanged.
10
+ - **The suite runner never discovers a test file under a dot dir, as bun does not.** So a repro archived under `.shipkit/repros/` costs no run, `tests.select` included. `.shipkit/repros/` is a tracked folder of the SK004 block.
11
+ - **`sk commands --compact`** renders one line per command, for a page every session loads.
12
+ - **No VS Code task starts the shipkit hub any more.** The hub runs in the home, so `sk start` and `sk init` write no `.vscode/tasks.json`, and the scaffold template is gone. SK017 now runs in the gate (a WARN, never a block) and its repair is in the fix pass of every `sk ci`: it removes a hub task (`bun run hub`, or a `🚀 shipkit` label) and the hub's `dependsOn` entries, a wrapper left starting nothing, and the old scaffold comment about the hub. A wrapper left starting ONE task folds into it, which then opens with the folder. A file left with no task is deleted. The `👁 TSC` rule is unchanged, and a package published to npm is still never rewritten. Run by hand: `sk check --fix SK017`.
13
+ - **`vendored: ['<dir>', …]` in repo.config.ts keeps third party code out of every tool that treats the tree as the repo's own source.** A vendored library (a git subtree, a copied package) stays byte identical to upstream. The import fixer and imports:check never crawl it and never adopt its package.json as one of the repo's packages, which rewrote every relative import of a vendored workspace into a package name form. Cycles and the source graph skip it, the on save `imports <file>` leaves a vendored file alone, `bun lint` gets `--ignore-pattern=<dir>/**`, `c.list` leaves it out of every check's listing (unless the glob names a path inside it), check discovery and the md scan never enter it, and `shipkit mv` never rewrites a reference inside it. A fixer the toolkit cannot scope (a package.json `imports:fix` script) still runs, and whatever it wrote under a vendored dir is put back byte for byte, an uncommitted edit of the repo's own included, with a WARN naming it. Cache keys, watchers and the leak guard still read vendored files. The value is a literal array of strings, because the tools that never load the config read it as text, and the config load refuses a value the text cannot show. A repo running an older copy (its hooks, its own scripts) refuses the key as unknown until it upgrades.
14
+ - **A directive id ends on a letter or digit.** `sk-disable-line RV027: why` reads as the id `RV027` with no reason, so it warns, where it used to name an id `RV027:` that matched nothing.
15
+ - **A failed linter or tsc names its errors on the gate.** A build step that prints no wire FAIL line now shows its parsed errors under the headline, up to 20, as `file:line:col: rule message`, where it used to show only its closing recap and a log path.
16
+ - **A finding carries its site, and the file it sits in can waive it.** `c.fail(msg, { fix, at: { file, line, col } })` and `c.warn` take the site beside the fix pointer, and a plain string is still the pointer, so no check file changes. A check that passes `{ fix, at }` needs this release: an older copy prints the object as its pointer. The site rides on the wire line through the cache replay into the problems pane, which no longer has to find `path:line` in the text, and a message that does not name its file gets the site in front. A directive in the file's own comment waives a site, and only with a reason: `sk-disable-next-line <ID>[, <ID>…] -- <reason>`, `sk-disable-line`, `sk-disable-file`. A waived site is neither a FAIL nor a WARN: the check prints `skip: [<ID>] 2 sites waived in their files`, and `--verbose` and STATUS.md list each one. A directive with no reason waives nothing and WARNs, and so does one that waives nothing in a file the check read. The waiving file is read through `c`, so it joins the answer's key, and a check keyed on `files` stores no answer that a directive outside its inputs decided. An in process `run` body applies them, a `cmd` check does not.
17
+ - **SK027 reports where it found each problem.** A dangling ref carries the line it sits on and an orphan its file, so the problems pane opens the page and an orphan kept on purpose can waive itself with `sk-disable-file SK027 -- <reason>`.
18
+ - **`sk start` on a repo with history seeds the house files when it writes the house include lines.** The `agent/` wiki the second include routes at, the `.shipkit/reflections/` seed and the two `.claude/` policy files land create-only (`adoptHouse`), so every file the repo already has wins and nothing is committed over its history. A start with no house still writes none of them.
19
+ - **`off: { <id>: '<why>' }` is the one switch, for a check, a floor slot and a commit gate alike.** The commit gates take ids: `tree-shrink`, `leak-guard`, `blocked-paths`, `root-identity`, `commit-message` and `commit-language`. `commit-message` off takes `commit-language` with it. An `off` id the gate cannot find still fails, a misspelled gate id included.
20
+ - **The commit gates are rows of the inventory.** `sk checks`, `sk when` and the hub list each one with its hook in the commit column (`pre-commit`, `commit-msg` or `both`), its owner page and its `off` reason. They still run only from the git hooks, which load nothing new, and `sk ci --only <gate>` says where the gate runs instead of running it.
21
+ - **`hooks.disable` and `defaults: { <slot>: false }` are retired, and still load.** Each reads as off with the reason `was hooks.disable: ['<id>'], no reason recorded` or `was defaults: { <slot>: false }, no reason recorded`, so every surface shows that nobody wrote one. SK029 fails while a config carries them, and the fix pass of `sk ci` ports them into `off`: the comment written right above the entry becomes its reason, and a shape it cannot read (a multi line `disable` array) is named for a person and left loading. `sk hooks list` prints an off gate as `(OFF: <reason>)` where it printed `(DISABLED by config)`.
22
+ - **A floor slot turned off through `off` is off everywhere, as `defaults: { <slot>: false }` was.** It resolves to no task, SK001 no longer routes or asks for its script, SK013 no longer denies its raw tool, SK019 no longer reads its script, and its fixer slot stays off. Before, `off` stopped the slot from running but kept routing and denying it. A hub switch that turns a slot off now lifts its deny rule too, on the next fix pass.
23
+ - **SK034 commit gates armed.** It FAILs a repo where git would not run the leak guard and the message gate: an unset `core.hooksPath`, or a foreign `pre-commit` or `commit-msg` that never calls shipkit, with the guarded line to chain. This was half of SK009, which only checked `commit-msg`, and it now covers the hook that carries the leak guard too.
24
+ - **`sk ci --only <check> --fix` repairs only what the named checks own.** Their own ci repair, and the fixer slot that governs one of them (`imports:fix` for `imports:check`). Before, `--fix` ran every fixer slot over the whole tree.
25
+ - **The STE prose rule is gone: SK014, `sk prose`, the hub's prose page and the `prose` config key.** No rule polices prose style any more. A `repo.config.ts` that still declares `prose` fails at load with "prose was removed with rule SK014: delete the key", and an `off` or `ruleExempt` entry that names SK014 or SK009 fails the same way, naming the retired rule.
26
+ - **The AI attribution guard is gone: SK009 and the attribution half of the commit-msg hook.** The hook keeps the dash and French checks, and `hooks.disable: ['commit-message']` now lifts the whole message gate. The scaffold still writes the `.claude/settings.json` attribution silencer, which is harness config and reaches no agent's context.
27
+ - **`bun lint` is the house linter.** With no `lint` script and a `[lint]` section in `bunfig.toml`, the lint slot runs `bun lint --format unix`, and the problems pane reads its findings. It needs bun 1.4.4 (canary today): an older bun gets a failing line that names `bun upgrade --canary`, never a `lint` script that calls itself.
28
+ - **Nothing formats in the gate.** The fix pass has no formatter slot any more: a `fmt` or `format` script, and a `[fmt]` section in `bunfig.toml`, are never run by `sk ci`, locally or under CI, so verify mode no longer fails a commit on formatting. `bun fmt` stays a manual tool, styled by `[fmt]`. SK001 no longer asks for `[fmt]` or a `fmt` script, and the `pretty` alias WARN is gone with it.
29
+ - **oxlint and oxfmt are retired.** SK001 WARNs, one line per tool, on a dependency (`oxlint-tsgolint` included) or a script body that names either, with the migration: port `.oxlintrc.json` into `[lint]` for `bun lint`, then delete the dependency, each script that is only the tool, and the tool's config file. A script that does more (a code generator that formats its output) is told to lose the step, never deleted. It never repairs this itself: a linter swap changes which rules gate the repo, and dropping a dependency moves `bun.lock`, which only an install rewrites.
30
+ - **A leftover `lint` script can no longer take the built-in's place.** bun runs a package.json script of that name instead of `bun lint`, flags and all, so the built-in now runs through `src/core/bun-tool-cli.ts`, which sets `npm_lifecycle_event` and drops an inherited `npm_package_json`, the two signals bun reads, and SK001 WARNs on a `lint` or `fmt` script that shadows a declared section.
31
+ - **A canary upgrade invalidates cached answers.** The toolchain identity hashes `Bun.revision` beside `Bun.version`, which every canary of one version shares.
32
+ - **`lint` is no longer a routed script.** SK001 stops asking for `lint` to route through `sk ci --only lint`, its fixer stops writing `lint:raw`, and the one-way-in deny list drops `oxlint` and `eslint`. A `[lint]` section meets SK001's baseline as a script did. A repo that already routes `lint` keeps working.
33
+ - **The scaffold writes `bunfig.toml` with `[fmt]` and `[lint]`, and no `lint` or `fmt` script.** The toolchain drops `oxlint` and `oxfmt`, and the editor settings drop `oxc.enable`.
34
+ - **A test suite is judged on its own CPU in the parallel phase, never on the files that ran before it in its worker.** A suite over `tests.budgetMs` in wall time skipped its lone run only when its own CPU plus everything its worker burned before it stayed under the budget, and after a worker's first file that sum is always over. So on a loaded machine nearly every suspect ran alone, one after the other: in a large repo 27 lone runs took 53 s of a 97.6 s test check. A suspect whose own CPU in the parallel phase, children included, is inside the same allowance a lone run gets (`cpuAllowance`) is now cleared there. A module that several suites share is charged to the first suite of each worker that loads it. A suite whose own figure is past its allowance, whose wall can hide a wait, or that ends with a child still alive runs alone as before, and only a lone run can fail the task. Measured on that repo, 760 suites under eight CPU burners: 19 lone runs and 47.1 s at load 13.5, then 0 lone runs and 27.0 s at load 24.5. The preload's line is now `<file>\t<cpu ms>`.
35
+
1
36
  ## 1.9.0
2
37
 
3
38
  - **A commit in a linked worktree passes the pre-commit hook.** git sets GIT_DIR for a hook there, and the installed copy then asked git where the house sits and got the worktree back, so it never handed off to the house source. Then it ran itself and failed every built-in check with ENOENT on a `?v=` stamped path. A directory with its own `.git` is now asked with the locating variables stripped, and an installed copy that runs itself (CI, `SHIPKIT_NO_HOME`) loads its own check files.
@@ -17,6 +52,9 @@
17
52
  - **The gate typecheck keeps its own incremental cache, and a release can publish with bun.** The built-in typecheck passes `--incremental --tsBuildInfoFile <project dir>/node_modules/.cache/shipkit/<config>.tsbuildinfo` for every project, so a `tsc --watch` task or a raw script on the same tsconfig no longer writes the cache the gate reads. A config without `incremental` gets it too: rvlib-shipkit's own typecheck went from 0.81 s to 0.35 s warm. `release.publisher: 'bun'` makes `sk release` and `sk bump` run `bun publish --access public`, with the token from `release.npmTokenCommand` in `NPM_CONFIG_TOKEN`, for a repo that tests its tarball with `bun pm pack`. The default stays npm, and an unknown publisher fails `defineRepo`.
18
53
 
19
54
  - **`alert`, `confirm` and `prompt` work in the shipkit app and in every process app.** The app window answered each one at once with no panel: `confirm` returned false and `prompt` null, so a page button behind one did nothing. Each now shows as a sheet on its window. The prompt's text field holds the default text, selected. Return is OK and Esc is Cancel. An app picks it up the next time `sk gui` or `sk gp open <id> --app` opens it, because a changed source rebuilds the app and quits the running copy.
55
+ - **The pre-commit hook refuses a commit that drops most of the tree.** A commit that keeps under half of HEAD's files fails `tree-shrink`, which is what a commit from a private `GIT_INDEX_FILE` never filled with `git read-tree HEAD` does. `SHIPKIT_ALLOW_MASS_DELETE=1` lifts it for one deliberate mass delete, and `hooks.disable: ['tree-shrink']` for good.
56
+ - **The pre-commit hook imports only the check files that run at commit.** A table under the git dir remembers, per file stamp, whether a check file declares `onPreCommit`, so a check file that never runs at commit costs the commit nothing while it stands still. A repo check file whose import takes over 30ms is named with a WARN on the commit that pays for it. The git steps share one staged listing, and the commit checks share one pass that records no config reads. Warm, on a consumer shaped fixture: 189ms before, 110ms after.
57
+ - **SK013 also writes `Bash(git stash*)` into every repo's deny list, and the guard refuses a stash in the forms the deny row cannot see**: after git's own options (`git -C dir stash`), inside `bash -c` or `eval`. `git stash list` and `git stash show` pass the guard.
20
58
 
21
59
  - **The `prompt` and `seed` vars contract binds only the workflows a kit kind drives.** Loading the workflows folder no longer refuses a module that lacks them, so a workflow the repo drives with its own tools (portraits, voice, music) names its vars freely. The check runs when the atelier resolves the workflow for a kind, and the error names that kind.
22
60
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rvlib-shipkit",
3
- "version": "1.9.0",
3
+ "version": "2.0.1",
4
4
  "funding": "https://github.com/sponsors/rvion",
5
5
  "description": "All-in-one repo companion: ci gate (task DAG), auto-discovered health checks, release-contract fulfillness, asks queue, md-wiki tools, asset atelier, live tui",
6
6
  "license": "MIT",
package/src/check.ts CHANGED
@@ -45,7 +45,7 @@ export type CheckDef = {
45
45
  * Default: the id. */
46
46
  title?: string
47
47
  /** what MUST BE TRUE, in the present tense: ONE line, at most 90
48
- * characters, as in `package.json routes lint, typecheck and test
48
+ * characters, as in `package.json routes typecheck and test
49
49
  * through the gate`. Never what red means, red is its negation. It
50
50
  * renders at the top of the check page and as the tree row's tooltip.
51
51
  * A title names the check, this states the invariant it holds. */
@@ -119,7 +119,7 @@ export type CheckDef = {
119
119
  /** what a healthy run takes, in ms, distinct from the ceiling: a run that
120
120
  * passes over it prints a WARN and reads yellow, and never blocks.
121
121
  * Judged on the wall time of a run that measured, at the quick tier.
122
- * Default 2000 for an in process body, 10000 for a build step, a `cmd`
122
+ * Default 1000 for an in process body, 5000 for a build step, a `cmd`
123
123
  * or a world probe (cache `ttl`, `fact` or `never`, core/budget.ts).
124
124
  * The repo's `budgets` wins over it. */
125
125
  budgetMs?: number
@@ -329,6 +329,7 @@ export async function runCheckBody(
329
329
  c.lines = tagCheckLines(u, c.lines)
330
330
  return
331
331
  }
332
+ c.checkId = u.id
332
333
  try {
333
334
  await u.run?.(c)
334
335
  } catch (e) {
@@ -340,6 +341,8 @@ export async function runCheckBody(
340
341
  // and it must not enter the body's read set
341
342
  if ((c.failed || c.warned) && !c.lines.some((l) => l.kind === 'fix-fail' || l.kind === 'fix-warn'))
342
343
  c.lines.push({ kind: c.failed ? 'fix-fail' : 'fix-warn', text: c.ownerPointer(u.owner) })
344
+ // after the pointer, so it binds to the body's own lines
345
+ c.settleWaivers()
343
346
  c.lines = tagCheckLines(u, c.lines)
344
347
  }
345
348
 
@@ -6,7 +6,7 @@
6
6
  // whatever the floor resolution read.
7
7
  //
8
8
  // It also enforces the package.json half of ONE WAY IN (build/shipkit/one-way-in.md):
9
- // `test`, `lint` and `typecheck` ROUTE to `shipkit ci --only <id>` and the
9
+ // `test` and `typecheck` ROUTE to `shipkit ci --only <id>` and the
10
10
  // tool body they used to hold sits at `<id>:raw`. That is what makes a cache
11
11
  // worth having: `bun run test` is the reflex, and the reflex has to land in
12
12
  // shipkit rather than beside it.
@@ -14,10 +14,13 @@ import { canonicalBodies, conditionalBodies, inferBin, ROUTED_SCRIPTS, rawName,
14
14
  import type { CheckCtx } from '../core/check-ctx.ts'
15
15
  import { checkFloorReader, resolveFloor } from '../core/floor-resolve.ts'
16
16
  import { rootPublishesNpm } from '../core/repo-facts.ts'
17
+ import { bunfigDeclares } from '../core/bun-builtins.ts'
18
+ import { repoOff } from '../core/when.ts'
17
19
  import type { RepoConfig } from '../repo-config.ts'
18
20
  import { check, type FixResult } from '../check.ts'
19
21
 
20
- type Pkg = { name?: string; private?: boolean; license?: string; scripts?: Record<string, string> }
22
+ type Deps = Record<string, string>
23
+ type Pkg = { name?: string; private?: boolean; license?: string; scripts?: Record<string, string>; dependencies?: Deps; devDependencies?: Deps; optionalDependencies?: Deps }
21
24
 
22
25
  const OWNER = 'meta/rv-conventions/package-json.md'
23
26
 
@@ -26,11 +29,18 @@ const OWNER = 'meta/rv-conventions/package-json.md'
26
29
  // folder must not be told to rename)
27
30
  const ALIASES: Record<string, { canonical: string; bodyMatch: RegExp }> = {
28
31
  'serve': { canonical: 'hub', bodyMatch: /(?:^|\s)(?:shipkit|\S*shipkit\S*\/cli\.ts) serve\b/ },
29
- 'pretty': { canonical: 'fmt', bodyMatch: /./ },
30
32
  'check-types': { canonical: 'typecheck', bodyMatch: /./ },
31
33
  }
32
34
 
33
- const BASELINE = ['test', 'lint', 'fmt'] as const
35
+ const BASELINE = ['test', 'lint'] as const
36
+
37
+ // tools bun's built-ins replaced. Never repaired here: a linter swap changes
38
+ // which rules gate the repo, and dropping a dependency moves bun.lock, which
39
+ // only an install rewrites
40
+ const RETIRED: Array<{ tool: string; deps: string[]; config: string; how: string }> = [
41
+ { tool: 'oxlint', deps: ['oxlint', 'oxlint-tsgolint'], config: '.oxlintrc.json', how: 'port its rules into [lint] in bunfig.toml (`bun lint` is the house linter), then' },
42
+ { tool: 'oxfmt', deps: ['oxfmt'], config: '.oxfmtrc.json', how: 'nothing formats in the gate (`bun fmt` styled by [fmt] is manual), so' },
43
+ ]
34
44
 
35
45
  // no per-line fix pointers here — the rule framework appends ONE pointer
36
46
  // (docsBase + OWNER) to a failing/warning run, the same one-per-severity
@@ -94,7 +104,7 @@ export function evaluatePackageJson(t: CheckCtx): void {
94
104
  // route to, so it is not governed.
95
105
  const floor = resolveFloor(c, t.root, checkFloorReader(t))
96
106
  for (const id of ROUTED_SCRIPTS) {
97
- if (routedExempt(c).has(id) || c.defaults?.[id] === false) continue
107
+ if (routedExempt(c).has(id) || repoOff(c, id) != null) continue
98
108
  const body = scripts[id]
99
109
  if (body == null) continue
100
110
  if (!routesToShipkit(body)) {
@@ -108,13 +118,38 @@ export function evaluatePackageJson(t: CheckCtx): void {
108
118
  // `test` is asked for only once the repo HAS tests: the fixer deliberately
109
119
  // refuses to scaffold it before that (a fixer never manufactures a
110
120
  // failure), so nagging for it would be a WARN no machine may clear. For
111
- // the same reason `lint` and `fmt` wait for a source file to read
121
+ // the same reason `lint` waits for a source file to read. It is met by
122
+ // bun's built-in once bunfig.toml declares [lint]
112
123
  const tests = hasTestFiles(testFiles(t))
113
124
  const source = tests || hasSourceFiles(t)
114
- const baseline = BASELINE.filter((n) => (n === 'test' ? tests : source))
115
- const missingBaseline = baseline.filter((n) => !has(n))
125
+ const bunfig = t.read('bunfig.toml')
126
+ // a lint slot turned off has no built-in to declare
127
+ const baseline = BASELINE.filter((n) => (n === 'test' ? tests : source) && !(n === 'lint' && repoOff(c, 'lint') != null))
128
+ const missingBaseline = baseline.filter((n) => !has(n) && !(n !== 'test' && bunfigDeclares(bunfig, n)))
129
+ // a script named like the built-in runs in its place whenever someone
130
+ // types `bun lint` or `bun fmt`, so a declared section makes it dead weight
131
+ for (const n of ['lint', 'fmt'] as const)
132
+ if (scripts[n] != null && bunfigDeclares(bunfig, n))
133
+ t.warn(`package.json: script '${n}' shadows \`bun ${n}\`, which bunfig.toml turns on with [${n}] — delete it${scripts[rawName(n)] != null ? ` and '${rawName(n)}'` : ''}`)
116
134
  if (missingBaseline.length > 0)
117
- t.warn(`package.json: missing baseline script(s): ${missingBaseline.join(', ')}`)
135
+ t.warn(`package.json: missing baseline: ${missingBaseline.map((n) => (n === 'test' ? `script 'test'` : `[${n}] in bunfig.toml (or a '${n}' script)`)).join(', ')}`)
136
+ for (const r of RETIRED) {
137
+ // the tool as a command word: `bunx oxlint@latest`, `"oxlint;"`, never `.oxlintrc` or `oxlint-smoke.ts`
138
+ const word = new RegExp(`(^|[^\\w.-])${r.tool}(?![\\w-])`)
139
+ const sole = new RegExp(`^\\s*(?:(?:bunx|npx|bun\\s+x)\\s+)?(?:\\S*/)?${r.tool}(?:@\\S+)?(?:\\s+[^;&|]*)?$`)
140
+ const deps = r.deps.filter((d) => [pkg.dependencies, pkg.devDependencies, pkg.optionalDependencies].some((m) => m?.[d] != null))
141
+ const using = Object.entries(scripts).filter(([, body]) => word.test(body))
142
+ const whole = using.filter(([, body]) => sole.test(body)).map(([name]) => `'${name}'`)
143
+ const steps = using.filter(([, body]) => !sole.test(body)).map(([name]) => `'${name}'`)
144
+ const del = [
145
+ ...deps.map((d) => `the ${d} dependency`),
146
+ ...(whole.length > 0 ? [`script ${whole.join(', ')}`] : []),
147
+ ...(t.exists(r.config) ? [r.config] : []),
148
+ ]
149
+ if (deps.length === 0 && using.length === 0) continue
150
+ const parts = [...(del.length > 0 ? [`delete ${del.join(', ')}`] : []), ...(steps.length > 0 ? [`take the ${r.tool} step out of script ${steps.join(', ')}, which does more`] : [])]
151
+ t.warn(`package.json: ${r.tool} is retired: ${r.how} ${parts.join(', and ')}`)
152
+ }
118
153
 
119
154
  if (!t.failed && !t.warned)
120
155
  t.ok(`package.json shape ok (private, no license, ${required.length} required script(s) present)`)
@@ -182,7 +217,7 @@ const SOURCE_GLOB = '**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}'
182
217
  const EMPTY_MODULE = /^(?:\s|;|export\s*\{\s*\})*$/
183
218
 
184
219
  /** a code file with something in it, outside installs, build output, dot dirs
185
- * and the repo config: what lint and fmt would read. Stops at the first */
220
+ * and the repo config: what lint would read. Stops at the first */
186
221
  function hasSourceFiles(c: CheckCtx): boolean {
187
222
  for (const f of c.list(SOURCE_GLOB)) {
188
223
  const parts = f.split('/')
@@ -196,7 +231,7 @@ export const packageJsonShape = check({
196
231
  id: 'SK001',
197
232
  blocking: true,
198
233
  title: 'package.json',
199
- description: 'package.json routes lint, typecheck and test through the gate',
234
+ description: 'package.json routes typecheck and test through the gate',
200
235
  owner: OWNER,
201
236
  runs: { onPreCommit: false, onCi: true, onFull: true, onRelease: true, onHealth: true, onSave: 'moved', onClock: false },
202
237
  cache: { by: 'reads' },
@@ -252,7 +287,7 @@ export const packageJsonShape = check({
252
287
  // call. Both keys keep their place in the file, so the diff a human
253
288
  // reads is the two lines that changed and not a reordered block
254
289
  for (const id of ROUTED_SCRIPTS) {
255
- if (routedExempt(c.config).has(id) || c.config.defaults?.[id] === false) continue
290
+ if (routedExempt(c.config).has(id) || repoOff(c.config, id) != null) continue
256
291
  const body = pkg.scripts[id]
257
292
  if (body == null || routesToShipkit(body)) continue
258
293
  const raw = rawName(id)
@@ -14,9 +14,10 @@
14
14
  // scaffold, so a policy written there would bind exactly one checkout.
15
15
  import { isAbsolute, join } from 'path'
16
16
  import { inferBin } from '../core/canonical-scripts.ts'
17
- import { denyRules, RAW_RUNNERS } from '../core/one-way-in-policy.ts'
17
+ import { denyRules, GIT_STASH_RULE, RAW_RUNNERS } from '../core/one-way-in-policy.ts'
18
18
  import { rootPublishesNpm } from '../core/repo-facts.ts'
19
19
  import type { RepoConfig } from '../repo-config.ts'
20
+ import { repoOff } from '../core/when.ts'
20
21
  import { check, type CheckCtx, type FixResult } from '../check.ts'
21
22
 
22
23
  const OWNER = 'build/shipkit/one-way-in.md'
@@ -31,7 +32,7 @@ type Settings = {
31
32
  * either: ONE declaration governs the script name and the raw call together */
32
33
  export function exemptSlots(c: RepoConfig): Set<string> {
33
34
  const off = new Set(c.oneWayIn?.exempt ?? [])
34
- for (const [slot, def] of Object.entries(c.defaults ?? {})) if (def === false) off.add(slot)
35
+ for (const id of [...Object.keys(c.defaults ?? {}), ...Object.keys(c.off ?? {}), ...Object.keys(c.when ?? {})]) if (repoOff(c, id) != null) off.add(id)
35
36
  return off
36
37
  }
37
38
 
@@ -132,14 +133,15 @@ export const oneWayInPolicy = check({
132
133
  if (rootPublishesNpm(c.config)) return c.ok(PUBLIC_SKIP)
133
134
  const d = c
134
135
  const want = denyRules(exemptSlots(c.config))
135
- if (want.length === 0) return c.ok('every governed slot is exempt (repo.config oneWayIn/defaults) — nothing to deny')
136
136
  const s = readSettings(d)
137
137
  if (s == null) {
138
138
  c.fail(`${SETTINGS_PATH} ${d.exists(SETTINGS_PATH) ? 'is unparseable' : 'missing'} — nothing stops an agent typing \`bun test\`, and a package.json script cannot intercept a bun builtin`)
139
139
  return
140
140
  }
141
141
  const have = new Set(s.permissions?.deny ?? [])
142
- const missing = want.filter((r) => !have.has(r))
142
+ const missing = want.filter((r) => !have.has(r) && r !== GIT_STASH_RULE)
143
+ if (!have.has(GIT_STASH_RULE))
144
+ c.fail(`${SETTINGS_PATH}: no "${GIT_STASH_RULE}" deny row, so an agent can stash every other agent's uncommitted work in a shared tree`)
143
145
  if (missing.length > 0)
144
146
  c.fail(`${SETTINGS_PATH}: ${missing.length} raw runner(s) still permitted (${missing.slice(0, 3).join(', ')}${missing.length > 3 ? ', …' : ''}) — those calls never reach shipkit, so nothing is cached and no run can be attached to`)
145
147
  const guard = guardHookCommand(s)
@@ -170,7 +172,7 @@ export const oneWayInPolicy = check({
170
172
  // replacement is named while the guard cannot run is the green that
171
173
  // measured nothing, one line under the WARN that says otherwise
172
174
  if (!c.failed)
173
- c.ok(`${SETTINGS_PATH} closes ${want.length}/${RAW_RUNNERS.length} raw runner(s)${guardRuns ? ', with the replacement named' : ' (the guard that names the replacement is declared, not yet runnable)'}`)
175
+ c.ok(`${SETTINGS_PATH} closes ${want.length - 1}/${RAW_RUNNERS.length} raw runner(s) and refuses git stash${guardRuns ? ', with the replacement named' : ' (the guard that names the replacement is declared, not yet runnable)'}`)
174
176
  },
175
177
  // MERGES, never rewrites: this file also carries the attribution silencer,
176
178
  // a repo's own allow list and its own hooks, and clobbering those to fix a
@@ -179,7 +181,6 @@ export const oneWayInPolicy = check({
179
181
  if (rootPublishesNpm(c.config)) return { changed: false, summary: PUBLIC_SKIP }
180
182
  const d = c
181
183
  const want = denyRules(exemptSlots(c.config))
182
- if (want.length === 0) return { changed: false, summary: 'every governed slot is exempt — nothing to write' }
183
184
  const existing = readSettings(d)
184
185
  if (existing == null && c.exists(SETTINGS_PATH)) return { changed: false, summary: `${SETTINGS_PATH} is unparseable — repair it by hand first, a merge would destroy it` }
185
186
  const s: Settings = existing ?? {}
@@ -188,7 +189,7 @@ export const oneWayInPolicy = check({
188
189
  const added = want.filter((r) => !deny.includes(r))
189
190
  if (added.length > 0) {
190
191
  s.permissions = { ...s.permissions, deny: [...deny, ...added] }
191
- changes.push(`${added.length} raw runner(s) denied`)
192
+ changes.push(`${added.length} deny rule(s) added`)
192
193
  }
193
194
  const wantGuard = await guardCommandFor(d)
194
195
  if (!hasGuardHook(s)) {
@@ -1,26 +1,24 @@
1
- // SK017 vscode hub task (build/vscode-tasks.md): the folder-open hub task
2
- // carries the problem matcher the hub prints for, it opens with the folder
3
- // itself when nothing else does, and no `👁 TSC` watcher runs beside a gate
4
- // that already runs `typecheck`.
1
+ // SK017 vscode hub task (build/vscode-tasks.md): no folder-open task starts
2
+ // the shipkit hub (it runs in the home, and VS Code needs no task for it), and
3
+ // no `👁 TSC` watcher runs beside a gate that already runs `typecheck`.
5
4
  //
6
- // the file is hand-written jsonc, so the fixer edits spans: it removes the TSC
7
- // task, a wrapper that only starts the hub, and their dependsOn entries, and
8
- // writes the hub's matcher and folder-open run option. Every other byte stays
9
- // as its author left it.
5
+ // the file is hand-written jsonc, so the fixer edits spans: it removes the hub
6
+ // task, the TSC task, a wrapper left starting nothing, and their dependsOn
7
+ // entries, and folds a wrapper left starting ONE task into it. Every other
8
+ // byte stays, except a dependsOn array it edits, which is rewritten inline.
9
+ // A file left holding nothing but an empty task list is deleted.
10
10
  import { basename } from 'path'
11
11
  import { check } from '../check.ts'
12
12
  import type { CheckCtx } from '../core/check-ctx.ts'
13
13
  import { checkFloorReader, resolveFloor } from '../core/floor-resolve.ts'
14
14
  import { rootPublishesNpm } from '../core/repo-facts.ts'
15
15
  import { repoOff } from '../core/when.ts'
16
- import { hubProblemMatcher } from '../problems/problem-line.ts'
17
- import { indentAt, parseSpans, plain, removalSpan, type Node } from '../scaffold/jsonc-spans.ts'
16
+ import { indentAt, parseSpans, plain, removalSpans, type Node } from '../scaffold/jsonc-spans.ts'
18
17
 
19
18
  export const TSC_LABEL = '👁 TSC'
20
19
  const TASKS = '.vscode/tasks.json'
21
20
 
22
21
  type Obj = Extract<Node, { kind: 'object' }>
23
- type Arr = Extract<Node, { kind: 'array' }>
24
22
 
25
23
  const prop = (o: Obj, key: string): Node | undefined => o.props.find((p) => p.key === key)?.value
26
24
 
@@ -35,12 +33,17 @@ function isTscWatcher(t: Record<string, unknown>): boolean {
35
33
  return t['label'] === TSC_LABEL && TSC_BINS.has(basename(cmd)) && args.includes('--watch')
36
34
  }
37
35
 
38
- /** the hub task: `bun run hub`, or the house label */
36
+ /** the hub task: `bun run hub` or `sk serve` (args or one shell string), or the house label */
39
37
  function isHub(t: Record<string, unknown>): boolean {
40
- const args = Array.isArray(t['args']) ? t['args'] : []
41
- return (t['command'] === 'bun' && args[0] === 'run' && args[1] === 'hub') || (typeof t['label'] === 'string' && /^🚀\s+shipkit\b/.test(t['label']))
38
+ const args = Array.isArray(t['args']) ? t['args'].map(String) : []
39
+ const line = [typeof t['command'] === 'string' ? t['command'] : '', ...args].join(' ')
40
+ return /(?:^|\s)(?:bun run hub|(?:sk|shipkit) serve)(?:\s|$)/.test(line) || (typeof t['label'] === 'string' && /^🚀\s+shipkit\b/.test(t['label']))
42
41
  }
43
42
 
43
+ /** the comment the old scaffold wrote above its hub task, byte for byte, any
44
+ * indent: it describes the hub task, so it goes with it */
45
+ const SCAFFOLD_HEADER = /^[ \t]*\/\/ the hub is the one process every wired repo starts, so it opens with the\n[ \t]*\/\/ folder itself\. It runs every check after each save and its problem\n[ \t]*\/\/ matcher fills the problems pane, so there is no type checker task\. A\n[ \t]*\/\/ stack that earns more long-running watchers \(dev server, css\) adds a\n[ \t]*\/\/ master task with runOn folderOpen whose dependsOn lists them all, the hub\n[ \t]*\/\/ included, and moves runOn off the hub\.\n/m
46
+
44
47
  /** a task that only starts other tasks: no command of its own */
45
48
  const isWrapper = (t: Record<string, unknown>): boolean => t['command'] == null && t['type'] == null && t['dependsOn'] != null
46
49
 
@@ -50,112 +53,111 @@ export type RepairOpts = {
50
53
  typecheckOn: boolean
51
54
  }
52
55
 
53
- /** what this file needs, applied: the text to write, one line per change, and
54
- * one line per thing it deliberately left. No change means the text comes
55
- * back byte for byte */
56
- export function repairTasks(text: string, o: RepairOpts): { text: string; changes: string[]; kept: string[] } {
56
+ /** what this file needs, applied: the text to write, one line per change,
57
+ * one line per thing it deliberately left, and whether any task is left. No
58
+ * change means the text comes back byte for byte */
59
+ export function repairTasks(text: string, o: RepairOpts): { text: string; changes: string[]; kept: string[]; empty: boolean } {
57
60
  const root = parseSpans(text)
58
61
  if (root.kind !== 'object') throw new Error('tasks.json is not an object')
59
62
  const tasks = prop(root, 'tasks')
60
- if (tasks == null || tasks.kind !== 'array') return { text, changes: [], kept: [] }
61
- const edits: { start: number; end: number; body: string }[] = []
63
+ if (tasks == null || tasks.kind !== 'array') return { text, changes: [], kept: [], empty: false }
62
64
  const changes: string[] = []
63
65
  const kept: string[] = []
64
66
  const record = (t: Node): Record<string, unknown> => plain(text, t) as Record<string, unknown>
65
67
  const labelOf = (t: Node): string => String(record(t)['label'] ?? '?')
66
- const items = tasks.items.filter((n): n is Obj => n.kind === 'object')
67
- const hubs = items.filter((t) => isHub(record(t)))
68
-
69
- const tscAt = tasks.items.flatMap((n, i) => (n.kind === 'object' && isTscWatcher(record(n)) ? [i] : []))
70
- const dropTsc = tscAt.length > 0 && o.typecheckOn
71
- if (tscAt.length > 0 && !o.typecheckOn) kept.push(`kept the '${TSC_LABEL}' task: this repo's gate has no enabled typecheck check, so it is the only type checker here`)
72
- if (dropTsc) for (const i of tscAt) {
73
- const [start, end] = removalSpan(text, tasks, i)
74
- edits.push({ start, end, body: '' })
75
- changes.push(`removed the '${TSC_LABEL}' task`)
68
+ const at = (pred: (t: Record<string, unknown>) => boolean): number[] => tasks.items.flatMap((n, i) => (n.kind === 'object' && pred(record(n)) ? [i] : []))
69
+
70
+ const remove = new Set<number>()
71
+ const gone = new Set<string>()
72
+ const drop = (i: number, why: string): void => {
73
+ remove.add(i)
74
+ gone.add(labelOf(tasks.items[i]!))
75
+ changes.push(why)
76
76
  }
77
+ for (const i of at(isHub)) drop(i, `removed the '${labelOf(tasks.items[i]!)}' hub task: the hub runs in the home, not in VS Code`)
78
+ const tscAt = at(isTscWatcher)
79
+ if (tscAt.length > 0 && !o.typecheckOn) kept.push(`kept the '${TSC_LABEL}' task: this repo's gate has no enabled typecheck check, so it is the only type checker here`)
80
+ if (o.typecheckOn) for (const i of tscAt) drop(i, `removed the '${TSC_LABEL}' task`)
77
81
 
78
- // a folder-open wrapper whose only job is starting the hub is a second
79
- // task on screen for nothing: the hub opens with the folder itself
80
- const hub = hubs.length === 1 ? hubs[0]! : null
81
- const hubLabel = hub == null ? null : labelOf(hub)
82
82
  const depsOf = (t: Node): string[] => {
83
83
  const d = record(t)['dependsOn']
84
- const list = Array.isArray(d) ? d.map(String) : [String(d)]
85
- return dropTsc ? list.filter((x) => x !== TSC_LABEL) : list
84
+ return d == null ? [] : Array.isArray(d) ? d.map(String) : [String(d)]
86
85
  }
87
- const wrapperAt = hub == null ? -1 : tasks.items.findIndex((n) => n.kind === 'object' && isWrapper(record(n)) && depsOf(n).length === 1 && depsOf(n)[0] === hubLabel)
88
- const wrapper = wrapperAt < 0 ? null : (tasks.items[wrapperAt] as Obj)
89
- if (wrapper != null) {
90
- const [start, end] = removalSpan(text, tasks, wrapperAt)
91
- edits.push({ start, end, body: '' })
92
- changes.push(`folded the '${labelOf(wrapper)}' wrapper into '${hubLabel}', which now opens with the folder`)
93
- if (/\/\/.*(master task|subtask)/i.test(text)) changes.push('a comment still describes a master task with subtasks: rewrite it by hand')
94
- }
95
-
96
- // a wrapper that started only the TSC task starts nothing once it goes
97
- const emptied = !dropTsc ? [] : tasks.items.flatMap((n, i) => (n !== wrapper && n.kind === 'object' && isWrapper(record(n)) && depsOf(n).length === 0 ? [i] : []))
98
- for (const i of emptied) {
99
- const [start, end] = removalSpan(text, tasks, i)
100
- edits.push({ start, end, body: '' })
101
- changes.push(`removed the '${labelOf(tasks.items[i]!)}' wrapper, which started nothing else`)
86
+ // a wrapper that started only what went starts nothing, and so on up
87
+ for (let moved = true; moved;) {
88
+ moved = false
89
+ tasks.items.forEach((n, i) => {
90
+ if (remove.has(i) || n.kind !== 'object' || !isWrapper(record(n))) return
91
+ const deps = depsOf(n)
92
+ if (deps.length > 0 && deps.every((x) => gone.has(x))) { drop(i, `removed the '${labelOf(n)}' wrapper, which started nothing else`); moved = true }
93
+ })
102
94
  }
103
95
 
104
- if (dropTsc) {
105
- for (const t of items) {
106
- if (t === wrapper || emptied.some((i) => tasks.items[i] === t)) continue
107
- const d = prop(t, 'dependsOn')
108
- if (d?.kind === 'array') {
109
- const idx = d.items.findIndex((x) => x.kind === 'scalar' && x.value === TSC_LABEL)
110
- if (idx < 0) continue
111
- const [start, end] = removalSpan(text, d as Arr, idx)
112
- edits.push({ start, end, body: '' })
113
- changes.push(`dropped '${TSC_LABEL}' from the dependsOn of '${labelOf(t)}'`)
114
- } else if (d?.kind === 'scalar' && d.value === TSC_LABEL) {
115
- edits.push({ start: d.start, end: d.end, body: '[]' })
116
- changes.push(`dropped '${TSC_LABEL}' from a dependsOn`)
117
- }
118
- }
119
- }
120
-
121
- const want = hubProblemMatcher()
122
- for (const h of hubs) {
123
- const first = h.props[0]
124
- const hubIndent = indentAt(text, h.start)
125
- const propIndent = first != null ? indentAt(text, first.keyStart) : `${hubIndent} `
126
- const unit = propIndent.length > hubIndent.length ? propIndent.slice(hubIndent.length) : ' '
127
- const render = (v: unknown): string => JSON.stringify(v, null, unit).split('\n').join(`\n${propIndent}`)
96
+ const edits: { start: number; end: number; body: string }[] = []
97
+ // a wrapper this pass cut down to ONE task is two tasks on screen for one
98
+ // process: it folds into that task, which takes the wrapper's own runOn
99
+ // and group, and every dependsOn naming the wrapper names the task instead
100
+ const alias = new Map<string, string>()
101
+ const folds = new Map<Obj, Obj[]>()
102
+ tasks.items.forEach((n, i) => {
103
+ if (remove.has(i) || n.kind !== 'object' || !isWrapper(record(n))) return
104
+ const deps = depsOf(n)
105
+ const left = deps.filter((x) => !gone.has(x))
106
+ if (left.length !== 1 || left.length === deps.length) return
107
+ const into = tasks.items.findIndex((t, j) => !remove.has(j) && t.kind === 'object' && labelOf(t) === left[0])
108
+ if (into < 0) return
109
+ remove.add(i)
110
+ alias.set(labelOf(n), left[0]!)
111
+ folds.set(tasks.items[into] as Obj, [...(folds.get(tasks.items[into] as Obj) ?? []), n])
112
+ const opens = (record(n)['runOptions'] as Record<string, unknown> | undefined)?.['runOn'] === 'folderOpen'
113
+ changes.push(`folded the '${labelOf(n)}' wrapper into '${left[0]}'${opens ? ', which now opens with the folder' : ''}`)
114
+ })
115
+ tasks.items.forEach((n, i) => {
116
+ if (remove.has(i) || n.kind !== 'object') return
117
+ const d = prop(n, 'dependsOn')
118
+ const deps = depsOf(n)
119
+ const next = [...new Set(deps.map((x) => alias.get(x) ?? x).filter((x) => !gone.has(x) && x !== labelOf(n)))]
120
+ if (d == null || JSON.stringify(next) === JSON.stringify(deps)) return
121
+ edits.push({ start: d.start, end: d.end, body: JSON.stringify(next).replace(/","/g, '", "') })
122
+ changes.push(`dropped ${deps.filter((x) => !next.includes(x)).map((x) => `'${x}'`).join(', ')} from the dependsOn of '${labelOf(n)}'`)
123
+ })
124
+ for (const [start, end] of removalSpans(text, tasks, remove)) edits.push({ start, end, body: '' })
125
+ // the default build group a removed hub held moves with the fold too
126
+ const hubGroup = at(isHub).map((i) => prop(tasks.items[i] as Obj, 'group')).find((g) => g != null && /"isDefault"\s*:\s*true/.test(text.slice(g.start, g.end)))
127
+ for (const [task, wrappers] of folds) {
128
+ const first = task.props[0]
129
+ const taskIndent = indentAt(text, task.start)
130
+ const propIndent = first != null ? indentAt(text, first.keyStart) : `${taskIndent} `
128
131
  const added: string[] = []
129
- const current = prop(h, 'problemMatcher')
130
- if (current == null || JSON.stringify(plain(text, current)) !== JSON.stringify(want)) {
131
- if (current != null) edits.push({ start: current.start, end: current.end, body: render(want) })
132
- else added.push(`"problemMatcher": ${render(want)}`)
133
- changes.push(`wrote the shipkit problem matcher on '${labelOf(h)}'`)
134
- }
135
- if (h === hub && wrapper != null) {
136
- const ro = prop(h, 'runOptions')
132
+ const runOn = wrappers.map((w) => record(w)['runOptions'] as Record<string, unknown> | undefined).find((r) => r?.['runOn'] != null)?.['runOn']
133
+ if (runOn != null) {
134
+ const ro = prop(task, 'runOptions')
137
135
  const was = ro != null && ro.kind === 'object' ? record(ro) : {}
138
- const inline = `{ ${Object.entries(Object.assign({ runOn: 'folderOpen', instanceLimit: 1 }, was, { runOn: 'folderOpen' })).map(([k, v]) => `${JSON.stringify(k)}: ${JSON.stringify(v)}`).join(', ')} }`
136
+ const inline = `{ ${Object.entries({ ...was, runOn, instanceLimit: was['instanceLimit'] ?? 1 }).map(([k, v]) => `${JSON.stringify(k)}: ${JSON.stringify(v)}`).join(', ')} }`
139
137
  if (ro != null) edits.push({ start: ro.start, end: ro.end, body: inline })
140
138
  else added.push(`"runOptions": ${inline}`)
141
- const wg = prop(wrapper, 'group')
142
- if (wg != null) {
143
- const body = text.slice(wg.start, wg.end)
144
- const g = prop(h, 'group')
145
- if (g != null) edits.push({ start: g.start, end: g.end, body })
146
- else added.push(`"group": ${body}`)
147
- }
148
139
  }
149
- if (added.length === 0) continue
150
- const last = h.props.at(-1)
151
- const joined = added.join(`,\n${propIndent}`)
152
- if (last == null) edits.push({ start: h.start + 1, end: h.start + 1, body: `\n${propIndent}${joined}\n${hubIndent}` })
153
- else edits.push({ start: last.value.end, end: last.value.end, body: `,\n${propIndent}${joined}` })
140
+ const wg = wrappers.map((w) => prop(w, 'group')).find((g) => g != null) ?? hubGroup
141
+ if (wg != null) {
142
+ const body = text.slice(wg.start, wg.end)
143
+ const g = prop(task, 'group')
144
+ if (g != null) edits.push({ start: g.start, end: g.end, body })
145
+ else added.push(`"group": ${body}`)
146
+ }
147
+ const last = task.props.at(-1)
148
+ if (added.length > 0 && last != null) edits.push({ start: last.value.end, end: last.value.end, body: `,\n${propIndent}${added.join(`,\n${propIndent}`)}` })
154
149
  }
155
150
 
151
+ const header = gone.size > 0 ? SCAFFOLD_HEADER.exec(text) : null
152
+ if (header != null) {
153
+ edits.push({ start: header.index, end: header.index + header[0].length, body: '' })
154
+ changes.push('removed the old scaffold comment that described the hub task')
155
+ }
156
156
  let out = text
157
157
  for (const e of edits.sort((a, b) => b.start - a.start)) out = out.slice(0, e.start) + e.body + out.slice(e.end)
158
- return { text: out, changes, kept }
158
+ // empty only when nothing else lives in the file either (`inputs`, a key of its own)
159
+ const onlyTasks = root.props.every((x) => x.key === 'version' || x.key === 'tasks')
160
+ return { text: out, changes, kept, empty: onlyTasks && tasks.items.length > 0 && remove.size === tasks.items.length }
159
161
  }
160
162
 
161
163
  /** whether this repo's gate runs a typecheck: a floor slot or a task that
@@ -184,26 +186,26 @@ const PUBLIC_SKIP = `the root unit publishes to npm: ${TASKS} belongs to every c
184
186
  export const vscodeHubTask = check({
185
187
  id: 'SK017',
186
188
  // a stale editor file is behind, not broken: it warns, and the fix pass
187
- // repairs it
189
+ // of every gate repairs it
188
190
  blocking: false,
189
191
  title: 'vscode hub task',
190
- description: 'the vscode hub task carries the problem matcher and no extra tsc watcher runs',
192
+ description: 'no vscode task starts the shipkit hub, and no extra tsc watcher runs',
191
193
  owner: 'build/vscode-tasks.md',
192
- runs: { onPreCommit: false, onCi: false, onFull: false, onRelease: false, onHealth: true, onSave: 'moved', onClock: false },
194
+ runs: { onPreCommit: false, onCi: true, onFull: true, onRelease: false, onHealth: true, onSave: 'moved', onClock: false },
193
195
  cache: { by: 'reads' },
194
- fix: 'manual',
196
+ fix: 'ci',
195
197
  applies: 'always',
196
198
  run: (c) => {
197
199
  if (rootPublishesNpm(c.config)) return c.ok(PUBLIC_SKIP)
198
200
  const own = ownTasks(c)
199
- if (own === 'none') return c.ok(`no ${TASKS}, so there is no hub task to wire`)
201
+ if (own === 'none') return c.ok(`no ${TASKS}, so no task starts the hub`)
200
202
  if (own === 'linked') return c.ok(`.vscode/ is a link to a shared dir, left alone`)
201
203
  let r: { changes: string[]; kept: string[] }
202
204
  try { r = repairTasks(own.text, { typecheckOn: typecheckOn(c) }) }
203
205
  catch (e) { return c.warn(`${TASKS} does not parse (${(e as Error).message}), so the hub task was not checked`) }
204
206
  for (const k of r.kept) c.note(`${TASKS}: ${k}`)
205
- if (r.changes.length === 0) return c.ok(`${TASKS}: the hub task feeds the problems pane`)
206
- for (const ch of r.changes) c.fail(`${TASKS}: ${ch.replace(/^removed/, 'should remove').replace(/^dropped/, 'should drop').replace(/^wrote/, 'should write').replace(/^folded/, 'should fold')}`)
207
+ if (r.changes.length === 0) return c.ok(`${TASKS}: no task starts the hub`)
208
+ for (const ch of r.changes) c.fail(`${TASKS}: ${ch.replace(/^removed/, 'should remove').replace(/^dropped/, 'should drop')}`)
207
209
  },
208
210
  repair: (c) => {
209
211
  if (rootPublishesNpm(c.config)) return { changed: false, summary: PUBLIC_SKIP }
@@ -211,8 +213,9 @@ export const vscodeHubTask = check({
211
213
  if (typeof own === 'string') return { changed: false, summary: `no ${TASKS} to repair` }
212
214
  const r = repairTasks(own.text, { typecheckOn: typecheckOn(c) })
213
215
  if (r.changes.length === 0) return { changed: false, summary: `${TASKS} already current` }
214
- c.write(TASKS, r.text)
215
- return { changed: true, summary: `${TASKS}: ${r.changes.join(', ')}` }
216
+ if (r.empty) c.remove(TASKS)
217
+ else c.write(TASKS, r.text)
218
+ return { changed: true, summary: `${TASKS}: ${r.changes.join(', ')}${r.empty ? ', and deleted the file, which held no other task' : ''}` }
216
219
  },
217
220
  })
218
221