rvlib-shipkit 1.9.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +34 -0
- package/package.json +1 -1
- package/src/check.ts +5 -2
- package/src/checks/SK001-package-json-shape.check.ts +47 -12
- package/src/checks/SK013-one-way-in-policy.check.ts +8 -7
- package/src/checks/SK017-vscode-hub-task.check.ts +109 -106
- package/src/checks/SK019-tests-run-in-units.check.ts +2 -1
- package/src/checks/SK027-agent-wiki-links.check.ts +5 -2
- package/src/checks/SK029-explicit-check-files.check.ts +25 -8
- package/src/checks/SK034-commit-gates-armed.check.ts +90 -0
- package/src/cli/cmd_Ci.ts +3 -3
- package/src/cli/cmd_Commands.ts +21 -5
- package/src/cli/cmd_Hooks.ts +6 -2
- package/src/cli/cmd_Init.ts +1 -1
- package/src/cli/cmd_Serve.ts +1 -2
- package/src/cli/cmd_Start.ts +1 -1
- package/src/cli.ts +2 -4
- package/src/core/budget.ts +6 -8
- package/src/core/bun-builtins.ts +36 -0
- package/src/core/bun-tool-cli.ts +23 -0
- package/src/core/canonical-scripts.ts +6 -4
- package/src/core/check-ctx.ts +104 -10
- package/src/core/check-file-names.ts +2 -3
- package/src/core/check-pass.ts +17 -1
- package/src/core/check-runner.ts +19 -6
- package/src/core/config-rewrite.ts +27 -6
- package/src/core/discover-checks.ts +30 -10
- package/src/core/floor-resolve.ts +16 -7
- package/src/core/job-run.ts +2 -2
- package/src/core/layout.ts +4 -1
- package/src/core/legacy-shapes.ts +40 -0
- package/src/core/one-lib.ts +1 -1
- package/src/core/one-way-in-policy.ts +106 -13
- package/src/core/pre-commit-discovery.ts +106 -0
- package/src/core/retired-checks.ts +2 -0
- package/src/core/run-ci.ts +16 -10
- package/src/core/run-fix.ts +9 -0
- package/src/core/run-fixers.ts +91 -35
- package/src/core/runs-matrix.ts +7 -0
- package/src/core/slots.ts +1 -1
- package/src/core/vendored-read.ts +39 -0
- package/src/core/vendored.ts +82 -0
- package/src/core/waivers.ts +125 -0
- package/src/core/when-block.ts +25 -185
- package/src/core/when.ts +30 -13
- package/src/core/wire.ts +17 -1
- package/src/git-bootstrap.ts +1 -1
- package/src/home/attach.ts +3 -5
- package/src/home/insights.ts +6 -1
- package/src/home/pm-config.ts +2 -2
- package/src/home/run-daemon.ts +2 -2
- package/src/home/run-unit-action.ts +2 -2
- package/src/hooks/commit-message.ts +2 -14
- package/src/hooks/hook-gates.ts +38 -0
- package/src/hooks/leak-guard.ts +8 -3
- package/src/hooks/one-way-in-guard.ts +8 -2
- package/src/hooks/root-identity.ts +9 -6
- package/src/hooks/run-hooks.ts +72 -41
- package/src/hooks/staged.ts +49 -0
- package/src/hooks/tree-shrink.ts +41 -0
- package/src/init-repo.ts +0 -42
- package/src/ledger/check-table.ts +1 -1
- package/src/ledger/gate-checks.ts +45 -9
- package/src/ledger/ledger.ts +6 -1
- package/src/ledger/repo-view.ts +14 -4
- package/src/ledger/toolchain-identity.ts +4 -2
- package/src/mv/mv-md.ts +6 -1
- package/src/problems/extract.ts +27 -5
- package/src/problems/problem-line.ts +2 -3
- package/src/release/run-release.ts +13 -8
- package/src/repo-config.ts +44 -27
- package/src/scaffold/files/bunfig.toml +11 -0
- package/src/scaffold/files/vscode-settings.json +1 -2
- package/src/scaffold/jsonc-spans.ts +16 -0
- package/src/scaffold/project-scaffold.ts +43 -77
- package/src/serve/hub-app/check-view.tsx +4 -4
- package/src/serve/hub-app/check-words.ts +1 -0
- package/src/serve/hub-app/checks-svc.ts +1 -2
- package/src/serve/hub-app/home.html +0 -1
- package/src/serve/hub-app/hub-types.ts +0 -2
- package/src/serve/hub-app/index.html +0 -1
- package/src/serve/hub-app/project-list.css +1 -2
- package/src/serve/hub-app/tree-build.ts +3 -2
- package/src/serve/hub-app/tree-svc.ts +2 -2
- package/src/serve/hub-port.ts +3 -3
- package/src/serve/hub-runtime.ts +0 -3
- package/src/serve/serve-hub.ts +1 -16
- package/src/serve/tracked-watch.ts +1 -1
- package/src/start-repo.ts +15 -3
- package/src/suite/judge-suites.ts +29 -28
- package/src/suite/select.ts +2 -0
- package/src/suite/suite-cli.ts +6 -3
- package/src/suite/suite-cpu-preload.ts +7 -11
- package/src/suite/test-units.ts +7 -4
- package/src/ts/config.ts +12 -5
- package/src/ts/imports.ts +3 -1
- package/src/tui/build-steps-panel.ts +1 -1
- package/src/wiki/scan-md.ts +16 -9
- package/src/checks/SK009-commit-ai-tells.check.ts +0 -131
- package/src/checks/SK014-prose-ste.check.ts +0 -78
- package/src/cli/cmd_Prose.ts +0 -74
- package/src/prose/prose-view.ts +0 -90
- package/src/prose/run-prose.ts +0 -187
- package/src/prose/ste.ts +0 -552
- package/src/scaffold/files/vscode-tasks.json +0 -24
- package/src/serve/hub-app/prose-hits-st.ts +0 -125
- package/src/serve/hub-app/prose-hits-view.tsx +0 -176
- package/src/serve/hub-app/prose-hits.css +0 -21
|
@@ -2,17 +2,13 @@
|
|
|
2
2
|
// worker by the suite runner. A suite over the budget in wall time is run again
|
|
3
3
|
// alone to learn its CPU, one after the other, and on a loaded machine nearly
|
|
4
4
|
// every gate heavy suite goes over in wall time: those re-runs were most of the
|
|
5
|
-
// test check's wall time. A suite whose CPU in the parallel phase
|
|
6
|
-
//
|
|
5
|
+
// test check's wall time. A suite whose own CPU in the parallel phase is inside
|
|
6
|
+
// the budget needs no re-run.
|
|
7
7
|
//
|
|
8
|
-
// One line per suite: `<file>\t<cpu ms
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
// load, top level and tests of every earlier suite. A lone run can only need
|
|
13
|
-
// what this worker computed by the end of the suite (a module an earlier suite
|
|
14
|
-
// loaded, a memo an earlier test filled), so cpu plus prior bounds it from
|
|
15
|
-
// above, and sharing the machine only raises CPU.
|
|
8
|
+
// One line per suite: `<file>\t<cpu ms>`, the worker's own CPU plus that of
|
|
9
|
+
// every child it reaped, from the end of the previous suite in this worker to
|
|
10
|
+
// the end of this one, so the file's import, its hooks and its tests count. What
|
|
11
|
+
// the worker burned before that (its start, earlier suites) is not this suite's.
|
|
16
12
|
//
|
|
17
13
|
// A child still alive, or dead and not yet reaped, when the suite ends has CPU
|
|
18
14
|
// nobody counted yet, so that suite's line says `-` and it runs alone if it is
|
|
@@ -84,7 +80,7 @@ if (out != null && p != null) {
|
|
|
84
80
|
// every suite gets a line, since the line is also how the runner knows
|
|
85
81
|
// it ran; a suite whose figure cannot be trusted says `-`
|
|
86
82
|
try {
|
|
87
|
-
const figure = p.liveChildren() === 0 ? `${Math.round(now - ended)}
|
|
83
|
+
const figure = p.liveChildren() === 0 ? `${Math.round(now - ended)}` : '-'
|
|
88
84
|
appendFileSync(out, `${Bun.main}\t${figure}\n`)
|
|
89
85
|
} catch { /* no line: the suite runs alone if it is a suspect */ }
|
|
90
86
|
ended = now
|
package/src/suite/test-units.ts
CHANGED
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
import { basename, posix } from 'path'
|
|
12
12
|
import { rawName, routesToShipkit, slotBody } from '../core/canonical-scripts.ts'
|
|
13
13
|
import { parseJsonc } from '../ts/jsonc.ts'
|
|
14
|
-
import {
|
|
14
|
+
import { repoOff, type OffKeys } from '../core/when.ts'
|
|
15
|
+
import { bunfigTest, inDotDir, normSuitePath, parseBunTestScript, type BunfigTest, type TestsConfig } from './judge-suites.ts'
|
|
15
16
|
|
|
16
17
|
export { bunfigTest, type BunfigTest }
|
|
17
18
|
|
|
@@ -41,6 +42,7 @@ export type TestRun = {
|
|
|
41
42
|
export function runReaches(file: string, run: TestRun): { runs: true } | { runs: false; ignoredBy: string } | { runs: false; outside: string } | null {
|
|
42
43
|
if (run.args == null) return null
|
|
43
44
|
const f = normSuitePath(file)
|
|
45
|
+
if (inDotDir(f)) return { runs: false, ignoredBy: 'a dot dir, which bun never discovers' }
|
|
44
46
|
const cli: string[] = []
|
|
45
47
|
const filters: string[] = []
|
|
46
48
|
for (let i = 0; i < run.args.length; i++) {
|
|
@@ -183,19 +185,20 @@ export function planTests(o: {
|
|
|
183
185
|
/** the whole plan for a repo, from what its config and files say. `read`
|
|
184
186
|
* answers a repo-relative path, null when absent */
|
|
185
187
|
export function testPlanOf(o: {
|
|
186
|
-
config:
|
|
188
|
+
config: OffKeys & { units?: { dir?: unknown; id?: string; tests?: TestsConfig; tsconfig?: string; release?: boolean; deps?: string[] }[] }
|
|
187
189
|
files: string[]
|
|
188
190
|
read: (rel: string) => string | null
|
|
189
191
|
}): TestPlan & { units: TestUnitDecl[] } {
|
|
190
192
|
const units = declaredTestUnits(o.config.units)
|
|
191
193
|
let scripts: Record<string, string> = {}
|
|
192
194
|
try { scripts = (JSON.parse(o.read('package.json') ?? '{}') as { scripts?: Record<string, string> }).scripts ?? {} } catch { scripts = {} }
|
|
193
|
-
const
|
|
195
|
+
const off = repoOff(o.config, 'test')
|
|
196
|
+
const slot = slotBody('test', off != null ? false : o.config.defaults?.['test'] as { cmd?: unknown; logic?: unknown } | undefined, scripts)
|
|
194
197
|
const bunfig = (dir: string): BunfigTest => bunfigTest(o.read(dir === '' ? 'bunfig.toml' : posix.join(dir, 'bunfig.toml')))
|
|
195
198
|
const rootRun: TestRun | null = slot.kind === 'override' ? { args: null, bunfig: bunfig(''), extraIgnores: [] }
|
|
196
199
|
: slot.kind === 'script' ? { args: parseBunTestScript(slot.body), bunfig: bunfig(''), extraIgnores: unitIgnores(units) }
|
|
197
200
|
: null
|
|
198
|
-
const rootWhy = slot.kind === 'off' ?
|
|
201
|
+
const rootWhy = slot.kind === 'off' ? `the repo turns the root test check off (${off?.reason ?? 'off'}), so nothing runs it`
|
|
199
202
|
: slot.kind === 'routed-empty' ? "the `test` script routes to shipkit and there is no 'test:raw' body, so the root test check runs nothing"
|
|
200
203
|
: 'no `test` or `test:raw` script, so the root has no test check'
|
|
201
204
|
const plan = planTests({
|
package/src/ts/config.ts
CHANGED
|
@@ -10,9 +10,13 @@
|
|
|
10
10
|
// 2. tsconfig "paths" keys ("src/*" → root 'src') when roots are not set
|
|
11
11
|
// 3. fallback: ['src', 'scripts']
|
|
12
12
|
// skip entries are repo-relative dir prefixes; the defaults below always apply.
|
|
13
|
+
// The repo config's `vendored` dirs (core/vendored.ts) are third party code:
|
|
14
|
+
// never crawled, never a package, never rewritten.
|
|
13
15
|
import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
|
|
14
16
|
import { join } from 'path'
|
|
15
17
|
import { gitRoot } from '../core/git-root.ts'
|
|
18
|
+
import { isVendored } from '../core/vendored.ts'
|
|
19
|
+
import { readVendored } from '../core/vendored-read.ts'
|
|
16
20
|
|
|
17
21
|
export type RvtPackage = {
|
|
18
22
|
name: string // package.json "name" — the import root for files inside
|
|
@@ -24,6 +28,7 @@ export type RvtConfig = {
|
|
|
24
28
|
roots: string[] // repo-relative dirs to crawl / allowed specifier roots
|
|
25
29
|
skip: string[] // repo-relative dir prefixes to never touch
|
|
26
30
|
packages: RvtPackage[] // discovered sub-packages, longest dir first
|
|
31
|
+
vendored?: string[] // repo-relative third party dirs (repo.config.ts `vendored`), skipped as exact prefixes
|
|
27
32
|
}
|
|
28
33
|
|
|
29
34
|
/** never source, at any depth */
|
|
@@ -83,9 +88,9 @@ function expandRootGlobs(root: string, roots: string[]): string[] {
|
|
|
83
88
|
|
|
84
89
|
// sub-package discovery: any dir under the roots with a named package.json is
|
|
85
90
|
// its own import root (files inside address each other via the package name)
|
|
86
|
-
function discoverPackages(root: string, roots: string[], skip: string[]): RvtPackage[] {
|
|
91
|
+
function discoverPackages(root: string, roots: string[], skip: string[], vendored: string[]): RvtPackage[] {
|
|
87
92
|
const found: RvtPackage[] = []
|
|
88
|
-
const cfg = { root, roots, skip, packages: [] }
|
|
93
|
+
const cfg = { root, roots, skip, packages: [], vendored }
|
|
89
94
|
const walk = (rel: string): void => {
|
|
90
95
|
const abs = join(root, rel)
|
|
91
96
|
let entries
|
|
@@ -128,9 +133,10 @@ export function loadConfig(cwd: string = process.cwd()): RvtConfig {
|
|
|
128
133
|
}
|
|
129
134
|
if (roots.length === 0) roots = rootsFromTsconfig(root)
|
|
130
135
|
if (roots.length === 0) roots = ['src', 'scripts'].filter((r) => existsSync(join(root, r)))
|
|
131
|
-
|
|
136
|
+
const vendored = readVendored(root)
|
|
137
|
+
roots = expandRootGlobs(root, roots).filter((r) => !isVendored(r, vendored))
|
|
132
138
|
const mergedSkip = [...DEFAULT_SKIP, ...skip]
|
|
133
|
-
const packages = discoverPackages(root, roots, mergedSkip)
|
|
139
|
+
const packages = discoverPackages(root, roots, mergedSkip, vendored)
|
|
134
140
|
// a package named like a root (or another package) hijacks that specifier
|
|
135
141
|
// prefix — resolution would silently diverge from tsc/bun; be loud
|
|
136
142
|
const seen = new Set<string>(roots)
|
|
@@ -138,7 +144,7 @@ export function loadConfig(cwd: string = process.cwd()): RvtConfig {
|
|
|
138
144
|
if (seen.has(p.name)) console.error(`rvt WARN: package name '${p.name}' (${p.dir}) collides with a root or another package — its specifiers may resolve wrong`)
|
|
139
145
|
seen.add(p.name)
|
|
140
146
|
}
|
|
141
|
-
return { root, roots, skip: mergedSkip, packages }
|
|
147
|
+
return { root, roots, skip: mergedSkip, packages, vendored }
|
|
142
148
|
}
|
|
143
149
|
|
|
144
150
|
// innermost package containing a repo-relative path, or null
|
|
@@ -169,6 +175,7 @@ export const toPosix = (p: string): string => p.replace(/\\/g, '/')
|
|
|
169
175
|
// package root is output, `wiki/build/<lib>/src` is source.
|
|
170
176
|
export function isSkipped(relPath: string, cfg: RvtConfig, packageDirs: readonly string[] = []): boolean {
|
|
171
177
|
const n = toPosix(relPath)
|
|
178
|
+
if (isVendored(n, cfg.vendored ?? [])) return true
|
|
172
179
|
const segs = n.split('/')
|
|
173
180
|
return cfg.skip.some((s) => {
|
|
174
181
|
if (s.includes('/')) return n === s || n.startsWith(`${s}/`)
|
package/src/ts/imports.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { readdirSync, readFileSync, writeFileSync, existsSync, type Dirent } fro
|
|
|
14
14
|
import { dirname, extname, isAbsolute, join, relative, resolve } from 'path'
|
|
15
15
|
import { canonicalSpecifier, isSkipped, loadConfig, parseJsonc, resolvePackageSpecifier, toPosix, type RvtConfig } from 'rvlib-shipkit/src/ts/config.ts'
|
|
16
16
|
import { templateLineMask } from 'rvlib-shipkit/src/ts/source-mask.ts'
|
|
17
|
+
import { isVendored } from 'rvlib-shipkit/src/core/vendored.ts'
|
|
17
18
|
|
|
18
19
|
const ASSET_EXTENSIONS = new Set([
|
|
19
20
|
'.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.ico',
|
|
@@ -341,7 +342,8 @@ export function runImports(opts: { save?: boolean; files?: string[]; cfg?: RvtCo
|
|
|
341
342
|
for (const e of result.errors) run.errors.push({ file: toPosix(relative(cfg.root, abs)), ...e })
|
|
342
343
|
}
|
|
343
344
|
if (opts.files != null && opts.files.length > 0) {
|
|
344
|
-
|
|
345
|
+
// a vendored file named by the on-save hook is still third party code
|
|
346
|
+
for (const fp of opts.files) if (!isVendored(toPosix(relative(cfg.root, resolve(cfg.root, fp))), cfg.vendored ?? [])) process1(fp)
|
|
345
347
|
} else {
|
|
346
348
|
for (const r of cfg.roots) {
|
|
347
349
|
const abs = join(cfg.root, r)
|
|
@@ -36,7 +36,7 @@ export function buildStepsPanel(root: string, config: RepoConfig): TuiPanelDef {
|
|
|
36
36
|
const { tasks, disabled, unfilled } = await allTasks(config, root)
|
|
37
37
|
rows = [
|
|
38
38
|
...tasks.map((t): TuiRow => ({ key: t.id, label: t.id, status: 'pending', detail: [] })),
|
|
39
|
-
...disabled.map((id): TuiRow => ({ key: id, label: id, status: 'skip', detail: ['skip:
|
|
39
|
+
...disabled.map((id): TuiRow => ({ key: id, label: id, status: 'skip', detail: ['skip: turned off in repo.config.ts'] })),
|
|
40
40
|
...unfilled.map((u): TuiRow => ({ key: u.id, label: u.id, status: 'skip', detail: [`skip: ${u.reason}`] })),
|
|
41
41
|
]
|
|
42
42
|
const byKey = new Map(rows.map((r, i) => [r.key, i]))
|
package/src/wiki/scan-md.ts
CHANGED
|
@@ -11,9 +11,11 @@
|
|
|
11
11
|
// Depth = 0-1 BFS from the index (eager edge keeps depth, lazy adds 1);
|
|
12
12
|
// unreachable files stay in the graph with no depth (lint-md.ts turns those,
|
|
13
13
|
// plus dangling refs, into failures — the graph itself is a viewer).
|
|
14
|
+
// The enclosing repo's vendored dirs (core/vendored.ts) are never scanned.
|
|
14
15
|
import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
|
|
15
16
|
import { basename, dirname, join, relative, resolve } from 'path'
|
|
16
17
|
import type { MdEdge, MdGraph, MdNode } from 'rvlib-shipkit/src/wiki/md-graph.ts'
|
|
18
|
+
import { vendoredAround } from 'rvlib-shipkit/src/core/vendored-read.ts'
|
|
17
19
|
|
|
18
20
|
export type ScanMdConfig = {
|
|
19
21
|
root: string // absolute dir to scan
|
|
@@ -22,7 +24,7 @@ export type ScanMdConfig = {
|
|
|
22
24
|
boundary?: string // absolute dir enclosing root (a repo): refs leaving root but staying here resolve on disk
|
|
23
25
|
}
|
|
24
26
|
|
|
25
|
-
export type MdDanglingRef = { from: string; token: string } // an @/→/[](…) ref whose target is not in the scan
|
|
27
|
+
export type MdDanglingRef = { from: string; token: string; line: number } // an @/→/[](…) ref whose target is not in the scan, and its 1-based line in `from`
|
|
26
28
|
export type MdScan = {
|
|
27
29
|
graph: MdGraph
|
|
28
30
|
index: string | null // resolved entrypoint(s), ' + '-joined when both defaults exist; null = none found
|
|
@@ -35,12 +37,14 @@ export function scanMdGraph(cfg: ScanMdConfig): MdGraph {
|
|
|
35
37
|
|
|
36
38
|
export function scanMd(cfg: ScanMdConfig): MdScan {
|
|
37
39
|
const skip = new Set(['node_modules', '.git', 'dist', 'tmp', ...(cfg.skipDirs ?? [])])
|
|
40
|
+
const repo = vendoredAround(cfg.root)
|
|
41
|
+
const vendored = new Set((repo?.dirs ?? []).map((d) => join(repo!.root, d)))
|
|
38
42
|
const files: string[] = []
|
|
39
43
|
const walk = (rel: string) => {
|
|
40
44
|
for (const e of readdirSync(join(cfg.root, rel))) {
|
|
41
45
|
const r = rel === '' ? e : `${rel}/${e}`
|
|
42
46
|
if (statSync(join(cfg.root, r)).isDirectory()) {
|
|
43
|
-
if (!skip.has(e) && !e.startsWith('.')) walk(r)
|
|
47
|
+
if (!skip.has(e) && !e.startsWith('.') && !vendored.has(join(cfg.root, r))) walk(r)
|
|
44
48
|
} else if (e.endsWith('.md')) files.push(r)
|
|
45
49
|
}
|
|
46
50
|
}
|
|
@@ -84,19 +88,22 @@ export function scanMd(cfg: ScanMdConfig): MdScan {
|
|
|
84
88
|
const raw = readFileSync(join(cfg.root, rel), 'utf8')
|
|
85
89
|
const body = raw.startsWith('---\n') ? raw.slice(4).replace(/^[\s\S]*?^---\s*$/m, '') : raw
|
|
86
90
|
const spans: string[] = []
|
|
87
|
-
|
|
91
|
+
// fences blanked to their newlines, so an offset in prose still names its line
|
|
92
|
+
const prose = body.replace(/^```[\s\S]*?^```\s*$/gm, (f) => f.replace(/[^\n]/g, '')).replace(/`([^`\n]+)`/g, (_, s: string) => { spans.push(s); return ' ' })
|
|
93
|
+
const above = raw.slice(0, raw.length - body.length).split('\n').length - 1
|
|
94
|
+
const lineOf = (m: RegExpMatchArray): number => above + prose.slice(0, m.index! + m[0].length - 1).split('\n').length
|
|
88
95
|
const out: MdEdge[] = []
|
|
89
96
|
// strict: @/→/[](…) are deliberate wiki refs — a miss is a dangling ref.
|
|
90
97
|
// Backtick spans mention paths casually (other repos, deleted examples) — resolve or ignore, never fail.
|
|
91
|
-
const push = (token: string, eager: boolean, strict: boolean) => {
|
|
98
|
+
const push = (token: string, eager: boolean, strict: boolean, line = 0) => {
|
|
92
99
|
const hit = resolveRef(rel, token)
|
|
93
|
-
if (hit == null) { if (strict) dangling.push({ from: rel, token }); return }
|
|
100
|
+
if (hit == null) { if (strict) dangling.push({ from: rel, token, line }); return }
|
|
94
101
|
for (const to of hit.to) if (to !== rel) out.push({ from: rel, to, eager })
|
|
95
102
|
}
|
|
96
|
-
for (const m of prose.matchAll(/(?:^|[\s(])@((?:\.{1,2}\/)*[\w][\w./-]*\.md)/gm)) push(m[1]!, true, true)
|
|
97
|
-
for (const m of prose.matchAll(/→\s*([\w][\w./-]*\.md)\b/g)) push(m[1]!, false, true)
|
|
98
|
-
for (const m of prose.matchAll(/\]\(([^)\s:]+\.md)\)/g)) push(m[1]!, false, true)
|
|
99
|
-
for (const m of prose.matchAll(/\]\(([^)\s:]*\/)\)/g)) push(m[1]!, false, true)
|
|
103
|
+
for (const m of prose.matchAll(/(?:^|[\s(])@((?:\.{1,2}\/)*[\w][\w./-]*\.md)/gm)) push(m[1]!, true, true, lineOf(m))
|
|
104
|
+
for (const m of prose.matchAll(/→\s*([\w][\w./-]*\.md)\b/g)) push(m[1]!, false, true, lineOf(m))
|
|
105
|
+
for (const m of prose.matchAll(/\]\(([^)\s:]+\.md)\)/g)) push(m[1]!, false, true, lineOf(m))
|
|
106
|
+
for (const m of prose.matchAll(/\]\(([^)\s:]*\/)\)/g)) push(m[1]!, false, true, lineOf(m))
|
|
100
107
|
for (const s of spans) for (const m of s.matchAll(/[\w][\w./-]*\.md/g)) push(m[0], false, false)
|
|
101
108
|
for (const s of spans) for (const m of s.matchAll(/(?:^|\s)([\w.][\w./-]*\/)(?=$|[\s<*])/g)) push(m[1]!, false, false)
|
|
102
109
|
return out
|
|
@@ -1,131 +0,0 @@
|
|
|
1
|
-
// SK009 commit AI tells (identity/how-we-commit.md): the commit-msg hook is
|
|
2
|
-
// what refuses AI attribution, in every repo, so this check verifies the hook
|
|
3
|
-
// is ARMED: shipkit's own hooksPath, or a foreign one whose commit-msg chains
|
|
4
|
-
// the shipkit gate. It never scans history: what is pushed is accepted as it
|
|
5
|
-
// is, and only a new commit can still be stopped. A declared-public repo also
|
|
6
|
-
// needs the tracked attribution silencer, and its ref NAMES are checked (a
|
|
7
|
-
// FABLE-checkpoint tag outs the agent like a trailer); a branch nobody pushed
|
|
8
|
-
// is named "local only" and warns.
|
|
9
|
-
import { createHash } from 'crypto'
|
|
10
|
-
import { readFileSync } from 'fs'
|
|
11
|
-
import { isAbsolute, join } from 'path'
|
|
12
|
-
import { nestedIn } from '../core/nesting.ts'
|
|
13
|
-
import { gitBin } from '../core/git-bin.ts'
|
|
14
|
-
import { fileStamp } from '../core/file-stamp.ts'
|
|
15
|
-
import { CONFIG_FILE } from '../core/check-pass.ts'
|
|
16
|
-
import { LAYOUT } from '../core/layout.ts'
|
|
17
|
-
import { check, type CheckEnv } from '../check.ts'
|
|
18
|
-
import { declaredVisibility } from '../core/repo-facts.ts'
|
|
19
|
-
import { hookBinInvocation } from '../hooks/run-hooks.ts'
|
|
20
|
-
|
|
21
|
-
// the documented silencer: .claude/settings.json attribution.commit/.pr both
|
|
22
|
-
// empty — the harness then never writes the trailer or the PR footer
|
|
23
|
-
const attributionSilenced = (root: string): boolean => {
|
|
24
|
-
try {
|
|
25
|
-
const s = JSON.parse(readFileSync(join(root, '.claude', 'settings.json'), 'utf8')) as { attribution?: { commit?: string; pr?: string } }
|
|
26
|
-
return s.attribution != null && s.attribution.commit === '' && s.attribution.pr === ''
|
|
27
|
-
} catch {
|
|
28
|
-
return false
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
const readOrNull = (abs: string): string | null => {
|
|
33
|
-
try {
|
|
34
|
-
return readFileSync(abs, 'utf8')
|
|
35
|
-
} catch {
|
|
36
|
-
return null
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/** a commit-msg body that runs the shipkit gate on the message: a live line
|
|
41
|
-
* (never a comment) whose failure is not swallowed by `|| true` */
|
|
42
|
-
const chainsShipkit = (body: string): boolean =>
|
|
43
|
-
body.split('\n').some((l) => !l.trim().startsWith('#') && /\bhooks\s+commit-msg\b/.test(l) && !/\|\|\s*(?:true|:)\b/.test(l))
|
|
44
|
-
|
|
45
|
-
/** the chain line to paste into a foreign hook: a clone with no install skips
|
|
46
|
-
* the gate with a note, rather than failing every commit */
|
|
47
|
-
export const guardedChain = (bin: string): string => {
|
|
48
|
-
const cli = bin.replace(/^bun\s+/, '')
|
|
49
|
-
return `if [ -f ${cli} ]; then ${bin} hooks commit-msg "$1" || exit 1; else echo "commit-msg: rvlib-shipkit not installed (run bun install), attribution gate skipped" >&2; fi`
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
// ref NAMES tell too. bare match only for names with no other meaning;
|
|
53
|
-
// vendor and codename words (cursor, openai, gemini, opus, fable, …) name
|
|
54
|
-
// legitimate branches and features, they only match in a checkpoint/session shape
|
|
55
|
-
const REF_TELL = /\b(?:claude|chatgpt|copilot|aider|devin)\b|(?:^|[-_/])(?:fable|sonnet|haiku|opus|gemini|codex)[-_](?:checkpoint|session|ckpt)/i
|
|
56
|
-
|
|
57
|
-
const gitOut = (root: string, args: string[]): { code: number; out: string } => {
|
|
58
|
-
const p = Bun.spawnSync([gitBin(), ...args], { cwd: root, stdout: 'pipe', stderr: 'pipe' })
|
|
59
|
-
return { code: p.exitCode, out: p.stdout.toString() }
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/** what the answer depends on: the declared visibility, the hooksPath and the
|
|
63
|
-
* commit-msg file it points at, and for a public repo every ref NAME and the
|
|
64
|
-
* silencer. A commit moves none of them */
|
|
65
|
-
export function historyKey(env: CheckEnv): string {
|
|
66
|
-
const nested = nestedIn(env.root)
|
|
67
|
-
if (nested != null) return `nested in ${nested}`
|
|
68
|
-
const declared = declaredVisibility(env.config)
|
|
69
|
-
const hooksPath = gitOut(env.root, ['config', 'core.hooksPath'])
|
|
70
|
-
const dir = hooksPath.code === 0 ? hooksPath.out.trim() : ''
|
|
71
|
-
const hook = `hooks=${dir}|msg=${dir === '' ? 'none' : fileStamp(isAbsolute(dir) ? join(dir, 'commit-msg') : join(env.root, dir, 'commit-msg'))}`
|
|
72
|
-
if (declared !== 'public') return `declared ${declared}|${hook}`
|
|
73
|
-
const refs = gitOut(env.root, ['for-each-ref', '--format=%(refname)', 'refs/tags', 'refs/heads', 'refs/remotes']).out
|
|
74
|
-
const digest = createHash('sha256').update(refs).digest('hex').slice(0, 16)
|
|
75
|
-
return `public|${hook}|refs=${digest}|settings=${fileStamp(join(env.root, '.claude', 'settings.json'))}`
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
export const commitAiTells = check({
|
|
79
|
-
id: 'SK009',
|
|
80
|
-
blocking: true,
|
|
81
|
-
title: 'commit ai tells',
|
|
82
|
-
description: 'the commit-msg hook that refuses AI attribution is armed, and no ref name tells',
|
|
83
|
-
owner: 'identity/how-we-commit.md',
|
|
84
|
-
runs: { onPreCommit: false, onCi: true, onFull: true, onRelease: true, onHealth: true, onSave: 'moved', onClock: false },
|
|
85
|
-
// git config and ref names, which no tree id describes, so they are the key
|
|
86
|
-
cache: { by: 'files', inputs: [CONFIG_FILE], key: historyKey },
|
|
87
|
-
fix: 'none',
|
|
88
|
-
applies: 'always',
|
|
89
|
-
run: async (c) => {
|
|
90
|
-
const nested = nestedIn(c.root)
|
|
91
|
-
if (nested != null) return void c.lines.push({ kind: 'skip', text: `commit gates not checked, nested in ${nested}: the parent repo owns them` })
|
|
92
|
-
const top = await c.exec(['git', 'rev-parse', '--show-toplevel'], { cwd: c.root })
|
|
93
|
-
if (top.code !== 0) return void c.lines.push({ kind: 'skip', text: 'not a git repo: no commit to guard yet' })
|
|
94
|
-
|
|
95
|
-
const hp = await c.exec(['git', 'config', 'core.hooksPath'], { cwd: c.root })
|
|
96
|
-
const dir = hp.code === 0 ? hp.out.trim() : ''
|
|
97
|
-
const chain = guardedChain(hookBinInvocation(c.root))
|
|
98
|
-
if (dir === '' && process.env['CI'] != null && process.env['CI'] !== '') {
|
|
99
|
-
c.lines.push({ kind: 'skip', text: 'commit-msg hook not armed in this CI checkout: a CI run makes no commit, and the fix pass arms hooks on a machine only' })
|
|
100
|
-
} else if (dir === '') {
|
|
101
|
-
c.fail('the commit-msg hook is not armed, so nothing refuses an AI attribution trailer: run `shipkit hooks install` (`shipkit ci` arms it too)')
|
|
102
|
-
} else {
|
|
103
|
-
const body = isAbsolute(dir) ? readOrNull(join(dir, 'commit-msg')) : c.read(`${dir}/commit-msg`)
|
|
104
|
-
const own = dir === LAYOUT.hooks
|
|
105
|
-
if (body == null)
|
|
106
|
-
c.fail(`core.hooksPath=${dir} has no ${dir}/commit-msg, so nothing refuses an AI attribution trailer: ${own ? 'run `shipkit hooks install`' : `add one that runs \`${chain}\``}`)
|
|
107
|
-
else if (!chainsShipkit(body))
|
|
108
|
-
c.fail(`core.hooksPath=${dir}: its commit-msg (${body.split('\n').find((l) => l.trim() !== '' && !l.startsWith('#'))?.trim().slice(0, 80) ?? 'empty'}) does not run the shipkit gate, so nothing refuses AI attribution: add \`${chain}\` above its last line`)
|
|
109
|
-
else c.ok(`the commit-msg hook refuses AI attribution (${own ? dir : `${dir}, chained to the shipkit gate`})`)
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
if (declaredVisibility(c.config) !== 'public') return
|
|
113
|
-
if (!attributionSilenced(c.root))
|
|
114
|
-
c.fail(`.claude/settings.json does not silence AI attribution: track it with {"attribution": {"commit": "", "pr": ""}} so no agent's harness ever writes the trailer or the PR footer`)
|
|
115
|
-
const refs = await c.exec(['git', 'for-each-ref', '--format=%(refname)', 'refs/tags', 'refs/heads', 'refs/remotes'], { cwd: c.root, timeoutMs: 30_000 })
|
|
116
|
-
if (refs.code !== 0) return void c.lines.push({ kind: 'skip', text: `ref names unverifiable: git for-each-ref exit ${refs.code}` })
|
|
117
|
-
const all = refs.out.split('\n').map((r) => r.trim()).filter((r) => r !== '')
|
|
118
|
-
const remote = new Set(all.filter((r) => r.startsWith('refs/remotes/')).map((r) => r.split('/').slice(3).join('/')))
|
|
119
|
-
const tells = all.filter((r) => REF_TELL.test(r.replace(/^refs\/(?:heads|tags|remotes)\//, '')))
|
|
120
|
-
const shown = (r: string): string => r.replace(/^refs\/(?:heads|tags|remotes)\//, '')
|
|
121
|
-
const local = tells.filter((r) => r.startsWith('refs/heads/') && !remote.has(shown(r)))
|
|
122
|
-
const out = tells.filter((r) => !local.includes(r) && !(r.startsWith('refs/heads/') && remote.has(shown(r))))
|
|
123
|
-
const list = (rs: string[], suffix = ''): string => rs.slice(0, 5).map((r) => `${shown(r)}${suffix}`).join(' ') + (rs.length > 5 ? ' …' : '')
|
|
124
|
-
if (out.length > 0)
|
|
125
|
-
c.fail(`${out.length} ref name(s) carry an AI tell in a declared-public repo (${list(out)}): rename or delete them, a pushed one on the remote too, then \`git fetch --prune\` so a remote ref deleted there leaves this clone`)
|
|
126
|
-
if (local.length > 0)
|
|
127
|
-
c.warn(`${local.length} branch name(s) carry an AI tell (${list(local, ' (local only)')}): never push them under this name, rename or delete them`)
|
|
128
|
-
},
|
|
129
|
-
})
|
|
130
|
-
|
|
131
|
-
export default commitAiTells
|
|
@@ -1,78 +0,0 @@
|
|
|
1
|
-
// SK014 — the STE prose standard, in the gate (identity/writing-voice.md).
|
|
2
|
-
//
|
|
3
|
-
// Opt-in by declaration: a repo with no `prose.include` is SKIPPED, not
|
|
4
|
-
// failed. STE is house doctrine for prose an agent writes, and a rule that
|
|
5
|
-
// turned every existing repo red the day it shipped would be turned off
|
|
6
|
-
// rather than obeyed.
|
|
7
|
-
//
|
|
8
|
-
// The tier split is the whole design. `fix` and `hard` hits BLOCK: they are
|
|
9
|
-
// mechanical, and a machine reporting them is never guessing. `soft` hits
|
|
10
|
-
// (sentence length, passive voice, modals, semicolons, …) need a human read,
|
|
11
|
-
// so they are a note, never an attention item, and only a declared
|
|
12
|
-
// `baseline` makes them blocking, as a ratchet that can only go down.
|
|
13
|
-
//
|
|
14
|
-
// It carries NO `fix()` on purpose. `shipkit ci` runs `--fix-rules` by
|
|
15
|
-
// default, so a rule with a fixer rewrites tracked files unattended, and
|
|
16
|
-
// prose is not repo shape: a wrong package.json line is a config, a wrong
|
|
17
|
-
// sentence is content. The rewrite lives behind an explicit `shipkit prose
|
|
18
|
-
// --fix` that a human ran and can read the diff of.
|
|
19
|
-
import { classSummary, proseLines, runProseCheck, type ProseReport } from '../prose/run-prose.ts'
|
|
20
|
-
import { tierOf, type ProseClass } from '../prose/ste.ts'
|
|
21
|
-
import { check } from '../check.ts'
|
|
22
|
-
|
|
23
|
-
const OWNER = 'identity/writing-voice.md'
|
|
24
|
-
|
|
25
|
-
export const proseSte = check({
|
|
26
|
-
id: 'SK014',
|
|
27
|
-
blocking: true,
|
|
28
|
-
title: 'STE prose',
|
|
29
|
-
description: 'house prose passes the mechanical STE rules',
|
|
30
|
-
owner: OWNER,
|
|
31
|
-
// keyed on what it reads: each include glob's listing and every file in
|
|
32
|
-
// scope, so an edit outside the declared prose never reruns it
|
|
33
|
-
runs: { onPreCommit: false, onCi: false, onFull: true, onRelease: true, onHealth: true, onSave: 'moved', onClock: false },
|
|
34
|
-
cache: { by: 'reads' },
|
|
35
|
-
fix: 'none',
|
|
36
|
-
applies: 'always',
|
|
37
|
-
run: (c) => {
|
|
38
|
-
const cfg = c.config.prose
|
|
39
|
-
if (cfg == null || (cfg.include ?? []).length === 0) {
|
|
40
|
-
c.ok(`no prose scope declared — add \`prose: { include: [...] }\` to repo.config.ts to gate house prose on STE`)
|
|
41
|
-
return
|
|
42
|
-
}
|
|
43
|
-
const r = runProseCheck({ c, config: cfg })
|
|
44
|
-
// a scope that resolves to zero files reads exactly like a clean repo,
|
|
45
|
-
// and it is the opposite: the repo asked for the gate and the gate
|
|
46
|
-
// inspects nothing. A green here is coverage nobody has.
|
|
47
|
-
if (r.files === 0) {
|
|
48
|
-
c.warn(
|
|
49
|
-
`prose scope matches no file: ${(cfg.include ?? []).join(', ')} — declared and inspecting nothing`,
|
|
50
|
-
'fix the globs in repo.config.ts prose.include (or its exempt list), or drop the key to opt out',
|
|
51
|
-
)
|
|
52
|
-
return
|
|
53
|
-
}
|
|
54
|
-
const blocking = r.byTier.fix + r.byTier.hard
|
|
55
|
-
for (const l of proseLines(r, { cap: 12, tiers: ['fix', 'hard'] })) c.note(l)
|
|
56
|
-
if (blocking > 0)
|
|
57
|
-
c.fail(
|
|
58
|
-
`${blocking} STE violation(s) across ${r.byFile.length}/${r.files} file(s) — ${classSummary(r)}`,
|
|
59
|
-
r.fixable > 0 ? `shipkit prose --fix rewrites ${r.fixable} of them` : undefined,
|
|
60
|
-
)
|
|
61
|
-
const soft = softSummary(r)
|
|
62
|
-
if (cfg.baseline != null && r.byTier.soft > cfg.baseline)
|
|
63
|
-
c.fail(`${r.byTier.soft} soft hits (${soft}), above the declared baseline of ${cfg.baseline}: the ratchet only goes down`)
|
|
64
|
-
else if (r.byTier.soft > 0)
|
|
65
|
-
c.note(`${r.byTier.soft} soft hit(s) need a human read (${soft})${cfg.baseline != null ? `, within the baseline of ${cfg.baseline}` : ''}: \`shipkit prose\` lists them`)
|
|
66
|
-
if (!c.failed && blocking === 0) c.ok(`${r.files} file(s) STE-clean on the mechanical rules`)
|
|
67
|
-
}
|
|
68
|
-
})
|
|
69
|
-
|
|
70
|
-
/** every soft class with its count, most first */
|
|
71
|
-
const softSummary = (r: ProseReport): string =>
|
|
72
|
-
Object.entries(r.byClass)
|
|
73
|
-
.filter(([cls]) => tierOf(cls as ProseClass) === 'soft')
|
|
74
|
-
.sort((a, b) => b[1] - a[1])
|
|
75
|
-
.map(([cls, n]) => `${cls} ${n}`)
|
|
76
|
-
.join(', ')
|
|
77
|
-
|
|
78
|
-
export default proseSte
|
package/src/cli/cmd_Prose.ts
DELETED
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
// shipkit prose — the STE checker as a command, so a writer can run it on one
|
|
2
|
-
// file while drafting instead of waiting for the gate. Rule SK014 runs the
|
|
3
|
-
// same module over the repo's declared scope.
|
|
4
|
-
import { Command, Option } from 'clipanion'
|
|
5
|
-
import { loadRepoConfig } from '../repo-config.ts'
|
|
6
|
-
import { classSummary, proseLines, runProseCheck } from '../prose/run-prose.ts'
|
|
7
|
-
import { CheckCtx } from '../core/check-ctx.ts'
|
|
8
|
-
|
|
9
|
-
export class ProseCommand extends Command {
|
|
10
|
-
static override paths = [['prose']]
|
|
11
|
-
|
|
12
|
-
static override usage = Command.Usage({
|
|
13
|
-
category: 'Health',
|
|
14
|
-
description: 'STE prose check (identity/writing-voice.md): mechanical fixes, hard violations, soft warnings',
|
|
15
|
-
examples: [
|
|
16
|
-
['every file this repo declares as prose', 'sk prose'],
|
|
17
|
-
['one file while drafting', 'sk prose wiki/build/shipkit.md'],
|
|
18
|
-
['apply every mechanical rewrite', 'sk prose --fix'],
|
|
19
|
-
['the procedural 20-word limit', 'sk prose --strict'],
|
|
20
|
-
['machine-readable', 'sk prose --json'],
|
|
21
|
-
],
|
|
22
|
-
})
|
|
23
|
-
|
|
24
|
-
// `targets`, not `paths`: clipanion's Command owns the static `paths`
|
|
25
|
-
targets = Option.Rest()
|
|
26
|
-
fix = Option.Boolean('--fix', false, { description: 'apply the mechanical rewrites (contractions, latin, one-to-one substitutions, empty words)' })
|
|
27
|
-
json = Option.Boolean('--json', false, { description: 'machine-readable { files, byClass, hits }' })
|
|
28
|
-
strict = Option.Boolean('--strict', false, { description: 'the 20-word procedural sentence limit instead of the 25-word descriptive one' })
|
|
29
|
-
cap = Option.String('--cap', '25', { description: 'how many hit lines to print before the per-class totals' })
|
|
30
|
-
|
|
31
|
-
async execute(): Promise<number> {
|
|
32
|
-
const root = process.cwd()
|
|
33
|
-
const config = await loadRepoConfig(root)
|
|
34
|
-
const cfg = { ...(config?.prose ?? {}) }
|
|
35
|
-
if (this.strict) cfg.strict = true
|
|
36
|
-
const explicit = this.targets.length > 0
|
|
37
|
-
if ((cfg.include ?? []).length === 0 && !explicit) {
|
|
38
|
-
this.context.stdout.write(
|
|
39
|
-
`skip: this repo declares no prose scope — add \`prose: { include: ['**/*.md'], exempt: [...] }\` to repo.config.ts (identity/writing-voice.md)\n`,
|
|
40
|
-
)
|
|
41
|
-
return 0
|
|
42
|
-
}
|
|
43
|
-
const r = runProseCheck({ c: CheckCtx.open({ root, config }), config: cfg, paths: explicit ? this.targets : undefined, fix: this.fix })
|
|
44
|
-
if (this.json) {
|
|
45
|
-
// every hit carries its file: a flat list without one cannot be
|
|
46
|
-
// acted on, and the grouping is already in byFile
|
|
47
|
-
const hits = r.byFile.flatMap((f) => f.hits.map((h) => ({ file: f.file, ...h })))
|
|
48
|
-
this.context.stdout.write(`${JSON.stringify({ files: r.files, byTier: r.byTier, byClass: r.byClass, fixed: r.fixed, hits }, null, 2)}\n`)
|
|
49
|
-
return r.byTier.fix + r.byTier.hard > 0 ? 1 : 0
|
|
50
|
-
}
|
|
51
|
-
const w = (l: string) => this.context.stdout.write(`${l}\n`)
|
|
52
|
-
// a declared scope that resolves to nothing reads as `0 file(s)
|
|
53
|
-
// STE-clean`, the same green a clean repo gets, over a check that
|
|
54
|
-
// inspected no file at all (rule SK014 warns on the same fact)
|
|
55
|
-
if (!explicit && r.files === 0) {
|
|
56
|
-
w(`WARN prose scope matches no file: ${(cfg.include ?? []).join(', ')} — declared and inspecting nothing`)
|
|
57
|
-
w(' fix: fix the globs in repo.config.ts prose.include (or its exempt list), or drop the key to opt out')
|
|
58
|
-
return 0
|
|
59
|
-
}
|
|
60
|
-
if (r.fixed.length > 0) w(`ok: rewrote ${r.fixed.length} file(s)`)
|
|
61
|
-
for (const l of proseLines(r, { cap: Number(this.cap) || 25 })) w(l)
|
|
62
|
-
const blocking = r.byTier.fix + r.byTier.hard
|
|
63
|
-
const baseline = cfg.baseline
|
|
64
|
-
const overBaseline = baseline != null && r.byTier.soft > baseline
|
|
65
|
-
if (blocking === 0 && !overBaseline) {
|
|
66
|
-
w(`ok: ${r.files} file(s) STE-clean${r.byTier.soft > 0 ? `, ${r.byTier.soft} soft warning(s)${baseline != null ? ` (baseline ${baseline})` : ''}` : ''}`)
|
|
67
|
-
return 0
|
|
68
|
-
}
|
|
69
|
-
if (blocking > 0)
|
|
70
|
-
w(`FAIL: ${blocking} STE violation(s) in ${r.byFile.length} file(s) — ${classSummary(r)}${r.fixable > 0 ? `\n fix: shipkit prose --fix clears ${r.fixable} of them` : ''}`)
|
|
71
|
-
if (overBaseline) w(`FAIL: ${r.byTier.soft} soft hits, above this repo's baseline of ${baseline} — lower the count, or move the baseline down in repo.config.ts`)
|
|
72
|
-
return 1
|
|
73
|
-
}
|
|
74
|
-
}
|
package/src/prose/prose-view.ts
DELETED
|
@@ -1,90 +0,0 @@
|
|
|
1
|
-
// The SK014 page's data: every hit of the prose check, grouped by the word
|
|
2
|
-
// (or the rule, for the classes that judge a shape), noisiest first, each hit
|
|
3
|
-
// with its file, its line and the sentence around it. It runs the SAME
|
|
4
|
-
// module the rule runs, over the same config, so the page and the gate can
|
|
5
|
-
// never count differently.
|
|
6
|
-
import type { CheckCtx } from '../core/check-ctx.ts'
|
|
7
|
-
import type { RepoConfig } from '../repo-config.ts'
|
|
8
|
-
import { runProseCheck } from './run-prose.ts'
|
|
9
|
-
import type { ProseClass, ProseHit, ProseTier, ProseWords } from './ste.ts'
|
|
10
|
-
|
|
11
|
-
export type ProseViewHit = {
|
|
12
|
-
file: string
|
|
13
|
-
line: number
|
|
14
|
-
/** the source line around the hit, clipped to a readable width */
|
|
15
|
-
excerpt: string
|
|
16
|
-
/** where the hit sits inside `excerpt`, [from, to) */
|
|
17
|
-
mark: [number, number]
|
|
18
|
-
why: string
|
|
19
|
-
fix?: string
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export type ProseGroup = {
|
|
23
|
-
/** `<class>:<word>` for a word hit, `<class>` for a shape */
|
|
24
|
-
key: string
|
|
25
|
-
cls: ProseClass
|
|
26
|
-
tier: ProseTier
|
|
27
|
-
/** the dictionary word or phrase an allow entry names; null on a shape */
|
|
28
|
-
word: string | null
|
|
29
|
-
count: number
|
|
30
|
-
hits: ProseViewHit[]
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export type ProseView = {
|
|
34
|
-
/** false when the repo declares no prose scope: the rule skips it */
|
|
35
|
-
scoped: boolean
|
|
36
|
-
files: number
|
|
37
|
-
total: number
|
|
38
|
-
byTier: Record<ProseTier, number>
|
|
39
|
-
groups: ProseGroup[]
|
|
40
|
-
/** the repo's own lists as the config loads them */
|
|
41
|
-
words: ProseWords
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/** how much of a long line the page shows on each side of the hit */
|
|
45
|
-
const SIDE = 70
|
|
46
|
-
|
|
47
|
-
/** the source line a hit sits on, clipped around it, with the hit's span
|
|
48
|
-
* inside the clip. A hit that runs past its line (a long sentence, a
|
|
49
|
-
* paragraph) marks to the end of the line */
|
|
50
|
-
export function excerptOf(text: string, h: Pick<ProseHit, 'start' | 'end'>): { excerpt: string; mark: [number, number] } {
|
|
51
|
-
const from = text.lastIndexOf('\n', h.start - 1) + 1
|
|
52
|
-
const nl = text.indexOf('\n', h.start)
|
|
53
|
-
const to = nl < 0 ? text.length : nl
|
|
54
|
-
const line = text.slice(from, to)
|
|
55
|
-
let a = h.start - from
|
|
56
|
-
let b = Math.min(h.end, to) - from
|
|
57
|
-
let lo = Math.max(0, a - SIDE)
|
|
58
|
-
const hi = Math.min(line.length, Math.max(b, a) + SIDE)
|
|
59
|
-
// a leading indent is noise in a one line quote
|
|
60
|
-
while (lo < a && /\s/.test(line[lo]!)) lo++
|
|
61
|
-
const head = lo > 0 ? '…' : ''
|
|
62
|
-
const tail = hi < line.length ? '…' : ''
|
|
63
|
-
const excerpt = head + line.slice(lo, hi).trimEnd() + tail
|
|
64
|
-
a = a - lo + head.length
|
|
65
|
-
b = Math.min(b - lo + head.length, excerpt.length - tail.length)
|
|
66
|
-
return { excerpt, mark: [a, Math.max(a, b)] }
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
export function proseView(c: Pick<CheckCtx, 'root' | 'read' | 'list' | 'write'>, config: RepoConfig): ProseView {
|
|
70
|
-
const cfg = config.prose
|
|
71
|
-
const empty: ProseView = { scoped: false, files: 0, total: 0, byTier: { fix: 0, hard: 0, soft: 0 }, groups: [], words: cfg?.words ?? {} }
|
|
72
|
-
if (cfg == null || (cfg.include ?? []).length === 0) return empty
|
|
73
|
-
const r = runProseCheck({ c, config: cfg, keepText: true })
|
|
74
|
-
const groups = new Map<string, ProseGroup>()
|
|
75
|
-
for (const f of r.byFile)
|
|
76
|
-
for (const h of f.hits) {
|
|
77
|
-
const key = h.word == null ? h.cls : `${h.cls}:${h.word}`
|
|
78
|
-
let g = groups.get(key)
|
|
79
|
-
if (g == null) {
|
|
80
|
-
g = { key, cls: h.cls, tier: h.tier, word: h.word ?? null, count: 0, hits: [] }
|
|
81
|
-
groups.set(key, g)
|
|
82
|
-
}
|
|
83
|
-
g.count++
|
|
84
|
-
const ex = excerptOf(f.text ?? '', h)
|
|
85
|
-
g.hits.push({ file: f.file, line: h.line, ...ex, why: h.why, ...(h.fix == null ? {} : { fix: h.fix }) })
|
|
86
|
-
}
|
|
87
|
-
// the noisiest first: one allow entry on the top word clears the most
|
|
88
|
-
const sorted = [...groups.values()].sort((a, b) => b.count - a.count || a.key.localeCompare(b.key))
|
|
89
|
-
return { scoped: true, files: r.files, total: r.hits.length, byTier: r.byTier, groups: sorted, words: cfg.words ?? {} }
|
|
90
|
-
}
|