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.
- package/CHANGELOG.md +38 -0
- package/package.json +1 -1
- 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 +3 -3
- 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/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 +19 -6
- package/src/core/config-rewrite.ts +27 -6
- package/src/core/discover-checks.ts +30 -10
- package/src/core/floor-resolve.ts +16 -7
- 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 +1 -1
- 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 +16 -10
- 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 +1 -1
- 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/fleet/update-fleet.ts +11 -2
- package/src/git-bootstrap.ts +1 -1
- package/src/home/attach.ts +3 -5
- package/src/home/insights.ts +6 -1
- package/src/home/pm-config.ts +2 -2
- 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/problems/extract.ts +27 -5
- package/src/problems/problem-line.ts +2 -3
- package/src/release/run-release.ts +13 -8
- package/src/repo-config.ts +44 -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 +0 -3
- package/src/serve/serve-hub.ts +1 -16
- package/src/serve/tracked-watch.ts +1 -1
- package/src/start-repo.ts +15 -3
- package/src/suite/judge-suites.ts +29 -28
- package/src/suite/select.ts +2 -0
- package/src/suite/suite-cli.ts +6 -3
- 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/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
|
@@ -26,6 +26,7 @@ import { CHECK_TIMEOUT_MS, type RunnableCheck, type CheckBody } from '../core/ch
|
|
|
26
26
|
import { FOREVER, ttlMsOf } from '../core/wire.ts'
|
|
27
27
|
import { budgetOf } from '../core/budget.ts'
|
|
28
28
|
import { repoOff, withRepoOff, whenSummary, type WhenDecl } from '../core/when.ts'
|
|
29
|
+
import { HOOK_BUDGET_MS, HOOK_GATES, HOOK_GATES_OWNER, hookGateOff, type HookName } from '../hooks/hook-gates.ts'
|
|
29
30
|
import { cacheText, filesText, NOWHERE, sourced, type CacheDecl, type ResolvedRuns, type Runs } from '../core/runs.ts'
|
|
30
31
|
import { isNamesInput, type CheckDecl } from './git-key.ts'
|
|
31
32
|
import { populationOf } from '../problems/problem-line.ts'
|
|
@@ -131,6 +132,9 @@ export type CheckRow = {
|
|
|
131
132
|
* `repo` = this repo declared it (a package.json script behind a floor
|
|
132
133
|
* slot, a task, a rule, a check file, a check file). */
|
|
133
134
|
from: CheckFrom
|
|
135
|
+
/** a COMMIT GATE: the git hook that runs it (hooks/hook-gates.ts). The
|
|
136
|
+
* runner never does, so every runner context reads false */
|
|
137
|
+
hook?: HookName
|
|
134
138
|
}
|
|
135
139
|
|
|
136
140
|
/** the one axis every check answers: who wrote the declaration */
|
|
@@ -348,15 +352,16 @@ export async function composeChecks(root: string, c: RepoConfig, o: ComposeOpts
|
|
|
348
352
|
// time is not judged and its answer never stands in for a quick one
|
|
349
353
|
out.push({ row, runnable: runnableOf(row, taskBody(t), { unbudgeted: full }) })
|
|
350
354
|
}
|
|
351
|
-
// a slot
|
|
352
|
-
//
|
|
353
|
-
//
|
|
354
|
-
//
|
|
355
|
-
//
|
|
355
|
+
// a slot the repo turned off never resolves to a task, so without this it
|
|
356
|
+
// had no row at all: no `runs in` value, no reason, and a count of off
|
|
357
|
+
// checks that read zero while a gate step sat dark. It has no command,
|
|
358
|
+
// which is why it declares `never` rather than joining the burn-down of
|
|
359
|
+
// checks nobody has keyed yet.
|
|
356
360
|
for (const id of floor.disabled) {
|
|
357
|
-
const
|
|
361
|
+
const off = repoOff(c, id) ?? { reason: 'turned off', source: 'off' as const }
|
|
362
|
+
const runs = sourced(NOWHERE, 'none', 'repo.config.ts', null, off.reason)
|
|
358
363
|
const row: CheckRow = {
|
|
359
|
-
decl: { id, never: `
|
|
364
|
+
decl: { id, never: `the slot is off, so there is no command to key` },
|
|
360
365
|
title: id,
|
|
361
366
|
description: slotDescription(id, declaredTestUnits(c.units).length > 0),
|
|
362
367
|
gate: false,
|
|
@@ -369,8 +374,8 @@ export async function composeChecks(root: string, c: RepoConfig, o: ComposeOpts
|
|
|
369
374
|
optIn: false,
|
|
370
375
|
fixable: false,
|
|
371
376
|
runs,
|
|
372
|
-
cache: `never:
|
|
373
|
-
when: whenSummary(runs,
|
|
377
|
+
cache: `never: the slot is off`,
|
|
378
|
+
when: whenSummary(runs, off, false),
|
|
374
379
|
deps: null,
|
|
375
380
|
// the repo wrote the line that turned it off
|
|
376
381
|
from: 'repo',
|
|
@@ -518,6 +523,37 @@ export async function composeChecks(root: string, c: RepoConfig, o: ComposeOpts
|
|
|
518
523
|
const body: CheckBody = { cmd: ['bun', d.path], ...(task?.env == null ? {} : { env: task.env }), ...(task?.cwd == null ? {} : { cwd: resolve(root, task.cwd) }) }
|
|
519
524
|
out.push({ row, runnable: runnableOf(row, body, { pointer: `bun ${rel}` }) })
|
|
520
525
|
}
|
|
526
|
+
// THE COMMIT GATES: the git hooks run them (hooks/run-hooks.ts), never the
|
|
527
|
+
// runner, so a row here only makes them enumerable and `off` able to name
|
|
528
|
+
// them. They run in no runner context, and a run by name says where they run
|
|
529
|
+
for (const g of HOOK_GATES) {
|
|
530
|
+
const na = c.nested === true ? 'n/a here: a nested folder, the parent work tree owns the commit gates'
|
|
531
|
+
: g.id === 'blocked-paths' && (c.hooks?.blockedPaths ?? []).length === 0 ? 'n/a here: no hooks.blockedPaths declared' : null
|
|
532
|
+
const off = hookGateOff(c, g.id)
|
|
533
|
+
const hookWords = g.hook === 'both' ? 'the pre-commit and commit-msg hooks' : `the ${g.hook} hook`
|
|
534
|
+
const runs = withRepoOff(sourced(NOWHERE, 'none', 'derived', `a commit gate: ${hookWords} run it on every commit`, na), off)
|
|
535
|
+
const row: CheckRow = {
|
|
536
|
+
decl: { id: g.id, never: `a commit gate runs on the index at commit, never cached` },
|
|
537
|
+
title: g.title,
|
|
538
|
+
description: g.description,
|
|
539
|
+
gate: false,
|
|
540
|
+
build: false,
|
|
541
|
+
blocking: true,
|
|
542
|
+
owner: HOOK_GATES_OWNER,
|
|
543
|
+
group: null,
|
|
544
|
+
timeoutMs: null,
|
|
545
|
+
budgetMs: HOOK_BUDGET_MS,
|
|
546
|
+
optIn: false,
|
|
547
|
+
fixable: false,
|
|
548
|
+
runs,
|
|
549
|
+
cache: `never: ${hookWords}`,
|
|
550
|
+
when: whenSummary(runs, off, false, { hook: true }),
|
|
551
|
+
deps: null,
|
|
552
|
+
from: 'shipkit',
|
|
553
|
+
hook: g.hook,
|
|
554
|
+
}
|
|
555
|
+
out.push({ row, runnable: runnableOf(row, { run: (ctx) => ctx.note(`${g.id} runs in ${hookWords}, never in a gate: \`shipkit hooks list\` shows it`) }) })
|
|
556
|
+
}
|
|
521
557
|
return out
|
|
522
558
|
}
|
|
523
559
|
|
package/src/ledger/ledger.ts
CHANGED
|
@@ -273,9 +273,14 @@ export class Ledger {
|
|
|
273
273
|
/** buffer what a check just measured. Nothing reaches the store until
|
|
274
274
|
* commit() has re-hashed the inputs (rule 2). A READS check with no read
|
|
275
275
|
* set (it reached the world) is never stored (rule 7). */
|
|
276
|
-
record(id: string, outcome: CheckOutcome, reads?: ReadSet | null, notKept?: string | null): void {
|
|
276
|
+
record(id: string, outcome: CheckOutcome, reads?: ReadSet | null, notKept?: string | null, waiverFiles: readonly string[] = []): void {
|
|
277
277
|
const k = this.key(id)
|
|
278
278
|
if (k.key == null) return
|
|
279
|
+
// a READS answer carries every waiving file in its read set. A
|
|
280
|
+
// CONTENT key moves only with its inputs, so a directive outside
|
|
281
|
+
// them could be added or removed under a stored answer
|
|
282
|
+
const outside = k.population === 'content' ? waiverFiles.find((f) => !k.inputOids.some(([p]) => p === '.' || f === p || f.startsWith(`${p}/`))) : undefined
|
|
283
|
+
if (outside != null) { this.ranWhy.set(id, { group: 'not kept', why: `a site in ${outside}, which an in-file directive can waive, sits outside its declared inputs` }); return }
|
|
279
284
|
if (k.population === 'reads') {
|
|
280
285
|
if (reads == null) { this.ranWhy.set(id, { group: 'not kept', why: notKept ?? 'it read outside what the cache can see' }); return }
|
|
281
286
|
this.buffered.set(id, { ...outcome, reads, components: k.components })
|
package/src/ledger/repo-view.ts
CHANGED
|
@@ -7,8 +7,14 @@
|
|
|
7
7
|
// Built once per run and shared: `c.list` in every check and the ledger's
|
|
8
8
|
// replay check read the same snapshot, so a check never lists one tree and is
|
|
9
9
|
// validated against another inside one run.
|
|
10
|
+
//
|
|
11
|
+
// A listing leaves out the repo's vendored dirs (core/vendored.ts), unless
|
|
12
|
+
// its glob names a path inside one: a check never mistakes third party code
|
|
13
|
+
// for the repo's own, and the replay check lists through the same filter.
|
|
10
14
|
import { relative } from 'path'
|
|
11
15
|
import { gitBin } from '../core/git-bin.ts'
|
|
16
|
+
import { listingWithoutVendored } from '../core/vendored.ts'
|
|
17
|
+
import { readVendored } from '../core/vendored-read.ts'
|
|
12
18
|
import { GitScanError, scanPaths, WHOLE_TREE, type ScanResult } from './git-scan.ts'
|
|
13
19
|
|
|
14
20
|
export class RepoView {
|
|
@@ -21,26 +27,30 @@ export class RepoView {
|
|
|
21
27
|
readonly fallback: string | null,
|
|
22
28
|
/** the scan it was read off, handed to the next open as its prior */
|
|
23
29
|
readonly scan: ScanResult | null = null,
|
|
30
|
+
/** the repo's vendored dirs, as its config text declared them at open */
|
|
31
|
+
readonly vendored: readonly string[] = [],
|
|
24
32
|
) {}
|
|
25
33
|
|
|
26
34
|
/** `prior`: an earlier view of the same root in this run. The tree is
|
|
27
35
|
* still staged and hashed fresh, so the list is today's, and an unchanged
|
|
28
36
|
* tree reuses the prior's answer */
|
|
29
37
|
static open(root: string, prior?: RepoView | null): RepoView {
|
|
38
|
+
const vendored = readVendored(root)
|
|
30
39
|
try {
|
|
31
40
|
const scan = scanPaths(root, [WHOLE_TREE], prior?.scan ?? undefined, { treeOnly: true })
|
|
32
41
|
const tree = scan.oidByPath.get(WHOLE_TREE)
|
|
33
42
|
if (tree == null) throw new GitScanError('git could not name the work tree')
|
|
34
|
-
return new RepoView(filesOf(root, tree), null, scan)
|
|
43
|
+
return new RepoView(filesOf(root, tree), null, scan, vendored)
|
|
35
44
|
} catch (e) {
|
|
36
|
-
return new RepoView(walk(root), e instanceof Error ? e.message : String(e))
|
|
45
|
+
return new RepoView(walk(root), e instanceof Error ? e.message : String(e), null, vendored)
|
|
37
46
|
}
|
|
38
47
|
}
|
|
39
48
|
|
|
40
|
-
/** the files matching `glob`, sorted
|
|
49
|
+
/** the files matching `glob`, sorted, the vendored ones left out unless
|
|
50
|
+
* the glob names a path inside one */
|
|
41
51
|
list(glob: string): string[] {
|
|
42
52
|
const g = new Bun.Glob(glob)
|
|
43
|
-
return this.files.filter((f) => g.match(f))
|
|
53
|
+
return listingWithoutVendored(this.files.filter((f) => g.match(f)), glob, this.vendored)
|
|
44
54
|
}
|
|
45
55
|
}
|
|
46
56
|
|
|
@@ -328,8 +328,10 @@ export function toolchainIdentity(repoRoot: string, cliEntry: string): Toolchain
|
|
|
328
328
|
// stands.
|
|
329
329
|
const lib = new LibSource(cliEntry)
|
|
330
330
|
const r = createHash('sha256')
|
|
331
|
-
// the runtime. Free, and a bun upgrade genuinely changes behaviour
|
|
332
|
-
|
|
331
|
+
// the runtime. Free, and a bun upgrade genuinely changes behaviour. The
|
|
332
|
+
// revision too: every canary of one version reports the same version,
|
|
333
|
+
// and a canary upgrade can change what `bun lint` reports
|
|
334
|
+
r.update(`bun=${Bun.version}+${Bun.revision}\n`)
|
|
333
335
|
for (const l of runnerLines(lib, cliEntry)) r.update(`${l}\n`)
|
|
334
336
|
return { id: r.digest('hex').slice(0, 16), install: installOf(root, lib), lib }
|
|
335
337
|
}
|
package/src/mv/mv-md.ts
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
import { execSync } from 'child_process'
|
|
9
9
|
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'fs'
|
|
10
10
|
import { basename, dirname, join, relative } from 'path'
|
|
11
|
+
import { isVendored } from '../core/vendored.ts'
|
|
12
|
+
import { vendoredAround } from '../core/vendored-read.ts'
|
|
11
13
|
|
|
12
14
|
export type MvIo = { print: (l: string) => void; printErr: (l: string) => void }
|
|
13
15
|
export type MvOpts = { write?: boolean; commit?: boolean }
|
|
@@ -130,8 +132,11 @@ export function movePage(root: string, oldRel: string, newRel: string, io: MvIo,
|
|
|
130
132
|
const bound = (t: string): RegExp =>
|
|
131
133
|
new RegExp('(?<![\\w-])' + t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + (/[/.]$/.test(t) || t.endsWith('.ts') ? '' : '(?![\\w-])'), 'g')
|
|
132
134
|
|
|
135
|
+
// third party code keeps its own references, byte for byte
|
|
136
|
+
const repo = vendoredAround(root)
|
|
137
|
+
const vendored = (repo?.dirs ?? []).map((d) => posix(relative(root, join(repo!.root, d))))
|
|
133
138
|
const tracked = sh('git ls-files', root).split('\n').filter(Boolean)
|
|
134
|
-
.filter((f) => !/\.(png|jpe?g|gif|webp|ico|woff2?|ttf|zip|gz|lockb|lock)$/i.test(f))
|
|
139
|
+
.filter((f) => !/\.(png|jpe?g|gif|webp|ico|woff2?|ttf|zip|gz|lockb|lock)$/i.test(f) && !isVendored(f, vendored))
|
|
135
140
|
|
|
136
141
|
// every path this move relocates, old → new. The sibling dir carries its
|
|
137
142
|
// whole subtree, and the probe is renamed after the page, so its own entry
|
package/src/problems/extract.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// file:line findings out of what a check printed, one function per format the
|
|
2
|
-
// floor meets: tsc,
|
|
3
|
-
//
|
|
2
|
+
// floor meets: tsc, bun lint (the unix format the built-in lint slot asks
|
|
3
|
+
// for), oxlint (both its piped and its graphical output, kept while a repo
|
|
4
|
+
// still lints with it: SK001 warns on one, and it dies with the last), eslint's stylish
|
|
5
|
+
// output, a failing bun test, and a shipkit wire line naming a file.
|
|
4
6
|
// Every one feeds the one Problem shape (problem-line.ts).
|
|
5
7
|
//
|
|
6
8
|
// a finding the source cannot anchor in a file is dropped rather than pinned
|
|
@@ -62,6 +64,20 @@ export function fromTsc(text: string, ctx: ExtractCtx): Problem[] {
|
|
|
62
64
|
return out
|
|
63
65
|
}
|
|
64
66
|
|
|
67
|
+
const BUN_LINT_UNIX = /^(.+?):(\d+):(\d+): (.*) \[(Error|Warning)\/([^\]]+)\]$/
|
|
68
|
+
|
|
69
|
+
/** bun lint `--format unix`: `src/a.ts:1:7: msg [Warning/no-unused-vars]` */
|
|
70
|
+
export function fromBunLint(text: string, ctx: ExtractCtx): Problem[] {
|
|
71
|
+
const out: Problem[] = []
|
|
72
|
+
for (const raw of stripAnsi(text).split('\n')) {
|
|
73
|
+
const m = BUN_LINT_UNIX.exec(raw)
|
|
74
|
+
if (m == null) continue
|
|
75
|
+
const file = repoFile(ctx, m[1]!, false)
|
|
76
|
+
if (file != null) out.push({ file, line: Number(m[2]), col: Number(m[3]), severity: m[5] === 'Error' ? 'error' : 'warning', check: ctx.check, message: `${m[6]} ${m[4]!.trim()}` })
|
|
77
|
+
}
|
|
78
|
+
return out
|
|
79
|
+
}
|
|
80
|
+
|
|
65
81
|
const OXLINT_PIPED = /^(.+?):(\d+):(\d+): (error|warning) (\S+?): (.*)$/
|
|
66
82
|
const OXLINT_HEAD = /^\s*(x|!|×|⚠)\s+(.+)$/
|
|
67
83
|
const OXLINT_AT = /^\s*(?:,-|╭─)\[(.+):(\d+):(\d+)\]\s*$/
|
|
@@ -148,14 +164,20 @@ export function fromBunTest(text: string, ctx: ExtractCtx): Problem[] {
|
|
|
148
164
|
|
|
149
165
|
const WIRE_REF = /((?:[\w@.~-]+\/)*[\w@.~-]+\.[A-Za-z0-9]+):(\d+)(?::(\d+))?/g
|
|
150
166
|
|
|
151
|
-
/** a FAIL or WARN wire line
|
|
152
|
-
*
|
|
167
|
+
/** a FAIL or WARN wire line: at the site the check reported when it gave one,
|
|
168
|
+
* else at every `path:line` or `path:line:col` the text names. The path must
|
|
169
|
+
* be a file this repo has: a url and a `host:port` look the same */
|
|
153
170
|
export function fromWire(lines: Line[], ctx: ExtractCtx): Problem[] {
|
|
154
171
|
const out: Problem[] = []
|
|
155
172
|
for (const l of lines) {
|
|
156
173
|
if (l.kind !== 'fail' && l.kind !== 'warn') continue
|
|
157
174
|
const severity: Severity = l.kind === 'fail' ? 'error' : 'warning'
|
|
158
175
|
const message = l.text.replace(/^\[[^\]]+\]\s*/, '').trim()
|
|
176
|
+
if (l.at != null) {
|
|
177
|
+
const file = repoFile(ctx, l.at.file, true)
|
|
178
|
+
if (file != null) out.push({ file, line: l.at.line ?? 1, col: l.at.col ?? 1, severity, check: ctx.check, message })
|
|
179
|
+
continue
|
|
180
|
+
}
|
|
159
181
|
for (const m of l.text.matchAll(WIRE_REF)) {
|
|
160
182
|
const before = l.text.slice(0, m.index)
|
|
161
183
|
if (/[a-z]+:\/\/\S*$/i.test(before)) continue
|
|
@@ -173,7 +195,7 @@ export function extractProblems(src: { lines: Line[]; output?: string }, ctx: Ex
|
|
|
173
195
|
const text = src.output ?? ''
|
|
174
196
|
const all = [
|
|
175
197
|
...fromWire(src.lines, ctx),
|
|
176
|
-
...(text === '' ? [] : [...fromTsc(text, ctx), ...fromOxlint(text, ctx), ...fromEslint(text, ctx), ...fromBunTest(text, ctx)]),
|
|
198
|
+
...(text === '' ? [] : [...fromTsc(text, ctx), ...fromBunLint(text, ctx), ...fromOxlint(text, ctx), ...fromEslint(text, ctx), ...fromBunTest(text, ctx)]),
|
|
177
199
|
]
|
|
178
200
|
const seen = new Set<string>()
|
|
179
201
|
return all.filter((p) => {
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
// the ONE shape a problem takes on the hub terminal, and the vscode problem
|
|
2
|
-
// matcher that reads it back
|
|
3
|
-
//
|
|
4
|
-
// never drift apart.
|
|
2
|
+
// matcher that reads it back, in one file so the two can never drift apart.
|
|
3
|
+
// No house task carries the matcher any more (SK017 removes hub tasks).
|
|
5
4
|
//
|
|
6
5
|
// a run prints one FRAME: the begin line, one problem line per finding, the
|
|
7
6
|
// end line. The matcher is a background one, so vscode clears the pane at the
|
|
@@ -117,7 +117,7 @@ export async function runReleaseGate(root: string, config: RepoConfig, print: (l
|
|
|
117
117
|
return await runCi(root, {
|
|
118
118
|
fix: o.fix ?? false,
|
|
119
119
|
// a check's repair never runs on the release path (runs.onRelease):
|
|
120
|
-
// only the mechanical slots (the
|
|
120
|
+
// only the mechanical slots (the import fixer) may, when bump asks
|
|
121
121
|
fixRules: false,
|
|
122
122
|
// the rows declaring onRelease, whatever the tier
|
|
123
123
|
release: true,
|
|
@@ -243,13 +243,7 @@ export async function releaseUnit(root: string, config: RepoConfig, unit: Releas
|
|
|
243
243
|
const remoteName0 = 'remote' in remote ? remote.remote : null
|
|
244
244
|
const tagExists = (): boolean => tagExistsLocally(root, tag)
|
|
245
245
|
const remoteState: RemoteTagState = remoteName0 == null ? { state: 'unreadable', error: 'no remote' } : remoteTagState(root, remoteName0, tag)
|
|
246
|
-
|
|
247
|
-
blockers.push(`cannot read tags on '${remoteName0}' (${remoteState.error}) — the push would be a guess`)
|
|
248
|
-
// a tag NAME the remote already carries at another commit is not a
|
|
249
|
-
// resumable release: this version would describe a tree that shipped as
|
|
250
|
-
// something else, and git refuses the push anyway
|
|
251
|
-
if (remoteState.state === 'different')
|
|
252
|
-
blockers.push(`tag ${tag} already on '${remoteName0 ?? ''}' at ${remoteState.remoteOid.slice(0, 8)}, ${remoteState.localOid == null ? 'and not here at all' : `not ${remoteState.localOid.slice(0, 8)} as here`} — that version number is burnt, bump a new one`)
|
|
246
|
+
blockers.push(...tagBlockers(remoteName0, remoteState, tag))
|
|
253
247
|
const ghReleaseExists = (): boolean =>
|
|
254
248
|
wantGh && Bun.which('gh') != null && Bun.spawnSync(['gh', 'release', 'view', tag], { cwd: root }).exitCode === 0
|
|
255
249
|
const fullyShipped = tagExists() && remoteState.state === 'same' && (!wantGh || ghReleaseExists()) && (!wantNpm || alreadyPublished)
|
|
@@ -365,3 +359,14 @@ export async function releaseUnit(root: string, config: RepoConfig, unit: Releas
|
|
|
365
359
|
rmSync(work, { recursive: true, force: true })
|
|
366
360
|
}
|
|
367
361
|
}
|
|
362
|
+
|
|
363
|
+
/** what the remote's view of the tag forbids: a remote nobody can read makes
|
|
364
|
+
* the push a guess, and a tag NAME it already carries at another commit is not
|
|
365
|
+
* a resumable release (this version would describe a tree that shipped as
|
|
366
|
+
* something else, and git refuses the push anyway) */
|
|
367
|
+
export function tagBlockers(remote: string | null, state: RemoteTagState, tag: string): string[] {
|
|
368
|
+
if (remote != null && state.state === 'unreadable') return [`cannot read tags on '${remote}' (${state.error}) — the push would be a guess`]
|
|
369
|
+
if (state.state === 'different')
|
|
370
|
+
return [`tag ${tag} already on '${remote ?? ''}' at ${state.remoteOid.slice(0, 8)}, ${state.localOid == null ? 'and not here at all' : `not ${state.localOid.slice(0, 8)} as here`} — that version number is burnt, bump a new one`]
|
|
371
|
+
return []
|
|
372
|
+
}
|
package/src/repo-config.ts
CHANGED
|
@@ -5,21 +5,21 @@
|
|
|
5
5
|
// (lint/typecheck/test/imports:check/cycles): a package.json script wins;
|
|
6
6
|
// with no script, imports:check/cycles fill from shipkit's own
|
|
7
7
|
// rvlib-ts-tools dep and typecheck from the repo's tsc (O6 zero-config);
|
|
8
|
-
// a slot that can't fill emits a visible reason,
|
|
9
|
-
// `
|
|
8
|
+
// a slot that can't fill emits a visible reason, and one turned off with
|
|
9
|
+
// `off: { x: '<why>' }` prints a skipped-by-config line, never silence.
|
|
10
10
|
// The `contracts` task is always present when any contract applies.
|
|
11
|
-
import { existsSync } from 'fs'
|
|
11
|
+
import { existsSync, readFileSync } from 'fs'
|
|
12
12
|
import { isAbsolute, join, normalize } from 'path'
|
|
13
13
|
import { ROUTED_SCRIPTS } from './core/canonical-scripts.ts'
|
|
14
14
|
import { FLOOR_IDS } from './core/floor.ts'
|
|
15
15
|
export { resolveFloor, type FloorResolution, type FloorSource } from './core/floor.ts'
|
|
16
16
|
import type { ContractManifest } from './contracts/contract-types.ts'
|
|
17
|
-
import type { ProseConfig } from './prose/run-prose.ts'
|
|
18
|
-
import { proseWordsProblems } from './prose/ste.ts'
|
|
19
17
|
import { validateSmokeConfig, type SmokeConfig } from './smoke/run-smoke.ts'
|
|
20
18
|
import { RULE_ID_RE, validateRules } from './rules/rule.ts'
|
|
21
19
|
import { builtinCheckIds } from './core/discover-checks.ts'
|
|
20
|
+
import { RETIRED_CHECK_IDS } from './core/retired-checks.ts'
|
|
22
21
|
import { parseWhen } from './core/when.ts'
|
|
22
|
+
import { normalizeVendored, vendoredOfText, vendoredProblem } from './core/vendored.ts'
|
|
23
23
|
import { budgetValueProblem } from './core/budget.ts'
|
|
24
24
|
import type { Rule } from './check.ts'
|
|
25
25
|
import { leakAllowProblems } from './hooks/leak-guard.ts'
|
|
@@ -80,12 +80,13 @@ export type RepoConfig = {
|
|
|
80
80
|
social?: string[]
|
|
81
81
|
/** prefix for contract fix: pointers (docs checkout or URL) — written by init, never baked in */
|
|
82
82
|
docsBase?: string
|
|
83
|
-
/** a floor slot: `
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
83
|
+
/** a floor slot: a def with a `cmd` or `logic` REPLACES it, and a def
|
|
84
|
+
* with neither DECORATES whatever fills it. The decoration is how a repo
|
|
85
|
+
* declares what its own `test` script reads (`{ test: { inputs:
|
|
86
|
+
* ['tests', 'src'] } }`) without restating the command: the read set is
|
|
87
|
+
* repo knowledge, the command is already in package.json, and restating
|
|
88
|
+
* it is a second source of truth. A slot set to `false` is RETIRED, read
|
|
89
|
+
* as `off` while SK029 ports it there: a slot turns off like any check */
|
|
89
90
|
defaults?: Record<string, Partial<TaskDef> | false>
|
|
90
91
|
/** DEPRECATED, read through 0.40 and rewritten by `shipkit check --fix
|
|
91
92
|
* SK016`: a build step is a `<name>.check.ts` file with `build: true` */
|
|
@@ -167,10 +168,10 @@ export type RepoConfig = {
|
|
|
167
168
|
* folder only: it never commits, untracks, tags or pushes on the parent's
|
|
168
169
|
* history. Rule SK015 fails when this and the disk disagree (core/nesting.ts) */
|
|
169
170
|
nested?: boolean
|
|
170
|
-
/** THE CHECKS THIS REPO TURNS OFF, check id → why
|
|
171
|
-
*
|
|
172
|
-
* declaration (build/shipkit/runs.md), and an
|
|
173
|
-
* context. `defineRepo` throws on a blank reason and on a rule id no
|
|
171
|
+
/** THE CHECKS THIS REPO TURNS OFF, check id → why, for a whole check it
|
|
172
|
+
* does not own (one false finding is waived at its site instead): where
|
|
173
|
+
* a check runs is its own declaration (build/shipkit/runs.md), and an
|
|
174
|
+
* off check runs in no context. `defineRepo` throws on a blank reason and on a rule id no
|
|
174
175
|
* check carries. The hub writes this map through the managed block in
|
|
175
176
|
* this file (core/when-block.ts). */
|
|
176
177
|
off?: Record<string, string>
|
|
@@ -251,6 +252,18 @@ export type RepoConfig = {
|
|
|
251
252
|
* repo root is refused outright: a single line that excuses everything
|
|
252
253
|
* would turn a blocking rule into a false green. */
|
|
253
254
|
notReleasable?: string[]
|
|
255
|
+
/** repo-relative dirs of THIRD PARTY code (a git subtree, a copied
|
|
256
|
+
* library) that must stay byte identical to upstream. Every tool that
|
|
257
|
+
* treats the tree as the repo's own source leaves them out: the import
|
|
258
|
+
* fixer and its package discovery, imports:check, cycles, the fix pass
|
|
259
|
+
* (a fixer that rewrites a file under one gets it put back, with a
|
|
260
|
+
* WARN naming it), bun lint, the checks' file
|
|
261
|
+
* listings, check discovery, the md scan and `shipkit mv`. Cache keys
|
|
262
|
+
* and watchers still read them, because they are still inputs, and the
|
|
263
|
+
* leak guard still scans what a commit stages there. Write it
|
|
264
|
+
* as a literal array of strings: the tools that never load this file
|
|
265
|
+
* read it as text. One entry is one dir, never a glob */
|
|
266
|
+
vendored?: string[]
|
|
254
267
|
/** git-hook gates: built-in leak guard + commit-message predicate, plus the
|
|
255
268
|
* repo's own commands. `shipkit hooks list` enumerates them, so what blocks
|
|
256
269
|
* a commit is answerable without reading shell. hooks/run-hooks.ts */
|
|
@@ -263,12 +276,6 @@ export type RepoConfig = {
|
|
|
263
276
|
/** what a `*.check.ts` with no `export const blocking` means here */
|
|
264
277
|
blocking?: boolean
|
|
265
278
|
}
|
|
266
|
-
/** house prose in STE (identity/writing-voice.md), gated by rule SK014.
|
|
267
|
-
* `include` is what makes the rule run at all: a repo that declares no
|
|
268
|
-
* scope is skipped rather than failed, because STE binds the prose an
|
|
269
|
-
* agent WRITES and no repo can be opted in from the outside.
|
|
270
|
-
* prose/run-prose.ts */
|
|
271
|
-
prose?: ProseConfig
|
|
272
279
|
/** `shipkit smoke`: the deployed url, the routes a read only browser pass
|
|
273
280
|
* visits after a deploy, and how it logs in. The password is a secret
|
|
274
281
|
* NAME: the value never sits in config, argv or env. smoke/run-smoke.ts */
|
|
@@ -300,7 +307,7 @@ export type RepoConfig = {
|
|
|
300
307
|
logDir?: string
|
|
301
308
|
}
|
|
302
309
|
|
|
303
|
-
const KNOWN_KEYS = new Set(['name', 'venues', 'social', 'docsBase', 'defaults', 'tasks', 'asks', 'creds', 'credsCommand', 'credsReadCommand', 'notifyCommand', 'contracts', 'gitignore', 'ignoredInputs', 'untrackGenerated', 'nested', 'off', 'when', 'budgets', 'ruleExempt', 'visibility', 'archived', 'reviewer', 'oneWayIn', 'rules', 'release', 'units', 'notReleasable', 'hooks', 'checkDefaults', 'tui', 'serve', 'identity', 'logDir', '
|
|
310
|
+
const KNOWN_KEYS = new Set(['name', 'venues', 'social', 'docsBase', 'defaults', 'tasks', 'asks', 'creds', 'credsCommand', 'credsReadCommand', 'notifyCommand', 'contracts', 'gitignore', 'ignoredInputs', 'untrackGenerated', 'nested', 'off', 'when', 'budgets', 'ruleExempt', 'visibility', 'archived', 'reviewer', 'oneWayIn', 'rules', 'release', 'units', 'notReleasable', 'vendored', 'hooks', 'checkDefaults', 'tui', 'serve', 'identity', 'logDir', 'smoke', 'tests'])
|
|
304
311
|
|
|
305
312
|
export function defineRepo(c: RepoConfig): RepoConfig {
|
|
306
313
|
if (typeof c.name !== 'string' || c.name === '') throw new Error('defineRepo: name is required')
|
|
@@ -324,7 +331,7 @@ export function defineRepo(c: RepoConfig): RepoConfig {
|
|
|
324
331
|
// gate is exactly the never-silence failure mode (A23)
|
|
325
332
|
for (const k of Object.keys(c))
|
|
326
333
|
if (!KNOWN_KEYS.has(k))
|
|
327
|
-
throw new Error(`defineRepo: unknown key '${k}'${k === 'checks' ? ` — checks:[] was removed; drop a <name>.check.ts file instead ('shipkit check' auto-discovers it)` : k === 'home' ? ` — home.resources was removed; declare each one in a <name>.shipkit.ts file as \`export default globalProcess({...})\`` : ''}`)
|
|
334
|
+
throw new Error(`defineRepo: unknown key '${k}'${k === 'checks' ? ` — checks:[] was removed; drop a <name>.check.ts file instead ('shipkit check' auto-discovers it)` : k === 'home' ? ` — home.resources was removed; declare each one in a <name>.shipkit.ts file as \`export default globalProcess({...})\`` : k === 'prose' ? ` — prose was removed with rule SK014: delete the key` : ''}`)
|
|
328
335
|
if (c.release?.publisher != null && !(PUBLISHERS as readonly string[]).includes(c.release.publisher))
|
|
329
336
|
throw new Error(`defineRepo: release.publisher '${String(c.release.publisher)}' is not a publisher (${PUBLISHERS.join(', ')})`)
|
|
330
337
|
// a typo'd unit key would silently drop a target and release the wrong shape
|
|
@@ -348,6 +355,11 @@ export function defineRepo(c: RepoConfig): RepoConfig {
|
|
|
348
355
|
if (isAbsolute(d) || rel === '.' || rel === '' || rel === '..' || rel.startsWith('../'))
|
|
349
356
|
throw new Error(`defineRepo: notReleasable '${d}' is the repo root or outside it, and would excuse every package at once — name one package dir per entry`)
|
|
350
357
|
}
|
|
358
|
+
if (c.vendored != null && !Array.isArray(c.vendored)) throw new Error(`defineRepo: vendored is a list of repo-relative dirs`)
|
|
359
|
+
for (const d of c.vendored ?? []) {
|
|
360
|
+
const bad = vendoredProblem(d)
|
|
361
|
+
if (bad != null) throw new Error(`defineRepo: vendored '${String(d)}' ${bad}`)
|
|
362
|
+
}
|
|
351
363
|
for (const [k, slot] of Object.entries(c.defaults ?? {})) {
|
|
352
364
|
if (!(FLOOR_IDS as readonly string[]).includes(k))
|
|
353
365
|
throw new Error(`defineRepo: defaults.${k} is not a floor task (floor: ${FLOOR_IDS.join(', ')})`)
|
|
@@ -385,6 +397,7 @@ export function defineRepo(c: RepoConfig): RepoConfig {
|
|
|
385
397
|
for (const [id, reason] of Object.entries(c.off ?? {})) {
|
|
386
398
|
if (id.trim() === '') throw new Error(`defineRepo: off has an entry with no check id`)
|
|
387
399
|
if (typeof reason !== 'string' || reason.trim() === '') throw new Error(`defineRepo: off.${id} needs a reason: a check nobody can explain is a silenced gate`)
|
|
400
|
+
if (RETIRED_CHECK_IDS[id] != null) throw new Error(`defineRepo: off.${id} names a retired rule (${RETIRED_CHECK_IDS[id]}): delete the entry`)
|
|
388
401
|
if (RULE_ID_RE.test(id) && !builtinCheckIds().includes(id) && !(c.rules ?? []).some((r) => r.id === id))
|
|
389
402
|
throw new Error(`defineRepo: off.${id} names no rule, the ids are in \`shipkit checks\``)
|
|
390
403
|
}
|
|
@@ -403,15 +416,12 @@ export function defineRepo(c: RepoConfig): RepoConfig {
|
|
|
403
416
|
}
|
|
404
417
|
// an exemption silences a gate, so it names a rule that exists and says why
|
|
405
418
|
for (const [id, reason] of Object.entries(c.ruleExempt ?? {})) {
|
|
419
|
+
if (RETIRED_CHECK_IDS[id] != null) throw new Error(`defineRepo: ruleExempt.${id} names a retired rule (${RETIRED_CHECK_IDS[id]}): delete the entry`)
|
|
406
420
|
if (!builtinCheckIds().includes(id) && !(c.rules ?? []).some((r) => r.id === id))
|
|
407
421
|
throw new Error(`defineRepo: ruleExempt.${id} names no rule, the ids are in \`shipkit checks\``)
|
|
408
422
|
if (typeof reason !== 'string' || reason.trim() === '')
|
|
409
423
|
throw new Error(`defineRepo: ruleExempt.${id} needs a reason, an exemption nobody can explain is a silenced gate`)
|
|
410
424
|
}
|
|
411
|
-
// a word list entry silences or adds gate hits, so a malformed one dies
|
|
412
|
-
// here rather than matching nothing
|
|
413
|
-
const words = proseWordsProblems(c.prose?.words)
|
|
414
|
-
if (words.length > 0) throw new Error(`defineRepo: ${words[0]}`)
|
|
415
425
|
// a smoke typo would land as a run that visits nothing and exits 0
|
|
416
426
|
if (c.smoke != null) validateSmokeConfig(c.smoke)
|
|
417
427
|
// a leak-guard waiver is the one config row that can make a gate quieter,
|
|
@@ -439,5 +449,12 @@ export async function loadRepoConfig(root: string, o: { fresh?: boolean } = {}):
|
|
|
439
449
|
if (!existsSync(path)) throw new Error(`no repo.config.ts in ${root} — run: shipkit init`)
|
|
440
450
|
const mod = (await import(o.fresh === true ? `${path}?v=${Date.now()}-${Math.random().toString(36).slice(2)}` : path)) as { default?: RepoConfig }
|
|
441
451
|
if (mod.default == null) throw new Error(`${path}: must default-export defineRepo({...})`)
|
|
452
|
+
// the tools that never load the config read `vendored` off the text, so
|
|
453
|
+
// a value the text cannot show would omit one set of dirs here and
|
|
454
|
+
// another there
|
|
455
|
+
const loaded = (mod.default.vendored ?? []).map(normalizeVendored)
|
|
456
|
+
const read = vendoredOfText(readFileSync(path, 'utf8'))
|
|
457
|
+
if (loaded.join('\0') !== read.join('\0'))
|
|
458
|
+
throw new Error(`${path}: \`vendored\` must be a literal array of strings, the tools that never load the config read it as text (they read [${read.join(', ')}], the config says [${loaded.join(', ')}])`)
|
|
442
459
|
return mod.default
|
|
443
460
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# `bun lint` is the house linter, and `sk ci` runs it once [lint] is declared.
|
|
2
|
+
# [fmt] is the house style for a manual `bun fmt`: nothing formats in the gate.
|
|
3
|
+
[fmt]
|
|
4
|
+
semi = false
|
|
5
|
+
singleQuote = true
|
|
6
|
+
printWidth = 140
|
|
7
|
+
tabWidth = 4
|
|
8
|
+
# code only: prose keeps its own shape, and package.json belongs to its fixers
|
|
9
|
+
ignorePatterns = ["*.md", "*.json", "*.jsonc", "*.yaml", "*.yml", "*.toml"]
|
|
10
|
+
|
|
11
|
+
[lint]
|
|
@@ -33,8 +33,7 @@
|
|
|
33
33
|
"js/ts.tsserver.automaticTypeAcquisition.enabled": false,
|
|
34
34
|
"js/ts.tsserver.checkNpmIsInstalled": false,
|
|
35
35
|
|
|
36
|
-
//
|
|
37
|
-
"oxc.enable": true,
|
|
36
|
+
// eslint off, and no formatter on save: formatting is a manual `bun fmt`
|
|
38
37
|
"eslint.enable": false,
|
|
39
38
|
"js/ts.format.enabled": false
|
|
40
39
|
}
|
|
@@ -111,3 +111,19 @@ export function removalSpan(text: string, arr: Extract<Node, { kind: 'array' }>,
|
|
|
111
111
|
}
|
|
112
112
|
return [item.start, item.end]
|
|
113
113
|
}
|
|
114
|
+
|
|
115
|
+
/** the spans that take several items out of one array at once, disjoint so
|
|
116
|
+
* they apply in any order. An item with a kept one after it goes with its own
|
|
117
|
+
* comma, and the removed run after the last kept item goes as one span from
|
|
118
|
+
* that item's end, so no comma is left dangling */
|
|
119
|
+
export function removalSpans(text: string, arr: Extract<Node, { kind: 'array' }>, idxs: Iterable<number>): [number, number][] {
|
|
120
|
+
const gone = new Set(idxs)
|
|
121
|
+
if (gone.size === 0) return []
|
|
122
|
+
const kept = arr.items.flatMap((_, i) => (gone.has(i) ? [] : [i]))
|
|
123
|
+
const lastKept = kept.at(-1) ?? -1
|
|
124
|
+
const spans: [number, number][] = []
|
|
125
|
+
for (const i of [...gone].sort((a, b) => a - b)) if (i < lastKept) spans.push(removalSpan(text, arr, i))
|
|
126
|
+
const tail = [...gone].filter((i) => i > lastKept).sort((a, b) => a - b)
|
|
127
|
+
if (tail.length > 0) spans.push([lastKept < 0 ? arr.items[tail[0]!]!.start : arr.items[lastKept]!.end, arr.items[tail.at(-1)!]!.end])
|
|
128
|
+
return spans
|
|
129
|
+
}
|