rvlib-shipkit 0.33.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.
- package/CHANGELOG.md +19 -1
- package/package.json +1 -1
- package/src/asks/asks-queue.ts +15 -2
- package/src/cli/cmd_Asks.ts +2 -1
- package/src/cli/cmd_Keys.ts +107 -0
- package/src/cli.ts +2 -0
- package/src/core/check-suite.ts +95 -33
- package/src/core/discover-checks.ts +30 -1
- package/src/core/floor.ts +21 -2
- package/src/core/locked-write.ts +170 -1
- package/src/core/one-way-in-policy.ts +8 -8
- package/src/core/run-ci.ts +25 -1
- package/src/core/run-fixers.ts +61 -18
- package/src/core/task-runner.ts +55 -6
- package/src/hooks/run-hooks.ts +23 -2
- package/src/ledger/gate-units.ts +58 -0
- package/src/ledger/git-key.ts +330 -0
- package/src/ledger/git-scan.ts +314 -0
- package/src/ledger/toolchain-identity.ts +15 -8
- package/src/release/run-bump.ts +50 -5
- package/src/release/unit-gate.ts +17 -4
- package/src/rules/SK001-package-json-shape.ts +8 -2
- package/src/rules/SK002-contracts-fulfillness.ts +4 -0
- package/src/rules/SK003-claude-md-shape.ts +7 -2
- package/src/rules/SK004-gitignore-policy.ts +11 -2
- package/src/rules/SK005-rvlib-versions.ts +28 -1
- package/src/rules/SK006-npm-publish-safety.ts +3 -0
- package/src/rules/SK008-reviewer-agent.ts +6 -2
- package/src/rules/SK009-commit-ai-tells.ts +3 -0
- package/src/rules/SK010-undeclared-release-unit.ts +2 -0
- package/src/rules/SK011-sibling-unit-ranges.ts +2 -0
- package/src/rules/SK012-config-barrel-import.ts +6 -2
- package/src/rules/SK013-one-way-in-policy.ts +24 -4
- package/src/rules/rule.ts +22 -1
- package/src/rules/run-fix.ts +5 -3
- package/src/scaffold/files/verify-skill.md +1 -1
- package/src/ts/config.ts +66 -6
- package/src/ts/cycles.ts +7 -92
- package/src/ts/source-graph.ts +158 -0
- package/src/ts/tsconfig-projects.ts +101 -0
- package/src/ts/typecheck-cli.ts +46 -0
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
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
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
|
|
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)))
|