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.
- package/CHANGELOG.md +23 -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_Ci.ts +3 -0
- package/src/cli/cmd_Keys.ts +113 -0
- package/src/cli.ts +2 -0
- package/src/core/check-suite.ts +54 -47
- package/src/core/discover-checks.ts +30 -7
- package/src/core/floor.ts +34 -6
- package/src/core/locked-write.ts +170 -1
- package/src/core/motherhome.ts +54 -1
- package/src/core/one-way-in-policy.ts +8 -8
- package/src/core/run-check.ts +92 -12
- package/src/core/run-ci.ts +115 -17
- package/src/core/run-fixers.ts +61 -18
- package/src/core/task-runner.ts +62 -6
- package/src/hooks/run-hooks.ts +23 -2
- package/src/ledger/gate-units.ts +84 -0
- package/src/ledger/git-key.ts +381 -0
- package/src/ledger/git-scan.ts +327 -0
- package/src/ledger/ledger.ts +202 -0
- package/src/ledger/store.ts +122 -0
- package/src/ledger/toolchain-identity.ts +45 -8
- package/src/pb/collection-scaffold.ts +4 -4
- package/src/release/run-bump.ts +50 -5
- package/src/release/run-release.ts +5 -0
- package/src/release/run-releases.ts +7 -1
- package/src/release/unit-gate.ts +17 -4
- package/src/repo-config.ts +26 -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 +12 -4
- 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
|
@@ -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
|
|
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
|
-
|
|
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
|
-
`
|
|
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
|
-
|
|
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 */
|
package/src/rules/run-fix.ts
CHANGED
|
@@ -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
|
-
//
|
|
52
|
-
|
|
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 <
|
|
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
|
|
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)))
|