rvlib-shipkit 0.38.1 → 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 +379 -16
- package/README.md +26 -14
- package/package.json +2 -2
- package/src/assets/gen/comfyts-gen.ts +27 -10
- package/src/assets/gen/derived.ts +1 -1
- package/src/assets/gen/generate-roll.ts +1 -1
- package/src/assets/kit-config.ts +6 -6
- package/src/assets/run-assets.ts +14 -10
- package/src/assets/server/studio-server.ts +6 -5
- package/src/check.ts +343 -0
- package/src/{rules/SK001-package-json-shape.ts → checks/SK001-package-json-shape.check.ts} +44 -46
- package/src/checks/SK002-contracts-fulfillness.check.ts +24 -0
- package/src/checks/SK003-claude-md-shape.check.ts +66 -0
- package/src/checks/SK004-gitignore-policy.check.ts +309 -0
- package/src/{rules/SK005-rvlib-versions.ts → checks/SK005-rvlib-versions.check.ts} +49 -32
- package/src/{rules/SK006-npm-publish-safety.ts → checks/SK006-npm-publish-safety.check.ts} +25 -24
- package/src/{rules/SK007-github-visibility.ts → checks/SK007-github-visibility.check.ts} +20 -14
- package/src/{rules/SK008-reviewer-agent.ts → checks/SK008-reviewer-agent.check.ts} +29 -14
- package/src/{rules/SK009-commit-ai-tells.ts → checks/SK009-commit-ai-tells.check.ts} +24 -21
- package/src/checks/SK010-undeclared-unit.check.ts +111 -0
- package/src/{rules/SK011-sibling-unit-ranges.ts → checks/SK011-sibling-unit-ranges.check.ts} +21 -20
- package/src/{rules/SK012-config-barrel-import.ts → checks/SK012-config-barrel-import.check.ts} +18 -23
- package/src/{rules/SK013-one-way-in-policy.ts → checks/SK013-one-way-in-policy.check.ts} +58 -50
- package/src/{rules/SK014-prose-ste.ts → checks/SK014-prose-ste.check.ts} +27 -13
- package/src/{rules/SK015-nested-repo.ts → checks/SK015-nested-repo.check.ts} +15 -12
- package/src/checks/SK016-legacy-check-shapes.check.ts +182 -0
- package/src/checks/SK017-vscode-hub-task.check.ts +197 -0
- package/src/checks/SK018-test-files-run.check.ts +28 -0
- package/src/checks/SK019-tests-run-in-units.check.ts +76 -0
- package/src/checks/SK020-self-spawning-check.check.ts +147 -0
- package/src/checks/contracts.check.ts +20 -0
- package/src/cli/cmd_Asks.ts +3 -2
- package/src/cli/cmd_Assets.ts +2 -2
- package/src/cli/cmd_Bump.ts +1 -1
- package/src/cli/cmd_Check.ts +6 -6
- package/src/cli/cmd_Checks.ts +95 -0
- package/src/cli/cmd_Ci.ts +48 -13
- package/src/cli/cmd_Commands.ts +2 -6
- package/src/cli/cmd_ConfigKeys.ts +69 -0
- package/src/cli/cmd_Contracts.ts +1 -1
- package/src/cli/cmd_Fleet.ts +16 -3
- package/src/cli/cmd_Hooks.ts +1 -1
- package/src/cli/cmd_Keys.ts +43 -18
- package/src/cli/cmd_Mv.ts +2 -2
- package/src/cli/cmd_Prose.ts +10 -1
- package/src/cli/cmd_Releases.ts +2 -2
- package/src/cli/cmd_Serve.ts +1 -1
- package/src/cli/cmd_Start.ts +6 -6
- package/src/cli/cmd_Status.ts +25 -0
- package/src/cli/cmd_Tui.ts +2 -2
- package/src/cli.ts +7 -4
- package/src/collections/actions.ts +4 -4
- package/src/collections/checks.ts +257 -0
- package/src/collections/collection-types.ts +254 -1
- package/src/collections/comfy.ts +23 -0
- package/src/collections/md-body.ts +14 -0
- package/src/collections/pings.ts +38 -12
- package/src/collections/posts.ts +19 -3
- package/src/collections/providers.ts +198 -91
- package/src/collections/reflections.ts +41 -0
- package/src/collections/state-rules.ts +73 -0
- package/src/collections/test-time.ts +288 -0
- package/src/collections/tests.ts +181 -0
- package/src/collections/wikis.ts +66 -0
- package/src/comfy/comfy-panel.ts +292 -0
- package/src/contracts/apply-contracts.ts +17 -12
- package/src/contracts/contract-types.ts +16 -4
- package/src/contracts/contracts-engine.ts +78 -42
- package/src/contracts/manifests/discord.contract.ts +6 -6
- package/src/contracts/manifests/identity.contract.ts +3 -3
- package/src/contracts/manifests/itch.contract.ts +12 -15
- package/src/contracts/manifests/social.contract.ts +5 -5
- package/src/contracts/manifests/steam.contract.ts +16 -16
- package/src/contracts/run-contracts.ts +4 -3
- package/src/core/attach.ts +96 -0
- package/src/core/canonical-scripts.ts +25 -0
- package/src/core/check-ctx.ts +204 -14
- package/src/core/check-file-names.ts +112 -0
- package/src/core/check-pass.ts +84 -0
- package/src/core/check-runner.ts +796 -0
- package/src/core/config-keys.ts +256 -0
- package/src/core/config-rewrite.ts +77 -0
- package/src/core/discover-checks.ts +166 -90
- package/src/core/discover-legacy-checks.ts +26 -0
- package/src/core/doc-block.ts +13 -0
- package/src/core/floor-resolve.ts +264 -0
- package/src/core/floor.ts +20 -137
- package/src/core/git-bin.ts +24 -0
- package/src/core/git-root.ts +108 -0
- package/src/core/instance.ts +195 -0
- package/src/core/jobs.ts +118 -0
- package/src/core/layout.ts +52 -0
- package/src/core/legacy-shapes.ts +207 -0
- package/src/core/lib-root.ts +5 -0
- package/src/core/locked-write.ts +110 -39
- package/src/core/machine-commit.ts +5 -4
- package/src/core/migrate-layout.ts +616 -0
- package/src/core/motherhome.ts +2 -8
- package/src/core/nesting.ts +4 -12
- package/src/core/one-way-in-policy.ts +73 -17
- package/src/core/outcome-words.ts +45 -0
- package/src/core/package-manager.ts +88 -0
- package/src/core/parallel.ts +21 -0
- package/src/core/process-tree.ts +44 -0
- package/src/core/raw-access.ts +93 -0
- package/src/core/read-set.ts +152 -0
- package/src/core/run-check.ts +155 -213
- package/src/core/run-ci.ts +152 -189
- package/src/core/run-fix.ts +99 -0
- package/src/core/run-fixers.ts +64 -33
- package/src/core/self-update.ts +11 -3
- package/src/core/slots.ts +83 -0
- package/src/core/spawn-timeout.ts +34 -3
- package/src/core/when-block.ts +401 -0
- package/src/core/when.ts +135 -0
- package/src/core/write-status.ts +5 -2
- package/src/fleet/scan-fleet.ts +16 -0
- package/src/hooks/leak-guard.ts +20 -9
- package/src/hooks/one-way-in-guard.ts +13 -5
- package/src/hooks/root-identity.ts +2 -1
- package/src/hooks/run-hooks.ts +38 -26
- package/src/init-repo.ts +39 -23
- package/src/ledger/check-stats.ts +155 -0
- package/src/ledger/check-table.ts +179 -0
- package/src/ledger/db.ts +255 -0
- package/src/ledger/gate-checks.ts +433 -0
- package/src/ledger/git-key.ts +116 -77
- package/src/ledger/git-scan.ts +96 -35
- package/src/ledger/ledger.ts +116 -42
- package/src/ledger/live-runs.ts +111 -0
- package/src/ledger/live-timeline.ts +97 -0
- package/src/ledger/repo-view.ts +73 -0
- package/src/ledger/run-log.ts +448 -0
- package/src/ledger/run-timeline.ts +173 -0
- package/src/ledger/store.ts +23 -12
- package/src/ledger/suite-log.ts +161 -0
- package/src/ledger/toolchain-identity.ts +7 -1
- package/src/ledger/world-cache.ts +111 -0
- package/src/mv/mv-md.ts +16 -6
- package/src/problems/extract.ts +182 -0
- package/src/problems/problem-board.ts +37 -0
- package/src/problems/problem-line.ts +77 -0
- package/src/prose/prose-view.ts +90 -0
- package/src/prose/run-prose.ts +60 -19
- package/src/prose/ste.ts +140 -24
- package/src/reflections/reflections-queue.ts +81 -0
- package/src/release/git-facts.ts +15 -9
- package/src/release/run-bump.ts +6 -5
- package/src/release/run-release.ts +25 -11
- package/src/release/run-releases.ts +2 -2
- package/src/release/unit-gate.ts +7 -54
- package/src/release/unit-resolve.ts +282 -0
- package/src/release/units.ts +33 -217
- package/src/repo-config.ts +157 -88
- package/src/rules/rule.ts +36 -96
- package/src/rules/untrack-generated.ts +5 -4
- package/src/scaffold/files/vscode-tasks.json +10 -31
- package/src/scaffold/jsonc-spans.ts +113 -0
- package/src/scaffold/project-scaffold.ts +16 -43
- package/src/scan.ts +98 -0
- package/src/serve/gate-trigger.ts +148 -0
- package/src/serve/health-schedule.ts +102 -0
- package/src/serve/hub-app/chart-view.ts +179 -0
- package/src/serve/hub-app/check-view.tsx +345 -0
- package/src/serve/hub-app/check-words.ts +164 -0
- package/src/serve/hub-app/checks-svc.ts +280 -0
- package/src/serve/hub-app/clock-svc.ts +33 -0
- package/src/serve/hub-app/col-card.tsx +23 -39
- package/src/serve/hub-app/cols-svc.ts +44 -20
- package/src/serve/hub-app/comfy-svc.ts +171 -0
- package/src/serve/hub-app/comfy-view.tsx +81 -0
- package/src/serve/hub-app/dur.ts +41 -0
- package/src/serve/hub-app/hash-route.ts +121 -0
- package/src/serve/hub-app/header-bar.tsx +117 -0
- package/src/serve/hub-app/health-svc.ts +23 -28
- package/src/serve/hub-app/help.tsx +29 -0
- package/src/serve/hub-app/hub-app.tsx +122 -119
- package/src/serve/hub-app/hub-st.ts +115 -42
- package/src/serve/hub-app/hub-types.ts +117 -2
- package/src/serve/hub-app/hub.css +534 -192
- package/src/serve/hub-app/index.html +2 -0
- package/src/serve/hub-app/live-svc.ts +174 -0
- package/src/serve/hub-app/md.ts +15 -0
- package/src/serve/hub-app/media-svc.ts +4 -1
- package/src/serve/hub-app/media-view.tsx +15 -0
- package/src/serve/hub-app/next-run.ts +68 -0
- package/src/serve/hub-app/project-gutter.css +65 -0
- package/src/serve/hub-app/project-gutter.tsx +128 -0
- package/src/serve/hub-app/projects-svc.ts +226 -0
- package/src/serve/hub-app/prose-hits-st.ts +124 -0
- package/src/serve/hub-app/prose-hits-view.tsx +176 -0
- package/src/serve/hub-app/prose-hits.css +21 -0
- package/src/serve/hub-app/run-strip.ts +47 -0
- package/src/serve/hub-app/runs-tabs.ts +24 -0
- package/src/serve/hub-app/test-view.tsx +107 -0
- package/src/serve/hub-app/tests-svc.ts +62 -0
- package/src/serve/hub-app/tests-view.tsx +238 -0
- package/src/serve/hub-app/time-ago.tsx +34 -0
- package/src/serve/hub-app/timeline-chart.tsx +274 -0
- package/src/serve/hub-app/timeline-geometry.ts +314 -0
- package/src/serve/hub-app/timeline-svc.ts +62 -0
- package/src/serve/hub-app/timeline-view.tsx +245 -0
- package/src/serve/hub-app/tree-build.ts +410 -0
- package/src/serve/hub-app/tree-row.tsx +42 -0
- package/src/serve/hub-app/tree-svc.ts +174 -0
- package/src/serve/hub-app/wikis-view.tsx +53 -0
- package/src/serve/other-repos.ts +79 -0
- package/src/serve/project-gutter-route.ts +350 -0
- package/src/serve/project-gutter.ts +449 -0
- package/src/serve/run-serve.ts +245 -16
- package/src/serve/same-origin.ts +14 -0
- package/src/serve/serve-hub.ts +213 -18
- package/src/shipkit.ts +19 -12
- package/src/smoke/run-smoke.ts +9 -4
- package/src/start-repo.ts +75 -36
- package/src/suite/judge-suites.ts +209 -15
- package/src/suite/kept-timings.ts +58 -0
- package/src/suite/runner-shapes.ts +88 -0
- package/src/suite/suite-cli.ts +147 -24
- package/src/suite/suite-cpu-preload.ts +92 -0
- package/src/suite/test-units.ts +243 -0
- package/src/ts/config.ts +4 -69
- package/src/ts/jsonc.ts +69 -0
- package/src/ts/lex.ts +143 -0
- package/src/ts/rename.ts +73 -10
- package/src/ts/source-graph.ts +28 -8
- package/src/ts/tsconfig-projects.ts +6 -97
- package/src/ts/tsconfig-resolve.ts +108 -0
- package/src/tui/build-steps-panel.ts +61 -0
- package/src/tui/checks-panel.ts +24 -16
- package/src/tui/run-tui.ts +2 -2
- package/src/tui/tui-panel.ts +5 -4
- package/src/wiki/graph-colors.ts +68 -0
- package/src/wiki/graph-mount.ts +7 -9
- package/src/wiki/graph-page.ts +42 -25
- package/src/wiki/graph-positions.ts +105 -0
- package/src/wiki/run-wiki.ts +1 -1
- package/src/wiki/scan-md.ts +1 -1
- package/src/wiki/serve-graph.ts +33 -3
- package/src/cli/cmd_Rules.ts +0 -51
- package/src/cli/cmd_Units.ts +0 -82
- package/src/collections/graphs.ts +0 -32
- package/src/contracts/contracts-once.ts +0 -43
- package/src/core/check-suite.ts +0 -302
- package/src/core/repair-contract-dirs.ts +0 -136
- package/src/core/task-runner.ts +0 -339
- package/src/ledger/gate-units.ts +0 -138
- package/src/ledger/unit-table.ts +0 -105
- package/src/rules/SK002-contracts-fulfillness.ts +0 -24
- package/src/rules/SK003-claude-md-shape.ts +0 -70
- package/src/rules/SK004-gitignore-policy.ts +0 -238
- package/src/rules/SK010-undeclared-release-unit.ts +0 -127
- package/src/rules/rule-registry.ts +0 -64
- package/src/rules/run-fix.ts +0 -72
- package/src/tui/tasks-panel.ts +0 -60
package/src/hooks/leak-guard.ts
CHANGED
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
import { createHash } from 'crypto'
|
|
19
19
|
import { existsSync, readFileSync } from 'fs'
|
|
20
20
|
import { basename, join } from 'path'
|
|
21
|
+
import { gitBin } from '../core/git-bin.ts'
|
|
22
|
+
import { KEYWORD_FILE, MACHINE_PRIVATE } from '../core/layout.ts'
|
|
21
23
|
|
|
22
24
|
export type LeakHit = { where: string; pattern: string; file?: string }
|
|
23
25
|
export type LeakWaiver = LeakHit & { reason: string; source: string }
|
|
@@ -78,6 +80,15 @@ export type ParsedPatterns = {
|
|
|
78
80
|
|
|
79
81
|
const escapeRegex = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
80
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
|
+
|
|
81
92
|
/** Parse the keyword file: blank lines and `#` comments dropped, `re:<pattern>`
|
|
82
93
|
* rows become case-insensitive regexes, `w:<word>` rows match on word
|
|
83
94
|
* boundaries, everything else a case-insensitive substring.
|
|
@@ -93,7 +104,7 @@ export function parsePatterns(text: string): ParsedPatterns {
|
|
|
93
104
|
const row = raw.trim()
|
|
94
105
|
if (row === '' || row.startsWith('#')) continue
|
|
95
106
|
if (row.startsWith('re:')) {
|
|
96
|
-
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') }) }
|
|
97
108
|
catch { /* a malformed row must not disable the whole guard */ }
|
|
98
109
|
} else if (row.startsWith('w:')) {
|
|
99
110
|
const word = row.slice(2).trim().toLowerCase()
|
|
@@ -227,11 +238,11 @@ export function leakAllowProblems(entries: unknown, where: string, tracked: bool
|
|
|
227
238
|
|
|
228
239
|
/** the machine-global list every repo's guard also reads: one private list
|
|
229
240
|
* per machine, never copied into a repo (a copy rots, and a not-yet-ignored
|
|
230
|
-
*
|
|
241
|
+
* private folder would make the list itself committable). */
|
|
231
242
|
export function globalKeywordFile(): string | null {
|
|
232
243
|
const home = process.env.HOME ?? ''
|
|
233
244
|
if (home === '') return null
|
|
234
|
-
const p = join(home,
|
|
245
|
+
const p = join(home, MACHINE_PRIVATE, 'banned-keywords.txt')
|
|
235
246
|
return existsSync(p) ? p : null
|
|
236
247
|
}
|
|
237
248
|
|
|
@@ -240,7 +251,7 @@ export function globalKeywordFile(): string | null {
|
|
|
240
251
|
export function globalAllowFile(): string | null {
|
|
241
252
|
const home = process.env.HOME ?? ''
|
|
242
253
|
if (home === '') return null
|
|
243
|
-
const p = join(home,
|
|
254
|
+
const p = join(home, MACHINE_PRIVATE, 'leak-allow.json')
|
|
244
255
|
return existsSync(p) ? p : null
|
|
245
256
|
}
|
|
246
257
|
|
|
@@ -263,7 +274,7 @@ export function loadAllowFile(path: string, tracked: boolean): { entries: Loaded
|
|
|
263
274
|
}
|
|
264
275
|
|
|
265
276
|
export type LeakGuardOpts = {
|
|
266
|
-
/** path to the keyword file, relative to root (default
|
|
277
|
+
/** path to the keyword file, relative to root (default .shipkit/private/banned-keywords.txt) */
|
|
267
278
|
keywordFile?: string
|
|
268
279
|
/** extra text to scan (a commit message, a release note) */
|
|
269
280
|
text?: { label: string; content: string }[]
|
|
@@ -334,10 +345,10 @@ export function addedLinesByFile(diff: string): { file: string; content: string
|
|
|
334
345
|
}
|
|
335
346
|
|
|
336
347
|
export function runLeakGuard(root: string, o: LeakGuardOpts = {}): LeakGuardResult {
|
|
337
|
-
const rel = o.keywordFile ??
|
|
348
|
+
const rel = o.keywordFile ?? KEYWORD_FILE
|
|
338
349
|
const file = join(root, rel)
|
|
339
350
|
const sources = [existsSync(file) ? file : null, globalKeywordFile()].filter((f): f is string => f != null)
|
|
340
|
-
if (sources.length === 0) return { status: 'skipped', reason: `${rel} not found and no
|
|
351
|
+
if (sources.length === 0) return { status: 'skipped', reason: `${rel} not found and no machine-global banned-keywords.txt — no keyword list, no leak guard` }
|
|
341
352
|
const patterns = parsePatterns(sources.map((f) => readFileSync(f, 'utf8')).join('\n'))
|
|
342
353
|
const count = patterns.substrings.length + patterns.words.length + patterns.regexes.length
|
|
343
354
|
if (count === 0) return { status: 'skipped', reason: `keyword file(s) have no patterns (${sources.join(', ')})` }
|
|
@@ -358,11 +369,11 @@ export function runLeakGuard(root: string, o: LeakGuardOpts = {}): LeakGuardResu
|
|
|
358
369
|
|
|
359
370
|
const targets: ScanTarget[] = []
|
|
360
371
|
if (o.staged !== false) {
|
|
361
|
-
const diff = Bun.spawnSync([
|
|
372
|
+
const diff = Bun.spawnSync([gitBin(), 'diff', '--cached'], { cwd: root, maxBuffer: 256 * 1024 * 1024 })
|
|
362
373
|
if (diff.exitCode === 0)
|
|
363
374
|
for (const f of addedLinesByFile(diff.stdout.toString())) targets.push({ where: 'staged content', file: f.file, content: f.content })
|
|
364
375
|
// a filename can leak as loudly as a line of code
|
|
365
|
-
const names = Bun.spawnSync([
|
|
376
|
+
const names = Bun.spawnSync([gitBin(), 'diff', '--cached', '--name-only'], { cwd: root })
|
|
366
377
|
if (names.exitCode === 0) for (const n of names.stdout.toString().split('\n')) if (n.trim() !== '') targets.push({ where: 'staged path', file: n, content: n })
|
|
367
378
|
}
|
|
368
379
|
for (const t of o.text ?? []) targets.push({ where: t.label, content: t.content })
|
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
// the PreToolUse guard for ONE WAY IN (core/one-way-in-policy.ts owns the
|
|
3
|
-
// table and the wording).
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
3
|
+
// table and the wording). It refuses a raw runner with the shipkit command to
|
|
4
|
+
// type instead: `permissions.deny` already blocks these, but a bare deny tells
|
|
5
|
+
// the model only that it was denied, and a refusal with no replacement just
|
|
6
|
+
// sends it looking for another way in. It also refuses a gate piped into
|
|
7
|
+
// another command, whose exit code the pipe hides.
|
|
7
8
|
//
|
|
8
9
|
// It runs before EVERY Bash call, so it stays a leaf: one tiny import, one
|
|
9
10
|
// small json read, no clipanion, no repo config. Anything it cannot decide it
|
|
10
11
|
// waves through, because a guard that fails closed on its own bugs would wedge
|
|
11
12
|
// a repo with no way to tell why, and `permissions.deny` is still the floor
|
|
12
13
|
// underneath it. Measured at ~10ms per call.
|
|
13
|
-
import { deniedHeads, rawRunnerHit, refusal } from '../core/one-way-in-policy.ts'
|
|
14
|
+
import { deniedHeads, pipedGateIn, pipedGateRefusal, rawRunnerHit, refusal } from '../core/one-way-in-policy.ts'
|
|
14
15
|
|
|
15
16
|
const input = await Bun.stdin.text()
|
|
16
17
|
let command = ''
|
|
@@ -21,6 +22,13 @@ try {
|
|
|
21
22
|
} catch {
|
|
22
23
|
process.exit(0)
|
|
23
24
|
}
|
|
25
|
+
// a piped gate is refused whatever the deny list says: it is not a raw
|
|
26
|
+
// runner, it is a verdict whose exit code the tail of the pipe replaces
|
|
27
|
+
const piped = pipedGateIn(command)
|
|
28
|
+
if (piped != null) {
|
|
29
|
+
console.error(pipedGateRefusal(piped))
|
|
30
|
+
process.exit(2)
|
|
31
|
+
}
|
|
24
32
|
// the settings file is the authority on WHICH runners this repo refuses, so
|
|
25
33
|
// deleting one `Bash(...)` line lifts the deny and this refusal together
|
|
26
34
|
const settingsPath = `${process.env['CLAUDE_PROJECT_DIR'] ?? process.cwd()}/.claude/settings.json`
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// valid package.json. Only the NAME betrayed it.
|
|
8
8
|
import { existsSync } from 'fs'
|
|
9
9
|
import { join } from 'path'
|
|
10
|
+
import { gitBin } from '../core/git-bin.ts'
|
|
10
11
|
|
|
11
12
|
export type IdentityOutcome =
|
|
12
13
|
| { status: 'skipped'; reason: string }
|
|
@@ -18,7 +19,7 @@ export type IdentityOutcome =
|
|
|
18
19
|
const SCRATCH = /\.(bak|orig|rej)$/
|
|
19
20
|
|
|
20
21
|
const git = (root: string, args: string[]): { code: number; out: string } => {
|
|
21
|
-
const p = Bun.spawnSync([
|
|
22
|
+
const p = Bun.spawnSync([gitBin(), ...args], { cwd: root, env: process.env })
|
|
22
23
|
return { code: p.exitCode, out: p.stdout.toString() }
|
|
23
24
|
}
|
|
24
25
|
|
package/src/hooks/run-hooks.ts
CHANGED
|
@@ -6,31 +6,44 @@
|
|
|
6
6
|
import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'fs'
|
|
7
7
|
import { dirname, join } from 'path'
|
|
8
8
|
import { fileURLToPath } from 'url'
|
|
9
|
+
import { KEYWORD_FILE, LAYOUT } from '../core/layout.ts'
|
|
9
10
|
import { commitMessageOffenses } from './commit-message.ts'
|
|
10
|
-
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'
|
|
11
12
|
import { runRootIdentity } from './root-identity.ts'
|
|
13
|
+
import { gitRoot } from '../core/git-root.ts'
|
|
12
14
|
import { spawnWithTimeout } from '../core/spawn-timeout.ts'
|
|
13
15
|
import type { RepoConfig } from '../repo-config.ts'
|
|
16
|
+
import { gitBin } from '../core/git-bin.ts'
|
|
14
17
|
|
|
15
18
|
export type HooksConfig = {
|
|
16
19
|
/** repo-specific commands run in the pre-commit gate, after the built-ins.
|
|
17
|
-
*
|
|
18
|
-
preCommit?: {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
20
|
+
* A non-zero exit blocks the commit, and the output is shown. */
|
|
21
|
+
preCommit?: {
|
|
22
|
+
/** gate name, printed by `shipkit hooks list` */
|
|
23
|
+
id: string
|
|
24
|
+
/** argv, run without a shell */
|
|
25
|
+
cmd: string[]
|
|
26
|
+
}[]
|
|
27
|
+
/** repo-specific commands run on the commit message. The message file path
|
|
28
|
+
* arrives as the last argument. */
|
|
29
|
+
commitMsg?: {
|
|
30
|
+
/** gate name, printed by `shipkit hooks list` */
|
|
31
|
+
id: string
|
|
32
|
+
/** argv, with the message file path appended */
|
|
33
|
+
cmd: string[]
|
|
34
|
+
}[]
|
|
35
|
+
/** keyword file for the leak guard (default .shipkit/private/banned-keywords.txt) */
|
|
23
36
|
keywordFile?: string
|
|
24
37
|
/** known-good leak-guard matches this repo waives: a lockfile whose
|
|
25
38
|
* dependency names or integrity hashes collide with a keyword is the
|
|
26
39
|
* motivating case. Each entry carries a reason, and a waiver that fires
|
|
27
40
|
* PRINTS, so nothing passes silently. This file is tracked, so an entry
|
|
28
41
|
* names a pattern by fingerprint (`fp:…`, printed beside every hit) and
|
|
29
|
-
* never by value. The machine-global twin is ~/.
|
|
42
|
+
* never by value. The machine-global twin is ~/.shipkit/private/leak-allow.json.
|
|
30
43
|
* hooks/leak-guard.ts */
|
|
31
44
|
leakAllow?: LeakAllowEntry[]
|
|
32
|
-
/** staged-path prefixes that
|
|
33
|
-
* '.comfy-ts/'). gitignore is advisory and editable
|
|
45
|
+
/** staged-path prefixes that can never enter git (local state like
|
|
46
|
+
* '.comfy-ts/'). A gitignore line is advisory and editable. This gate is not. */
|
|
34
47
|
blockedPaths?: string[]
|
|
35
48
|
/** words the commit-message language gate never counts as French
|
|
36
49
|
* (proper nouns that carry accents without being a language choice) */
|
|
@@ -41,7 +54,7 @@ export type HooksConfig = {
|
|
|
41
54
|
disable?: ('leak-guard' | 'commit-message' | 'root-identity' | 'commit-language')[]
|
|
42
55
|
}
|
|
43
56
|
|
|
44
|
-
const HOOK_DIR =
|
|
57
|
+
const HOOK_DIR = LAYOUT.hooks
|
|
45
58
|
const HOOKS = ['pre-commit', 'commit-msg'] as const
|
|
46
59
|
|
|
47
60
|
/** how a self-written hook body should invoke shipkit in this repo: the
|
|
@@ -66,15 +79,14 @@ export type ArmHooksResult = { state: 'armed' | 'current' | 'foreign' | 'no-git'
|
|
|
66
79
|
* arm (a subdir would set the parent's hooksPath onto a dir that does not
|
|
67
80
|
* exist there, disarming the parent's gates). */
|
|
68
81
|
export function armHooks(root: string, config: RepoConfig): ArmHooksResult {
|
|
69
|
-
const
|
|
70
|
-
if (
|
|
71
|
-
const topDir = top.stdout.toString().trim()
|
|
82
|
+
const topDir = gitRoot(root)?.top
|
|
83
|
+
if (topDir == null) return { state: 'no-git', detail: 'not a git repo' }
|
|
72
84
|
try {
|
|
73
85
|
if (realpathSync(topDir) !== realpathSync(root)) return { state: 'not-root', detail: `nested in ${topDir}, which owns its own commit gates` }
|
|
74
86
|
} catch {
|
|
75
87
|
return { state: 'no-git', detail: 'work tree root unresolvable' }
|
|
76
88
|
}
|
|
77
|
-
const p = Bun.spawnSync([
|
|
89
|
+
const p = Bun.spawnSync([gitBin(), 'config', 'core.hooksPath'], { cwd: root })
|
|
78
90
|
const current = p.exitCode === 0 ? p.stdout.toString().trim() : ''
|
|
79
91
|
if (current !== '' && current !== HOOK_DIR) return { state: 'foreign', detail: `core.hooksPath=${current}, foreign, left alone` }
|
|
80
92
|
if (current === HOOK_DIR && HOOKS.every((h) => existsSync(join(root, HOOK_DIR, h)))) return { state: 'current', detail: 'hooks already armed' }
|
|
@@ -94,7 +106,7 @@ function setHooksPath(root: string): { code: number; detail: string } {
|
|
|
94
106
|
let last = { code: -1, detail: '' }
|
|
95
107
|
for (let attempt = 0; attempt < 5; attempt++) {
|
|
96
108
|
if (attempt > 0) Bun.sleepSync(20 * 2 ** (attempt - 1))
|
|
97
|
-
const p = Bun.spawnSync([
|
|
109
|
+
const p = Bun.spawnSync([gitBin(), 'config', 'core.hooksPath', HOOK_DIR], { cwd: root })
|
|
98
110
|
if (p.exitCode === 0) return { code: 0, detail: '' }
|
|
99
111
|
last = { code: p.exitCode, detail: p.stderr.toString().trim().split('\n')[0]?.slice(0, 120) ?? '' }
|
|
100
112
|
// only lock contention is worth retrying; anything else is a real
|
|
@@ -129,16 +141,16 @@ export function installHooks(root: string, binInvocation: string, keywordFile?:
|
|
|
129
141
|
|
|
130
142
|
// a starter so the leak guard bites from day one instead of skipping forever:
|
|
131
143
|
// generic secret shapes only. private names live in the machine-global
|
|
132
|
-
// ~/.
|
|
144
|
+
// ~/.shipkit/private/banned-keywords.txt, which the guard reads on every run
|
|
133
145
|
// (never copied into a repo: a copy rots and a copy can leak)
|
|
134
|
-
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,
|
|
135
147
|
# message) for these rows. one row = one case-insensitive substring, or
|
|
136
148
|
# re:<regex>. add repo-specific names here; machine-wide private names go in
|
|
137
|
-
# ~/.
|
|
149
|
+
# ~/.shipkit/private/banned-keywords.txt, read automatically alongside this file.
|
|
138
150
|
# this file is gitignored on purpose: the list itself is private.
|
|
139
151
|
re:-----BEGIN [A-Z ]*PRIVATE KEY-----
|
|
140
152
|
re:AKIA[0-9A-Z]{16}
|
|
141
|
-
|
|
153
|
+
${SK_KEY_ROW}
|
|
142
154
|
re:ghp_[A-Za-z0-9]{36}
|
|
143
155
|
re:xox[baprs]-[A-Za-z0-9-]{10,}
|
|
144
156
|
`
|
|
@@ -146,7 +158,7 @@ re:xox[baprs]-[A-Za-z0-9-]{10,}
|
|
|
146
158
|
/** seed the configured keyword file with the generic starter so the guard is
|
|
147
159
|
* ARMED, not a skip. an existing file is never touched. */
|
|
148
160
|
function seedKeywordFile(root: string, keywordFile?: string): string | null {
|
|
149
|
-
const rel = keywordFile ??
|
|
161
|
+
const rel = keywordFile ?? KEYWORD_FILE
|
|
150
162
|
const dest = join(root, rel)
|
|
151
163
|
if (existsSync(dest)) return null
|
|
152
164
|
mkdirSync(dirname(dest), { recursive: true })
|
|
@@ -212,7 +224,7 @@ export async function runPreCommit(root: string, config: RepoConfig): Promise<Ga
|
|
|
212
224
|
})
|
|
213
225
|
}
|
|
214
226
|
if ((h.blockedPaths ?? []).length > 0) {
|
|
215
|
-
const names = Bun.spawnSync([
|
|
227
|
+
const names = Bun.spawnSync([gitBin(), 'diff', '--cached', '--name-only'], { cwd: root })
|
|
216
228
|
const staged = names.stdout.toString().split('\n').filter(Boolean)
|
|
217
229
|
const blocked = staged.filter((f) => h.blockedPaths!.some((p) => f.startsWith(p)))
|
|
218
230
|
out.push(blocked.length === 0
|
|
@@ -305,16 +317,16 @@ export async function runCommitMsg(root: string, config: RepoConfig, messageFile
|
|
|
305
317
|
export function listGates(config: RepoConfig, root = process.cwd()): string[] {
|
|
306
318
|
const h = config.hooks ?? {}
|
|
307
319
|
const off = new Set(h.disable ?? [])
|
|
308
|
-
const kw = h.keywordFile ??
|
|
320
|
+
const kw = h.keywordFile ?? KEYWORD_FILE
|
|
309
321
|
// a gate listed as active but unable to fire is worse than no listing:
|
|
310
322
|
// this surface exists so nobody has to guess
|
|
311
|
-
const leakState = off.has('leak-guard') ? ' (DISABLED by config)' : existsSync(join(root, kw)) || globalKeywordFile() != null ? '' : ` (INACTIVE, ${kw} absent and no ~/.
|
|
323
|
+
const leakState = off.has('leak-guard') ? ' (DISABLED by config)' : existsSync(join(root, kw)) || globalKeywordFile() != null ? '' : ` (INACTIVE, ${kw} absent and no ~/.shipkit/private list)`
|
|
312
324
|
// a waiver nobody can enumerate is a hole nobody can audit: the count of
|
|
313
325
|
// declared exemptions belongs on the same surface as the gate itself
|
|
314
326
|
const waivers = (h.leakAllow ?? []).length + (globalAllowFile() != null ? 1 : 0)
|
|
315
|
-
const leakAllowState = waivers === 0 ? '' : `, ${(h.leakAllow ?? []).length} repo waiver(s)${globalAllowFile() == null ? '' : ' + ~/.
|
|
327
|
+
const leakAllowState = waivers === 0 ? '' : `, ${(h.leakAllow ?? []).length} repo waiver(s)${globalAllowFile() == null ? '' : ' + ~/.shipkit/private/leak-allow.json'}`
|
|
316
328
|
return [
|
|
317
|
-
`pre-commit leak-guard${leakState} — staged added lines + paths vs ${kw} + ~/.
|
|
329
|
+
`pre-commit leak-guard${leakState} — staged added lines + paths vs ${kw} + ~/.shipkit/private/banned-keywords.txt${leakAllowState}`,
|
|
318
330
|
...((h.blockedPaths ?? []).length > 0 ? [`pre-commit blocked-paths — nothing under ${h.blockedPaths!.join(', ')} enters git`] : []),
|
|
319
331
|
`pre-commit root-identity${off.has('root-identity') ? ' (DISABLED by config)' : ''} — the repo's own name in package.json + repo.config.ts, and scratch files`,
|
|
320
332
|
...(h.preCommit ?? []).map((c) => `pre-commit ${c.id} — ${c.cmd.join(' ')}`),
|
package/src/init-repo.ts
CHANGED
|
@@ -9,12 +9,15 @@
|
|
|
9
9
|
import { existsSync, readFileSync, writeFileSync } from 'fs'
|
|
10
10
|
import { basename, join } from 'path'
|
|
11
11
|
import { canonicalBodies } from './core/canonical-scripts.ts'
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
12
|
+
import { CheckCtx } from './core/check-ctx.ts'
|
|
13
|
+
import { hasTestFiles, packageJsonShape } from './checks/SK001-package-json-shape.check.ts'
|
|
14
|
+
import { oneWayInPolicy } from './checks/SK013-one-way-in-policy.check.ts'
|
|
15
|
+
import { listRepoFiles } from './core/floor.ts'
|
|
14
16
|
import { reportToMotherhome } from './core/motherhome.ts'
|
|
15
17
|
import { ensureGitRepo, publishRepo, type Run } from './git-bootstrap.ts'
|
|
16
18
|
import { defineRepo, loadRepoConfig, resolveFloor } from './repo-config.ts'
|
|
17
|
-
import { gitignorePolicy } from './
|
|
19
|
+
import { gitignorePolicy } from './checks/SK004-gitignore-policy.check.ts'
|
|
20
|
+
import { vscodeHubTask } from './checks/SK017-vscode-hub-task.check.ts'
|
|
18
21
|
import { editorErgonomics } from './scaffold/project-scaffold.ts'
|
|
19
22
|
import { defaultHubPort } from './serve/hub-port.ts'
|
|
20
23
|
|
|
@@ -26,21 +29,23 @@ const CONFIG_TEMPLATE = (name: string): string => `import { defineRepo } from 'r
|
|
|
26
29
|
|
|
27
30
|
export default defineRepo({
|
|
28
31
|
name: '${name}',
|
|
29
|
-
venues: [], // e.g. ['itch', 'steam'], selects which .
|
|
30
|
-
social: [], // e.g. ['x', 'discord'], the .
|
|
32
|
+
venues: [], // e.g. ['itch', 'steam'], selects which .shipkit/<venue>/ contracts are REQUIRED
|
|
33
|
+
social: [], // e.g. ['x', 'discord'], the .shipkit/social/<channel>/ post folders (+ .shipkit/discord/)
|
|
31
34
|
tasks: [], // repo commit-gate tasks; deps double as race guards
|
|
32
|
-
// lint, typecheck, imports:check and cycles
|
|
33
|
-
// \`test\`
|
|
35
|
+
// lint, typecheck, imports:check and cycles are cached on the tracked tree.
|
|
36
|
+
// \`test\` runs every time until you say what it reads, because an
|
|
34
37
|
// ignored path (a fixture, a local db, a .env) is not in a tree key:
|
|
35
38
|
// defaults: { test: { inputs: ['tests', 'src', 'package.json'] } },
|
|
36
|
-
//
|
|
37
|
-
// gitignore (SK004) is advisory
|
|
38
|
-
|
|
39
|
+
// staged paths that may never enter git: local state, big derived output, a
|
|
40
|
+
// dump naming something private. gitignore (SK004) is advisory and editable,
|
|
41
|
+
// this pre-commit gate is not
|
|
42
|
+
hooks: { blockedPaths: ['.comfy-ts/hosts/', '.comfy-ts/cache/', '.comfy-ts/outputs/'] },
|
|
39
43
|
// the declared reviewer agent type (rule SK008). Uncomment with YOUR
|
|
40
44
|
// standing brief, then \`shipkit check --fix SK008\` writes the agent file:
|
|
41
45
|
// reviewer: { name: 'my-reviewer', brief: '~/path/to/reviewer-brief.md' },
|
|
42
|
-
//
|
|
43
|
-
// \`shipkit check\`
|
|
46
|
+
// checks need no config. Drop a <name>.check.ts file anywhere, and
|
|
47
|
+
// \`shipkit ci\`, \`shipkit check\` and the hub discover it
|
|
48
|
+
// every key this file accepts, one line each: \`shipkit config-keys\`
|
|
44
49
|
})
|
|
45
50
|
`
|
|
46
51
|
|
|
@@ -95,12 +100,12 @@ export async function initRepo(root: string, opts: InitOpts = {}): Promise<numbe
|
|
|
95
100
|
// its .gitignore policy block (same fixer as `check --fix SK004`),
|
|
96
101
|
// with the repo's own contributed lines honored
|
|
97
102
|
const cfg = await loadRepoConfig(root)
|
|
98
|
-
const g = await gitignorePolicy.fix!({ root, config: cfg })
|
|
103
|
+
const g = await gitignorePolicy.fix!(CheckCtx.open({ root, config: cfg }))
|
|
99
104
|
p(`ok: .gitignore policy block ${g.changed ? 'written' : 'already current'}`)
|
|
100
105
|
// the repos that need ONE WAY IN most are the ones that already exist,
|
|
101
106
|
// so a re-run REPAIRS rather than only seeding a fresh checkout
|
|
102
107
|
await repairOneWayIn(root, cfg, p)
|
|
103
|
-
furnishEditor(root, cfg, p)
|
|
108
|
+
await furnishEditor(root, cfg, p)
|
|
104
109
|
const mh = reportToMotherhome(root, cfg)
|
|
105
110
|
if (mh.note != null) p(mh.note)
|
|
106
111
|
if (wired && opts.noPublish !== true) await publishRepo(root, { github: opts.github, gh: opts.gh, print: p })
|
|
@@ -121,7 +126,7 @@ export async function initRepo(root: string, opts: InitOpts = {}): Promise<numbe
|
|
|
121
126
|
// `test` is scaffolded only once the repo has tests: `bun test` exits
|
|
122
127
|
// non-zero with none, and a scaffold that reds the gate is a bad scaffold
|
|
123
128
|
for (const [k, v] of Object.entries(canonicalBodies({ bin: opts.bin }))) {
|
|
124
|
-
if (k === 'test' && !hasTestFiles(root)) continue
|
|
129
|
+
if (k === 'test' && !hasTestFiles(listRepoFiles(root))) continue
|
|
125
130
|
if (pkg.scripts[k] == null) { pkg.scripts[k] = v; added++ }
|
|
126
131
|
else if (pkg.scripts[k] !== v) p(`skip: package.json script '${k}' exists — left untouched`)
|
|
127
132
|
}
|
|
@@ -131,10 +136,10 @@ export async function initRepo(root: string, opts: InitOpts = {}): Promise<numbe
|
|
|
131
136
|
}
|
|
132
137
|
// the .gitignore policy block (contract git policy) — same fixer as
|
|
133
138
|
// `shipkit check --fix SK004`, one code path
|
|
134
|
-
const g = await gitignorePolicy.fix!({ root, config: defineRepo({ name }) })
|
|
139
|
+
const g = await gitignorePolicy.fix!(CheckCtx.open({ root, config: defineRepo({ name }) }))
|
|
135
140
|
p(`ok: .gitignore policy block ${g.changed ? 'written' : 'already current'}`)
|
|
136
141
|
await repairOneWayIn(root, defineRepo({ name }), p)
|
|
137
|
-
furnishEditor(root, { name }, p)
|
|
142
|
+
await furnishEditor(root, { name }, p)
|
|
138
143
|
// what the gate actually runs, in ONE line: a thin floor must be seen at
|
|
139
144
|
// init time rather than discovered after a bad commit slipped through.
|
|
140
145
|
// The per-task resolution is diagnostics, and a `skip:` naming a script
|
|
@@ -143,7 +148,7 @@ export async function initRepo(root: string, opts: InitOpts = {}): Promise<numbe
|
|
|
143
148
|
// matter, and reports them about a repo that has code in it
|
|
144
149
|
const floor = resolveFloor(defineRepo({ name }), root)
|
|
145
150
|
if (floor.tasks.length > 0) p(`ok: gate floor: ${floor.tasks.map((t) => t.id).join(', ')}`)
|
|
146
|
-
else p(`WARN the gate would check NOTHING here (${floor.unfilled.map((u) => u.id).join(', ')} all unresolved) — see \`shipkit
|
|
151
|
+
else p(`WARN the gate would check NOTHING here (${floor.unfilled.map((u) => u.id).join(', ')} all unresolved) — see \`shipkit checks\``)
|
|
147
152
|
// every verb comes from the `shipkit` bin — dead on arrival unless the
|
|
148
153
|
// package is actually installed (bunx runs without adding the dep)
|
|
149
154
|
if (opts.bin == null) {
|
|
@@ -174,7 +179,7 @@ export async function initRepo(root: string, opts: InitOpts = {}): Promise<numbe
|
|
|
174
179
|
* files are the only half of that scaffold an adopted repo can be handed
|
|
175
180
|
* unasked, because they change no output of any command (project-scaffold.ts
|
|
176
181
|
* owns the line). Create-only, so a .vscode/ the repo brought with it wins. */
|
|
177
|
-
function furnishEditor(root: string, cfg: { name?: string; serve?: { port?: number } }, p: (line: string) => void): void {
|
|
182
|
+
async function furnishEditor(root: string, cfg: { name?: string; serve?: { port?: number } }, p: (line: string) => void): Promise<void> {
|
|
178
183
|
// the least important thing init does, and it runs AFTER repo.config.ts is
|
|
179
184
|
// on disk: an unwritable .vscode (a read-only tree, a path that is a file),
|
|
180
185
|
// or a config with no name at all, must not abort the run before the
|
|
@@ -183,9 +188,20 @@ function furnishEditor(root: string, cfg: { name?: string; serve?: { port?: numb
|
|
|
183
188
|
try {
|
|
184
189
|
const name = repoName(root, undefined, cfg.name)
|
|
185
190
|
const port = cfg.serve?.port ?? defaultHubPort(name)
|
|
186
|
-
if (editorErgonomics(root, { name, port }).length === 0)
|
|
191
|
+
if (editorErgonomics(root, { name, port }).length === 0) {
|
|
192
|
+
// the repo's own file stays its own, except the hub task, which
|
|
193
|
+
// rule SK017 keeps feeding the problems pane
|
|
194
|
+
// a file SK017 cannot read is still the repo's own: it is never
|
|
195
|
+
// reported as missing, and it is never rewritten
|
|
196
|
+
let hub: { changed: boolean; summary: string }
|
|
197
|
+
try { hub = await vscodeHubTask.fix!(CheckCtx.open({ root, config: defineRepo({ name }) })) } catch (e) {
|
|
198
|
+
p('ok: .vscode/ is this repo\'s own, left untouched')
|
|
199
|
+
return void p(`WARN .vscode/tasks.json does not parse (${e instanceof Error ? e.message : String(e)}): the hub task gets no problem matcher until it does`)
|
|
200
|
+
}
|
|
201
|
+
return void p(hub.changed ? `ok: ${hub.summary}` : 'ok: .vscode/ is this repo\'s own, left untouched')
|
|
202
|
+
}
|
|
187
203
|
p(`ok: .vscode/tasks.json written — opening the folder starts the hub on ${port}`)
|
|
188
|
-
p(' next: add this stack\'s own watchers (dev server, css
|
|
204
|
+
p(' next: add this stack\'s own watchers (dev server, css) — build/vscode-tasks.md carries the shapes')
|
|
189
205
|
} catch (e) {
|
|
190
206
|
p(`WARN no .vscode/tasks.json here: ${e instanceof Error ? e.message : String(e)} — opening the folder starts nothing, everything else landed`)
|
|
191
207
|
}
|
|
@@ -198,8 +214,8 @@ function furnishEditor(root: string, cfg: { name?: string; serve?: { port?: numb
|
|
|
198
214
|
* one that was not. The deny is a RESTRICTION, which is why it lands
|
|
199
215
|
* unconditionally where the scaffold's allow list stays opt in. */
|
|
200
216
|
async function repairOneWayIn(root: string, config: Parameters<typeof defineRepo>[0], p: (line: string) => void): Promise<void> {
|
|
201
|
-
const pkg = await packageJsonShape.fix!({ root, config })
|
|
217
|
+
const pkg = await packageJsonShape.fix!(CheckCtx.open({ root, config }))
|
|
202
218
|
if (pkg.changed) p(`ok: package.json — ${pkg.summary}`)
|
|
203
|
-
const policy = await oneWayInPolicy.fix!({ root, config })
|
|
219
|
+
const policy = await oneWayInPolicy.fix!(CheckCtx.open({ root, config }))
|
|
204
220
|
p(policy.changed ? `ok: ${policy.summary}` : `ok: .claude/settings.json already closes every raw runner`)
|
|
205
221
|
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// What a check costs, per check, from the two things this repo remembers: the
|
|
2
|
+
// store (one file per key, greens only, `at` plus `measuredMs`) and the runs
|
|
3
|
+
// run record beside it (every outcome, red included).
|
|
4
|
+
//
|
|
5
|
+
// The store is HISTORY here and the db is the record. They overlap for as
|
|
6
|
+
// long as both describe the same run, so a stored entry is counted only when
|
|
7
|
+
// it predates that CHECK's first row in the record: otherwise one measurement
|
|
8
|
+
// lands in the average twice, and the sample count, the one number that says
|
|
9
|
+
// whether an average means anything, is the first thing to lie.
|
|
10
|
+
//
|
|
11
|
+
// PER CHECK, never one boundary for the whole repo, because the two sources do
|
|
12
|
+
// not cover the same checks. A discovered check NEVER enters the store: its
|
|
13
|
+
// result lives in the check ttl cache or nowhere at all, so the store holds
|
|
14
|
+
// rules and tasks only, and a surface reading it alone shows every check as
|
|
15
|
+
// never measured however often it runs. The record is the only source that
|
|
16
|
+
// measures a check, and the store is only ever the history of the other two.
|
|
17
|
+
import { readdirSync, readFileSync, statSync } from 'fs'
|
|
18
|
+
import { join } from 'path'
|
|
19
|
+
import type { RunRow, RunStatus } from './run-log.ts'
|
|
20
|
+
import { readRuns, runsPath, runsStamp } from './run-log.ts'
|
|
21
|
+
import { storeRoot } from './store.ts'
|
|
22
|
+
|
|
23
|
+
/** one measurement read back off the store */
|
|
24
|
+
export type StoreSample = { key: string; at: number; ms: number; status: RunStatus }
|
|
25
|
+
|
|
26
|
+
export type CheckLast = { status: RunStatus; at: number; ms: number; replayed: boolean }
|
|
27
|
+
|
|
28
|
+
export type CheckStat = {
|
|
29
|
+
/** the newest outcome, replayed or measured */
|
|
30
|
+
last: CheckLast | null
|
|
31
|
+
/** mean of the MEASURED runs, ms. null when nothing measured it */
|
|
32
|
+
avgMs: number | null
|
|
33
|
+
/** how many measurements the mean is over: an average over two runs is
|
|
34
|
+
* not an average, and the page says which it is looking at */
|
|
35
|
+
samples: number
|
|
36
|
+
slowestMs: number | null
|
|
37
|
+
/** measurements that went red, out of `samples` */
|
|
38
|
+
failures: number
|
|
39
|
+
/** when it was last MEASURED rather than replayed, and what that said:
|
|
40
|
+
* a remembered answer expires from its measurement, never its replay */
|
|
41
|
+
measuredAt?: number | null
|
|
42
|
+
measuredStatus?: RunStatus | null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** the ONE merge. Pure, so the rules above are testable without a disk. */
|
|
46
|
+
export function mergeCheckStats(o: { runs: RunRow[]; store: StoreSample[] }): Map<string, CheckStat> {
|
|
47
|
+
// each check's own first row is its boundary: what the store holds from
|
|
48
|
+
// before it is history the record never saw, and what comes after it is a
|
|
49
|
+
// measurement the record already carries. A check with no rows at all keeps
|
|
50
|
+
// its whole stored history
|
|
51
|
+
const firstRunAt = new Map<string, number>()
|
|
52
|
+
for (const r of o.runs) {
|
|
53
|
+
const prev = firstRunAt.get(r.id)
|
|
54
|
+
if (prev == null || r.at < prev) firstRunAt.set(r.id, r.at)
|
|
55
|
+
}
|
|
56
|
+
type Sample = { at: number; ms: number; status: RunStatus; full?: boolean }
|
|
57
|
+
const samples = new Map<string, Sample[]>()
|
|
58
|
+
const last = new Map<string, CheckLast>()
|
|
59
|
+
const push = (key: string, s: Sample): void => {
|
|
60
|
+
const list = samples.get(key) ?? []
|
|
61
|
+
list.push(s)
|
|
62
|
+
samples.set(key, list)
|
|
63
|
+
}
|
|
64
|
+
for (const s of o.store) {
|
|
65
|
+
if (s.at >= (firstRunAt.get(s.key) ?? Infinity)) continue
|
|
66
|
+
push(s.key, { at: s.at, ms: s.ms, status: s.status })
|
|
67
|
+
const prev = last.get(s.key)
|
|
68
|
+
if (prev == null || s.at > prev.at) last.set(s.key, { status: s.status, at: s.at, ms: s.ms, replayed: false })
|
|
69
|
+
}
|
|
70
|
+
for (const r of o.runs) {
|
|
71
|
+
// a replay measured nothing and a skip ran nothing: both are the last
|
|
72
|
+
// VERDICT and neither is a timing
|
|
73
|
+
if (!r.replayed && r.status !== 'skip') push(r.id, { at: r.at, ms: r.ms, status: r.status, ...(r.full === true ? { full: true } : {}) })
|
|
74
|
+
const prev = last.get(r.id)
|
|
75
|
+
if (prev == null || r.at >= prev.at) last.set(r.id, { status: r.status, at: r.at, ms: r.ms, replayed: r.replayed })
|
|
76
|
+
}
|
|
77
|
+
const out = new Map<string, CheckStat>()
|
|
78
|
+
for (const [key, l] of last) {
|
|
79
|
+
// the quick tier is what every edit waits on: a full tier run (every
|
|
80
|
+
// slow suite, one at a time) joins the numbers only of a check that
|
|
81
|
+
// never ran at the quick one
|
|
82
|
+
const all = samples.get(key) ?? []
|
|
83
|
+
const quick = all.filter((s) => s.full !== true)
|
|
84
|
+
const list = quick.length > 0 ? quick : all
|
|
85
|
+
const newest = list.reduce<Sample | null>((a, s) => (a == null || s.at >= a.at ? s : a), null)
|
|
86
|
+
out.set(key, {
|
|
87
|
+
last: l,
|
|
88
|
+
avgMs: list.length === 0 ? null : Math.round(list.reduce((a, s) => a + s.ms, 0) / list.length),
|
|
89
|
+
samples: list.length,
|
|
90
|
+
slowestMs: list.length === 0 ? null : Math.max(...list.map((s) => s.ms)),
|
|
91
|
+
failures: list.filter((s) => s.status === 'fail').length,
|
|
92
|
+
measuredAt: newest?.at ?? null,
|
|
93
|
+
measuredStatus: newest?.status ?? null,
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
return out
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** the store, read back. A check's file name is the slug of its id
|
|
100
|
+
* (ledger/store.ts entryPath), so the caller's check list decides which
|
|
101
|
+
* prefixes are read: deriving the id back OUT of a slug cannot work, since
|
|
102
|
+
* both `:` and `/` slug to `_`. */
|
|
103
|
+
export function scanCheckStore(store: string, checks: { id: string }[]): StoreSample[] {
|
|
104
|
+
const byPrefix = new Map<string, string>()
|
|
105
|
+
for (const u of checks) byPrefix.set(storeSlug(u.id), u.id)
|
|
106
|
+
const dir = join(store, 'checks')
|
|
107
|
+
const out: StoreSample[] = []
|
|
108
|
+
let files: string[] = []
|
|
109
|
+
try { files = readdirSync(dir) } catch { return out }
|
|
110
|
+
for (const f of files) {
|
|
111
|
+
if (!f.endsWith('.json')) continue
|
|
112
|
+
// `<slug>.<12 hex of the key>.json`
|
|
113
|
+
const key = byPrefix.get(f.replace(/\.[0-9a-f]{12}\.json$/, ''))
|
|
114
|
+
if (key == null) continue
|
|
115
|
+
try {
|
|
116
|
+
const e = JSON.parse(readFileSync(join(dir, f), 'utf8')) as { at?: number; payload?: { measuredMs?: number; status?: RunStatus } }
|
|
117
|
+
const ms = e.payload?.measuredMs
|
|
118
|
+
if (typeof e.at !== 'number' || typeof ms !== 'number') continue
|
|
119
|
+
out.push({ key, at: e.at, ms, status: e.payload?.status === 'warn' ? 'warn' : 'pass' })
|
|
120
|
+
} catch { /* a half written or foreign entry reads as no sample */ }
|
|
121
|
+
}
|
|
122
|
+
return out
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** ledger/store.ts entryPath's file name rule, which this module must agree
|
|
126
|
+
* with byte for byte or it reads nothing */
|
|
127
|
+
const storeSlug = (s: string): string => s.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 60)
|
|
128
|
+
|
|
129
|
+
type Cached = { stamp: string; stats: Map<string, CheckStat> }
|
|
130
|
+
const cache = new Map<string, Cached>()
|
|
131
|
+
|
|
132
|
+
/** the per check numbers for a repo, cached on what the two sources look like
|
|
133
|
+
* from the outside: the store dir's mtime, plus what the run record holds.
|
|
134
|
+
* The store is thousands of small files and the hub asks on a ten second
|
|
135
|
+
* poll. The record's own FILE cannot answer for it: in WAL mode a commit
|
|
136
|
+
* lands in the sidecar and the db's mtime does not move. */
|
|
137
|
+
export function checkStatsFor(root: string, checks: { id: string }[], o?: { fresh?: boolean }): Map<string, CheckStat> {
|
|
138
|
+
const store = storeRoot(root)
|
|
139
|
+
const record = runsPath(root)
|
|
140
|
+
const stamp = `${checks.length}:${stampOf(join(store, 'checks'))}:${runsStamp(record)}`
|
|
141
|
+
const hit = cache.get(root)
|
|
142
|
+
if (o?.fresh !== true && hit != null && hit.stamp === stamp) return hit.stats
|
|
143
|
+
const stats = mergeCheckStats({ runs: readRuns(record), store: scanCheckStore(store, checks) })
|
|
144
|
+
cache.set(root, { stamp, stats })
|
|
145
|
+
return stats
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const stampOf = (p: string): string => {
|
|
149
|
+
try {
|
|
150
|
+
const s = statSync(p)
|
|
151
|
+
return `${s.mtimeMs}/${s.size}`
|
|
152
|
+
} catch {
|
|
153
|
+
return 'none'
|
|
154
|
+
}
|
|
155
|
+
}
|