rvlib-shipkit 2.0.1 → 2.1.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 +10 -0
- package/package.json +1 -1
- package/src/checks/SK001-package-json-shape.check.ts +13 -0
- package/src/checks/SK027-agent-wiki-links.check.ts +24 -16
- package/src/checks/SK031-trace-audit.check.ts +3 -1
- package/src/core/jobs.ts +4 -2
- package/src/ledger/store.ts +2 -1
- package/src/scaffold/files/bunfig.toml +4 -0
- package/src/wiki/agent-index.ts +114 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
## 2.1.0
|
|
2
|
+
|
|
3
|
+
- **SK027 requires exhaustive agent/ indexes.** Every `agent/` page is listed by an index (a bullet, numbered item, table row, `→` pointer or `@` include names it or its folder), and every index is loaded from CLAUDE.md or listed by a loaded file. A page linked only from prose now fails, with the page that already names it as the fix. The template's house `[lint]` block makes `typescript/no-empty-object-type` an error.
|
|
4
|
+
- **The store sweep clears what it measures.** A job past its 7 day keep window is swept even when its pid is alive, because a pid that old has been reused. The answers measure leaves out the forever answers the sweep keeps, so a decided fact never reads as over cap.
|
|
5
|
+
- **SK031 states a task it cannot trace as one note.** A binary macOS will not trace is the platform, so it no longer prints one WARN per task.
|
|
6
|
+
|
|
7
|
+
## 2.0.2
|
|
8
|
+
|
|
9
|
+
- **SK001 clears its own lint shadow.** A `lint` or `fmt` script that routes through `shipkit ci` beside the bunfig section of the same name is deleted by the repair, with its `:raw` half, so `bun lint` runs the built-in. A script whose body is a tool stays named for a human.
|
|
10
|
+
|
|
1
11
|
## 2.0.1
|
|
2
12
|
|
|
3
13
|
- **`sk fleet --update --major` crosses the major again.** Each repo's guarded run now stays in its own process (`SHIPKIT_NO_HOME`), because a check the home answered never saw `SHIPKIT_ALLOW_MAJOR` and refused the breaking jump, naming the very command that was running.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rvlib-shipkit",
|
|
3
|
-
"version": "2.0
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"funding": "https://github.com/sponsors/rvion",
|
|
5
5
|
"description": "All-in-one repo companion: ci gate (task DAG), auto-discovered health checks, release-contract fulfillness, asks queue, md-wiki tools, asset atelier, live tui",
|
|
6
6
|
"license": "MIT",
|
|
@@ -282,6 +282,19 @@ export const packageJsonShape = check({
|
|
|
282
282
|
// none, so scaffolding this into a repo without any turns the gate red
|
|
283
283
|
// on work the fixer itself did. a fixer must never manufacture a failure.
|
|
284
284
|
if (pkg.scripts['test'] == null && hasTestFiles(testFiles(c))) { pkg.scripts['test'] = 'bun test'; changes.push(`script 'test' added`) }
|
|
285
|
+
// a script beside its declared bunfig section only shadows the built-in.
|
|
286
|
+
// A routed body and its `<id>:raw` are house wiring, so they go. A tool
|
|
287
|
+
// body is a repo's own answer and stays named
|
|
288
|
+
const bunfig = c.read('bunfig.toml')
|
|
289
|
+
for (const n of ['lint', 'fmt'] as const) {
|
|
290
|
+
const body = pkg.scripts[n]
|
|
291
|
+
if (body == null || !bunfigDeclares(bunfig, n)) continue
|
|
292
|
+
if (!routesToShipkit(body)) { notes.push(`'${n}' shadows \`bun ${n}\` — its body is a tool, left for a human`); continue }
|
|
293
|
+
delete pkg.scripts[n]
|
|
294
|
+
const gone: string[] = [n]
|
|
295
|
+
if (pkg.scripts[rawName(n)] != null) { delete pkg.scripts[rawName(n)]; gone.push(rawName(n)) }
|
|
296
|
+
changes.push(`script ${gone.map((g) => `'${g}'`).join(' and ')} deleted (bunfig.toml [${n}] runs \`bun ${n}\`)`)
|
|
297
|
+
}
|
|
285
298
|
// ONE WAY IN, last so it also routes whatever this pass just added:
|
|
286
299
|
// the tool body slides to `<id>:raw` and `<id>` becomes the shipkit
|
|
287
300
|
// call. Both keys keep their place in the file, so the diff a human
|
|
@@ -1,42 +1,50 @@
|
|
|
1
1
|
// SK027 agent wiki links (build/shipkit/built-in-checks.md): a repo's agent/
|
|
2
|
-
// IS an md wiki
|
|
3
|
-
//
|
|
4
|
-
//
|
|
2
|
+
// IS an md wiki, entered by agent/index.md. Every page is LISTED by an index
|
|
3
|
+
// (src/wiki/agent-index.ts: one bullet, numbered item, table row or @ include
|
|
4
|
+
// names it or its folder), every index is in sight of what a session loads,
|
|
5
|
+
// and a dangling ref fails. A link that leaves agent/ but stays in the repo is
|
|
6
|
+
// checked on disk; a pointer out of the repo (a house wiki) is backticked,
|
|
5
7
|
// never a bare → or @. A repo without agent/index.md skips.
|
|
6
|
-
// agent/global-contribution.md is
|
|
8
|
+
// agent/global-contribution.md is listed by the house index (SK033), never agent/'s.
|
|
7
9
|
import { existsSync, readFileSync } from 'fs'
|
|
8
|
-
import { basename, join } from 'path'
|
|
10
|
+
import { basename, dirname, join } from 'path'
|
|
9
11
|
import { check } from '../check.ts'
|
|
10
12
|
import { CONTRIBUTION_FILE } from '../core/global-contribution.ts'
|
|
13
|
+
import { indexCovers, indexesOutOfSight, loadedFiles, unlisted } from '../wiki/agent-index.ts'
|
|
11
14
|
import { lintMd } from '../wiki/lint-md.ts'
|
|
12
15
|
|
|
13
16
|
export const agentWikiLinks = check({
|
|
14
17
|
id: 'SK027',
|
|
15
18
|
blocking: true,
|
|
16
19
|
title: 'agent wiki links',
|
|
17
|
-
description: 'every agent/ page is
|
|
20
|
+
description: 'every agent/ page is listed by an index a session sees, and every agent/ link resolves',
|
|
18
21
|
owner: 'meta/agent-folder.md',
|
|
19
22
|
runs: { onPreCommit: false, onCi: true, onFull: true, onRelease: true, onHealth: true, onSave: 'moved', onClock: false },
|
|
20
|
-
// the md lint walks agent/ on disk rather than through `c
|
|
21
|
-
cache: { by: 'files', inputs: ['agent'], key: null },
|
|
23
|
+
// the md lint walks agent/ on disk rather than through `c`; the loaded context starts at CLAUDE.md
|
|
24
|
+
cache: { by: 'files', inputs: ['agent', 'CLAUDE.md', 'CLAUDE.local.md'], key: null },
|
|
22
25
|
fix: 'none',
|
|
23
26
|
applies: 'always',
|
|
24
27
|
run: (c) => {
|
|
25
28
|
if (!existsSync(join(c.root, 'agent/index.md'))) return void c.lines.push({ kind: 'skip', text: 'no agent/ wiki in this repo' })
|
|
26
29
|
const lint = lintMd({ root: join(c.root, 'agent'), index: 'index.md', boundary: c.root })
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
const linked = lint.pages.filter((p) => !orphans.includes(p))
|
|
30
|
-
for (const f of orphans) {
|
|
31
|
-
const by = namingPage(c.root, f, linked)
|
|
32
|
-
const where = by != null ? `${by}, which already names it (turn that mention into the link)` : 'agent/index.md or the page of its topic'
|
|
33
|
-
c.fail(`agent/${f}: orphan, unreachable from agent/index.md: add a markdown link to it from ${where}; delete it only if the page is dead`, { at: { file: `agent/${f}` } })
|
|
30
|
+
const read = (p: string): string | null => {
|
|
31
|
+
try { return readFileSync(join(c.root, p), 'utf8') } catch { return null }
|
|
34
32
|
}
|
|
33
|
+
const pages = new Map(lint.pages.map((p) => [`agent/${p}`, read(`agent/${p}`) ?? '']))
|
|
34
|
+
const folders = new Set([...pages.keys()].map((p) => dirname(p)))
|
|
35
|
+
for (const f of unlisted(pages, new Set([CONTRIBUTION_FILE]))) {
|
|
36
|
+
// only an index that can list the page is worth naming as the fix
|
|
37
|
+
const by = namingPage(c.root, f.slice('agent/'.length), lint.pages.filter((p) => indexCovers(`agent/${p}`, f, folders)))
|
|
38
|
+
const where = by != null ? `${by}, which already names it (make that mention a listing line)` : 'agent/index.md or the index of its folder'
|
|
39
|
+
c.fail(`${f}: no index lists it: add one bullet or table row naming it to ${where}; delete it only if the page is dead`, { at: { file: f } })
|
|
40
|
+
}
|
|
41
|
+
for (const f of indexesOutOfSight(pages, loadedFiles(read), read))
|
|
42
|
+
c.fail(`${f}: an index no session sees: list it on one line of agent/index.md (or another loaded index), or include it with @`, { at: { file: f } })
|
|
35
43
|
for (const d of lint.dangling) {
|
|
36
44
|
const file = `agent/${d.from}`
|
|
37
45
|
c.fail(`${file}:${d.line}: dangling ref → ${d.token}, target not found on disk: fix the path or delete the pointer (a pointer out of this repo, into a house wiki or another repo, goes in backticks)`, { at: { file, line: d.line } })
|
|
38
46
|
}
|
|
39
|
-
if (!c.failed) c.ok(
|
|
47
|
+
if (!c.failed) c.ok(`agent/: ${pages.size} pages, each listed by an index in sight; no dangling refs`)
|
|
40
48
|
},
|
|
41
49
|
})
|
|
42
50
|
|
|
@@ -27,7 +27,9 @@ export const traceAuditCheck = check({
|
|
|
27
27
|
let out: { skipped?: string; tasks: { id: string; status: string; why?: string; uncovered: string[]; propose: string[] }[] }
|
|
28
28
|
try { out = JSON.parse(r.out.trim().split('\n').at(-1) ?? '') } catch { return c.fail(`sk trace-audit did not answer (exit ${r.code}: ${r.out.trim().slice(0, 160)})`) }
|
|
29
29
|
if (out.skipped != null) return c.note(`nothing traced: ${out.skipped}`)
|
|
30
|
-
|
|
30
|
+
// a binary macOS will not trace is the platform, never the repo: nobody can clear it, so it is a note
|
|
31
|
+
const unaudited = out.tasks.filter((x) => x.status === 'unaudited').map((x) => x.id)
|
|
32
|
+
if (unaudited.length > 0) c.note(`${unaudited.length} task(s) not auditable here, a binary ran untraced (macOS traces no system binary): ${unaudited.join(', ')}`)
|
|
31
33
|
const missing = out.tasks.filter((x) => x.uncovered.length > 0)
|
|
32
34
|
// a finding joins its task's key at once (core/audit-report.ts), so it
|
|
33
35
|
// is news, never a failure: SK032 carries it into repo.config.ts
|
package/src/core/jobs.ts
CHANGED
|
@@ -177,9 +177,11 @@ export function sweepJobs(root: string, now = Date.now(), o: { keepMs?: number;
|
|
|
177
177
|
})
|
|
178
178
|
} catch { return /* no jobs yet: nothing to sweep */ }
|
|
179
179
|
dirs.sort((a, b) => b.mtime - a.mtime).forEach((d, i) => {
|
|
180
|
-
|
|
180
|
+
const fresh = d.mtime > now - (o.keepMs ?? JOBS_KEEP_MS)
|
|
181
|
+
if (fresh && i < (o.max ?? JOBS_MAX)) return
|
|
182
|
+
// past the keep window a live pid is a reused one: no gate runs for a week
|
|
181
183
|
const j = readJson<Job>(join(d.dir, 'job.json'))
|
|
182
|
-
if (j != null && alive(j.pid)) return
|
|
184
|
+
if (fresh && j != null && alive(j.pid)) return
|
|
183
185
|
try { rmSync(d.dir, { recursive: true, force: true }) } catch { /* raced: gone either way */ }
|
|
184
186
|
})
|
|
185
187
|
}
|
package/src/ledger/store.ts
CHANGED
|
@@ -193,8 +193,9 @@ export class Answers {
|
|
|
193
193
|
return r == null ? 'none' : `${r.n}/${r.last ?? 0}`
|
|
194
194
|
}
|
|
195
195
|
|
|
196
|
+
/** the rows the cap governs: a forever answer is kept by the sweep, so it is no use against the cap */
|
|
196
197
|
use(): AnswersUse | null {
|
|
197
|
-
const r = this.with(READ_BUSY_MS, (h) => h.query('select count(*) as n, min(used) as oldest from answers').get() as { n: number; oldest: number | null } | null)
|
|
198
|
+
const r = this.with(READ_BUSY_MS, (h) => h.query('select count(*) as n, min(used) as oldest from answers where coalesce(keeps, 0) < ?').get(FOREVER_MS) as { n: number; oldest: number | null } | null)
|
|
198
199
|
return r == null ? null : { count: Number(r.n), oldestUsed: r.oldest }
|
|
199
200
|
}
|
|
200
201
|
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// every agent/ page is LISTED: a bullet, a numbered item, a table row or an `@`
|
|
2
|
+
// include of an index page names it, or names a folder that holds it. A link in
|
|
3
|
+
// prose does not count, so an agent finds every page by reading indexes, never
|
|
4
|
+
// by luck. Index pages: agent/index.md and any `index.md` under agent/ (they
|
|
5
|
+
// list any page), `agent/<dir>.md` (it lists the pages of `agent/<dir>/`). Every
|
|
6
|
+
// index page is in sight of what a session loads: loaded, or named by a listing
|
|
7
|
+
// line of a loaded file. Pure: callers pass the texts, so the rule tests in memory.
|
|
8
|
+
import { dirname, join, normalize } from 'path'
|
|
9
|
+
|
|
10
|
+
export const ROOT_INDEX = 'agent/index.md'
|
|
11
|
+
|
|
12
|
+
/** the files a session loads first, before any include */
|
|
13
|
+
export const ENTRIES = ['CLAUDE.md', 'CLAUDE.local.md'] as const
|
|
14
|
+
|
|
15
|
+
const MAX_HOPS = 5
|
|
16
|
+
|
|
17
|
+
/** a line that lists: a bullet, a numbered item, a table row (not its rule), an `@` include, a `→` pointer */
|
|
18
|
+
const LISTING = /^\s*(?:[-*+]\s|\d+\.\s|\|(?!\s*:?-{3})|@\S|→\s)/
|
|
19
|
+
|
|
20
|
+
/** the `@` paths of a page, code fences and code spans left out */
|
|
21
|
+
export function includesOf(text: string): string[] {
|
|
22
|
+
const prose = text.replace(/^```[\s\S]*?^```/gm, '').replace(/`[^`\n]*`/g, '')
|
|
23
|
+
return [...prose.matchAll(/(?:^|\s)@([^\s]+)/g)].map((m) => m[1]!)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** an include as a repo path, or null when it leaves the repo (`~/…`, absolute, `../` past the root) */
|
|
27
|
+
function repoPath(from: string, include: string): string | null {
|
|
28
|
+
if (include.startsWith('~') || include.startsWith('/')) return null
|
|
29
|
+
const p = normalize(join(dirname(from), include))
|
|
30
|
+
return p.startsWith('..') ? null : p
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** every repo file a session loads: each entry, then its `@` includes inside the repo */
|
|
34
|
+
export function loadedFiles(read: (path: string) => string | null): string[] {
|
|
35
|
+
const seen = new Set<string>()
|
|
36
|
+
const visit = (path: string, hops: number): void => {
|
|
37
|
+
if (seen.has(path)) return
|
|
38
|
+
const text = read(path)
|
|
39
|
+
if (text == null) return
|
|
40
|
+
seen.add(path)
|
|
41
|
+
if (hops >= MAX_HOPS) return
|
|
42
|
+
for (const include of includesOf(text)) {
|
|
43
|
+
const next = repoPath(path, include)
|
|
44
|
+
if (next != null) visit(next, hops + 1)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
// agent/index.md is in sight by contract: its include in CLAUDE.md is another rule's
|
|
48
|
+
for (const e of [...ENTRIES, ROOT_INDEX]) visit(e, 0)
|
|
49
|
+
return [...seen]
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** the md pages and folders (ending in `/`) one listing line names, as repo paths */
|
|
53
|
+
function named(line: string, from: string): string[] {
|
|
54
|
+
const dir = dirname(from)
|
|
55
|
+
const out: string[] = []
|
|
56
|
+
const add = (raw: string): void => {
|
|
57
|
+
const p = raw.split('#')[0]!.replace(/^@/, '')
|
|
58
|
+
if (/^[a-z]+:/.test(p) || !(p.endsWith('.md') || p.endsWith('/'))) return
|
|
59
|
+
const abs = p.startsWith('agent/') ? normalize(p) : normalize(join(dir, p))
|
|
60
|
+
out.push(p.endsWith('/') && !abs.endsWith('/') ? `${abs}/` : abs)
|
|
61
|
+
}
|
|
62
|
+
for (const m of line.matchAll(/\]\(([^)\s]+)\)/g)) add(m[1]!)
|
|
63
|
+
for (const m of line.matchAll(/(?:^|[\s`(@])((?:\.{0,2}\/)?(?:[\w.-]+\/)*[\w.-]+(?:\.md|\/))(?=[\s`),.;:|]|$)/g)) add(m[1]!)
|
|
64
|
+
return out
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** the folder an index page lists, '' for every page, null when the page is no index */
|
|
68
|
+
function scopeOf(path: string, folders: ReadonlySet<string>): string | null {
|
|
69
|
+
if (path === ROOT_INDEX || path.endsWith('/index.md')) return ''
|
|
70
|
+
const sibling = path.slice(0, -'.md'.length)
|
|
71
|
+
return folders.has(sibling) ? `${sibling}/` : null
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** can `index` list `page`: it is an index page and its folder holds the page */
|
|
75
|
+
export function indexCovers(index: string, page: string, folders: ReadonlySet<string>): boolean {
|
|
76
|
+
const scope = scopeOf(index, folders)
|
|
77
|
+
return scope != null && index !== page && page.startsWith(scope)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** what the listing lines of `path` name, code fences and html comments left out */
|
|
81
|
+
function listedBy(path: string, text: string): string[] {
|
|
82
|
+
const out: string[] = []
|
|
83
|
+
let fence = false
|
|
84
|
+
for (const line of text.replace(/<!--[\s\S]*?-->/g, '').split('\n')) {
|
|
85
|
+
if (/^\s*(?:```|~~~)/.test(line)) fence = !fence
|
|
86
|
+
if (!fence && LISTING.test(line)) out.push(...named(line, path))
|
|
87
|
+
}
|
|
88
|
+
return out
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const covers = (names: ReadonlySet<string>, page: string): boolean =>
|
|
92
|
+
names.has(page) || [...names].some((n) => n.endsWith('/') && n !== 'agent/' && page.startsWith(n))
|
|
93
|
+
|
|
94
|
+
/** every page of `pages` (repo path → text) that no index lists, sorted */
|
|
95
|
+
export function unlisted(pages: ReadonlyMap<string, string>, exempt: ReadonlySet<string> = new Set()): string[] {
|
|
96
|
+
const folders = new Set([...pages.keys()].map((p) => dirname(p)))
|
|
97
|
+
const listed = new Set<string>([ROOT_INDEX])
|
|
98
|
+
for (const [path, text] of pages) {
|
|
99
|
+
const scope = scopeOf(path, folders)
|
|
100
|
+
if (scope == null) continue
|
|
101
|
+
// naming its own folder lists no page: each page gets its own line
|
|
102
|
+
const own = new Set([`${dirname(path)}/`, scope])
|
|
103
|
+
for (const p of listedBy(path, text)) if (p !== path && p.startsWith(scope) && !own.has(p)) listed.add(p)
|
|
104
|
+
}
|
|
105
|
+
return [...pages.keys()].filter((p) => !exempt.has(p) && !covers(listed, p)).sort()
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** every index page that no loaded file includes or names on a listing line, sorted */
|
|
109
|
+
export function indexesOutOfSight(pages: ReadonlyMap<string, string>, loaded: readonly string[], read: (path: string) => string | null): string[] {
|
|
110
|
+
const folders = new Set([...pages.keys()].map((p) => dirname(p)))
|
|
111
|
+
const seen = new Set<string>(loaded)
|
|
112
|
+
for (const path of loaded) for (const p of listedBy(path, read(path) ?? '')) seen.add(p)
|
|
113
|
+
return [...pages.keys()].filter((p) => scopeOf(p, folders) != null && !covers(seen, p)).sort()
|
|
114
|
+
}
|