@skitterbyte/skitterspec-jira 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/MIGRATION.md +1089 -0
- package/README.md +42 -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 +554 -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 +61 -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 +692 -0
- package/assets/rules/spec-reports.md +557 -0
- package/assets/skills/no-spec/SKILL.md +246 -0
- package/assets/skills/spec/SKILL.md +714 -0
- package/assets/skills/spec-bug/SKILL.md +583 -0
- package/assets/skills/spec-cancel/SKILL.md +261 -0
- package/assets/skills/spec-complete/SKILL.md +448 -0
- package/assets/skills/spec-diff/SKILL.md +870 -0
- package/assets/skills/spec-hotfix/SKILL.md +605 -0
- package/assets/skills/spec-init/SKILL.md +178 -0
- package/assets/skills/spec-jira-setup/SKILL.md +220 -0
- package/assets/skills/spec-list/SKILL.md +173 -0
- package/assets/skills/spec-next/SKILL.md +676 -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 +112 -0
- package/assets/skills/spec-sync/SKILL.md +218 -0
- package/assets/skills/spec-to-main/SKILL.md +139 -0
- package/bin/skitterspec-jira.js +61 -0
- package/package.json +40 -4
- package/src/cli.js +5901 -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 +753 -0
- package/src/env/dev.js +63 -0
- package/src/env/hooks.js +226 -0
- package/src/env/hotfix.js +385 -0
- package/src/env/hotfixland.js +302 -0
- package/src/env/integrate.js +126 -0
- package/src/env/live.js +504 -0
- package/src/env/mainguard.js +299 -0
- package/src/env/provision.js +466 -0
- package/src/env/proxy.js +192 -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 +2577 -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 +1256 -0
- package/src/vendor/jira/commands.js +44 -0
- package/src/vendor/jira/config.js +296 -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 +197 -0
- package/src/vendor/provider-kit/src/args.js +68 -0
- package/src/vendor/provider-kit/src/config.js +198 -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 +44 -0
- package/src/vendor/provider-kit/src/identity.js +93 -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 +226 -0
- package/src/vendor/provider-kit/src/specs.js +166 -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 +306 -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 +989 -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
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Flatten markdown tables that sit INSIDE a list item, because both tracker
|
|
5
|
+
* dialects need it: Linear's renderer corrupts nested-table data cells
|
|
6
|
+
* (measured on probe SKI-28, 2026-08-28 — every data cell loses its first N
|
|
7
|
+
* characters, N being the rendered list indent; the auth header
|
|
8
|
+
* `X-Extraction-Key` was stored as `Extraction-Key`), and ADF's `listItem`
|
|
9
|
+
* cannot contain a `table` node at all, so a Jira render would drop or mangle
|
|
10
|
+
* it. The re-emitted shapes are ones proven to survive nesting unchanged:
|
|
11
|
+
*
|
|
12
|
+
* - 2 columns → a bullet list (`- a — b`), the key/value case
|
|
13
|
+
* - otherwise → a fenced code block wrapping the original rows verbatim
|
|
14
|
+
*
|
|
15
|
+
* This shapes the PROJECTION only. Repo files are never rewritten: the source
|
|
16
|
+
* markdown is valid and renders correctly in GitHub and every editor.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const { fenceMask } = require('../../sync-core/src/task-block.js')
|
|
20
|
+
|
|
21
|
+
// A table row: optional indent, then a `|`-delimited line. We only ever act on
|
|
22
|
+
// indented ones — a column-0 table (the `## Phases` index, every Impact map) is
|
|
23
|
+
// rendered correctly by Linear and must project byte-identically.
|
|
24
|
+
const ROW_RE = /^([ \t]+)\|(.*)\|[ \t]*$/
|
|
25
|
+
// The separator under the header — `|---|:--:|`. Its presence is what makes the
|
|
26
|
+
// block a table rather than prose that happens to contain pipes.
|
|
27
|
+
const SEPARATOR_RE = /^[ \t]+\|[\s:|-]+\|[ \t]*$/
|
|
28
|
+
|
|
29
|
+
// Split a row into cells on pipes that are OUTSIDE an inline-code span, so a
|
|
30
|
+
// documented `` `a | b` `` alternation stays one cell instead of splitting.
|
|
31
|
+
function splitCells(body) {
|
|
32
|
+
const cells = []
|
|
33
|
+
let cur = ''
|
|
34
|
+
let code = false
|
|
35
|
+
for (const ch of body) {
|
|
36
|
+
if (ch === '`') code = !code
|
|
37
|
+
if (ch === '|' && !code) {
|
|
38
|
+
cells.push(cur.trim())
|
|
39
|
+
cur = ''
|
|
40
|
+
continue
|
|
41
|
+
}
|
|
42
|
+
cur += ch
|
|
43
|
+
}
|
|
44
|
+
cells.push(cur.trim())
|
|
45
|
+
return cells
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Rewrite every indented table in `md`. Returns the text unchanged when there is
|
|
50
|
+
* nothing nested to flatten.
|
|
51
|
+
* @param {string} md
|
|
52
|
+
* @returns {string}
|
|
53
|
+
*/
|
|
54
|
+
function flattenNestedTables(md) {
|
|
55
|
+
if (md == null) return md
|
|
56
|
+
const lines = String(md).split('\n')
|
|
57
|
+
const inFence = fenceMask(lines)
|
|
58
|
+
const out = []
|
|
59
|
+
|
|
60
|
+
for (let i = 0; i < lines.length; i++) {
|
|
61
|
+
const header = ROW_RE.exec(lines[i])
|
|
62
|
+
// A table shown as an EXAMPLE inside a ``` block is documentation — often of
|
|
63
|
+
// this very bug — so it is left exactly as written.
|
|
64
|
+
if (!header || inFence[i] || !SEPARATOR_RE.test(lines[i + 1] || '')) {
|
|
65
|
+
out.push(lines[i])
|
|
66
|
+
continue
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const indent = header[1]
|
|
70
|
+
const rows = [splitCells(header[2])]
|
|
71
|
+
const raw = [lines[i], lines[i + 1]]
|
|
72
|
+
let j = i + 2
|
|
73
|
+
for (; j < lines.length && !inFence[j]; j++) {
|
|
74
|
+
const row = ROW_RE.exec(lines[j])
|
|
75
|
+
if (!row) break
|
|
76
|
+
rows.push(splitCells(row[2]))
|
|
77
|
+
raw.push(lines[j])
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (rows[0].length === 2) {
|
|
81
|
+
// Header first, bolded: dropping it would lose content and inventing a
|
|
82
|
+
// caption would invent it.
|
|
83
|
+
out.push(`${indent}- **${rows[0][0]}** — **${rows[0][1]}**`)
|
|
84
|
+
for (const r of rows.slice(1)) out.push(`${indent}- ${r[0]} — ${r[1]}`)
|
|
85
|
+
} else {
|
|
86
|
+
out.push(`${indent}\`\`\``, ...raw, `${indent}\`\`\``)
|
|
87
|
+
}
|
|
88
|
+
i = j - 1
|
|
89
|
+
}
|
|
90
|
+
return out.join('\n')
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
module.exports = { flattenNestedTables, splitCells }
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Terminal input helpers for a credentials CLI — reading a piped secret, and
|
|
5
|
+
* prompting for one with the echo off.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// Read stdin to completion (for `--stdin`).
|
|
9
|
+
function readAllStdin(input) {
|
|
10
|
+
return new Promise((resolve, reject) => {
|
|
11
|
+
let data = ''
|
|
12
|
+
input.setEncoding('utf-8')
|
|
13
|
+
input.on('data', (chunk) => (data += chunk))
|
|
14
|
+
input.on('end', () => resolve(data))
|
|
15
|
+
input.on('error', reject)
|
|
16
|
+
})
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Prompt on a TTY with the input hidden. `_writeToOutput` is readline's own echo
|
|
20
|
+
// hook — filtering it is what keeps the key off the screen (and out of a
|
|
21
|
+
// screen-shared terminal or a recorded session).
|
|
22
|
+
//
|
|
23
|
+
// READLINE OWNS THE PROMPT, deliberately. Writing it ourselves and then starting
|
|
24
|
+
// the interface loses it: readline clears from the cursor to the end of the
|
|
25
|
+
// screen (`ESC[0J`) before its first redraw, so the prompt was wiped the instant
|
|
26
|
+
// it appeared and the user was left staring at a blank line while the process
|
|
27
|
+
// waited for a key — indistinguishable from a hang.
|
|
28
|
+
//
|
|
29
|
+
// The hook is assigned BEFORE `question`, so the very first redraw goes through
|
|
30
|
+
// it. readline hands it `prompt + what has been typed`; re-writing only the
|
|
31
|
+
// prompt is what keeps the key hidden while the prompt survives every redraw.
|
|
32
|
+
function promptHidden(question, input, out) {
|
|
33
|
+
const readline = require('node:readline')
|
|
34
|
+
return new Promise((resolve) => {
|
|
35
|
+
// `out`, never `process.stdout`: they are the same stream in production, and
|
|
36
|
+
// hardcoding one half meant readline cleared a screen the prompt had not
|
|
37
|
+
// been written to under test — the split that hid this bug from the suite.
|
|
38
|
+
const rl = readline.createInterface({ input, output: out, terminal: true })
|
|
39
|
+
rl._writeToOutput = (s) => {
|
|
40
|
+
if (s.includes(question)) out.write(question)
|
|
41
|
+
}
|
|
42
|
+
rl.question(question, (answer) => {
|
|
43
|
+
out.write('\n')
|
|
44
|
+
rl.close()
|
|
45
|
+
resolve(answer)
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
module.exports = { readAllStdin, promptHidden }
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Which tickets a release contains.
|
|
5
|
+
*
|
|
6
|
+
* The repo lands specs with `merge --ff-only`, so history is linear and branch
|
|
7
|
+
* names never reach it. A commit's `Refs:` trailer is therefore the only place
|
|
8
|
+
* the ticket survives into the range a release scans — see the
|
|
9
|
+
* `commit-trailers` rule. Nothing here is tracker-specific: the identifier
|
|
10
|
+
* shape (`KEY-123`) and the team-key prefix are common to the trackers this
|
|
11
|
+
* scans for, which is why the module lives in the kit.
|
|
12
|
+
*
|
|
13
|
+
* The parsing half is pure and takes commits as data, so the rules below are
|
|
14
|
+
* testable without git. Reading the range, and enriching refs with issue titles,
|
|
15
|
+
* belong to the caller.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
// An issue identifier: uppercase team key, dash, number.
|
|
19
|
+
const IDENTIFIER_RE = /\b[A-Z][A-Z0-9]*-\d+\b/g
|
|
20
|
+
// A `Refs:` trailer line. Anchored, so it is a line of its own — never prose
|
|
21
|
+
// that happens to mention the word.
|
|
22
|
+
const TRAILER_RE = /^Refs:[ \t]*(.+?)[ \t]*$/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The refs a single commit body claims.
|
|
26
|
+
*
|
|
27
|
+
* Deliberately blind to three things that LOOK like trailers and are not:
|
|
28
|
+
*
|
|
29
|
+
* - a line inside a ``` fence — a commit explaining the convention quotes it;
|
|
30
|
+
* - a quoted line (`> Refs: …`) — same reason, in review replies;
|
|
31
|
+
* - an indented line (4+ spaces) — markdown code, e.g. a sample message.
|
|
32
|
+
*
|
|
33
|
+
* Counting any of those would make a release claim work it does not contain,
|
|
34
|
+
* which is worse than missing a ticket: the missing one is noticed when someone
|
|
35
|
+
* looks for it, the invented one never is.
|
|
36
|
+
*/
|
|
37
|
+
function refsInBody(body) {
|
|
38
|
+
const found = []
|
|
39
|
+
let fenced = false
|
|
40
|
+
for (const raw of String(body || '').split('\n')) {
|
|
41
|
+
const line = raw.replace(/\r$/, '')
|
|
42
|
+
if (/^\s*```/.test(line)) {
|
|
43
|
+
fenced = !fenced
|
|
44
|
+
continue
|
|
45
|
+
}
|
|
46
|
+
if (fenced) continue
|
|
47
|
+
if (/^\s*>/.test(line)) continue
|
|
48
|
+
if (/^ {4,}|\t/.test(line)) continue
|
|
49
|
+
const m = TRAILER_RE.exec(line.trim() === line ? line : line.trimStart())
|
|
50
|
+
if (!m) continue
|
|
51
|
+
for (const id of m[1].match(IDENTIFIER_RE) || []) found.push(id)
|
|
52
|
+
}
|
|
53
|
+
return found
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Whether a commit's changed paths are ENTIRELY bookkeeping.
|
|
58
|
+
*
|
|
59
|
+
* The problem it solves: a spec's `chore(spec): complete <name>` commit carries
|
|
60
|
+
* the same `Refs:` trailer as the code it describes, but lands after the tag
|
|
61
|
+
* that shipped that code — so the ticket turns up in two consecutive release
|
|
62
|
+
* ranges, once for its code and once for its paperwork.
|
|
63
|
+
*
|
|
64
|
+
* Deliberately a POSITIVE signal: it says yes only when it actually saw paths
|
|
65
|
+
* and every one of them sits under an ignored prefix. It never reasons from an
|
|
66
|
+
* absence.
|
|
67
|
+
*
|
|
68
|
+
* WHAT WOULD FOOL THE OTHER PHRASING ("no unignored path was found"): git lists
|
|
69
|
+
* no files at all for a MERGE commit (without `-m`) — verified, not assumed —
|
|
70
|
+
* nor for a genuinely empty one, and a `git log` that failed outright yields no
|
|
71
|
+
* paths for anything. Under that phrasing every one of those becomes a release
|
|
72
|
+
* silently losing its tickets. Under this one they mean "nothing was seen, so
|
|
73
|
+
* nothing is known", which keeps the pre-filter behaviour: an over-claimed
|
|
74
|
+
* ticket is noticed when someone looks for it, whereas a ticket that quietly
|
|
75
|
+
* belongs to no release never is.
|
|
76
|
+
*
|
|
77
|
+
* Matching is path-prefix, not glob: `specs` and `specs/` both mean the
|
|
78
|
+
* directory, and a prefix only matches on a path SEGMENT boundary, so `specs/`
|
|
79
|
+
* never swallows `specs-archive/`.
|
|
80
|
+
*
|
|
81
|
+
* @param {string[]|null|undefined} paths repo-relative paths the commit changed
|
|
82
|
+
* @param {string[]} ignorePaths repo-relative prefixes that are bookkeeping
|
|
83
|
+
*/
|
|
84
|
+
function onlyIgnoredPaths(paths, ignorePaths) {
|
|
85
|
+
if (!Array.isArray(paths) || !paths.length) return false
|
|
86
|
+
const prefixes = (Array.isArray(ignorePaths) ? ignorePaths : [])
|
|
87
|
+
.filter((p) => typeof p === 'string' && p.trim())
|
|
88
|
+
.map((p) => p.trim().replace(/^\.\//, '').replace(/\/+$/, ''))
|
|
89
|
+
.filter(Boolean)
|
|
90
|
+
if (!prefixes.length) return false
|
|
91
|
+
return paths.every((raw) => {
|
|
92
|
+
const file = String(raw == null ? '' : raw)
|
|
93
|
+
.trim()
|
|
94
|
+
.replace(/^\.\//, '')
|
|
95
|
+
// An unreadable entry is an unknown, not an ignored one — it makes the whole
|
|
96
|
+
// commit count, per the bias above.
|
|
97
|
+
if (!file) return false
|
|
98
|
+
return prefixes.some((prefix) => file === prefix || file.startsWith(`${prefix}/`))
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Fold commits into the report a release needs.
|
|
104
|
+
*
|
|
105
|
+
* @param {Array<{sha?:string, subject?:string, body?:string, paths?:string[]}>} commits
|
|
106
|
+
* @param {{ignorePaths?:string[]}} [options] `ignorePaths` marks bookkeeping —
|
|
107
|
+
* see `onlyIgnoredPaths`. Omitted (or empty) means nothing is ignored, so the
|
|
108
|
+
* report is what it was before the filter existed.
|
|
109
|
+
* @returns {{tickets: Array<{ref:string, commits:number}>, unreferenced:number, ignored:number, total:number}}
|
|
110
|
+
* `tickets` is deduped in FIRST-SEEN order: a ticket touched by eight commits
|
|
111
|
+
* is listed once, where it first appears, not eight times. `ignored` is
|
|
112
|
+
* reported rather than merely subtracted — a filter that removes commits in
|
|
113
|
+
* silence reads as "there was nothing there".
|
|
114
|
+
*/
|
|
115
|
+
function ticketsInRange(commits, options = {}) {
|
|
116
|
+
const ignorePaths = (options && options.ignorePaths) || []
|
|
117
|
+
const seen = new Map()
|
|
118
|
+
let unreferenced = 0
|
|
119
|
+
let ignored = 0
|
|
120
|
+
for (const commit of commits || []) {
|
|
121
|
+
if (onlyIgnoredPaths(commit && commit.paths, ignorePaths)) {
|
|
122
|
+
// Not counted as unreferenced either: that number exists to surface a
|
|
123
|
+
// MISSED trailer, and a paperwork commit is not a gap someone should hunt.
|
|
124
|
+
ignored++
|
|
125
|
+
continue
|
|
126
|
+
}
|
|
127
|
+
const refs = [...new Set(refsInBody(commit && commit.body))]
|
|
128
|
+
if (!refs.length) {
|
|
129
|
+
unreferenced++
|
|
130
|
+
continue
|
|
131
|
+
}
|
|
132
|
+
for (const ref of refs) seen.set(ref, (seen.get(ref) || 0) + 1)
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
tickets: [...seen.entries()].map(([ref, count]) => ({ ref, commits: count })),
|
|
136
|
+
unreferenced,
|
|
137
|
+
ignored,
|
|
138
|
+
total: (commits || []).length,
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Split a release's tickets into the ones a stage move may touch and the ones it
|
|
144
|
+
* must not, with a reason for every exclusion.
|
|
145
|
+
*
|
|
146
|
+
* Three reasons to leave a ticket alone, and each is a case where acting would
|
|
147
|
+
* be worse than not acting:
|
|
148
|
+
*
|
|
149
|
+
* - **foreign** — the ref is not this repo's team. A range can carry another
|
|
150
|
+
* team's ref (a shared dependency, a quoted ticket), and writing to a team
|
|
151
|
+
* this repo was never configured for is the worst failure available here.
|
|
152
|
+
* - **unlinked** — the ref is this team's, but no spec in the repo claims it.
|
|
153
|
+
* It may be tracker-only work, or a typo in a trailer. Either way nothing
|
|
154
|
+
* here knows what it is, so it is reported, not moved.
|
|
155
|
+
* - **unfinished** — a spec that has not reached the ceded bucket. Its code
|
|
156
|
+
* really is in the release (it landed via `/spec-to-main`), but push still
|
|
157
|
+
* owns its workflow state and would bounce it straight back. Exactly one
|
|
158
|
+
* writer per issue at any moment beats a visible flip-flop.
|
|
159
|
+
*
|
|
160
|
+
* Pure: it takes the spec list as data, so the rules are testable without a repo.
|
|
161
|
+
*/
|
|
162
|
+
function partitionStageMoves({ tickets, teamKey, specs, cededBucket = 'complete' }) {
|
|
163
|
+
const bucketOf = new Map()
|
|
164
|
+
for (const spec of specs || []) {
|
|
165
|
+
if (spec && spec.identifier) bucketOf.set(spec.identifier, spec.bucket)
|
|
166
|
+
}
|
|
167
|
+
const key = String(teamKey || '').trim().toUpperCase()
|
|
168
|
+
const movable = []
|
|
169
|
+
const foreign = []
|
|
170
|
+
const unlinked = []
|
|
171
|
+
const unfinished = []
|
|
172
|
+
for (const ticket of tickets || []) {
|
|
173
|
+
const ref = ticket && ticket.ref
|
|
174
|
+
if (!ref) continue
|
|
175
|
+
if (key && !ref.toUpperCase().startsWith(`${key}-`)) {
|
|
176
|
+
foreign.push(ticket)
|
|
177
|
+
} else if (!bucketOf.has(ref)) {
|
|
178
|
+
unlinked.push(ticket)
|
|
179
|
+
} else if (bucketOf.get(ref) !== cededBucket) {
|
|
180
|
+
unfinished.push({ ...ticket, bucket: bucketOf.get(ref) })
|
|
181
|
+
} else {
|
|
182
|
+
movable.push(ticket)
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return { movable, foreign, unlinked, unfinished }
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Whether moving from `fromState` to the rung `toKey` runs against the declared
|
|
190
|
+
* order — and if so, how to say it.
|
|
191
|
+
*
|
|
192
|
+
* WARNS, never refuses. A rollback from test and a hotfix going straight to prod
|
|
193
|
+
* are both legitimate, and a check that blocked either would be wrong on healthy
|
|
194
|
+
* input. An issue that is on no rung yet is entering the ladder, which is the
|
|
195
|
+
* normal case and says nothing.
|
|
196
|
+
*
|
|
197
|
+
* `lifecycleStates` is the bucket map's own state names, and a state among them
|
|
198
|
+
* means the issue is at its LIFECYCLE position, not on a rung — even when the
|
|
199
|
+
* two names coincide. They routinely do: `states.complete` and a final `prod`
|
|
200
|
+
* rung are both naturally "Done", and without this a spec that had merely been
|
|
201
|
+
* completed would read as already deployed to prod, so its first real deploy
|
|
202
|
+
* would be warned about as a move backwards. Bucket wins over ladder here for
|
|
203
|
+
* the same reason it wins in `bucketForState`.
|
|
204
|
+
*
|
|
205
|
+
* @returns {string|null}
|
|
206
|
+
*/
|
|
207
|
+
function stageOrderWarning(stages, fromState, toKey, lifecycleStates = []) {
|
|
208
|
+
const list = Array.isArray(stages) ? stages : []
|
|
209
|
+
const to = list.findIndex((s) => s && s.key === toKey)
|
|
210
|
+
if (to < 0) return null
|
|
211
|
+
const want = String(fromState || '').toLowerCase().trim()
|
|
212
|
+
if (!want) return null
|
|
213
|
+
const lifecycle = new Set(
|
|
214
|
+
(Array.isArray(lifecycleStates) ? lifecycleStates : [])
|
|
215
|
+
.filter((n) => typeof n === 'string')
|
|
216
|
+
.map((n) => n.toLowerCase().trim()),
|
|
217
|
+
)
|
|
218
|
+
if (lifecycle.has(want)) return null
|
|
219
|
+
const from = list.findIndex((s) => s && typeof s.state === 'string' && s.state.toLowerCase().trim() === want)
|
|
220
|
+
if (from < 0) return null
|
|
221
|
+
if (to < from) return `moves back from "${list[from].key}"`
|
|
222
|
+
if (to > from + 1) return `skips ${to - from - 1} rung(s) from "${list[from].key}"`
|
|
223
|
+
return null
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
module.exports = { ticketsInRange, refsInBody, onlyIgnoredPaths, partitionStageMoves, stageOrderWarning }
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Spec resolution and listing for a provider CLI — how a spec argument becomes
|
|
5
|
+
* a snapshot directory, how a spec names its snapshot sidecar, and which specs
|
|
6
|
+
* a repo holds with which tracker links.
|
|
7
|
+
*
|
|
8
|
+
* The frontmatter key that links a spec is the provider's
|
|
9
|
+
* (`config.fields.identifier` — sync-core `src/fields.js`); everything else
|
|
10
|
+
* here is filesystem shape shared by every provider. `program` prefixes the
|
|
11
|
+
* user-facing refusals (`spec-sync: spec not found: …`).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const fs = require('node:fs')
|
|
15
|
+
const path = require('node:path')
|
|
16
|
+
|
|
17
|
+
const { BUCKETS, findSpecFolder } = require('../../../env/resolve.js')
|
|
18
|
+
const { readSnapshot, parseFrontmatter, lintPhases, fieldsOf, projectionOf, compareStored } = require('../../sync-core')
|
|
19
|
+
|
|
20
|
+
function makeSpecHelpers({ program = 'spec-sync' } = {}) {
|
|
21
|
+
// Resolve a spec argument to its snapshot dir. Accepts a spec name/folder
|
|
22
|
+
// found under specs/** (preferred) or a literal path to a snapshot directory.
|
|
23
|
+
function resolveSnapshotDir(specArg, dir) {
|
|
24
|
+
const found = findSpecFolder(specArg, dir)
|
|
25
|
+
if (found) return found.path
|
|
26
|
+
const literal = path.resolve(dir, specArg)
|
|
27
|
+
if (fs.existsSync(literal) && fs.statSync(literal).isDirectory()) return literal
|
|
28
|
+
return null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// The identifier keying the snapshot sidecar: the spec's stamped identifier
|
|
32
|
+
// if set, else its folder name (so the engine is usable before a spec is
|
|
33
|
+
// linked).
|
|
34
|
+
function specIdentifier(snapshotDir, config) {
|
|
35
|
+
const field = fieldsOf(config).identifier
|
|
36
|
+
try {
|
|
37
|
+
const { frontmatter } = readSnapshot(snapshotDir, config)
|
|
38
|
+
if (frontmatter[field]) return String(frontmatter[field])
|
|
39
|
+
} catch {
|
|
40
|
+
/* fall through to folder name */
|
|
41
|
+
}
|
|
42
|
+
return path.basename(snapshotDir)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Read a spec's stamped identifier without throwing: an unlinked spec, an
|
|
46
|
+
// unreadable file and a spec with no frontmatter all read as `null`.
|
|
47
|
+
function linkedIdentifier(overviewPath, config) {
|
|
48
|
+
const field = fieldsOf(config).identifier
|
|
49
|
+
let raw
|
|
50
|
+
try {
|
|
51
|
+
raw = fs.readFileSync(overviewPath, 'utf-8')
|
|
52
|
+
} catch {
|
|
53
|
+
return null
|
|
54
|
+
}
|
|
55
|
+
const { data } = parseFrontmatter(raw)
|
|
56
|
+
return data[field] ? String(data[field]) : null
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Every spec under specs/<bucket>/, paired with the tracker issue it is
|
|
60
|
+
// linked to (`null` when it has never been pushed). Folder specs read
|
|
61
|
+
// `snapshot.overviewFile`; legacy bare `<name>.md` specs read the file
|
|
62
|
+
// itself. Sorted for stable output.
|
|
63
|
+
function listSpecs(dir, config) {
|
|
64
|
+
const overviewFile = (config.snapshot && config.snapshot.overviewFile) || '00-overview.md'
|
|
65
|
+
const specs = []
|
|
66
|
+
for (const bucket of BUCKETS) {
|
|
67
|
+
const root = path.join(dir, 'specs', bucket)
|
|
68
|
+
let entries
|
|
69
|
+
try {
|
|
70
|
+
entries = fs.readdirSync(root, { withFileTypes: true })
|
|
71
|
+
} catch {
|
|
72
|
+
continue
|
|
73
|
+
}
|
|
74
|
+
for (const entry of entries) {
|
|
75
|
+
let name
|
|
76
|
+
let overviewPath
|
|
77
|
+
if (entry.isDirectory()) {
|
|
78
|
+
name = entry.name
|
|
79
|
+
overviewPath = path.join(root, name, overviewFile)
|
|
80
|
+
} else if (entry.isFile() && entry.name.endsWith('.md')) {
|
|
81
|
+
name = entry.name.slice(0, -3)
|
|
82
|
+
overviewPath = path.join(root, entry.name)
|
|
83
|
+
} else {
|
|
84
|
+
continue
|
|
85
|
+
}
|
|
86
|
+
specs.push({ spec: name, bucket, identifier: linkedIdentifier(overviewPath, config) })
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return specs.sort((a, b) => a.spec.localeCompare(b.spec))
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Resolve a spec argument to its folder, or null with a message on stdout.
|
|
93
|
+
// EVERY failure path here must print and EVERY caller must return a non-zero
|
|
94
|
+
// code: a push skill checks $? before it applies a plan, so a resolve failure
|
|
95
|
+
// that exits 0 reads as "nothing to do" rather than "I could not find the
|
|
96
|
+
// spec".
|
|
97
|
+
function resolveOrExit(specArg, dir, out) {
|
|
98
|
+
if (!specArg) {
|
|
99
|
+
out.write(`${program}: no spec given\n`)
|
|
100
|
+
return null
|
|
101
|
+
}
|
|
102
|
+
const snapshotDir = resolveSnapshotDir(specArg, dir)
|
|
103
|
+
if (!snapshotDir) {
|
|
104
|
+
out.write(`${program}: spec not found: ${specArg}\n`)
|
|
105
|
+
return null
|
|
106
|
+
}
|
|
107
|
+
return snapshotDir
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Phase-status warnings for a spec, one formatted line each. A spec states
|
|
111
|
+
// each phase's status three times and only the h1 emoji is read — get it
|
|
112
|
+
// wrong and the phase projects as `backlog`, pushes cleanly, and is recorded
|
|
113
|
+
// as intended: invisible. Reported by every subcommand that reads a
|
|
114
|
+
// projection, fatal in none — a legacy spec must still push.
|
|
115
|
+
function warningLines(snapshotDir, config) {
|
|
116
|
+
return lintPhases(snapshotDir, config).map((w) => ` warning ${w.file}: ${w.message}`)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Emit warnings on stderr, keeping stdout pure for a machine-readable payload.
|
|
120
|
+
function warnToErr(snapshotDir, config, err) {
|
|
121
|
+
const lines = warningLines(snapshotDir, config)
|
|
122
|
+
if (lines.length) err.write(lines.join('\n') + '\n')
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Render the read-back comparison — the same check `verify` and `apply`
|
|
126
|
+
// both report through, so the two cannot disagree about what "intact" means.
|
|
127
|
+
function verifyLines(snapshotDir, config, stored, identifier) {
|
|
128
|
+
const projection = projectionOf(snapshotDir, config)
|
|
129
|
+
const checks = []
|
|
130
|
+
if (typeof stored.issue === 'string') checks.push(['issue', projection.description, stored.issue])
|
|
131
|
+
for (const [ref, text] of Object.entries(stored.subIssues || {})) {
|
|
132
|
+
const sub = projection.subIssues.find((s) => s.ref === ref)
|
|
133
|
+
if (!sub) {
|
|
134
|
+
checks.push([`sub-issue ${ref}`, null, text])
|
|
135
|
+
continue
|
|
136
|
+
}
|
|
137
|
+
checks.push([`sub-issue ${ref}`, sub.goal, text])
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const lines = [`${program} verify: ${identifier}`]
|
|
141
|
+
let bad = 0
|
|
142
|
+
for (const [label, sent, got] of checks) {
|
|
143
|
+
if (sent == null) {
|
|
144
|
+
lines.push(` ?? ${label}: read back, but the projection has no such phase — stale ref?`)
|
|
145
|
+
bad++
|
|
146
|
+
continue
|
|
147
|
+
}
|
|
148
|
+
const r = compareStored(sent, got, config)
|
|
149
|
+
if (r.ok) continue
|
|
150
|
+
bad++
|
|
151
|
+
lines.push(
|
|
152
|
+
` !! ${label}: the tracker stored different text — ${Math.abs(r.lost)} character(s) ` +
|
|
153
|
+
`${r.lost > 0 ? 'lost' : 'added'}, first difference at ${r.at}`,
|
|
154
|
+
` sent: …${r.sentContext}…`,
|
|
155
|
+
` stored: …${r.storedContext}…`,
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
if (!bad) lines.push(` ${checks.length} description(s) round-tripped intact`)
|
|
159
|
+
else lines.push(' the repo is unchanged and still correct; re-push to overwrite the mirror')
|
|
160
|
+
return lines
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return { resolveSnapshotDir, specIdentifier, linkedIdentifier, listSpecs, resolveOrExit, warningLines, warnToErr, verifyLines }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
module.exports = { makeSpecHelpers }
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Which transport a command runs over: `--via` wins, else the config's
|
|
5
|
+
* declared default, else "api when a credential resolved, mcp when it didn't".
|
|
6
|
+
*
|
|
7
|
+
* This expression was repeated verbatim nine times in the first provider —
|
|
8
|
+
* hoisted so the precedence is decided once. Per-command DEGRADATION stays with
|
|
9
|
+
* the command: which operations are impossible over mcp (exit 1, say so) and
|
|
10
|
+
* which hand the skill instructions (exit 0) is a per-command judgment, not a
|
|
11
|
+
* transport fact.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
function resolveTransport(flags, config, credentialOk) {
|
|
15
|
+
return (
|
|
16
|
+
(flags && flags.via) ||
|
|
17
|
+
(config && config.apply && config.apply.transport) ||
|
|
18
|
+
(credentialOk ? 'api' : 'mcp')
|
|
19
|
+
)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
module.exports = { resolveTransport }
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Provider-neutral one-way sync engine (repo → tracker).
|
|
5
|
+
*
|
|
6
|
+
* Every function is parameterised by a plain `config` object — it knows nothing
|
|
7
|
+
* about any specific tracker. The repo is the source of truth: the engine builds
|
|
8
|
+
* a local projection, diffs it against a committed last-pushed snapshot
|
|
9
|
+
* (`planChanges`), and returns a create/update plan the provider skill applies
|
|
10
|
+
* over its API. No remote content is read for CONTENT: nothing the tracker holds
|
|
11
|
+
* ever feeds the projection, the snapshot, or a repo file. `compareStored` is the
|
|
12
|
+
* one function that looks at a tracker value, and it only checks that what was
|
|
13
|
+
* stored matches what was sent — it merges nothing (see `src/verify.js`).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const { ownsField, normalizeLocal, LIFECYCLE_BUCKETS, lintPhases, readSnapshot, parseFrontmatter, remoteWorkflowState, titleFromText, validateStates, stateSuggestions, stageForState, remoteStage, phaseModeFor, LADDER_ORIGIN_BUCKET } = require('./src/normalize.js')
|
|
17
|
+
const { planChanges, snapshotOf, isEmptyPlan, hashField, stableStringify, remoteDescriptionEdited } = require('./src/compare.js')
|
|
18
|
+
const { readBase, writeBase } = require('./src/base.js')
|
|
19
|
+
const { push, recordPush, projectionOf } = require('./src/push.js')
|
|
20
|
+
const { writeFrontmatter, deleteFrontmatter, stampSubIssueId, stampIssueId, findPhaseFileByTitle, listPhaseFiles } = require('./src/write.js')
|
|
21
|
+
const { detectLegacyMirror } = require('./src/legacy.js')
|
|
22
|
+
const { compareStored, stream } = require('./src/verify.js')
|
|
23
|
+
const { identityDialect, dialectOf } = require('./src/dialect.js')
|
|
24
|
+
const { fieldsOf } = require('./src/fields.js')
|
|
25
|
+
const { renderTaskBlock, findTaskBlocks, wrapEmphasisAware, collapseHyphenAware, inferWidth, fenceMask, DEFAULT_WIDTH } = require('./src/task-block.js')
|
|
26
|
+
const { planRetarget, applyRetarget, deriveRecordedKey, isEmptyRetarget, dirtyPaths } = require('./src/retarget.js')
|
|
27
|
+
|
|
28
|
+
module.exports = {
|
|
29
|
+
ownsField,
|
|
30
|
+
normalizeLocal,
|
|
31
|
+
// Exported for `spec-sync list`: an `inline`-mode spec has no phase sub-issues
|
|
32
|
+
// to read, so the listing must resolve the mode rather than assume `subissue`.
|
|
33
|
+
phaseModeFor,
|
|
34
|
+
lintPhases,
|
|
35
|
+
readSnapshot,
|
|
36
|
+
parseFrontmatter,
|
|
37
|
+
projectionOf,
|
|
38
|
+
planChanges,
|
|
39
|
+
snapshotOf,
|
|
40
|
+
isEmptyPlan,
|
|
41
|
+
push,
|
|
42
|
+
recordPush,
|
|
43
|
+
remoteWorkflowState,
|
|
44
|
+
titleFromText,
|
|
45
|
+
validateStates,
|
|
46
|
+
stateSuggestions,
|
|
47
|
+
stageForState,
|
|
48
|
+
remoteStage,
|
|
49
|
+
LADDER_ORIGIN_BUCKET,
|
|
50
|
+
hashField,
|
|
51
|
+
stableStringify,
|
|
52
|
+
readBase,
|
|
53
|
+
writeBase,
|
|
54
|
+
writeFrontmatter,
|
|
55
|
+
deleteFrontmatter,
|
|
56
|
+
stampSubIssueId,
|
|
57
|
+
stampIssueId,
|
|
58
|
+
findPhaseFileByTitle,
|
|
59
|
+
listPhaseFiles,
|
|
60
|
+
detectLegacyMirror,
|
|
61
|
+
compareStored,
|
|
62
|
+
// The tolerant reduction behind both `compareStored` and the snapshot's
|
|
63
|
+
// `descriptionStream` hash — exported so a provider can reduce a read-back the
|
|
64
|
+
// same way rather than writing a second one that disagrees.
|
|
65
|
+
stream,
|
|
66
|
+
// Did someone else edit the tracker's description since the last push?
|
|
67
|
+
// Three-valued; `null` is cannot-tell and must never be reported as drift.
|
|
68
|
+
remoteDescriptionEdited,
|
|
69
|
+
// Provider seams: the frontmatter field vocabulary and the markdown dialect
|
|
70
|
+
// a provider's config supplies (`config.fields` / `config.dialect`).
|
|
71
|
+
fieldsOf,
|
|
72
|
+
identityDialect,
|
|
73
|
+
dialectOf,
|
|
74
|
+
// The lifecycle buckets, exported once so providers and tests stop growing
|
|
75
|
+
// private copies (the tracker-free common package keeps its own — it cannot
|
|
76
|
+
// depend on this package).
|
|
77
|
+
LIFECYCLE_BUCKETS,
|
|
78
|
+
// Task-block helpers a provider's own text tooling builds on (the Linear
|
|
79
|
+
// sanitiser reflows emphasis-aware with these).
|
|
80
|
+
renderTaskBlock,
|
|
81
|
+
findTaskBlocks,
|
|
82
|
+
wrapEmphasisAware,
|
|
83
|
+
collapseHyphenAware,
|
|
84
|
+
inferWidth,
|
|
85
|
+
fenceMask,
|
|
86
|
+
DEFAULT_WIDTH,
|
|
87
|
+
planRetarget,
|
|
88
|
+
applyRetarget,
|
|
89
|
+
deriveRecordedKey,
|
|
90
|
+
isEmptyRetarget,
|
|
91
|
+
dirtyPaths,
|
|
92
|
+
}
|