rvlib-shipkit 1.0.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/package.json +1 -1
- package/src/assets/gen/derived.ts +1 -1
- package/src/checks/SK004-gitignore-policy.check.ts +4 -4
- package/src/checks/SK005-rvlib-versions.check.ts +7 -4
- package/src/cli/cmd_Fleet.ts +12 -3
- package/src/collections/checks.ts +0 -1
- package/src/core/floor-resolve.ts +2 -2
- package/src/core/migrate-layout.ts +67 -8
- package/src/core/package-manager.ts +17 -15
- package/src/core/run-ci.ts +2 -3
- package/src/core/run-fix.ts +4 -1
- package/src/core/run-fixers.ts +19 -11
- package/src/core/self-update.ts +4 -2
- package/src/fleet/scan-fleet.ts +16 -0
- package/src/hooks/leak-guard.ts +10 -1
- package/src/hooks/run-hooks.ts +3 -3
- package/src/repo-config.ts +2 -3
- package/src/smoke/run-smoke.ts +6 -2
- package/src/wiki/scan-md.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## 1.0.1
|
|
2
|
+
|
|
3
|
+
- **A layout move lands once, and its commit says what it moved.** A tracked root `STATUS.md` that named an old folder was dropped and then written back by the text sweep in the same pass, so the move commit kept it, and the next run landed a second commit, titled as a move, that moved nothing and reported `moved under .shipkit/`. A legacy generated file is now only dropped, never swept. The commit title is built from what the pass did (`chore(machine): move asks/ and .rv-identity/ under .shipkit/, and drop the root STATUS.md, which shipkit writes under .shipkit/`), so a pass that moves no folder never carries the move title, and the fix pass and SK004 print the same words (`layoutSummary` in `core/migrate-layout.ts`, which replaces `MIGRATION_MESSAGE`).
|
|
4
|
+
|
|
5
|
+
- **An update never guesses the package manager.** `detectPackageManager` returns the manager the tracked lock files name, or a refusal (`{ refused }`, `isRefusal`) when they name none or two: no lock at all used to mean bun, and two managers' locks used to mean bun with a warning, so an npm repo could be updated with bun, which moves package.json and leaves `package-lock.json` on the old version. SK005 and the self-update WARN with the refusal and run nothing. `shipkit fleet --update` refuses a repo it cannot tell, and a non bun repo whose installed shipkit is older than 1.0.0, whose own update ran bun whatever the lock said; it prints the command in the repo's manager instead (`updateRefusal`, `PM_AWARE_SINCE` in `fleet/scan-fleet.ts`). The `warn` field of `PackageManager` is gone.
|
|
6
|
+
|
|
7
|
+
- **The layout move rewrites the paths `repo.config.ts` holds.** Every quoted value that names a moved folder as a path (`hooks.leakAllow` paths, `hooks.blockedPaths`, `gitignore` lines, a prose include, any other string starting with `asks/`, `tmp/`, `hooks/` or a `.rv-<x>/` folder) now names its new place, in the same machine commit. The ignore block is rendered from those rewritten values, and the rule fixers that run after the move in the same fix pass read the rewritten config (`runFix` takes `config`, and the fix pass reports the config as reshaped). Before, `leakAllow: [{ paths: ['asks/'] }]` kept naming a folder that was gone, and a repo line in `gitignore` came back under its old name, which took a second move commit to clear. `sweepConfigSource` and `rebaseConfigPaths` are exported from `core/migrate-layout.ts`, and `renderGitignore` receives the moved config as a third argument.
|
|
8
|
+
|
|
9
|
+
- **The seeded `sk-` key shape starts where a token starts.** `re:sk-[A-Za-z0-9_-]{20,}` matched inside a word ending in `sk`, so an ask file named `ask-the-town-hall-...` was refused as an API key. The starter row is now `re:(?<![A-Za-z0-9_-])sk-[A-Za-z0-9_-]{20,}` (`SK_KEY_ROW`), and a keyword file that still carries the old seeded row is read with the fixed shape under its old id, so a waiver written against its fingerprint keeps working.
|
|
10
|
+
|
|
11
|
+
- **Every file a check file or `repo.config.ts` reaches compiles under a strict, node only consumer tsconfig.** The package ships its source, so a consumer's compiler judges it. Unused imports in `core/run-ci.ts`, `repo-config.ts`, `core/floor-resolve.ts`, `collections/checks.ts`, `wiki/scan-md.ts` and `assets/gen/derived.ts` failed a consumer with `noUnusedLocals` or `noUnusedParameters`, and `smoke/run-smoke.ts` named the dom `document`, which fails a consumer whose `lib` has no dom. `tsconfig.consumer.json` compiles `check.ts`, `repo-config.ts` and the barrel with those flags, `lib: ["ESNext"]` and bun types only, and `tests/consumer-typecheck.test.ts` (full tier) runs it, with samples that prove each flag bites.
|
|
12
|
+
|
|
1
13
|
## 1.0.0
|
|
2
14
|
|
|
3
15
|
- **The PreToolUse guard refuses a piped gate.** `shipkit ci …|`, `bun <dir>/cli.ts ci …|`, `bunx shipkit ci …|`, `bun run test …|` and `bun run ci …|` exit 2 with the reason and the fix, whatever the deny list says: a pipeline exits with its last command's code, so a red gate reads green. A `>` redirect, `bun run test:raw <file> | tail` and a pipeline under `set -o pipefail` pass. `pipedGateIn`, `pipedGateRefusal` and `shellParts` (each segment with the operator that ends it) are exported from `core/one-way-in-policy.ts`, and a redirection's `&` (`2>&1`, `&>log`) no longer splits a segment.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rvlib-shipkit",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.1",
|
|
4
4
|
"funding": "https://github.com/sponsors/rvion",
|
|
5
5
|
"description": "All-in-one repo companion: ci gate (task DAG), auto-discovered health checks, release-contract fulfillness, asks queue, md-wiki tools, asset atelier, live tui",
|
|
6
6
|
"license": "MIT",
|
|
@@ -47,7 +47,7 @@ export async function recordParents(destDir: string, seed: number, parents: Reco
|
|
|
47
47
|
/** A derived asset is STALE when its PICKED roll's recorded parent picks
|
|
48
48
|
* differ from the parents' CURRENT picks (or were never recorded). An
|
|
49
49
|
* unpicked asset is never stale — it's just unpicked. */
|
|
50
|
-
export async function isStale(
|
|
50
|
+
export async function isStale(_kit: KitConfig, workspace: string, f: FoundAsset): Promise<boolean> {
|
|
51
51
|
const parentIds = parentIdsOf(f)
|
|
52
52
|
if (parentIds.length === 0) return false
|
|
53
53
|
const dir = assetDir(workspace, f.asset.id)
|
|
@@ -20,7 +20,7 @@ import { GENERATED_IGNORE_LINES, GENERATED_TRACKED_PATHS, untrackGenerated } fro
|
|
|
20
20
|
import { check, type CheckCtx, type FixResult } from '../check.ts'
|
|
21
21
|
import { gitBin } from '../core/git-bin.ts'
|
|
22
22
|
import { LAYOUT, SHIPKIT_DIR, TRACKED_DIRS } from '../core/layout.ts'
|
|
23
|
-
import { LEGACY_CONTRACT_PREFIX, LEGACY_IGNORED, LEGACY_TMP, LEGACY_VENUES, legacyLayoutLeft, migrateLayout } from '../core/migrate-layout.ts'
|
|
23
|
+
import { LEGACY_CONTRACT_PREFIX, LEGACY_IGNORED, LEGACY_TMP, LEGACY_VENUES, layoutSummary, legacyLayoutLeft, migrateLayout } from '../core/migrate-layout.ts'
|
|
24
24
|
|
|
25
25
|
/** what this check reads, writes and asks git through */
|
|
26
26
|
type Disk = Pick<CheckCtx, 'root' | 'read' | 'write' | 'exists' | 'exec'>
|
|
@@ -272,11 +272,11 @@ export const gitignorePolicy = check({
|
|
|
272
272
|
},
|
|
273
273
|
fix: async (c): Promise<FixResult> => {
|
|
274
274
|
// the move first: it writes the new block itself, before any folder moves
|
|
275
|
-
const moved = process.env.SHIPKIT_FROZEN === '1' ? null : migrateLayout(c.root, { config: c.config, renderGitignore: (t, legacy) => renderPolicy(t, c.config, legacy) })
|
|
275
|
+
const moved = process.env.SHIPKIT_FROZEN === '1' ? null : migrateLayout(c.root, { config: c.config, renderGitignore: (t, legacy, cfg) => renderPolicy(t, cfg ?? c.config, legacy) })
|
|
276
276
|
const layout = moved == null ? null
|
|
277
277
|
: moved.deferred != null ? `layout move deferred: ${moved.deferred}`
|
|
278
|
-
: moved
|
|
279
|
-
: `
|
|
278
|
+
: layoutSummary(moved) === '' ? null
|
|
279
|
+
: `layout: ${layoutSummary(moved)}${moved.commit != null ? ` (committed as ${moved.commit})` : moved.uncommitted === true ? ', the commit did not land' : ''}`
|
|
280
280
|
const legacy = hasLegacy(c.root)
|
|
281
281
|
const before = blockState(c, c.config, legacy)
|
|
282
282
|
// terminate an unterminated file even when the block itself is current:
|
|
@@ -42,7 +42,7 @@ import { existsSync, readFileSync } from 'fs'
|
|
|
42
42
|
import { join } from 'path'
|
|
43
43
|
import type { CheckCtx } from '../core/check-ctx.ts'
|
|
44
44
|
import { mutateLease, withMutateLease } from '../core/locked-write.ts'
|
|
45
|
-
import { detectPackageManager } from '../core/package-manager.ts'
|
|
45
|
+
import { detectPackageManager, isRefusal, LOCK_FILES } from '../core/package-manager.ts'
|
|
46
46
|
import { check, type CheckEnv } from '../check.ts'
|
|
47
47
|
|
|
48
48
|
type Pkg = { version?: string; dependencies?: Record<string, string>; devDependencies?: Record<string, string> }
|
|
@@ -85,7 +85,7 @@ export const rvlibVersions = check({
|
|
|
85
85
|
const deps = Object.entries({ ...pkg.dependencies, ...pkg.devDependencies }).filter(([n]) => n.startsWith('rvlib-'))
|
|
86
86
|
if (deps.length === 0) return c.ok('no rvlib-* deps')
|
|
87
87
|
const pm = detectPackageManager(c.root)
|
|
88
|
-
const manifest = ['package.json', ...pm.locks]
|
|
88
|
+
const manifest = ['package.json', ...(isRefusal(pm) ? LOCK_FILES : pm.locks)]
|
|
89
89
|
|
|
90
90
|
const stale: Array<{ name: string; installed: string; latest: string }> = []
|
|
91
91
|
for (const [name, spec] of deps) {
|
|
@@ -95,7 +95,7 @@ export const rvlibVersions = check({
|
|
|
95
95
|
continue
|
|
96
96
|
}
|
|
97
97
|
const installed = installedVersion(c.root, name)
|
|
98
|
-
if (installed == null) { c.fail(`${name} declared (${spec}) but not installed — run: ${pm.install.join(' ')}`); continue }
|
|
98
|
+
if (installed == null) { c.fail(`${name} declared (${spec}) but not installed — ${isRefusal(pm) ? pm.refused : `run: ${pm.install.join(' ')}`}`); continue }
|
|
99
99
|
let latest: string
|
|
100
100
|
try { latest = await latestVersion(c, name) } catch (e) {
|
|
101
101
|
c.lines.push({ kind: 'skip', text: `${name} ${installed} — registry unreachable (${String(e).slice(0, 80)}), version drift unknown` })
|
|
@@ -113,8 +113,11 @@ export const rvlibVersions = check({
|
|
|
113
113
|
|
|
114
114
|
const drift = stale.map((s) => `${s.name} ${s.installed} → ${s.latest}`).join(', ')
|
|
115
115
|
const names = stale.map((s) => s.name)
|
|
116
|
+
if (isRefusal(pm)) {
|
|
117
|
+
c.warn(`newer rvlib live: ${drift}, not updating: ${pm.refused}`)
|
|
118
|
+
return
|
|
119
|
+
}
|
|
116
120
|
const cmd = pm.update(names)
|
|
117
|
-
if (pm.warn != null) c.warn(pm.warn)
|
|
118
121
|
if (process.env['SHIPKIT_FROZEN'] === '1') {
|
|
119
122
|
c.warn(`newer rvlib live: ${drift} (frozen — apply with: ${cmd.join(' ')})`)
|
|
120
123
|
return
|
package/src/cli/cmd_Fleet.ts
CHANGED
|
@@ -5,14 +5,17 @@
|
|
|
5
5
|
import { Command, Option } from 'clipanion'
|
|
6
6
|
import { existsSync } from 'fs'
|
|
7
7
|
import { join } from 'path'
|
|
8
|
-
import { npmLatest, resolveLatest, scanFleet, type FleetRepo } from '../fleet/scan-fleet.ts'
|
|
8
|
+
import { npmLatest, resolveLatest, scanFleet, updateRefusal, type FleetRepo } from '../fleet/scan-fleet.ts'
|
|
9
9
|
import { spawnWithTimeout } from '../core/spawn-timeout.ts'
|
|
10
|
-
import { detectPackageManager } from '../core/package-manager.ts'
|
|
10
|
+
import { detectPackageManager, isRefusal } from '../core/package-manager.ts'
|
|
11
11
|
|
|
12
12
|
const short = (name: string): string => name.replace(/^rvlib-/, '')
|
|
13
13
|
// the hand-run fallback speaks the manager THAT repo installs with: telling
|
|
14
14
|
// an npm consumer to run bun leaves its package-lock.json behind
|
|
15
|
-
const manualUpdate = (r: FleetRepo): string =>
|
|
15
|
+
const manualUpdate = (r: FleetRepo): string => {
|
|
16
|
+
const pm = detectPackageManager(r.dir)
|
|
17
|
+
return isRefusal(pm) ? `<its package manager> update ${r.behind.map((d) => d.name).join(' ')} (${pm.refused})` : pm.update(r.behind.map((d) => d.name)).join(' ')
|
|
18
|
+
}
|
|
16
19
|
|
|
17
20
|
export class FleetCommand extends Command {
|
|
18
21
|
static override paths = [['fleet']]
|
|
@@ -92,6 +95,12 @@ export class FleetCommand extends Command {
|
|
|
92
95
|
failed++
|
|
93
96
|
continue
|
|
94
97
|
}
|
|
98
|
+
const refused = updateRefusal(r)
|
|
99
|
+
if (refused != null) {
|
|
100
|
+
w(`WARN ${r.name}: not updating: ${refused}`)
|
|
101
|
+
failed++
|
|
102
|
+
continue
|
|
103
|
+
}
|
|
95
104
|
const cli = join(r.dir, 'node_modules', 'rvlib-shipkit', 'src', 'cli.ts')
|
|
96
105
|
if (!existsSync(cli)) {
|
|
97
106
|
w(`WARN ${r.name}: no shipkit installed, so it cannot update itself — run: cd ${r.dir} && ${manualUpdate(r)}`)
|
|
@@ -142,7 +142,6 @@ function instanceOf(root: string, c: RepoConfig, row: CheckRow, stat: CheckStat
|
|
|
142
142
|
keepsUntil: keepsUntil(row.decl.ttl, stat),
|
|
143
143
|
recent: recent ?? [],
|
|
144
144
|
}
|
|
145
|
-
const wire = last?.lines ?? []
|
|
146
145
|
return {
|
|
147
146
|
id: `check:${id}`,
|
|
148
147
|
text: feedLine(row, facts),
|
|
@@ -99,7 +99,7 @@ const SUITE_CLI = fileURLToPath(import.meta.resolve('rvlib-shipkit/src/suite/sui
|
|
|
99
99
|
* bunfig, its preload and its `units[].tests` budget apply, after the checks
|
|
100
100
|
* its `deps` name. Its inputs are its dir and the dirs of the
|
|
101
101
|
* sibling units its package.json names, derived rather than declared */
|
|
102
|
-
function unitTestTasks(
|
|
102
|
+
function unitTestTasks(root: string, io: FloorReader, subs: TestUnitDecl[], siblings: Map<string, string[]>, notes: string[], unfilled: { id: string; reason: string }[]): TaskDef[] {
|
|
103
103
|
const read = io.text
|
|
104
104
|
const out: TaskDef[] = []
|
|
105
105
|
for (const u of subs) {
|
|
@@ -258,7 +258,7 @@ export function resolveFloor(c: RepoConfig, root: string, io: FloorReader): Floo
|
|
|
258
258
|
else { tasks.push(decorate(b.task)); sources[id] = b.source }
|
|
259
259
|
}
|
|
260
260
|
const siblings = siblingUnits(subs, io.text)
|
|
261
|
-
for (const t of unitTestTasks(
|
|
261
|
+
for (const t of unitTestTasks(root, io, subs, siblings, notes, unfilled)) { tasks.push(t); sources[t.id] = 'unit' }
|
|
262
262
|
for (const t of unitTypecheckTasks(root, io, subs, siblings, unfilled)) { tasks.push(t); sources[t.id] = 'unit' }
|
|
263
263
|
return { tasks, disabled, unfilled, sources, notes }
|
|
264
264
|
}
|
|
@@ -34,7 +34,20 @@ import { nestedIn } from './nesting.ts'
|
|
|
34
34
|
/** the prefix every contract folder carried before `.shipkit/` */
|
|
35
35
|
export const LEGACY_CONTRACT_PREFIX = '.rv-'
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
const CONFIG_FILE = 'repo.config.ts'
|
|
38
|
+
|
|
39
|
+
/** what one pass did, in words: the folders it moved and the root files it
|
|
40
|
+
* dropped. Empty when it did neither, so no caller prints a sentence with a
|
|
41
|
+
* hole where the list should be */
|
|
42
|
+
export function layoutSummary(r: { moved: LayoutMove[]; removed: string[] }): string {
|
|
43
|
+
const list = (xs: string[]): string => (xs.length <= 1 ? xs.join('') : `${xs.slice(0, -1).join(', ')} and ${xs.at(-1)}`)
|
|
44
|
+
const folders = r.moved.filter((m) => m.kind !== 'cache').map((m) => `${m.from}/`)
|
|
45
|
+
const parts = [
|
|
46
|
+
...(folders.length > 0 ? [`move ${list(folders)} under ${SHIPKIT_DIR}/`] : []),
|
|
47
|
+
...(r.removed.length > 0 ? [`drop the root ${list(r.removed)}, which shipkit writes under ${SHIPKIT_DIR}/`] : []),
|
|
48
|
+
]
|
|
49
|
+
return parts.join(', and ')
|
|
50
|
+
}
|
|
38
51
|
|
|
39
52
|
/** the contract names a typo is matched against */
|
|
40
53
|
const KNOWN = ['identity', 'assets', 'comfy', 'private', 'reflections', 'social', 'journal', 'domain', 'itch', 'steam', 'discord'] as const
|
|
@@ -200,6 +213,39 @@ export function rebaseRelative(text: string, at: { from: string; to: string }, m
|
|
|
200
213
|
.replace(/(\]\()(\.\.?\/[^)\s#]+)/g, (_m, lead: string, spec: string) => `${lead}${rebase(spec)}`)
|
|
201
214
|
}
|
|
202
215
|
|
|
216
|
+
/** one repo.config.ts value that names a moved folder, as a path: the folder
|
|
217
|
+
* then a slash, behind an optional `!`, `/` or `./`. A word, a bare folder
|
|
218
|
+
* name and a longer name that merely starts the same stay as they are */
|
|
219
|
+
function rebasePathValue(v: string, moves: LayoutMove[]): string {
|
|
220
|
+
const m = /^(!?(?:\/|\.\/)?)(.*)$/s.exec(v)!
|
|
221
|
+
const rest = m[2]!
|
|
222
|
+
const hit = moves.find((x) => rest.startsWith(`${x.from}/`))
|
|
223
|
+
return hit == null ? v : `${m[1]}${hit.to}${rest.slice(hit.from.length)}`
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** PURE: repo.config.ts source with every quoted path that names a moved
|
|
227
|
+
* folder rewritten (`leakAllow` paths, `blockedPaths`, `gitignore` lines, a
|
|
228
|
+
* prose include, any other value): each of them is a repo path by contract */
|
|
229
|
+
export function sweepConfigSource(text: string, moves: LayoutMove[]): string {
|
|
230
|
+
if (moves.length === 0) return text
|
|
231
|
+
return text.replace(/(['"`])((?:\\.|(?!\1)[^\\\n])*)\1/g, (lit, q: string, body: string) => {
|
|
232
|
+
if (q === '`' && body.includes('${')) return lit
|
|
233
|
+
const next = rebasePathValue(body, moves)
|
|
234
|
+
return next === body ? lit : `${q}${next}${q}`
|
|
235
|
+
})
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** PURE: the loaded config with the same rewrite, so what this pass renders
|
|
239
|
+
* from it (the ignore block) reads the values the file now holds. Only plain
|
|
240
|
+
* objects and arrays are copied; functions and class instances are kept */
|
|
241
|
+
export function rebaseConfigPaths<T>(v: T, moves: LayoutMove[]): T {
|
|
242
|
+
if (typeof v === 'string') return rebasePathValue(v, moves) as T
|
|
243
|
+
if (Array.isArray(v)) return v.map((x) => rebaseConfigPaths(x, moves)) as T
|
|
244
|
+
if (v != null && typeof v === 'object' && Object.getPrototypeOf(v) === Object.prototype)
|
|
245
|
+
return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, rebaseConfigPaths(x, moves)])) as T
|
|
246
|
+
return v
|
|
247
|
+
}
|
|
248
|
+
|
|
203
249
|
/** PURE: the .gitignore after the move. Old names turn into new ones, except
|
|
204
250
|
* the folders still on disk (`keep`), whose lines keep guarding them. A
|
|
205
251
|
* negation of one FILE inside a folder (`!.shipkit/social/README.md`) is dead
|
|
@@ -400,8 +446,10 @@ function inProgress(root: string): string | null {
|
|
|
400
446
|
export type MigrateOpts = {
|
|
401
447
|
config?: RepoConfig
|
|
402
448
|
/** re-render the SK004 block into this .gitignore text (SK004 owns the
|
|
403
|
-
* block). `legacy`: an old folder is still on disk, so its name stays
|
|
404
|
-
|
|
449
|
+
* block). `legacy`: an old folder is still on disk, so its name stays
|
|
450
|
+
* ignored. `config`: the repo config with its paths already moved, which
|
|
451
|
+
* is what the block must be rendered from */
|
|
452
|
+
renderGitignore?: (text: string, legacy: boolean, config?: RepoConfig) => string
|
|
405
453
|
}
|
|
406
454
|
|
|
407
455
|
/** tracked symlinks, with where each points */
|
|
@@ -442,8 +490,13 @@ export function migrateLayout(root: string, o: MigrateOpts = {}): LayoutResult {
|
|
|
442
490
|
const targets = Object.fromEntries(plan.moves.filter((m) => m.kind === 'contract').map((m) => [m.from, m.to]))
|
|
443
491
|
const sources = plan.moves.filter((m) => m.kind !== 'cache').map((m) => m.from)
|
|
444
492
|
const tracked = inGit ? lsFiles(root, [...sources, ...legacyStatus]) : []
|
|
445
|
-
// a moved file is rebased whether or not it names an old folder
|
|
446
|
-
|
|
493
|
+
// a moved file is rebased whether or not it names an old folder, and
|
|
494
|
+
// repo.config.ts whenever one of its values names one
|
|
495
|
+
const configNames = inGit && lsFiles(root, [CONFIG_FILE]).length > 0 && sweepConfigSource(readFileSync(join(root, CONFIG_FILE), 'utf8'), plan.moves) !== readFileSync(join(root, CONFIG_FILE), 'utf8') ? [CONFIG_FILE] : []
|
|
496
|
+
// a legacy generated file is dropped, never rewritten: a rewrite would
|
|
497
|
+
// write it back after the drop, and the commit would keep it
|
|
498
|
+
const dropped = new Set<string>(legacyStatus)
|
|
499
|
+
const swept = inGit ? [...new Set([...sweepTargets(root, contractNames), ...tracked.filter((t) => /\.(m?[jt]sx?|json|md)$/.test(t)), ...configNames])].filter((f) => !dropped.has(f)) : []
|
|
447
500
|
const links = inGit ? trackedLinks(root) : []
|
|
448
501
|
// commit the ignore file when git tracks it, or when this pass creates it:
|
|
449
502
|
// it is what keeps the moved private folders out. An existing untracked
|
|
@@ -464,7 +517,8 @@ export function migrateLayout(root: string, o: MigrateOpts = {}): LayoutResult {
|
|
|
464
517
|
const text = readFileSync(join(root, rel), 'utf8')
|
|
465
518
|
if (text.includes(SWEEP_SKIP)) continue
|
|
466
519
|
const now = movedTo(moves, rel)
|
|
467
|
-
const
|
|
520
|
+
const paths = rel === CONFIG_FILE ? sweepConfigSource(text, moves) : text
|
|
521
|
+
const next = sweepText(rebaseRelative(paths, { from: rel, to: now }, moves), targets, { from: rel, to: now })
|
|
468
522
|
if (next !== text) writes.push({ now, text: next })
|
|
469
523
|
}
|
|
470
524
|
const relinks: { now: string; target: string }[] = []
|
|
@@ -486,7 +540,8 @@ export function migrateLayout(root: string, o: MigrateOpts = {}): LayoutResult {
|
|
|
486
540
|
|
|
487
541
|
const gi = join(root, '.gitignore')
|
|
488
542
|
const giOriginal = existsSync(gi) ? readFileSync(gi, 'utf8') : ''
|
|
489
|
-
const
|
|
543
|
+
const config = o.config == null ? undefined : rebaseConfigPaths(o.config, moves)
|
|
544
|
+
const render = (text: string, legacy: boolean): string => (o.renderGitignore != null ? o.renderGitignore(text, legacy, config) : text)
|
|
490
545
|
const stillOld = (): string[] => dirsIn(root).filter((d) => d.startsWith(LEGACY_CONTRACT_PREFIX))
|
|
491
546
|
try {
|
|
492
547
|
// 1. both layouts ignored while any folder is in flight
|
|
@@ -550,7 +605,11 @@ export function migrateLayout(root: string, o: MigrateOpts = {}): LayoutResult {
|
|
|
550
605
|
const giChanged = giNew || (giTracked && readFileSync(join(root, '.gitignore'), 'utf8') !== giOriginal)
|
|
551
606
|
const touched = [...new Set([...tracked, ...added, ...sweptTracked, ...(giChanged ? ['.gitignore'] : [])])]
|
|
552
607
|
if (touched.length === 0) return out
|
|
553
|
-
|
|
608
|
+
// the title names what this commit carries; a pass that did neither has
|
|
609
|
+
// nothing tracked to land and returned above
|
|
610
|
+
const what = layoutSummary(out)
|
|
611
|
+
if (what === '') return out
|
|
612
|
+
const landed = machineCommit({ anchor: root, message: `chore(machine): ${what}`, paths: touched.map((p) => join(root, p)) })
|
|
554
613
|
if (!landed) return { ...out, uncommitted: true, pending: touched }
|
|
555
614
|
out.commit = git(root, ['rev-parse', '--short', 'HEAD']).out.trim()
|
|
556
615
|
return out
|
|
@@ -7,9 +7,8 @@
|
|
|
7
7
|
// TRACKED locks decide, because an untracked bun.lock is exactly what a wrong
|
|
8
8
|
// update leaves behind, and reading it as the repo's choice would make that
|
|
9
9
|
// first mistake permanent. Nothing tracked (a fresh tree, no git) falls back
|
|
10
|
-
// to what sits on disk.
|
|
11
|
-
//
|
|
12
|
-
// house default.
|
|
10
|
+
// to what sits on disk. No lock at all, or locks of two managers, is a
|
|
11
|
+
// REFUSAL that names what it saw: a guessed manager is the failure above.
|
|
13
12
|
import { existsSync } from 'fs'
|
|
14
13
|
import { join } from 'path'
|
|
15
14
|
import { gitBin } from './git-bin.ts'
|
|
@@ -24,10 +23,11 @@ export type PackageManager = {
|
|
|
24
23
|
install: string[]
|
|
25
24
|
/** move these deps to their latest version, manifest AND lock in one call */
|
|
26
25
|
update: (names: string[]) => string[]
|
|
27
|
-
/** set when several managers own a lock here, so the pick is a guess */
|
|
28
|
-
warn?: string
|
|
29
26
|
}
|
|
30
27
|
|
|
28
|
+
/** the manager, or why the lock files do not name exactly one */
|
|
29
|
+
export type PackageManagerPick = PackageManager | { refused: string }
|
|
30
|
+
|
|
31
31
|
const BUN_LOCKS = ['bun.lock', 'bun.lockb']
|
|
32
32
|
export const LOCK_FILES = [...BUN_LOCKS, 'pnpm-lock.yaml', 'yarn.lock', 'package-lock.json']
|
|
33
33
|
|
|
@@ -70,17 +70,19 @@ export function trackedLocks(root: string): string[] {
|
|
|
70
70
|
}
|
|
71
71
|
|
|
72
72
|
/** the manager this repo installs with, read off its lock files */
|
|
73
|
-
export function detectPackageManager(root: string, tracked: string[] = trackedLocks(root)):
|
|
73
|
+
export function detectPackageManager(root: string, tracked: string[] = trackedLocks(root)): PackageManagerPick {
|
|
74
74
|
const present = LOCK_FILES.filter((f) => existsSync(join(root, f)))
|
|
75
75
|
const owned = present.filter((f) => tracked.includes(f))
|
|
76
76
|
const locks = owned.length > 0 ? owned : present
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
if (
|
|
85
|
-
return {
|
|
77
|
+
const managers = [
|
|
78
|
+
...(locks.some((f) => BUN_LOCKS.includes(f)) ? [bun] : []),
|
|
79
|
+
...(locks.includes('pnpm-lock.yaml') ? [pnpm] : []),
|
|
80
|
+
...(locks.includes('yarn.lock') ? [yarn(existsSync(join(root, '.yarnrc.yml')))] : []),
|
|
81
|
+
...(locks.includes('package-lock.json') ? [npm] : []),
|
|
82
|
+
]
|
|
83
|
+
if (managers.length === 1) return managers[0]!
|
|
84
|
+
if (managers.length === 0) return { refused: `no lock file at the root (looked for ${LOCK_FILES.join(', ')}), so which package manager installs this repo is unknown: install once with the right one and commit its lock` }
|
|
85
|
+
return { refused: `lock files of ${managers.length} package managers ${owned.length > 0 ? 'are tracked' : 'sit at the root'} (${locks.join(', ')}), so which one installs this repo is unknown: delete the lock of the one it does not install with` }
|
|
86
86
|
}
|
|
87
|
+
|
|
88
|
+
export const isRefusal = (p: PackageManagerPick): p is { refused: string } => 'refused' in p
|
package/src/core/run-ci.ts
CHANGED
|
@@ -6,15 +6,14 @@
|
|
|
6
6
|
// (write-status.ts — atomic + lock) unless status:false, and the fix pass plus
|
|
7
7
|
// some checks WRITE, so a run is not read-only unless frozen is set and
|
|
8
8
|
// --no-fix is passed.
|
|
9
|
-
import { join } from 'path'
|
|
10
9
|
import { loadRepoConfig, type RepoConfig } from '../repo-config.ts'
|
|
11
10
|
import { reportToMotherhome } from './motherhome.ts'
|
|
12
11
|
import { runCheckPass, type CheckOpts } from './run-check.ts'
|
|
13
12
|
import { poolLines } from './slots.ts'
|
|
14
13
|
import { runFixPass } from './run-fixers.ts'
|
|
15
|
-
import { formatLine
|
|
14
|
+
import { formatLine } from './wire.ts'
|
|
16
15
|
import { writeStatus } from './write-status.ts'
|
|
17
|
-
import { allTasks, composeChecks, envTier, FULL_TESTS_ENV,
|
|
16
|
+
import { allTasks, composeChecks, envTier, FULL_TESTS_ENV, type TestTier } from '../ledger/gate-checks.ts'
|
|
18
17
|
import { CHECK_TABLE_FILE, writeCheckTable } from '../ledger/check-table.ts'
|
|
19
18
|
import { Ledger } from '../ledger/ledger.ts'
|
|
20
19
|
import { RunLog, type InvokedBy } from '../ledger/run-log.ts'
|
package/src/core/run-fix.ts
CHANGED
|
@@ -17,11 +17,14 @@ export type FixOpts = {
|
|
|
17
17
|
* not wire-shaped, which the frozen output contract forbids */
|
|
18
18
|
quiet?: boolean
|
|
19
19
|
print?: (line: string) => void
|
|
20
|
+
/** the config as it stands on disk now, when the caller holds one: a
|
|
21
|
+
* second load of a rewritten repo.config.ts can hit the module cache */
|
|
22
|
+
config?: RepoConfig
|
|
20
23
|
}
|
|
21
24
|
|
|
22
25
|
export async function runFix(root: string, o: FixOpts = {}): Promise<number> {
|
|
23
26
|
const print = o.print ?? ((l: string) => console.log(l))
|
|
24
|
-
const config = await loadRepoConfig(root)
|
|
27
|
+
const config = o.config ?? (await loadRepoConfig(root))
|
|
25
28
|
const env: CheckEnv = { root, config }
|
|
26
29
|
// a fresh context per body: a fixer may rewrite the config, and the body
|
|
27
30
|
// after it must read the one on disk now
|
package/src/core/run-fixers.ts
CHANGED
|
@@ -12,13 +12,13 @@ import { existsSync, readFileSync } from 'fs'
|
|
|
12
12
|
import { join } from 'path'
|
|
13
13
|
import { fileURLToPath } from 'url'
|
|
14
14
|
import { armHooks } from '../hooks/run-hooks.ts'
|
|
15
|
-
import type
|
|
15
|
+
import { loadRepoConfig, type RepoConfig } from '../repo-config.ts'
|
|
16
16
|
import { runFix } from './run-fix.ts'
|
|
17
17
|
import { routesToShipkit } from './canonical-scripts.ts'
|
|
18
18
|
import { discoverLegacyChecks } from './discover-legacy-checks.ts'
|
|
19
19
|
import { repoCheckFiles } from './discover-checks.ts'
|
|
20
20
|
import { mutateLease, withMutateLease } from './locked-write.ts'
|
|
21
|
-
import { migrateLayout } from './migrate-layout.ts'
|
|
21
|
+
import { layoutSummary, migrateLayout } from './migrate-layout.ts'
|
|
22
22
|
import { renderPolicy } from '../checks/SK004-gitignore-policy.check.ts'
|
|
23
23
|
import { spawnWithTimeout } from './spawn-timeout.ts'
|
|
24
24
|
import { gitBin } from './git-bin.ts'
|
|
@@ -180,20 +180,29 @@ export async function runFixPass(root: string, config: RepoConfig, o: FixPassOpt
|
|
|
180
180
|
return { ran: false, touched: [], failed: false, reshaped: false }
|
|
181
181
|
}
|
|
182
182
|
|
|
183
|
-
async function fixPassBody(root: string,
|
|
183
|
+
async function fixPassBody(root: string, loaded: RepoConfig, o: FixPassOpts, verify: boolean): Promise<FixPassResult> {
|
|
184
|
+
let config = loaded
|
|
185
|
+
const shapeBefore = shapeStamp(root)
|
|
184
186
|
// the old root layout moves under .shipkit/ before anything else reads a
|
|
185
187
|
// path: a move is not a decision, so it lands as one machine commit and
|
|
186
188
|
// only what a machine cannot decide reaches a human. Runs before the tree
|
|
187
189
|
// snapshot, and before hooks arm, because the hooks folder may be what moves
|
|
188
190
|
if (!verify) {
|
|
189
|
-
const m = migrateLayout(root, { config, renderGitignore: (t, legacy) => renderPolicy(t, config, legacy) })
|
|
190
|
-
|
|
191
|
-
|
|
191
|
+
const m = migrateLayout(root, { config, renderGitignore: (t, legacy, cfg) => renderPolicy(t, cfg ?? config, legacy) })
|
|
192
|
+
// the move rewrote repo.config.ts, so the config loaded before it names
|
|
193
|
+
// folders that are gone, and every fixer below must read the new one
|
|
194
|
+
if (m.swept.includes('repo.config.ts')) {
|
|
195
|
+
try { config = await loadRepoConfig(root, { fresh: true }) } catch (e) {
|
|
196
|
+
o.print(`WARN repo.config.ts does not load after the move rewrote it: ${String((e as Error)?.message ?? e).slice(0, 160)}`)
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
const what = layoutSummary(m)
|
|
200
|
+
if (what !== '') {
|
|
192
201
|
if (m.uncommitted === true) {
|
|
193
|
-
o.print(`WARN
|
|
202
|
+
o.print(`WARN layout: ${what}. The commit did not land`)
|
|
194
203
|
const p = m.pending ?? []
|
|
195
|
-
o.print(` fix: commit exactly these paths, never a bare commit: git commit -m 'chore:
|
|
196
|
-
} else
|
|
204
|
+
o.print(` fix: commit exactly these paths, never a bare commit: git commit -m 'chore: ${what}' -- ${p.slice(0, 12).join(' ')}${p.length > 12 ? ` (+${p.length - 12} more, listed by git status)` : ''}`)
|
|
205
|
+
} else o.print(`ok: layout: ${what}${m.commit != null ? `, committed as ${m.commit}` : ''}`)
|
|
197
206
|
}
|
|
198
207
|
if (m.deferred != null) {
|
|
199
208
|
o.print(`WARN the move under .shipkit/ is deferred: ${m.deferred}`)
|
|
@@ -218,7 +227,6 @@ async function fixPassBody(root: string, config: RepoConfig, o: FixPassOpts, ver
|
|
|
218
227
|
for (const u of unfilled) o.print(`skip: ${u.id} (${u.reason})`)
|
|
219
228
|
for (const d of disabled) o.print(`skip: ${d.id} (${d.reason})`)
|
|
220
229
|
const before = treeState(root)
|
|
221
|
-
const shapeBefore = shapeStamp(root)
|
|
222
230
|
|
|
223
231
|
for (const s of slots) {
|
|
224
232
|
const r = await spawnWithTimeout(s.cmd, { cwd: root, timeoutMs: FIXER_TIMEOUT_MS })
|
|
@@ -228,7 +236,7 @@ async function fixPassBody(root: string, config: RepoConfig, o: FixPassOpts, ver
|
|
|
228
236
|
o.print(`WARN fix ${s.id} failed (exit ${r.code}) — ${r.out.trim().split('\n').at(-1)?.slice(0, 160) ?? ''}; the gate below still ran`)
|
|
229
237
|
}
|
|
230
238
|
// structural repo-config repair is never implicit, and never in verify mode
|
|
231
|
-
if (o.rules === true && !verify) await runFix(root, { quiet: true, print: o.print })
|
|
239
|
+
if (o.rules === true && !verify) await runFix(root, { quiet: true, print: o.print, config })
|
|
232
240
|
|
|
233
241
|
const after = treeState(root)
|
|
234
242
|
const reshaped = shapeStamp(root) !== shapeBefore
|
package/src/core/self-update.ts
CHANGED
|
@@ -14,7 +14,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
|
|
14
14
|
import { homedir } from 'os'
|
|
15
15
|
import { dirname, join } from 'path'
|
|
16
16
|
import { fileURLToPath } from 'url'
|
|
17
|
-
import { detectPackageManager } from './package-manager.ts'
|
|
17
|
+
import { detectPackageManager, isRefusal } from './package-manager.ts'
|
|
18
18
|
import { gitBin } from './git-bin.ts'
|
|
19
19
|
|
|
20
20
|
const PKG = 'rvlib-shipkit'
|
|
@@ -104,7 +104,9 @@ export async function maybeSelfUpdateAndRestart(root: string): Promise<void> {
|
|
|
104
104
|
const plan = planSelfUpdate({ ...base, latest: await latestCached(), manifestDirty: manifestDirty(root) })
|
|
105
105
|
if (plan.action === 'warn') { console.error(`WARN self-update: ${plan.reason}`); return }
|
|
106
106
|
if (plan.action !== 'update') return
|
|
107
|
-
const
|
|
107
|
+
const pm = detectPackageManager(root)
|
|
108
|
+
if (isRefusal(pm)) { console.error(`WARN self-update: rvlib-shipkit ${plan.installed} is behind ${plan.latest}, not updating: ${pm.refused}`); return }
|
|
109
|
+
const cmd = pm.update([PKG])
|
|
108
110
|
console.error(`ok: rvlib-shipkit ${plan.installed} → ${plan.latest}, self-updating (${cmd.join(' ')})`)
|
|
109
111
|
const up = Bun.spawnSync(cmd, { cwd: root, stdout: 'inherit', stderr: 'inherit' })
|
|
110
112
|
if (up.exitCode !== 0) { console.error(`WARN self-update failed (exit ${up.exitCode}), continuing on ${plan.installed}`) ; return }
|
package/src/fleet/scan-fleet.ts
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
// so the fix travels through that one code path.
|
|
13
13
|
import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
|
|
14
14
|
import { join } from 'path'
|
|
15
|
+
import { detectPackageManager, isRefusal } from '../core/package-manager.ts'
|
|
15
16
|
|
|
16
17
|
export type Pkg = { name?: string; version?: string; dependencies?: Record<string, string>; devDependencies?: Record<string, string> }
|
|
17
18
|
|
|
@@ -140,3 +141,18 @@ export async function npmLatest(name: string): Promise<string | null> {
|
|
|
140
141
|
if (!res.ok) return null
|
|
141
142
|
return ((await res.json()) as Pkg).version ?? null
|
|
142
143
|
}
|
|
144
|
+
|
|
145
|
+
/** the first release whose SK005 updates through the manager the repo's lock
|
|
146
|
+
* files name. An older one runs bun whatever the lock says */
|
|
147
|
+
export const PM_AWARE_SINCE = '1.0.0'
|
|
148
|
+
|
|
149
|
+
/** why `fleet --update` must not hand this repo its own update, or null.
|
|
150
|
+
* The fleet runs the REPO's shipkit, so what matters is what THAT binary
|
|
151
|
+
* does: one older than PM_AWARE_SINCE updates an npm repo with bun */
|
|
152
|
+
export function updateRefusal(r: FleetRepo): string | null {
|
|
153
|
+
const pm = detectPackageManager(r.dir)
|
|
154
|
+
if (isRefusal(pm)) return `cannot tell which package manager installs it: ${pm.refused}`
|
|
155
|
+
const own = r.deps.find((d) => d.name === 'rvlib-shipkit')?.installed
|
|
156
|
+
if (pm.name === 'bun' || own == null || !isBehind(own, PM_AWARE_SINCE)) return null
|
|
157
|
+
return `its shipkit ${own} updates with bun whatever the lock says, and it installs with ${pm.name}. Run: cd ${r.dir} && ${pm.update(r.behind.map((d) => d.name)).join(' ')}`
|
|
158
|
+
}
|
package/src/hooks/leak-guard.ts
CHANGED
|
@@ -80,6 +80,15 @@ export type ParsedPatterns = {
|
|
|
80
80
|
|
|
81
81
|
const escapeRegex = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
82
82
|
|
|
83
|
+
/** a key shape is a token, so it starts where a token starts: `ask-the-...`
|
|
84
|
+
* or `desk-lamp-...` is a word that ends in `sk`, never a key */
|
|
85
|
+
export const SK_KEY_ROW = 're:(?<![A-Za-z0-9_-])sk-[A-Za-z0-9_-]{20,}'
|
|
86
|
+
|
|
87
|
+
/** rows the hooks seeded before their shape was fixed, read as the fixed
|
|
88
|
+
* shape. Their id stays the seeded row, so a waiver written against its
|
|
89
|
+
* fingerprint keeps working */
|
|
90
|
+
const SEEDED_ROW_FIXES: Record<string, string> = { 're:sk-[A-Za-z0-9_-]{20,}': SK_KEY_ROW }
|
|
91
|
+
|
|
83
92
|
/** Parse the keyword file: blank lines and `#` comments dropped, `re:<pattern>`
|
|
84
93
|
* rows become case-insensitive regexes, `w:<word>` rows match on word
|
|
85
94
|
* boundaries, everything else a case-insensitive substring.
|
|
@@ -95,7 +104,7 @@ export function parsePatterns(text: string): ParsedPatterns {
|
|
|
95
104
|
const row = raw.trim()
|
|
96
105
|
if (row === '' || row.startsWith('#')) continue
|
|
97
106
|
if (row.startsWith('re:')) {
|
|
98
|
-
try { regexes.push({ row, re: new RegExp(row.slice(3), 'i') }) }
|
|
107
|
+
try { regexes.push({ row, re: new RegExp((SEEDED_ROW_FIXES[row] ?? row).slice(3), 'i') }) }
|
|
99
108
|
catch { /* a malformed row must not disable the whole guard */ }
|
|
100
109
|
} else if (row.startsWith('w:')) {
|
|
101
110
|
const word = row.slice(2).trim().toLowerCase()
|
package/src/hooks/run-hooks.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { dirname, join } from 'path'
|
|
|
8
8
|
import { fileURLToPath } from 'url'
|
|
9
9
|
import { KEYWORD_FILE, LAYOUT } from '../core/layout.ts'
|
|
10
10
|
import { commitMessageOffenses } from './commit-message.ts'
|
|
11
|
-
import { globalAllowFile, globalKeywordFile, maskPattern, patternFingerprint, runLeakGuard, type LeakAllowEntry, type LeakHit, type LeakWaiver } from './leak-guard.ts'
|
|
11
|
+
import { globalAllowFile, globalKeywordFile, maskPattern, patternFingerprint, runLeakGuard, SK_KEY_ROW, type LeakAllowEntry, type LeakHit, type LeakWaiver } from './leak-guard.ts'
|
|
12
12
|
import { runRootIdentity } from './root-identity.ts'
|
|
13
13
|
import { gitRoot } from '../core/git-root.ts'
|
|
14
14
|
import { spawnWithTimeout } from '../core/spawn-timeout.ts'
|
|
@@ -143,14 +143,14 @@ export function installHooks(root: string, binInvocation: string, keywordFile?:
|
|
|
143
143
|
// generic secret shapes only. private names live in the machine-global
|
|
144
144
|
// ~/.shipkit/private/banned-keywords.txt, which the guard reads on every run
|
|
145
145
|
// (never copied into a repo: a copy rots and a copy can leak)
|
|
146
|
-
const KEYWORD_STARTER = `# banned-keywords — the leak guard greps every commit (added lines, paths,
|
|
146
|
+
export const KEYWORD_STARTER = `# banned-keywords — the leak guard greps every commit (added lines, paths,
|
|
147
147
|
# message) for these rows. one row = one case-insensitive substring, or
|
|
148
148
|
# re:<regex>. add repo-specific names here; machine-wide private names go in
|
|
149
149
|
# ~/.shipkit/private/banned-keywords.txt, read automatically alongside this file.
|
|
150
150
|
# this file is gitignored on purpose: the list itself is private.
|
|
151
151
|
re:-----BEGIN [A-Z ]*PRIVATE KEY-----
|
|
152
152
|
re:AKIA[0-9A-Z]{16}
|
|
153
|
-
|
|
153
|
+
${SK_KEY_ROW}
|
|
154
154
|
re:ghp_[A-Za-z0-9]{36}
|
|
155
155
|
re:xox[baprs]-[A-Za-z0-9-]{10,}
|
|
156
156
|
`
|
package/src/repo-config.ts
CHANGED
|
@@ -8,11 +8,10 @@
|
|
|
8
8
|
// a slot that can't fill emits a visible reason, disabling via
|
|
9
9
|
// `defaults: { x: false }` prints a skipped-by-config line — never silence.
|
|
10
10
|
// The `contracts` task is always present when any contract applies.
|
|
11
|
-
import { existsSync
|
|
11
|
+
import { existsSync } from 'fs'
|
|
12
12
|
import { isAbsolute, join, normalize } from 'path'
|
|
13
|
-
import { fileURLToPath } from 'url'
|
|
14
13
|
import { ROUTED_SCRIPTS } from './core/canonical-scripts.ts'
|
|
15
|
-
import { FLOOR_IDS
|
|
14
|
+
import { FLOOR_IDS } from './core/floor.ts'
|
|
16
15
|
export { resolveFloor, type FloorResolution, type FloorSource } from './core/floor.ts'
|
|
17
16
|
import type { ContractManifest } from './contracts/contract-types.ts'
|
|
18
17
|
import type { ProseConfig } from './prose/run-prose.ts'
|
package/src/smoke/run-smoke.ts
CHANGED
|
@@ -379,8 +379,12 @@ export async function runSmoke(root: string, config: SmokeConfig, opts: SmokeRun
|
|
|
379
379
|
const chars = (await page.locator('body').innerText().catch(() => '')).trim().length
|
|
380
380
|
const matches = waitFor == null ? 0 : await page.locator(waitFor).count().catch(() => 0)
|
|
381
381
|
const overflowPx = await page.evaluate(() => {
|
|
382
|
-
|
|
383
|
-
|
|
382
|
+
// runs in the browser, typed locally: a consumer compiling this
|
|
383
|
+
// file may have no dom lib
|
|
384
|
+
type Box = { scrollWidth: number; clientWidth: number }
|
|
385
|
+
const doc = (globalThis as unknown as { document: { documentElement: Box; body: Box } }).document
|
|
386
|
+
const de = doc.documentElement
|
|
387
|
+
return Math.max(de.scrollWidth - de.clientWidth, doc.body.scrollWidth - doc.body.clientWidth)
|
|
384
388
|
}).catch(() => 0)
|
|
385
389
|
await page.screenshot({ path: join(shots, `${routeSlug(label)}.png`), fullPage: true })
|
|
386
390
|
return judgeRoute({ route: label, chars, matches, problems: problems.length - before, waitFor, overflowPx })
|
package/src/wiki/scan-md.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// Depth = 0-1 BFS from the index (eager edge keeps depth, lazy adds 1);
|
|
8
8
|
// unreachable files stay in the graph with no depth (lint-md.ts turns those,
|
|
9
9
|
// plus dangling refs, into failures — the graph itself is a viewer).
|
|
10
|
-
import {
|
|
10
|
+
import { readdirSync, readFileSync, statSync } from 'fs'
|
|
11
11
|
import { basename, dirname, join, relative, resolve } from 'path'
|
|
12
12
|
import type { MdEdge, MdGraph, MdNode } from 'rvlib-shipkit/src/wiki/md-graph.ts'
|
|
13
13
|
|