@skitterbyte/skitterspec-linear 10.7.0 → 11.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 +36 -0
- package/README.md +3 -3
- package/assets/claude-md-section.md +13 -51
- package/assets/commands/spec-live.md +2 -2
- package/assets/core/SETUP.md +1 -1
- package/assets/core/env.config.json.example +4 -1
- package/assets/core/env.config.md +26 -6
- package/assets/core/linear.config.json.example +2 -1
- package/assets/core/linear.config.md +46 -4
- package/assets/rules/spec-planning.md +30 -10
- package/assets/skills/spec/SKILL.md +15 -10
- package/assets/skills/spec-bug/SKILL.md +43 -9
- package/assets/skills/spec-cancel/SKILL.md +12 -0
- package/assets/skills/spec-complete/SKILL.md +26 -5
- package/assets/skills/spec-hotfix/SKILL.md +42 -8
- package/assets/skills/spec-init/SKILL.md +16 -6
- package/assets/skills/spec-linear-setup/SKILL.md +1 -1
- package/assets/skills/spec-next/SKILL.md +141 -0
- package/assets/skills/spec-push/SKILL.md +2 -2
- package/assets/skills/spec-review/SKILL.md +5 -5
- package/assets/skills/spec-start/SKILL.md +146 -0
- package/assets/skills/spec-status/SKILL.md +2 -2
- package/assets/skills/spec-sync/SKILL.md +9 -1
- package/assets/skills/spec-to-main/SKILL.md +7 -5
- package/package.json +1 -1
- package/src/cli.js +234 -20
- package/src/env/config.js +24 -1
- package/src/env/integrate.js +61 -1
- package/src/env/live.js +27 -3
- package/src/env/provision.js +58 -1
- package/src/env/teardown.js +41 -3
- package/src/init.js +21 -12
- package/src/prompts.js +31 -3
- package/src/vendor/linear/cli-sync.js +55 -4
- package/src/vendor/linear/config.js +70 -3
- package/src/vendor/linear/released.js +65 -5
- package/assets/skills/spec-go/SKILL.md +0 -233
package/src/env/provision.js
CHANGED
|
@@ -158,4 +158,61 @@ function planUp(spec, alloc, config) {
|
|
|
158
158
|
}
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
-
|
|
161
|
+
/**
|
|
162
|
+
* Plan `spec-env up` in CHECKOUT mode — the branch is built in the primary
|
|
163
|
+
* checkout and there is no worktree at all.
|
|
164
|
+
*
|
|
165
|
+
* Pure: every git fact it needs arrives in `ctx`, and it writes nothing.
|
|
166
|
+
*
|
|
167
|
+
* ctx: { current, base, onBase, clean, branchExists }
|
|
168
|
+
*
|
|
169
|
+
* What it refuses, and why each is a refusal rather than a warning:
|
|
170
|
+
* - a dirty tree, because `git switch -c` CARRIES uncommitted changes onto the
|
|
171
|
+
* new branch. That is silent and it is the operator's work, so it is theirs
|
|
172
|
+
* to place, not ours.
|
|
173
|
+
* - standing on another branch, because switching away from it is a decision
|
|
174
|
+
* about someone else's unfinished spec. Being on THIS spec's branch is not a
|
|
175
|
+
* refusal — it is the re-run, and the answer is "already attached".
|
|
176
|
+
*
|
|
177
|
+
* There is no bootstrap and no opener: the primary checkout already has its
|
|
178
|
+
* dependencies, and no new session is being opened.
|
|
179
|
+
*/
|
|
180
|
+
function planCheckoutUp(spec, ctx, config) {
|
|
181
|
+
const base = ctx.base || (config && config.baseBranch) || 'main'
|
|
182
|
+
const result = {
|
|
183
|
+
mode: 'checkout',
|
|
184
|
+
blocked: false,
|
|
185
|
+
reason: null,
|
|
186
|
+
attached: false,
|
|
187
|
+
branch: spec.branch,
|
|
188
|
+
checkoutPath: ctx.checkoutPath || null,
|
|
189
|
+
commands: [],
|
|
190
|
+
}
|
|
191
|
+
const block = (reason) => ({ ...result, blocked: true, reason })
|
|
192
|
+
|
|
193
|
+
// Order matters: report "already attached" before anything else, so a re-run
|
|
194
|
+
// on the spec's own branch is never refused for a dirty tree it legitimately
|
|
195
|
+
// has — you are mid-phase, with the phase's own edits in progress.
|
|
196
|
+
if (ctx.current && ctx.current === spec.branch) {
|
|
197
|
+
return { ...result, attached: true }
|
|
198
|
+
}
|
|
199
|
+
if (!ctx.clean) {
|
|
200
|
+
return block(
|
|
201
|
+
'the primary checkout has uncommitted changes — commit or stash them first ' +
|
|
202
|
+
'(switching would carry them onto the new branch)',
|
|
203
|
+
)
|
|
204
|
+
}
|
|
205
|
+
if (!ctx.onBase) {
|
|
206
|
+
return block(
|
|
207
|
+
`the primary checkout is on ${ctx.current || '(detached)'}, not ${base} — ` +
|
|
208
|
+
'finish or park that branch first; checkout mode holds one spec at a time',
|
|
209
|
+
)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
result.commands.push(
|
|
213
|
+
ctx.branchExists ? `git switch ${spec.branch}` : `git switch -c ${spec.branch}`,
|
|
214
|
+
)
|
|
215
|
+
return result
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
module.exports = { planUp, planCheckoutUp, seedCommandFor, worktreeCd }
|
package/src/env/teardown.js
CHANGED
|
@@ -105,7 +105,7 @@ function planDown(spec, config, flags, ctx) {
|
|
|
105
105
|
// different question from ours: it also declines a branch that is ahead of its
|
|
106
106
|
// upstream ref, reporting `not yet merged to refs/remotes/origin/<branch>,
|
|
107
107
|
// even though it is merged to HEAD`. That fires on the ordinary spec flow —
|
|
108
|
-
// `/spec-
|
|
108
|
+
// `/spec-start` pushes the branch when it provisions, and the phase commits after
|
|
109
109
|
// it are landed locally rather than pushed — so teardown meets a branch whose
|
|
110
110
|
// every commit is on `main` and `-d` refuses it. `merged` (HEAD is an ancestor
|
|
111
111
|
// of base) already establishes what we actually care about, and establishes it
|
|
@@ -122,7 +122,7 @@ function planDown(spec, config, flags, ctx) {
|
|
|
122
122
|
|
|
123
123
|
// --- delete the branch on the remote (planned, never run here) ---
|
|
124
124
|
//
|
|
125
|
-
// `/spec-
|
|
125
|
+
// `/spec-start` pushes the branch at provision time, so a completed spec otherwise
|
|
126
126
|
// leaves a merged branch on the remote forever. Cleaning that up is the goal;
|
|
127
127
|
// doing it safely is the constraint.
|
|
128
128
|
//
|
|
@@ -185,4 +185,42 @@ function blocked(reason) {
|
|
|
185
185
|
}
|
|
186
186
|
}
|
|
187
187
|
|
|
188
|
-
|
|
188
|
+
/**
|
|
189
|
+
* Pure teardown planner for CHECKOUT mode.
|
|
190
|
+
*
|
|
191
|
+
* There is no worktree to remove, no slot and no volumes — the only thing a
|
|
192
|
+
* finished spec leaves behind is its branch, and the checkout standing on it.
|
|
193
|
+
*
|
|
194
|
+
* Order is load-bearing: git refuses to delete the branch you are on, so the
|
|
195
|
+
* switch to base must come first. It is also what returns the checkout to a
|
|
196
|
+
* state the next `spec-env up` will accept.
|
|
197
|
+
*
|
|
198
|
+
* The same "are these commits recoverable?" question decides `-d` vs `-D`, and
|
|
199
|
+
* it is asked exactly as worktree-mode teardown asks it — a landed branch is
|
|
200
|
+
* safe to force-delete because its commits are on base (or under a tag); an
|
|
201
|
+
* unlanded one is not, and is refused rather than quietly dropped.
|
|
202
|
+
*
|
|
203
|
+
* ctx: { dirty, landed, onBranch, base, checkoutPath }
|
|
204
|
+
*/
|
|
205
|
+
function planDownCheckout(spec, config, flags, ctx) {
|
|
206
|
+
const { dirty, landed, onBranch, base, checkoutPath } = ctx || {}
|
|
207
|
+
const force = Boolean(flags && flags.force)
|
|
208
|
+
const result = { mode: 'checkout', blocked: false, reason: null, commands: [], branch: spec.branch }
|
|
209
|
+
const block = (reason) => ({ ...result, blocked: true, reason })
|
|
210
|
+
|
|
211
|
+
if (!force) {
|
|
212
|
+
if (config.guards.refuseTeardownIfDirty && dirty) {
|
|
213
|
+
return block('the checkout has uncommitted changes')
|
|
214
|
+
}
|
|
215
|
+
if (!landed) {
|
|
216
|
+
return block(`${spec.branch} is not merged into ${base} — landing it first is what makes the delete safe`)
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const commands = []
|
|
221
|
+
if (onBranch !== false) commands.push(`git -C ${checkoutPath} switch ${base}`)
|
|
222
|
+
commands.push(`git -C ${checkoutPath} branch ${landed ? '-D' : '-d'} ${spec.branch}`)
|
|
223
|
+
return { ...result, commands }
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
module.exports = { planDown, planDownCheckout }
|
package/src/init.js
CHANGED
|
@@ -423,18 +423,24 @@ function installCore(dir, opts) {
|
|
|
423
423
|
}
|
|
424
424
|
|
|
425
425
|
// Activate opt-in per-spec isolation: write specs/.core/env.config.json from the
|
|
426
|
-
// example asset so /spec-
|
|
426
|
+
// example asset so /spec-start provisions a worktree for every in-progress spec.
|
|
427
427
|
// Only called when the operator opts in, and never on `update` (adopting isolation
|
|
428
428
|
// is a deliberate choice, not something a re-sync flips on). Idempotent: writeFile
|
|
429
429
|
// never clobbers an existing env.config.json without --force.
|
|
430
|
-
function installIsolation(dir, { enabled }, opts) {
|
|
430
|
+
function installIsolation(dir, { enabled, workspaceMode }, opts) {
|
|
431
431
|
if (!enabled) return
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
432
|
+
const target = path.join(dir, 'specs', '.core', 'env.config.json')
|
|
433
|
+
copyAsset(dir, path.join('core', 'env.config.json.example'), target, opts)
|
|
434
|
+
|
|
435
|
+
// Only 'checkout' is written; 'worktree' is already what the template says and
|
|
436
|
+
// what the loader defaults to, so the common path leaves the file untouched.
|
|
437
|
+
// Guarded by existsSync because copyAsset legitimately declines to overwrite a
|
|
438
|
+
// config the operator already customized — rewriting it here would undo that.
|
|
439
|
+
if (workspaceMode === 'checkout' && fs.existsSync(target)) {
|
|
440
|
+
const parsed = JSON.parse(fs.readFileSync(target, 'utf8'))
|
|
441
|
+
parsed.mode = 'checkout'
|
|
442
|
+
fs.writeFileSync(target, `${JSON.stringify(parsed, null, 2)}\n`)
|
|
443
|
+
}
|
|
438
444
|
trustWorktreeRoot(dir)
|
|
439
445
|
}
|
|
440
446
|
|
|
@@ -673,7 +679,7 @@ function printReport(dir, mode, { diff = false } = {}) {
|
|
|
673
679
|
const isolationOn = fs.existsSync(path.join(dir, 'specs', '.core', 'env.config.json'))
|
|
674
680
|
const isolationNote = isolationOn
|
|
675
681
|
? 'Per-spec isolation is ON: every in-progress spec gets its own git worktree' +
|
|
676
|
-
' at /spec-
|
|
682
|
+
' at /spec-start (Docker is a per-spec escalation — set > **Stack:** in the spec).\n'
|
|
677
683
|
: 'Per-spec isolation is opt-in: re-run with --isolation (or copy' +
|
|
678
684
|
' specs/.core/env.config.json.example → env.config.json) to enable it.\n'
|
|
679
685
|
// A provider superset ships its own `spec-<provider>-setup` skill; the base
|
|
@@ -696,7 +702,7 @@ function printReport(dir, mode, { diff = false } = {}) {
|
|
|
696
702
|
' (it discovers your workspace and writes the config), or see' +
|
|
697
703
|
' specs/.core/SETUP.md.\n'
|
|
698
704
|
process.stdout.write(
|
|
699
|
-
'\nDone. Skills resolve as /spec, /spec-
|
|
705
|
+
'\nDone. Skills resolve as /spec, /spec-start, /spec-next, /spec-complete,' +
|
|
700
706
|
' /spec-bug, /spec-review, /spec-init, /spec-connect.\n' +
|
|
701
707
|
'Next: tailor .claude/rules/spec-planning.md + the CLAUDE.md section to this' +
|
|
702
708
|
" project's stack, then run /spec.\n" +
|
|
@@ -705,7 +711,10 @@ function printReport(dir, mode, { diff = false } = {}) {
|
|
|
705
711
|
)
|
|
706
712
|
}
|
|
707
713
|
|
|
708
|
-
|
|
714
|
+
// `mode` here is the INSTALL mode ('init' | 'update'), long-standing and
|
|
715
|
+
// unrelated to the config's own `mode` key — which arrives as `workspaceMode`
|
|
716
|
+
// precisely so the two cannot be confused at a call site.
|
|
717
|
+
async function init({ dir, force, claudeMd, mode, isolation, workspaceMode }) {
|
|
709
718
|
if (!fs.existsSync(dir)) throw new Error(`target dir does not exist: ${dir}`)
|
|
710
719
|
resetReport()
|
|
711
720
|
|
|
@@ -716,7 +725,7 @@ async function init({ dir, force, claudeMd, mode, isolation }) {
|
|
|
716
725
|
removeRetiredFiles(dir)
|
|
717
726
|
installCore(dir, { force })
|
|
718
727
|
// Adopting isolation writes the live env.config.json — init only, never update.
|
|
719
|
-
if (mode !== 'update') installIsolation(dir, { enabled: isolation }, { force })
|
|
728
|
+
if (mode !== 'update') installIsolation(dir, { enabled: isolation, workspaceMode }, { force })
|
|
720
729
|
if (claudeMd) installClaudeMd(dir, { mode })
|
|
721
730
|
|
|
722
731
|
// Record what we wrote (and migrate a pre-manifest repo) so a later resync can
|
package/src/prompts.js
CHANGED
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
* test suite never imports the interactive UI.
|
|
8
8
|
*
|
|
9
9
|
* `isolationSeed` pre-fills the per-spec isolation question. Returns
|
|
10
|
-
* `{ isolation }
|
|
10
|
+
* `{ isolation, mode }` — `mode` is only asked when isolation is enabled, and
|
|
11
|
+
* is `'worktree'` otherwise (the value the config defaults to anyway).
|
|
11
12
|
*/
|
|
12
13
|
|
|
13
14
|
async function promptSetup({ isolationSeed = false } = {}) {
|
|
@@ -23,15 +24,42 @@ async function promptSetup({ isolationSeed = false } = {}) {
|
|
|
23
24
|
{
|
|
24
25
|
type: 'confirm',
|
|
25
26
|
name: 'isolation',
|
|
26
|
-
message: 'Enable per-spec isolation —
|
|
27
|
+
message: 'Enable per-spec isolation — build each spec on its own branch?',
|
|
27
28
|
initial: isolationSeed,
|
|
28
29
|
},
|
|
30
|
+
{
|
|
31
|
+
// Only reachable when isolation was accepted: `prev` is the previous
|
|
32
|
+
// answer, and returning null skips the question entirely.
|
|
33
|
+
type: (prev) => (prev ? 'select' : null),
|
|
34
|
+
name: 'mode',
|
|
35
|
+
message: 'Where should a spec be built?',
|
|
36
|
+
hint: '- this is the trade, not a preference',
|
|
37
|
+
initial: 0,
|
|
38
|
+
choices: [
|
|
39
|
+
{
|
|
40
|
+
title: 'Its own worktree (default)',
|
|
41
|
+
value: 'worktree',
|
|
42
|
+
description: 'several specs at once, main left free — one terminal session per spec',
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
title: 'The checkout you are in',
|
|
46
|
+
value: 'checkout',
|
|
47
|
+
description: 'one spec at a time, no second session — your terminal follows the work',
|
|
48
|
+
},
|
|
49
|
+
],
|
|
50
|
+
},
|
|
29
51
|
]
|
|
30
52
|
|
|
31
53
|
const ans = await prompts(questions, { onCancel })
|
|
32
54
|
if (cancelled) throw new Error('Setup cancelled')
|
|
33
55
|
|
|
34
|
-
|
|
56
|
+
// Anything other than an explicit 'checkout' resolves to the default, so a
|
|
57
|
+
// skipped or cancelled-into-default answer can never select the mode that
|
|
58
|
+
// puts a spec's work in the primary checkout.
|
|
59
|
+
return {
|
|
60
|
+
isolation: Boolean(ans.isolation),
|
|
61
|
+
mode: ans.mode === 'checkout' ? 'checkout' : 'worktree',
|
|
62
|
+
}
|
|
35
63
|
}
|
|
36
64
|
|
|
37
65
|
/**
|
|
@@ -61,6 +61,7 @@ const {
|
|
|
61
61
|
CONFIG_FILE,
|
|
62
62
|
LIFECYCLE_BUCKETS,
|
|
63
63
|
releaseStages,
|
|
64
|
+
releaseIgnorePaths,
|
|
64
65
|
stageFor,
|
|
65
66
|
} = require('./config.js')
|
|
66
67
|
const { resolveApiKey, makeApiAdapter, stateIdFor, fetchWorkspaceStates } = require('./api.js')
|
|
@@ -251,7 +252,7 @@ function specSyncPush(dir, config, specArg, flags, out, err) {
|
|
|
251
252
|
function deferredLines(n) {
|
|
252
253
|
return [
|
|
253
254
|
` ${n} phase(s) deferred — mapping.phases is "deferred" and this spec has not started`,
|
|
254
|
-
' they are created on the push that follows /spec-
|
|
255
|
+
' they are created on the push that follows /spec-start',
|
|
255
256
|
]
|
|
256
257
|
}
|
|
257
258
|
|
|
@@ -942,6 +943,38 @@ function unreachableBase(git, range) {
|
|
|
942
943
|
return git(['merge-base', '--is-ancestor', base, head]) === null ? base : null
|
|
943
944
|
}
|
|
944
945
|
|
|
946
|
+
/**
|
|
947
|
+
* The paths each commit in the range changed, as sha → string[], or null when
|
|
948
|
+
* git could not be asked at all.
|
|
949
|
+
*
|
|
950
|
+
* A SECOND `git log` rather than `--name-only` on the first one: that format is
|
|
951
|
+
* NUL-delimited so a body cannot split a record, and appending a file list to
|
|
952
|
+
* the same stream would put one commit's files inside the next commit's record.
|
|
953
|
+
* `-z` also stops git C-quoting a non-ASCII filename (`"specs/\303\251.md"`),
|
|
954
|
+
* which a prefix match would then miss.
|
|
955
|
+
*
|
|
956
|
+
* Returning null on failure is deliberate: the caller leaves every commit's
|
|
957
|
+
* paths unknown, nothing is filtered, and the report is exactly what it was
|
|
958
|
+
* before the filter existed. A read that failed must not be able to empty a
|
|
959
|
+
* release.
|
|
960
|
+
*/
|
|
961
|
+
function readChangedPaths(git, range) {
|
|
962
|
+
const raw = git(['log', '-z', '--format=%x1e%H', '--name-only', range])
|
|
963
|
+
if (raw === null) return null
|
|
964
|
+
const bySha = new Map()
|
|
965
|
+
for (const chunk of raw.split('\x1e')) {
|
|
966
|
+
if (!chunk.trim()) continue
|
|
967
|
+
const [sha, ...names] = chunk.split('\x00')
|
|
968
|
+
const id = String(sha || '').trim()
|
|
969
|
+
if (!id) continue
|
|
970
|
+
bySha.set(
|
|
971
|
+
id,
|
|
972
|
+
names.map((n) => n.replace(/^\n+/, '').trim()).filter(Boolean),
|
|
973
|
+
)
|
|
974
|
+
}
|
|
975
|
+
return bySha
|
|
976
|
+
}
|
|
977
|
+
|
|
945
978
|
/**
|
|
946
979
|
* Resolve a commit range and read it, shared by `released` (which reports on it)
|
|
947
980
|
* and `stage` (which acts on it). Both must agree on what a release contains,
|
|
@@ -1007,6 +1040,14 @@ function readCommitRange(dir, rangeArg, verb) {
|
|
|
1007
1040
|
return { sha, subject, body: rest.join('\x00') }
|
|
1008
1041
|
})
|
|
1009
1042
|
|
|
1043
|
+
// What each commit CHANGED, so bookkeeping can be told from shipped work. A
|
|
1044
|
+
// commit git listed no files for keeps `paths: null` — unknown, never
|
|
1045
|
+
// "changed nothing" — see `onlyIgnoredPaths`.
|
|
1046
|
+
const pathsBySha = readChangedPaths(git, range)
|
|
1047
|
+
for (const commit of commits) {
|
|
1048
|
+
commit.paths = (pathsBySha && pathsBySha.get(commit.sha)) || null
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1010
1051
|
return { range, commits }
|
|
1011
1052
|
}
|
|
1012
1053
|
|
|
@@ -1018,7 +1059,7 @@ async function specSyncReleased(dir, config, rangeArg, flags, out) {
|
|
|
1018
1059
|
}
|
|
1019
1060
|
const { range, commits } = read
|
|
1020
1061
|
|
|
1021
|
-
const report = ticketsInRange(commits)
|
|
1062
|
+
const report = ticketsInRange(commits, { ignorePaths: releaseIgnorePaths(config) })
|
|
1022
1063
|
|
|
1023
1064
|
// Titles are an ENRICHMENT: the scan itself is offline. No key, the MCP
|
|
1024
1065
|
// transport, or a read failure degrades to bare refs — never a failure.
|
|
@@ -1061,6 +1102,12 @@ async function specSyncReleased(dir, config, rangeArg, flags, out) {
|
|
|
1061
1102
|
// MISSED trailer looks identical, so silence here would read as "everything is
|
|
1062
1103
|
// accounted for".
|
|
1063
1104
|
lines.push(` ${report.unreferenced} commit(s) carry no ref`)
|
|
1105
|
+
// A filter that removes commits without saying so reads as "there was nothing
|
|
1106
|
+
// there". Named only when it actually fired — on a project with no paperwork
|
|
1107
|
+
// in the range there is nothing to disclose.
|
|
1108
|
+
if (report.ignored) {
|
|
1109
|
+
lines.push(` ${report.ignored} commit(s) ignored as bookkeeping (release.ignorePaths)`)
|
|
1110
|
+
}
|
|
1064
1111
|
out.write(lines.join('\n') + '\n')
|
|
1065
1112
|
return 0
|
|
1066
1113
|
}
|
|
@@ -1110,7 +1157,7 @@ async function specSyncStage(dir, config, stageKey, rangeArg, flags, out) {
|
|
|
1110
1157
|
}
|
|
1111
1158
|
const { range, commits } = read
|
|
1112
1159
|
|
|
1113
|
-
const report = ticketsInRange(commits)
|
|
1160
|
+
const report = ticketsInRange(commits, { ignorePaths: releaseIgnorePaths(config) })
|
|
1114
1161
|
const teamKey = (config.linear && config.linear.teamKey) || ''
|
|
1115
1162
|
const parts = partitionStageMoves({
|
|
1116
1163
|
tickets: report.tickets,
|
|
@@ -1211,6 +1258,7 @@ async function specSyncStage(dir, config, stageKey, rangeArg, flags, out) {
|
|
|
1211
1258
|
unreadable: unreadable.map((t) => t.ref),
|
|
1212
1259
|
},
|
|
1213
1260
|
unreferencedCommits: report.unreferenced,
|
|
1261
|
+
ignoredCommits: report.ignored,
|
|
1214
1262
|
totalCommits: report.total,
|
|
1215
1263
|
},
|
|
1216
1264
|
null,
|
|
@@ -1248,6 +1296,9 @@ async function specSyncStage(dir, config, stageKey, rangeArg, flags, out) {
|
|
|
1248
1296
|
// legitimately carries no ref and a MISSED trailer looks identical, so silence
|
|
1249
1297
|
// would read as "every commit is accounted for".
|
|
1250
1298
|
lines.push(` ${report.unreferenced} commit(s) carry no ref, of ${report.total}`)
|
|
1299
|
+
if (report.ignored) {
|
|
1300
|
+
lines.push(` ${report.ignored} commit(s) ignored as bookkeeping (release.ignorePaths)`)
|
|
1301
|
+
}
|
|
1251
1302
|
if (!applying) lines.push(' dry run — pass --apply to move them')
|
|
1252
1303
|
out.write(lines.join('\n') + '\n')
|
|
1253
1304
|
return failed.length ? 1 : 0
|
|
@@ -1276,7 +1327,7 @@ async function specSyncStage(dir, config, stageKey, rangeArg, flags, out) {
|
|
|
1276
1327
|
* beats no ref for looking correct). An explicit override cannot be blinded, and
|
|
1277
1328
|
* needs no answer for mixed staging.
|
|
1278
1329
|
*
|
|
1279
|
-
* The branch→spec direction is the INVERSE of what `/spec-
|
|
1330
|
+
* The branch→spec direction is the INVERSE of what `/spec-start` provisions with,
|
|
1280
1331
|
* so it is computed by running `branchFor` over each spec and matching, rather
|
|
1281
1332
|
* than by re-deriving the pattern here. A second implementation of the naming
|
|
1282
1333
|
* rule would drift from the one that created the branch.
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Config loader for the one-way Linear sync feature (`/spec-status`, `/spec-push`
|
|
5
|
-
* and the Linear-aware paths of `/spec` and `/spec-
|
|
5
|
+
* and the Linear-aware paths of `/spec` and `/spec-next`).
|
|
6
6
|
*
|
|
7
7
|
* Reads `specs/.core/linear.config.json` from the project root and normalises it
|
|
8
8
|
* over frozen defaults. The feature is strictly opt-in: when the file is absent
|
|
@@ -92,6 +92,23 @@ const DEFAULT_KEY_ENV = 'LINEAR_API_KEY'
|
|
|
92
92
|
// LIFECYCLE_BUCKETS.
|
|
93
93
|
const DEFAULT_RELEASE_STAGES = Object.freeze([])
|
|
94
94
|
|
|
95
|
+
// Repo-relative path prefixes whose commits are BOOKKEEPING, not shipped work.
|
|
96
|
+
//
|
|
97
|
+
// A spec's `chore(spec): complete <name>` commit carries the same `Refs:`
|
|
98
|
+
// trailer as the code it describes, but lands AFTER the tag that shipped that
|
|
99
|
+
// code — so without this the ticket appears in two consecutive release ranges:
|
|
100
|
+
// once for its code, once for its paperwork. Measured on one consumer, 14 of the
|
|
101
|
+
// 22 ref-carrying commits in 300 were spec bookkeeping, so this is the dominant
|
|
102
|
+
// case rather than an edge one.
|
|
103
|
+
//
|
|
104
|
+
// PATHS, not commit subjects. `chore(spec):` is a convention a mislabelled
|
|
105
|
+
// commit escapes; what a commit changed is a fact. A commit touching an ignored
|
|
106
|
+
// path AND a source file still counts — it shipped code.
|
|
107
|
+
//
|
|
108
|
+
// An explicit `[]` is the opt-out, and a project that keeps its paperwork
|
|
109
|
+
// elsewhere names its own directories here.
|
|
110
|
+
const DEFAULT_RELEASE_IGNORE_PATHS = Object.freeze(['specs/'])
|
|
111
|
+
|
|
95
112
|
const DEFAULT_CONFIG = Object.freeze({
|
|
96
113
|
// `projectId` is the project picker's DEFAULT, not a mandate: `/spec` and the
|
|
97
114
|
// first `/spec-push` offer the team's projects and pre-select this one; empty
|
|
@@ -128,7 +145,9 @@ const DEFAULT_CONFIG = Object.freeze({
|
|
|
128
145
|
// See DEFAULT_RELEASE_STAGES above. `stages` is ordered: the order is recorded
|
|
129
146
|
// for reporting and doctor's ladder check, and deliberately NOT enforced — a
|
|
130
147
|
// rollback from test and a hotfix going straight to prod are both legitimate.
|
|
131
|
-
|
|
148
|
+
// `ignorePaths` (see DEFAULT_RELEASE_IGNORE_PATHS) is what `released`/`stage`
|
|
149
|
+
// treat as bookkeeping rather than shipped work.
|
|
150
|
+
release: Object.freeze({ stages: DEFAULT_RELEASE_STAGES, ignorePaths: DEFAULT_RELEASE_IGNORE_PATHS }),
|
|
132
151
|
branch: Object.freeze({ pattern: '{type}/{slug}' }),
|
|
133
152
|
// `keyEnv` names the env var holding the personal API key. It is a NAME, not a
|
|
134
153
|
// key: putting the secret itself here would commit it.
|
|
@@ -180,7 +199,10 @@ function defaults() {
|
|
|
180
199
|
mapping: { ...DEFAULT_CONFIG.mapping },
|
|
181
200
|
states: { ...DEFAULT_CONFIG.states },
|
|
182
201
|
snapshot: { ...DEFAULT_CONFIG.snapshot },
|
|
183
|
-
release: {
|
|
202
|
+
release: {
|
|
203
|
+
stages: DEFAULT_CONFIG.release.stages.map((s) => ({ ...s })),
|
|
204
|
+
ignorePaths: [...DEFAULT_CONFIG.release.ignorePaths],
|
|
205
|
+
},
|
|
184
206
|
branch: { ...DEFAULT_CONFIG.branch },
|
|
185
207
|
auth: { ...DEFAULT_CONFIG.auth },
|
|
186
208
|
apply: { ...DEFAULT_CONFIG.apply },
|
|
@@ -317,6 +339,35 @@ function mergeReleaseStages(base, parsed) {
|
|
|
317
339
|
base.stages = stages
|
|
318
340
|
}
|
|
319
341
|
|
|
342
|
+
// Merge (and validate) release.ignorePaths — the path prefixes whose commits are
|
|
343
|
+
// bookkeeping. Loud on anything but an array of non-empty strings, like
|
|
344
|
+
// release.stages above: a bad value that quietly fell back to the default would
|
|
345
|
+
// let a project believe it had opted out and go on double-counting tickets.
|
|
346
|
+
//
|
|
347
|
+
// A BLANK entry is rejected rather than dropped. `""` is a prefix of every path,
|
|
348
|
+
// so a stray empty string would silently ignore every commit in the range and
|
|
349
|
+
// report a release as containing nothing — the loudest possible wrong answer,
|
|
350
|
+
// arriving as silence.
|
|
351
|
+
function mergeReleaseIgnorePaths(base, parsed) {
|
|
352
|
+
const value = parsed.ignorePaths
|
|
353
|
+
if (value === undefined) return
|
|
354
|
+
if (!Array.isArray(value)) {
|
|
355
|
+
throw new Error(
|
|
356
|
+
`Invalid ${CONFIG_FILE}: release.ignorePaths = ${JSON.stringify(value)} ` +
|
|
357
|
+
'(expected an array of repo-relative path prefixes)',
|
|
358
|
+
)
|
|
359
|
+
}
|
|
360
|
+
value.forEach((entry, i) => {
|
|
361
|
+
if (typeof entry !== 'string' || !entry.trim()) {
|
|
362
|
+
throw new Error(
|
|
363
|
+
`Invalid ${CONFIG_FILE}: release.ignorePaths[${i}] = ${JSON.stringify(entry)} ` +
|
|
364
|
+
'(expected a non-empty repo-relative path prefix, e.g. "specs/")',
|
|
365
|
+
)
|
|
366
|
+
}
|
|
367
|
+
})
|
|
368
|
+
base.ignorePaths = stringList(value)
|
|
369
|
+
}
|
|
370
|
+
|
|
320
371
|
// Merge (and validate) sync.keyedFields. Each value is the item's id property
|
|
321
372
|
// name (a non-empty string); a field listed here is compared per item.
|
|
322
373
|
function mergeKeyedFields(base, parsed) {
|
|
@@ -382,6 +433,7 @@ function mergeConfig(base, parsed) {
|
|
|
382
433
|
|
|
383
434
|
if (isObject(parsed.release)) {
|
|
384
435
|
mergeReleaseStages(base.release, parsed.release)
|
|
436
|
+
mergeReleaseIgnorePaths(base.release, parsed.release)
|
|
385
437
|
}
|
|
386
438
|
|
|
387
439
|
if (isObject(parsed.branch)) {
|
|
@@ -462,9 +514,23 @@ function stageFor(config, key) {
|
|
|
462
514
|
return releaseStages(config).find((s) => s.key === key) || null
|
|
463
515
|
}
|
|
464
516
|
|
|
517
|
+
/**
|
|
518
|
+
* The path prefixes whose commits are bookkeeping, always an array.
|
|
519
|
+
*
|
|
520
|
+
* A config object that predates the field gets the DEFAULT — the list
|
|
521
|
+
* `loadLinearConfig` would have produced — rather than an empty one, so an older
|
|
522
|
+
* object cannot quietly turn the filter off. An explicit `[]` survives
|
|
523
|
+
* `Array.isArray` and is honoured as the opt-out.
|
|
524
|
+
*/
|
|
525
|
+
function releaseIgnorePaths(config) {
|
|
526
|
+
const paths = config && config.release && config.release.ignorePaths
|
|
527
|
+
return Array.isArray(paths) ? paths : [...DEFAULT_RELEASE_IGNORE_PATHS]
|
|
528
|
+
}
|
|
529
|
+
|
|
465
530
|
module.exports = {
|
|
466
531
|
loadLinearConfig,
|
|
467
532
|
releaseStages,
|
|
533
|
+
releaseIgnorePaths,
|
|
468
534
|
stageFor,
|
|
469
535
|
mergeConfig,
|
|
470
536
|
defaults,
|
|
@@ -476,4 +542,5 @@ module.exports = {
|
|
|
476
542
|
LIFECYCLE_BUCKETS,
|
|
477
543
|
TRANSPORTS,
|
|
478
544
|
DEFAULT_KEY_ENV,
|
|
545
|
+
DEFAULT_RELEASE_IGNORE_PATHS,
|
|
479
546
|
}
|
|
@@ -51,18 +51,77 @@ function refsInBody(body) {
|
|
|
51
51
|
return found
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Whether a commit's changed paths are ENTIRELY bookkeeping.
|
|
56
|
+
*
|
|
57
|
+
* The problem it solves: a spec's `chore(spec): complete <name>` commit carries
|
|
58
|
+
* the same `Refs:` trailer as the code it describes, but lands after the tag
|
|
59
|
+
* that shipped that code — so the ticket turns up in two consecutive release
|
|
60
|
+
* ranges, once for its code and once for its paperwork.
|
|
61
|
+
*
|
|
62
|
+
* Deliberately a POSITIVE signal: it says yes only when it actually saw paths
|
|
63
|
+
* and every one of them sits under an ignored prefix. It never reasons from an
|
|
64
|
+
* absence.
|
|
65
|
+
*
|
|
66
|
+
* WHAT WOULD FOOL THE OTHER PHRASING ("no unignored path was found"): git lists
|
|
67
|
+
* no files at all for a MERGE commit (without `-m`) — verified, not assumed —
|
|
68
|
+
* nor for a genuinely empty one, and a `git log` that failed outright yields no
|
|
69
|
+
* paths for anything. Under that phrasing every one of those becomes a release
|
|
70
|
+
* silently losing its tickets. Under this one they mean "nothing was seen, so
|
|
71
|
+
* nothing is known", which keeps the pre-filter behaviour: an over-claimed
|
|
72
|
+
* ticket is noticed when someone looks for it, whereas a ticket that quietly
|
|
73
|
+
* belongs to no release never is.
|
|
74
|
+
*
|
|
75
|
+
* Matching is path-prefix, not glob: `specs` and `specs/` both mean the
|
|
76
|
+
* directory, and a prefix only matches on a path SEGMENT boundary, so `specs/`
|
|
77
|
+
* never swallows `specs-archive/`.
|
|
78
|
+
*
|
|
79
|
+
* @param {string[]|null|undefined} paths repo-relative paths the commit changed
|
|
80
|
+
* @param {string[]} ignorePaths repo-relative prefixes that are bookkeeping
|
|
81
|
+
*/
|
|
82
|
+
function onlyIgnoredPaths(paths, ignorePaths) {
|
|
83
|
+
if (!Array.isArray(paths) || !paths.length) return false
|
|
84
|
+
const prefixes = (Array.isArray(ignorePaths) ? ignorePaths : [])
|
|
85
|
+
.filter((p) => typeof p === 'string' && p.trim())
|
|
86
|
+
.map((p) => p.trim().replace(/^\.\//, '').replace(/\/+$/, ''))
|
|
87
|
+
.filter(Boolean)
|
|
88
|
+
if (!prefixes.length) return false
|
|
89
|
+
return paths.every((raw) => {
|
|
90
|
+
const file = String(raw == null ? '' : raw)
|
|
91
|
+
.trim()
|
|
92
|
+
.replace(/^\.\//, '')
|
|
93
|
+
// An unreadable entry is an unknown, not an ignored one — it makes the whole
|
|
94
|
+
// commit count, per the bias above.
|
|
95
|
+
if (!file) return false
|
|
96
|
+
return prefixes.some((prefix) => file === prefix || file.startsWith(`${prefix}/`))
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
|
|
54
100
|
/**
|
|
55
101
|
* Fold commits into the report a release needs.
|
|
56
102
|
*
|
|
57
|
-
* @param {Array<{sha?:string, subject?:string, body?:string}>} commits
|
|
58
|
-
* @
|
|
103
|
+
* @param {Array<{sha?:string, subject?:string, body?:string, paths?:string[]}>} commits
|
|
104
|
+
* @param {{ignorePaths?:string[]}} [options] `ignorePaths` marks bookkeeping —
|
|
105
|
+
* see `onlyIgnoredPaths`. Omitted (or empty) means nothing is ignored, so the
|
|
106
|
+
* report is what it was before the filter existed.
|
|
107
|
+
* @returns {{tickets: Array<{ref:string, commits:number}>, unreferenced:number, ignored:number, total:number}}
|
|
59
108
|
* `tickets` is deduped in FIRST-SEEN order: a ticket touched by eight commits
|
|
60
|
-
* is listed once, where it first appears, not eight times.
|
|
109
|
+
* is listed once, where it first appears, not eight times. `ignored` is
|
|
110
|
+
* reported rather than merely subtracted — a filter that removes commits in
|
|
111
|
+
* silence reads as "there was nothing there".
|
|
61
112
|
*/
|
|
62
|
-
function ticketsInRange(commits) {
|
|
113
|
+
function ticketsInRange(commits, options = {}) {
|
|
114
|
+
const ignorePaths = (options && options.ignorePaths) || []
|
|
63
115
|
const seen = new Map()
|
|
64
116
|
let unreferenced = 0
|
|
117
|
+
let ignored = 0
|
|
65
118
|
for (const commit of commits || []) {
|
|
119
|
+
if (onlyIgnoredPaths(commit && commit.paths, ignorePaths)) {
|
|
120
|
+
// Not counted as unreferenced either: that number exists to surface a
|
|
121
|
+
// MISSED trailer, and a paperwork commit is not a gap someone should hunt.
|
|
122
|
+
ignored++
|
|
123
|
+
continue
|
|
124
|
+
}
|
|
66
125
|
const refs = [...new Set(refsInBody(commit && commit.body))]
|
|
67
126
|
if (!refs.length) {
|
|
68
127
|
unreferenced++
|
|
@@ -73,6 +132,7 @@ function ticketsInRange(commits) {
|
|
|
73
132
|
return {
|
|
74
133
|
tickets: [...seen.entries()].map(([ref, count]) => ({ ref, commits: count })),
|
|
75
134
|
unreferenced,
|
|
135
|
+
ignored,
|
|
76
136
|
total: (commits || []).length,
|
|
77
137
|
}
|
|
78
138
|
}
|
|
@@ -161,4 +221,4 @@ function stageOrderWarning(stages, fromState, toKey, lifecycleStates = []) {
|
|
|
161
221
|
return null
|
|
162
222
|
}
|
|
163
223
|
|
|
164
|
-
module.exports = { ticketsInRange, refsInBody, partitionStageMoves, stageOrderWarning }
|
|
224
|
+
module.exports = { ticketsInRange, refsInBody, onlyIgnoredPaths, partitionStageMoves, stageOrderWarning }
|