rvlib-shipkit 0.38.0 → 1.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 +371 -16
- package/README.md +26 -14
- package/package.json +2 -2
- package/src/assets/gen/comfyts-gen.ts +27 -10
- 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} +46 -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 +6 -2
- 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 +258 -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 +557 -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 +86 -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 +153 -189
- package/src/core/run-fix.ts +96 -0
- package/src/core/run-fixers.ts +53 -30
- package/src/core/self-update.ts +9 -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/hooks/leak-guard.ts +10 -8
- package/src/hooks/one-way-in-guard.ts +13 -5
- package/src/hooks/root-identity.ts +2 -1
- package/src/hooks/run-hooks.ts +35 -23
- 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 +155 -85
- 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 +3 -2
- package/src/start-repo.ts +75 -36
- package/src/suite/judge-suites.ts +216 -18
- 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/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/ledger/unit-table.ts
DELETED
|
@@ -1,105 +0,0 @@
|
|
|
1
|
-
// THE inventory render — every gate unit as one markdown row, so the question
|
|
2
|
-
// "what runs here, does it block me, and how often does it really run" has a
|
|
3
|
-
// surface that is read rather than reconstructed from three commands.
|
|
4
|
-
//
|
|
5
|
-
// Deliberately carries NO live run state: no timestamps, no key hashes, no
|
|
6
|
-
// last verdict. Everything here is what a unit DECLARED about itself, so two
|
|
7
|
-
// runs a week apart over an unchanged repo write identical bytes and the file
|
|
8
|
-
// never churns. It is still generated, gitignored AND untracked like STATUS.md
|
|
9
|
-
// (rules/untrack-generated.ts), so it is read on disk, never in git history.
|
|
10
|
-
// Live keys are `shipkit keys`, live verdicts are STATUS.md.
|
|
11
|
-
import { join } from 'path'
|
|
12
|
-
import { atomicWrite, withFileLock } from '../core/locked-write.ts'
|
|
13
|
-
import { classify } from './git-key.ts'
|
|
14
|
-
import type { UnitRow } from './gate-units.ts'
|
|
15
|
-
|
|
16
|
-
/** the sidecar every gate run refreshes beside STATUS.md */
|
|
17
|
-
export const UNIT_TABLE_FILE = 'STATUS.full.md'
|
|
18
|
-
|
|
19
|
-
/** a cell can never break the table it sits in */
|
|
20
|
-
const cell = (s: string): string => s.replace(/\|/g, '\\|').replace(/\n/g, ' ')
|
|
21
|
-
|
|
22
|
-
export type UnitTableInput = {
|
|
23
|
-
/** repo name, for the headline */
|
|
24
|
-
name: string
|
|
25
|
-
rows: UnitRow[]
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/** what a unit's cache declaration means for how often it actually runs */
|
|
29
|
-
function cacheOf(r: UnitRow): { population: string; ttl: string; reads: string; why: string } {
|
|
30
|
-
const c = classify(r.decl)
|
|
31
|
-
const inputs = r.decl.inputs ?? []
|
|
32
|
-
if (c.error != null) return { population: 'BROKEN', ttl: '', reads: '', why: c.error }
|
|
33
|
-
if (c.population === 'content') return { population: 'content', ttl: '', reads: inputs.join(', '), why: '' }
|
|
34
|
-
// no `reads` for these two: the cache column already says "world"/"never",
|
|
35
|
-
// and restating it on every such row is a column of noise
|
|
36
|
-
if (c.population === 'world') return { population: 'world', ttl: r.decl.ttl ?? '', reads: '', why: '' }
|
|
37
|
-
if (c.decided === true) return { population: 'never', ttl: '', reads: '', why: r.decl.never ?? '' }
|
|
38
|
-
return { population: 'UNDECLARED', ttl: '', reads: '', why: 'nobody has declared what this reads, so it re-runs every single time' }
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export function renderUnitTable(input: UnitTableInput): string {
|
|
42
|
-
const { name, rows } = input
|
|
43
|
-
const counts = { task: 0, rule: 0, check: 0 }
|
|
44
|
-
for (const r of rows) counts[r.decl.kind]++
|
|
45
|
-
|
|
46
|
-
const body: string[] = []
|
|
47
|
-
body.push(`<!-- generated by \`shipkit units\`, refreshed by every gate run, gitignored (SK004) -->`)
|
|
48
|
-
body.push(`# ${name} units — ${rows.length} total`)
|
|
49
|
-
body.push('')
|
|
50
|
-
body.push(`Everything \`shipkit ci\` and \`shipkit check\` know how to run: ${counts.task} task(s), ${counts.rule} rule(s), ${counts.check} discovered check(s).`)
|
|
51
|
-
body.push('')
|
|
52
|
-
body.push('- **runs in** — `gate` means `shipkit ci` runs it, so a red one says THIS change is broken. `proactive` means only `shipkit check` runs it: it reports the world (a box that is off, a cred nobody minted, a page nobody refreshed), it can sit red for months with nothing wrong, and it never stands between you and a commit.')
|
|
53
|
-
body.push('- **on failure** — `blocks` stops the run. `warns` prints and carries on. `UNDECLARED` is a defect, not a setting: the unit never said and the repo set no default, so the runner treats it as fatal by guess.')
|
|
54
|
-
body.push('- **cache** — `content` replays while the files it reads are untouched. `world` replays for its TTL, and a measured RED replays too, capped at one hour so the run after a fix sees the fix. `never` re-runs every time and somebody wrote down why. `UNDECLARED` re-runs every time and nobody has looked yet, which is the burn-down (`shipkit keys --burn-down`). `SHIPKIT_FRESH=1` busts all of it.')
|
|
55
|
-
const proactive = rows.filter((r) => !r.gate && !r.optIn).length
|
|
56
|
-
const warns = rows.filter((r) => r.blocking === false).length
|
|
57
|
-
const neverSaid = rows.filter((r) => r.blocking == null).length
|
|
58
|
-
const undeclared = rows.filter((r) => cacheOf(r).population === 'UNDECLARED')
|
|
59
|
-
body.push('')
|
|
60
|
-
// the most useful line in the file, so it sits ABOVE the 68 rows rather
|
|
61
|
-
// than under them
|
|
62
|
-
body.push(
|
|
63
|
-
`**${rows.length - proactive} in the gate, ${proactive} proactive · ${warns} warn instead of blocking${neverSaid > 0 ? `, ${neverSaid} never said` : ''} · ${undeclared.length} uncacheable with nobody having looked.**`,
|
|
64
|
-
)
|
|
65
|
-
body.push('')
|
|
66
|
-
body.push('| unit | kind | runs in | on failure | cache | ttl | reads | owner |')
|
|
67
|
-
body.push('|---|---|---|---|---|---|---|---|')
|
|
68
|
-
for (const r of rows) {
|
|
69
|
-
const c = cacheOf(r)
|
|
70
|
-
const blocking = r.blocking == null ? '**UNDECLARED**' : r.blocking ? 'blocks' : 'warns'
|
|
71
|
-
const runsIn = r.optIn ? 'opt-in' : r.gate ? 'gate' : 'proactive'
|
|
72
|
-
const population = c.population === 'UNDECLARED' ? '**UNDECLARED**' : c.population
|
|
73
|
-
body.push(
|
|
74
|
-
`| \`${cell(r.decl.id)}\` | ${r.decl.kind} | ${runsIn} | ${blocking} | ${population} | ${cell(c.ttl)} | ${cell(c.reads)} | ${r.owner == null ? '' : `\`${cell(r.owner)}\``} |`,
|
|
75
|
-
)
|
|
76
|
-
}
|
|
77
|
-
body.push('')
|
|
78
|
-
|
|
79
|
-
const decided = rows.filter((r) => cacheOf(r).population === 'never')
|
|
80
|
-
if (undeclared.length > 0) {
|
|
81
|
-
body.push(`## ${undeclared.length} unit(s) nothing can cache yet`)
|
|
82
|
-
body.push('')
|
|
83
|
-
body.push('Each of these runs on every single pass. Declaring `inputs` (it reads files), `ttl` (it reads the world) or `never` (nothing can describe it) is the fix, and the last one counts: a written reason is a decision, not a hole.')
|
|
84
|
-
body.push('')
|
|
85
|
-
for (const r of undeclared) body.push(`- \`${cell(r.decl.id)}\` (${r.decl.kind})${r.owner == null ? '' : ` — \`${cell(r.owner)}\``}`)
|
|
86
|
-
body.push('')
|
|
87
|
-
}
|
|
88
|
-
if (decided.length > 0) {
|
|
89
|
-
body.push(`## ${decided.length} unit(s) decided uncacheable`)
|
|
90
|
-
body.push('')
|
|
91
|
-
for (const r of decided) body.push(`- \`${cell(r.decl.id)}\` — ${cell(r.decl.never ?? '')}`)
|
|
92
|
-
body.push('')
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
return body.join('\n')
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/** refresh the sidecar beside STATUS.md. Lock-guarded exactly like STATUS.md:
|
|
99
|
-
* several runners in one checkout is the normal case, and the loser skipping
|
|
100
|
-
* a render it would have written identically costs nothing. */
|
|
101
|
-
export function writeUnitTable(root: string, input: UnitTableInput): { written: boolean; warn?: string } {
|
|
102
|
-
const target = join(root, UNIT_TABLE_FILE)
|
|
103
|
-
const r = withFileLock(target, () => { atomicWrite(target, `${renderUnitTable(input).trimEnd()}\n`) })
|
|
104
|
-
return r.ok ? { written: true } : { written: false, warn: `${UNIT_TABLE_FILE} locked by another runner — skipped this write` }
|
|
105
|
-
}
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
// SK002 — .rv-* contract fulfillness (meta/contracts.md). The rule is the
|
|
2
|
-
// registration point; the evaluation machinery stays in
|
|
3
|
-
// contracts/contracts-engine.ts (venue manifests are DATA rows, not rules —
|
|
4
|
-
// one SK id covers the invariant "declared contracts are fulfilled, folders
|
|
5
|
-
// can't rot undeclared"). Every surface reporting fulfillness (`check`,
|
|
6
|
-
// `shipkit contracts`, the ci `contracts` task) tags with this id via
|
|
7
|
-
// contracts/apply-contracts.ts.
|
|
8
|
-
import { activeContracts, applyContractsResult, contractsConfigFrom } from '../contracts/apply-contracts.ts'
|
|
9
|
-
import { evaluateContractsOnce } from '../contracts/contracts-once.ts'
|
|
10
|
-
import type { Rule } from './rule.ts'
|
|
11
|
-
|
|
12
|
-
export const contractsFulfillness: Rule = {
|
|
13
|
-
id: 'SK002',
|
|
14
|
-
blocking: true,
|
|
15
|
-
title: 'contracts',
|
|
16
|
-
owner: 'meta/contracts.md',
|
|
17
|
-
// the contract engine is pure filesystem (no fetch, no spawn anywhere in
|
|
18
|
-
// contracts/), and WHICH folders it reads depends on the venues declared,
|
|
19
|
-
// so the honest input is the work tree
|
|
20
|
-
inputs: ['.'],
|
|
21
|
-
lint: async (t, env) => {
|
|
22
|
-
applyContractsResult(t, await evaluateContractsOnce(activeContracts(env.config), contractsConfigFrom(env.config, env.root)), contractsFulfillness)
|
|
23
|
-
},
|
|
24
|
-
}
|
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
// SK003 — the CLAUDE.md two-line contract (meta/agent-folder.md): CLAUDE.md
|
|
2
|
-
// is an include index, never a doc; `@./agent/index.md` comes LAST (include
|
|
3
|
-
// order is SEMANTIC — base context first, repo docs after so they
|
|
4
|
-
// override); a
|
|
5
|
-
// public repo's `@./CLAUDE.local.md` line-1 indirection must be gitignored.
|
|
6
|
-
// The lib stays generic: no personal include path is asserted — `shipkit
|
|
7
|
-
// start --include` flags own which base include a machine wires in. A repo
|
|
8
|
-
// with neither CLAUDE.md nor agent/ is not a two-line-contract repo and
|
|
9
|
-
// passes.
|
|
10
|
-
import { existsSync, readFileSync } from 'fs'
|
|
11
|
-
import { join } from 'path'
|
|
12
|
-
import { spawnWithTimeout } from '../core/spawn-timeout.ts'
|
|
13
|
-
import type { FixResult, Rule, RuleEnv } from './rule.ts'
|
|
14
|
-
import { atomicWrite } from '../core/locked-write.ts'
|
|
15
|
-
|
|
16
|
-
const AGENT_LINE = '@./agent/index.md'
|
|
17
|
-
const LOCAL_LINE = '@./CLAUDE.local.md'
|
|
18
|
-
|
|
19
|
-
export const claudeMdShape: Rule = {
|
|
20
|
-
id: 'SK003',
|
|
21
|
-
blocking: true,
|
|
22
|
-
title: 'CLAUDE.md',
|
|
23
|
-
owner: 'meta/agent-folder.md',
|
|
24
|
-
// CLAUDE.md plus whether agent/index.md exists — and a repo that has
|
|
25
|
-
// neither is the case this rule exists for, so the tree rather than two
|
|
26
|
-
// paths that may not be there
|
|
27
|
-
inputs: ['.'],
|
|
28
|
-
lint: async (t, env) => {
|
|
29
|
-
const claudePath = join(env.root, 'CLAUDE.md')
|
|
30
|
-
const hasAgent = existsSync(join(env.root, 'agent/index.md'))
|
|
31
|
-
if (!existsSync(claudePath)) {
|
|
32
|
-
if (hasAgent) t.fail(`agent/index.md exists but there is no CLAUDE.md routing it — the two-line contract wants ${AGENT_LINE}`)
|
|
33
|
-
else t.ok('no CLAUDE.md / agent — not a two-line-contract repo')
|
|
34
|
-
return
|
|
35
|
-
}
|
|
36
|
-
const lines = readFileSync(claudePath, 'utf8').split('\n').filter((l) => l.trim() !== '')
|
|
37
|
-
const includes = lines.filter((l) => l.startsWith('@'))
|
|
38
|
-
const prose = lines.length - includes.length
|
|
39
|
-
if (prose > 0)
|
|
40
|
-
t.fail(`CLAUDE.md carries ${prose} non-include line(s) — it is a tiny include index, content belongs in agent/index.md`)
|
|
41
|
-
if (includes.length > 2)
|
|
42
|
-
t.fail(`CLAUDE.md has ${includes.length} include lines — the contract is 2: base context, then ${AGENT_LINE}`)
|
|
43
|
-
if (hasAgent && !lines.includes(AGENT_LINE))
|
|
44
|
-
t.fail(`agent/index.md exists but CLAUDE.md does not import it — add ${AGENT_LINE} as the LAST line`)
|
|
45
|
-
if (lines.includes(AGENT_LINE) && lines.at(-1) !== AGENT_LINE)
|
|
46
|
-
t.fail(`include order inverted — base context first, ${AGENT_LINE} LAST (order is semantic: repo docs override the base)`)
|
|
47
|
-
if (lines.includes(LOCAL_LINE)) {
|
|
48
|
-
const r = await spawnWithTimeout(['git', 'check-ignore', '-q', 'CLAUDE.local.md'], { cwd: env.root, timeoutMs: 5_000 })
|
|
49
|
-
if (r.code === 1)
|
|
50
|
-
t.fail(`${LOCAL_LINE} is imported but CLAUDE.local.md is not gitignored — the machine-local include must never be tracked`)
|
|
51
|
-
else if (r.code !== 0)
|
|
52
|
-
t.note(`CLAUDE.local.md gitignore not verifiable (git says: ${r.out.trim().split('\n')[0] ?? `exit ${r.code}`})`)
|
|
53
|
-
}
|
|
54
|
-
if (!t.failed) t.ok(`CLAUDE.md include-index ok (${lines.length} line(s))`)
|
|
55
|
-
},
|
|
56
|
-
// the one mechanical repair: the missing agent/index.md route, appended
|
|
57
|
-
// to an EXISTING CLAUDE.md. Never creates the file — a created one-line
|
|
58
|
-
// CLAUDE.md would lint green with the base include forever missing,
|
|
59
|
-
// trading a loud FAIL for a permanent silent gap. Creation and prose
|
|
60
|
-
// relocation stay human/machine-local (`shipkit start --include`).
|
|
61
|
-
fix: (env: RuleEnv): FixResult => {
|
|
62
|
-
const claudePath = join(env.root, 'CLAUDE.md')
|
|
63
|
-
if (!existsSync(join(env.root, 'agent/index.md'))) return { changed: false, summary: 'no agent/index.md — nothing mechanical to fix' }
|
|
64
|
-
if (!existsSync(claudePath)) return { changed: false, summary: 'CLAUDE.md missing — creation needs the base include (shipkit start --include), not autofixable' }
|
|
65
|
-
const cur = readFileSync(claudePath, 'utf8')
|
|
66
|
-
if (cur.split('\n').includes(AGENT_LINE)) return { changed: false, summary: `${AGENT_LINE} already routed — remaining findings need a human` }
|
|
67
|
-
atomicWrite(claudePath, `${cur.endsWith('\n') || cur === '' ? cur : `${cur}\n`}${AGENT_LINE}\n`)
|
|
68
|
-
return { changed: true, summary: `${AGENT_LINE} appended as the last line` }
|
|
69
|
-
},
|
|
70
|
-
}
|
|
@@ -1,238 +0,0 @@
|
|
|
1
|
-
// SK004 — the .gitignore policy block (meta/contracts.md git policy; tmp/
|
|
2
|
-
// included because shipkit writes its per-run logs there in every repo). A marker-keyed managed block so the always-gitignored contract
|
|
3
|
-
// surfaces can never silently rot per repo. Ecosystem-specific names the
|
|
4
|
-
// published lib may not carry ride as repo-contributed lines:
|
|
5
|
-
// `defineRepo({ gitignore: [...] })` renders them INSIDE the block, so the
|
|
6
|
-
// whole policy is machine-enforced. A well-formed block is rewritten IN
|
|
7
|
-
// PLACE — lines below it (e.g. a live venue's `!.rv-itch/` override, where
|
|
8
|
-
// last-match-wins) keep their position; everything outside the block is
|
|
9
|
-
// never touched. `shipkit init` writes the same block through this fixer.
|
|
10
|
-
import { execFileSync } from 'child_process'
|
|
11
|
-
import { existsSync, readFileSync } from 'fs'
|
|
12
|
-
import { join } from 'path'
|
|
13
|
-
import type { RepoConfig } from '../repo-config.ts'
|
|
14
|
-
import { GENERATED_IGNORE_LINES, trackedGeneratedPaths, untrackGenerated } from './untrack-generated.ts'
|
|
15
|
-
import type { FixResult, Rule, RuleEnv } from './rule.ts'
|
|
16
|
-
import { atomicWrite } from '../core/locked-write.ts'
|
|
17
|
-
|
|
18
|
-
const BEGIN = '# >>> shipkit gitignore policy (SK004) — managed block, edit outside only'
|
|
19
|
-
const END = '# <<< shipkit gitignore policy'
|
|
20
|
-
const CONTRIB = '# repo-contributed lines (repo.config.ts gitignore:)'
|
|
21
|
-
|
|
22
|
-
export const POLICY_LINES = [
|
|
23
|
-
// the leak guard reads .rv-private/banned-keywords.txt: the file listing
|
|
24
|
-
// what must never be committed cannot itself be committable
|
|
25
|
-
'.rv-private/',
|
|
26
|
-
'.rv-reflections/',
|
|
27
|
-
'.rv-social/',
|
|
28
|
-
'.rv-journal/',
|
|
29
|
-
'.rv-domain/',
|
|
30
|
-
'.rv-assets/out/',
|
|
31
|
-
'# venue folders stay ignored until the listing is live — override below the block',
|
|
32
|
-
'.rv-itch/',
|
|
33
|
-
'.rv-steam/',
|
|
34
|
-
'.rv-discord/',
|
|
35
|
-
'CLAUDE.local.md',
|
|
36
|
-
'tmp/',
|
|
37
|
-
// atomicWrite lands every file via `.<name>.tmp-<pid>` next to its target,
|
|
38
|
-
// so a run killed mid write leaves one behind ANYWHERE in the tree, and an
|
|
39
|
-
// agent finds untracked debris it did not create. The ignore row ships in
|
|
40
|
-
// the same policy that started writing them.
|
|
41
|
-
'.*.tmp-*',
|
|
42
|
-
// comfy-ts state (schema dumps, drafts) and per-workflow lora tags name a
|
|
43
|
-
// private model collection; shipkit itself writes .comfy-ts/ at hub boot,
|
|
44
|
-
// so every consumer repo needs both ignored before its first commit
|
|
45
|
-
'.comfy-ts/',
|
|
46
|
-
'*.loras.local.txt',
|
|
47
|
-
// shipkit's own per-run output. Ignoring is only half of it: the fixer
|
|
48
|
-
// also removes these from the index, because .gitignore does nothing to a
|
|
49
|
-
// file already tracked (untrack-generated.ts owns the pair)
|
|
50
|
-
...GENERATED_IGNORE_LINES,
|
|
51
|
-
]
|
|
52
|
-
|
|
53
|
-
export function expectedBlock(config: RepoConfig): string {
|
|
54
|
-
const extra = config.gitignore ?? []
|
|
55
|
-
return [BEGIN, ...POLICY_LINES, ...(extra.length > 0 ? [CONTRIB, ...extra] : []), END].join('\n')
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
export function policyPatterns(config: RepoConfig): string[] {
|
|
59
|
-
return [...POLICY_LINES.filter((l) => !l.startsWith('#')), ...(config.gitignore ?? [])]
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
// the three venue folders are DESIGNED to be unignored below the block once a
|
|
63
|
-
// listing is live (last-match-wins, the comment inside POLICY_LINES says so),
|
|
64
|
-
// so an override on one of them is the mechanism working. Every other policy
|
|
65
|
-
// path is a claim the block makes, and a claim git contradicts is a bug.
|
|
66
|
-
function endsWithNewline(root: string): boolean {
|
|
67
|
-
const p = join(root, '.gitignore')
|
|
68
|
-
if (!existsSync(p)) return true
|
|
69
|
-
const text = readFileSync(p, 'utf8')
|
|
70
|
-
return text === '' || text.endsWith('\n')
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
const OVERRIDABLE = new Set(['.rv-itch/', '.rv-steam/', '.rv-discord/'])
|
|
74
|
-
|
|
75
|
-
/** a path git can be asked about, for a pattern that may be a glob or a dir */
|
|
76
|
-
function probePath(pattern: string): string {
|
|
77
|
-
if (pattern.endsWith('/')) return `${pattern}probe`
|
|
78
|
-
// `.*.tmp-*` and `*.loras.local.txt`: a literal standing in for the glob
|
|
79
|
-
return pattern.includes('*') ? pattern.replace(/\*/g, 'probe') : pattern
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/** which of `paths` git does NOT ignore. null when this is not a git repo, so
|
|
83
|
-
* a rule can say it did not measure rather than report a green it invented */
|
|
84
|
-
export function notIgnored(root: string, paths: string[]): string[] | null {
|
|
85
|
-
if (paths.length === 0) return []
|
|
86
|
-
try {
|
|
87
|
-
// --stdin -z -n: one call, every path, and `-n` makes git print a row
|
|
88
|
-
// for the MISSES too, which is the answer we want
|
|
89
|
-
const out = execFileSync('git', ['check-ignore', '--stdin', '-z', '-n', '-v'], {
|
|
90
|
-
cwd: root, input: paths.join('\0'), encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
|
|
91
|
-
})
|
|
92
|
-
// -v -z rows are source\0line\0pattern\0path; an unignored path has an
|
|
93
|
-
// empty source and an empty pattern
|
|
94
|
-
const f = out.split('\0')
|
|
95
|
-
const miss: string[] = []
|
|
96
|
-
for (let i = 0; i + 3 < f.length + 1; i += 4) {
|
|
97
|
-
if (f[i] === '' && f[i + 2] === '' && f[i + 3] != null && f[i + 3] !== '') miss.push(f[i + 3]!)
|
|
98
|
-
}
|
|
99
|
-
return miss
|
|
100
|
-
} catch (e) {
|
|
101
|
-
// exit 1 means "none of them are ignored", which is an ANSWER, and git
|
|
102
|
-
// still wrote the rows we parse. Anything else (no repo, no git) is not
|
|
103
|
-
const err = e as { status?: number; stdout?: string }
|
|
104
|
-
if (err.status === 1 && typeof err.stdout === 'string') {
|
|
105
|
-
const f = err.stdout.split('\0')
|
|
106
|
-
const miss: string[] = []
|
|
107
|
-
for (let i = 0; i + 3 < f.length + 1; i += 4) {
|
|
108
|
-
if (f[i] === '' && f[i + 2] === '' && f[i + 3] != null && f[i + 3] !== '') miss.push(f[i + 3]!)
|
|
109
|
-
}
|
|
110
|
-
return miss
|
|
111
|
-
}
|
|
112
|
-
return null
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
type Parsed = { text: string; crlf: boolean; lines: string[]; b: number; e: number }
|
|
117
|
-
|
|
118
|
-
function parse(root: string): Parsed | null {
|
|
119
|
-
const p = join(root, '.gitignore')
|
|
120
|
-
if (!existsSync(p)) return null
|
|
121
|
-
const text = readFileSync(p, 'utf8')
|
|
122
|
-
const crlf = text.includes('\r\n')
|
|
123
|
-
const lines = text.split('\n').map((l) => l.replace(/\r$/, ''))
|
|
124
|
-
return { text, crlf, lines, b: lines.indexOf(BEGIN), e: lines.indexOf(END) }
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
type BlockState = 'missing-file' | 'missing-block' | 'corrupted' | 'stale' | 'current'
|
|
128
|
-
|
|
129
|
-
function blockState(root: string, config: RepoConfig): BlockState {
|
|
130
|
-
const f = parse(root)
|
|
131
|
-
if (f == null) return 'missing-file'
|
|
132
|
-
if (f.b === -1 && f.e === -1) return 'missing-block'
|
|
133
|
-
if (f.b === -1 || f.e === -1 || f.e < f.b) return 'corrupted'
|
|
134
|
-
return f.lines.slice(f.b, f.e + 1).join('\n') === expectedBlock(config) ? 'current' : 'stale'
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
function writeBlock(root: string, config: RuleEnv['config']): void {
|
|
138
|
-
const p = join(root, '.gitignore')
|
|
139
|
-
const f = parse(root)
|
|
140
|
-
const block = expectedBlock(config).split('\n')
|
|
141
|
-
let out: string[]
|
|
142
|
-
if (f == null) out = block
|
|
143
|
-
else if (f.b !== -1 && f.e !== -1 && f.e >= f.b)
|
|
144
|
-
// well-formed block: rewrite IN PLACE so below-block overrides
|
|
145
|
-
// (last-match-wins) keep working
|
|
146
|
-
out = [...f.lines.slice(0, f.b), ...block, ...f.lines.slice(f.e + 1)]
|
|
147
|
-
else {
|
|
148
|
-
// absent or corrupted: drop stray markers, keep every repo line
|
|
149
|
-
// visible, append the canonical block
|
|
150
|
-
const kept = (f.b === -1 && f.e === -1 ? f.lines : f.lines.filter((l) => l !== BEGIN && l !== END))
|
|
151
|
-
while (kept.length > 0 && kept.at(-1)!.trim() === '') kept.pop()
|
|
152
|
-
out = kept.length > 0 ? [...kept, '', ...block] : block
|
|
153
|
-
}
|
|
154
|
-
const eol = f?.crlf === true ? '\r\n' : '\n'
|
|
155
|
-
atomicWrite(p, `${out.join(eol)}${out.at(-1) === '' ? '' : eol}`)
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
export const gitignorePolicy: Rule = {
|
|
159
|
-
id: 'SK004',
|
|
160
|
-
blocking: true,
|
|
161
|
-
title: '.gitignore',
|
|
162
|
-
owner: 'meta/contracts.md',
|
|
163
|
-
// .gitignore plus what git currently TRACKS, which the tree carries and a
|
|
164
|
-
// single path does not
|
|
165
|
-
inputs: ['.'],
|
|
166
|
-
lint: (t, env) => {
|
|
167
|
-
// a generated path still in the index is the half-applied state the
|
|
168
|
-
// managed block cannot fix on its own
|
|
169
|
-
const tracked = trackedGeneratedPaths(env.root)
|
|
170
|
-
if (tracked.length > 0) {
|
|
171
|
-
t.fail(`${tracked.join(', ')} generated on every run but TRACKED — gitignore does not apply to a tracked file, so it dirties every tree`)
|
|
172
|
-
t.lines.push({ kind: 'fix-fail', text: 'shipkit ci (or check --fix SK004) untracks it in one commit, the file stays on disk' })
|
|
173
|
-
}
|
|
174
|
-
const s = blockState(env.root, env.config)
|
|
175
|
-
switch (s) {
|
|
176
|
-
case 'missing-file': return t.fail('.gitignore missing — the contract git policy is unenforced')
|
|
177
|
-
case 'missing-block': return t.fail('.gitignore has no shipkit policy block — contract surfaces can leak into git')
|
|
178
|
-
case 'corrupted': return t.fail('.gitignore policy block corrupted (unpaired marker)')
|
|
179
|
-
case 'stale': return t.fail('.gitignore policy block is stale — the policy or repo-contributed lines moved since it was written')
|
|
180
|
-
}
|
|
181
|
-
// an unterminated last line is a TRAP, not a cosmetic defect: the next
|
|
182
|
-
// `>> .gitignore` fuses onto it and the result is one pattern that
|
|
183
|
-
// ignores neither path, with the file ending in a newline afterwards
|
|
184
|
-
// so nothing textual looks wrong
|
|
185
|
-
if (!endsWithNewline(env.root)) {
|
|
186
|
-
t.fail('.gitignore does not end with a newline — the next append fuses onto its last line and ignores neither path')
|
|
187
|
-
t.lines.push({ kind: 'fix-fail', text: 'shipkit ci (or check --fix SK004) terminates it' })
|
|
188
|
-
return
|
|
189
|
-
}
|
|
190
|
-
// the block states what must be ignored. git is the only thing that
|
|
191
|
-
// knows whether it IS: a stray negation below the block, or a line
|
|
192
|
-
// fused into another, leaves the text current over a broken repo
|
|
193
|
-
const claimed = policyPatterns(env.config).filter((p) => !OVERRIDABLE.has(p))
|
|
194
|
-
const probes = new Map(claimed.map((p) => [probePath(p), p]))
|
|
195
|
-
const miss = notIgnored(env.root, [...probes.keys()])
|
|
196
|
-
if (miss == null) {
|
|
197
|
-
// not a git repo (a fixture, or a dir before `git init`). Say the
|
|
198
|
-
// verification did not run rather than reporting a green nothing
|
|
199
|
-
// measured, and stay quiet: there is nothing wrong here to fix
|
|
200
|
-
t.ok(`.gitignore policy block current (${claimed.length} patterns)`)
|
|
201
|
-
t.lines.push({ kind: 'skip', text: 'not a git repo here, so nothing verified the block against what git actually ignores' })
|
|
202
|
-
return
|
|
203
|
-
}
|
|
204
|
-
if (miss.length > 0) {
|
|
205
|
-
const names = [...new Set(miss.map((m) => probes.get(m) ?? m))]
|
|
206
|
-
t.fail(`.gitignore block claims ${names.join(', ')} ignored, git says otherwise — a negation below the block, or a line fused into its neighbour`)
|
|
207
|
-
t.lines.push({ kind: 'fix-fail', text: `git check-ignore -v ${miss[0]} names the line that wins` })
|
|
208
|
-
return
|
|
209
|
-
}
|
|
210
|
-
t.ok(`.gitignore policy block current, ${claimed.length} patterns verified against git`)
|
|
211
|
-
},
|
|
212
|
-
fix: (env: RuleEnv): FixResult => {
|
|
213
|
-
const before = blockState(env.root, env.config)
|
|
214
|
-
// terminate an unterminated file even when the block itself is current:
|
|
215
|
-
// that missing byte is what the next append fuses onto
|
|
216
|
-
let terminated: string | null = null
|
|
217
|
-
if (before === 'current' && !endsWithNewline(env.root)) {
|
|
218
|
-
const p = join(env.root, '.gitignore')
|
|
219
|
-
atomicWrite(p, `${readFileSync(p, 'utf8')}\n`)
|
|
220
|
-
terminated = '.gitignore terminated with a newline (the next append would have fused onto its last line)'
|
|
221
|
-
}
|
|
222
|
-
const blockSummary = before === 'current'
|
|
223
|
-
? null
|
|
224
|
-
: before === 'missing-file' ? '.gitignore created with the policy block'
|
|
225
|
-
: before === 'missing-block' ? 'policy block appended (repo lines untouched)'
|
|
226
|
-
: before === 'corrupted' ? 'policy block rebuilt (stray markers dropped, repo lines kept visible)'
|
|
227
|
-
: 'policy block rewritten in place (repo lines untouched)'
|
|
228
|
-
if (blockSummary != null) writeBlock(env.root, env.config)
|
|
229
|
-
// the block must be written FIRST: untracking a path the ignore list
|
|
230
|
-
// does not yet carry leaves it staged for re-add on the next `git add`
|
|
231
|
-
const un = untrackGenerated(env.root, { enabled: env.config.untrackGenerated })
|
|
232
|
-
const parts = [blockSummary, terminated]
|
|
233
|
-
if (un.did === 'untracked') parts.push(`untracked ${un.paths.join(', ')} (generated, now gitignored, committed as ${un.commit})`)
|
|
234
|
-
if (un.did === 'refused') parts.push(`left ${trackedGeneratedPaths(env.root).join(', ')} tracked: ${un.why}`)
|
|
235
|
-
const summary = parts.filter((x): x is string => x != null).join(' · ')
|
|
236
|
-
return { changed: blockSummary != null || terminated != null || un.did === 'untracked', summary: summary === '' ? 'policy block already current' : summary }
|
|
237
|
-
},
|
|
238
|
-
}
|
|
@@ -1,127 +0,0 @@
|
|
|
1
|
-
// SK010 — undeclared release unit (build/shipkit.md). Release units are
|
|
2
|
-
// DECLARED, never inferred, so resolution is deterministic. This rule is the
|
|
3
|
-
// other half of that deal: a package.json sitting in the repo that no unit
|
|
4
|
-
// claims would be invisible to every release surface, and its version would
|
|
5
|
-
// drift forever with nothing reporting it. Two declarations clear it, so the
|
|
6
|
-
// green is always reachable: `units: [{ dir }]` for a releasable package,
|
|
7
|
-
// `notReleasable: [dir]` for one that ships inside something else. That second
|
|
8
|
-
// escape has teeth on purpose, because it is the one an agent staring at a red
|
|
9
|
-
// gate reaches for: an entry must cover at least one real package and never the
|
|
10
|
-
// whole repo, and the green counts the PACKAGES it excused, so a wide entry
|
|
11
|
-
// cannot read as a narrow one.
|
|
12
|
-
import { existsSync, readFileSync, readdirSync, statSync } from 'fs'
|
|
13
|
-
import { join, relative } from 'path'
|
|
14
|
-
import { resolveUnits } from '../release/units.ts'
|
|
15
|
-
import type { Rule } from './rule.ts'
|
|
16
|
-
|
|
17
|
-
const SKIP = new Set(['node_modules', 'references', 'tmp', 'stats', 'dist', 'build'])
|
|
18
|
-
|
|
19
|
-
/** package dirs git knows about (tracked + untracked-not-ignored), so a
|
|
20
|
-
* gitignored vendor tree or a python venv never trips the gate. Null when
|
|
21
|
-
* git cannot answer, and the caller falls back to walking. */
|
|
22
|
-
function gitPackageDirs(root: string): string[] | null {
|
|
23
|
-
const p = Bun.spawnSync(['git', 'ls-files', '--cached', '--others', '--exclude-standard', '-z'], { cwd: root })
|
|
24
|
-
if (p.exitCode !== 0) return null
|
|
25
|
-
return p.stdout.toString().split('\0')
|
|
26
|
-
.filter((f) => f === 'package.json' || f.endsWith('/package.json'))
|
|
27
|
-
.map((f) => join(root, f, '..'))
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/** the fs fallback: an unreadable entry (a dangling symlink, an unmounted
|
|
31
|
-
* volume) is skipped, never thrown — a broken link is not a release fact */
|
|
32
|
-
function walkPackageDirs(root: string): string[] {
|
|
33
|
-
const out: string[] = [root]
|
|
34
|
-
const walk = (dir: string): void => {
|
|
35
|
-
let entries: string[]
|
|
36
|
-
try { entries = readdirSync(dir) } catch { return }
|
|
37
|
-
for (const e of entries) {
|
|
38
|
-
if (SKIP.has(e) || e.startsWith('.')) continue
|
|
39
|
-
const p = join(dir, e)
|
|
40
|
-
try { if (!statSync(p).isDirectory()) continue } catch { continue }
|
|
41
|
-
if (existsSync(join(p, 'package.json'))) out.push(p)
|
|
42
|
-
walk(p)
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
walk(root)
|
|
46
|
-
return out
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/** every package.json below the root that carries a name, root excluded */
|
|
50
|
-
export function packageDirs(root: string): string[] {
|
|
51
|
-
const dirs = gitPackageDirs(root) ?? walkPackageDirs(root)
|
|
52
|
-
const named = dirs.filter((d) => {
|
|
53
|
-
if (d === root) return false
|
|
54
|
-
if (relative(root, d).split('/').some((seg) => SKIP.has(seg) || seg.startsWith('.'))) return false
|
|
55
|
-
try {
|
|
56
|
-
const pkg = JSON.parse(readFileSync(join(d, 'package.json'), 'utf8')) as { name?: string }
|
|
57
|
-
return typeof pkg.name === 'string' && pkg.name !== ''
|
|
58
|
-
} catch { return false } // a broken package.json is SK001's story, not this rule's
|
|
59
|
-
})
|
|
60
|
-
return [...new Set(named)].sort()
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
export const undeclaredReleaseUnit: Rule = {
|
|
64
|
-
id: 'SK010',
|
|
65
|
-
blocking: true,
|
|
66
|
-
title: 'release units declared',
|
|
67
|
-
owner: 'build/shipkit.md',
|
|
68
|
-
// `git ls-files` plus a walk for every package.json in the repo
|
|
69
|
-
inputs: ['.'],
|
|
70
|
-
lint: (t, env) => {
|
|
71
|
-
const { units, errors } = resolveUnits(env.root, env.config)
|
|
72
|
-
// a DECLARED unit that does not resolve is a declaration bug and
|
|
73
|
-
// belongs here. A root package that is not release-shaped (no version
|
|
74
|
-
// yet) is not: the release path refuses on its own, the day it matters.
|
|
75
|
-
const declared = errors.filter((e) => e.id !== 'root')
|
|
76
|
-
if (declared.length > 0) {
|
|
77
|
-
for (const e of declared) t.fail(`declared unit '${e.id}' does not resolve: ${e.message}`, 'repo.config units[]')
|
|
78
|
-
return
|
|
79
|
-
}
|
|
80
|
-
// an entry is a PREFIX over the packages under it, so `examples/` can
|
|
81
|
-
// excuse examples/a and examples/b without enumerating them. Two shapes
|
|
82
|
-
// are refused, both being the kill switch this rule exists to prevent:
|
|
83
|
-
// an entry covering the whole repo, and one covering nothing today that
|
|
84
|
-
// stands ready to excuse whatever lands there tomorrow. defineRepo
|
|
85
|
-
// refuses the root too, and a repo.config.ts that never calls it is
|
|
86
|
-
// exactly why the check also lives in the blocking rule.
|
|
87
|
-
const decls = env.config.notReleasable ?? []
|
|
88
|
-
const covered = (list: string[], d: string): boolean => list.some((c) => d === c || d.startsWith(`${c}/`))
|
|
89
|
-
const pkgs = packageDirs(env.root)
|
|
90
|
-
const wide = decls.filter((d) => join(env.root, d) === env.root)
|
|
91
|
-
if (wide.length > 0) {
|
|
92
|
-
for (const d of wide)
|
|
93
|
-
t.fail(`notReleasable '${d}' resolves to the repo root, so it excuses every package at once`, `repo.config notReleasable: name one package dir (or one container dir) per entry`)
|
|
94
|
-
return
|
|
95
|
-
}
|
|
96
|
-
const empty = decls.filter((d) => !pkgs.some((p) => covered([join(env.root, d)], p)))
|
|
97
|
-
if (empty.length > 0) {
|
|
98
|
-
for (const d of empty)
|
|
99
|
-
t.fail(`notReleasable '${d}' covers no package.json, so it excuses no package today and every package that lands there tomorrow`, `repo.config notReleasable: point it at a package dir (or a dir holding some), or drop the entry`)
|
|
100
|
-
return
|
|
101
|
-
}
|
|
102
|
-
const excused = decls.map((d) => join(env.root, d))
|
|
103
|
-
const declaredDirs = units.filter((u) => u.rel !== '').map((u) => u.dir)
|
|
104
|
-
// an excuse that also covers a RELEASE UNIT is the kill switch one
|
|
105
|
-
// level down: `notReleasable: ['libs']` beside `units: [libs/a]` reads
|
|
106
|
-
// as green while every new package under libs/ hides. The two lists
|
|
107
|
-
// describe opposite intents, so they must not overlap.
|
|
108
|
-
const overlapping = decls.filter((d) => declaredDirs.some((u) => u === join(env.root, d) || u.startsWith(`${join(env.root, d)}/`)))
|
|
109
|
-
if (overlapping.length > 0) {
|
|
110
|
-
for (const d of overlapping)
|
|
111
|
-
t.fail(`notReleasable '${d}' also covers a declared release unit, so it excuses every package under it while reading as green`, `repo.config notReleasable: name the dirs that are NOT release units, never a container holding one`)
|
|
112
|
-
return
|
|
113
|
-
}
|
|
114
|
-
const claimed = [...declaredDirs, ...excused]
|
|
115
|
-
const orphans = pkgs.filter((d) => !covered(claimed, d))
|
|
116
|
-
if (orphans.length === 0) {
|
|
117
|
-
// PACKAGES, not entries: one entry over a nested tree excuses more
|
|
118
|
-
// than one, and the count is the only place that shows it
|
|
119
|
-
const n = pkgs.filter((d) => covered(excused, d)).length
|
|
120
|
-
return t.ok(`${units.length} release unit(s) declared${units.length === 1 ? ' (the repo root)' : ''}${n > 0 ? ` + ${n} package(s) declared not releasable` : ''} — no package hides from a release`)
|
|
121
|
-
}
|
|
122
|
-
for (const d of orphans) {
|
|
123
|
-
const rel = relative(env.root, d)
|
|
124
|
-
t.fail(`${rel}/package.json is claimed by nothing — declare what it is, or its version drifts with nothing watching`, `repo.config units: [{ dir: '${rel}' }] if it releases, else notReleasable: ['${rel}']`)
|
|
125
|
-
}
|
|
126
|
-
},
|
|
127
|
-
}
|
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
// THE rule registry — single source of every SK rule. `shipkit check` runs
|
|
2
|
-
// them, `shipkit rules` lists them, fix pointers and wiki pages name their
|
|
3
|
-
// ids. A duplicate or malformed id throws at import time: every surface
|
|
4
|
-
// that loads rules dies loud rather than run with an ambiguous registry.
|
|
5
|
-
import type { Rule } from './rule.ts'
|
|
6
|
-
import { packageJsonShape } from './SK001-package-json-shape.ts'
|
|
7
|
-
import { contractsFulfillness } from './SK002-contracts-fulfillness.ts'
|
|
8
|
-
import { claudeMdShape } from './SK003-claude-md-shape.ts'
|
|
9
|
-
import { gitignorePolicy } from './SK004-gitignore-policy.ts'
|
|
10
|
-
import { rvlibVersions } from './SK005-rvlib-versions.ts'
|
|
11
|
-
import { npmPublishSafety } from './SK006-npm-publish-safety.ts'
|
|
12
|
-
import { githubVisibility } from './SK007-github-visibility.ts'
|
|
13
|
-
import { reviewerAgent } from './SK008-reviewer-agent.ts'
|
|
14
|
-
import { commitAiTells } from './SK009-commit-ai-tells.ts'
|
|
15
|
-
import { undeclaredReleaseUnit } from './SK010-undeclared-release-unit.ts'
|
|
16
|
-
import { siblingUnitRanges } from './SK011-sibling-unit-ranges.ts'
|
|
17
|
-
import { configBarrelImport } from './SK012-config-barrel-import.ts'
|
|
18
|
-
import { oneWayInPolicy } from './SK013-one-way-in-policy.ts'
|
|
19
|
-
import { proseSte } from './SK014-prose-ste.ts'
|
|
20
|
-
import { nestedRepo } from './SK015-nested-repo.ts'
|
|
21
|
-
|
|
22
|
-
export const RULES: readonly Rule[] = [
|
|
23
|
-
packageJsonShape,
|
|
24
|
-
contractsFulfillness,
|
|
25
|
-
claudeMdShape,
|
|
26
|
-
gitignorePolicy,
|
|
27
|
-
rvlibVersions,
|
|
28
|
-
npmPublishSafety,
|
|
29
|
-
githubVisibility,
|
|
30
|
-
reviewerAgent,
|
|
31
|
-
commitAiTells,
|
|
32
|
-
undeclaredReleaseUnit,
|
|
33
|
-
siblingUnitRanges,
|
|
34
|
-
configBarrelImport,
|
|
35
|
-
oneWayInPolicy,
|
|
36
|
-
proseSte,
|
|
37
|
-
nestedRepo,
|
|
38
|
-
]
|
|
39
|
-
|
|
40
|
-
// THE id shape, one place: 2-4 uppercase letters + exactly 3 digits. `SK` is
|
|
41
|
-
// reserved for the built-ins above so a repo rule can never shadow one.
|
|
42
|
-
export const RULE_ID_RE = /^[A-Z]{2,4}\d{3}$/
|
|
43
|
-
|
|
44
|
-
/** validate a rule set and its ids against everything already registered.
|
|
45
|
-
* Throws on a malformed id, a duplicate, a missing owner page, or a repo
|
|
46
|
-
* rule squatting the reserved SK prefix. Shared by the built-in registry
|
|
47
|
-
* and by `defineRepo`, so both die loud at import time. */
|
|
48
|
-
export function validateRules(rules: readonly Rule[], o: { label: string; builtin?: boolean; taken?: Iterable<string> } = { label: 'rule-registry' }): void {
|
|
49
|
-
const seen = new Set<string>(o.taken ?? [])
|
|
50
|
-
for (const r of rules) {
|
|
51
|
-
if (!RULE_ID_RE.test(r.id)) throw new Error(`${o.label}: bad id '${r.id}' — rules are PREFIX+3 digits (e.g. SK001, RV001)`)
|
|
52
|
-
if (o.builtin !== true && r.id.startsWith('SK'))
|
|
53
|
-
throw new Error(`${o.label}: id '${r.id}' uses the reserved SK prefix — repo rules pick their own (e.g. RV${r.id.slice(-3)})`)
|
|
54
|
-
if (seen.has(r.id)) throw new Error(`${o.label}: duplicate id ${r.id}`)
|
|
55
|
-
if (r.owner === '') throw new Error(`${o.label}: ${r.id} has no owner page`)
|
|
56
|
-
seen.add(r.id)
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
validateRules(RULES, { label: 'rule-registry', builtin: true })
|
|
61
|
-
|
|
62
|
-
export const BUILTIN_RULE_IDS: readonly string[] = RULES.map((r) => r.id)
|
|
63
|
-
|
|
64
|
-
export const ruleById = (id: string): Rule | undefined => RULES.find((r) => r.id === id)
|