@skitterbyte/skitterspec-jira 0.0.0-stage → 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/MIGRATION.md +1266 -0
- package/README.md +44 -2
- package/assets/claude-md-section.md +85 -0
- package/assets/commands/allow-main.md +33 -0
- package/assets/commands/spec-connect.md +13 -0
- package/assets/commands/spec-live.md +14 -0
- package/assets/commands/spec-remote-review.md +37 -0
- package/assets/commands/spec-skip.md +24 -0
- package/assets/core/SETUP.md +192 -0
- package/assets/core/env.config.json.example +56 -0
- package/assets/core/env.config.md +606 -0
- package/assets/core/gating.config.json.example +4 -0
- package/assets/core/gating.config.md +81 -0
- package/assets/core/jira.config.json.example +18 -0
- package/assets/core/jira.config.md +88 -0
- package/assets/hooks/main-guard.cjs +162 -0
- package/assets/hooks/review-gate.cjs +146 -0
- package/assets/review/page.html +3384 -0
- package/assets/rules/commit-trailers.md +96 -0
- package/assets/rules/negative-checks.md +69 -0
- package/assets/rules/offered-unblocks.md +100 -0
- package/assets/rules/spec-planning.md +702 -0
- package/assets/rules/spec-reports.md +572 -0
- package/assets/skills/no-spec/SKILL.md +249 -0
- package/assets/skills/spec/SKILL.md +714 -0
- package/assets/skills/spec-bug/SKILL.md +585 -0
- package/assets/skills/spec-cancel/SKILL.md +261 -0
- package/assets/skills/spec-complete/SKILL.md +451 -0
- package/assets/skills/spec-diff/SKILL.md +870 -0
- package/assets/skills/spec-hotfix/SKILL.md +607 -0
- package/assets/skills/spec-init/SKILL.md +178 -0
- package/assets/skills/spec-jira-setup/SKILL.md +221 -0
- package/assets/skills/spec-list/SKILL.md +173 -0
- package/assets/skills/spec-next/SKILL.md +678 -0
- package/assets/skills/spec-push/SKILL.md +306 -0
- package/assets/skills/spec-review/SKILL.md +256 -0
- package/assets/skills/spec-reviewed/SKILL.md +284 -0
- package/assets/skills/spec-start/SKILL.md +496 -0
- package/assets/skills/spec-status/SKILL.md +114 -0
- package/assets/skills/spec-sync/SKILL.md +220 -0
- package/assets/skills/spec-to-main/SKILL.md +141 -0
- package/bin/skitterspec-jira.js +68 -0
- package/package.json +40 -4
- package/src/cli.js +5967 -0
- package/src/deprecate.js +138 -0
- package/src/env/bootstrap.js +188 -0
- package/src/env/building.js +143 -0
- package/src/env/classify.js +176 -0
- package/src/env/commitcmd.js +108 -0
- package/src/env/config.js +850 -0
- package/src/env/dev.js +63 -0
- package/src/env/glob.js +34 -0
- package/src/env/hooks.js +226 -0
- package/src/env/hotfix.js +413 -0
- package/src/env/hotfixland.js +302 -0
- package/src/env/integrate.js +167 -0
- package/src/env/live.js +479 -0
- package/src/env/mainguard.js +299 -0
- package/src/env/provision.js +466 -0
- package/src/env/proxy.js +225 -0
- package/src/env/prune.js +119 -0
- package/src/env/registry.js +159 -0
- package/src/env/render.js +17 -0
- package/src/env/resolve.js +716 -0
- package/src/env/review.js +2622 -0
- package/src/env/reviewers/coderabbit.js +228 -0
- package/src/env/reviewers.js +517 -0
- package/src/env/serve.js +1044 -0
- package/src/env/supervise.js +157 -0
- package/src/env/teardown.js +248 -0
- package/src/env/trust.js +87 -0
- package/src/gating.js +155 -0
- package/src/init.js +1150 -0
- package/src/lines-diff.js +114 -0
- package/src/prompts.js +129 -0
- package/src/vendor/jira/adf.js +414 -0
- package/src/vendor/jira/api.js +295 -0
- package/src/vendor/jira/cli-sync.js +1252 -0
- package/src/vendor/jira/commands.js +44 -0
- package/src/vendor/jira/config.js +286 -0
- package/src/vendor/jira/credentials.js +108 -0
- package/src/vendor/jira/dialect.js +45 -0
- package/src/vendor/jira/mcp.js +129 -0
- package/src/vendor/provider-kit/index.js +41 -0
- package/src/vendor/provider-kit/src/adapter.js +23 -0
- package/src/vendor/provider-kit/src/apply.js +207 -0
- package/src/vendor/provider-kit/src/args.js +68 -0
- package/src/vendor/provider-kit/src/assignment.js +277 -0
- package/src/vendor/provider-kit/src/config.js +251 -0
- package/src/vendor/provider-kit/src/credentials.js +404 -0
- package/src/vendor/provider-kit/src/doctor.js +35 -0
- package/src/vendor/provider-kit/src/http.js +63 -0
- package/src/vendor/provider-kit/src/identity.js +93 -0
- package/src/vendor/provider-kit/src/list-assignee.js +71 -0
- package/src/vendor/provider-kit/src/list-rows.js +35 -0
- package/src/vendor/provider-kit/src/md-compare.js +39 -0
- package/src/vendor/provider-kit/src/md-tables.js +94 -0
- package/src/vendor/provider-kit/src/prompt.js +50 -0
- package/src/vendor/provider-kit/src/released.js +219 -0
- package/src/vendor/provider-kit/src/specs.js +248 -0
- package/src/vendor/provider-kit/src/transport.js +22 -0
- package/src/vendor/sync-core/index.js +92 -0
- package/src/vendor/sync-core/src/base.js +81 -0
- package/src/vendor/sync-core/src/compare.js +338 -0
- package/src/vendor/sync-core/src/dialect.js +51 -0
- package/src/vendor/sync-core/src/fields.js +36 -0
- package/src/vendor/sync-core/src/legacy.js +100 -0
- package/src/vendor/sync-core/src/normalize.js +971 -0
- package/src/vendor/sync-core/src/push.js +85 -0
- package/src/vendor/sync-core/src/retarget.js +278 -0
- package/src/vendor/sync-core/src/task-block.js +308 -0
- package/src/vendor/sync-core/src/verify.js +67 -0
- package/src/vendor/sync-core/src/write.js +191 -0
package/src/deprecate.js
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Cleanup path for projects that installed release tooling from an older
|
|
4
|
+
// skitterspec (before it moved to @skitterbyte/skittership). `skitterspec update`
|
|
5
|
+
// detects the leftover files and — only on an explicit interactive "yes" or the
|
|
6
|
+
// --remove-release-tooling flag — removes exactly what skitterspec used to
|
|
7
|
+
// install. It never touches the user's generated CHANGELOG.md / RELEASES.md, nor
|
|
8
|
+
// any script it didn't add.
|
|
9
|
+
|
|
10
|
+
const fs = require('fs')
|
|
11
|
+
const path = require('path')
|
|
12
|
+
|
|
13
|
+
const SKITTERSHIP = '@skitterbyte/skittership'
|
|
14
|
+
|
|
15
|
+
// Files/dirs skitterspec used to install for release tooling (repo-relative).
|
|
16
|
+
const RELEASE_PATHS = [
|
|
17
|
+
'skitterspec.config.json',
|
|
18
|
+
path.join('scripts', 'generate-changelog.js'),
|
|
19
|
+
path.join('scripts', 'generate-releases.js'),
|
|
20
|
+
path.join('scripts', 'lib', 'git-commits.js'),
|
|
21
|
+
path.join('scripts', 'lib', 'config.js'),
|
|
22
|
+
path.join('.claude', 'skills', 'commit'),
|
|
23
|
+
path.join('.claude', 'rules', 'commit-messages.md'),
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
// npm scripts skitterspec used to wire; only removed when their value still
|
|
27
|
+
// matches the generator command (so a user's custom override is preserved).
|
|
28
|
+
const HELPER_SCRIPTS = {
|
|
29
|
+
changelog: 'node scripts/generate-changelog.js',
|
|
30
|
+
'changelog:retro': 'node scripts/generate-changelog.js --retro',
|
|
31
|
+
releases: 'node scripts/generate-releases.js',
|
|
32
|
+
'releases:retro': 'node scripts/generate-releases.js --retro',
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function readPkg(dir) {
|
|
36
|
+
const pkgPath = path.join(dir, 'package.json')
|
|
37
|
+
if (!fs.existsSync(pkgPath)) return null
|
|
38
|
+
try {
|
|
39
|
+
return JSON.parse(fs.readFileSync(pkgPath, 'utf8'))
|
|
40
|
+
} catch {
|
|
41
|
+
return null
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Does package.json have a `version` script that runs the release generators?
|
|
46
|
+
function versionHookReferencesGenerators(pkg) {
|
|
47
|
+
const v = pkg && pkg.scripts && pkg.scripts.version
|
|
48
|
+
return typeof v === 'string' && /generate-(changelog|releases)\.js/.test(v)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Is skittership the source of the release tooling here (rather than a leftover
|
|
52
|
+
// legacy skitterspec install)? True when the project has adopted skittership —
|
|
53
|
+
// its config file is present, or it's a declared dependency. In that case the
|
|
54
|
+
// release files are skittership's current install and must NOT be offered for
|
|
55
|
+
// removal (they'd just come back on the next `skittership init`).
|
|
56
|
+
function skittershipAdopted(dir) {
|
|
57
|
+
if (fs.existsSync(path.join(dir, 'skittership.config.json'))) return true
|
|
58
|
+
const pkg = readPkg(dir)
|
|
59
|
+
const deps = Object.assign({}, pkg && pkg.dependencies, pkg && pkg.devDependencies)
|
|
60
|
+
return Boolean(deps['@skitterbyte/skittership'])
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Report which release-tooling artifacts are present. `present` is true only when
|
|
64
|
+
// there are legacy artifacts to clean up (a file/dir or a generator-driven version
|
|
65
|
+
// hook) AND skittership hasn't been adopted — otherwise the files belong to a
|
|
66
|
+
// live skittership install, not an old bundled-skitterspec one.
|
|
67
|
+
function detectReleaseTooling(dir) {
|
|
68
|
+
const files = RELEASE_PATHS.filter((rel) => fs.existsSync(path.join(dir, rel)))
|
|
69
|
+
const pkg = readPkg(dir)
|
|
70
|
+
const versionHook = versionHookReferencesGenerators(pkg)
|
|
71
|
+
const adopted = skittershipAdopted(dir)
|
|
72
|
+
return { present: (files.length > 0 || versionHook) && !adopted, files, versionHook, adopted }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Remove an emptied directory, walking up while parents are left empty. Never
|
|
76
|
+
// climbs out of `dir`.
|
|
77
|
+
function pruneEmptyDirs(dir, startAbs) {
|
|
78
|
+
let cur = startAbs
|
|
79
|
+
while (cur.startsWith(dir) && cur !== dir && fs.existsSync(cur)) {
|
|
80
|
+
if (fs.readdirSync(cur).length > 0) break
|
|
81
|
+
fs.rmdirSync(cur)
|
|
82
|
+
cur = path.dirname(cur)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Remove exactly the detected artifacts + unwire the version hook. Returns a
|
|
87
|
+
// report of what was removed. Scoped and non-destructive: only skitterspec's own
|
|
88
|
+
// files, and only npm scripts whose value still matches the generator command.
|
|
89
|
+
function removeReleaseTooling(dir, detection = detectReleaseTooling(dir)) {
|
|
90
|
+
const removed = []
|
|
91
|
+
|
|
92
|
+
for (const rel of detection.files) {
|
|
93
|
+
const abs = path.join(dir, rel)
|
|
94
|
+
if (!fs.existsSync(abs)) continue
|
|
95
|
+
fs.rmSync(abs, { recursive: true, force: true })
|
|
96
|
+
removed.push(rel)
|
|
97
|
+
pruneEmptyDirs(dir, path.dirname(abs))
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const pkg = readPkg(dir)
|
|
101
|
+
if (pkg && pkg.scripts) {
|
|
102
|
+
let changed = false
|
|
103
|
+
if (versionHookReferencesGenerators(pkg)) {
|
|
104
|
+
delete pkg.scripts.version
|
|
105
|
+
removed.push('package.json (version hook)')
|
|
106
|
+
changed = true
|
|
107
|
+
}
|
|
108
|
+
for (const [name, cmd] of Object.entries(HELPER_SCRIPTS)) {
|
|
109
|
+
if (pkg.scripts[name] === cmd) {
|
|
110
|
+
delete pkg.scripts[name]
|
|
111
|
+
changed = true
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (changed) {
|
|
115
|
+
if (Object.keys(pkg.scripts).length === 0) delete pkg.scripts
|
|
116
|
+
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify(pkg, null, 2) + '\n')
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return { removed }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// One-line pointer shown when we detect release tooling but don't remove it
|
|
124
|
+
// (declined, or a non-interactive run).
|
|
125
|
+
function releaseToolingNotice() {
|
|
126
|
+
return (
|
|
127
|
+
`Release tooling has moved to ${SKITTERSHIP} — run ` +
|
|
128
|
+
`npx ${SKITTERSHIP} init to keep it (your config is migrated automatically).`
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
module.exports = {
|
|
133
|
+
detectReleaseTooling,
|
|
134
|
+
removeReleaseTooling,
|
|
135
|
+
releaseToolingNotice,
|
|
136
|
+
RELEASE_PATHS,
|
|
137
|
+
SKITTERSHIP,
|
|
138
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Run a worktree's bootstrap steps — the engine half of `spec-env bootstrap`.
|
|
5
|
+
*
|
|
6
|
+
* `planUp` decides WHAT a fresh worktree needs (`worktreeSteps`); this module
|
|
7
|
+
* does it. Seeds and the env file are plain filesystem work, done here with
|
|
8
|
+
* `fs` and no shell at all. `setup` commands and the stack are spawned with the
|
|
9
|
+
* worktree as their cwd.
|
|
10
|
+
*
|
|
11
|
+
* WHY THE ENGINE RUNS THEM. These steps used to be printed as shell for the
|
|
12
|
+
* agent to run, and an agent does not copy shell faithfully. A consumer's
|
|
13
|
+
* secrets hook refuses any Bash call naming a `.env` file, with one exception
|
|
14
|
+
* for the exact printed shape — so every rewrite was refused, and the retry was
|
|
15
|
+
* refused again by auto mode as a workaround. The call that replaces them,
|
|
16
|
+
* `skitterspec spec-env bootstrap <name>`, names no file at all.
|
|
17
|
+
*
|
|
18
|
+
* Behaviour is the printed commands' behaviour, kept exactly: idempotent, one
|
|
19
|
+
* `seeded …` / `… — skipped` line per seed, a seed missing from the primary
|
|
20
|
+
* checkout is skipped rather than failed, a symlinked env file is refused before
|
|
21
|
+
* anything is written through it, and the run stops at the first failing step.
|
|
22
|
+
*
|
|
23
|
+
* Every side effect goes through `io`, so the tests drive it with a real temp
|
|
24
|
+
* directory and a fake spawn — no docker, no package manager.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const realFs = require('node:fs')
|
|
28
|
+
const path = require('node:path')
|
|
29
|
+
const { spawnSync } = require('node:child_process')
|
|
30
|
+
|
|
31
|
+
/** One line describing a step, for `--plan` and for the failure report. */
|
|
32
|
+
function describeStep(step) {
|
|
33
|
+
switch (step.kind) {
|
|
34
|
+
case 'seed':
|
|
35
|
+
return `seed ${step.file} (${step.mode} from the primary checkout)`
|
|
36
|
+
case 'envFile':
|
|
37
|
+
return `env file ${step.file} (COMPOSE_PROJECT_NAME, PORT_OFFSET)`
|
|
38
|
+
case 'setup':
|
|
39
|
+
return `setup: ${step.command}`
|
|
40
|
+
case 'docker':
|
|
41
|
+
return `docker: ${step.argv.join(' ')}`
|
|
42
|
+
default:
|
|
43
|
+
return `unknown step ${step.kind}`
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// `lstat` that answers null for a missing path rather than throwing.
|
|
48
|
+
function lstatOrNull(fs, p) {
|
|
49
|
+
try {
|
|
50
|
+
return fs.lstatSync(p)
|
|
51
|
+
} catch {
|
|
52
|
+
return null
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Seed one gitignored file from the primary checkout.
|
|
58
|
+
*
|
|
59
|
+
* The source is tested with `existsSync`, which follows links — a dangling
|
|
60
|
+
* link in the primary checkout is "not in main", exactly as `[ -e ]` read it.
|
|
61
|
+
* The target is tested with `lstat`, which does not: an existing link, even a
|
|
62
|
+
* dangling one, is somebody's deliberate state and is left alone.
|
|
63
|
+
*/
|
|
64
|
+
function runSeed(step, { cwd, mainRoot, fs }) {
|
|
65
|
+
const src = path.join(mainRoot, step.file)
|
|
66
|
+
const dst = path.join(cwd, step.file)
|
|
67
|
+
if (!fs.existsSync(src)) {
|
|
68
|
+
return { outcome: 'skipped', line: `seed ${step.file}: not in main — skipped` }
|
|
69
|
+
}
|
|
70
|
+
if (lstatOrNull(fs, dst)) {
|
|
71
|
+
return { outcome: 'skipped', line: `seed ${step.file}: exists — skipped` }
|
|
72
|
+
}
|
|
73
|
+
fs.mkdirSync(path.dirname(dst), { recursive: true })
|
|
74
|
+
if (step.mode === 'copy') fs.copyFileSync(src, dst)
|
|
75
|
+
else fs.symlinkSync(src, dst)
|
|
76
|
+
return { outcome: 'seeded', line: `seeded ${step.file} → ${src}` }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Make sure the spec's env file sets its project name and port block.
|
|
81
|
+
*
|
|
82
|
+
* Additive: a key is appended only when no line already sets it, so a re-run
|
|
83
|
+
* writes nothing and a file `setup` already wrote to keeps every value it holds
|
|
84
|
+
* — a deliberate `PORT_OFFSET` of its own included.
|
|
85
|
+
*
|
|
86
|
+
* A SYMLINK IS REFUSED, before anything is written. Seeding links gitignored
|
|
87
|
+
* files from the primary checkout, so a seeded env file IS the primary
|
|
88
|
+
* checkout's own, and appending through it would write this spec's port block
|
|
89
|
+
* into the main stack's configuration. The fix is a `docker.envFile` naming a
|
|
90
|
+
* file of its own.
|
|
91
|
+
*/
|
|
92
|
+
function runEnvFile(step, { cwd, fs }) {
|
|
93
|
+
const p = path.join(cwd, step.file)
|
|
94
|
+
const st = lstatOrNull(fs, p)
|
|
95
|
+
if (st && st.isSymbolicLink()) {
|
|
96
|
+
return {
|
|
97
|
+
outcome: 'refused',
|
|
98
|
+
line:
|
|
99
|
+
`env file ${step.file} is a symlink — docker.envFile must name a file of its own, ` +
|
|
100
|
+
"not a seeded one; refusing to write this spec's port block through it",
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const before = st ? fs.readFileSync(p, 'utf8') : ''
|
|
104
|
+
let add = ''
|
|
105
|
+
// A file without a trailing newline would glue the first appended key onto
|
|
106
|
+
// its last line.
|
|
107
|
+
const sep = before.length && !before.endsWith('\n') ? '\n' : ''
|
|
108
|
+
for (const [key, value] of [
|
|
109
|
+
['COMPOSE_PROJECT_NAME', step.projectName],
|
|
110
|
+
['PORT_OFFSET', step.offset],
|
|
111
|
+
]) {
|
|
112
|
+
if (!new RegExp(`^${key}=`, 'm').test(before)) add += `${key}=${value}\n`
|
|
113
|
+
}
|
|
114
|
+
if (!st || add) {
|
|
115
|
+
fs.mkdirSync(path.dirname(p), { recursive: true })
|
|
116
|
+
fs.writeFileSync(p, before + (add ? sep + add : ''))
|
|
117
|
+
}
|
|
118
|
+
return { outcome: add ? 'written' : 'skipped', line: `env file ${step.file}: ready` }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Spawn a setup command (through the shell — it is the project's own command
|
|
123
|
+
* line) or the stack (an argv, no shell).
|
|
124
|
+
*
|
|
125
|
+
* `stdout` is where the child's own output goes: inherited normally, so an
|
|
126
|
+
* install streams as it runs, and moved to stderr under `--json` so the payload
|
|
127
|
+
* on stdout stays parseable.
|
|
128
|
+
*/
|
|
129
|
+
function runSpawned(step, { cwd, spawn, childStdout }) {
|
|
130
|
+
const stdio = ['ignore', childStdout, 'inherit']
|
|
131
|
+
const res =
|
|
132
|
+
step.kind === 'setup'
|
|
133
|
+
? spawn(step.command, { cwd, shell: true, stdio })
|
|
134
|
+
: spawn(step.argv[0], step.argv.slice(1), { cwd, stdio })
|
|
135
|
+
if (res.error) {
|
|
136
|
+
return { outcome: 'failed', line: `${describeStep(step)} — ${res.error.message}` }
|
|
137
|
+
}
|
|
138
|
+
if (res.status !== 0) {
|
|
139
|
+
const why = res.status === null ? `killed by ${res.signal || 'a signal'}` : `exit ${res.status}`
|
|
140
|
+
return { outcome: 'failed', line: `${describeStep(step)} — ${why}` }
|
|
141
|
+
}
|
|
142
|
+
return { outcome: 'ran', line: null }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Run `steps` in order in `cwd`, stopping at the first that fails.
|
|
147
|
+
*
|
|
148
|
+
* io: { cwd, mainRoot, fs?, spawn?, write?, childStdout? }
|
|
149
|
+
* - `write(line)` receives every report line as it happens
|
|
150
|
+
* - `childStdout` is the stdio slot for spawned output ('inherit' default)
|
|
151
|
+
*
|
|
152
|
+
* Returns { ok, results: [{ step, outcome, line }], failedAt } — `failedAt` is
|
|
153
|
+
* the 1-based index of the step that stopped the run, or null.
|
|
154
|
+
*/
|
|
155
|
+
function runBootstrap(steps, io) {
|
|
156
|
+
const ctx = {
|
|
157
|
+
cwd: io.cwd,
|
|
158
|
+
mainRoot: io.mainRoot,
|
|
159
|
+
fs: io.fs || realFs,
|
|
160
|
+
spawn: io.spawn || spawnSync,
|
|
161
|
+
childStdout: io.childStdout || 'inherit',
|
|
162
|
+
}
|
|
163
|
+
const write = io.write || (() => {})
|
|
164
|
+
const results = []
|
|
165
|
+
for (let i = 0; i < steps.length; i++) {
|
|
166
|
+
const step = steps[i]
|
|
167
|
+
// A spawned step announces itself BEFORE it runs, so the streamed output
|
|
168
|
+
// that follows has a heading.
|
|
169
|
+
if (step.kind === 'setup' || step.kind === 'docker') write(describeStep(step))
|
|
170
|
+
let r
|
|
171
|
+
try {
|
|
172
|
+
if (step.kind === 'seed') r = runSeed(step, ctx)
|
|
173
|
+
else if (step.kind === 'envFile') r = runEnvFile(step, ctx)
|
|
174
|
+
else if (step.kind === 'setup' || step.kind === 'docker') r = runSpawned(step, ctx)
|
|
175
|
+
else r = { outcome: 'failed', line: describeStep(step) }
|
|
176
|
+
} catch (err) {
|
|
177
|
+
r = { outcome: 'failed', line: `${describeStep(step)} — ${err.message}` }
|
|
178
|
+
}
|
|
179
|
+
results.push({ step, ...r })
|
|
180
|
+
if (r.line && r.outcome !== 'failed' && r.outcome !== 'refused') write(r.line)
|
|
181
|
+
if (r.outcome === 'failed' || r.outcome === 'refused') {
|
|
182
|
+
return { ok: false, results, failedAt: i + 1 }
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return { ok: true, results, failedAt: null }
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
module.exports = { runBootstrap, describeStep }
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The primary-checkout leak guard.
|
|
5
|
+
*
|
|
6
|
+
* A phase can be built in a worktree from a session standing somewhere else —
|
|
7
|
+
* every write an absolute path, every command `cd`-prefixed. That works;
|
|
8
|
+
* `spec-env review` already reads a worktree exactly that way. What it cannot do
|
|
9
|
+
* is *prove* it worked: one relative path and the edit lands in the primary
|
|
10
|
+
* checkout, on the base branch, silently.
|
|
11
|
+
*
|
|
12
|
+
* So record what the primary checkout looked like before the build
|
|
13
|
+
* (`--record-primary`) and compare after it (`--assert-primary-clean`).
|
|
14
|
+
*
|
|
15
|
+
* The baseline is the whole point. `negative-checks.md` rule 1 asks for a
|
|
16
|
+
* positive signal rather than an absence, and "these were the dirty paths at a
|
|
17
|
+
* known moment" is one; a bare "the primary must be clean" check would accuse
|
|
18
|
+
* anyone who left an unrelated edit open in another window. Rule 4 supplies the
|
|
19
|
+
* third state: when the baseline is missing, or is for another spec, the check
|
|
20
|
+
* cannot tell — and cannot-tell reports and exits 0 rather than accusing.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const path = require('node:path')
|
|
24
|
+
|
|
25
|
+
// The state dir sits beside the registry file, e.g. `.spec-env` — the same
|
|
26
|
+
// convention `dev.js` uses for logs and pids.
|
|
27
|
+
function stateDir(config) {
|
|
28
|
+
return path.posix.dirname(config.registry) || '.spec-env'
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Where the baseline lives. Gitignored with the rest of `.spec-env/`. */
|
|
32
|
+
function baselinePath(dir, config) {
|
|
33
|
+
return path.join(dir, stateDir(config), 'building.json')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Merge one or more newline-separated git path listings into a sorted, deduped
|
|
38
|
+
* array. Blank lines are dropped; nothing is parsed out of the line.
|
|
39
|
+
*
|
|
40
|
+
* DELIBERATELY NOT `git status --porcelain`, for two reasons, both found by
|
|
41
|
+
* running this against a real worktree:
|
|
42
|
+
*
|
|
43
|
+
* 1. Porcelain puts the path behind two fixed status columns and a space, and
|
|
44
|
+
* the repo's `gitReader` TRIMS its stdout — so the first line loses its
|
|
45
|
+
* leading space and a fixed `slice(3)` eats the first character of the
|
|
46
|
+
* first path. A guard that renames the file it is accusing you of is worse
|
|
47
|
+
* than no guard.
|
|
48
|
+
* 2. Porcelain reports stat-dirty entries: a file whose mtime moved but whose
|
|
49
|
+
* content is identical to HEAD. One was observed here, reported as ` M`
|
|
50
|
+
* with an empty `git diff`. Accusing someone of leaking a file they never
|
|
51
|
+
* changed is exactly the false accusation `negative-checks.md` exists to
|
|
52
|
+
* prevent.
|
|
53
|
+
*
|
|
54
|
+
* `git diff --name-only HEAD` compares CONTENT, and
|
|
55
|
+
* `git ls-files --others --exclude-standard` lists genuinely new files. Between
|
|
56
|
+
* them they answer "what did this tree gain?" without a column to miscount.
|
|
57
|
+
*/
|
|
58
|
+
function mergePaths(...listings) {
|
|
59
|
+
const out = new Set()
|
|
60
|
+
for (const listing of listings) {
|
|
61
|
+
for (const line of String(listing || '').split('\n')) {
|
|
62
|
+
const p = line.replace(/\r$/, '').trim()
|
|
63
|
+
if (p !== '') out.add(p)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return [...out].sort()
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Are these two paths the same tree? */
|
|
70
|
+
function sameTree(a, b) {
|
|
71
|
+
return path.resolve(a) === path.resolve(b)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The record written by `--record-primary`. Pure. */
|
|
75
|
+
function buildBaseline({ spec, worktreePath, primary, paths }) {
|
|
76
|
+
return { spec, worktreePath, primary, paths: [...paths].sort() }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Compare a recorded baseline against the primary checkout's current state.
|
|
81
|
+
*
|
|
82
|
+
* Three verdicts, never two (`negative-checks.md` rule 4):
|
|
83
|
+
* - `clean` — nothing new appeared; say nothing and exit 0.
|
|
84
|
+
* - `leaked` — these paths appeared since; name them and exit non-zero.
|
|
85
|
+
* - `unknown` — the check cannot tell; say why and exit 0.
|
|
86
|
+
*
|
|
87
|
+
* Pure: every input is supplied, nothing is read from disk or git.
|
|
88
|
+
*
|
|
89
|
+
* WHAT WOULD FOOL THIS CHECK, deliberately unhandled:
|
|
90
|
+
* - Work the build COMMITTED in the primary checkout. A commit empties the
|
|
91
|
+
* porcelain, so a leak that was tidied away looks identical to no leak. The
|
|
92
|
+
* guard is aimed at the actual failure mode — writes going astray mid-build,
|
|
93
|
+
* while nothing has been committed yet — and runs before the phase commit
|
|
94
|
+
* for that reason.
|
|
95
|
+
* - A write to a GITIGNORED path in the primary (`.spec-env/` itself, build
|
|
96
|
+
* output, `node_modules`). `git status` cannot see it, by design, and the
|
|
97
|
+
* baseline file lives in exactly such a path so that recording it is not
|
|
98
|
+
* itself a change the next comparison trips over.
|
|
99
|
+
* - A path dirty at record time and then edited FURTHER by the build. It is in
|
|
100
|
+
* the baseline, so it stays silent. That is the deliberate trade: silence
|
|
101
|
+
* there costs a missed edit to a file someone was already working on, and
|
|
102
|
+
* the opposite default accuses every healthy concurrent edit.
|
|
103
|
+
*/
|
|
104
|
+
function compare(baseline, currentPaths, { spec, worktreePath, primary }) {
|
|
105
|
+
if (sameTree(primary, worktreePath)) {
|
|
106
|
+
return {
|
|
107
|
+
verdict: 'unknown',
|
|
108
|
+
paths: [],
|
|
109
|
+
reason: 'the worktree IS the primary checkout — there is no second tree to leak into',
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (!baseline) {
|
|
113
|
+
return { verdict: 'unknown', paths: [], reason: 'no baseline recorded' }
|
|
114
|
+
}
|
|
115
|
+
if (baseline.spec !== spec) {
|
|
116
|
+
return {
|
|
117
|
+
verdict: 'unknown',
|
|
118
|
+
paths: [],
|
|
119
|
+
reason: `the baseline was recorded for ${baseline.spec}, not ${spec}`,
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (!sameTree(baseline.worktreePath, worktreePath)) {
|
|
123
|
+
return {
|
|
124
|
+
verdict: 'unknown',
|
|
125
|
+
paths: [],
|
|
126
|
+
reason: 'the baseline records a different worktree for this spec',
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const before = new Set(baseline.paths || [])
|
|
130
|
+
const appeared = [...currentPaths].filter((p) => !before.has(p)).sort()
|
|
131
|
+
return appeared.length === 0
|
|
132
|
+
? { verdict: 'clean', paths: [], reason: null }
|
|
133
|
+
: { verdict: 'leaked', paths: appeared, reason: null }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
module.exports = {
|
|
137
|
+
stateDir,
|
|
138
|
+
baselinePath,
|
|
139
|
+
mergePaths,
|
|
140
|
+
sameTree,
|
|
141
|
+
buildBaseline,
|
|
142
|
+
compare,
|
|
143
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Which uncommitted paths belong to ONE spec?
|
|
5
|
+
*
|
|
6
|
+
* `/spec-start` refuses a dirty tree because moving another spec's unfinished
|
|
7
|
+
* work is not ours to do. But the commonest dirty tree there is — the spec you
|
|
8
|
+
* just authored and are now starting — is not another spec's work at all, and
|
|
9
|
+
* refusing it costs a round trip through `/commit` on every single start.
|
|
10
|
+
*
|
|
11
|
+
* The distinction this module draws is deliberately NOT "does the dirt look
|
|
12
|
+
* important?" (a judgement, and the gate rightly refuses to make one) but
|
|
13
|
+
* membership in an exactly-known set: the spec's own folder, plus whatever the
|
|
14
|
+
* project declares in `spec.companionPaths`. Everything else is foreign, and one
|
|
15
|
+
* foreign path disqualifies the whole tree.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const path = require('node:path')
|
|
19
|
+
const { BUCKETS, expandTokens, readFrontmatterField } = require('./resolve.js')
|
|
20
|
+
|
|
21
|
+
// git reports repo-relative, forward-slashed paths; an untracked directory comes
|
|
22
|
+
// back with a trailing slash. Normalise both away so comparisons are exact.
|
|
23
|
+
function normalize(p) {
|
|
24
|
+
return String(p || '')
|
|
25
|
+
.trim()
|
|
26
|
+
.replace(/\\/g, '/')
|
|
27
|
+
.replace(/\/+$/, '')
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Expand a `companionPaths` pattern for this spec, or return null when it cannot
|
|
32
|
+
* be expanded.
|
|
33
|
+
*
|
|
34
|
+
* A PATTERN THAT REFERENCES `{identifier}` WHEN NO IDENTIFIER RESOLVES MATCHES
|
|
35
|
+
* NOTHING, deliberately. Two ordinary situations produce a spec with no id — a
|
|
36
|
+
* project that never set `branch.identifierField`, and a spec deliberately kept
|
|
37
|
+
* local, never pushed to a tracker — and in both the file the pattern describes
|
|
38
|
+
* either does not exist or belongs to some OTHER spec. Expanding `{identifier}`
|
|
39
|
+
* to a placeholder, or dropping the token, would widen the owned set to a path
|
|
40
|
+
* this spec has no claim on and sweep another spec's snapshot into its commit.
|
|
41
|
+
* Being wrong this way costs a refusal the operator can fix with `/commit`; the
|
|
42
|
+
* other way costs them a file they never staged.
|
|
43
|
+
*/
|
|
44
|
+
function expandCompanion(pattern, spec, config) {
|
|
45
|
+
const tokens = { slug: spec.slug }
|
|
46
|
+
if (/\{identifier\}/.test(pattern)) {
|
|
47
|
+
const field = config && config.branch && config.branch.identifierField
|
|
48
|
+
// NO PATH IS CANNOT-TELL, not an error. Callers hand in spec objects of two
|
|
49
|
+
// shapes: `resolveSpec` returns a full one, and `allSpecs` returns
|
|
50
|
+
// `{folder, slug, worktreePath}` with no `path` at all. Reading the
|
|
51
|
+
// frontmatter of `undefined` threw, and the throw surfaced as
|
|
52
|
+
// `render failed: The "path" argument must be of type string` on the review
|
|
53
|
+
// server's index — every spec, not just one.
|
|
54
|
+
//
|
|
55
|
+
// It routes to the same answer a missing identifier already routes to:
|
|
56
|
+
// expand nothing, so the pattern matches nothing and the path is not
|
|
57
|
+
// claimed as this spec's. Being wrong here costs a companion file left
|
|
58
|
+
// unstaged; throwing cost the whole page.
|
|
59
|
+
const identifier = spec.path ? readFrontmatterField(spec.path, field) : null
|
|
60
|
+
if (!identifier) return null
|
|
61
|
+
tokens.identifier = identifier
|
|
62
|
+
}
|
|
63
|
+
return normalize(expandTokens(pattern, tokens))
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Split `dirtyPaths` into the ones that belong to `spec` and the ones that do
|
|
68
|
+
* not. Repo-relative paths in, repo-relative paths out; nothing is read from git
|
|
69
|
+
* and nothing is written.
|
|
70
|
+
*
|
|
71
|
+
* Owned:
|
|
72
|
+
* - anything inside `specs/<bucket>/<folder>/` for ANY bucket. Every bucket is
|
|
73
|
+
* checked rather than the spec's current one because starting a spec MOVES it
|
|
74
|
+
* (backlog → in-progress), so a tree mid-move is dirty in two buckets at once
|
|
75
|
+
* and both halves are the same spec's.
|
|
76
|
+
* - each expandable `spec.companionPaths` entry.
|
|
77
|
+
*
|
|
78
|
+
* @returns {{owned: string[], foreign: string[]}}
|
|
79
|
+
*/
|
|
80
|
+
function classifyDirtyTree(spec, dirtyPaths, config) {
|
|
81
|
+
const owned = []
|
|
82
|
+
const foreign = []
|
|
83
|
+
if (!spec) return { owned, foreign: (dirtyPaths || []).map(normalize).filter(Boolean) }
|
|
84
|
+
|
|
85
|
+
const folders = BUCKETS.map((bucket) => `specs/${bucket}/${spec.folder}`)
|
|
86
|
+
const companions = new Set()
|
|
87
|
+
for (const pattern of (config && config.spec && config.spec.companionPaths) || []) {
|
|
88
|
+
const expanded = expandCompanion(pattern, spec, config)
|
|
89
|
+
if (expanded) companions.add(expanded)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
for (const raw of dirtyPaths || []) {
|
|
93
|
+
const p = normalize(raw)
|
|
94
|
+
if (!p) continue
|
|
95
|
+
const inFolder = folders.some((f) => p === f || p.startsWith(`${f}/`))
|
|
96
|
+
if (inFolder || companions.has(p)) owned.push(p)
|
|
97
|
+
else foreign.push(p)
|
|
98
|
+
}
|
|
99
|
+
return { owned, foreign }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// git quotes a path containing unusual bytes and C-escapes it. Unquote what we
|
|
103
|
+
// can; anything we cannot parse confidently is returned as-is, which makes it
|
|
104
|
+
// fail the spec-folder comparison and land in `foreign` — a refusal, which is the
|
|
105
|
+
// safe direction to be wrong in.
|
|
106
|
+
function unquotePath(p) {
|
|
107
|
+
if (!p.startsWith('"') || !p.endsWith('"')) return p
|
|
108
|
+
try {
|
|
109
|
+
return JSON.parse(p)
|
|
110
|
+
} catch {
|
|
111
|
+
return p
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Repo-relative paths of everything uncommitted. Returns null when git could not
|
|
117
|
+
* be read at all — the caller must treat that as "nobody looked", never "clean".
|
|
118
|
+
*
|
|
119
|
+
* Two prefix-free listings rather than `git status --porcelain`, deliberately.
|
|
120
|
+
* Porcelain prefixes every path with a two-character status field, and the shared
|
|
121
|
+
* git reader TRIMS its output — which eats the leading space of the first line
|
|
122
|
+
* only, so a fixed-offset parse silently returned `EADME.md` for `README.md`.
|
|
123
|
+
* These emit bare paths, so there is no offset to get wrong. `--others` also
|
|
124
|
+
* lists untracked files INDIVIDUALLY, where porcelain collapses them into their
|
|
125
|
+
* topmost untracked directory — reporting a brand-new spec as `specs/backlog/`,
|
|
126
|
+
* an ancestor attributable to no single spec, and so refusing the very tree this
|
|
127
|
+
* gate exists to accept. Both were found by running it, not by reading it.
|
|
128
|
+
*/
|
|
129
|
+
function dirtyPaths(git) {
|
|
130
|
+
const lists = [
|
|
131
|
+
git(['diff', '--name-only', 'HEAD']),
|
|
132
|
+
git(['ls-files', '--others', '--exclude-standard']),
|
|
133
|
+
]
|
|
134
|
+
if (lists.every((l) => l === null)) return null
|
|
135
|
+
const out = []
|
|
136
|
+
for (const list of lists) {
|
|
137
|
+
if (!list) continue
|
|
138
|
+
for (const line of list.split('\n')) {
|
|
139
|
+
const q = line.trim()
|
|
140
|
+
if (q) out.push(unquotePath(q))
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return out
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* A spec's own uncommitted documents in one tree: `{tree, owned}`, or a reason.
|
|
148
|
+
*
|
|
149
|
+
* SHARED ON PURPOSE. Two surfaces render a spec's documents — the CLI's
|
|
150
|
+
* `review --docs` and the review server's route for a spec with no worktree —
|
|
151
|
+
* and if they classified separately they could disagree about what the page
|
|
152
|
+
* shows. A reader's verdict is about the page they read, so the served page and
|
|
153
|
+
* the written page have to be the same page.
|
|
154
|
+
*
|
|
155
|
+
* Three states, never two (`.claude/rules/negative-checks.md` rule 4). An
|
|
156
|
+
* unreadable git is `cannot tell`, and must not collapse into an empty file
|
|
157
|
+
* set: an empty set renders a page saying nothing changed, which is the one
|
|
158
|
+
* reading that is certainly wrong.
|
|
159
|
+
*
|
|
160
|
+
* WHAT WOULD FOOL THIS: a document of this spec's that is already committed.
|
|
161
|
+
* `dirtyPaths` answers about the uncommitted tree only, so a spec whose files
|
|
162
|
+
* are all committed reports `empty` rather than showing its own text. That is
|
|
163
|
+
* the intended reading — there is no change to review — and it is why `empty`
|
|
164
|
+
* is a distinct answer rather than an error.
|
|
165
|
+
*
|
|
166
|
+
* @returns {{tree: string, owned: string[]}|{error: string}|{empty: true}}
|
|
167
|
+
*/
|
|
168
|
+
function specDocsIn(tree, spec, config, git) {
|
|
169
|
+
const paths = dirtyPaths(git)
|
|
170
|
+
if (paths === null) return { error: 'git could not be read' }
|
|
171
|
+
const { owned } = classifyDirtyTree(spec, paths, config)
|
|
172
|
+
if (!owned.length) return { empty: true }
|
|
173
|
+
return { tree, owned }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
module.exports = { classifyDirtyTree, expandCompanion, unquotePath, dirtyPaths, specDocsIn }
|