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
|
@@ -83,9 +83,8 @@ export type LegacyCheck = {
|
|
|
83
83
|
|
|
84
84
|
// read without importing: checks run as subprocesses, and discovery must stay
|
|
85
85
|
// pure-fs. `export const blocking = true` (or false), nothing cleverer.
|
|
86
|
-
// the tail
|
|
87
|
-
//
|
|
88
|
-
// the gate itself just wrote.
|
|
86
|
+
// the tail takes what a formatter on its defaults writes: it terminates every
|
|
87
|
+
// statement, so a reader that refuses a semicolon refuses a formatted file.
|
|
89
88
|
const TAIL = String.raw`(?:\s+as\s+const)?\s*;?\s*$`
|
|
90
89
|
const BLOCKING_RE = new RegExp(String.raw`^\s*export\s+const\s+blocking\s*(?::\s*boolean\s*)?=\s*(true|false)` + TAIL, 'm')
|
|
91
90
|
// `export const inputs = ['a', 'b']`, single or multi line. Anything cleverer
|
package/src/core/check-pass.ts
CHANGED
|
@@ -12,9 +12,10 @@ import { importClosure } from '../ts/source-graph.ts'
|
|
|
12
12
|
import { LIB_ROOT } from './lib-root.ts'
|
|
13
13
|
import type { RepoConfig } from '../repo-config.ts'
|
|
14
14
|
import type { Scan } from '../scan.ts'
|
|
15
|
+
import { parseDirectives, type Directive } from './waivers.ts'
|
|
15
16
|
|
|
16
17
|
/** a scan's context, and what it recorded while the scan ran */
|
|
17
|
-
type ScanRecorder = ScanCtx & { readonly reads: Reads; readonly untracked: string | null }
|
|
18
|
+
type ScanRecorder = ScanCtx & { readonly reads: Reads; readonly untracked: string | null; readonly marked: ReadonlyMap<string, string> }
|
|
18
19
|
|
|
19
20
|
/** the config file every check that consults `c.config` depends on */
|
|
20
21
|
export const CONFIG_FILE = 'repo.config.ts'
|
|
@@ -27,6 +28,7 @@ export class CheckPass {
|
|
|
27
28
|
private readonly scans = new Map<string, { value: Promise<unknown>; ctx: ScanRecorder }>()
|
|
28
29
|
private viewMemo: RepoView | null = null
|
|
29
30
|
private configMemo: { files: Map<string, string | null>; untracked: string | null } | null = null
|
|
31
|
+
private readonly directiveMemo = new Map<string, { text: string; directives: Directive[] }>()
|
|
30
32
|
|
|
31
33
|
constructor(
|
|
32
34
|
readonly root: string,
|
|
@@ -34,6 +36,10 @@ export class CheckPass {
|
|
|
34
36
|
/** where the file list comes from: the run's ledger shares its own,
|
|
35
37
|
* so a listing and its validation read one snapshot */
|
|
36
38
|
private readonly openView: () => RepoView = () => RepoView.open(root),
|
|
39
|
+
/** false for a pass whose answers nothing stores (the pre-commit
|
|
40
|
+
* gate): listing what the config imports is then pure cost, measured
|
|
41
|
+
* at 12 to 70ms on the first check that reads `c.config` */
|
|
42
|
+
readonly recordsConfig = true,
|
|
37
43
|
) {
|
|
38
44
|
this.configOid = oidOnDisk(join(root, CONFIG_FILE))
|
|
39
45
|
}
|
|
@@ -61,6 +67,16 @@ export class CheckPass {
|
|
|
61
67
|
return this.configMemo
|
|
62
68
|
}
|
|
63
69
|
|
|
70
|
+
/** the directives of a file as read, parsed once per pass however many
|
|
71
|
+
* checks read it */
|
|
72
|
+
directives(path: string, text: string): Directive[] {
|
|
73
|
+
const hit = this.directiveMemo.get(path)
|
|
74
|
+
if (hit != null && hit.text === text) return hit.directives
|
|
75
|
+
const directives = parseDirectives(path, text)
|
|
76
|
+
this.directiveMemo.set(path, { text, directives })
|
|
77
|
+
return directives
|
|
78
|
+
}
|
|
79
|
+
|
|
64
80
|
view(): RepoView {
|
|
65
81
|
this.viewMemo ??= this.openView()
|
|
66
82
|
return this.viewMemo
|
package/src/core/check-runner.ts
CHANGED
|
@@ -450,6 +450,8 @@ type Live = RunnableCheck & CheckResult & {
|
|
|
450
450
|
reads: ReadSet | null
|
|
451
451
|
/** why its answer cannot be stored on its reads, in its own words */
|
|
452
452
|
untracked: string | null
|
|
453
|
+
/** the files whose in-file directives its answer depends on */
|
|
454
|
+
waiverFiles: string[]
|
|
453
455
|
/** it printed nothing of its own, so its one line is the headline below */
|
|
454
456
|
synthetic: boolean
|
|
455
457
|
/** what the headline carries after the id: a failure's last line, or the
|
|
@@ -506,7 +508,7 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
|
|
|
506
508
|
const runDir = openRunDir(o.logDir)
|
|
507
509
|
const checks: Live[] = sel.selected.map((u) => ({
|
|
508
510
|
...u,
|
|
509
|
-
status: 'skip', advisory: false, replayed: false, ms: 0, exitCode: null, lines: [], problems: [], lastLine: '', note: '', startedAt: 0, endedAt: 0, unmeasured: false, attached: false, reads: null, untracked: null, synthetic: false, tail: '', extra: [],
|
|
511
|
+
status: 'skip', advisory: false, replayed: false, ms: 0, exitCode: null, lines: [], problems: [], lastLine: '', note: '', startedAt: 0, endedAt: 0, unmeasured: false, attached: false, reads: null, untracked: null, waiverFiles: [], synthetic: false, tail: '', extra: [],
|
|
510
512
|
logPath: u.body.cmd != null ? join(runDir, `${u.id.replace(/[^a-zA-Z0-9-]/g, '_')}.log`) : null,
|
|
511
513
|
}))
|
|
512
514
|
const selectedIds = new Set(checks.map((u) => u.id))
|
|
@@ -599,8 +601,9 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
|
|
|
599
601
|
: [ranHere(composed[0], u.ms), ...composed.slice(1)]
|
|
600
602
|
// a caller printing its own verdict wants one line per green check and
|
|
601
603
|
// only the warnings of a warned one; a failure prints whole
|
|
604
|
+
// a waived site is detail whatever the verdict, and its count never is
|
|
602
605
|
const quiet = (l: Line, i: number): boolean =>
|
|
603
|
-
o.detail != null && (u.status === 'pass' ? i > 0 : u.status === 'warn' || u.advisory ? l.kind === 'ok' || l.kind === 'note' || l.kind === 'skip' : false)
|
|
606
|
+
o.detail != null && l.waiver !== 'count' && (l.waiver === 'site' || (u.status === 'pass' ? i > 0 : u.status === 'warn' || u.advisory ? l.kind === 'ok' || l.kind === 'note' || l.kind === 'skip' : false))
|
|
604
607
|
for (const [i, l] of shown.entries()) if (quiet(l, i)) o.detail!(formatLine(l, color)); else emit(l)
|
|
605
608
|
if (u.status === 'fail' && !u.advisory && u.logPath != null)
|
|
606
609
|
emit({ kind: 'fix-fail', text: `full log: ${u.logPath}` })
|
|
@@ -641,6 +644,7 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
|
|
|
641
644
|
await runCheckBody({ id: u.id, title: u.title, owner: u.owner, run: u.body.run, fix: u.fix }, t)
|
|
642
645
|
u.reads = t.untracked == null ? t.reads.snapshot() : null
|
|
643
646
|
u.untracked = t.untracked
|
|
647
|
+
u.waiverFiles = t.waiverFiles()
|
|
644
648
|
u.lines = t.lines
|
|
645
649
|
u.problems = findings(u, t.lines)
|
|
646
650
|
u.unmeasured = t.unmeasured
|
|
@@ -718,6 +722,13 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
|
|
|
718
722
|
let i = parsed.length
|
|
719
723
|
while (i > 0 && (parsed[i - 1]!.kind === 'fail' || parsed[i - 1]!.kind === 'fix-fail')) i--
|
|
720
724
|
u.extra = parsed.slice(i).filter((l) => !(l.kind === 'fail' && `FAIL: ${l.text}` === u.lastLine)).slice(-MAX_EXTRA)
|
|
725
|
+
// a tool that speaks no wire (a linter, tsc) ends on a recap, and
|
|
726
|
+
// its errors are the findings its output parsed into
|
|
727
|
+
if (!u.extra.some((l) => l.kind === 'fail'))
|
|
728
|
+
u.extra = u.problems
|
|
729
|
+
.filter((p) => p.severity === 'error' && !u.lastLine.includes(p.message))
|
|
730
|
+
.slice(0, MAX_EXTRA)
|
|
731
|
+
.map((p) => ({ kind: 'fail', text: `${p.file}:${p.line}:${p.col}: ${p.message}` }))
|
|
721
732
|
}
|
|
722
733
|
}
|
|
723
734
|
|
|
@@ -732,9 +743,9 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
|
|
|
732
743
|
|
|
733
744
|
/** what an attached asker adopts: the verdict and the lines BEFORE this
|
|
734
745
|
* run's own demotion, which the asker applies from its own declaration */
|
|
735
|
-
type Answer = Pick<Live, 'status' | 'lines' | 'problems' | 'lastLine' | 'exitCode' | 'logPath' | 'synthetic' | 'tail'> & { measuredMs: number }
|
|
746
|
+
type Answer = Pick<Live, 'status' | 'lines' | 'problems' | 'lastLine' | 'exitCode' | 'logPath' | 'synthetic' | 'tail' | 'waiverFiles'> & { measuredMs: number }
|
|
736
747
|
const answerOf = (u: Live): Answer => ({
|
|
737
|
-
status: u.status, lines: u.lines, problems: u.problems, lastLine: u.lastLine, exitCode: u.exitCode, logPath: u.logPath,
|
|
748
|
+
status: u.status, lines: u.lines, problems: u.problems, lastLine: u.lastLine, exitCode: u.exitCode, logPath: u.logPath, waiverFiles: u.waiverFiles,
|
|
738
749
|
synthetic: u.synthetic, tail: u.tail, measuredMs: (u.endedAt > 0 ? u.endedAt : clock()) - u.startedAt,
|
|
739
750
|
})
|
|
740
751
|
// only a GREEN is ever stored. A pass is one whatever the check blocks. A
|
|
@@ -777,7 +788,7 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
|
|
|
777
788
|
u.lines = head == null ? [] : [{ ...head, text: `${head.text}${stamp}` }, ...a.lines.slice(1)]
|
|
778
789
|
u.problems = feeds(u) ? a.problems ?? [] : []
|
|
779
790
|
if (o.ledger != null && storable(u))
|
|
780
|
-
o.ledger.record(u.id, { status: u.status, lines: a.lines, lastLine: a.lastLine, problems: u.problems, measuredMs: a.measuredMs })
|
|
791
|
+
o.ledger.record(u.id, { status: u.status, lines: a.lines, lastLine: a.lastLine, problems: u.problems, measuredMs: a.measuredMs }, undefined, undefined, a.waiverFiles)
|
|
781
792
|
}
|
|
782
793
|
|
|
783
794
|
/** ONE measurement per in flight question: an asker whose exact key
|
|
@@ -835,12 +846,14 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
|
|
|
835
846
|
u.lines = rememberWorld(o.cache, u.id, worldKey(u), u.ttlMs, {
|
|
836
847
|
lines: u.lines,
|
|
837
848
|
clean,
|
|
849
|
+
// a waiver count is a skip line: a world key cannot see the
|
|
850
|
+
// waiving file, so a waived world answer is never kept
|
|
838
851
|
unmeasured: u.unmeasured || u.lines.some((l) => l.kind === 'skip'),
|
|
839
852
|
keepNegative: u.blocking === false,
|
|
840
853
|
})
|
|
841
854
|
}
|
|
842
855
|
if (o.ledger != null && keep)
|
|
843
|
-
o.ledger.record(u.id, { status: u.status as 'pass' | 'warn', lines: u.lines, lastLine: u.lastLine, problems: u.problems, measuredMs: clock() - u.startedAt }, u.reads, u.untracked)
|
|
856
|
+
o.ledger.record(u.id, { status: u.status as 'pass' | 'warn', lines: u.lines, lastLine: u.lastLine, problems: u.problems, measuredMs: clock() - u.startedAt }, u.reads, u.untracked, u.waiverFiles)
|
|
844
857
|
}
|
|
845
858
|
|
|
846
859
|
const start = (u: Live): void => {
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
import { readFileSync } from 'fs'
|
|
14
14
|
import { join } from 'path'
|
|
15
15
|
import { atomicWrite } from './locked-write.ts'
|
|
16
|
-
import { dropConfigKey } from './legacy-shapes.ts'
|
|
16
|
+
import { dropConfigEntry, dropConfigKey } from './legacy-shapes.ts'
|
|
17
17
|
import { writeGateBlock } from './when-block.ts'
|
|
18
18
|
import { loadRepoConfig } from '../repo-config.ts'
|
|
19
19
|
|
|
@@ -25,6 +25,12 @@ export type ConfigPlan = {
|
|
|
25
25
|
drop: { key: string; why: string; phase?: 'first' | 'afterOff' }[]
|
|
26
26
|
/** the config's `name` before the rewrite: what the read back must still say */
|
|
27
27
|
name: string
|
|
28
|
+
/** entries of an object key to lift into `off` (`hooks.disable`, a
|
|
29
|
+
* `defaults` slot set to false), dropped once the block took them. The
|
|
30
|
+
* comment written right above an entry becomes the reason of every id it
|
|
31
|
+
* carries, since it is the author's own words; `reason` is the honest
|
|
32
|
+
* fallback when there is none */
|
|
33
|
+
lift?: { parent: string; entry: string; value: string; ids: string[]; reason: Record<string, string> }[]
|
|
28
34
|
}
|
|
29
35
|
|
|
30
36
|
export type ConfigRewrite = {
|
|
@@ -48,17 +54,23 @@ export async function rewriteConfig(root: string, plan: ConfigPlan): Promise<Con
|
|
|
48
54
|
let first = before
|
|
49
55
|
for (const d of plan.drop.filter((x) => x.phase === 'first')) first = drop(first, d)
|
|
50
56
|
if (first !== before) atomicWrite(path, first)
|
|
57
|
+
// each lifted entry's reason, read off the text before anything moves
|
|
58
|
+
const off = { ...plan.off }
|
|
59
|
+
for (const l of plan.lift ?? []) {
|
|
60
|
+
const seen = dropConfigEntry(first, l.parent, l.entry, l.value)
|
|
61
|
+
for (const id of l.ids) off[id] ??= seen?.comment ?? l.reason[id] ?? ''
|
|
62
|
+
}
|
|
51
63
|
let offWritten = false
|
|
52
|
-
if (Object.keys(
|
|
64
|
+
if (Object.keys(off).length > 0) {
|
|
53
65
|
try {
|
|
54
|
-
await writeGateBlock(root, { off
|
|
66
|
+
await writeGateBlock(root, { off })
|
|
55
67
|
offWritten = true
|
|
56
|
-
did.push(`${Object.keys(
|
|
68
|
+
did.push(`${Object.keys(off).length} off entr(ies) written into the managed block`)
|
|
57
69
|
} catch (e) {
|
|
58
70
|
// a key dropped first held these entries: without the block they
|
|
59
71
|
// would be gone, so the file goes back as it was
|
|
60
|
-
if (first !== before) { atomicWrite(path, before); return { did: [], hand, restored: `the managed block refused off ${JSON.stringify(
|
|
61
|
-
hand.push(`the managed block refused the rewrite (${String(e).slice(0, 160)}) — write off: ${JSON.stringify(
|
|
72
|
+
if (first !== before) { atomicWrite(path, before); return { did: [], hand, restored: `the managed block refused off ${JSON.stringify(off)}: ${String(e).slice(0, 160)}` } }
|
|
73
|
+
hand.push(`the managed block refused the rewrite (${String(e).slice(0, 160)}) — write off: ${JSON.stringify(off)} into it by hand`)
|
|
62
74
|
}
|
|
63
75
|
}
|
|
64
76
|
const mid = readFileSync(path, 'utf8')
|
|
@@ -67,11 +79,20 @@ export async function rewriteConfig(root: string, plan: ConfigPlan): Promise<Con
|
|
|
67
79
|
if (d.phase === 'afterOff' && !offWritten) continue
|
|
68
80
|
text = drop(text, d)
|
|
69
81
|
}
|
|
82
|
+
// a lifted entry goes only once the block holds it, so it is never lost
|
|
83
|
+
for (const l of offWritten ? plan.lift ?? [] : []) {
|
|
84
|
+
const next = dropConfigEntry(text, l.parent, l.entry, l.value)
|
|
85
|
+
if (next == null) { hand.push(`repo.config.ts \`${l.parent}.${l.entry}\` is \`off\` now, and the entry could not be read mechanically — delete it by hand`); continue }
|
|
86
|
+
did.push(`repo.config.ts drops \`${l.parent}.${l.entry}\``)
|
|
87
|
+
text = next.text
|
|
88
|
+
}
|
|
70
89
|
if (text !== mid) atomicWrite(path, text)
|
|
71
90
|
if (text !== before) {
|
|
72
91
|
try {
|
|
73
92
|
const after = await loadRepoConfig(root, { fresh: true })
|
|
74
93
|
if (after.name !== plan.name) throw new Error(`the config reads as '${String(after.name)}'`)
|
|
94
|
+
const lost = Object.keys(off).filter((id) => after.off?.[id] == null)
|
|
95
|
+
if (offWritten && lost.length > 0) throw new Error(`the config no longer turns ${lost.join(', ')} off`)
|
|
75
96
|
} catch (e) {
|
|
76
97
|
atomicWrite(path, before)
|
|
77
98
|
return { did, hand, restored: String(e).slice(0, 160) }
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
// are told apart by reading the source first (`isLegacyCheckSource`).
|
|
17
17
|
//
|
|
18
18
|
// Skipped dirs: node_modules, dist, tmp, references (templates for other
|
|
19
|
-
// repos),
|
|
19
|
+
// repos), every dot-dir, and the repo's vendored dirs (core/vendored.ts).
|
|
20
20
|
import { RETIRED_CHECK_IDS } from './retired-checks.ts'
|
|
21
21
|
import { readdirSync, readFileSync, realpathSync, statSync } from 'fs'
|
|
22
22
|
import { basename, join, relative } from 'path'
|
|
@@ -24,6 +24,7 @@ import { check, type CheckDef, type CheckFrom } from '../check.ts'
|
|
|
24
24
|
|
|
25
25
|
import { CHECK_SUFFIX as SUFFIX, fallbackNames, isLegacyCheckSource, SKIPPED_DIRS as IGNORED_DIRS, UNIT_SUFFIX, unitBase } from './check-file-names.ts'
|
|
26
26
|
import { unitKind } from '../shipkit-files.ts'
|
|
27
|
+
import { readVendored } from './vendored-read.ts'
|
|
27
28
|
|
|
28
29
|
export { fallbackNames, isLegacyCheckSource } from './check-file-names.ts'
|
|
29
30
|
|
|
@@ -56,23 +57,27 @@ export type DiscoveredCheck = {
|
|
|
56
57
|
from: CheckFrom
|
|
57
58
|
}
|
|
58
59
|
|
|
59
|
-
function walk(dir: string, out: string[], suffix = SUFFIX, dirs?: string[]): void {
|
|
60
|
+
function walk(dir: string, out: string[], suffix = SUFFIX, dirs?: string[], prune?: ReadonlySet<string>): void {
|
|
60
61
|
dirs?.push(dir)
|
|
61
62
|
let entries
|
|
62
63
|
try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
|
|
63
64
|
for (const e of entries) {
|
|
64
65
|
if (e.isDirectory()) {
|
|
65
|
-
|
|
66
|
+
const sub = join(dir, e.name)
|
|
67
|
+
if (!IGNORED_DIRS.has(e.name) && !e.name.startsWith('.') && prune?.has(sub) !== true) walk(sub, out, suffix, dirs, prune)
|
|
66
68
|
} else if (e.name.endsWith(suffix)) out.push(join(dir, e.name))
|
|
67
69
|
}
|
|
68
70
|
}
|
|
69
71
|
|
|
72
|
+
/** the absolute vendored dirs of a repo, which no repo walk enters */
|
|
73
|
+
const vendoredPrune = (root: string): ReadonlySet<string> => new Set(readVendored(root).map((d) => join(root, d)))
|
|
74
|
+
|
|
70
75
|
/** every `*.shipkit.ts` file under a repo root, by the same walk. Sorted.
|
|
71
76
|
* `dirs` collects every dir the walk read: a file added to one moves its
|
|
72
77
|
* mtime, which is how a reader tells the list moved without walking again */
|
|
73
78
|
export function repoUnitFiles(root: string, dirs?: string[]): string[] {
|
|
74
79
|
const out: string[] = []
|
|
75
|
-
walk(root, out, UNIT_SUFFIX, dirs)
|
|
80
|
+
walk(root, out, UNIT_SUFFIX, dirs, vendoredPrune(root))
|
|
76
81
|
return out.sort()
|
|
77
82
|
}
|
|
78
83
|
|
|
@@ -97,7 +102,7 @@ export async function importFresh(path: string): Promise<unknown> {
|
|
|
97
102
|
* Sorted. */
|
|
98
103
|
export function repoCheckFiles(root: string): string[] {
|
|
99
104
|
const out: string[] = []
|
|
100
|
-
walk(root, out)
|
|
105
|
+
walk(root, out, SUFFIX, undefined, vendoredPrune(root))
|
|
101
106
|
return out.sort()
|
|
102
107
|
}
|
|
103
108
|
|
|
@@ -175,13 +180,24 @@ const BUILTIN_OWNER = 'build/shipkit/built-in-checks.md'
|
|
|
175
180
|
* on a registration nobody can act on (a duplicate id, a repo check squatting
|
|
176
181
|
* the reserved prefix, a built-in with no owner page), so a broken
|
|
177
182
|
* declaration dies at load rather than half way through a gate. A broken
|
|
178
|
-
* built-in is that only in the toolkit's own repo: elsewhere it warns.
|
|
179
|
-
|
|
180
|
-
|
|
183
|
+
* built-in is that only in the toolkit's own repo: elsewhere it warns. */
|
|
184
|
+
export type DiscoverOpts = {
|
|
185
|
+
/** a file not to import at all: the pre-commit gate skips the files its
|
|
186
|
+
* declarations table already knows run no onPreCommit body */
|
|
187
|
+
skip?: (path: string) => boolean
|
|
188
|
+
/** every import, timed: `def` is null for a unit file of another kind,
|
|
189
|
+
* and `loaded` is false for a file that did not load */
|
|
190
|
+
imported?: (path: string, ms: number, def: CheckDef | null, loaded: boolean) => void
|
|
191
|
+
/** the toolkit's checks dir, a parameter for tests only */
|
|
192
|
+
builtinDir?: string
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export async function discoverChecks(root: string, o: DiscoverOpts = {}): Promise<DiscoveredCheck[]> {
|
|
196
|
+
const builtinDir = o.builtinDir ?? BUILTIN_CHECK_DIR
|
|
181
197
|
const builtins: string[] = []
|
|
182
198
|
walk(builtinDir, builtins)
|
|
183
199
|
const found: string[] = []
|
|
184
|
-
walk(root, found)
|
|
200
|
+
walk(root, found, SUFFIX, undefined, vendoredPrune(root))
|
|
185
201
|
const units = repoUnitFiles(root)
|
|
186
202
|
// the repo that HOSTS the toolkit walks over `src/checks/` too, and a file
|
|
187
203
|
// discovered twice is a duplicate id rather than a second check. Real paths,
|
|
@@ -195,18 +211,22 @@ export async function discoverChecks(root: string, builtinDir: string = BUILTIN_
|
|
|
195
211
|
builtins.sort()
|
|
196
212
|
const names = fallbackNames(root, all)
|
|
197
213
|
// a legacy script is left to its own reader, and never imported here
|
|
198
|
-
const own = all.filter((f) => !isLegacyCheckFile(f))
|
|
214
|
+
const own = all.filter((f) => o.skip?.(f) === true || !isLegacyCheckFile(f))
|
|
199
215
|
const base = (f: string): string => basename(f).slice(0, -SUFFIX.length)
|
|
200
216
|
|
|
201
217
|
const out: DiscoveredCheck[] = []
|
|
202
218
|
const ids = new Set<string>()
|
|
203
219
|
for (const f of [...builtins, ...own, ...units]) {
|
|
204
220
|
const isBuiltin = builtins.includes(f)
|
|
221
|
+
if (o.skip?.(f) === true) continue
|
|
205
222
|
let def: CheckDef | null
|
|
206
223
|
let loadError: string | null = null
|
|
224
|
+
const t = performance.now()
|
|
207
225
|
try {
|
|
208
226
|
def = await load(f)
|
|
227
|
+
o.imported?.(f, performance.now() - t, def, true)
|
|
209
228
|
} catch (e) {
|
|
229
|
+
o.imported?.(f, performance.now() - t, null, false)
|
|
210
230
|
// a file that does not load is ONE red row, never a dead inventory:
|
|
211
231
|
// every other check still runs, and this one says why in every run
|
|
212
232
|
loadError = (e as Error).message
|
|
@@ -25,6 +25,9 @@ import { parseBunTestScript, suiteFlags } from '../suite/judge-suites.ts'
|
|
|
25
25
|
import { declaredTestUnits, siblingUnits, testCheckId, unitIgnores, unitTestBody, unitTsconfig, type TestUnitDecl } from '../suite/test-units.ts'
|
|
26
26
|
import { describeFirst, type TaskDef } from '../check.ts'
|
|
27
27
|
import type { RepoConfig } from '../repo-config.ts'
|
|
28
|
+
import { repoOff } from './when.ts'
|
|
29
|
+
import { bunfigDeclares, bunLintCmd } from './bun-builtins.ts'
|
|
30
|
+
import { normalizeVendored } from './vendored.ts'
|
|
28
31
|
|
|
29
32
|
/** how the floor reads the repo, by repo-relative path, plus the one lookup
|
|
30
33
|
* outside it: a binary on PATH */
|
|
@@ -65,10 +68,10 @@ export const unitTestDescription = (u: { id: string; rel: string }): string =>
|
|
|
65
68
|
export const unitTypecheckDescription = (u: { id: string; rel: string }): string =>
|
|
66
69
|
describeFirst(`tsc finds no type error under ${u.rel}`, `tsc finds no type error in unit ${u.id}`)
|
|
67
70
|
|
|
68
|
-
export type FloorSource = 'package.json script' | 'repo.config override' | 'built-in (shipkit)' | 'built-in (tsc)' | 'unit'
|
|
71
|
+
export type FloorSource = 'package.json script' | 'repo.config override' | 'built-in (shipkit)' | 'built-in (tsc)' | 'built-in (bun)' | 'unit'
|
|
69
72
|
export type FloorResolution = {
|
|
70
73
|
tasks: TaskDef[]
|
|
71
|
-
/** slots
|
|
74
|
+
/** slots the repo turned off (core/when.ts repoOff), always printed */
|
|
72
75
|
disabled: string[]
|
|
73
76
|
/** slots nothing could fill — always printed, never silent */
|
|
74
77
|
unfilled: { id: string; reason: string }[]
|
|
@@ -161,9 +164,14 @@ function tsconfigChain(rel: string, io: FloorReader): string[] {
|
|
|
161
164
|
}
|
|
162
165
|
|
|
163
166
|
// zero-config floor (O6): with no script, imports:check/cycles resolve to
|
|
164
|
-
// shipkit's own rvlib-ts-tools dep, typecheck to the repo's tsc
|
|
165
|
-
//
|
|
166
|
-
|
|
167
|
+
// shipkit's own rvlib-ts-tools dep, typecheck to the repo's tsc, lint to
|
|
168
|
+
// `bun lint` once bunfig.toml declares [lint]. Returns the task, or the
|
|
169
|
+
// reason the slot stays empty.
|
|
170
|
+
function builtinFloorTask(id: string, root: string, io: FloorReader, vendored: readonly string[]): { task: TaskDef; source: FloorSource } | string {
|
|
171
|
+
if (id === 'lint') {
|
|
172
|
+
if (!bunfigDeclares(io.text('bunfig.toml'), 'lint')) return `no 'lint' script and no [lint] section in bunfig.toml`
|
|
173
|
+
return { task: { id, cmd: bunLintCmd(vendored) }, source: 'built-in (bun)' }
|
|
174
|
+
}
|
|
167
175
|
if (id === 'imports:check' || id === 'cycles') {
|
|
168
176
|
let cli: string
|
|
169
177
|
// shipkit's OWN module, resolved from this file: no dependency to be
|
|
@@ -234,7 +242,8 @@ export function resolveFloor(c: RepoConfig, root: string, io: FloorReader): Floo
|
|
|
234
242
|
const notes: string[] = []
|
|
235
243
|
const subs = declaredTestUnits(c.units)
|
|
236
244
|
for (const id of FLOOR_IDS) {
|
|
237
|
-
|
|
245
|
+
// a slot the repo turned off resolves to no task, whichever spelling said so
|
|
246
|
+
const override = repoOff(c, id) != null ? false : c.defaults?.[id]
|
|
238
247
|
const description = slotDescription(id, subs.length > 0)
|
|
239
248
|
const slot = slotBody(id, override, scripts)
|
|
240
249
|
if (slot.kind === 'off') { disabled.push(id); continue }
|
|
@@ -250,7 +259,7 @@ export function resolveFloor(c: RepoConfig, root: string, io: FloorReader): Floo
|
|
|
250
259
|
return override == null || override === false ? t : { ...t, ...override, id }
|
|
251
260
|
}
|
|
252
261
|
if (slot.kind === 'script') { tasks.push(decorate({ id, cmd: scriptCmd(c, id, slot.name, slot.body, notes, subs) })); sources[id] = 'package.json script'; continue }
|
|
253
|
-
const b = builtinFloorTask(id, root, io)
|
|
262
|
+
const b = builtinFloorTask(id, root, io, (c.vendored ?? []).map(normalizeVendored))
|
|
254
263
|
// a routed script with nothing behind it measures NOTHING, and the
|
|
255
264
|
// reason must say that rather than `no '<id>' script`, which is false
|
|
256
265
|
if (typeof b === 'string') unfilled.push({ id, reason: slot.kind === 'routed-empty' ? `script '${id}' routes to shipkit and there is no '${rawName(id)}' body to run` : b })
|
package/src/core/job-run.ts
CHANGED
|
@@ -4,14 +4,14 @@
|
|
|
4
4
|
// the machine's one background turn here, so the turn outlives a daemon
|
|
5
5
|
// reload the same as the run does.
|
|
6
6
|
import { basename } from 'path'
|
|
7
|
-
import {
|
|
7
|
+
import { installOneLib } from './one-lib.ts'
|
|
8
8
|
import { appendJobCheck, type JobKind, type JobOpts } from './jobs.ts'
|
|
9
9
|
import { autoGatePool, takeSlot } from './slots.ts'
|
|
10
10
|
import type { CheckEvent } from './check-runner.ts'
|
|
11
11
|
|
|
12
12
|
export async function runJob(root: string, id: string, kind: JobKind, job: JobOpts, print: (l: string) => void): Promise<number> {
|
|
13
13
|
// before the repo config loads: it imports this copy of the lib, never its own install
|
|
14
|
-
if (job.oneLib === true)
|
|
14
|
+
if (job.oneLib === true) installOneLib()
|
|
15
15
|
const turn = job.turn === true ? await takeSlot(autoGatePool(), { repo: basename(root), check: 'auto gate' }, { pollMs: 250 }) : null
|
|
16
16
|
try {
|
|
17
17
|
const onCheck = (e: CheckEvent): void => appendJobCheck(root, id, e)
|
package/src/core/layout.ts
CHANGED
|
@@ -19,6 +19,9 @@ export const LAYOUT = {
|
|
|
19
19
|
identity: sk('identity'),
|
|
20
20
|
assets: sk('assets'),
|
|
21
21
|
comfy: sk('comfy'),
|
|
22
|
+
// a bug's repro test once its fix lands: kept for the record, never run
|
|
23
|
+
// (bun test skips dot dirs), moved into the suite only when it earns its wait
|
|
24
|
+
repros: sk('repros'),
|
|
22
25
|
// local: drafts, private notes, venue listings until they go live
|
|
23
26
|
private: sk('private'),
|
|
24
27
|
reflections: sk('reflections'),
|
|
@@ -41,7 +44,7 @@ export const LAYOUT = {
|
|
|
41
44
|
} as const
|
|
42
45
|
|
|
43
46
|
/** the children the SK004 block re-includes: everything else under `.shipkit/` stays local */
|
|
44
|
-
export const TRACKED_DIRS = [LAYOUT.asks, LAYOUT.hooks, LAYOUT.identity, LAYOUT.assets, LAYOUT.comfy] as const
|
|
47
|
+
export const TRACKED_DIRS = [LAYOUT.asks, LAYOUT.hooks, LAYOUT.identity, LAYOUT.assets, LAYOUT.comfy, LAYOUT.repros] as const
|
|
45
48
|
|
|
46
49
|
/** the machine-global private folder, under $HOME: one keyword list and one
|
|
47
50
|
* allowlist per machine, never copied into a repo */
|
|
@@ -221,3 +221,43 @@ export function dropConfigKey(text: string, key: string): string | null {
|
|
|
221
221
|
while (from > 0 && lines[from - 1]!.trim().startsWith('//') && !/^\/\/ (>>>|<<<) shipkit/.test(lines[from - 1]!.trim())) from--
|
|
222
222
|
return [...lines.slice(0, from), ...lines.slice(end + 1)].join('\n')
|
|
223
223
|
}
|
|
224
|
+
|
|
225
|
+
const esc = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
226
|
+
|
|
227
|
+
/** the config text without one entry of a top level object key, as in
|
|
228
|
+
* `disable` inside `hooks` or `lint: false` inside `defaults`, plus the words
|
|
229
|
+
* of the comment written right above the entry, which are the closest thing
|
|
230
|
+
* to a reason the entry ever had. `value` is the entry's value as a regex
|
|
231
|
+
* source, so only the shape the caller expects is touched. The parent goes
|
|
232
|
+
* too once nothing is left in it. Null when the parent or the entry is not
|
|
233
|
+
* there exactly once, or sits in a shape this reader does not follow */
|
|
234
|
+
export function dropConfigEntry(text: string, parent: string, entry: string, value: string): { text: string; comment: string | null } | null {
|
|
235
|
+
const lines = text.split('\n')
|
|
236
|
+
const at = lines.map((l, i) => (new RegExp(`^\\s*${esc(parent)}\\s*:`).test(l) ? i : -1)).filter((i) => i !== -1)
|
|
237
|
+
if (at.length !== 1) return null
|
|
238
|
+
const start = at[0]!
|
|
239
|
+
const end = statementEnd(lines, start)
|
|
240
|
+
const key = `(['"]?)${esc(entry)}\\1\\s*:\\s*(?:${value})`
|
|
241
|
+
if (start === end) {
|
|
242
|
+
// one line: `hooks: { blockedPaths: [...], disable: ['leak-guard'] },`
|
|
243
|
+
const line = lines[start]!
|
|
244
|
+
const hits = line.match(new RegExp(`(?<![\\w$])${key}`, 'g')) ?? []
|
|
245
|
+
if (hits.length !== 1) return null
|
|
246
|
+
let next = line.replace(new RegExp(`,\\s*${key}`), '')
|
|
247
|
+
if (next === line) next = line.replace(new RegExp(`(?<![\\w$])${key}\\s*,\\s*`), '')
|
|
248
|
+
if (next === line) next = line.replace(new RegExp(`(?<![\\w$])${key}`), '')
|
|
249
|
+
const out = [...lines.slice(0, start), next, ...lines.slice(start + 1)].join('\n')
|
|
250
|
+
return { text: /:\s*\{\s*\}\s*,?\s*(\/\/.*)?$/.test(next) ? (dropConfigKey(out, parent) ?? out) : out, comment: null }
|
|
251
|
+
}
|
|
252
|
+
const inner = lines.map((l, i) => (i > start && i <= end && new RegExp(`^\\s*${key}\\s*,?\\s*$`).test(l) ? i : -1)).filter((i) => i !== -1)
|
|
253
|
+
if (inner.length !== 1) return null
|
|
254
|
+
const i = inner[0]!
|
|
255
|
+
let from = i
|
|
256
|
+
while (from - 1 > start && lines[from - 1]!.trim().startsWith('//')) from--
|
|
257
|
+
const comment = lines.slice(from, i).map((l) => l.trim().replace(/^\/\/\s?/, '')).join(' ').trim()
|
|
258
|
+
const kept = [...lines.slice(0, from), ...lines.slice(i + 1)]
|
|
259
|
+
const body = kept.slice(start + 1, end - (i - from + 1))
|
|
260
|
+
const empty = body.every((l) => l.trim() === '' || l.trim().startsWith('//'))
|
|
261
|
+
const out = kept.join('\n')
|
|
262
|
+
return { text: empty ? (dropConfigKey(out, parent) ?? out) : out, comment: comment === '' ? null : comment }
|
|
263
|
+
}
|
package/src/core/one-lib.ts
CHANGED
|
@@ -102,7 +102,7 @@ const LOADERS: Record<string, 'ts' | 'tsx' | 'js' | 'jsx'> = { '.ts': 'ts', '.ts
|
|
|
102
102
|
let installed = false
|
|
103
103
|
|
|
104
104
|
/** route every import of the package to this process's copy. Idempotent */
|
|
105
|
-
export function
|
|
105
|
+
export function installOneLib(root: string = LIB_ROOT): void {
|
|
106
106
|
if (installed) return
|
|
107
107
|
installed = true
|
|
108
108
|
const exports = readExports(root)
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
// The deny is the floor and the hook is the messenger. When the hook cannot
|
|
15
15
|
// run, enforcement degrades to terse rather than to nothing.
|
|
16
16
|
//
|
|
17
|
-
// Subprocesses are NOT intercepted (verified): shipkit spawning
|
|
17
|
+
// Subprocesses are NOT intercepted (verified): shipkit spawning tsc or
|
|
18
18
|
// `bun run test:raw` from inside a permitted `shipkit ci` is invisible to
|
|
19
19
|
// these rules, which is exactly what makes the gate itself still work.
|
|
20
20
|
|
|
@@ -24,7 +24,7 @@ export type RawRunner = {
|
|
|
24
24
|
/** the floor slot it answers, so ONE exemption governs the script name and
|
|
25
25
|
* the shell command together: a repo that keeps `test` raw is not then
|
|
26
26
|
* refused `bun test` */
|
|
27
|
-
slot: 'test' | '
|
|
27
|
+
slot: 'test' | 'typecheck'
|
|
28
28
|
/** the shipkit command that answers the same question, cache and all */
|
|
29
29
|
use: string
|
|
30
30
|
/** the narrower way in, when there is one: iterating on a single repro
|
|
@@ -47,25 +47,118 @@ export const RAW_RUNNERS: readonly RawRunner[] = [
|
|
|
47
47
|
{ head: 'bunx tsc', slot: 'typecheck', use: 'shipkit ci --only typecheck' },
|
|
48
48
|
{ head: 'npx tsc', slot: 'typecheck', use: 'shipkit ci --only typecheck' },
|
|
49
49
|
{ head: 'tsc', slot: 'typecheck', use: 'shipkit ci --only typecheck' },
|
|
50
|
-
{ head: 'bunx oxlint', slot: 'lint', use: 'shipkit ci --only lint' },
|
|
51
|
-
{ head: 'npx oxlint', slot: 'lint', use: 'shipkit ci --only lint' },
|
|
52
|
-
{ head: 'oxlint', slot: 'lint', use: 'shipkit ci --only lint' },
|
|
53
|
-
{ head: 'npx eslint', slot: 'lint', use: 'shipkit ci --only lint' },
|
|
54
|
-
{ head: 'eslint', slot: 'lint', use: 'shipkit ci --only lint' },
|
|
55
50
|
]
|
|
56
51
|
|
|
57
|
-
// the FORMATTER
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
// formats before it gates, which is the only reason the reflex matters at all.
|
|
52
|
+
// the LINTER and the FORMATTER are deliberately absent. `bun lint` and
|
|
53
|
+
// `bun fmt` are the house way in: bun's built-ins cover a whole repo in well
|
|
54
|
+
// under a second, so a cache has nothing to save, and the formatter is a
|
|
55
|
+
// manual tool the gate never runs.
|
|
62
56
|
|
|
63
57
|
/** the `permissions.deny` entries, in the syntax Claude Code matches:
|
|
64
58
|
* `Bash(<head>*)` is a prefix match, and a compound command is checked one
|
|
65
59
|
* segment at a time, so `cd x && bun test` is caught too */
|
|
66
60
|
export const denyRules = (exemptSlots: Iterable<string> = []): string[] => {
|
|
67
61
|
const off = new Set(exemptSlots)
|
|
68
|
-
return RAW_RUNNERS.filter((r) => !off.has(r.slot)).map((r) => `Bash(${r.head}*)`)
|
|
62
|
+
return [...RAW_RUNNERS.filter((r) => !off.has(r.slot)).map((r) => `Bash(${r.head}*)`), GIT_STASH_RULE]
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// `git stash` in a tree that other agents share takes every agent's
|
|
66
|
+
// uncommitted work, not just the caller's (identity/how-we-commit.md: never
|
|
67
|
+
// stash as a side effect). The deny row is the floor. Claude Code matches it
|
|
68
|
+
// per subcommand of a compound and inside `$(...)`, and never inside
|
|
69
|
+
// `bash -c '...'`, after git's own global options (`git -C dir stash`), or
|
|
70
|
+
// through `eval`: the guard closes those (stashCallIn).
|
|
71
|
+
export const GIT_STASH_HEAD = 'git stash'
|
|
72
|
+
export const GIT_STASH_RULE = `Bash(${GIT_STASH_HEAD}*)`
|
|
73
|
+
|
|
74
|
+
/** the stash subcommands that only read, which the guard lets through */
|
|
75
|
+
const STASH_READS = new Set(['list', 'show'])
|
|
76
|
+
|
|
77
|
+
/** git's global options that take their value as the NEXT word */
|
|
78
|
+
const GIT_VALUE_OPTS = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--config-env', '--exec-path'])
|
|
79
|
+
|
|
80
|
+
const unquote = (w: string): string => w.replace(/^(['"])(.*)\1$/, '$2')
|
|
81
|
+
|
|
82
|
+
/** a segment's words, a quoted run kept as one word, subshell and group
|
|
83
|
+
* brackets dropped */
|
|
84
|
+
const quotedWords = (seg: string): string[] =>
|
|
85
|
+
(seg.match(/(?:'[^']*'|"(?:[^"\\]|\\.)*"|[^\s'"])+/g) ?? []).map((w) => w.replace(/^[({]+|[)}]+$/g, '')).filter((w) => w !== '')
|
|
86
|
+
|
|
87
|
+
/** the bodies of `$(...)` and backtick substitutions outside single quotes,
|
|
88
|
+
* where a shell runs them */
|
|
89
|
+
function substitutions(seg: string): string[] {
|
|
90
|
+
const out: string[] = []
|
|
91
|
+
let single = false
|
|
92
|
+
for (let i = 0; i < seg.length; i++) {
|
|
93
|
+
const ch = seg[i]!
|
|
94
|
+
if (ch === "'") { single = !single; continue }
|
|
95
|
+
if (single) continue
|
|
96
|
+
if (ch === '`') {
|
|
97
|
+
const end = seg.indexOf('`', i + 1)
|
|
98
|
+
if (end < 0) break
|
|
99
|
+
out.push(seg.slice(i + 1, end))
|
|
100
|
+
i = end
|
|
101
|
+
} else if (ch === '$' && seg[i + 1] === '(') {
|
|
102
|
+
let depth = 0
|
|
103
|
+
let j = i + 1
|
|
104
|
+
for (; j < seg.length; j++) {
|
|
105
|
+
if (seg[j] === '(') depth++
|
|
106
|
+
else if (seg[j] === ')' && --depth === 0) break
|
|
107
|
+
}
|
|
108
|
+
out.push(seg.slice(i + 2, j))
|
|
109
|
+
i = j
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return out
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** the script a segment hands to another shell: `bash -c '<script>'`,
|
|
116
|
+
* `sh -c`, `zsh -c`, or `eval <words>` */
|
|
117
|
+
function innerScript(words: string[], seg: string): string | null {
|
|
118
|
+
const program = words[0]?.split('/').pop() ?? ''
|
|
119
|
+
if (program === 'eval') {
|
|
120
|
+
const all = quotedWords(seg)
|
|
121
|
+
return all.slice(all.indexOf(words[0]!) + 1).map(unquote).join(' ')
|
|
122
|
+
}
|
|
123
|
+
if (!/^(?:ba|z|da|k)?sh$/.test(program)) return null
|
|
124
|
+
const m = /\s-[a-z]*c[a-z]*\s+(?:'([^']*)'|"((?:[^"\\]|\\.)*)"|(\S+))/.exec(seg)
|
|
125
|
+
return m == null ? null : (m[1] ?? m[2] ?? m[3] ?? null)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** the segment of a command that runs a stash which is not a read, or null.
|
|
129
|
+
* Like rawRunnerHit it reads only what a shell would RUN: a heredoc body and
|
|
130
|
+
* a quoted argument are data, so a commit message that mentions the command
|
|
131
|
+
* is never refused. */
|
|
132
|
+
export function stashCallIn(command: string, depth = 0): string | null {
|
|
133
|
+
if (depth > 3) return null
|
|
134
|
+
for (const seg of shellSegments(command)) {
|
|
135
|
+
for (const inner of substitutions(seg)) {
|
|
136
|
+
const hit = stashCallIn(inner, depth + 1)
|
|
137
|
+
if (hit != null) return hit
|
|
138
|
+
}
|
|
139
|
+
const words = programWords(seg)
|
|
140
|
+
const script = innerScript(words, seg)
|
|
141
|
+
if (script != null) {
|
|
142
|
+
const hit = stashCallIn(script, depth + 1)
|
|
143
|
+
if (hit != null) return hit
|
|
144
|
+
continue
|
|
145
|
+
}
|
|
146
|
+
if ((words[0]?.split('/').pop() ?? '') !== 'git') continue
|
|
147
|
+
// re-split with quotes kept whole: `git -C "a dir" stash`
|
|
148
|
+
const all = quotedWords(seg)
|
|
149
|
+
const g = all.findIndex((w) => w.split('/').pop() === 'git')
|
|
150
|
+
const args = all.slice(g + 1)
|
|
151
|
+
let i = 0
|
|
152
|
+
while (i < args.length && args[i]!.startsWith('-')) i += GIT_VALUE_OPTS.has(args[i]!) ? 2 : 1
|
|
153
|
+
if (unquote(args[i] ?? '') !== 'stash') continue
|
|
154
|
+
if (!STASH_READS.has(unquote(args[i + 1] ?? ''))) return seg.trim()
|
|
155
|
+
}
|
|
156
|
+
return null
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** what a stash refusal says: why, what to do instead, how to lift it */
|
|
160
|
+
export function stashRefusal(segment: string): string {
|
|
161
|
+
return `\`${segment.slice(0, 80)}\` stashes: in a tree that other agents share, a stash takes every agent's uncommitted work, not just yours. Set your own work aside with a commit by pathspec (git commit -m … -- <paths>), and read an existing stash by revision: \`git log -g --oneline refs/stash\`, \`git show stash@{0}:<file>\`. Lifting this: delete the "${GIT_STASH_RULE}" line from .claude/settings.json permissions.deny (identity/how-we-commit.md).`
|
|
69
162
|
}
|
|
70
163
|
|
|
71
164
|
/** what a refusal says. Never just the ban: a refusal with no replacement
|