rvlib-shipkit 2.0.2 → 2.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/package.json +1 -1
- package/src/checks/SK027-agent-wiki-links.check.ts +24 -16
- package/src/checks/SK031-trace-audit.check.ts +3 -1
- package/src/cli/cmd_CacheAudit.ts +3 -2
- package/src/cli/cmd_Ci.ts +4 -3
- package/src/cli/cmd_Fleet.ts +3 -1
- package/src/cli/cmd_GlobalProcesses.ts +7 -2
- package/src/cli/list-flag.ts +3 -0
- package/src/core/jobs.ts +4 -2
- package/src/ledger/store.ts +2 -1
- package/src/mv/mv-md.ts +2 -1
- package/src/scaffold/files/bunfig.toml +4 -0
- package/src/ts/rename.ts +9 -0
- package/src/wiki/agent-index.ts +114 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## 2.1.1
|
|
2
|
+
|
|
3
|
+
- **`--only a,b` takes a comma list** in `sk ci`, `sk cache-audit` and `sk fleet`, the same as repeating the flag.
|
|
4
|
+
- **`sk gp start` waits for pm to act.** The state the switch leaves (down for a start, up for a stop) counts as pm not there yet for 30 s, so a process that takes a few seconds to launch no longer reads as down. A crash still fails at once.
|
|
5
|
+
- **`sk mv` rewrites relative markdown links on a folder move**, through the same pass the page move uses, and a page move no longer crashes on a repo with a git submodule.
|
|
6
|
+
|
|
7
|
+
## 2.1.0
|
|
8
|
+
|
|
9
|
+
- **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.
|
|
10
|
+
- **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.
|
|
11
|
+
- **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.
|
|
12
|
+
|
|
1
13
|
## 2.0.2
|
|
2
14
|
|
|
3
15
|
- **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.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rvlib-shipkit",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.1.1",
|
|
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",
|
|
@@ -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
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import { Command, Option } from 'clipanion'
|
|
5
5
|
import { loadRepoConfig } from '../repo-config.ts'
|
|
6
6
|
import { auditCache } from '../core/cache-audit.ts'
|
|
7
|
+
import { listFlag } from './list-flag.ts'
|
|
7
8
|
|
|
8
9
|
export class CacheAuditCommand extends Command {
|
|
9
10
|
static override paths = [['cache-audit']]
|
|
@@ -28,9 +29,9 @@ export class CacheAuditCommand extends Command {
|
|
|
28
29
|
const root = process.cwd()
|
|
29
30
|
const n = Number(this.sample)
|
|
30
31
|
if (!Number.isInteger(n) || n < 1) { this.context.stdout.write(`FAIL: --sample takes a whole number above 0, got '${this.sample}'\n`); return 1 }
|
|
31
|
-
const r = await auditCache(root, await loadRepoConfig(root), { sample: n, only: this.only })
|
|
32
|
+
const r = await auditCache(root, await loadRepoConfig(root), { sample: n, only: this.only == null ? undefined : listFlag(this.only) })
|
|
32
33
|
if (this.json) { this.context.stdout.write(`${JSON.stringify(r)}\n`); return r.stale.length > 0 ? 1 : 0 }
|
|
33
|
-
if (r.rows.length === 0) { this.context.stdout.write(`skip: nothing to audit, the cache answers none of ${this.only
|
|
34
|
+
if (r.rows.length === 0) { this.context.stdout.write(`skip: nothing to audit, the cache answers none of ${this.only == null ? 'the checks' : listFlag(this.only).join(', ')} right now\n`); return 0 }
|
|
34
35
|
for (const row of r.rows) {
|
|
35
36
|
const same = row.fresh === row.cached || row.fresh === 'skip'
|
|
36
37
|
this.context.stdout.write(same
|
package/src/cli/cmd_Ci.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { runJob } from '../core/job-run.ts'
|
|
|
7
7
|
import { runCi } from '../core/run-ci.ts'
|
|
8
8
|
import { runViaHub } from '../home/via-hub.ts'
|
|
9
9
|
import { maybeSelfUpdateAndRestart } from '../core/self-update.ts'
|
|
10
|
+
import { listFlag } from './list-flag.ts'
|
|
10
11
|
|
|
11
12
|
export class CiCommand extends Command {
|
|
12
13
|
static override paths = [['ci']]
|
|
@@ -37,7 +38,7 @@ export class CiCommand extends Command {
|
|
|
37
38
|
frozen = Option.Boolean('--frozen', false, { description: 'report-only mode for self-maintaining checks: SK005 never touches package.json, and generated paths are never untracked. STATUS.md is still refreshed on disk, but it is gitignored, so a run leaves the tree clean' })
|
|
38
39
|
skip = Option.Array('--skip', [], { description: 'skip a check group (repeatable)' })
|
|
39
40
|
withIds = Option.Array('--with', [], { description: 'include a check the quick gate leaves out (repeatable)' })
|
|
40
|
-
only = Option.Array('--only', [], { description: 'run only this check + its dep closure (repeatable). A group name runs every check declaring that group. No repair pass unless --fix is named' })
|
|
41
|
+
only = Option.Array('--only', [], { description: 'run only this check + its dep closure (repeatable, or comma separated). A group name runs every check declaring that group. No repair pass unless --fix is named' })
|
|
41
42
|
fresh = Option.Boolean('--fresh', false, { description: 'run every check: no answer comes from the cache, and what this run finds is stored fresh' })
|
|
42
43
|
full = Option.Boolean('--full', false, { description: `run the FULL test tier: every suite the repo declares, the slow ones it skips while you work included. What the release path always runs. Same as SHIPKIT_TEST_FULL=1` })
|
|
43
44
|
|
|
@@ -74,7 +75,7 @@ export class CiCommand extends Command {
|
|
|
74
75
|
...(this.frozen ? ['--frozen'] : []),
|
|
75
76
|
...this.skip.flatMap((v) => ['--skip', v]),
|
|
76
77
|
...this.withIds.flatMap((v) => ['--with', v]),
|
|
77
|
-
...this.only.flatMap((v) => ['--only', v]),
|
|
78
|
+
...listFlag(this.only).flatMap((v) => ['--only', v]),
|
|
78
79
|
...(this.fresh ? ['--fresh'] : []),
|
|
79
80
|
...(this.full ? ['--full'] : []),
|
|
80
81
|
...(this.verbose ? ['--verbose'] : []),
|
|
@@ -95,7 +96,7 @@ export class CiCommand extends Command {
|
|
|
95
96
|
frozen: this.frozen,
|
|
96
97
|
skipGroups: this.skip,
|
|
97
98
|
withIds: this.withIds,
|
|
98
|
-
onlyIds: this.only,
|
|
99
|
+
onlyIds: listFlag(this.only),
|
|
99
100
|
fresh: this.fresh,
|
|
100
101
|
...(this.full ? { tests: 'full' as const } : {}),
|
|
101
102
|
...(this.verbose ? { verbose: true } : {}),
|
package/src/cli/cmd_Fleet.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { Command, Option } from 'clipanion'
|
|
|
6
6
|
import { join } from 'path'
|
|
7
7
|
import { npmLatest, resolveLatest, scanFleet, type FleetRepo } from '../fleet/scan-fleet.ts'
|
|
8
8
|
import { short, updateFleet } from '../fleet/update-fleet.ts'
|
|
9
|
+
import { listFlag } from './list-flag.ts'
|
|
9
10
|
|
|
10
11
|
export class FleetCommand extends Command {
|
|
11
12
|
static override paths = [['fleet']]
|
|
@@ -32,7 +33,8 @@ export class FleetCommand extends Command {
|
|
|
32
33
|
async execute(): Promise<number> {
|
|
33
34
|
const root = this.root ?? join(process.cwd(), '..')
|
|
34
35
|
let repos = scanFleet(root)
|
|
35
|
-
|
|
36
|
+
const only = listFlag(this.only)
|
|
37
|
+
if (only.length > 0) repos = repos.filter((r) => only.includes(r.name))
|
|
36
38
|
const w = (l: string) => this.context.stdout.write(`${l}\n`)
|
|
37
39
|
if (repos.length === 0) {
|
|
38
40
|
w(`skip: no repo under ${root} depends on an rvlib-* package`)
|
|
@@ -172,6 +172,8 @@ export type SwitchDeps = {
|
|
|
172
172
|
/** the longest a switch is followed: a bring-up polls its health for minutes */
|
|
173
173
|
export const SWITCH_WAIT_MS = 10 * 60_000
|
|
174
174
|
const SWITCH_POLL_MS = 1_000
|
|
175
|
+
/** how long the state the switch left counts as pm not having acted yet */
|
|
176
|
+
export const SWITCH_GRACE_MS = 30_000
|
|
175
177
|
|
|
176
178
|
/** flip a global process through pm, then follow pm's state until its probe
|
|
177
179
|
* settles it: 0 once it is up (or down for stop), 1 otherwise. The launcher's
|
|
@@ -183,7 +185,8 @@ export async function switchProcess(id: string, act: SwitchAct, d: SwitchDeps, w
|
|
|
183
185
|
return 1
|
|
184
186
|
}
|
|
185
187
|
const want = act === 'stop' ? 'down' : 'up'
|
|
186
|
-
const
|
|
188
|
+
const start = d.now()
|
|
189
|
+
const end = start + waitMs
|
|
187
190
|
let said = ''
|
|
188
191
|
for (;;) {
|
|
189
192
|
const v = await d.view(id)
|
|
@@ -195,7 +198,9 @@ export async function switchProcess(id: string, act: SwitchAct, d: SwitchDeps, w
|
|
|
195
198
|
said = v.status
|
|
196
199
|
d.log(`${id}: ${v.status}`)
|
|
197
200
|
}
|
|
198
|
-
|
|
201
|
+
// the state it is leaving, read just after the switch, is pm not there yet
|
|
202
|
+
const leaving = v.status === (want === 'up' ? 'down' : 'up') && d.now() - start < SWITCH_GRACE_MS
|
|
203
|
+
const moving = v.status === 'starting' || v.status === 'stopping' || v.want !== want || leaving
|
|
199
204
|
if (!moving) {
|
|
200
205
|
if (v.status === want) return 0
|
|
201
206
|
d.log(`FAIL ${id} is ${v.status}, not ${want}${v.note == null ? '' : `: ${v.note}`}`)
|
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
|
|
package/src/mv/mv-md.ts
CHANGED
|
@@ -154,7 +154,8 @@ export function movePage(root: string, oldRel: string, newRel: string, io: MvIo,
|
|
|
154
154
|
let touched = 0
|
|
155
155
|
for (const f of tracked) {
|
|
156
156
|
const p = join(root, f)
|
|
157
|
-
|
|
157
|
+
// a submodule is one ls-files entry and a directory: nothing to read
|
|
158
|
+
if (!isFile(p)) continue
|
|
158
159
|
const before = readFileSync(p, 'utf8')
|
|
159
160
|
let after = retargetRelativeRefs(root, before, dirOf(f), dirOf(moves.get(f) ?? f), moves)
|
|
160
161
|
for (const [oldTok, newTok] of pairs) after = after.replace(bound(oldTok), newTok)
|
package/src/ts/rename.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { basename, dirname, extname, join, relative, resolve } from 'path'
|
|
|
16
16
|
import { canonicalSpecifier, isSkipped, loadConfig, toPosix, type RvtConfig } from 'rvlib-shipkit/src/ts/config.ts'
|
|
17
17
|
import { extractSpecs } from 'rvlib-shipkit/src/ts/source-graph.ts'
|
|
18
18
|
import { gitBin } from '../core/git-bin.ts'
|
|
19
|
+
import { retargetRelativeRefs } from '../mv/mv-md.ts'
|
|
19
20
|
|
|
20
21
|
const CODE_RE = /\.[cm]?[jt]sx?$/
|
|
21
22
|
|
|
@@ -106,7 +107,15 @@ export function runRename(srcArg: string, dstArg: string, opts: { apply?: boolea
|
|
|
106
107
|
const isFile = (rel: string): boolean => {
|
|
107
108
|
try { return statSync(join(repoRoot, rel)).isFile() } catch { return false }
|
|
108
109
|
}
|
|
110
|
+
// a markdown link is relative to its page: the md engine's pass retargets it
|
|
111
|
+
const mdMoves = new Map(allFiles.flatMap((f): [string, string][] => { const to = movedTo(f); return to == null ? [] : [[f, to]] }))
|
|
112
|
+
const dirOf = (p: string): string => { const d = dirname(p); return d === '.' ? '' : d }
|
|
109
113
|
const rewriteFile = (file: string, content: string): { text: string; count: number } => {
|
|
114
|
+
if (file.endsWith('.md')) {
|
|
115
|
+
const linked = retargetRelativeRefs(repoRoot, content, dirOf(file), dirOf(movedTo(file) ?? file), mdMoves)
|
|
116
|
+
const scanned = scanPairs(linked, pairs)
|
|
117
|
+
return { text: scanned.text, count: scanned.count + (linked === content ? 0 : 1) }
|
|
118
|
+
}
|
|
110
119
|
const rel = CODE_RE.test(file) ? relativeRewrites(file, content, movedTo, isFile) : new Map<string, string>()
|
|
111
120
|
if (rel.size === 0) return scanPairs(content, pairs)
|
|
112
121
|
// the relative specifiers this move retargets are set aside while the
|
|
@@ -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
|
+
}
|