@skitterbyte/skitterspec-linear 10.7.0 → 10.8.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.
@@ -1,6 +1,6 @@
1
1
  ---
2
- description: Put one spec live on the already-running dev server (take | release | abort | status)
3
- argument-hint: "[take <spec> | release | abort | status]"
2
+ description: Put one spec live on the already-running dev server — `<spec>` takes it, a bare `take` takes the spec you are on, `main` hands the instance back
3
+ argument-hint: "[<spec> | main | take | release | abort | status]"
4
4
  allowed-tools: Bash({{exec}} skitterspec spec-env live:*)
5
5
  disable-model-invocation: true
6
6
  ---
@@ -24,7 +24,8 @@
24
24
  "overviewFile": "00-overview.md"
25
25
  },
26
26
  "release": {
27
- "stages": []
27
+ "stages": [],
28
+ "ignorePaths": ["specs/"]
28
29
  },
29
30
  "branch": {
30
31
  "pattern": "{type}/{slug}"
@@ -88,8 +88,12 @@ absence). A `sync.fieldOwnership` value outside `both|pull|push` is a hard error
88
88
  // The project's OWN deployment ladder — where a ticket goes AFTER its spec is
89
89
  // complete. Empty (the default) means no ladder is declared and every
90
90
  // stage-aware path is unused. See "The deployment ladder" below.
91
+ //
92
+ // `ignorePaths` is what `released`/`stage` treat as BOOKKEEPING rather than
93
+ // shipped work — see "Bookkeeping commits" below. `[]` opts out.
91
94
  "release": {
92
- "stages": []
95
+ "stages": [],
96
+ "ignorePaths": ["specs/"]
93
97
  },
94
98
 
95
99
  // Git branch name derived for a linked spec. Tokens: {type}, {slug},
@@ -168,6 +172,44 @@ Run `skitterspec spec-sync states` to see the whole configured vocabulary — th
168
172
  bucket map and the ladder — against what the workspace actually has.
169
173
 
170
174
 
175
+ ## Bookkeeping commits (`release.ignorePaths`)
176
+
177
+ `spec-sync released` and `spec-sync stage` answer "what did this release
178
+ contain?" from the `Refs:` trailers in the range. Left alone, that over-answers:
179
+ a spec's `chore(spec): complete <name>` commit carries the **same ref** as the
180
+ code it describes, but lands **after** the tag that shipped that code — so the
181
+ ticket turns up in two consecutive release ranges, once for its code and once
182
+ for its paperwork. Downstream, a deployment ladder then drags an issue that had
183
+ already reached the top back down a rung.
184
+
185
+ `release.ignorePaths` names the repo-relative prefixes that are paperwork:
186
+
187
+ ```jsonc
188
+ "release": {
189
+ "ignorePaths": ["specs/"]
190
+ }
191
+ ```
192
+
193
+ - **Paths, not subjects.** `chore(spec):` is a convention a mislabelled commit
194
+ escapes; what a commit changed is a fact. A commit touching an ignored path
195
+ **and** a source file still counts — it shipped code.
196
+ - **Prefixes, not globs.** `specs` and `specs/` both mean the directory, and a
197
+ prefix matches only on a path boundary, so `specs/` never swallows
198
+ `specs-archive/`. Name a single file to ignore just that file.
199
+ - **Only a commit whose paths are ALL ignored is dropped**, and only when git
200
+ actually listed paths for it. A merge commit (for which git lists none) counts,
201
+ as does every commit if the path read fails — a lookup that saw nothing knows
202
+ nothing, and dropping a ticket nobody notices is worse than the double-count
203
+ this is fixing.
204
+ - **Every drop is disclosed.** The report says `N commit(s) ignored as
205
+ bookkeeping`, and `--json` carries `ignored` / `ignoredCommits`.
206
+ - **`[]` is the opt-out**; a blank entry is a hard error, since `""` is a prefix
207
+ of every path and would silently empty the release.
208
+
209
+ Default: `["specs/"]`. A project that keeps its paperwork elsewhere names its own
210
+ directories instead.
211
+
212
+
171
213
  ## Spec → Issue, phases → sub-issues
172
214
 
173
215
  Push maps the spec's structure to Linear's, keyed by id so it updates rather than
@@ -55,7 +55,8 @@ your canonical `localhost` ports so you can test it at the normal URL
55
55
  move, header edits, the code) happens on the spec's branch in the worktree; `main`
56
56
  changes only when it merges. Teardown is folded into `/spec-complete` ·
57
57
  `/spec-cancel`. Beneath the skills, `skitterspec spec-env
58
- <up|down|prune|dev|connect|integrate|hotfix>` is the CLI engine. Teardown drops
58
+ <up|down|prune|dev|connect|integrate|hotfix|live|status|resolve>` is the CLI
59
+ engine (omit the spec name and it uses the worktree you are standing in). Teardown drops
59
60
  the finished spec's own test-DB volume; `spec-env prune` additionally reaps
60
61
  **orphaned** volumes left by declined/aborted teardowns, so `/spec-complete` and
61
62
  `/spec-cancel` also sweep orphans (confirm-first). A **hotfix** is the one
@@ -312,6 +312,38 @@ and **never auto-push git** — Linear's own automation reacts to real branch/PR
312
312
  events later. Report the Linear issue URL as part of the skill's finish-up
313
313
  message.
314
314
 
315
+ ### Picking the Linear Project
316
+
317
+ Run this **only when minting a spec issue** — creating it for the first time. On
318
+ an update the issue already has a project (or deliberately has none), and that
319
+ placement is **Linear's to own**: never send `project` on an update, and never
320
+ record the choice in the spec file or the snapshot. A PM re-homing a spec issue
321
+ must not show up as drift or be overwritten on the next push.
322
+
323
+ 1. **List the candidates.** Ask the engine:
324
+ `skitterspec spec-sync projects --json`. On the API path it returns the team's
325
+ projects; on the MCP path it says so, and you call the discovered project-list
326
+ tool instead. Drop archived / completed projects — they can't take new work.
327
+ 2. **Offer them.** Show the names (most recently updated first is fine), plus an
328
+ explicit **None (team only)** option. Pre-select `linear.projectId` from
329
+ `linear.config.json` when it's set and still in the list; otherwise pre-select
330
+ **None**.
331
+ 3. **Narrow on request.** If the user types a fragment rather than choosing, filter
332
+ the list case-insensitively by name and re-offer. Don't re-fetch.
333
+ 4. **Never offer to create a project.** Projects are the PM's surface — if none
334
+ fits, that's **None (team only)**, and someone makes the project in Linear.
335
+ 5. **Pass it once**, to whichever thing mints the issue: `--project <chosen id>`
336
+ on `spec-sync apply`, or `project: <chosen id>` on the MCP issue-create call.
337
+ Chose None → omit it entirely (do not pass an empty string).
338
+
339
+ **Degrade, never block.** If the list can't be fetched — Linear not connected, no
340
+ project-list tool, no API key, or `spec-sync projects` reporting it couldn't ask —
341
+ say so in one line — *"project picker unavailable"* — and carry on with
342
+ `linear.projectId` if it's set, else no project at all. A missing picker must never fail the skill that called it.
343
+
344
+ If `linear.projectId` is set but that Project is archived or missing, relay
345
+ Linear's error and stop rather than silently minting an unparented issue.
346
+
315
347
  ## 5. Drive to GREEN
316
348
 
317
349
  - Implement the **minimal, root-cause** fix. Match surrounding code; honour all
@@ -323,6 +323,38 @@ and **never auto-push git** — Linear's own automation reacts to real branch/PR
323
323
  events later. Report the Linear issue URL as part of the skill's finish-up
324
324
  message.
325
325
 
326
+ ### Picking the Linear Project
327
+
328
+ Run this **only when minting a spec issue** — creating it for the first time. On
329
+ an update the issue already has a project (or deliberately has none), and that
330
+ placement is **Linear's to own**: never send `project` on an update, and never
331
+ record the choice in the spec file or the snapshot. A PM re-homing a spec issue
332
+ must not show up as drift or be overwritten on the next push.
333
+
334
+ 1. **List the candidates.** Ask the engine:
335
+ `skitterspec spec-sync projects --json`. On the API path it returns the team's
336
+ projects; on the MCP path it says so, and you call the discovered project-list
337
+ tool instead. Drop archived / completed projects — they can't take new work.
338
+ 2. **Offer them.** Show the names (most recently updated first is fine), plus an
339
+ explicit **None (team only)** option. Pre-select `linear.projectId` from
340
+ `linear.config.json` when it's set and still in the list; otherwise pre-select
341
+ **None**.
342
+ 3. **Narrow on request.** If the user types a fragment rather than choosing, filter
343
+ the list case-insensitively by name and re-offer. Don't re-fetch.
344
+ 4. **Never offer to create a project.** Projects are the PM's surface — if none
345
+ fits, that's **None (team only)**, and someone makes the project in Linear.
346
+ 5. **Pass it once**, to whichever thing mints the issue: `--project <chosen id>`
347
+ on `spec-sync apply`, or `project: <chosen id>` on the MCP issue-create call.
348
+ Chose None → omit it entirely (do not pass an empty string).
349
+
350
+ **Degrade, never block.** If the list can't be fetched — Linear not connected, no
351
+ project-list tool, no API key, or `spec-sync projects` reporting it couldn't ask —
352
+ say so in one line — *"project picker unavailable"* — and carry on with
353
+ `linear.projectId` if it's set, else no project at all. A missing picker must never fail the skill that called it.
354
+
355
+ If `linear.projectId` is set but that Project is archived or missing, relay
356
+ Linear's error and stop rather than silently minting an unparented issue.
357
+
326
358
  ## 6. Drive to GREEN
327
359
 
328
360
  - Implement the **minimal, root-cause** fix on the branch. Match surrounding code;
@@ -156,6 +156,14 @@ Relay the **unreferenced count** even when it is zero. A chore commit
156
156
  legitimately carries no ticket and a *missed* trailer looks identical, so
157
157
  omitting the number reads as "everything is accounted for" when it may not be.
158
158
 
159
+ **Bookkeeping commits are excluded.** A spec's `chore(spec): complete <name>`
160
+ commit carries the same ref as the code it describes but lands *after* the tag
161
+ that shipped it, so counting it would put the ticket in two consecutive releases.
162
+ A commit whose changed paths are **all** under `release.ignorePaths` (default
163
+ `specs/`) contributes nothing; one that touches an ignored path *and* a source
164
+ file still counts. Relay the **ignored count** whenever it is non-zero — that is
165
+ the report saying which commits it set aside.
166
+
159
167
  ## 8. `retarget` — after a Linear team is renamed
160
168
 
161
169
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skitterbyte/skitterspec-linear",
3
- "version": "10.7.0",
3
+ "version": "10.8.0",
4
4
  "description": "Spec-driven development for Claude Code, with one-way Linear sync — a superset of @skitterbyte/skitterspec: the base filesystem workflow plus /spec-status · /spec-push and the spec-sync CLI. The repo is canonical; Linear is a generated mirror. Install this OR the base, not both.",
5
5
  "keywords": [
6
6
  "claude",
package/src/cli.js CHANGED
@@ -1151,25 +1151,53 @@ const DEPS_RE = /(^|\/)(package\.json|pnpm-lock\.yaml|package-lock\.json|yarn\.l
1151
1151
  // receipt is advisory metadata. `status` is read-only; `take` performs the switch
1152
1152
  // (release/abort land in a later phase).
1153
1153
  async function specEnvLive(dir, config, positional) {
1154
- const action = positional[0] || 'status'
1154
+ const { action, specArg } = liveGrammar(dir, config, positional)
1155
1155
  switch (action) {
1156
1156
  case 'status':
1157
- specEnvLiveStatus(dir, config, positional[1])
1157
+ specEnvLiveStatus(dir, config, specArg)
1158
1158
  break
1159
1159
  case 'take':
1160
- await specEnvLiveTake(dir, config, positional[1])
1160
+ await specEnvLiveTake(dir, config, specArg)
1161
1161
  break
1162
1162
  case 'release':
1163
- await specEnvLiveRelease(dir, config, positional[1])
1163
+ await specEnvLiveRelease(dir, config, specArg)
1164
1164
  break
1165
1165
  case 'abort':
1166
1166
  await specEnvLiveAbort(dir, config)
1167
1167
  break
1168
1168
  default:
1169
- process.stdout.write('Usage: skitterspec spec-env live <take|release|abort|status> [spec]\n')
1169
+ process.stdout.write(
1170
+ 'Usage: skitterspec spec-env live <spec>|<base branch>|<take|release|abort|status> [spec]\n',
1171
+ )
1170
1172
  }
1171
1173
  }
1172
1174
 
1175
+ const LIVE_VERBS = new Set(['status', 'take', 'release', 'abort'])
1176
+
1177
+ // The two front doors every doc names — `/spec-live <spec>` and `/spec-live main`
1178
+ // — translated to verbs. They live here rather than in the command because
1179
+ // `.claude/commands/spec-live.md` relays `$ARGUMENTS` untranslated (it is a
1180
+ // pre-executed script, with no model turn to rewrite them); the skill this
1181
+ // replaced did the translation itself, which is how these forms came to be
1182
+ // documented but unimplemented. `connect` needs no equivalent — its argument was
1183
+ // always spec-shaped (`specArg || 'main'`).
1184
+ //
1185
+ // VERB PRECEDENCE IS DELIBERATE, and so is the order below: the four verbs and
1186
+ // the base branch are matched BEFORE the spec-name fallback, so a spec folder
1187
+ // that happens to be called `status` cannot silently branch-switch the primary
1188
+ // checkout. Such a spec is still reachable — as `live take status`. The literal
1189
+ // `main` is honoured even where the base branch is named something else,
1190
+ // matching `connect main`, so the muscle memory works in either repo.
1191
+ function liveGrammar(dir, config, positional) {
1192
+ const [first, second] = positional
1193
+ if (!first) return { action: 'status', specArg: undefined }
1194
+ if (LIVE_VERBS.has(first)) return { action: first, specArg: second }
1195
+ if (first === 'main' || first === resolveBaseBranch(config, gitReader(dir))) {
1196
+ return { action: 'release', specArg: undefined }
1197
+ }
1198
+ return { action: 'take', specArg: first }
1199
+ }
1200
+
1173
1201
  // Take the running instance: rebase the spec's branch onto base, free it from its
1174
1202
  // worktree, and check it out in the primary checkout so the dev server reloads it.
1175
1203
  async function specEnvLiveTake(dir, config, specArg) {
@@ -1466,7 +1494,9 @@ async function specEnv(rest) {
1466
1494
  ' [spec] is optional for up/down/dev/integrate/hotfix/resolve and live take:\n' +
1467
1495
  ' omit it and the sole provisioned spec is used (several -> it lists them).\n' +
1468
1496
  ' NOTE connect and live status keep their own meaning for a missing spec:\n' +
1469
- ' connect disconnects (= main), live status reports on the whole repo.\n',
1497
+ ' connect disconnects (= main), live status reports on the whole repo.\n' +
1498
+ ' connect and live also take a bare spec name: `live <spec>` takes the\n' +
1499
+ ' instance, `live main` (or your base branch) hands it back.\n',
1470
1500
  )
1471
1501
  }
1472
1502
  }
@@ -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')
@@ -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
@@ -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
- release: Object.freeze({ stages: DEFAULT_RELEASE_STAGES }),
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: { stages: DEFAULT_CONFIG.release.stages.map((s) => ({ ...s })) },
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
- * @returns {{tickets: Array<{ref:string, commits:number}>, unreferenced:number, total:number}}
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 }