rvlib-shipkit 1.8.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +66 -0
- package/package.json +1 -1
- package/src/assets/gen/comfyts-gen.ts +31 -14
- package/src/assets/gen/generate-roll.ts +20 -7
- package/src/assets/kit-config.ts +9 -0
- package/src/assets/kits/identity-kit.ts +7 -1
- package/src/assets/run-assets.ts +3 -3
- package/src/assets/server/studio-server.ts +3 -3
- package/src/check.ts +5 -2
- package/src/checks/SK001-package-json-shape.check.ts +47 -12
- package/src/checks/SK013-one-way-in-policy.check.ts +8 -7
- package/src/checks/SK017-vscode-hub-task.check.ts +109 -106
- package/src/checks/SK019-tests-run-in-units.check.ts +2 -1
- package/src/checks/SK027-agent-wiki-links.check.ts +5 -2
- package/src/checks/SK029-explicit-check-files.check.ts +25 -8
- package/src/checks/SK034-commit-gates-armed.check.ts +90 -0
- package/src/cli/cmd_Ci.ts +6 -5
- package/src/cli/cmd_Commands.ts +21 -5
- package/src/cli/cmd_Hooks.ts +6 -2
- package/src/cli/cmd_Init.ts +1 -1
- package/src/cli/cmd_Serve.ts +1 -2
- package/src/cli/cmd_Start.ts +1 -1
- package/src/cli.ts +2 -4
- package/src/collections/providers.ts +1 -1
- package/src/core/budget.ts +6 -8
- package/src/core/bun-builtins.ts +36 -0
- package/src/core/bun-tool-cli.ts +23 -0
- package/src/core/canonical-scripts.ts +6 -4
- package/src/core/check-ctx.ts +104 -10
- package/src/core/check-file-names.ts +2 -3
- package/src/core/check-pass.ts +17 -1
- package/src/core/check-runner.ts +32 -10
- package/src/core/config-rewrite.ts +27 -6
- package/src/core/discover-checks.ts +64 -15
- package/src/core/fix-scope.ts +5 -0
- package/src/core/floor-resolve.ts +18 -8
- package/src/core/git-root.ts +22 -12
- package/src/core/job-run.ts +2 -2
- package/src/core/layout.ts +4 -1
- package/src/core/legacy-shapes.ts +40 -0
- package/src/core/one-lib.ts +5 -3
- package/src/core/one-way-in-policy.ts +106 -13
- package/src/core/pre-commit-discovery.ts +106 -0
- package/src/core/retired-checks.ts +2 -0
- package/src/core/run-ci.ts +23 -16
- package/src/core/run-fix.ts +9 -0
- package/src/core/run-fixers.ts +91 -35
- package/src/core/runs-matrix.ts +7 -0
- package/src/core/slots.ts +36 -6
- package/src/core/vendored-read.ts +39 -0
- package/src/core/vendored.ts +82 -0
- package/src/core/waivers.ts +125 -0
- package/src/core/when-block.ts +25 -185
- package/src/core/when.ts +30 -13
- package/src/core/wire.ts +17 -1
- package/src/git-bootstrap.ts +1 -1
- package/src/home/attach.ts +3 -5
- package/src/home/gui-app/ShipkitGui.swift +96 -0
- package/src/home/insights.ts +6 -1
- package/src/home/pm-config.ts +2 -2
- package/src/home/resources.ts +3 -0
- package/src/home/run-daemon.ts +2 -2
- package/src/home/run-unit-action.ts +2 -2
- package/src/hooks/commit-message.ts +2 -14
- package/src/hooks/hook-gates.ts +38 -0
- package/src/hooks/leak-guard.ts +8 -3
- package/src/hooks/one-way-in-guard.ts +8 -2
- package/src/hooks/root-identity.ts +9 -6
- package/src/hooks/run-hooks.ts +72 -41
- package/src/hooks/staged.ts +49 -0
- package/src/hooks/tree-shrink.ts +41 -0
- package/src/init-repo.ts +0 -42
- package/src/ledger/check-table.ts +1 -1
- package/src/ledger/gate-checks.ts +45 -9
- package/src/ledger/ledger.ts +6 -1
- package/src/ledger/repo-view.ts +14 -4
- package/src/ledger/toolchain-identity.ts +4 -2
- package/src/mv/mv-md.ts +6 -1
- package/src/plan/planner.ts +21 -4
- package/src/plan/repo-source.ts +7 -3
- package/src/problems/extract.ts +27 -5
- package/src/problems/problem-line.ts +2 -3
- package/src/release/run-release.ts +30 -16
- package/src/release/unit-gate.ts +12 -5
- package/src/repo-config.ts +51 -27
- package/src/scaffold/files/bunfig.toml +11 -0
- package/src/scaffold/files/vscode-settings.json +1 -2
- package/src/scaffold/jsonc-spans.ts +16 -0
- package/src/scaffold/project-scaffold.ts +43 -77
- package/src/serve/hub-app/check-view.tsx +4 -4
- package/src/serve/hub-app/check-words.ts +1 -0
- package/src/serve/hub-app/checks-svc.ts +1 -2
- package/src/serve/hub-app/home.html +0 -1
- package/src/serve/hub-app/hub-types.ts +0 -2
- package/src/serve/hub-app/index.html +0 -1
- package/src/serve/hub-app/project-list.css +1 -2
- package/src/serve/hub-app/tree-build.ts +3 -2
- package/src/serve/hub-app/tree-svc.ts +2 -2
- package/src/serve/hub-port.ts +3 -3
- package/src/serve/hub-runtime.ts +3 -5
- package/src/serve/project-gutter.ts +5 -2
- package/src/serve/run-lanes.ts +7 -3
- package/src/serve/serve-hub.ts +1 -16
- package/src/serve/tracked-watch.ts +1 -1
- package/src/smoke/run-smoke.ts +13 -6
- package/src/start-repo.ts +15 -3
- package/src/suite/judge-suites.ts +81 -32
- package/src/suite/select.ts +2 -0
- package/src/suite/suite-cli.ts +27 -16
- package/src/suite/suite-cpu-preload.ts +7 -11
- package/src/suite/test-units.ts +7 -4
- package/src/ts/config.ts +12 -5
- package/src/ts/imports.ts +3 -1
- package/src/ts/typecheck-cache.ts +11 -0
- package/src/ts/typecheck-cli.ts +2 -1
- package/src/tui/build-steps-panel.ts +1 -1
- package/src/wiki/scan-md.ts +16 -9
- package/src/checks/SK009-commit-ai-tells.check.ts +0 -131
- package/src/checks/SK014-prose-ste.check.ts +0 -78
- package/src/cli/cmd_Prose.ts +0 -74
- package/src/prose/prose-view.ts +0 -90
- package/src/prose/run-prose.ts +0 -187
- package/src/prose/ste.ts +0 -552
- package/src/scaffold/files/vscode-tasks.json +0 -24
- package/src/serve/hub-app/prose-hits-st.ts +0 -125
- package/src/serve/hub-app/prose-hits-view.tsx +0 -176
- package/src/serve/hub-app/prose-hits.css +0 -21
package/src/suite/suite-cli.ts
CHANGED
|
@@ -20,7 +20,7 @@ import { SUITE_READS_ENV, SUITE_ROOT_ENV, SUITE_SELECT_ENV } from './reads-env.t
|
|
|
20
20
|
import { changedKeys, dropStaticEdges, extras, isBunSuite, envFilesOf, foldReads, loadErrorModules, loadState, preloadsOf, saveState, selectStatePath, selectSuites, snapshot, suiteFiles, taintedSuites, walkGraph, type GraphEntry, type Selection, type SelectState } from './select.ts'
|
|
21
21
|
import { gitRoot } from '../core/git-root.ts'
|
|
22
22
|
import { loadConfig } from '../ts/config.ts'
|
|
23
|
-
import { aloneRunsNeeded, bunfigTest, type BunfigTest, readPhaseCpu, SUITE_CPU_ENV, type AloneRun, type Confirmed, confirmOver, crashedWorkers, overWords, DEFAULT_BUDGET_MS, FULL_CEILING_MS, failedSuites, failLines, judgeSuites, normSuitePath, parseSuiteFlags, planPhases, QUICK_CEILING_MS, poolWorkers } from './judge-suites.ts'
|
|
23
|
+
import { aloneRunsNeeded, bunfigTest, type BunfigTest, readPhaseCpu, SUITE_CPU_ENV, type AloneRun, type Confirmed, confirmOver, crashedWorkers, interruptedSuites, overWords, ranAs, retryAlone, smallerRun, stallWords, DEFAULT_BUDGET_MS, FULL_CEILING_MS, failedSuites, failLines, judgeSuites, normSuitePath, parseSuiteFlags, planPhases, QUICK_CEILING_MS, poolWorkers } from './judge-suites.ts'
|
|
24
24
|
|
|
25
25
|
type Phase = { code: number; signal: string | null; stalled: boolean; out: string; timings: Record<string, number>; cpuMs: number | null }
|
|
26
26
|
|
|
@@ -57,7 +57,10 @@ async function runPhase(args: string[], timingsFile: string | null, ceilingMs: n
|
|
|
57
57
|
if (echo) sink.write(text)
|
|
58
58
|
}
|
|
59
59
|
}
|
|
60
|
-
|
|
60
|
+
// bun reports on stderr, and it is echoed on stdout: two pipes reach the
|
|
61
|
+
// gate in either order, so bun's `Ran 0 tests` could land after the FAIL
|
|
62
|
+
// printed below and become the headline. One stream keeps the order
|
|
63
|
+
await Promise.all([pump(proc.stdout, process.stdout), pump(proc.stderr, process.stdout)])
|
|
61
64
|
const code = await proc.exited
|
|
62
65
|
clearTimeout(ceiling)
|
|
63
66
|
clearTimeout(killer)
|
|
@@ -194,7 +197,9 @@ async function runSuites(argv: string[], ctx: SelectCtx): Promise<number> {
|
|
|
194
197
|
const ran = phases.reduce((n, p) => n + lastCount(p.out, /Ran (\d+) tests?/g), 0)
|
|
195
198
|
const failedTests = phases.reduce((n, p) => n + lastCount(p.out, /^\s*(\d+) fail$/gm), 0)
|
|
196
199
|
const timings = Object.assign({}, ...phases.map((p) => p.timings)) as Record<string, number>
|
|
197
|
-
const
|
|
200
|
+
const crashed = crashedWorkers(p1.out)
|
|
201
|
+
const red = new Set([...phases.flatMap((p) => failedSuites(p.out)), ...crashed.map((c) => c.file)])
|
|
202
|
+
const judged = judgeSuites({ timings, tests, tier, red: [...red] })
|
|
198
203
|
const slowest = judged.slowest.length > 0 ? ` (slowest: ${judged.slowest.map((t) => `${t.file} ${secs(t.ms)}`).join(', ')})` : ''
|
|
199
204
|
|
|
200
205
|
// a parallel timing carries every other suite's load, so a suspect
|
|
@@ -205,25 +210,32 @@ async function runSuites(argv: string[], ctx: SelectCtx): Promise<number> {
|
|
|
205
210
|
const parallelCpu = phaseCpu.cpu
|
|
206
211
|
const { rerun, cleared: clearedInPhase } = aloneRunsNeeded(judged.over, parallelCpu, budget, load)
|
|
207
212
|
const aloneAt = Date.now()
|
|
208
|
-
|
|
209
|
-
const run = await runPhase([...plan.alone, t.file], join(dir,
|
|
213
|
+
const runAlone = async (t: { file: string }, name: string): Promise<AloneRun | null> => {
|
|
214
|
+
const run = await runPhase([...plan.alone, t.file], join(dir, `${name}.json`), ceilingMs, tmp, false, null, rec)
|
|
210
215
|
const wallMs = Object.entries(run.timings).find(([f]) => normSuitePath(f) === t.file)?.[1]
|
|
211
|
-
|
|
216
|
+
return wallMs == null ? null : { wallMs, cpuMs: run.cpuMs }
|
|
217
|
+
}
|
|
218
|
+
for (const [i, t] of rerun.entries()) {
|
|
219
|
+
const first = await runAlone(t, `alone-${i}`)
|
|
220
|
+
if (first == null) continue
|
|
221
|
+
aloneRuns[t.file] = first
|
|
222
|
+
if (!retryAlone(t, first, budget, load)) continue
|
|
223
|
+
const second = await runAlone(t, `alone-${i}-again`)
|
|
224
|
+
if (second == null) continue
|
|
225
|
+
aloneRuns[t.file] = smallerRun(first, second)
|
|
226
|
+
notes.push(`note: ${t.file} ran alone twice, ${secs(first.cpuMs ?? first.wallMs)} then ${secs(second.cpuMs ?? second.wallMs)} of CPU, and the smaller stands: a lone run the scheduler spills onto slower cores burns more CPU for the same work`)
|
|
212
227
|
}
|
|
213
228
|
const aloneMs = Date.now() - aloneAt
|
|
214
229
|
const { over, cleared } = rerun.length > 0 ? confirmOver(rerun, aloneRuns, budget, load) : { over: [], cleared: [] }
|
|
215
|
-
for (const c of clearedInPhase) notes.push(`note: ${c.file} took ${secs(c.wallMs)} in the parallel run
|
|
230
|
+
for (const c of clearedInPhase) notes.push(`note: ${c.file} took ${secs(c.wallMs)} in the parallel run but burned ${secs(c.ms)} of its own CPU there, inside the ${budget}ms budget (load ${load.load1.toFixed(1)} on ${load.cores} cores), so it did not run again alone`)
|
|
216
231
|
for (const c of cleared) notes.push(`note: ${c.file} took ${secs(c.wallMs)} alone but burned ${c.cpuMs != null ? secs(c.cpuMs) : '?'} of CPU, inside the ${budget}ms budget (load ${load.load1.toFixed(1)} on ${load.cores} cores)`)
|
|
217
232
|
|
|
218
233
|
// a ceiling kill makes bun report every worker it held as crashed, and
|
|
219
234
|
// those are the suites that never finished, not an outside kill
|
|
220
235
|
const stalledIn = (name: string, p: Phase | null, files: string[]): string | null =>
|
|
221
|
-
p?.stalled === true
|
|
222
|
-
? `${name} ran past its ${secs(ceilingMs)} ceiling and was killed${files.length > 0 ? ` with ${files.join(', ')} still running` : ''}: a test worker stopped answering, most likely stuck in a synchronous call, which no per test timeout can interrupt`
|
|
223
|
-
: null
|
|
224
|
-
const crashed = crashedWorkers(p1.out)
|
|
236
|
+
p?.stalled === true ? stallWords(name, ceilingMs, [...new Set([...files, ...interruptedSuites(p.out)])]) : null
|
|
225
237
|
const killed = p1.stalled ? [] : crashed.map((c) => `killed by ${c.signal} while running ${c.file} (out of memory, or killed from outside)`)
|
|
226
|
-
const stalls = [stalledIn('the parallel phase', p1, crashed.map((c) => c.file)), stalledIn('the serial phase', p2, phaseFiles(serialArgs ?? [], rawArgs).filter((a) => p2?.timings[normSuitePath(a)] == null))]
|
|
238
|
+
const stalls = [stalledIn('the parallel phase', p1, crashed.map((c) => c.file)), stalledIn('the serial phase', p2, phaseFiles(serialArgs ?? [], rawArgs).filter((a) => p2?.timings[normSuitePath(a)] == null).map(normSuitePath))]
|
|
227
239
|
for (const line of stalls) if (line != null) killed.unshift(line)
|
|
228
240
|
if (p1.signal != null) killed.push(`killed by ${p1.signal} during the parallel run (out of memory, or killed from outside)`)
|
|
229
241
|
if (p2?.signal != null) {
|
|
@@ -233,7 +245,6 @@ async function runSuites(argv: string[], ctx: SelectCtx): Promise<number> {
|
|
|
233
245
|
// the record behind the hub's test files to fix: every suite this run
|
|
234
246
|
// timed, with the alone timing where one was taken, and red when a
|
|
235
247
|
// test in it failed or its worker died
|
|
236
|
-
const red = new Set([...phases.flatMap((p) => failedSuites(p.out)), ...crashedWorkers(p1.out).map((c) => c.file)])
|
|
237
248
|
const overSet = new Set(over.map((o) => o.file))
|
|
238
249
|
ctx.complete = killed.length === 0
|
|
239
250
|
ctx.out = phases.map((p) => p.out).join('\n')
|
|
@@ -241,12 +252,12 @@ async function runSuites(argv: string[], ctx: SelectCtx): Promise<number> {
|
|
|
241
252
|
const at = Date.now()
|
|
242
253
|
const alone = new Map([...over, ...cleared, ...clearedInPhase].map((t) => [t.file, t.ms]))
|
|
243
254
|
const why = failLines(phases.map((p) => p.out).join('\n'))
|
|
244
|
-
for (const c of
|
|
255
|
+
for (const c of crashed) if (!why.has(c.file)) why.set(c.file, `its worker crashed: ${c.signal}`)
|
|
245
256
|
// which phase timed each file, and the CPU spent on it: an alone
|
|
246
257
|
// rerun, the parallel phase's own figure, or a phase that ran that one file
|
|
247
258
|
const phaseOf = (f: string): SuitePhase => (p2 != null && Object.keys(p2.timings).some((k) => normSuitePath(k) === f) ? 'serial' : parallel ? 'parallel' : 'sequential')
|
|
248
259
|
const soloCpu = (p: Phase | null): number | null => (p != null && Object.keys(p.timings).length === 1 ? p.cpuMs : null)
|
|
249
|
-
const cpuOf = (f: string): number | null => aloneRuns[f]?.cpuMs ?? (phaseOf(f) === 'serial' ? soloCpu(p2) : parallelCpu[f]
|
|
260
|
+
const cpuOf = (f: string): number | null => aloneRuns[f]?.cpuMs ?? (phaseOf(f) === 'serial' ? soloCpu(p2) : parallelCpu[f] ?? soloCpu(p1))
|
|
250
261
|
const check = process.env[CHECK_ID_ENV] ?? ''
|
|
251
262
|
const serialFiles = p2 == null ? 0 : Object.keys(p2.timings).length
|
|
252
263
|
const unrecorded = recordSuites(process.cwd(), Object.entries(timings).map(([file, ms]) => {
|
|
@@ -289,7 +300,7 @@ async function runSuites(argv: string[], ctx: SelectCtx): Promise<number> {
|
|
|
289
300
|
if (fails.length > 0) { console.log(fails[0]); return 1 }
|
|
290
301
|
const serialCount = serialArgs != null ? phaseFiles(serialArgs, rawArgs).length : 0
|
|
291
302
|
const chosen = ctx.sel?.picked != null ? `, the ${ctx.sel.picked.length} of ${ctx.sel.total} a change reaches` : ''
|
|
292
|
-
console.log(`ok: Ran ${ran} tests across ${Object.keys(timings).length || '?'} suites${chosen}, ${parallel ? `parallel on ${workers} workers` : 'one at a time'}${serialCount > 0 ? ` then ${serialCount} serial` : ''}, ${tier} tier${slowest}`)
|
|
303
|
+
console.log(`ok: Ran ${ran} tests across ${Object.keys(timings).length || '?'} suites${chosen}, ${parallel ? `parallel on ${workers} workers` : 'one at a time'}${serialCount > 0 ? ` then ${serialCount} serial` : ''}, ${tier} tier, ${ranAs(plan.parallel, rawArgs)}${slowest}`)
|
|
293
304
|
return 0
|
|
294
305
|
} finally {
|
|
295
306
|
rmSync(dir, { recursive: true, force: true })
|
|
@@ -2,17 +2,13 @@
|
|
|
2
2
|
// worker by the suite runner. A suite over the budget in wall time is run again
|
|
3
3
|
// alone to learn its CPU, one after the other, and on a loaded machine nearly
|
|
4
4
|
// every gate heavy suite goes over in wall time: those re-runs were most of the
|
|
5
|
-
// test check's wall time. A suite whose CPU in the parallel phase
|
|
6
|
-
//
|
|
5
|
+
// test check's wall time. A suite whose own CPU in the parallel phase is inside
|
|
6
|
+
// the budget needs no re-run.
|
|
7
7
|
//
|
|
8
|
-
// One line per suite: `<file>\t<cpu ms
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
// load, top level and tests of every earlier suite. A lone run can only need
|
|
13
|
-
// what this worker computed by the end of the suite (a module an earlier suite
|
|
14
|
-
// loaded, a memo an earlier test filled), so cpu plus prior bounds it from
|
|
15
|
-
// above, and sharing the machine only raises CPU.
|
|
8
|
+
// One line per suite: `<file>\t<cpu ms>`, the worker's own CPU plus that of
|
|
9
|
+
// every child it reaped, from the end of the previous suite in this worker to
|
|
10
|
+
// the end of this one, so the file's import, its hooks and its tests count. What
|
|
11
|
+
// the worker burned before that (its start, earlier suites) is not this suite's.
|
|
16
12
|
//
|
|
17
13
|
// A child still alive, or dead and not yet reaped, when the suite ends has CPU
|
|
18
14
|
// nobody counted yet, so that suite's line says `-` and it runs alone if it is
|
|
@@ -84,7 +80,7 @@ if (out != null && p != null) {
|
|
|
84
80
|
// every suite gets a line, since the line is also how the runner knows
|
|
85
81
|
// it ran; a suite whose figure cannot be trusted says `-`
|
|
86
82
|
try {
|
|
87
|
-
const figure = p.liveChildren() === 0 ? `${Math.round(now - ended)}
|
|
83
|
+
const figure = p.liveChildren() === 0 ? `${Math.round(now - ended)}` : '-'
|
|
88
84
|
appendFileSync(out, `${Bun.main}\t${figure}\n`)
|
|
89
85
|
} catch { /* no line: the suite runs alone if it is a suspect */ }
|
|
90
86
|
ended = now
|
package/src/suite/test-units.ts
CHANGED
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
import { basename, posix } from 'path'
|
|
12
12
|
import { rawName, routesToShipkit, slotBody } from '../core/canonical-scripts.ts'
|
|
13
13
|
import { parseJsonc } from '../ts/jsonc.ts'
|
|
14
|
-
import {
|
|
14
|
+
import { repoOff, type OffKeys } from '../core/when.ts'
|
|
15
|
+
import { bunfigTest, inDotDir, normSuitePath, parseBunTestScript, type BunfigTest, type TestsConfig } from './judge-suites.ts'
|
|
15
16
|
|
|
16
17
|
export { bunfigTest, type BunfigTest }
|
|
17
18
|
|
|
@@ -41,6 +42,7 @@ export type TestRun = {
|
|
|
41
42
|
export function runReaches(file: string, run: TestRun): { runs: true } | { runs: false; ignoredBy: string } | { runs: false; outside: string } | null {
|
|
42
43
|
if (run.args == null) return null
|
|
43
44
|
const f = normSuitePath(file)
|
|
45
|
+
if (inDotDir(f)) return { runs: false, ignoredBy: 'a dot dir, which bun never discovers' }
|
|
44
46
|
const cli: string[] = []
|
|
45
47
|
const filters: string[] = []
|
|
46
48
|
for (let i = 0; i < run.args.length; i++) {
|
|
@@ -183,19 +185,20 @@ export function planTests(o: {
|
|
|
183
185
|
/** the whole plan for a repo, from what its config and files say. `read`
|
|
184
186
|
* answers a repo-relative path, null when absent */
|
|
185
187
|
export function testPlanOf(o: {
|
|
186
|
-
config:
|
|
188
|
+
config: OffKeys & { units?: { dir?: unknown; id?: string; tests?: TestsConfig; tsconfig?: string; release?: boolean; deps?: string[] }[] }
|
|
187
189
|
files: string[]
|
|
188
190
|
read: (rel: string) => string | null
|
|
189
191
|
}): TestPlan & { units: TestUnitDecl[] } {
|
|
190
192
|
const units = declaredTestUnits(o.config.units)
|
|
191
193
|
let scripts: Record<string, string> = {}
|
|
192
194
|
try { scripts = (JSON.parse(o.read('package.json') ?? '{}') as { scripts?: Record<string, string> }).scripts ?? {} } catch { scripts = {} }
|
|
193
|
-
const
|
|
195
|
+
const off = repoOff(o.config, 'test')
|
|
196
|
+
const slot = slotBody('test', off != null ? false : o.config.defaults?.['test'] as { cmd?: unknown; logic?: unknown } | undefined, scripts)
|
|
194
197
|
const bunfig = (dir: string): BunfigTest => bunfigTest(o.read(dir === '' ? 'bunfig.toml' : posix.join(dir, 'bunfig.toml')))
|
|
195
198
|
const rootRun: TestRun | null = slot.kind === 'override' ? { args: null, bunfig: bunfig(''), extraIgnores: [] }
|
|
196
199
|
: slot.kind === 'script' ? { args: parseBunTestScript(slot.body), bunfig: bunfig(''), extraIgnores: unitIgnores(units) }
|
|
197
200
|
: null
|
|
198
|
-
const rootWhy = slot.kind === 'off' ?
|
|
201
|
+
const rootWhy = slot.kind === 'off' ? `the repo turns the root test check off (${off?.reason ?? 'off'}), so nothing runs it`
|
|
199
202
|
: slot.kind === 'routed-empty' ? "the `test` script routes to shipkit and there is no 'test:raw' body, so the root test check runs nothing"
|
|
200
203
|
: 'no `test` or `test:raw` script, so the root has no test check'
|
|
201
204
|
const plan = planTests({
|
package/src/ts/config.ts
CHANGED
|
@@ -10,9 +10,13 @@
|
|
|
10
10
|
// 2. tsconfig "paths" keys ("src/*" → root 'src') when roots are not set
|
|
11
11
|
// 3. fallback: ['src', 'scripts']
|
|
12
12
|
// skip entries are repo-relative dir prefixes; the defaults below always apply.
|
|
13
|
+
// The repo config's `vendored` dirs (core/vendored.ts) are third party code:
|
|
14
|
+
// never crawled, never a package, never rewritten.
|
|
13
15
|
import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
|
|
14
16
|
import { join } from 'path'
|
|
15
17
|
import { gitRoot } from '../core/git-root.ts'
|
|
18
|
+
import { isVendored } from '../core/vendored.ts'
|
|
19
|
+
import { readVendored } from '../core/vendored-read.ts'
|
|
16
20
|
|
|
17
21
|
export type RvtPackage = {
|
|
18
22
|
name: string // package.json "name" — the import root for files inside
|
|
@@ -24,6 +28,7 @@ export type RvtConfig = {
|
|
|
24
28
|
roots: string[] // repo-relative dirs to crawl / allowed specifier roots
|
|
25
29
|
skip: string[] // repo-relative dir prefixes to never touch
|
|
26
30
|
packages: RvtPackage[] // discovered sub-packages, longest dir first
|
|
31
|
+
vendored?: string[] // repo-relative third party dirs (repo.config.ts `vendored`), skipped as exact prefixes
|
|
27
32
|
}
|
|
28
33
|
|
|
29
34
|
/** never source, at any depth */
|
|
@@ -83,9 +88,9 @@ function expandRootGlobs(root: string, roots: string[]): string[] {
|
|
|
83
88
|
|
|
84
89
|
// sub-package discovery: any dir under the roots with a named package.json is
|
|
85
90
|
// its own import root (files inside address each other via the package name)
|
|
86
|
-
function discoverPackages(root: string, roots: string[], skip: string[]): RvtPackage[] {
|
|
91
|
+
function discoverPackages(root: string, roots: string[], skip: string[], vendored: string[]): RvtPackage[] {
|
|
87
92
|
const found: RvtPackage[] = []
|
|
88
|
-
const cfg = { root, roots, skip, packages: [] }
|
|
93
|
+
const cfg = { root, roots, skip, packages: [], vendored }
|
|
89
94
|
const walk = (rel: string): void => {
|
|
90
95
|
const abs = join(root, rel)
|
|
91
96
|
let entries
|
|
@@ -128,9 +133,10 @@ export function loadConfig(cwd: string = process.cwd()): RvtConfig {
|
|
|
128
133
|
}
|
|
129
134
|
if (roots.length === 0) roots = rootsFromTsconfig(root)
|
|
130
135
|
if (roots.length === 0) roots = ['src', 'scripts'].filter((r) => existsSync(join(root, r)))
|
|
131
|
-
|
|
136
|
+
const vendored = readVendored(root)
|
|
137
|
+
roots = expandRootGlobs(root, roots).filter((r) => !isVendored(r, vendored))
|
|
132
138
|
const mergedSkip = [...DEFAULT_SKIP, ...skip]
|
|
133
|
-
const packages = discoverPackages(root, roots, mergedSkip)
|
|
139
|
+
const packages = discoverPackages(root, roots, mergedSkip, vendored)
|
|
134
140
|
// a package named like a root (or another package) hijacks that specifier
|
|
135
141
|
// prefix — resolution would silently diverge from tsc/bun; be loud
|
|
136
142
|
const seen = new Set<string>(roots)
|
|
@@ -138,7 +144,7 @@ export function loadConfig(cwd: string = process.cwd()): RvtConfig {
|
|
|
138
144
|
if (seen.has(p.name)) console.error(`rvt WARN: package name '${p.name}' (${p.dir}) collides with a root or another package — its specifiers may resolve wrong`)
|
|
139
145
|
seen.add(p.name)
|
|
140
146
|
}
|
|
141
|
-
return { root, roots, skip: mergedSkip, packages }
|
|
147
|
+
return { root, roots, skip: mergedSkip, packages, vendored }
|
|
142
148
|
}
|
|
143
149
|
|
|
144
150
|
// innermost package containing a repo-relative path, or null
|
|
@@ -169,6 +175,7 @@ export const toPosix = (p: string): string => p.replace(/\\/g, '/')
|
|
|
169
175
|
// package root is output, `wiki/build/<lib>/src` is source.
|
|
170
176
|
export function isSkipped(relPath: string, cfg: RvtConfig, packageDirs: readonly string[] = []): boolean {
|
|
171
177
|
const n = toPosix(relPath)
|
|
178
|
+
if (isVendored(n, cfg.vendored ?? [])) return true
|
|
172
179
|
const segs = n.split('/')
|
|
173
180
|
return cfg.skip.some((s) => {
|
|
174
181
|
if (s.includes('/')) return n === s || n.startsWith(`${s}/`)
|
package/src/ts/imports.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { readdirSync, readFileSync, writeFileSync, existsSync, type Dirent } fro
|
|
|
14
14
|
import { dirname, extname, isAbsolute, join, relative, resolve } from 'path'
|
|
15
15
|
import { canonicalSpecifier, isSkipped, loadConfig, parseJsonc, resolvePackageSpecifier, toPosix, type RvtConfig } from 'rvlib-shipkit/src/ts/config.ts'
|
|
16
16
|
import { templateLineMask } from 'rvlib-shipkit/src/ts/source-mask.ts'
|
|
17
|
+
import { isVendored } from 'rvlib-shipkit/src/core/vendored.ts'
|
|
17
18
|
|
|
18
19
|
const ASSET_EXTENSIONS = new Set([
|
|
19
20
|
'.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.ico',
|
|
@@ -341,7 +342,8 @@ export function runImports(opts: { save?: boolean; files?: string[]; cfg?: RvtCo
|
|
|
341
342
|
for (const e of result.errors) run.errors.push({ file: toPosix(relative(cfg.root, abs)), ...e })
|
|
342
343
|
}
|
|
343
344
|
if (opts.files != null && opts.files.length > 0) {
|
|
344
|
-
|
|
345
|
+
// a vendored file named by the on-save hook is still third party code
|
|
346
|
+
for (const fp of opts.files) if (!isVendored(toPosix(relative(cfg.root, resolve(cfg.root, fp))), cfg.vendored ?? [])) process1(fp)
|
|
345
347
|
} else {
|
|
346
348
|
for (const r of cfg.roots) {
|
|
347
349
|
const abs = join(cfg.root, r)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// the gate's typecheck keeps its own incremental cache, one file per project.
|
|
2
|
+
// a tsconfig's tsBuildInfoFile is shared by every tsc that runs that config (a
|
|
3
|
+
// watcher task, a raw script), and a verdict read off a cache another process
|
|
4
|
+
// writes is not a verdict on this tree. no fs here: it only names paths.
|
|
5
|
+
import { basename, dirname, join } from 'path'
|
|
6
|
+
|
|
7
|
+
export const gateBuildInfoArgs = (tsconfig: string): string[] => [
|
|
8
|
+
'--incremental',
|
|
9
|
+
'--tsBuildInfoFile',
|
|
10
|
+
join(dirname(tsconfig), 'node_modules/.cache/shipkit', `${basename(tsconfig, '.json')}.tsbuildinfo`),
|
|
11
|
+
]
|
package/src/ts/typecheck-cli.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
// who fixes the one error tsc showed and re-runs into a second one has been
|
|
11
11
|
// told the truth twice and believed it once.
|
|
12
12
|
import { resolveTypecheckProjects } from 'rvlib-shipkit/src/ts/tsconfig-projects.ts'
|
|
13
|
+
import { gateBuildInfoArgs } from 'rvlib-shipkit/src/ts/typecheck-cache.ts'
|
|
13
14
|
|
|
14
15
|
export function typecheckCli(argv: string[]): number {
|
|
15
16
|
const [tsc, tsconfig] = argv
|
|
@@ -30,7 +31,7 @@ export function typecheckCli(argv: string[]): number {
|
|
|
30
31
|
|
|
31
32
|
let worst = 0
|
|
32
33
|
for (const p of resolved.projects) {
|
|
33
|
-
const r = Bun.spawnSync([tsc, '--noEmit', '-p', p], { stdout: 'inherit', stderr: 'inherit' })
|
|
34
|
+
const r = Bun.spawnSync([tsc, '--noEmit', '-p', p, ...gateBuildInfoArgs(p)], { stdout: 'inherit', stderr: 'inherit' })
|
|
34
35
|
if (r.exitCode !== 0) {
|
|
35
36
|
console.log(`FAIL: ${p}`)
|
|
36
37
|
worst = r.exitCode ?? 1
|
|
@@ -36,7 +36,7 @@ export function buildStepsPanel(root: string, config: RepoConfig): TuiPanelDef {
|
|
|
36
36
|
const { tasks, disabled, unfilled } = await allTasks(config, root)
|
|
37
37
|
rows = [
|
|
38
38
|
...tasks.map((t): TuiRow => ({ key: t.id, label: t.id, status: 'pending', detail: [] })),
|
|
39
|
-
...disabled.map((id): TuiRow => ({ key: id, label: id, status: 'skip', detail: ['skip:
|
|
39
|
+
...disabled.map((id): TuiRow => ({ key: id, label: id, status: 'skip', detail: ['skip: turned off in repo.config.ts'] })),
|
|
40
40
|
...unfilled.map((u): TuiRow => ({ key: u.id, label: u.id, status: 'skip', detail: [`skip: ${u.reason}`] })),
|
|
41
41
|
]
|
|
42
42
|
const byKey = new Map(rows.map((r, i) => [r.key, i]))
|
package/src/wiki/scan-md.ts
CHANGED
|
@@ -11,9 +11,11 @@
|
|
|
11
11
|
// Depth = 0-1 BFS from the index (eager edge keeps depth, lazy adds 1);
|
|
12
12
|
// unreachable files stay in the graph with no depth (lint-md.ts turns those,
|
|
13
13
|
// plus dangling refs, into failures — the graph itself is a viewer).
|
|
14
|
+
// The enclosing repo's vendored dirs (core/vendored.ts) are never scanned.
|
|
14
15
|
import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
|
|
15
16
|
import { basename, dirname, join, relative, resolve } from 'path'
|
|
16
17
|
import type { MdEdge, MdGraph, MdNode } from 'rvlib-shipkit/src/wiki/md-graph.ts'
|
|
18
|
+
import { vendoredAround } from 'rvlib-shipkit/src/core/vendored-read.ts'
|
|
17
19
|
|
|
18
20
|
export type ScanMdConfig = {
|
|
19
21
|
root: string // absolute dir to scan
|
|
@@ -22,7 +24,7 @@ export type ScanMdConfig = {
|
|
|
22
24
|
boundary?: string // absolute dir enclosing root (a repo): refs leaving root but staying here resolve on disk
|
|
23
25
|
}
|
|
24
26
|
|
|
25
|
-
export type MdDanglingRef = { from: string; token: string } // an @/→/[](…) ref whose target is not in the scan
|
|
27
|
+
export type MdDanglingRef = { from: string; token: string; line: number } // an @/→/[](…) ref whose target is not in the scan, and its 1-based line in `from`
|
|
26
28
|
export type MdScan = {
|
|
27
29
|
graph: MdGraph
|
|
28
30
|
index: string | null // resolved entrypoint(s), ' + '-joined when both defaults exist; null = none found
|
|
@@ -35,12 +37,14 @@ export function scanMdGraph(cfg: ScanMdConfig): MdGraph {
|
|
|
35
37
|
|
|
36
38
|
export function scanMd(cfg: ScanMdConfig): MdScan {
|
|
37
39
|
const skip = new Set(['node_modules', '.git', 'dist', 'tmp', ...(cfg.skipDirs ?? [])])
|
|
40
|
+
const repo = vendoredAround(cfg.root)
|
|
41
|
+
const vendored = new Set((repo?.dirs ?? []).map((d) => join(repo!.root, d)))
|
|
38
42
|
const files: string[] = []
|
|
39
43
|
const walk = (rel: string) => {
|
|
40
44
|
for (const e of readdirSync(join(cfg.root, rel))) {
|
|
41
45
|
const r = rel === '' ? e : `${rel}/${e}`
|
|
42
46
|
if (statSync(join(cfg.root, r)).isDirectory()) {
|
|
43
|
-
if (!skip.has(e) && !e.startsWith('.')) walk(r)
|
|
47
|
+
if (!skip.has(e) && !e.startsWith('.') && !vendored.has(join(cfg.root, r))) walk(r)
|
|
44
48
|
} else if (e.endsWith('.md')) files.push(r)
|
|
45
49
|
}
|
|
46
50
|
}
|
|
@@ -84,19 +88,22 @@ export function scanMd(cfg: ScanMdConfig): MdScan {
|
|
|
84
88
|
const raw = readFileSync(join(cfg.root, rel), 'utf8')
|
|
85
89
|
const body = raw.startsWith('---\n') ? raw.slice(4).replace(/^[\s\S]*?^---\s*$/m, '') : raw
|
|
86
90
|
const spans: string[] = []
|
|
87
|
-
|
|
91
|
+
// fences blanked to their newlines, so an offset in prose still names its line
|
|
92
|
+
const prose = body.replace(/^```[\s\S]*?^```\s*$/gm, (f) => f.replace(/[^\n]/g, '')).replace(/`([^`\n]+)`/g, (_, s: string) => { spans.push(s); return ' ' })
|
|
93
|
+
const above = raw.slice(0, raw.length - body.length).split('\n').length - 1
|
|
94
|
+
const lineOf = (m: RegExpMatchArray): number => above + prose.slice(0, m.index! + m[0].length - 1).split('\n').length
|
|
88
95
|
const out: MdEdge[] = []
|
|
89
96
|
// strict: @/→/[](…) are deliberate wiki refs — a miss is a dangling ref.
|
|
90
97
|
// Backtick spans mention paths casually (other repos, deleted examples) — resolve or ignore, never fail.
|
|
91
|
-
const push = (token: string, eager: boolean, strict: boolean) => {
|
|
98
|
+
const push = (token: string, eager: boolean, strict: boolean, line = 0) => {
|
|
92
99
|
const hit = resolveRef(rel, token)
|
|
93
|
-
if (hit == null) { if (strict) dangling.push({ from: rel, token }); return }
|
|
100
|
+
if (hit == null) { if (strict) dangling.push({ from: rel, token, line }); return }
|
|
94
101
|
for (const to of hit.to) if (to !== rel) out.push({ from: rel, to, eager })
|
|
95
102
|
}
|
|
96
|
-
for (const m of prose.matchAll(/(?:^|[\s(])@((?:\.{1,2}\/)*[\w][\w./-]*\.md)/gm)) push(m[1]!, true, true)
|
|
97
|
-
for (const m of prose.matchAll(/→\s*([\w][\w./-]*\.md)\b/g)) push(m[1]!, false, true)
|
|
98
|
-
for (const m of prose.matchAll(/\]\(([^)\s:]+\.md)\)/g)) push(m[1]!, false, true)
|
|
99
|
-
for (const m of prose.matchAll(/\]\(([^)\s:]*\/)\)/g)) push(m[1]!, false, true)
|
|
103
|
+
for (const m of prose.matchAll(/(?:^|[\s(])@((?:\.{1,2}\/)*[\w][\w./-]*\.md)/gm)) push(m[1]!, true, true, lineOf(m))
|
|
104
|
+
for (const m of prose.matchAll(/→\s*([\w][\w./-]*\.md)\b/g)) push(m[1]!, false, true, lineOf(m))
|
|
105
|
+
for (const m of prose.matchAll(/\]\(([^)\s:]+\.md)\)/g)) push(m[1]!, false, true, lineOf(m))
|
|
106
|
+
for (const m of prose.matchAll(/\]\(([^)\s:]*\/)\)/g)) push(m[1]!, false, true, lineOf(m))
|
|
100
107
|
for (const s of spans) for (const m of s.matchAll(/[\w][\w./-]*\.md/g)) push(m[0], false, false)
|
|
101
108
|
for (const s of spans) for (const m of s.matchAll(/(?:^|\s)([\w.][\w./-]*\/)(?=$|[\s<*])/g)) push(m[1]!, false, false)
|
|
102
109
|
return out
|
|
@@ -1,131 +0,0 @@
|
|
|
1
|
-
// SK009 commit AI tells (identity/how-we-commit.md): the commit-msg hook is
|
|
2
|
-
// what refuses AI attribution, in every repo, so this check verifies the hook
|
|
3
|
-
// is ARMED: shipkit's own hooksPath, or a foreign one whose commit-msg chains
|
|
4
|
-
// the shipkit gate. It never scans history: what is pushed is accepted as it
|
|
5
|
-
// is, and only a new commit can still be stopped. A declared-public repo also
|
|
6
|
-
// needs the tracked attribution silencer, and its ref NAMES are checked (a
|
|
7
|
-
// FABLE-checkpoint tag outs the agent like a trailer); a branch nobody pushed
|
|
8
|
-
// is named "local only" and warns.
|
|
9
|
-
import { createHash } from 'crypto'
|
|
10
|
-
import { readFileSync } from 'fs'
|
|
11
|
-
import { isAbsolute, join } from 'path'
|
|
12
|
-
import { nestedIn } from '../core/nesting.ts'
|
|
13
|
-
import { gitBin } from '../core/git-bin.ts'
|
|
14
|
-
import { fileStamp } from '../core/file-stamp.ts'
|
|
15
|
-
import { CONFIG_FILE } from '../core/check-pass.ts'
|
|
16
|
-
import { LAYOUT } from '../core/layout.ts'
|
|
17
|
-
import { check, type CheckEnv } from '../check.ts'
|
|
18
|
-
import { declaredVisibility } from '../core/repo-facts.ts'
|
|
19
|
-
import { hookBinInvocation } from '../hooks/run-hooks.ts'
|
|
20
|
-
|
|
21
|
-
// the documented silencer: .claude/settings.json attribution.commit/.pr both
|
|
22
|
-
// empty — the harness then never writes the trailer or the PR footer
|
|
23
|
-
const attributionSilenced = (root: string): boolean => {
|
|
24
|
-
try {
|
|
25
|
-
const s = JSON.parse(readFileSync(join(root, '.claude', 'settings.json'), 'utf8')) as { attribution?: { commit?: string; pr?: string } }
|
|
26
|
-
return s.attribution != null && s.attribution.commit === '' && s.attribution.pr === ''
|
|
27
|
-
} catch {
|
|
28
|
-
return false
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
const readOrNull = (abs: string): string | null => {
|
|
33
|
-
try {
|
|
34
|
-
return readFileSync(abs, 'utf8')
|
|
35
|
-
} catch {
|
|
36
|
-
return null
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/** a commit-msg body that runs the shipkit gate on the message: a live line
|
|
41
|
-
* (never a comment) whose failure is not swallowed by `|| true` */
|
|
42
|
-
const chainsShipkit = (body: string): boolean =>
|
|
43
|
-
body.split('\n').some((l) => !l.trim().startsWith('#') && /\bhooks\s+commit-msg\b/.test(l) && !/\|\|\s*(?:true|:)\b/.test(l))
|
|
44
|
-
|
|
45
|
-
/** the chain line to paste into a foreign hook: a clone with no install skips
|
|
46
|
-
* the gate with a note, rather than failing every commit */
|
|
47
|
-
export const guardedChain = (bin: string): string => {
|
|
48
|
-
const cli = bin.replace(/^bun\s+/, '')
|
|
49
|
-
return `if [ -f ${cli} ]; then ${bin} hooks commit-msg "$1" || exit 1; else echo "commit-msg: rvlib-shipkit not installed (run bun install), attribution gate skipped" >&2; fi`
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
// ref NAMES tell too. bare match only for names with no other meaning;
|
|
53
|
-
// vendor and codename words (cursor, openai, gemini, opus, fable, …) name
|
|
54
|
-
// legitimate branches and features, they only match in a checkpoint/session shape
|
|
55
|
-
const REF_TELL = /\b(?:claude|chatgpt|copilot|aider|devin)\b|(?:^|[-_/])(?:fable|sonnet|haiku|opus|gemini|codex)[-_](?:checkpoint|session|ckpt)/i
|
|
56
|
-
|
|
57
|
-
const gitOut = (root: string, args: string[]): { code: number; out: string } => {
|
|
58
|
-
const p = Bun.spawnSync([gitBin(), ...args], { cwd: root, stdout: 'pipe', stderr: 'pipe' })
|
|
59
|
-
return { code: p.exitCode, out: p.stdout.toString() }
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/** what the answer depends on: the declared visibility, the hooksPath and the
|
|
63
|
-
* commit-msg file it points at, and for a public repo every ref NAME and the
|
|
64
|
-
* silencer. A commit moves none of them */
|
|
65
|
-
export function historyKey(env: CheckEnv): string {
|
|
66
|
-
const nested = nestedIn(env.root)
|
|
67
|
-
if (nested != null) return `nested in ${nested}`
|
|
68
|
-
const declared = declaredVisibility(env.config)
|
|
69
|
-
const hooksPath = gitOut(env.root, ['config', 'core.hooksPath'])
|
|
70
|
-
const dir = hooksPath.code === 0 ? hooksPath.out.trim() : ''
|
|
71
|
-
const hook = `hooks=${dir}|msg=${dir === '' ? 'none' : fileStamp(isAbsolute(dir) ? join(dir, 'commit-msg') : join(env.root, dir, 'commit-msg'))}`
|
|
72
|
-
if (declared !== 'public') return `declared ${declared}|${hook}`
|
|
73
|
-
const refs = gitOut(env.root, ['for-each-ref', '--format=%(refname)', 'refs/tags', 'refs/heads', 'refs/remotes']).out
|
|
74
|
-
const digest = createHash('sha256').update(refs).digest('hex').slice(0, 16)
|
|
75
|
-
return `public|${hook}|refs=${digest}|settings=${fileStamp(join(env.root, '.claude', 'settings.json'))}`
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
export const commitAiTells = check({
|
|
79
|
-
id: 'SK009',
|
|
80
|
-
blocking: true,
|
|
81
|
-
title: 'commit ai tells',
|
|
82
|
-
description: 'the commit-msg hook that refuses AI attribution is armed, and no ref name tells',
|
|
83
|
-
owner: 'identity/how-we-commit.md',
|
|
84
|
-
runs: { onPreCommit: false, onCi: true, onFull: true, onRelease: true, onHealth: true, onSave: 'moved', onClock: false },
|
|
85
|
-
// git config and ref names, which no tree id describes, so they are the key
|
|
86
|
-
cache: { by: 'files', inputs: [CONFIG_FILE], key: historyKey },
|
|
87
|
-
fix: 'none',
|
|
88
|
-
applies: 'always',
|
|
89
|
-
run: async (c) => {
|
|
90
|
-
const nested = nestedIn(c.root)
|
|
91
|
-
if (nested != null) return void c.lines.push({ kind: 'skip', text: `commit gates not checked, nested in ${nested}: the parent repo owns them` })
|
|
92
|
-
const top = await c.exec(['git', 'rev-parse', '--show-toplevel'], { cwd: c.root })
|
|
93
|
-
if (top.code !== 0) return void c.lines.push({ kind: 'skip', text: 'not a git repo: no commit to guard yet' })
|
|
94
|
-
|
|
95
|
-
const hp = await c.exec(['git', 'config', 'core.hooksPath'], { cwd: c.root })
|
|
96
|
-
const dir = hp.code === 0 ? hp.out.trim() : ''
|
|
97
|
-
const chain = guardedChain(hookBinInvocation(c.root))
|
|
98
|
-
if (dir === '' && process.env['CI'] != null && process.env['CI'] !== '') {
|
|
99
|
-
c.lines.push({ kind: 'skip', text: 'commit-msg hook not armed in this CI checkout: a CI run makes no commit, and the fix pass arms hooks on a machine only' })
|
|
100
|
-
} else if (dir === '') {
|
|
101
|
-
c.fail('the commit-msg hook is not armed, so nothing refuses an AI attribution trailer: run `shipkit hooks install` (`shipkit ci` arms it too)')
|
|
102
|
-
} else {
|
|
103
|
-
const body = isAbsolute(dir) ? readOrNull(join(dir, 'commit-msg')) : c.read(`${dir}/commit-msg`)
|
|
104
|
-
const own = dir === LAYOUT.hooks
|
|
105
|
-
if (body == null)
|
|
106
|
-
c.fail(`core.hooksPath=${dir} has no ${dir}/commit-msg, so nothing refuses an AI attribution trailer: ${own ? 'run `shipkit hooks install`' : `add one that runs \`${chain}\``}`)
|
|
107
|
-
else if (!chainsShipkit(body))
|
|
108
|
-
c.fail(`core.hooksPath=${dir}: its commit-msg (${body.split('\n').find((l) => l.trim() !== '' && !l.startsWith('#'))?.trim().slice(0, 80) ?? 'empty'}) does not run the shipkit gate, so nothing refuses AI attribution: add \`${chain}\` above its last line`)
|
|
109
|
-
else c.ok(`the commit-msg hook refuses AI attribution (${own ? dir : `${dir}, chained to the shipkit gate`})`)
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
if (declaredVisibility(c.config) !== 'public') return
|
|
113
|
-
if (!attributionSilenced(c.root))
|
|
114
|
-
c.fail(`.claude/settings.json does not silence AI attribution: track it with {"attribution": {"commit": "", "pr": ""}} so no agent's harness ever writes the trailer or the PR footer`)
|
|
115
|
-
const refs = await c.exec(['git', 'for-each-ref', '--format=%(refname)', 'refs/tags', 'refs/heads', 'refs/remotes'], { cwd: c.root, timeoutMs: 30_000 })
|
|
116
|
-
if (refs.code !== 0) return void c.lines.push({ kind: 'skip', text: `ref names unverifiable: git for-each-ref exit ${refs.code}` })
|
|
117
|
-
const all = refs.out.split('\n').map((r) => r.trim()).filter((r) => r !== '')
|
|
118
|
-
const remote = new Set(all.filter((r) => r.startsWith('refs/remotes/')).map((r) => r.split('/').slice(3).join('/')))
|
|
119
|
-
const tells = all.filter((r) => REF_TELL.test(r.replace(/^refs\/(?:heads|tags|remotes)\//, '')))
|
|
120
|
-
const shown = (r: string): string => r.replace(/^refs\/(?:heads|tags|remotes)\//, '')
|
|
121
|
-
const local = tells.filter((r) => r.startsWith('refs/heads/') && !remote.has(shown(r)))
|
|
122
|
-
const out = tells.filter((r) => !local.includes(r) && !(r.startsWith('refs/heads/') && remote.has(shown(r))))
|
|
123
|
-
const list = (rs: string[], suffix = ''): string => rs.slice(0, 5).map((r) => `${shown(r)}${suffix}`).join(' ') + (rs.length > 5 ? ' …' : '')
|
|
124
|
-
if (out.length > 0)
|
|
125
|
-
c.fail(`${out.length} ref name(s) carry an AI tell in a declared-public repo (${list(out)}): rename or delete them, a pushed one on the remote too, then \`git fetch --prune\` so a remote ref deleted there leaves this clone`)
|
|
126
|
-
if (local.length > 0)
|
|
127
|
-
c.warn(`${local.length} branch name(s) carry an AI tell (${list(local, ' (local only)')}): never push them under this name, rename or delete them`)
|
|
128
|
-
},
|
|
129
|
-
})
|
|
130
|
-
|
|
131
|
-
export default commitAiTells
|
|
@@ -1,78 +0,0 @@
|
|
|
1
|
-
// SK014 — the STE prose standard, in the gate (identity/writing-voice.md).
|
|
2
|
-
//
|
|
3
|
-
// Opt-in by declaration: a repo with no `prose.include` is SKIPPED, not
|
|
4
|
-
// failed. STE is house doctrine for prose an agent writes, and a rule that
|
|
5
|
-
// turned every existing repo red the day it shipped would be turned off
|
|
6
|
-
// rather than obeyed.
|
|
7
|
-
//
|
|
8
|
-
// The tier split is the whole design. `fix` and `hard` hits BLOCK: they are
|
|
9
|
-
// mechanical, and a machine reporting them is never guessing. `soft` hits
|
|
10
|
-
// (sentence length, passive voice, modals, semicolons, …) need a human read,
|
|
11
|
-
// so they are a note, never an attention item, and only a declared
|
|
12
|
-
// `baseline` makes them blocking, as a ratchet that can only go down.
|
|
13
|
-
//
|
|
14
|
-
// It carries NO `fix()` on purpose. `shipkit ci` runs `--fix-rules` by
|
|
15
|
-
// default, so a rule with a fixer rewrites tracked files unattended, and
|
|
16
|
-
// prose is not repo shape: a wrong package.json line is a config, a wrong
|
|
17
|
-
// sentence is content. The rewrite lives behind an explicit `shipkit prose
|
|
18
|
-
// --fix` that a human ran and can read the diff of.
|
|
19
|
-
import { classSummary, proseLines, runProseCheck, type ProseReport } from '../prose/run-prose.ts'
|
|
20
|
-
import { tierOf, type ProseClass } from '../prose/ste.ts'
|
|
21
|
-
import { check } from '../check.ts'
|
|
22
|
-
|
|
23
|
-
const OWNER = 'identity/writing-voice.md'
|
|
24
|
-
|
|
25
|
-
export const proseSte = check({
|
|
26
|
-
id: 'SK014',
|
|
27
|
-
blocking: true,
|
|
28
|
-
title: 'STE prose',
|
|
29
|
-
description: 'house prose passes the mechanical STE rules',
|
|
30
|
-
owner: OWNER,
|
|
31
|
-
// keyed on what it reads: each include glob's listing and every file in
|
|
32
|
-
// scope, so an edit outside the declared prose never reruns it
|
|
33
|
-
runs: { onPreCommit: false, onCi: false, onFull: true, onRelease: true, onHealth: true, onSave: 'moved', onClock: false },
|
|
34
|
-
cache: { by: 'reads' },
|
|
35
|
-
fix: 'none',
|
|
36
|
-
applies: 'always',
|
|
37
|
-
run: (c) => {
|
|
38
|
-
const cfg = c.config.prose
|
|
39
|
-
if (cfg == null || (cfg.include ?? []).length === 0) {
|
|
40
|
-
c.ok(`no prose scope declared — add \`prose: { include: [...] }\` to repo.config.ts to gate house prose on STE`)
|
|
41
|
-
return
|
|
42
|
-
}
|
|
43
|
-
const r = runProseCheck({ c, config: cfg })
|
|
44
|
-
// a scope that resolves to zero files reads exactly like a clean repo,
|
|
45
|
-
// and it is the opposite: the repo asked for the gate and the gate
|
|
46
|
-
// inspects nothing. A green here is coverage nobody has.
|
|
47
|
-
if (r.files === 0) {
|
|
48
|
-
c.warn(
|
|
49
|
-
`prose scope matches no file: ${(cfg.include ?? []).join(', ')} — declared and inspecting nothing`,
|
|
50
|
-
'fix the globs in repo.config.ts prose.include (or its exempt list), or drop the key to opt out',
|
|
51
|
-
)
|
|
52
|
-
return
|
|
53
|
-
}
|
|
54
|
-
const blocking = r.byTier.fix + r.byTier.hard
|
|
55
|
-
for (const l of proseLines(r, { cap: 12, tiers: ['fix', 'hard'] })) c.note(l)
|
|
56
|
-
if (blocking > 0)
|
|
57
|
-
c.fail(
|
|
58
|
-
`${blocking} STE violation(s) across ${r.byFile.length}/${r.files} file(s) — ${classSummary(r)}`,
|
|
59
|
-
r.fixable > 0 ? `shipkit prose --fix rewrites ${r.fixable} of them` : undefined,
|
|
60
|
-
)
|
|
61
|
-
const soft = softSummary(r)
|
|
62
|
-
if (cfg.baseline != null && r.byTier.soft > cfg.baseline)
|
|
63
|
-
c.fail(`${r.byTier.soft} soft hits (${soft}), above the declared baseline of ${cfg.baseline}: the ratchet only goes down`)
|
|
64
|
-
else if (r.byTier.soft > 0)
|
|
65
|
-
c.note(`${r.byTier.soft} soft hit(s) need a human read (${soft})${cfg.baseline != null ? `, within the baseline of ${cfg.baseline}` : ''}: \`shipkit prose\` lists them`)
|
|
66
|
-
if (!c.failed && blocking === 0) c.ok(`${r.files} file(s) STE-clean on the mechanical rules`)
|
|
67
|
-
}
|
|
68
|
-
})
|
|
69
|
-
|
|
70
|
-
/** every soft class with its count, most first */
|
|
71
|
-
const softSummary = (r: ProseReport): string =>
|
|
72
|
-
Object.entries(r.byClass)
|
|
73
|
-
.filter(([cls]) => tierOf(cls as ProseClass) === 'soft')
|
|
74
|
-
.sort((a, b) => b[1] - a[1])
|
|
75
|
-
.map(([cls, n]) => `${cls} ${n}`)
|
|
76
|
-
.join(', ')
|
|
77
|
-
|
|
78
|
-
export default proseSte
|