rvlib-shipkit 0.32.0 → 0.34.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 (46) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/package.json +4 -1
  3. package/src/asks/asks-queue.ts +15 -2
  4. package/src/cli/cmd_Asks.ts +2 -1
  5. package/src/cli/cmd_Keys.ts +107 -0
  6. package/src/cli.ts +2 -0
  7. package/src/core/canonical-scripts.ts +3 -1
  8. package/src/core/check-suite.ts +95 -33
  9. package/src/core/discover-checks.ts +30 -1
  10. package/src/core/floor.ts +21 -2
  11. package/src/core/locked-write.ts +170 -1
  12. package/src/core/one-way-in-policy.ts +183 -0
  13. package/src/core/run-ci.ts +42 -3
  14. package/src/core/run-fixers.ts +61 -18
  15. package/src/core/task-runner.ts +55 -6
  16. package/src/hooks/one-way-in-guard.ts +39 -0
  17. package/src/hooks/run-hooks.ts +23 -2
  18. package/src/init-repo.ts +19 -1
  19. package/src/ledger/gate-units.ts +58 -0
  20. package/src/ledger/git-key.ts +330 -0
  21. package/src/ledger/git-scan.ts +314 -0
  22. package/src/ledger/toolchain-identity.ts +15 -8
  23. package/src/release/run-bump.ts +50 -5
  24. package/src/release/unit-gate.ts +17 -4
  25. package/src/rules/SK001-package-json-shape.ts +10 -3
  26. package/src/rules/SK002-contracts-fulfillness.ts +4 -0
  27. package/src/rules/SK003-claude-md-shape.ts +7 -2
  28. package/src/rules/SK004-gitignore-policy.ts +11 -2
  29. package/src/rules/SK005-rvlib-versions.ts +28 -1
  30. package/src/rules/SK006-npm-publish-safety.ts +3 -0
  31. package/src/rules/SK008-reviewer-agent.ts +6 -2
  32. package/src/rules/SK009-commit-ai-tells.ts +3 -0
  33. package/src/rules/SK010-undeclared-release-unit.ts +2 -0
  34. package/src/rules/SK011-sibling-unit-ranges.ts +2 -0
  35. package/src/rules/SK012-config-barrel-import.ts +6 -2
  36. package/src/rules/SK013-one-way-in-policy.ts +156 -0
  37. package/src/rules/rule-registry.ts +2 -0
  38. package/src/rules/rule.ts +22 -1
  39. package/src/rules/run-fix.ts +5 -3
  40. package/src/scaffold/files/claude-settings-local.json +0 -3
  41. package/src/scaffold/files/verify-skill.md +1 -1
  42. package/src/ts/config.ts +66 -6
  43. package/src/ts/cycles.ts +7 -92
  44. package/src/ts/source-graph.ts +158 -0
  45. package/src/ts/tsconfig-projects.ts +101 -0
  46. package/src/ts/typecheck-cli.ts +46 -0
@@ -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)))