rvlib-shipkit 0.32.0 → 0.34.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 +27 -0
- package/package.json +4 -1
- package/src/asks/asks-queue.ts +15 -2
- package/src/cli/cmd_Asks.ts +2 -1
- package/src/cli/cmd_Keys.ts +107 -0
- package/src/cli.ts +2 -0
- package/src/core/canonical-scripts.ts +3 -1
- package/src/core/check-suite.ts +95 -33
- package/src/core/discover-checks.ts +30 -1
- package/src/core/floor.ts +21 -2
- package/src/core/locked-write.ts +170 -1
- package/src/core/one-way-in-policy.ts +183 -0
- package/src/core/run-ci.ts +42 -3
- package/src/core/run-fixers.ts +61 -18
- package/src/core/task-runner.ts +55 -6
- package/src/hooks/one-way-in-guard.ts +39 -0
- package/src/hooks/run-hooks.ts +23 -2
- package/src/init-repo.ts +19 -1
- package/src/ledger/gate-units.ts +58 -0
- package/src/ledger/git-key.ts +330 -0
- package/src/ledger/git-scan.ts +314 -0
- package/src/ledger/toolchain-identity.ts +15 -8
- package/src/release/run-bump.ts +50 -5
- package/src/release/unit-gate.ts +17 -4
- package/src/rules/SK001-package-json-shape.ts +10 -3
- package/src/rules/SK002-contracts-fulfillness.ts +4 -0
- package/src/rules/SK003-claude-md-shape.ts +7 -2
- package/src/rules/SK004-gitignore-policy.ts +11 -2
- package/src/rules/SK005-rvlib-versions.ts +28 -1
- package/src/rules/SK006-npm-publish-safety.ts +3 -0
- package/src/rules/SK008-reviewer-agent.ts +6 -2
- package/src/rules/SK009-commit-ai-tells.ts +3 -0
- package/src/rules/SK010-undeclared-release-unit.ts +2 -0
- package/src/rules/SK011-sibling-unit-ranges.ts +2 -0
- package/src/rules/SK012-config-barrel-import.ts +6 -2
- package/src/rules/SK013-one-way-in-policy.ts +156 -0
- package/src/rules/rule-registry.ts +2 -0
- package/src/rules/rule.ts +22 -1
- package/src/rules/run-fix.ts +5 -3
- package/src/scaffold/files/claude-settings-local.json +0 -3
- package/src/scaffold/files/verify-skill.md +1 -1
- package/src/ts/config.ts +66 -6
- package/src/ts/cycles.ts +7 -92
- package/src/ts/source-graph.ts +158 -0
- package/src/ts/tsconfig-projects.ts +101 -0
- package/src/ts/typecheck-cli.ts +46 -0
|
@@ -16,6 +16,12 @@
|
|
|
16
16
|
// never touch
|
|
17
17
|
// - package.json/lockfile git-dirty (or no git) → WARN deferred,
|
|
18
18
|
// a tool must not mix its writes into human-staged work
|
|
19
|
+
// - another run already holds the repo's mutate lease → the rule
|
|
20
|
+
// WAITS for it, re-reads the dirty probe inside the lease, and
|
|
21
|
+
// defers with a WARN if that run already did the work. Two
|
|
22
|
+
// `bun update --latest` calls against one node_modules is how a
|
|
23
|
+
// tree ends up half installed, and demoting the rule to report
|
|
24
|
+
// only instead would break "machine clearable red clears ITSELF"
|
|
19
25
|
// - the update itself failing splits by what it LEFT: a half-
|
|
20
26
|
// written package.json/lockfile is this repo and FAILs, an
|
|
21
27
|
// untouched tree is the registry and only WARNs
|
|
@@ -31,6 +37,7 @@
|
|
|
31
37
|
import { existsSync, readFileSync } from 'fs'
|
|
32
38
|
import { join } from 'path'
|
|
33
39
|
import type { CheckCtx } from '../core/check-ctx.ts'
|
|
40
|
+
import { mutateLease, withMutateLease } from '../core/locked-write.ts'
|
|
34
41
|
import type { Rule, RuleEnv } from './rule.ts'
|
|
35
42
|
|
|
36
43
|
type Pkg = { version?: string; dependencies?: Record<string, string>; devDependencies?: Record<string, string> }
|
|
@@ -105,7 +112,27 @@ export const rvlibVersions: Rule = {
|
|
|
105
112
|
return
|
|
106
113
|
}
|
|
107
114
|
const names = stale.map((s) => s.name)
|
|
108
|
-
|
|
115
|
+
// this rule WRITES, and three agents in one checkout is the normal
|
|
116
|
+
// case: two `bun update --latest` runs against one node_modules and
|
|
117
|
+
// one lockfile is how a tree ends up half installed. The lease is what
|
|
118
|
+
// lets the rule keep repairing itself instead of being demoted to
|
|
119
|
+
// report only, which would break "machine clearable red clears ITSELF".
|
|
120
|
+
// The dirty probe above is re-run INSIDE the lease, because the holder
|
|
121
|
+
// we waited for may have just done this exact update.
|
|
122
|
+
const leased = await withMutateLease(mutateLease(env.root, 'fix'), { label: 'self-update', print: () => {}, escape: 'SHIPKIT_FROZEN=1' }, async () => {
|
|
123
|
+
const again = await t.exec(['git', 'status', '--porcelain', '--', 'package.json', 'bun.lock', 'bun.lockb'], { cwd: env.root })
|
|
124
|
+
if (again.code === 0 && again.out.trim() !== '') return null
|
|
125
|
+
return await t.exec(['bun', 'update', '--latest', ...names], { cwd: env.root, timeoutMs: 120_000 })
|
|
126
|
+
})
|
|
127
|
+
if (!leased.ok) {
|
|
128
|
+
t.warn(`newer rvlib live: ${drift} — self-update deferred (${leased.warn}); re-run check once that run finishes`)
|
|
129
|
+
return
|
|
130
|
+
}
|
|
131
|
+
if (leased.value == null) {
|
|
132
|
+
t.warn(`newer rvlib live: ${drift} — self-update deferred (package.json/lockfile went dirty while waiting for the write lease); commit first, then re-run check`)
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
const up = leased.value
|
|
109
136
|
if (up.code !== 0) {
|
|
110
137
|
// which axis the failure sits on is measurable, so measure it: the
|
|
111
138
|
// tree is either half-written (this repo, blocks) or untouched, and
|
|
@@ -37,6 +37,9 @@ export const npmPublishSafety: Rule = {
|
|
|
37
37
|
blocking: true,
|
|
38
38
|
title: 'npm publish safety',
|
|
39
39
|
owner: 'meta/rv-conventions/package-json.md',
|
|
40
|
+
// pure filesystem: the root package.json plus every declared unit's, at
|
|
41
|
+
// dirs the config picks, so the tree rather than a list of paths
|
|
42
|
+
inputs: ['.'],
|
|
40
43
|
lint: (t, env) => {
|
|
41
44
|
const p = join(env.root, 'package.json')
|
|
42
45
|
if (!existsSync(p)) return t.ok('no package.json — nothing publishable')
|
|
@@ -7,11 +7,12 @@
|
|
|
7
7
|
// that edits by proxy. An absent `tools:` line is the worst case of all: the
|
|
8
8
|
// harness then inherits every tool. The brief itself must exist. No
|
|
9
9
|
// declaration = not a reviewer repo, and the rule passes silently.
|
|
10
|
-
import { existsSync, mkdirSync, readFileSync
|
|
10
|
+
import { existsSync, mkdirSync, readFileSync } from 'fs'
|
|
11
11
|
import { homedir } from 'os'
|
|
12
12
|
import { dirname, isAbsolute, join } from 'path'
|
|
13
13
|
import type { RepoConfig } from '../repo-config.ts'
|
|
14
14
|
import type { FixResult, Rule, RuleEnv } from './rule.ts'
|
|
15
|
+
import { atomicWrite } from '../core/locked-write.ts'
|
|
15
16
|
|
|
16
17
|
/** default read-only toolset — an ALLOWLIST, not a denylist: a tool that can
|
|
17
18
|
* edit by proxy (spawning a subagent, an MCP writer) is exactly what a
|
|
@@ -72,6 +73,9 @@ export const reviewerAgent: Rule = {
|
|
|
72
73
|
blocking: true,
|
|
73
74
|
title: 'reviewer agent',
|
|
74
75
|
owner: 'identity/how-we-work.md',
|
|
76
|
+
// the brief is a CONFIG path, and a `~/...` or absolute one puts it
|
|
77
|
+
// outside the repo, where no tree id can see whether it still exists
|
|
78
|
+
never: 'the reviewer brief may live outside the repo, where no tree id reaches it',
|
|
75
79
|
lint: (t, env) => {
|
|
76
80
|
const r = env.config.reviewer
|
|
77
81
|
if (r == null) return t.ok('no reviewer agent declared (repo.config reviewer:) — skipped')
|
|
@@ -93,7 +97,7 @@ export const reviewerAgent: Rule = {
|
|
|
93
97
|
const p = join(env.root, rel)
|
|
94
98
|
if (existsSync(p)) return { changed: false, summary: `${rel} exists — remaining findings are about its content, fix them by hand` }
|
|
95
99
|
mkdirSync(dirname(p), { recursive: true })
|
|
96
|
-
|
|
100
|
+
atomicWrite(p, canonicalAgent(r))
|
|
97
101
|
return { changed: true, summary: `${rel} written — points at ${r.brief}, no edit tools` }
|
|
98
102
|
},
|
|
99
103
|
}
|
|
@@ -29,6 +29,9 @@ export const commitAiTells: Rule = {
|
|
|
29
29
|
blocking: true,
|
|
30
30
|
title: 'commit ai tells',
|
|
31
31
|
owner: 'identity/how-we-commit.md',
|
|
32
|
+
// it scans the full `git log` and every ref name. History and refs are not
|
|
33
|
+
// work-tree content, so nothing a scan can produce describes this answer
|
|
34
|
+
never: 'reads the full commit history and every ref name, neither of which is work-tree content',
|
|
32
35
|
lint: async (t, env) => {
|
|
33
36
|
const declared = env.config.visibility ?? 'private'
|
|
34
37
|
if (declared !== 'public')
|
|
@@ -65,6 +65,8 @@ export const undeclaredReleaseUnit: Rule = {
|
|
|
65
65
|
blocking: true,
|
|
66
66
|
title: 'release units declared',
|
|
67
67
|
owner: 'build/shipkit.md',
|
|
68
|
+
// `git ls-files` plus a walk for every package.json in the repo
|
|
69
|
+
inputs: ['.'],
|
|
68
70
|
lint: (t, env) => {
|
|
69
71
|
const { units, errors } = resolveUnits(env.root, env.config)
|
|
70
72
|
// a DECLARED unit that does not resolve is a declaration bug and
|
|
@@ -59,6 +59,8 @@ export const siblingUnitRanges: Rule = {
|
|
|
59
59
|
blocking: true,
|
|
60
60
|
title: 'sibling unit ranges',
|
|
61
61
|
owner: 'build/shipkit.md',
|
|
62
|
+
// every declared unit's package.json, at dirs the config picks
|
|
63
|
+
inputs: ['.'],
|
|
62
64
|
lint: (t, env) => {
|
|
63
65
|
const { units } = resolveUnits(env.root, env.config)
|
|
64
66
|
const version = new Map(units.map((u) => [u.name, u.version]))
|
|
@@ -10,9 +10,10 @@
|
|
|
10
10
|
// A barrel is a fine PUBLIC surface and a terrible one to import from a config
|
|
11
11
|
// file that exists to be read before anything else happens. This rule is
|
|
12
12
|
// narrow on purpose: it is about repo.config.ts, not about barrels in general.
|
|
13
|
-
import { existsSync, readFileSync
|
|
13
|
+
import { existsSync, readFileSync } from 'fs'
|
|
14
14
|
import { join } from 'path'
|
|
15
15
|
import type { FixResult, Rule, RuleEnv } from './rule.ts'
|
|
16
|
+
import { atomicWrite } from '../core/locked-write.ts'
|
|
16
17
|
|
|
17
18
|
const CONFIG = 'repo.config.ts'
|
|
18
19
|
const BARREL = /(^|\n)(\s*import\s+(?:type\s+)?\{[^}]*\}\s+from\s+)['"]rvlib-shipkit['"]/
|
|
@@ -31,6 +32,9 @@ export const configBarrelImport: Rule = {
|
|
|
31
32
|
blocking: true,
|
|
32
33
|
title: 'config import path',
|
|
33
34
|
owner: 'build/shipkit.md',
|
|
35
|
+
// ONE file, and the one file shipkit guarantees exists: loadRepoConfig
|
|
36
|
+
// refuses a repo without it
|
|
37
|
+
inputs: ['repo.config.ts'],
|
|
34
38
|
lint: (t, env) => {
|
|
35
39
|
const p = join(env.root, CONFIG)
|
|
36
40
|
if (!existsSync(p)) return void t.ok(`${CONFIG} absent — nothing to narrow`)
|
|
@@ -54,7 +58,7 @@ export const configBarrelImport: Rule = {
|
|
|
54
58
|
// only rewrite what the narrow module actually exports: silently
|
|
55
59
|
// repointing an import it cannot serve turns a slow gate into a broken one
|
|
56
60
|
if (importedSymbols(line).some((s) => !CONFIG_SYMBOLS.has(s))) return { changed: false, summary: 'imports symbols the narrow module does not export — move them by hand' }
|
|
57
|
-
|
|
61
|
+
atomicWrite(p, src.replace(BARREL, `$1$2'${NARROW}'`))
|
|
58
62
|
return { changed: true, summary: `${CONFIG} now imports ${NARROW} (~400ms saved per run)` }
|
|
59
63
|
},
|
|
60
64
|
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// SK013 — the harness half of ONE WAY IN (build/shipkit.md). Rule SK001 routes
|
|
2
|
+
// the package.json scripts, and that alone is not enough: `bun test` is a bun
|
|
3
|
+
// BUILTIN, so no script can intercept it and the reflex stays open. This rule
|
|
4
|
+
// makes the repo carry the two things that CAN close it, and repairs them:
|
|
5
|
+
//
|
|
6
|
+
// - `permissions.deny` — declarative and unbypassable (deny beats allow at
|
|
7
|
+
// every settings level, and bypassPermissions does not lift it)
|
|
8
|
+
// - a PreToolUse hook — the only way to put a REASON in front of the model.
|
|
9
|
+
// A bare deny says only that the call was denied, and a refusal with no
|
|
10
|
+
// replacement sends the agent looking for another runner.
|
|
11
|
+
//
|
|
12
|
+
// Both are generated from ONE table (core/one-way-in-policy.ts). The file is
|
|
13
|
+
// TRACKED on purpose: `.claude/settings.local.json` is gitignored by the house
|
|
14
|
+
// scaffold, so a policy written there would bind exactly one checkout.
|
|
15
|
+
import { existsSync, mkdirSync, readFileSync } from 'fs'
|
|
16
|
+
import { dirname, join } from 'path'
|
|
17
|
+
import { inferBin } from '../core/canonical-scripts.ts'
|
|
18
|
+
import { denyRules, RAW_RUNNERS } from '../core/one-way-in-policy.ts'
|
|
19
|
+
import type { RepoConfig } from '../repo-config.ts'
|
|
20
|
+
import type { FixResult, Rule, RuleEnv } from './rule.ts'
|
|
21
|
+
import { atomicWrite } from '../core/locked-write.ts'
|
|
22
|
+
|
|
23
|
+
const OWNER = 'build/shipkit.md'
|
|
24
|
+
export const SETTINGS_PATH = '.claude/settings.json'
|
|
25
|
+
|
|
26
|
+
type Settings = {
|
|
27
|
+
permissions?: { deny?: string[] }
|
|
28
|
+
hooks?: Record<string, Array<{ matcher?: string; hooks?: Array<{ type?: string; command?: string }> }>>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** slots this repo does not route, so it must not be refused the shell command
|
|
32
|
+
* either: ONE declaration governs the script name and the raw call together */
|
|
33
|
+
export function exemptSlots(c: RepoConfig): Set<string> {
|
|
34
|
+
const off = new Set(c.oneWayIn?.exempt ?? [])
|
|
35
|
+
for (const [slot, def] of Object.entries(c.defaults ?? {})) if (def === false) off.add(slot)
|
|
36
|
+
return off
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** how the hook invokes the guard. `$CLAUDE_PROJECT_DIR` because a hook's cwd
|
|
40
|
+
* is not promised to be the repo root */
|
|
41
|
+
export function guardCommand(bin?: string): string {
|
|
42
|
+
const path = bin == null
|
|
43
|
+
? 'node_modules/rvlib-shipkit/src/hooks/one-way-in-guard.ts'
|
|
44
|
+
: bin.replace(/cli\.ts$/, 'hooks/one-way-in-guard.ts')
|
|
45
|
+
return `bun "$CLAUDE_PROJECT_DIR/${path}"`
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** a guard under `node_modules/` that is not there yet says the dependency is
|
|
49
|
+
* not installed, which `bun install` fixes and no edit to this repo can. Any
|
|
50
|
+
* other unresolvable path is a config this repo chose and got wrong. */
|
|
51
|
+
const isUninstalledDep = (rel: string): boolean => rel.startsWith('node_modules/')
|
|
52
|
+
|
|
53
|
+
const readSettings = (root: string): Settings | null => {
|
|
54
|
+
const p = join(root, SETTINGS_PATH)
|
|
55
|
+
if (!existsSync(p)) return null
|
|
56
|
+
try { return JSON.parse(readFileSync(p, 'utf8')) as Settings } catch { return null }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const guardHookCommand = (s: Settings): string | null => {
|
|
60
|
+
for (const m of s.hooks?.['PreToolUse'] ?? [])
|
|
61
|
+
for (const h of m.hooks ?? [])
|
|
62
|
+
if (h.command?.includes('one-way-in-guard') === true) return h.command
|
|
63
|
+
return null
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const hasGuardHook = (s: Settings): boolean => guardHookCommand(s) != null
|
|
67
|
+
|
|
68
|
+
/** the guard file the hook command points at, resolved against the repo root.
|
|
69
|
+
* A hook whose script does not exist errors on EVERY Bash call and refuses
|
|
70
|
+
* nothing, so the agent sees only the bare deny, which is the one failure
|
|
71
|
+
* this rule exists to prevent. Declaring it and never running it is worse
|
|
72
|
+
* than not declaring it, so it FAILs. */
|
|
73
|
+
function guardScriptMissing(root: string, command: string): string | null {
|
|
74
|
+
const m = /["']?\$(?:\{)?CLAUDE_PROJECT_DIR(?:\})?\/([^"'\s]+)["']?/.exec(command)
|
|
75
|
+
const rel = m?.[1]
|
|
76
|
+
if (rel == null) return null // a form this rule cannot resolve is not a form it may condemn
|
|
77
|
+
return existsSync(join(root, rel)) ? null : rel
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const readBin = (root: string): string | undefined => {
|
|
81
|
+
try { return inferBin((JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { scripts?: Record<string, string> }).scripts ?? {}) }
|
|
82
|
+
catch { return undefined }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export const oneWayInPolicy: Rule = {
|
|
86
|
+
id: 'SK013',
|
|
87
|
+
blocking: true,
|
|
88
|
+
title: 'one way in policy',
|
|
89
|
+
owner: OWNER,
|
|
90
|
+
// .claude/settings.json, package.json, and whether the declared guard
|
|
91
|
+
// script resolves — the last one is a config-chosen path
|
|
92
|
+
inputs: ['.'],
|
|
93
|
+
lint: (t, env) => {
|
|
94
|
+
const want = denyRules(exemptSlots(env.config))
|
|
95
|
+
if (want.length === 0) return t.ok('every governed slot is exempt (repo.config oneWayIn/defaults) — nothing to deny')
|
|
96
|
+
const s = readSettings(env.root)
|
|
97
|
+
if (s == null) {
|
|
98
|
+
t.fail(`${SETTINGS_PATH} ${existsSync(join(env.root, SETTINGS_PATH)) ? 'is unparseable' : 'missing'} — nothing stops an agent typing \`bun test\`, and a package.json script cannot intercept a bun builtin`)
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
const have = new Set(s.permissions?.deny ?? [])
|
|
102
|
+
const missing = want.filter((r) => !have.has(r))
|
|
103
|
+
if (missing.length > 0)
|
|
104
|
+
t.fail(`${SETTINGS_PATH}: ${missing.length} raw runner(s) still permitted (${missing.slice(0, 3).join(', ')}${missing.length > 3 ? ', …' : ''}) — those calls never reach shipkit, so nothing is cached and no run can be attached to`)
|
|
105
|
+
const guard = guardHookCommand(s)
|
|
106
|
+
if (guard == null)
|
|
107
|
+
t.fail(`${SETTINGS_PATH}: no PreToolUse guard — a bare deny tells the model only that it was denied, so it goes looking for another runner instead of the shipkit command`)
|
|
108
|
+
else {
|
|
109
|
+
const missing = guardScriptMissing(env.root, guard)
|
|
110
|
+
// the hook is DECLARED either way, so it starts working the moment
|
|
111
|
+
// the dependency lands: no second fix run, nothing to remember. An
|
|
112
|
+
// uninstalled dependency is therefore a WARN naming the command
|
|
113
|
+
// that fixes it, not a red gate on a repo nobody has installed yet,
|
|
114
|
+
// and the deny list is the floor throughout. A path this repo chose
|
|
115
|
+
// and got wrong is still a FAIL: it will never resolve on its own.
|
|
116
|
+
if (missing != null && isUninstalledDep(missing))
|
|
117
|
+
t.warn(
|
|
118
|
+
`${SETTINGS_PATH}: the PreToolUse guard points at ${missing}, which is not installed yet — until then a refusal names no replacement, and the deny list is the whole floor`,
|
|
119
|
+
`bun install (or bun add -d rvlib-shipkit)`,
|
|
120
|
+
)
|
|
121
|
+
else if (missing != null)
|
|
122
|
+
t.fail(
|
|
123
|
+
`${SETTINGS_PATH}: the PreToolUse guard points at ${missing}, which does not exist — every Bash call errors and no refusal ever names the shipkit command`,
|
|
124
|
+
`point it at this repo's own copy of hooks/one-way-in-guard.ts, or delete the hook and let the deny list stand alone`,
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
if (!t.failed) t.ok(`${SETTINGS_PATH} closes ${want.length}/${RAW_RUNNERS.length} raw runner(s), with the replacement named`)
|
|
128
|
+
},
|
|
129
|
+
// MERGES, never rewrites: this file also carries the attribution silencer,
|
|
130
|
+
// a repo's own allow list and its own hooks, and clobbering those to fix a
|
|
131
|
+
// deny list would cost more than the finding it repairs
|
|
132
|
+
fix: (env: RuleEnv): FixResult => {
|
|
133
|
+
const want = denyRules(exemptSlots(env.config))
|
|
134
|
+
if (want.length === 0) return { changed: false, summary: 'every governed slot is exempt — nothing to write' }
|
|
135
|
+
const p = join(env.root, SETTINGS_PATH)
|
|
136
|
+
const existing = readSettings(env.root)
|
|
137
|
+
if (existing == null && existsSync(p)) return { changed: false, summary: `${SETTINGS_PATH} is unparseable — repair it by hand first, a merge would destroy it` }
|
|
138
|
+
const s: Settings = existing ?? {}
|
|
139
|
+
const changes: string[] = []
|
|
140
|
+
const deny = s.permissions?.deny ?? []
|
|
141
|
+
const added = want.filter((r) => !deny.includes(r))
|
|
142
|
+
if (added.length > 0) {
|
|
143
|
+
s.permissions = { ...s.permissions, deny: [...deny, ...added] }
|
|
144
|
+
changes.push(`${added.length} raw runner(s) denied`)
|
|
145
|
+
}
|
|
146
|
+
if (!hasGuardHook(s)) {
|
|
147
|
+
const pre = s.hooks?.['PreToolUse'] ?? []
|
|
148
|
+
s.hooks = { ...s.hooks, PreToolUse: [...pre, { matcher: 'Bash', hooks: [{ type: 'command', command: guardCommand(readBin(env.root)) }] }] }
|
|
149
|
+
changes.push('PreToolUse guard wired (every refusal names the shipkit command)')
|
|
150
|
+
}
|
|
151
|
+
if (changes.length === 0) return { changed: false, summary: `${SETTINGS_PATH} already closes every raw runner` }
|
|
152
|
+
mkdirSync(dirname(p), { recursive: true })
|
|
153
|
+
atomicWrite(p, JSON.stringify(s, null, 2) + '\n')
|
|
154
|
+
return { changed: true, summary: `${SETTINGS_PATH}: ${changes.join(', ')}` }
|
|
155
|
+
},
|
|
156
|
+
}
|
|
@@ -15,6 +15,7 @@ import { commitAiTells } from './SK009-commit-ai-tells.ts'
|
|
|
15
15
|
import { undeclaredReleaseUnit } from './SK010-undeclared-release-unit.ts'
|
|
16
16
|
import { siblingUnitRanges } from './SK011-sibling-unit-ranges.ts'
|
|
17
17
|
import { configBarrelImport } from './SK012-config-barrel-import.ts'
|
|
18
|
+
import { oneWayInPolicy } from './SK013-one-way-in-policy.ts'
|
|
18
19
|
|
|
19
20
|
export const RULES: readonly Rule[] = [
|
|
20
21
|
packageJsonShape,
|
|
@@ -29,6 +30,7 @@ export const RULES: readonly Rule[] = [
|
|
|
29
30
|
undeclaredReleaseUnit,
|
|
30
31
|
siblingUnitRanges,
|
|
31
32
|
configBarrelImport,
|
|
33
|
+
oneWayInPolicy,
|
|
32
34
|
]
|
|
33
35
|
|
|
34
36
|
// THE id shape, one place: 2-4 uppercase letters + exactly 3 digits. `SK` is
|
package/src/rules/rule.ts
CHANGED
|
@@ -28,8 +28,29 @@ export type Rule = {
|
|
|
28
28
|
* as the fix: pointer whenever a failing/warning lint attached none */
|
|
29
29
|
owner: string
|
|
30
30
|
/** clean-pass cache duration (check-suite TTL, e.g. '24h') — for rules that
|
|
31
|
-
* probe the network; skip/WARN/FAIL runs are never cached
|
|
31
|
+
* probe the network; skip/WARN/FAIL runs are never cached. Declares the
|
|
32
|
+
* WORLD population (ledger/git-key.ts). */
|
|
32
33
|
ttl?: string
|
|
34
|
+
/** CONTENT population: the repo-relative paths this rule reads. A rule with
|
|
35
|
+
* neither `inputs` nor `ttl` is never cached, which is the safe default.
|
|
36
|
+
* A rule declared by a REPO rather than by shipkit must list the dir its
|
|
37
|
+
* own code lives in too: shipkit's `src` tree id covers the built-ins and
|
|
38
|
+
* cannot see a repo's. Declaring both `inputs` and `ttl` is a registration
|
|
39
|
+
* failure — a rule reads files or it reads the world, never both. */
|
|
40
|
+
inputs?: string[]
|
|
41
|
+
/** repo-relative entry file(s) of this rule's OWN code, walked through the
|
|
42
|
+
* module graph so a shared helper the rule imports is in its key too. A
|
|
43
|
+
* built-in SK rule needs none: the toolchain identity content-hashes
|
|
44
|
+
* shipkit's whole source, so editing any of it moves every key. A rule a
|
|
45
|
+
* REPO declares is invisible to that, so it names its module here, or
|
|
46
|
+
* includes the dir holding it in `inputs`. Without either, fixing a bug
|
|
47
|
+
* in the rule does not move its key. */
|
|
48
|
+
code?: string[]
|
|
49
|
+
/** a DECIDED never-cacheable, with the reason: this rule reads git history,
|
|
50
|
+
* a path that may live outside the repo, or the wall clock, so no key can
|
|
51
|
+
* describe it. Says somebody looked, which is what takes it off the
|
|
52
|
+
* burn-down list. */
|
|
53
|
+
never?: string
|
|
33
54
|
/** cache-identity contribution for TTL'd rules whose verdict depends on
|
|
34
55
|
* config — a changed key busts the cache instantly instead of replaying
|
|
35
56
|
* a stale assertion for the TTL */
|
package/src/rules/run-fix.ts
CHANGED
|
@@ -47,9 +47,11 @@ export async function runFix(root: string, o: FixOpts = {}): Promise<number> {
|
|
|
47
47
|
continue
|
|
48
48
|
}
|
|
49
49
|
const f = await rule.fix!(env)
|
|
50
|
-
// an unchanged fix is the DESIGNED outcome for judgment findings
|
|
51
|
-
//
|
|
52
|
-
|
|
50
|
+
// an unchanged fix is the DESIGNED outcome for judgment findings, so
|
|
51
|
+
// it is an ok, not a warning. It carries a frozen wire prefix like
|
|
52
|
+
// every other line the fix pass emits: `shipkit ci` promises its whole
|
|
53
|
+
// output is parseable, and a bare line breaks that for every reader.
|
|
54
|
+
print(f.changed ? `ok: [${rule.id}] fixed: ${f.summary}` : `ok: [${rule.id}] fix changed nothing — ${f.summary}`)
|
|
53
55
|
const after = new CheckCtx(new Map())
|
|
54
56
|
await runRuleLint(rule, after, env)
|
|
55
57
|
// the fix already ran: advertising it again on surviving findings
|
|
@@ -3,10 +3,8 @@
|
|
|
3
3
|
"allow": [
|
|
4
4
|
"Bash(shipkit *)",
|
|
5
5
|
"Bash(bun run *)",
|
|
6
|
-
"Bash(bun test*)",
|
|
7
6
|
"Bash(bun install*)",
|
|
8
7
|
"Bash(bun add *)",
|
|
9
|
-
"Bash(bunx tsc*)",
|
|
10
8
|
"Bash(bunx wrangler*)",
|
|
11
9
|
"Bash(git status*)",
|
|
12
10
|
"Bash(git diff*)",
|
|
@@ -15,7 +13,6 @@
|
|
|
15
13
|
"Bash(git commit*)",
|
|
16
14
|
"Bash(git push*)",
|
|
17
15
|
"Bash(curl *)",
|
|
18
|
-
"Bash(oxlint*)",
|
|
19
16
|
"Bash(./deploy.sh)"
|
|
20
17
|
],
|
|
21
18
|
"deny": []
|
|
@@ -11,7 +11,7 @@ description: Prove a change in THIS project actually works — drive the real fl
|
|
|
11
11
|
|
|
12
12
|
## The rules (house-wide)
|
|
13
13
|
- Failing repro test BEFORE the fix. Green checks ≠ verification.
|
|
14
|
-
- Logic → fast headless
|
|
14
|
+
- Logic → fast headless bun tests, run through `shipkit ci --only test` (tiny ~10-line test files reusing fixtures). While you fix one bug, `bun run test:raw <test file>` is the narrow way in, and one test by name is `bun run test:raw -- -t "<name>"`.
|
|
15
15
|
- Look & feel → NEVER screenshot-self-verify; the repo owner playtests it.
|
|
16
16
|
- Verify as the end user would: drive the real flow, observe the real signal.
|
|
17
17
|
|
package/src/ts/config.ts
CHANGED
|
@@ -40,12 +40,72 @@ export function findRepoRoot(cwd: string = process.cwd()): string {
|
|
|
40
40
|
return out !== '' ? out : cwd
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
// tsconfig files are JSONC
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
43
|
+
// tsconfig files are JSONC: strip comments and trailing commas.
|
|
44
|
+
//
|
|
45
|
+
// A quote-aware scanner rather than regexes, because tsconfig is the one
|
|
46
|
+
// dialect where the regex version is guaranteed to be wrong: `"src/**/*"` is
|
|
47
|
+
// a `/*` with its own `*/`, so a block-comment regex eats an arbitrary span of
|
|
48
|
+
// a perfectly valid file. Measured across ~/dev, the regex version THREW on 8
|
|
49
|
+
// real tsconfigs (this repo's included) and silently rewrote `"src/**/*.ts"`
|
|
50
|
+
// to `"src*.ts"` in 8 more. This parser gates typecheck AND publish, so it
|
|
51
|
+
// does not get to be loose.
|
|
52
|
+
export function parseJsonc(text: string): unknown {
|
|
53
|
+
let out = ''
|
|
54
|
+
let i = 0
|
|
55
|
+
while (i < text.length) {
|
|
56
|
+
const c = text[i]!
|
|
57
|
+
if (c === '"') {
|
|
58
|
+
out += c
|
|
59
|
+
i++
|
|
60
|
+
while (i < text.length) {
|
|
61
|
+
const s = text[i]!
|
|
62
|
+
out += s
|
|
63
|
+
i++
|
|
64
|
+
if (s === '\\') { out += text[i] ?? ''; i++; continue }
|
|
65
|
+
if (s === '"') break
|
|
66
|
+
}
|
|
67
|
+
continue
|
|
68
|
+
}
|
|
69
|
+
if (c === '/' && text[i + 1] === '/') { while (i < text.length && text[i] !== '\n') i++; continue }
|
|
70
|
+
if (c === '/' && text[i + 1] === '*') {
|
|
71
|
+
i += 2
|
|
72
|
+
while (i < text.length && !(text[i] === '*' && text[i + 1] === '/')) i++
|
|
73
|
+
i += 2
|
|
74
|
+
continue
|
|
75
|
+
}
|
|
76
|
+
out += c
|
|
77
|
+
i++
|
|
78
|
+
}
|
|
79
|
+
return JSON.parse(dropTrailingCommas(out))
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** a comma before the next `}` or `]`, outside any string, is dropped */
|
|
83
|
+
function dropTrailingCommas(text: string): string {
|
|
84
|
+
let out = ''
|
|
85
|
+
let i = 0
|
|
86
|
+
let pendingComma = -1 // index in `out` of a comma awaiting its next token
|
|
87
|
+
while (i < text.length) {
|
|
88
|
+
const c = text[i]!
|
|
89
|
+
if (c === '"') {
|
|
90
|
+
if (pendingComma >= 0) pendingComma = -1
|
|
91
|
+
out += c
|
|
92
|
+
i++
|
|
93
|
+
while (i < text.length) {
|
|
94
|
+
const s = text[i]!
|
|
95
|
+
out += s
|
|
96
|
+
i++
|
|
97
|
+
if (s === '\\') { out += text[i] ?? ''; i++; continue }
|
|
98
|
+
if (s === '"') break
|
|
99
|
+
}
|
|
100
|
+
continue
|
|
101
|
+
}
|
|
102
|
+
if (c === ',') { pendingComma = out.length; out += c; i++; continue }
|
|
103
|
+
if ((c === '}' || c === ']') && pendingComma >= 0) out = out.slice(0, pendingComma) + out.slice(pendingComma + 1)
|
|
104
|
+
if (!/\s/.test(c)) pendingComma = -1
|
|
105
|
+
out += c
|
|
106
|
+
i++
|
|
107
|
+
}
|
|
108
|
+
return out
|
|
49
109
|
}
|
|
50
110
|
|
|
51
111
|
function rootsFromTsconfig(root: string): string[] {
|
package/src/ts/cycles.ts
CHANGED
|
@@ -1,87 +1,14 @@
|
|
|
1
1
|
// zero-dep circular import detector. The repo uses absolute repo-root import
|
|
2
2
|
// specifiers ('src/...') with explicit .ts extensions, so resolution is string
|
|
3
|
-
// identity
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import { templateLineMask } from 'rvlib-shipkit/src/ts/source-mask.ts'
|
|
3
|
+
// identity. The crawl, the specifier extraction and the resolution rule live
|
|
4
|
+
// in source-graph.ts (shared with the ledger key layer, so a cycle and a cache
|
|
5
|
+
// key can never disagree about where an import points); this file owns the
|
|
6
|
+
// tarjan + elementary-cycle half. Runs on a bare checkout, no install needed.
|
|
7
|
+
import { loadConfig, type RvtConfig } from 'rvlib-shipkit/src/ts/config.ts'
|
|
8
|
+
import { buildSourceGraph } from 'rvlib-shipkit/src/ts/source-graph.ts'
|
|
10
9
|
|
|
11
10
|
const CYCLE_CAP = 500 // bail loudly instead of exploding on a pathological graph
|
|
12
11
|
|
|
13
|
-
function crawl(cfg: RvtConfig, dir: string, out: string[]): void {
|
|
14
|
-
let entries
|
|
15
|
-
try {
|
|
16
|
-
entries = readdirSync(join(cfg.root, dir), { withFileTypes: true })
|
|
17
|
-
} catch {
|
|
18
|
-
return
|
|
19
|
-
}
|
|
20
|
-
for (const entry of entries) {
|
|
21
|
-
const rel = `${dir}/${entry.name}`
|
|
22
|
-
if (entry.isDirectory()) {
|
|
23
|
-
if (!isSkipped(rel, cfg)) crawl(cfg, rel, out)
|
|
24
|
-
} else if (/\.tsx?$/.test(entry.name) && !entry.name.endsWith('.d.ts')) {
|
|
25
|
-
out.push(rel)
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
// strip comments (line comments only when preceded by whitespace, so
|
|
31
|
-
// 'http://...' survives) and type-only import/export statements, then extract
|
|
32
|
-
// the remaining runtime specifiers
|
|
33
|
-
export function extractSpecs(content: string): string[] {
|
|
34
|
-
// template-literal content is text, a codegen emitting import lines inside
|
|
35
|
-
// a backtick block is not an import of this module. single-line templates
|
|
36
|
-
// strip in place, multiline ones blank via the shared mask (null mask =
|
|
37
|
-
// desynced walk, scan everything rather than eat code between backticks)
|
|
38
|
-
const rawLines = content.split('\n')
|
|
39
|
-
const mask = templateLineMask(rawLines)
|
|
40
|
-
const demasked = mask == null ? content : rawLines.map((l, i) => (mask[i] ? '' : l)).join('\n')
|
|
41
|
-
const cleaned = demasked
|
|
42
|
-
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
43
|
-
.replace(/(^|\s)\/\/[^\n]*/g, '$1')
|
|
44
|
-
.replace(/`(?:\\.|[^`\\\n])*`/g, '``')
|
|
45
|
-
.replace(/\b(?:import|export)\s+type\s[^'"]*?from\s*(['"])[^'"\n]*\1/g, '')
|
|
46
|
-
const specs: string[] = []
|
|
47
|
-
// import x from / export { x } from — skipped when every specifier is `type X` (erased at runtime)
|
|
48
|
-
for (const m of cleaned.matchAll(/\b(?:import|export)\s([^'";]*?)from\s*['"]([^'"\n]+)['"]/g)) {
|
|
49
|
-
const clause = (m[1] ?? '').trim()
|
|
50
|
-
if (/^\{[^{}]*\}$/.test(clause)) {
|
|
51
|
-
const items = clause.slice(1, -1).split(',').map((s) => s.trim()).filter((s) => s !== '')
|
|
52
|
-
if (items.length > 0 && items.every((s) => /^type\s/.test(s))) continue
|
|
53
|
-
}
|
|
54
|
-
if (m[2] != null) specs.push(m[2])
|
|
55
|
-
}
|
|
56
|
-
for (const re of [
|
|
57
|
-
/\bimport\s*['"]([^'"\n]+)['"]/g, // side-effect import
|
|
58
|
-
/\b(?:import|require)\s*\(\s*['"]([^'"\n]+)['"]\s*\)/g, // dynamic import / require
|
|
59
|
-
]) {
|
|
60
|
-
for (const m of cleaned.matchAll(re)) {
|
|
61
|
-
if (m[1] != null) specs.push(m[1])
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
return specs
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
function resolveSpec(cfg: RvtConfig, fromFile: string, spec: string, fileSet: Set<string>): string | null {
|
|
68
|
-
let base: string
|
|
69
|
-
const viaPackage = resolvePackageSpecifier(spec, cfg)
|
|
70
|
-
if (spec.startsWith('./') || spec.startsWith('../')) {
|
|
71
|
-
base = toPosix(join(dirname(fromFile), spec))
|
|
72
|
-
} else if (viaPackage != null) {
|
|
73
|
-
base = viaPackage // package-name specifier → the sub-package's dir
|
|
74
|
-
} else if (cfg.roots.some((r) => spec.startsWith(`${r}/`))) {
|
|
75
|
-
base = spec
|
|
76
|
-
} else {
|
|
77
|
-
return null // third-party
|
|
78
|
-
}
|
|
79
|
-
for (const candidate of [base, base.replace(/\.js$/, '.ts'), `${base}.ts`, `${base}.tsx`, `${base}/index.ts`, `${base}/index.tsx`]) {
|
|
80
|
-
if (fileSet.has(candidate)) return candidate
|
|
81
|
-
}
|
|
82
|
-
return null
|
|
83
|
-
}
|
|
84
|
-
|
|
85
12
|
// tarjan strongly connected components, iterative to survive deep graphs
|
|
86
13
|
function tarjanSCCs(nodes: string[], adj: Map<string, string[]>): string[][] {
|
|
87
14
|
const index = new Map<string, number>()
|
|
@@ -166,19 +93,7 @@ function cyclesInSCC(scc: string[], adj: Map<string, string[]>): string[][] {
|
|
|
166
93
|
}
|
|
167
94
|
|
|
168
95
|
export function findCircularDependencies(cfg: RvtConfig = loadConfig()): string[][] {
|
|
169
|
-
const files
|
|
170
|
-
for (const root of cfg.roots) crawl(cfg, root, files)
|
|
171
|
-
files.sort()
|
|
172
|
-
const fileSet = new Set(files)
|
|
173
|
-
const adj = new Map<string, string[]>()
|
|
174
|
-
for (const file of files) {
|
|
175
|
-
const targets = new Set<string>()
|
|
176
|
-
for (const spec of extractSpecs(readFileSync(join(cfg.root, file), 'utf8'))) {
|
|
177
|
-
const target = resolveSpec(cfg, file, spec, fileSet)
|
|
178
|
-
if (target != null && target !== file) targets.add(target)
|
|
179
|
-
}
|
|
180
|
-
adj.set(file, [...targets].sort())
|
|
181
|
-
}
|
|
96
|
+
const { files, adj } = buildSourceGraph(cfg)
|
|
182
97
|
const cycles: string[][] = []
|
|
183
98
|
for (const scc of tarjanSCCs(files, adj)) {
|
|
184
99
|
if (scc.length < 2) continue
|