rvlib-shipkit 0.31.0 → 0.33.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 +18 -0
- package/README.md +3 -0
- package/package.json +4 -1
- package/src/cli/cmd_Bump.ts +2 -0
- package/src/cli/cmd_Ci.ts +3 -1
- package/src/cli/cmd_Pb.ts +27 -0
- package/src/cli/cmd_Release.ts +1 -1
- package/src/cli/cmd_Wiki.ts +2 -2
- package/src/cli.ts +49 -39
- package/src/collections/providers.ts +7 -2
- package/src/contracts/contracts-once.ts +43 -0
- package/src/core/canonical-scripts.ts +35 -0
- package/src/core/check-ctx.ts +13 -0
- package/src/core/check-suite.ts +121 -53
- package/src/core/floor.ts +82 -0
- package/src/core/motherhome.ts +14 -1
- package/src/core/one-way-in-policy.ts +183 -0
- package/src/core/run-check.ts +34 -0
- package/src/core/run-ci.ts +80 -4
- package/src/core/run-fixers.ts +6 -1
- package/src/hooks/one-way-in-guard.ts +39 -0
- package/src/init-repo.ts +19 -1
- package/src/ledger/toolchain-identity.ts +134 -0
- package/src/pb/collection-scaffold.ts +299 -0
- package/src/pb/pb-types.ts +233 -0
- package/src/pb/run-pb.ts +157 -0
- package/src/release/git-facts.ts +3 -2
- package/src/release/release-scope.ts +52 -0
- package/src/release/releases-share.ts +85 -0
- package/src/release/run-bump.ts +31 -8
- package/src/release/run-release.ts +8 -1
- package/src/release/run-releases.ts +20 -6
- package/src/repo-config.ts +22 -65
- package/src/rules/SK001-package-json-shape.ts +65 -1
- package/src/rules/SK002-contracts-fulfillness.ts +2 -2
- package/src/rules/SK004-gitignore-policy.ts +27 -3
- package/src/rules/SK012-config-barrel-import.ts +60 -0
- package/src/rules/SK013-one-way-in-policy.ts +136 -0
- package/src/rules/rule-registry.ts +4 -0
- package/src/rules/untrack-generated.ts +117 -0
- package/src/scaffold/files/claude-settings-local.json +0 -3
- package/src/scaffold/files/verify-skill.md +1 -1
- package/src/scaffold/project-scaffold.ts +1 -1
- package/src/serve/hub-port.ts +17 -0
- package/src/serve/run-serve.ts +2 -5
- package/src/wiki/run-wiki.ts +18 -8
|
@@ -2,10 +2,17 @@
|
|
|
2
2
|
// private:true, no license, capability-required script names FAIL, banned
|
|
3
3
|
// aliases + missing baseline scripts WARN. Blocking, so it runs in `shipkit
|
|
4
4
|
// ci` like every other rule. Pure local fs, no TTL.
|
|
5
|
+
//
|
|
6
|
+
// It also enforces the package.json half of ONE WAY IN (build/shipkit.md):
|
|
7
|
+
// `test`, `lint` and `typecheck` ROUTE to `shipkit ci --only <id>` and the
|
|
8
|
+
// tool body they used to hold sits at `<id>:raw`. That is what makes a cache
|
|
9
|
+
// worth having: `bun run test` is the reflex, and the reflex has to land in
|
|
10
|
+
// shipkit rather than beside it.
|
|
5
11
|
import { existsSync, readdirSync, readFileSync, writeFileSync } from 'fs'
|
|
6
12
|
import { join } from 'path'
|
|
7
|
-
import { canonicalBodies, conditionalBodies, inferBin } from '../core/canonical-scripts.ts'
|
|
13
|
+
import { canonicalBodies, conditionalBodies, inferBin, ROUTED_SCRIPTS, rawName, routedBody, routesToShipkit } from '../core/canonical-scripts.ts'
|
|
8
14
|
import type { CheckCtx } from '../core/check-ctx.ts'
|
|
15
|
+
import { resolveFloor } from '../core/floor.ts'
|
|
9
16
|
import type { RepoConfig } from '../repo-config.ts'
|
|
10
17
|
import type { FixResult, Rule, RuleEnv } from './rule.ts'
|
|
11
18
|
|
|
@@ -68,6 +75,23 @@ export async function evaluatePackageJson(t: CheckCtx, root: string, c: RepoConf
|
|
|
68
75
|
const missing = required.filter((r) => !has(r.name))
|
|
69
76
|
for (const m of missing) t.fail(`package.json: missing script '${m.name}' (${m.why})`)
|
|
70
77
|
|
|
78
|
+
// ONE WAY IN — a governed script that runs the tool directly is the whole
|
|
79
|
+
// reason a cache gets bypassed: an agent types `bun run test` and shipkit
|
|
80
|
+
// never hears about it. A slot the repo turned off has no shipkit task to
|
|
81
|
+
// route to, so it is not governed.
|
|
82
|
+
const floor = resolveFloor(c, root)
|
|
83
|
+
for (const id of ROUTED_SCRIPTS) {
|
|
84
|
+
if (routedExempt(c).has(id) || c.defaults?.[id] === false) continue
|
|
85
|
+
const body = scripts[id]
|
|
86
|
+
if (body == null) continue
|
|
87
|
+
if (!routesToShipkit(body)) {
|
|
88
|
+
t.fail(`package.json: script '${id}' runs the tool directly — route it to \`${routedBody(id, { bin: inferBin(scripts) })}\` and move the body to '${rawName(id)}', so \`bun run ${id}\` gets the cache instead of bypassing it`)
|
|
89
|
+
continue
|
|
90
|
+
}
|
|
91
|
+
const gap = floor.unfilled.find((u) => u.id === id)
|
|
92
|
+
if (gap != null) t.fail(`package.json: ${gap.reason} — \`bun run ${id}\` would measure nothing`)
|
|
93
|
+
}
|
|
94
|
+
|
|
71
95
|
// `test` is asked for only once the repo HAS tests: the fixer deliberately
|
|
72
96
|
// refuses to scaffold it before that (a fixer never manufactures a
|
|
73
97
|
// failure), so nagging for it would be a WARN no machine may clear
|
|
@@ -96,6 +120,29 @@ function requiredScripts(root: string, _c: RepoConfig): Array<{ name: string; wh
|
|
|
96
120
|
return required
|
|
97
121
|
}
|
|
98
122
|
|
|
123
|
+
/** rebuild the script map with `key` inserted directly after `after`, so a
|
|
124
|
+
* routed pair reads as two adjacent lines instead of the tool body landing at
|
|
125
|
+
* the bottom of the file, far from the name it belongs to */
|
|
126
|
+
function insertAfter(scripts: Record<string, string>, after: string, key: string, value: string): Record<string, string> {
|
|
127
|
+
const out: Record<string, string> = {}
|
|
128
|
+
for (const [k, v] of Object.entries(scripts)) {
|
|
129
|
+
if (k === key) continue
|
|
130
|
+
out[k] = v
|
|
131
|
+
if (k === after) out[key] = value
|
|
132
|
+
}
|
|
133
|
+
if (out[key] == null) out[key] = value
|
|
134
|
+
return out
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** governed names this repo keeps as a direct tool call. Declared per NAME in
|
|
138
|
+
* repo.config, never as a list of banned binaries: the invariant is about
|
|
139
|
+
* which VERBS an agent reaches for, and a binary blocklist goes stale the day
|
|
140
|
+
* a repo changes runner. `defineRepo` rejects a name outside ROUTED_SCRIPTS,
|
|
141
|
+
* so a typo can never silently un-govern a script. */
|
|
142
|
+
function routedExempt(c: RepoConfig): Set<string> {
|
|
143
|
+
return new Set(c.oneWayIn?.exempt ?? [])
|
|
144
|
+
}
|
|
145
|
+
|
|
99
146
|
const TEST_SKIP = new Set(['node_modules', 'dist', 'tmp', 'references', 'coverage'])
|
|
100
147
|
|
|
101
148
|
/** any `*.test.ts` in the repo, so the test script is only scaffolded once it
|
|
@@ -164,6 +211,23 @@ export const packageJsonShape: Rule = {
|
|
|
164
211
|
// none, so scaffolding this into a repo without any turns the gate red
|
|
165
212
|
// on work the fixer itself did. a fixer must never manufacture a failure.
|
|
166
213
|
if (pkg.scripts['test'] == null && hasTestFiles(env.root)) { pkg.scripts['test'] = 'bun test'; changes.push(`script 'test' added`) }
|
|
214
|
+
// ONE WAY IN, last so it also routes whatever this pass just added:
|
|
215
|
+
// the tool body slides to `<id>:raw` and `<id>` becomes the shipkit
|
|
216
|
+
// call. Both keys keep their place in the file, so the diff a human
|
|
217
|
+
// reads is the two lines that changed and not a reordered block
|
|
218
|
+
for (const id of ROUTED_SCRIPTS) {
|
|
219
|
+
if (routedExempt(env.config).has(id) || env.config.defaults?.[id] === false) continue
|
|
220
|
+
const body = pkg.scripts[id]
|
|
221
|
+
if (body == null || routesToShipkit(body)) continue
|
|
222
|
+
const raw = rawName(id)
|
|
223
|
+
const held = pkg.scripts[raw]
|
|
224
|
+
// a `<id>:raw` that already says something else is a repo's own
|
|
225
|
+
// answer, and overwriting it would delete a body nobody can recover
|
|
226
|
+
if (held != null && held !== body) { notes.push(`'${id}' left as it is — '${raw}' already holds a different body`); continue }
|
|
227
|
+
pkg.scripts = insertAfter(pkg.scripts, id, raw, body)
|
|
228
|
+
pkg.scripts[id] = routedBody(id, { bin })
|
|
229
|
+
changes.push(`script '${id}' routed through shipkit ('${raw}' holds the tool)`)
|
|
230
|
+
}
|
|
167
231
|
if (changes.length > 0) {
|
|
168
232
|
const indent = raw.match(/^(\s+)"/m)?.[1] ?? ' '
|
|
169
233
|
writeFileSync(pkgPath, JSON.stringify(pkg, null, indent) + '\n')
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// `shipkit contracts`, the ci `contracts` task) tags with this id via
|
|
7
7
|
// contracts/apply-contracts.ts.
|
|
8
8
|
import { activeContracts, applyContractsResult, contractsConfigFrom } from '../contracts/apply-contracts.ts'
|
|
9
|
-
import {
|
|
9
|
+
import { evaluateContractsOnce } from '../contracts/contracts-once.ts'
|
|
10
10
|
import type { Rule } from './rule.ts'
|
|
11
11
|
|
|
12
12
|
export const contractsFulfillness: Rule = {
|
|
@@ -15,6 +15,6 @@ export const contractsFulfillness: Rule = {
|
|
|
15
15
|
title: 'contracts',
|
|
16
16
|
owner: 'meta/contracts.md',
|
|
17
17
|
lint: async (t, env) => {
|
|
18
|
-
applyContractsResult(t, await
|
|
18
|
+
applyContractsResult(t, await evaluateContractsOnce(activeContracts(env.config), contractsConfigFrom(env.config, env.root)), contractsFulfillness)
|
|
19
19
|
},
|
|
20
20
|
}
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import { existsSync, readFileSync, writeFileSync } from 'fs'
|
|
12
12
|
import { join } from 'path'
|
|
13
13
|
import type { RepoConfig } from '../repo-config.ts'
|
|
14
|
+
import { GENERATED_IGNORE_LINES, trackedGeneratedPaths, untrackGenerated } from './untrack-generated.ts'
|
|
14
15
|
import type { FixResult, Rule, RuleEnv } from './rule.ts'
|
|
15
16
|
|
|
16
17
|
const BEGIN = '# >>> shipkit gitignore policy (SK004) — managed block, edit outside only'
|
|
@@ -37,6 +38,10 @@ const POLICY_LINES = [
|
|
|
37
38
|
// so every consumer repo needs both ignored before its first commit
|
|
38
39
|
'.comfy-ts/',
|
|
39
40
|
'*.loras.local.txt',
|
|
41
|
+
// shipkit's own per-run output. Ignoring is only half of it: the fixer
|
|
42
|
+
// also removes these from the index, because .gitignore does nothing to a
|
|
43
|
+
// file already tracked (untrack-generated.ts owns the pair)
|
|
44
|
+
...GENERATED_IGNORE_LINES,
|
|
40
45
|
]
|
|
41
46
|
|
|
42
47
|
export function expectedBlock(config: RepoConfig): string {
|
|
@@ -96,6 +101,13 @@ export const gitignorePolicy: Rule = {
|
|
|
96
101
|
title: '.gitignore',
|
|
97
102
|
owner: 'meta/contracts.md',
|
|
98
103
|
lint: (t, env) => {
|
|
104
|
+
// a generated path still in the index is the half-applied state the
|
|
105
|
+
// managed block cannot fix on its own
|
|
106
|
+
const tracked = trackedGeneratedPaths(env.root)
|
|
107
|
+
if (tracked.length > 0) {
|
|
108
|
+
t.fail(`${tracked.join(', ')} generated on every run but TRACKED — gitignore does not apply to a tracked file, so it dirties every tree`)
|
|
109
|
+
t.lines.push({ kind: 'fix-fail', text: 'shipkit ci (or check --fix SK004) untracks it in one commit, the file stays on disk' })
|
|
110
|
+
}
|
|
99
111
|
const s = blockState(env.root, env.config)
|
|
100
112
|
switch (s) {
|
|
101
113
|
case 'missing-file': return t.fail('.gitignore missing — the contract git policy is unenforced')
|
|
@@ -107,8 +119,20 @@ export const gitignorePolicy: Rule = {
|
|
|
107
119
|
},
|
|
108
120
|
fix: (env: RuleEnv): FixResult => {
|
|
109
121
|
const before = blockState(env.root, env.config)
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
122
|
+
const blockSummary = before === 'current'
|
|
123
|
+
? null
|
|
124
|
+
: before === 'missing-file' ? '.gitignore created with the policy block'
|
|
125
|
+
: before === 'missing-block' ? 'policy block appended (repo lines untouched)'
|
|
126
|
+
: before === 'corrupted' ? 'policy block rebuilt (stray markers dropped, repo lines kept visible)'
|
|
127
|
+
: 'policy block rewritten in place (repo lines untouched)'
|
|
128
|
+
if (blockSummary != null) writeBlock(env.root, env.config)
|
|
129
|
+
// the block must be written FIRST: untracking a path the ignore list
|
|
130
|
+
// does not yet carry leaves it staged for re-add on the next `git add`
|
|
131
|
+
const un = untrackGenerated(env.root, { enabled: env.config.untrackGenerated })
|
|
132
|
+
const parts = [blockSummary]
|
|
133
|
+
if (un.did === 'untracked') parts.push(`untracked ${un.paths.join(', ')} (generated, now gitignored, committed as ${un.commit})`)
|
|
134
|
+
if (un.did === 'refused') parts.push(`left ${trackedGeneratedPaths(env.root).join(', ')} tracked: ${un.why}`)
|
|
135
|
+
const summary = parts.filter((x): x is string => x != null).join(' · ')
|
|
136
|
+
return { changed: blockSummary != null || un.did === 'untracked', summary: summary === '' ? 'policy block already current' : summary }
|
|
113
137
|
},
|
|
114
138
|
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// SK012 — `repo.config.ts` must import from the NARROW module, never from the
|
|
2
|
+
// package barrel.
|
|
3
|
+
//
|
|
4
|
+
// THE COST, measured on this tree: `import { defineRepo } from 'rvlib-shipkit'`
|
|
5
|
+
// pulls the whole toolkit, 405ms of module load. The same symbol from
|
|
6
|
+
// `rvlib-shipkit/src/repo-config.ts` is 7ms. Every shipkit invocation in every
|
|
7
|
+
// repo loads this file first, the ~1s pre-commit hook included, so the barrel
|
|
8
|
+
// is a fixed tax on every gate run, every hook, every hub boot.
|
|
9
|
+
//
|
|
10
|
+
// A barrel is a fine PUBLIC surface and a terrible one to import from a config
|
|
11
|
+
// file that exists to be read before anything else happens. This rule is
|
|
12
|
+
// narrow on purpose: it is about repo.config.ts, not about barrels in general.
|
|
13
|
+
import { existsSync, readFileSync, writeFileSync } from 'fs'
|
|
14
|
+
import { join } from 'path'
|
|
15
|
+
import type { FixResult, Rule, RuleEnv } from './rule.ts'
|
|
16
|
+
|
|
17
|
+
const CONFIG = 'repo.config.ts'
|
|
18
|
+
const BARREL = /(^|\n)(\s*import\s+(?:type\s+)?\{[^}]*\}\s+from\s+)['"]rvlib-shipkit['"]/
|
|
19
|
+
const NARROW = 'rvlib-shipkit/src/repo-config.ts'
|
|
20
|
+
|
|
21
|
+
/** the symbols a config file can legitimately want, all of which the narrow
|
|
22
|
+
* module already exports. A config reaching for anything else is asking the
|
|
23
|
+
* barrel for a reason, and the rule says so rather than rewriting it. */
|
|
24
|
+
const CONFIG_SYMBOLS = new Set(['defineRepo', 'RepoConfig', 'TaskDef', 'ServeConfig', 'FloorSource', 'FloorResolution', 'loadRepoConfig', 'resolveFloor', 'allTasks'])
|
|
25
|
+
|
|
26
|
+
const importedSymbols = (line: string): string[] =>
|
|
27
|
+
(/\{([^}]*)\}/.exec(line)?.[1] ?? '').split(',').map((s) => s.replace(/^\s*type\s+/, '').trim()).filter((s) => s !== '')
|
|
28
|
+
|
|
29
|
+
export const configBarrelImport: Rule = {
|
|
30
|
+
id: 'SK012',
|
|
31
|
+
blocking: true,
|
|
32
|
+
title: 'config import path',
|
|
33
|
+
owner: 'build/shipkit.md',
|
|
34
|
+
lint: (t, env) => {
|
|
35
|
+
const p = join(env.root, CONFIG)
|
|
36
|
+
if (!existsSync(p)) return void t.ok(`${CONFIG} absent — nothing to narrow`)
|
|
37
|
+
const src = readFileSync(p, 'utf8')
|
|
38
|
+
if (!BARREL.test(src)) return void t.ok(`${CONFIG} imports the narrow config module`)
|
|
39
|
+
const line = src.split('\n').find((l) => /from\s+['"]rvlib-shipkit['"]/.test(l)) ?? ''
|
|
40
|
+
const symbols = importedSymbols(line)
|
|
41
|
+
const strays = symbols.filter((s) => !CONFIG_SYMBOLS.has(s))
|
|
42
|
+
if (strays.length > 0) {
|
|
43
|
+
t.fail(`${CONFIG} imports ${strays.join(', ')} from the package barrel, which costs ~400ms of module load on every shipkit run`)
|
|
44
|
+
return void t.lines.push({ kind: 'fix-fail', text: `move ${strays.join(', ')} to their own module, then import from ${NARROW}` })
|
|
45
|
+
}
|
|
46
|
+
t.fail(`${CONFIG} imports from the package barrel (~400ms of module load on every shipkit run, the pre-commit hook included)`)
|
|
47
|
+
},
|
|
48
|
+
fix: (env: RuleEnv): FixResult => {
|
|
49
|
+
const p = join(env.root, CONFIG)
|
|
50
|
+
if (!existsSync(p)) return { changed: false, summary: `${CONFIG} absent` }
|
|
51
|
+
const src = readFileSync(p, 'utf8')
|
|
52
|
+
if (!BARREL.test(src)) return { changed: false, summary: 'already narrow' }
|
|
53
|
+
const line = src.split('\n').find((l) => /from\s+['"]rvlib-shipkit['"]/.test(l)) ?? ''
|
|
54
|
+
// only rewrite what the narrow module actually exports: silently
|
|
55
|
+
// repointing an import it cannot serve turns a slow gate into a broken one
|
|
56
|
+
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
|
+
writeFileSync(p, src.replace(BARREL, `$1$2'${NARROW}'`))
|
|
58
|
+
return { changed: true, summary: `${CONFIG} now imports ${NARROW} (~400ms saved per run)` }
|
|
59
|
+
},
|
|
60
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
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, writeFileSync } 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
|
+
|
|
22
|
+
const OWNER = 'build/shipkit.md'
|
|
23
|
+
export const SETTINGS_PATH = '.claude/settings.json'
|
|
24
|
+
|
|
25
|
+
type Settings = {
|
|
26
|
+
permissions?: { deny?: string[] }
|
|
27
|
+
hooks?: Record<string, Array<{ matcher?: string; hooks?: Array<{ type?: string; command?: string }> }>>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** slots this repo does not route, so it must not be refused the shell command
|
|
31
|
+
* either: ONE declaration governs the script name and the raw call together */
|
|
32
|
+
export function exemptSlots(c: RepoConfig): Set<string> {
|
|
33
|
+
const off = new Set(c.oneWayIn?.exempt ?? [])
|
|
34
|
+
for (const [slot, def] of Object.entries(c.defaults ?? {})) if (def === false) off.add(slot)
|
|
35
|
+
return off
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** how the hook invokes the guard. `$CLAUDE_PROJECT_DIR` because a hook's cwd
|
|
39
|
+
* is not promised to be the repo root */
|
|
40
|
+
export function guardCommand(bin?: string): string {
|
|
41
|
+
const path = bin == null
|
|
42
|
+
? 'node_modules/rvlib-shipkit/src/hooks/one-way-in-guard.ts'
|
|
43
|
+
: bin.replace(/cli\.ts$/, 'hooks/one-way-in-guard.ts')
|
|
44
|
+
return `bun "$CLAUDE_PROJECT_DIR/${path}"`
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const readSettings = (root: string): Settings | null => {
|
|
48
|
+
const p = join(root, SETTINGS_PATH)
|
|
49
|
+
if (!existsSync(p)) return null
|
|
50
|
+
try { return JSON.parse(readFileSync(p, 'utf8')) as Settings } catch { return null }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const guardHookCommand = (s: Settings): string | null => {
|
|
54
|
+
for (const m of s.hooks?.['PreToolUse'] ?? [])
|
|
55
|
+
for (const h of m.hooks ?? [])
|
|
56
|
+
if (h.command?.includes('one-way-in-guard') === true) return h.command
|
|
57
|
+
return null
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const hasGuardHook = (s: Settings): boolean => guardHookCommand(s) != null
|
|
61
|
+
|
|
62
|
+
/** the guard file the hook command points at, resolved against the repo root.
|
|
63
|
+
* A hook whose script does not exist errors on EVERY Bash call and refuses
|
|
64
|
+
* nothing, so the agent sees only the bare deny, which is the one failure
|
|
65
|
+
* this rule exists to prevent. Declaring it and never running it is worse
|
|
66
|
+
* than not declaring it, so it FAILs. */
|
|
67
|
+
function guardScriptMissing(root: string, command: string): string | null {
|
|
68
|
+
const m = /["']?\$(?:\{)?CLAUDE_PROJECT_DIR(?:\})?\/([^"'\s]+)["']?/.exec(command)
|
|
69
|
+
const rel = m?.[1]
|
|
70
|
+
if (rel == null) return null // a form this rule cannot resolve is not a form it may condemn
|
|
71
|
+
return existsSync(join(root, rel)) ? null : rel
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const readBin = (root: string): string | undefined => {
|
|
75
|
+
try { return inferBin((JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { scripts?: Record<string, string> }).scripts ?? {}) }
|
|
76
|
+
catch { return undefined }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export const oneWayInPolicy: Rule = {
|
|
80
|
+
id: 'SK013',
|
|
81
|
+
blocking: true,
|
|
82
|
+
title: 'one way in policy',
|
|
83
|
+
owner: OWNER,
|
|
84
|
+
lint: (t, env) => {
|
|
85
|
+
const want = denyRules(exemptSlots(env.config))
|
|
86
|
+
if (want.length === 0) return t.ok('every governed slot is exempt (repo.config oneWayIn/defaults) — nothing to deny')
|
|
87
|
+
const s = readSettings(env.root)
|
|
88
|
+
if (s == null) {
|
|
89
|
+
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`)
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
const have = new Set(s.permissions?.deny ?? [])
|
|
93
|
+
const missing = want.filter((r) => !have.has(r))
|
|
94
|
+
if (missing.length > 0)
|
|
95
|
+
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`)
|
|
96
|
+
const guard = guardHookCommand(s)
|
|
97
|
+
if (guard == null)
|
|
98
|
+
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`)
|
|
99
|
+
else {
|
|
100
|
+
const missing = guardScriptMissing(env.root, guard)
|
|
101
|
+
if (missing != null)
|
|
102
|
+
t.fail(
|
|
103
|
+
`${SETTINGS_PATH}: the PreToolUse guard points at ${missing}, which does not exist — every Bash call errors and no refusal ever names the shipkit command`,
|
|
104
|
+
`bun install (or bun add -d rvlib-shipkit)`,
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
if (!t.failed) t.ok(`${SETTINGS_PATH} closes ${want.length}/${RAW_RUNNERS.length} raw runner(s), with the replacement named`)
|
|
108
|
+
},
|
|
109
|
+
// MERGES, never rewrites: this file also carries the attribution silencer,
|
|
110
|
+
// a repo's own allow list and its own hooks, and clobbering those to fix a
|
|
111
|
+
// deny list would cost more than the finding it repairs
|
|
112
|
+
fix: (env: RuleEnv): FixResult => {
|
|
113
|
+
const want = denyRules(exemptSlots(env.config))
|
|
114
|
+
if (want.length === 0) return { changed: false, summary: 'every governed slot is exempt — nothing to write' }
|
|
115
|
+
const p = join(env.root, SETTINGS_PATH)
|
|
116
|
+
const existing = readSettings(env.root)
|
|
117
|
+
if (existing == null && existsSync(p)) return { changed: false, summary: `${SETTINGS_PATH} is unparseable — repair it by hand first, a merge would destroy it` }
|
|
118
|
+
const s: Settings = existing ?? {}
|
|
119
|
+
const changes: string[] = []
|
|
120
|
+
const deny = s.permissions?.deny ?? []
|
|
121
|
+
const added = want.filter((r) => !deny.includes(r))
|
|
122
|
+
if (added.length > 0) {
|
|
123
|
+
s.permissions = { ...s.permissions, deny: [...deny, ...added] }
|
|
124
|
+
changes.push(`${added.length} raw runner(s) denied`)
|
|
125
|
+
}
|
|
126
|
+
if (!hasGuardHook(s)) {
|
|
127
|
+
const pre = s.hooks?.['PreToolUse'] ?? []
|
|
128
|
+
s.hooks = { ...s.hooks, PreToolUse: [...pre, { matcher: 'Bash', hooks: [{ type: 'command', command: guardCommand(readBin(env.root)) }] }] }
|
|
129
|
+
changes.push('PreToolUse guard wired (every refusal names the shipkit command)')
|
|
130
|
+
}
|
|
131
|
+
if (changes.length === 0) return { changed: false, summary: `${SETTINGS_PATH} already closes every raw runner` }
|
|
132
|
+
mkdirSync(dirname(p), { recursive: true })
|
|
133
|
+
writeFileSync(p, JSON.stringify(s, null, 2) + '\n')
|
|
134
|
+
return { changed: true, summary: `${SETTINGS_PATH}: ${changes.join(', ')}` }
|
|
135
|
+
},
|
|
136
|
+
}
|
|
@@ -14,6 +14,8 @@ import { reviewerAgent } from './SK008-reviewer-agent.ts'
|
|
|
14
14
|
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
|
+
import { configBarrelImport } from './SK012-config-barrel-import.ts'
|
|
18
|
+
import { oneWayInPolicy } from './SK013-one-way-in-policy.ts'
|
|
17
19
|
|
|
18
20
|
export const RULES: readonly Rule[] = [
|
|
19
21
|
packageJsonShape,
|
|
@@ -27,6 +29,8 @@ export const RULES: readonly Rule[] = [
|
|
|
27
29
|
commitAiTells,
|
|
28
30
|
undeclaredReleaseUnit,
|
|
29
31
|
siblingUnitRanges,
|
|
32
|
+
configBarrelImport,
|
|
33
|
+
oneWayInPolicy,
|
|
30
34
|
]
|
|
31
35
|
|
|
32
36
|
// THE id shape, one place: 2-4 uppercase letters + exactly 3 digits. `SK` is
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// paths shipkit GENERATES on every run, and the one operation that makes a
|
|
2
|
+
// declaration like that actually hold.
|
|
3
|
+
//
|
|
4
|
+
// THE CONTRADICTION THIS RESOLVES: `.gitignore` does not apply to a file that
|
|
5
|
+
// is already tracked. So a path shipkit ignores but leaves in the index keeps
|
|
6
|
+
// dirtying every tree forever, and rides into unrelated commits as a mystery
|
|
7
|
+
// diff. Ignoring and untracking are therefore ONE operation, never two, and
|
|
8
|
+
// any path declared machine-generated gets both.
|
|
9
|
+
//
|
|
10
|
+
// STATUS.md is the first customer and the reason: 141 of this repo's 625
|
|
11
|
+
// commits carried it, 125 of them as an unrelated rider inside someone else's
|
|
12
|
+
// diff, in a repo whose commits ARE the session-resume mechanism. It is
|
|
13
|
+
// nondeterministic by construction (minute-resolution header, task lines in
|
|
14
|
+
// DAG completion order, per-worktree cache stamps that move backwards), and
|
|
15
|
+
// nothing on this disk ever reads a past version: the hub reads it off disk,
|
|
16
|
+
// the TUI renders live, the tests assert the writer.
|
|
17
|
+
//
|
|
18
|
+
// WHY THIS ONE FIXER COMMITS, when shipkit otherwise never does: `git rm
|
|
19
|
+
// --cached` leaves a STAGED DELETION in the index, which the next agent's
|
|
20
|
+
// unrelated commit sweeps up as an unexplained deletion. That is strictly
|
|
21
|
+
// worse than the disease. The commit is a one-time migration per path, it
|
|
22
|
+
// goes through the hooks like any other, and it refuses in every state where
|
|
23
|
+
// it could surprise someone.
|
|
24
|
+
import { existsSync } from 'fs'
|
|
25
|
+
import { join } from 'path'
|
|
26
|
+
|
|
27
|
+
/** rendered into the SK004 managed block. A glob is fine HERE: ignoring is
|
|
28
|
+
* not destructive. */
|
|
29
|
+
export const GENERATED_IGNORE_LINES = [
|
|
30
|
+
'# shipkit writes these on every run — ignored AND untracked (untrack-generated.ts)',
|
|
31
|
+
'STATUS.md',
|
|
32
|
+
'STATUS.md.lock',
|
|
33
|
+
'.STATUS.md.tmp-*',
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
/** the LITERAL subset an autofix may remove from the index. Never a glob and
|
|
37
|
+
* never derived from a pattern: `git rm --cached` driven by a wildcard is how
|
|
38
|
+
* a fixer eats source it was never pointed at. A future entry is added HERE,
|
|
39
|
+
* by hand, spelled out. */
|
|
40
|
+
export const GENERATED_TRACKED_PATHS = ['STATUS.md', 'STATUS.md.lock'] as const
|
|
41
|
+
|
|
42
|
+
export type UntrackOutcome =
|
|
43
|
+
| { did: 'nothing' }
|
|
44
|
+
| { did: 'refused'; why: string }
|
|
45
|
+
| { did: 'untracked'; paths: string[]; commit: string }
|
|
46
|
+
|
|
47
|
+
const git = (root: string, args: string[]): { code: number; out: string } => {
|
|
48
|
+
const p = Bun.spawnSync(['git', ...args], { cwd: root })
|
|
49
|
+
return { code: p.exitCode, out: `${p.stdout.toString()}${p.stderr.toString()}`.trim() }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** every declared path this repo still carries in its index. ONE git spawn,
|
|
53
|
+
* not one per path: this runs inside the SK004 lint, which runs on every
|
|
54
|
+
* `shipkit check` in every repo, and a process start is ~10ms of it. */
|
|
55
|
+
export function trackedGeneratedPaths(root: string): string[] {
|
|
56
|
+
if (!existsSync(join(root, '.git'))) return []
|
|
57
|
+
const r = git(root, ['ls-files', '-z', '--', ...GENERATED_TRACKED_PATHS])
|
|
58
|
+
if (r.code !== 0) return []
|
|
59
|
+
const listed = new Set(r.out.split('\0').filter((l) => l !== ''))
|
|
60
|
+
return GENERATED_TRACKED_PATHS.filter((p) => listed.has(p))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** a state where an automated commit would surprise someone, or land on the
|
|
64
|
+
* wrong ref. Named rather than boolean so the refusal says which one. */
|
|
65
|
+
function unsafeState(root: string): string | null {
|
|
66
|
+
const gitDir = git(root, ['rev-parse', '--git-dir'])
|
|
67
|
+
if (gitDir.code !== 0) return 'not a git work tree'
|
|
68
|
+
const d = join(root, gitDir.out)
|
|
69
|
+
if (existsSync(join(d, 'MERGE_HEAD'))) return 'a merge is in progress'
|
|
70
|
+
if (existsSync(join(d, 'rebase-merge')) || existsSync(join(d, 'rebase-apply'))) return 'a rebase is in progress'
|
|
71
|
+
if (git(root, ['symbolic-ref', '-q', 'HEAD']).code !== 0) return 'HEAD is detached'
|
|
72
|
+
if (git(root, ['rev-parse', '--verify', '-q', 'HEAD']).code !== 0) return 'the repo has no commit yet'
|
|
73
|
+
// the commit below stages nothing but our own removal, so ANY other staged
|
|
74
|
+
// change would ride along in it. A one-time migration can wait for a run
|
|
75
|
+
// where the index is the fixer's alone
|
|
76
|
+
const staged = git(root, ['diff', '--cached', '--name-only'])
|
|
77
|
+
if (staged.code === 0 && staged.out !== '') return `something else is already staged (${staged.out.split('\n')[0]})`
|
|
78
|
+
return null
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const message = (paths: string[]): string =>
|
|
82
|
+
[
|
|
83
|
+
`chore(machine): untrack ${paths.join(', ')}`,
|
|
84
|
+
'',
|
|
85
|
+
`shipkit rewrites ${paths.length === 1 ? 'this path' : 'these paths'} on every run, so ${paths.length === 1 ? 'it was' : 'they were'} never source.`,
|
|
86
|
+
'Tracked, it dirtied every tree and rode into unrelated commits as a',
|
|
87
|
+
'mystery diff. Gitignore alone cannot fix that: it does not apply to a',
|
|
88
|
+
'file already in the index, so the two halves land together.',
|
|
89
|
+
'',
|
|
90
|
+
'The file stays on disk. Nothing reads a past version of it.',
|
|
91
|
+
].join('\n')
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* ignore AND untrack, in one operation. Loud when it acts, loud when it
|
|
95
|
+
* refuses, silent when there is nothing to do.
|
|
96
|
+
*
|
|
97
|
+
* `frozen` is the release-shaped run: report only, touch nothing.
|
|
98
|
+
*/
|
|
99
|
+
export function untrackGenerated(root: string, o: { frozen?: boolean; enabled?: boolean } = {}): UntrackOutcome {
|
|
100
|
+
if (o.enabled === false) return { did: 'nothing' }
|
|
101
|
+
if (o.frozen === true || process.env.SHIPKIT_FROZEN === '1') return { did: 'nothing' }
|
|
102
|
+
const paths = trackedGeneratedPaths(root)
|
|
103
|
+
if (paths.length === 0) return { did: 'nothing' }
|
|
104
|
+
const unsafe = unsafeState(root)
|
|
105
|
+
if (unsafe != null) return { did: 'refused', why: unsafe }
|
|
106
|
+
|
|
107
|
+
const rm = git(root, ['rm', '--cached', '--quiet', '--', ...paths])
|
|
108
|
+
if (rm.code !== 0) return { did: 'refused', why: `git rm --cached failed: ${rm.out.slice(0, 120)}` }
|
|
109
|
+
// through the hooks, never --no-verify. A rejected commit must not leave
|
|
110
|
+
// the staged deletion behind: that IS the failure mode this exists to stop
|
|
111
|
+
const commit = git(root, ['commit', '-m', message(paths)])
|
|
112
|
+
if (commit.code !== 0) {
|
|
113
|
+
git(root, ['reset', '--quiet', '--', ...paths])
|
|
114
|
+
return { did: 'refused', why: `the commit was rejected, index restored: ${commit.out.slice(0, 160)}` }
|
|
115
|
+
}
|
|
116
|
+
return { did: 'untracked', paths, commit: git(root, ['rev-parse', '--short', 'HEAD']).out }
|
|
117
|
+
}
|
|
@@ -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 <name filter>` is the narrow way in.
|
|
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
|
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// none of them may name a machine-local tool or a private path.
|
|
13
13
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
|
14
14
|
import { dirname, join } from 'path'
|
|
15
|
-
import { defaultHubPort } from '../serve/
|
|
15
|
+
import { defaultHubPort } from '../serve/hub-port.ts'
|
|
16
16
|
|
|
17
17
|
const FILES = join(import.meta.dir, 'files')
|
|
18
18
|
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// the hub's default port, alone in its own module ON PURPOSE.
|
|
2
|
+
//
|
|
3
|
+
// It is four lines of arithmetic that several places need BEFORE any server
|
|
4
|
+
// exists: the repo scaffold writes it into .vscode/tasks.json, `init` prints
|
|
5
|
+
// it, the config resolver defaults to it. Importing it from run-serve.ts drags
|
|
6
|
+
// the whole hub (react, the markdown renderer, the sanitiser, every panel)
|
|
7
|
+
// into a caller that only wanted a number, which measured 393ms of module
|
|
8
|
+
// load in the scaffold alone.
|
|
9
|
+
//
|
|
10
|
+
// A port derived from the repo NAME rather than assigned means two repos open
|
|
11
|
+
// side by side almost never collide, and the same repo always reopens on the
|
|
12
|
+
// same port, which is what makes a folder-open task able to advertise a URL.
|
|
13
|
+
export function defaultHubPort(name: string): number {
|
|
14
|
+
let h = 0
|
|
15
|
+
for (const c of name) h = (h * 31 + c.charCodeAt(0)) >>> 0
|
|
16
|
+
return 4800 + (h % 180)
|
|
17
|
+
}
|
package/src/serve/run-serve.ts
CHANGED
|
@@ -20,14 +20,11 @@ import { loadRepoConfig, type RepoConfig } from '../repo-config.ts'
|
|
|
20
20
|
import type { GraphMount } from '../wiki/graph-mount.ts'
|
|
21
21
|
import { scanMdGraph } from '../wiki/scan-md.ts'
|
|
22
22
|
import { serveHub, type HubDeps } from './serve-hub.ts'
|
|
23
|
+
import { defaultHubPort } from './hub-port.ts'
|
|
24
|
+
export { defaultHubPort }
|
|
23
25
|
|
|
24
26
|
export type ServeIo = { print: (l: string) => void; printErr: (l: string) => void }
|
|
25
27
|
|
|
26
|
-
export function defaultHubPort(name: string): number {
|
|
27
|
-
let h = 0
|
|
28
|
-
for (const c of name) h = (h * 31 + c.charCodeAt(0)) >>> 0
|
|
29
|
-
return 4800 + (h % 180)
|
|
30
|
-
}
|
|
31
28
|
|
|
32
29
|
// the BASE-CONTEXT wiki: the shared wiki this repo's CLAUDE.md include chain
|
|
33
30
|
// points at (`@~/dev/<other-repo>/wiki/index.md`). It lives in ANOTHER repo and
|
package/src/wiki/run-wiki.ts
CHANGED
|
@@ -6,7 +6,15 @@ import { lintMd } from './lint-md.ts'
|
|
|
6
6
|
import { scanMdGraph } from './scan-md.ts'
|
|
7
7
|
import { serveMdGraph } from './serve-graph.ts'
|
|
8
8
|
|
|
9
|
-
export type WikiScanOpts = {
|
|
9
|
+
export type WikiScanOpts = {
|
|
10
|
+
root: string
|
|
11
|
+
index?: string
|
|
12
|
+
skip?: string
|
|
13
|
+
/** where the verdict goes. The command passes its own stream, so the
|
|
14
|
+
* output is capturable: a console.log inside a command bypasses the
|
|
15
|
+
* stream clipanion hands it, and nothing downstream can read it */
|
|
16
|
+
print?: (line: string) => void
|
|
17
|
+
}
|
|
10
18
|
|
|
11
19
|
const scanConfig = (o: WikiScanOpts): { root: string; index?: string; skipDirs?: string[] } => ({
|
|
12
20
|
root: resolve(process.cwd(), o.root),
|
|
@@ -15,18 +23,19 @@ const scanConfig = (o: WikiScanOpts): { root: string; index?: string; skipDirs?:
|
|
|
15
23
|
})
|
|
16
24
|
|
|
17
25
|
export async function runWikiServe(o: WikiScanOpts & { port: number; title?: string }): Promise<number> {
|
|
26
|
+
const say = o.print ?? console.log
|
|
18
27
|
const cfg = scanConfig(o)
|
|
19
28
|
const rel = relative(process.cwd(), cfg.root)
|
|
20
29
|
const title = o.title ?? (rel === '' ? basename(cfg.root) : `${basename(process.cwd())} ${rel}`)
|
|
21
30
|
try {
|
|
22
31
|
serveMdGraph({ port: o.port, title, load: () => scanMdGraph(cfg) })
|
|
23
|
-
|
|
32
|
+
say(`wiki graph → http://localhost:${o.port} (open in browser, or VSCode "Simple Browser: Show")`)
|
|
24
33
|
// keep the server alive — the command must not resolve
|
|
25
34
|
await new Promise(() => {})
|
|
26
35
|
return 0
|
|
27
36
|
} catch (e) {
|
|
28
37
|
if (e instanceof Error && e.message.includes('in use')) {
|
|
29
|
-
|
|
38
|
+
say(`wiki graph already served → http://localhost:${o.port} (another window owns it)`)
|
|
30
39
|
return 0
|
|
31
40
|
}
|
|
32
41
|
throw e
|
|
@@ -34,17 +43,18 @@ export async function runWikiServe(o: WikiScanOpts & { port: number; title?: str
|
|
|
34
43
|
}
|
|
35
44
|
|
|
36
45
|
export function runWikiLint(o: WikiScanOpts): number {
|
|
46
|
+
const say = o.print ?? console.log
|
|
37
47
|
const cfg = scanConfig(o)
|
|
38
48
|
const r = lintMd(cfg)
|
|
39
49
|
if (r.index == null) {
|
|
40
|
-
|
|
50
|
+
say(o.index != null
|
|
41
51
|
? `FAIL entrypoint '${o.index}' not found in ${cfg.root}`
|
|
42
52
|
: `FAIL no entrypoint: neither index.md nor README.md in ${cfg.root} — pass --index <entry.md>`)
|
|
43
53
|
return 1
|
|
44
54
|
}
|
|
45
|
-
for (const f of r.orphans)
|
|
46
|
-
for (const d of r.dangling)
|
|
47
|
-
if (!r.ok) {
|
|
48
|
-
|
|
55
|
+
for (const f of r.orphans) say(`FAIL orphan: ${f} — unreachable from ${r.index}; link it or delete it`)
|
|
56
|
+
for (const d of r.dangling) say(`FAIL dangling: ${d.from} → ${d.token} — target not found`)
|
|
57
|
+
if (!r.ok) { say(`FAIL mdwiki lint: ${r.orphans.length} orphan(s), ${r.dangling.length} dangling ref(s) in ${cfg.root}`); return 1 }
|
|
58
|
+
say(`ok mdwiki lint: ${r.files} files, all reachable from ${r.index}, no dangling refs`)
|
|
49
59
|
return 0
|
|
50
60
|
}
|