rvlib-shipkit 0.33.0 → 0.35.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.
Files changed (50) hide show
  1. package/CHANGELOG.md +23 -1
  2. package/package.json +1 -1
  3. package/src/asks/asks-queue.ts +15 -2
  4. package/src/cli/cmd_Asks.ts +2 -1
  5. package/src/cli/cmd_Ci.ts +3 -0
  6. package/src/cli/cmd_Keys.ts +113 -0
  7. package/src/cli.ts +2 -0
  8. package/src/core/check-suite.ts +54 -47
  9. package/src/core/discover-checks.ts +30 -7
  10. package/src/core/floor.ts +34 -6
  11. package/src/core/locked-write.ts +170 -1
  12. package/src/core/motherhome.ts +54 -1
  13. package/src/core/one-way-in-policy.ts +8 -8
  14. package/src/core/run-check.ts +92 -12
  15. package/src/core/run-ci.ts +115 -17
  16. package/src/core/run-fixers.ts +61 -18
  17. package/src/core/task-runner.ts +62 -6
  18. package/src/hooks/run-hooks.ts +23 -2
  19. package/src/ledger/gate-units.ts +84 -0
  20. package/src/ledger/git-key.ts +381 -0
  21. package/src/ledger/git-scan.ts +327 -0
  22. package/src/ledger/ledger.ts +202 -0
  23. package/src/ledger/store.ts +122 -0
  24. package/src/ledger/toolchain-identity.ts +45 -8
  25. package/src/pb/collection-scaffold.ts +4 -4
  26. package/src/release/run-bump.ts +50 -5
  27. package/src/release/run-release.ts +5 -0
  28. package/src/release/run-releases.ts +7 -1
  29. package/src/release/unit-gate.ts +17 -4
  30. package/src/repo-config.ts +26 -4
  31. package/src/rules/SK001-package-json-shape.ts +8 -2
  32. package/src/rules/SK002-contracts-fulfillness.ts +4 -0
  33. package/src/rules/SK003-claude-md-shape.ts +7 -2
  34. package/src/rules/SK004-gitignore-policy.ts +12 -4
  35. package/src/rules/SK005-rvlib-versions.ts +28 -1
  36. package/src/rules/SK006-npm-publish-safety.ts +3 -0
  37. package/src/rules/SK008-reviewer-agent.ts +6 -2
  38. package/src/rules/SK009-commit-ai-tells.ts +3 -0
  39. package/src/rules/SK010-undeclared-release-unit.ts +2 -0
  40. package/src/rules/SK011-sibling-unit-ranges.ts +2 -0
  41. package/src/rules/SK012-config-barrel-import.ts +6 -2
  42. package/src/rules/SK013-one-way-in-policy.ts +24 -4
  43. package/src/rules/rule.ts +22 -1
  44. package/src/rules/run-fix.ts +5 -3
  45. package/src/scaffold/files/verify-skill.md +1 -1
  46. package/src/ts/config.ts +66 -6
  47. package/src/ts/cycles.ts +7 -92
  48. package/src/ts/source-graph.ts +158 -0
  49. package/src/ts/tsconfig-projects.ts +101 -0
  50. package/src/ts/typecheck-cli.ts +46 -0
@@ -12,12 +12,13 @@
12
12
  // Both are generated from ONE table (core/one-way-in-policy.ts). The file is
13
13
  // TRACKED on purpose: `.claude/settings.local.json` is gitignored by the house
14
14
  // scaffold, so a policy written there would bind exactly one checkout.
15
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
15
+ import { existsSync, mkdirSync, readFileSync } from 'fs'
16
16
  import { dirname, join } from 'path'
17
17
  import { inferBin } from '../core/canonical-scripts.ts'
18
18
  import { denyRules, RAW_RUNNERS } from '../core/one-way-in-policy.ts'
19
19
  import type { RepoConfig } from '../repo-config.ts'
20
20
  import type { FixResult, Rule, RuleEnv } from './rule.ts'
21
+ import { atomicWrite } from '../core/locked-write.ts'
21
22
 
22
23
  const OWNER = 'build/shipkit.md'
23
24
  export const SETTINGS_PATH = '.claude/settings.json'
@@ -44,6 +45,11 @@ export function guardCommand(bin?: string): string {
44
45
  return `bun "$CLAUDE_PROJECT_DIR/${path}"`
45
46
  }
46
47
 
48
+ /** a guard under `node_modules/` that is not there yet says the dependency is
49
+ * not installed, which `bun install` fixes and no edit to this repo can. Any
50
+ * other unresolvable path is a config this repo chose and got wrong. */
51
+ const isUninstalledDep = (rel: string): boolean => rel.startsWith('node_modules/')
52
+
47
53
  const readSettings = (root: string): Settings | null => {
48
54
  const p = join(root, SETTINGS_PATH)
49
55
  if (!existsSync(p)) return null
@@ -81,6 +87,9 @@ export const oneWayInPolicy: Rule = {
81
87
  blocking: true,
82
88
  title: 'one way in policy',
83
89
  owner: OWNER,
90
+ // .claude/settings.json, package.json, and whether the declared guard
91
+ // script resolves — the last one is a config-chosen path
92
+ inputs: ['.'],
84
93
  lint: (t, env) => {
85
94
  const want = denyRules(exemptSlots(env.config))
86
95
  if (want.length === 0) return t.ok('every governed slot is exempt (repo.config oneWayIn/defaults) — nothing to deny')
@@ -98,10 +107,21 @@ export const oneWayInPolicy: Rule = {
98
107
  t.fail(`${SETTINGS_PATH}: no PreToolUse guard — a bare deny tells the model only that it was denied, so it goes looking for another runner instead of the shipkit command`)
99
108
  else {
100
109
  const missing = guardScriptMissing(env.root, guard)
101
- if (missing != null)
110
+ // the hook is DECLARED either way, so it starts working the moment
111
+ // the dependency lands: no second fix run, nothing to remember. An
112
+ // uninstalled dependency is therefore a WARN naming the command
113
+ // that fixes it, not a red gate on a repo nobody has installed yet,
114
+ // and the deny list is the floor throughout. A path this repo chose
115
+ // and got wrong is still a FAIL: it will never resolve on its own.
116
+ if (missing != null && isUninstalledDep(missing))
117
+ t.warn(
118
+ `${SETTINGS_PATH}: the PreToolUse guard points at ${missing}, which is not installed yet — until then a refusal names no replacement, and the deny list is the whole floor`,
119
+ `bun install (or bun add -d rvlib-shipkit)`,
120
+ )
121
+ else if (missing != null)
102
122
  t.fail(
103
123
  `${SETTINGS_PATH}: the PreToolUse guard points at ${missing}, which does not exist — every Bash call errors and no refusal ever names the shipkit command`,
104
- `bun install (or bun add -d rvlib-shipkit)`,
124
+ `point it at this repo's own copy of hooks/one-way-in-guard.ts, or delete the hook and let the deny list stand alone`,
105
125
  )
106
126
  }
107
127
  if (!t.failed) t.ok(`${SETTINGS_PATH} closes ${want.length}/${RAW_RUNNERS.length} raw runner(s), with the replacement named`)
@@ -130,7 +150,7 @@ export const oneWayInPolicy: Rule = {
130
150
  }
131
151
  if (changes.length === 0) return { changed: false, summary: `${SETTINGS_PATH} already closes every raw runner` }
132
152
  mkdirSync(dirname(p), { recursive: true })
133
- writeFileSync(p, JSON.stringify(s, null, 2) + '\n')
153
+ atomicWrite(p, JSON.stringify(s, null, 2) + '\n')
134
154
  return { changed: true, summary: `${SETTINGS_PATH}: ${changes.join(', ')}` }
135
155
  },
136
156
  }
package/src/rules/rule.ts CHANGED
@@ -28,8 +28,29 @@ export type Rule = {
28
28
  * as the fix: pointer whenever a failing/warning lint attached none */
29
29
  owner: string
30
30
  /** clean-pass cache duration (check-suite TTL, e.g. '24h') — for rules that
31
- * probe the network; skip/WARN/FAIL runs are never cached */
31
+ * probe the network; skip/WARN/FAIL runs are never cached. Declares the
32
+ * WORLD population (ledger/git-key.ts). */
32
33
  ttl?: string
34
+ /** CONTENT population: the repo-relative paths this rule reads. A rule with
35
+ * neither `inputs` nor `ttl` is never cached, which is the safe default.
36
+ * A rule declared by a REPO rather than by shipkit must list the dir its
37
+ * own code lives in too: shipkit's `src` tree id covers the built-ins and
38
+ * cannot see a repo's. Declaring both `inputs` and `ttl` is a registration
39
+ * failure — a rule reads files or it reads the world, never both. */
40
+ inputs?: string[]
41
+ /** repo-relative entry file(s) of this rule's OWN code, walked through the
42
+ * module graph so a shared helper the rule imports is in its key too. A
43
+ * built-in SK rule needs none: the toolchain identity content-hashes
44
+ * shipkit's whole source, so editing any of it moves every key. A rule a
45
+ * REPO declares is invisible to that, so it names its module here, or
46
+ * includes the dir holding it in `inputs`. Without either, fixing a bug
47
+ * in the rule does not move its key. */
48
+ code?: string[]
49
+ /** a DECIDED never-cacheable, with the reason: this rule reads git history,
50
+ * a path that may live outside the repo, or the wall clock, so no key can
51
+ * describe it. Says somebody looked, which is what takes it off the
52
+ * burn-down list. */
53
+ never?: string
33
54
  /** cache-identity contribution for TTL'd rules whose verdict depends on
34
55
  * config — a changed key busts the cache instantly instead of replaying
35
56
  * a stale assertion for the TTL */
@@ -47,9 +47,11 @@ export async function runFix(root: string, o: FixOpts = {}): Promise<number> {
47
47
  continue
48
48
  }
49
49
  const f = await rule.fix!(env)
50
- // an unchanged fix is the DESIGNED outcome for judgment findings —
51
- // a note, not a warning
52
- print(f.changed ? `ok: [${rule.id}] fixed: ${f.summary}` : `[${rule.id}] fix changed nothing — ${f.summary}`)
50
+ // an unchanged fix is the DESIGNED outcome for judgment findings, so
51
+ // it is an ok, not a warning. It carries a frozen wire prefix like
52
+ // every other line the fix pass emits: `shipkit ci` promises its whole
53
+ // output is parseable, and a bare line breaks that for every reader.
54
+ print(f.changed ? `ok: [${rule.id}] fixed: ${f.summary}` : `ok: [${rule.id}] fix changed nothing — ${f.summary}`)
53
55
  const after = new CheckCtx(new Map())
54
56
  await runRuleLint(rule, after, env)
55
57
  // the fix already ran: advertising it again on surviving findings
@@ -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 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.
14
+ - Logic → fast headless bun tests, run through `shipkit ci --only test` (tiny ~10-line test files reusing fixtures). While you fix one bug, `bun run test:raw <test file>` is the narrow way in, and one test by name is `bun run test:raw -- -t "<name>"`.
15
15
  - Look & feel → NEVER screenshot-self-verify; the repo owner playtests it.
16
16
  - Verify as the end user would: drive the real flow, observe the real signal.
17
17
 
package/src/ts/config.ts CHANGED
@@ -40,12 +40,72 @@ export function findRepoRoot(cwd: string = process.cwd()): string {
40
40
  return out !== '' ? out : cwd
41
41
  }
42
42
 
43
- // tsconfig files are JSONC — strip comments + trailing commas, loosely
44
- function parseJsonc(text: string): unknown {
45
- const noComments = text
46
- .replace(/\/\*[\s\S]*?\*\//g, '')
47
- .replace(/(^|[^:])\/\/[^\n]*/g, '$1') // keep 'http://...' intact
48
- return JSON.parse(noComments.replace(/,\s*([}\]])/g, '$1'))
43
+ // tsconfig files are JSONC: strip comments and trailing commas.
44
+ //
45
+ // A quote-aware scanner rather than regexes, because tsconfig is the one
46
+ // dialect where the regex version is guaranteed to be wrong: `"src/**/*"` is
47
+ // a `/*` with its own `*/`, so a block-comment regex eats an arbitrary span of
48
+ // a perfectly valid file. Measured across ~/dev, the regex version THREW on 8
49
+ // real tsconfigs (this repo's included) and silently rewrote `"src/**/*.ts"`
50
+ // to `"src*.ts"` in 8 more. This parser gates typecheck AND publish, so it
51
+ // does not get to be loose.
52
+ export function parseJsonc(text: string): unknown {
53
+ let out = ''
54
+ let i = 0
55
+ while (i < text.length) {
56
+ const c = text[i]!
57
+ if (c === '"') {
58
+ out += c
59
+ i++
60
+ while (i < text.length) {
61
+ const s = text[i]!
62
+ out += s
63
+ i++
64
+ if (s === '\\') { out += text[i] ?? ''; i++; continue }
65
+ if (s === '"') break
66
+ }
67
+ continue
68
+ }
69
+ if (c === '/' && text[i + 1] === '/') { while (i < text.length && text[i] !== '\n') i++; continue }
70
+ if (c === '/' && text[i + 1] === '*') {
71
+ i += 2
72
+ while (i < text.length && !(text[i] === '*' && text[i + 1] === '/')) i++
73
+ i += 2
74
+ continue
75
+ }
76
+ out += c
77
+ i++
78
+ }
79
+ return JSON.parse(dropTrailingCommas(out))
80
+ }
81
+
82
+ /** a comma before the next `}` or `]`, outside any string, is dropped */
83
+ function dropTrailingCommas(text: string): string {
84
+ let out = ''
85
+ let i = 0
86
+ let pendingComma = -1 // index in `out` of a comma awaiting its next token
87
+ while (i < text.length) {
88
+ const c = text[i]!
89
+ if (c === '"') {
90
+ if (pendingComma >= 0) pendingComma = -1
91
+ out += c
92
+ i++
93
+ while (i < text.length) {
94
+ const s = text[i]!
95
+ out += s
96
+ i++
97
+ if (s === '\\') { out += text[i] ?? ''; i++; continue }
98
+ if (s === '"') break
99
+ }
100
+ continue
101
+ }
102
+ if (c === ',') { pendingComma = out.length; out += c; i++; continue }
103
+ if ((c === '}' || c === ']') && pendingComma >= 0) out = out.slice(0, pendingComma) + out.slice(pendingComma + 1)
104
+ if (!/\s/.test(c)) pendingComma = -1
105
+ out += c
106
+ i++
107
+ }
108
+ return out
49
109
  }
50
110
 
51
111
  function rootsFromTsconfig(root: string): string[] {
package/src/ts/cycles.ts CHANGED
@@ -1,87 +1,14 @@
1
1
  // zero-dep circular import detector. The repo uses absolute repo-root import
2
2
  // specifiers ('src/...') with explicit .ts extensions, so resolution is string
3
- // identity: crawl .ts/.tsx files, regex out the runtime import specifiers
4
- // (type-only imports don't count), build the graph, tarjan the SCCs, enumerate
5
- // elementary cycles. Runs on a bare checkout, no install needed.
6
- import { readdirSync, readFileSync } from 'fs'
7
- import { dirname, join } from 'path'
8
- import { isSkipped, loadConfig, resolvePackageSpecifier, toPosix, type RvtConfig } from 'rvlib-shipkit/src/ts/config.ts'
9
- import { templateLineMask } from 'rvlib-shipkit/src/ts/source-mask.ts'
3
+ // identity. The crawl, the specifier extraction and the resolution rule live
4
+ // in source-graph.ts (shared with the ledger key layer, so a cycle and a cache
5
+ // key can never disagree about where an import points); this file owns the
6
+ // tarjan + elementary-cycle half. Runs on a bare checkout, no install needed.
7
+ import { loadConfig, type RvtConfig } from 'rvlib-shipkit/src/ts/config.ts'
8
+ import { buildSourceGraph } from 'rvlib-shipkit/src/ts/source-graph.ts'
10
9
 
11
10
  const CYCLE_CAP = 500 // bail loudly instead of exploding on a pathological graph
12
11
 
13
- function crawl(cfg: RvtConfig, dir: string, out: string[]): void {
14
- let entries
15
- try {
16
- entries = readdirSync(join(cfg.root, dir), { withFileTypes: true })
17
- } catch {
18
- return
19
- }
20
- for (const entry of entries) {
21
- const rel = `${dir}/${entry.name}`
22
- if (entry.isDirectory()) {
23
- if (!isSkipped(rel, cfg)) crawl(cfg, rel, out)
24
- } else if (/\.tsx?$/.test(entry.name) && !entry.name.endsWith('.d.ts')) {
25
- out.push(rel)
26
- }
27
- }
28
- }
29
-
30
- // strip comments (line comments only when preceded by whitespace, so
31
- // 'http://...' survives) and type-only import/export statements, then extract
32
- // the remaining runtime specifiers
33
- export function extractSpecs(content: string): string[] {
34
- // template-literal content is text, a codegen emitting import lines inside
35
- // a backtick block is not an import of this module. single-line templates
36
- // strip in place, multiline ones blank via the shared mask (null mask =
37
- // desynced walk, scan everything rather than eat code between backticks)
38
- const rawLines = content.split('\n')
39
- const mask = templateLineMask(rawLines)
40
- const demasked = mask == null ? content : rawLines.map((l, i) => (mask[i] ? '' : l)).join('\n')
41
- const cleaned = demasked
42
- .replace(/\/\*[\s\S]*?\*\//g, '')
43
- .replace(/(^|\s)\/\/[^\n]*/g, '$1')
44
- .replace(/`(?:\\.|[^`\\\n])*`/g, '``')
45
- .replace(/\b(?:import|export)\s+type\s[^'"]*?from\s*(['"])[^'"\n]*\1/g, '')
46
- const specs: string[] = []
47
- // import x from / export { x } from — skipped when every specifier is `type X` (erased at runtime)
48
- for (const m of cleaned.matchAll(/\b(?:import|export)\s([^'";]*?)from\s*['"]([^'"\n]+)['"]/g)) {
49
- const clause = (m[1] ?? '').trim()
50
- if (/^\{[^{}]*\}$/.test(clause)) {
51
- const items = clause.slice(1, -1).split(',').map((s) => s.trim()).filter((s) => s !== '')
52
- if (items.length > 0 && items.every((s) => /^type\s/.test(s))) continue
53
- }
54
- if (m[2] != null) specs.push(m[2])
55
- }
56
- for (const re of [
57
- /\bimport\s*['"]([^'"\n]+)['"]/g, // side-effect import
58
- /\b(?:import|require)\s*\(\s*['"]([^'"\n]+)['"]\s*\)/g, // dynamic import / require
59
- ]) {
60
- for (const m of cleaned.matchAll(re)) {
61
- if (m[1] != null) specs.push(m[1])
62
- }
63
- }
64
- return specs
65
- }
66
-
67
- function resolveSpec(cfg: RvtConfig, fromFile: string, spec: string, fileSet: Set<string>): string | null {
68
- let base: string
69
- const viaPackage = resolvePackageSpecifier(spec, cfg)
70
- if (spec.startsWith('./') || spec.startsWith('../')) {
71
- base = toPosix(join(dirname(fromFile), spec))
72
- } else if (viaPackage != null) {
73
- base = viaPackage // package-name specifier → the sub-package's dir
74
- } else if (cfg.roots.some((r) => spec.startsWith(`${r}/`))) {
75
- base = spec
76
- } else {
77
- return null // third-party
78
- }
79
- for (const candidate of [base, base.replace(/\.js$/, '.ts'), `${base}.ts`, `${base}.tsx`, `${base}/index.ts`, `${base}/index.tsx`]) {
80
- if (fileSet.has(candidate)) return candidate
81
- }
82
- return null
83
- }
84
-
85
12
  // tarjan strongly connected components, iterative to survive deep graphs
86
13
  function tarjanSCCs(nodes: string[], adj: Map<string, string[]>): string[][] {
87
14
  const index = new Map<string, number>()
@@ -166,19 +93,7 @@ function cyclesInSCC(scc: string[], adj: Map<string, string[]>): string[][] {
166
93
  }
167
94
 
168
95
  export function findCircularDependencies(cfg: RvtConfig = loadConfig()): string[][] {
169
- const files: string[] = []
170
- for (const root of cfg.roots) crawl(cfg, root, files)
171
- files.sort()
172
- const fileSet = new Set(files)
173
- const adj = new Map<string, string[]>()
174
- for (const file of files) {
175
- const targets = new Set<string>()
176
- for (const spec of extractSpecs(readFileSync(join(cfg.root, file), 'utf8'))) {
177
- const target = resolveSpec(cfg, file, spec, fileSet)
178
- if (target != null && target !== file) targets.add(target)
179
- }
180
- adj.set(file, [...targets].sort())
181
- }
96
+ const { files, adj } = buildSourceGraph(cfg)
182
97
  const cycles: string[][] = []
183
98
  for (const scc of tarjanSCCs(files, adj)) {
184
99
  if (scc.length < 2) continue
@@ -0,0 +1,158 @@
1
+ // the module graph, ONE resolver, two traversals.
2
+ //
3
+ // `cycles` needs the WHOLE graph (tarjan wants every node). The ledger key
4
+ // layer needs the transitive import closure of a handful of entry files and
5
+ // nothing else, and building the whole graph to answer that costs the repo's
6
+ // entire source tree per run. Both go through `resolveSpec` here, so what a
7
+ // cycle sees and what a key sees can never disagree about where an import
8
+ // points.
9
+ import { existsSync, readFileSync, readdirSync } from 'fs'
10
+ import { dirname, join } from 'path'
11
+ import { isSkipped, loadConfig, resolvePackageSpecifier, toPosix, type RvtConfig } from 'rvlib-shipkit/src/ts/config.ts'
12
+ import { templateLineMask } from 'rvlib-shipkit/src/ts/source-mask.ts'
13
+
14
+ /** repo-relative posix paths of every source file under the config roots */
15
+ export function crawlSources(cfg: RvtConfig, dir: string, out: string[]): void {
16
+ let entries
17
+ try {
18
+ entries = readdirSync(join(cfg.root, dir), { withFileTypes: true })
19
+ } catch {
20
+ return
21
+ }
22
+ for (const entry of entries) {
23
+ const rel = `${dir}/${entry.name}`
24
+ if (entry.isDirectory()) {
25
+ if (!isSkipped(rel, cfg)) crawlSources(cfg, rel, out)
26
+ } else if (/\.tsx?$/.test(entry.name) && !entry.name.endsWith('.d.ts')) {
27
+ out.push(rel)
28
+ }
29
+ }
30
+ }
31
+
32
+ // strip comments (line comments only when preceded by whitespace, so
33
+ // 'http://...' survives) and type-only import/export statements, then extract
34
+ // the remaining runtime specifiers
35
+ export function extractSpecs(content: string): string[] {
36
+ // template-literal content is text, a codegen emitting import lines inside
37
+ // a backtick block is not an import of this module. single-line templates
38
+ // strip in place, multiline ones blank via the shared mask (null mask =
39
+ // desynced walk, scan everything rather than eat code between backticks)
40
+ const rawLines = content.split('\n')
41
+ const mask = templateLineMask(rawLines)
42
+ const demasked = mask == null ? content : rawLines.map((l, i) => (mask[i] ? '' : l)).join('\n')
43
+ const cleaned = demasked
44
+ .replace(/\/\*[\s\S]*?\*\//g, '')
45
+ .replace(/(^|\s)\/\/[^\n]*/g, '$1')
46
+ .replace(/`(?:\\.|[^`\\\n])*`/g, '``')
47
+ .replace(/\b(?:import|export)\s+type\s[^'"]*?from\s*(['"])[^'"\n]*\1/g, '')
48
+ const specs: string[] = []
49
+ // import x from / export { x } from — skipped when every specifier is `type X` (erased at runtime)
50
+ for (const m of cleaned.matchAll(/\b(?:import|export)\s([^'";]*?)from\s*['"]([^'"\n]+)['"]/g)) {
51
+ const clause = (m[1] ?? '').trim()
52
+ if (/^\{[^{}]*\}$/.test(clause)) {
53
+ const items = clause.slice(1, -1).split(',').map((s) => s.trim()).filter((s) => s !== '')
54
+ if (items.length > 0 && items.every((s) => /^type\s/.test(s))) continue
55
+ }
56
+ if (m[2] != null) specs.push(m[2])
57
+ }
58
+ for (const re of [
59
+ /\bimport\s*['"]([^'"\n]+)['"]/g, // side-effect import
60
+ /\b(?:import|require)\s*\(\s*['"]([^'"\n]+)['"]\s*\)/g, // dynamic import / require
61
+ ]) {
62
+ for (const m of cleaned.matchAll(re)) {
63
+ if (m[1] != null) specs.push(m[1])
64
+ }
65
+ }
66
+ return specs
67
+ }
68
+
69
+ /** THE resolution rule. `has` decides what exists: a prebuilt file set for the
70
+ * whole-graph walk, a disk probe for the lazy closure. Returns null for
71
+ * third-party specifiers and for anything outside the config roots. */
72
+ export function resolveSpec(cfg: RvtConfig, fromFile: string, spec: string, has: (rel: string) => boolean): string | null {
73
+ let base: string
74
+ const viaPackage = resolvePackageSpecifier(spec, cfg)
75
+ if (spec.startsWith('./') || spec.startsWith('../')) {
76
+ base = toPosix(join(dirname(fromFile), spec))
77
+ } else if (viaPackage != null) {
78
+ base = viaPackage // package-name specifier → the sub-package's dir
79
+ } else {
80
+ // a root-relative specifier ('src/a.ts'), or anything else the caller's
81
+ // `has` recognises. Third-party names die in the candidate loop below
82
+ // rather than here: `has` is what decides existence, and a resolution
83
+ // rule that also decided membership made every specifier outside the
84
+ // crawl roots invisible, which is where the checks in this repo live.
85
+ base = spec
86
+ }
87
+ for (const candidate of [base, base.replace(/\.js$/, '.ts'), `${base}.ts`, `${base}.tsx`, `${base}/index.ts`, `${base}/index.tsx`]) {
88
+ if (has(candidate)) return candidate
89
+ }
90
+ return null
91
+ }
92
+
93
+ export type SourceGraph = {
94
+ /** every source file under the roots, repo-relative posix, sorted */
95
+ files: string[]
96
+ /** file → the local files it imports at runtime, sorted */
97
+ adj: Map<string, string[]>
98
+ }
99
+
100
+ /** the WHOLE graph — every file under the roots crawled and resolved. */
101
+ export function buildSourceGraph(cfg: RvtConfig = loadConfig()): SourceGraph {
102
+ const files: string[] = []
103
+ for (const root of cfg.roots) crawlSources(cfg, root, files)
104
+ files.sort()
105
+ const fileSet = new Set(files)
106
+ const has = (p: string): boolean => fileSet.has(p)
107
+ const adj = new Map<string, string[]>()
108
+ for (const file of files) {
109
+ const targets = new Set<string>()
110
+ for (const spec of extractSpecs(readFileSync(join(cfg.root, file), 'utf8'))) {
111
+ const target = resolveSpec(cfg, file, spec, has)
112
+ if (target != null && target !== file) targets.add(target)
113
+ }
114
+ adj.set(file, [...targets].sort())
115
+ }
116
+ return { files, adj }
117
+ }
118
+
119
+ /**
120
+ * the transitive import closure of `entries`, entries included, WITHOUT
121
+ * crawling the repo: files are read only as the walk reaches them.
122
+ *
123
+ * This is what puts a unit's own code in its cache key. A check that imports a
124
+ * shared helper carries that helper's identity, so fixing the helper moves the
125
+ * check's key. An entry that does not exist is dropped: the caller already
126
+ * knows a missing declared path makes a unit uncacheable, and a throw here
127
+ * would turn a stale declaration into a dead gate.
128
+ */
129
+ export function importClosure(cfg: RvtConfig, entries: string[]): string[] {
130
+ const probe = new Map<string, boolean>()
131
+ const has = (p: string): boolean => {
132
+ let r = probe.get(p)
133
+ if (r == null) {
134
+ r = /\.tsx?$/.test(p) && existsSync(join(cfg.root, p))
135
+ probe.set(p, r)
136
+ }
137
+ return r
138
+ }
139
+ const seen = new Set<string>()
140
+ const queue = entries.map((e) => toPosix(e)).filter((e) => has(e))
141
+ for (const e of queue) seen.add(e)
142
+ while (queue.length > 0) {
143
+ const file = queue.shift()!
144
+ let content: string
145
+ try {
146
+ content = readFileSync(join(cfg.root, file), 'utf8')
147
+ } catch {
148
+ continue
149
+ }
150
+ for (const spec of extractSpecs(content)) {
151
+ const target = resolveSpec(cfg, file, spec, has)
152
+ if (target == null || seen.has(target)) continue
153
+ seen.add(target)
154
+ queue.push(target)
155
+ }
156
+ }
157
+ return [...seen].sort()
158
+ }
@@ -0,0 +1,101 @@
1
+ // which tsconfig files a typecheck must actually visit.
2
+ //
3
+ // `tsc --noEmit -p <solution>` on a solution-style config (`files: []` plus
4
+ // `references`) compiles ZERO files and exits 0. The gate then prints
5
+ // `ok: typecheck` while a type error sits in src, which is the single worst
6
+ // output this repo can produce: an unmeasured green nobody can tell from a
7
+ // measured one. tsc will not warn about it, so the caller has to resolve the
8
+ // leaves itself and refuse when there are none.
9
+ import { existsSync, readFileSync, statSync } from 'fs'
10
+ import { dirname, isAbsolute, join, resolve } from 'path'
11
+ import { parseJsonc } from 'rvlib-shipkit/src/ts/config.ts'
12
+
13
+ export type TypecheckProjects = {
14
+ /** absolute paths, in declaration order, of the configs that compile files */
15
+ projects: string[]
16
+ /** nothing would be compiled: the check would pass without checking */
17
+ vacuous: boolean
18
+ /** references pointing at a path that does not exist, verbatim as declared */
19
+ missing: string[]
20
+ }
21
+
22
+ type RawConfig = {
23
+ files?: unknown
24
+ include?: unknown
25
+ references?: { path?: unknown }[]
26
+ }
27
+
28
+ /** a reference may name a directory, a config file, or a dir with no config */
29
+ function configPathFor(base: string, declared: string): string | null {
30
+ const p = isAbsolute(declared) ? declared : resolve(base, declared)
31
+ if (!existsSync(p)) return null
32
+ try { if (statSync(p).isDirectory()) return existsSync(join(p, 'tsconfig.json')) ? join(p, 'tsconfig.json') : null }
33
+ catch { return null }
34
+ return p
35
+ }
36
+
37
+ /**
38
+ * whether tsc would compile anything for THIS config, ignoring its references.
39
+ *
40
+ * tsc's rule, and every branch here is a shape found in the field:
41
+ * - `files` non-empty, or `include` non-empty: compiles those
42
+ * - neither key present: the default `include` is `**\/*`, so it compiles the
43
+ * whole directory tree, which is why a bare `{ references: [...] }` is NOT
44
+ * a solution config
45
+ * - `files: []` with no `include`: the solution-style marker, compiles nothing
46
+ * - `include: []`: compiles nothing whatever `files` says, unless `files` lists
47
+ * something
48
+ */
49
+ function compilesOwnFiles(cfg: RawConfig): boolean {
50
+ const files = Array.isArray(cfg.files) ? cfg.files : null
51
+ const include = Array.isArray(cfg.include) ? cfg.include : null
52
+ if (files != null && files.length > 0) return true
53
+ if (include != null) return include.length > 0
54
+ return files == null
55
+ }
56
+
57
+ function read(path: string): RawConfig {
58
+ let text: string
59
+ try { text = readFileSync(path, 'utf8') }
60
+ catch (e) { throw new Error(`tsconfig unreadable at ${path}: ${String(e).slice(0, 120)}`) }
61
+ try { return (parseJsonc(text) ?? {}) as RawConfig }
62
+ catch (e) { throw new Error(`tsconfig unparseable at ${path}: ${String(e).slice(0, 120)}`) }
63
+ }
64
+
65
+ /**
66
+ * Walk `references` to the leaves. A leaf that declares `files: []` with no
67
+ * `include` compiles nothing and is dropped, so a solution whose every leaf is
68
+ * empty comes back `vacuous` rather than as a command that passes for free.
69
+ */
70
+ export function resolveTypecheckProjects(tsconfigPath: string): TypecheckProjects {
71
+ const start = resolve(tsconfigPath)
72
+ const projects: string[] = []
73
+ const missing: string[] = []
74
+ // a reference loop is legal to WRITE and would otherwise recurse forever
75
+ const seen = new Set<string>()
76
+
77
+ const visit = (path: string): void => {
78
+ if (seen.has(path)) return
79
+ seen.add(path)
80
+ const cfg = read(path)
81
+ // references are checked FIRST so the list reads dependencies before
82
+ // dependents, the order `tsc -b` would build in
83
+ for (const r of Array.isArray(cfg.references) ? cfg.references : []) {
84
+ const declared = typeof r?.path === 'string' ? r.path : null
85
+ if (declared == null) continue
86
+ const next = configPathFor(dirname(path), declared)
87
+ if (next == null) missing.push(declared)
88
+ else visit(next)
89
+ }
90
+ // `references` does NOT suppress a config's own inputs: only an empty
91
+ // `files` with no `include` does, and that is what makes a config
92
+ // solution-style. Skipping a referencing config outright is how the
93
+ // first version of this file reintroduced the false green it exists to
94
+ // kill, on `{ include: ['src'], references: [...] }`, which is the
95
+ // ordinary monorepo package shape
96
+ if (compilesOwnFiles(cfg)) projects.push(path)
97
+ }
98
+
99
+ visit(start)
100
+ return { projects, vacuous: projects.length === 0, missing }
101
+ }
@@ -0,0 +1,46 @@
1
+ #!/usr/bin/env bun
2
+ // the typecheck floor for a repo that has no typecheck script, as one small
3
+ // binary the gate spawns, for the same reason source-graph-cli.ts exists: a
4
+ // tool the gate REQUIRES must not be a published version behind the repo it
5
+ // gates.
6
+ //
7
+ // One tsc run per project, sequential, output streamed straight through so the
8
+ // task log and its last-line summary behave like any other subprocess task. It
9
+ // runs EVERY project rather than stopping at the first red, because a caller
10
+ // who fixes the one error tsc showed and re-runs into a second one has been
11
+ // told the truth twice and believed it once.
12
+ import { resolveTypecheckProjects } from 'rvlib-shipkit/src/ts/tsconfig-projects.ts'
13
+
14
+ export function typecheckCli(argv: string[]): number {
15
+ const [tsc, tsconfig] = argv
16
+ if (tsc == null || tsconfig == null) {
17
+ console.log('usage: typecheck <tsc-path> <tsconfig-path>')
18
+ return 2
19
+ }
20
+ let resolved: ReturnType<typeof resolveTypecheckProjects>
21
+ try { resolved = resolveTypecheckProjects(tsconfig) }
22
+ catch (e) { console.log(`FAIL: ${String((e as Error)?.message ?? e)}`); return 2 }
23
+
24
+ // a reference pointing nowhere means whatever it covered is NOT being
25
+ // checked, which is a coverage hole wearing a green
26
+ for (const m of resolved.missing)
27
+ console.log(`FAIL: ${tsconfig} references '${m}', which does not exist, so whatever that project covered is unchecked`)
28
+ if (resolved.vacuous)
29
+ console.log(`FAIL: ${tsconfig} compiles no files of its own and reaches no project that does, so a typecheck over it proves nothing`)
30
+
31
+ let worst = 0
32
+ for (const p of resolved.projects) {
33
+ const r = Bun.spawnSync([tsc, '--noEmit', '-p', p], { stdout: 'inherit', stderr: 'inherit' })
34
+ if (r.exitCode !== 0) {
35
+ console.log(`FAIL: ${p}`)
36
+ worst = r.exitCode ?? 1
37
+ }
38
+ }
39
+ // a pass that does not say what it checked is the shape this file exists to
40
+ // remove, so it always names the projects
41
+ if (worst === 0 && resolved.missing.length === 0 && !resolved.vacuous)
42
+ console.log(`ok: ${resolved.projects.length} project(s) checked: ${resolved.projects.join(', ')}`)
43
+ return worst !== 0 ? worst : resolved.missing.length > 0 || resolved.vacuous ? 2 : 0
44
+ }
45
+
46
+ if (import.meta.main) process.exit(typecheckCli(process.argv.slice(2)))