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 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.0.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 (entry index.md, the CLAUDE.md include), so an orphan page or
3
- // a dangling ref there fails. A link that leaves agent/ but stays in the repo
4
- // is checked on disk; a pointer out of the repo (a house wiki) is backticked,
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 no orphan: the house index links it (SK033).
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 linked from somewhere, and every agent/ link resolves',
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`: agent/ is all it reads
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
- // the global contribution is the house index's to link (SK033), never agent/'s
28
- const orphans = lint.orphans.filter((f) => `agent/${f}` !== CONTRIBUTION_FILE)
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('agent/ has no orphans and no dangling refs')
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
- for (const t of out.tasks.filter((x) => x.status === 'unaudited')) c.warn(`${t.id} could not be audited: ${t.why}`)
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?.join(', ') ?? 'the checks'} right now\n`); return 0 }
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 } : {}),
@@ -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
- if (this.only != null && this.only.length > 0) repos = repos.filter((r) => this.only!.includes(r.name))
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 end = d.now() + waitMs
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
- const moving = v.status === 'starting' || v.status === 'stopping' || v.want !== want
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}`}`)
@@ -0,0 +1,3 @@
1
+ /** a repeatable flag's values, each also split on commas: `--only a,b` is `--only a --only b` */
2
+ export const listFlag = (values: readonly string[] | undefined): string[] =>
3
+ (values ?? []).flatMap((v) => v.split(',')).map((v) => v.trim()).filter((v) => v !== '')
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
- if (d.mtime > now - (o.keepMs ?? JOBS_KEEP_MS) && i < (o.max ?? JOBS_MAX)) return
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
  }
@@ -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
- if (!existsSync(p)) continue
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)
@@ -9,3 +9,7 @@ tabWidth = 4
9
9
  ignorePatterns = ["*.md", "*.json", "*.jsonc", "*.yaml", "*.yml", "*.toml"]
10
10
 
11
11
  [lint]
12
+
13
+ [lint.rules]
14
+ # `{}` as a type accepts any non nullish value: name the shape, or write Record<string, never>
15
+ "typescript/no-empty-object-type" = "error"
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
+ }