@nickmeriano/task 0.7.1 → 0.9.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/README.md +73 -25
- package/dist/asks.test.d.ts +17 -0
- package/dist/asks.test.d.ts.map +1 -0
- package/dist/asks.test.js +278 -0
- package/dist/asks.test.js.map +1 -0
- package/dist/check.d.ts +43 -0
- package/dist/check.d.ts.map +1 -0
- package/dist/check.js +403 -0
- package/dist/check.js.map +1 -0
- package/dist/check.test.d.ts +9 -0
- package/dist/check.test.d.ts.map +1 -0
- package/dist/check.test.js +248 -0
- package/dist/check.test.js.map +1 -0
- package/dist/claim-io.d.ts +73 -0
- package/dist/claim-io.d.ts.map +1 -0
- package/dist/claim-io.js +344 -0
- package/dist/claim-io.js.map +1 -0
- package/dist/claim.d.ts +61 -9
- package/dist/claim.d.ts.map +1 -1
- package/dist/claim.js +197 -67
- package/dist/claim.js.map +1 -1
- package/dist/claim.test.d.ts +2 -2
- package/dist/claim.test.js +235 -64
- package/dist/claim.test.js.map +1 -1
- package/dist/cli.js +724 -136
- package/dist/cli.js.map +1 -1
- package/dist/file-store.d.ts +110 -38
- package/dist/file-store.d.ts.map +1 -1
- package/dist/file-store.js +514 -238
- package/dist/file-store.js.map +1 -1
- package/dist/git-serve.d.ts +183 -0
- package/dist/git-serve.d.ts.map +1 -0
- package/dist/git-serve.js +503 -0
- package/dist/git-serve.js.map +1 -0
- package/dist/git-serve.test.d.ts +16 -0
- package/dist/git-serve.test.d.ts.map +1 -0
- package/dist/git-serve.test.js +183 -0
- package/dist/git-serve.test.js.map +1 -0
- package/dist/git.d.ts +65 -0
- package/dist/git.d.ts.map +1 -0
- package/dist/git.js +114 -0
- package/dist/git.js.map +1 -0
- package/dist/id.d.ts +39 -0
- package/dist/id.d.ts.map +1 -0
- package/dist/id.js +67 -0
- package/dist/id.js.map +1 -0
- package/dist/inbox.d.ts +41 -0
- package/dist/inbox.d.ts.map +1 -0
- package/dist/inbox.js +56 -0
- package/dist/inbox.js.map +1 -0
- package/dist/index.d.ts +5 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -3
- package/dist/index.js.map +1 -1
- package/dist/overview.d.ts +52 -0
- package/dist/overview.d.ts.map +1 -0
- package/dist/overview.js +61 -0
- package/dist/overview.js.map +1 -0
- package/dist/overview.test.d.ts +8 -0
- package/dist/overview.test.d.ts.map +1 -0
- package/dist/overview.test.js +48 -0
- package/dist/overview.test.js.map +1 -0
- package/dist/promote.test.d.ts +15 -0
- package/dist/promote.test.d.ts.map +1 -0
- package/dist/promote.test.js +104 -0
- package/dist/promote.test.js.map +1 -0
- package/dist/publish.d.ts +2 -17
- package/dist/publish.d.ts.map +1 -1
- package/dist/publish.js +4 -49
- package/dist/publish.js.map +1 -1
- package/dist/search.d.ts +34 -0
- package/dist/search.d.ts.map +1 -0
- package/dist/search.js +79 -0
- package/dist/search.js.map +1 -0
- package/dist/search.test.d.ts +2 -0
- package/dist/search.test.d.ts.map +1 -0
- package/dist/search.test.js +53 -0
- package/dist/search.test.js.map +1 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +228 -23
- package/dist/server.js.map +1 -1
- package/dist/store.d.ts +43 -63
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +0 -368
- package/dist/store.js.map +1 -1
- package/dist/store.test.d.ts +1 -2
- package/dist/store.test.d.ts.map +1 -1
- package/dist/store.test.js +148 -106
- package/dist/store.test.js.map +1 -1
- package/dist/ticket-doc.d.ts +74 -5
- package/dist/ticket-doc.d.ts.map +1 -1
- package/dist/ticket-doc.js +229 -15
- package/dist/ticket-doc.js.map +1 -1
- package/dist/types.d.ts +115 -28
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
- package/skill/SKILL.md +153 -40
- package/src/asks.test.ts +355 -0
- package/src/check.test.ts +328 -0
- package/src/check.ts +497 -0
- package/src/claim-io.ts +401 -0
- package/src/claim.test.ts +301 -71
- package/src/claim.ts +238 -81
- package/src/cli.ts +740 -131
- package/src/file-store.ts +572 -254
- package/src/git-serve.test.ts +240 -0
- package/src/git-serve.ts +595 -0
- package/src/git.ts +141 -0
- package/src/id.ts +68 -0
- package/src/inbox.ts +77 -0
- package/src/index.ts +4 -2
- package/src/overview.test.ts +52 -0
- package/src/overview.ts +105 -0
- package/src/promote.test.ts +143 -0
- package/src/publish.ts +6 -53
- package/src/search.test.ts +64 -0
- package/src/search.ts +105 -0
- package/src/server.ts +232 -21
- package/src/store.test.ts +166 -116
- package/src/store.ts +46 -444
- package/src/ticket-doc.ts +284 -21
- package/src/types.ts +120 -28
- package/ui/dist/assets/index-BjsorZOU.js +229 -0
- package/ui/dist/assets/index-CoKCUYic.css +1 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/index-COunM-QN.css +0 -1
- package/ui/dist/assets/index-D4homvrQ.js +0 -229
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `task check` exists for the write paths the CLI never sees — git merges and
|
|
3
|
+
* hand-edits — so its tests are those: files written slightly wrong, links
|
|
4
|
+
* whose target one branch deleted, tickets a merge duplicated into the board
|
|
5
|
+
* *and* the archive. Plus the sharp edge fixed alongside it: unknown
|
|
6
|
+
* frontmatter keys now survive a rewrite instead of being dropped.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import assert from "node:assert/strict"
|
|
10
|
+
import { test } from "node:test"
|
|
11
|
+
import { mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"
|
|
12
|
+
import { tmpdir } from "node:os"
|
|
13
|
+
import { join } from "node:path"
|
|
14
|
+
import { checkBoards } from "./check.ts"
|
|
15
|
+
import { initProject } from "./file-store.ts"
|
|
16
|
+
import { parseTicket, serializeTicket } from "./ticket-doc.ts"
|
|
17
|
+
|
|
18
|
+
function tempRoot(): string {
|
|
19
|
+
const dir = mkdtempSync(join(tmpdir(), "task-check-"))
|
|
20
|
+
process.on("exit", () => rmSync(dir, { recursive: true, force: true }))
|
|
21
|
+
return dir
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
test("a board the CLI wrote is clean", () => {
|
|
25
|
+
const root = tempRoot()
|
|
26
|
+
const store = initProject(root, { name: "clean", prefix: "CLN" })
|
|
27
|
+
store.createGoal({ title: "Ship it", description: "the why" })
|
|
28
|
+
const a = store.create({ title: "First", description: "with a body", goal: "ship-it" })
|
|
29
|
+
const b = store.create({ title: "Second", status: "backlog", tags: ["x"] })
|
|
30
|
+
store.link(b.key, "blocked_by", a.key)
|
|
31
|
+
store.addComment(a.key, "hello", "claude")
|
|
32
|
+
store.update(a.key, { status: "done" })
|
|
33
|
+
store.archive(a.key)
|
|
34
|
+
|
|
35
|
+
const result = checkBoards(root, false)
|
|
36
|
+
assert.deepEqual(result.issues, [])
|
|
37
|
+
assert.equal(result.boards, 1)
|
|
38
|
+
assert.equal(result.files, 4) // two tickets + one comment + one goal
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test("ask files are linted like comments; the retired flag reads as an unknown key", () => {
|
|
42
|
+
const root = tempRoot()
|
|
43
|
+
const store = initProject(root, { name: "owed", prefix: "OWE" })
|
|
44
|
+
const t = store.create({ title: "Owes things", status: "in_progress" })
|
|
45
|
+
store.addAsk(t.key, "Mint the token", "claude")
|
|
46
|
+
assert.deepEqual(checkBoards(root, false).issues, [], "a CLI-written ask is clean")
|
|
47
|
+
|
|
48
|
+
const asksDir = join(root, ".task", "tickets", t.key, "asks")
|
|
49
|
+
// Hand-edit shapes: an ask that says nothing, a resolution that isn't a
|
|
50
|
+
// timestamp, a key nothing reads, and mere drift (unquoted author).
|
|
51
|
+
writeFileSync(join(asksDir, "z-empty.md"), '---\nauthor: "x"\ncreated: 2026-08-27T00:00:00.000Z\n---\n\n\n')
|
|
52
|
+
writeFileSync(
|
|
53
|
+
join(asksDir, "z-bad.md"),
|
|
54
|
+
'---\nauthor: "x"\ncreated: 2026-08-27T00:00:00.000Z\nresolved: yesterday-ish\nurgency: high\n---\n\nDo the thing\n',
|
|
55
|
+
)
|
|
56
|
+
const found = checkBoards(root, false)
|
|
57
|
+
const messages = found.issues.map((issue) => issue.message)
|
|
58
|
+
assert.ok(messages.some((m) => /empty ask/.test(m)), "empty ask reported")
|
|
59
|
+
assert.ok(messages.some((m) => /resolved is not a valid timestamp/.test(m)), "bad resolution reported")
|
|
60
|
+
assert.ok(messages.some((m) => /unknown frontmatter key "urgency"/.test(m)), "unknown key reported")
|
|
61
|
+
|
|
62
|
+
// The retired pre-0.8 boolean isn't a field anymore — a file still carrying
|
|
63
|
+
// it (deep history, an ancient branch) reads as an unknown key: flagged,
|
|
64
|
+
// preserved through rewrites, never interpreted.
|
|
65
|
+
const ticketPath = join(root, ".task", "tickets", t.key, "ticket.md")
|
|
66
|
+
writeFileSync(
|
|
67
|
+
ticketPath,
|
|
68
|
+
readFileSync(ticketPath, "utf8").replace("status: in_progress", "status: in_progress\nneeds_human: true"),
|
|
69
|
+
)
|
|
70
|
+
const flagged = checkBoards(root, true)
|
|
71
|
+
const unknown = flagged.issues.find((issue) => /unknown frontmatter key "needs_human"/.test(issue.message))
|
|
72
|
+
assert.ok(unknown, "retired key flagged as unknown")
|
|
73
|
+
assert.match(readFileSync(ticketPath, "utf8"), /needs_human: true/)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test("finds what merges and hand-edits leave behind, and --fix repairs the mechanical part", () => {
|
|
77
|
+
const root = tempRoot()
|
|
78
|
+
const store = initProject(root, { name: "rough", prefix: "RGH" })
|
|
79
|
+
const a = store.create({ title: "Kept", status: "todo" })
|
|
80
|
+
const b = store.create({ title: "Points at a ghost", status: "todo" })
|
|
81
|
+
const c = store.create({ title: "Broken later", status: "todo" })
|
|
82
|
+
|
|
83
|
+
// A dangling reference: link b to a ticket, then lose the ticket the way a
|
|
84
|
+
// merge would — the directory vanishes without the store's delete cascade.
|
|
85
|
+
store.link(b.key, "blocked_by", a.key)
|
|
86
|
+
const ticketPath = (key: string) => join(root, ".task", "tickets", key, "ticket.md")
|
|
87
|
+
const bText = readFileSync(ticketPath(b.key), "utf8")
|
|
88
|
+
writeFileSync(
|
|
89
|
+
ticketPath(b.key),
|
|
90
|
+
bText.replace(`blocked_by: ["${a.key}"]`, `blocked_by: ["${a.key}", "zz9zz", "zz9zz"]`),
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
// A hand-edit that still parses but isn't canonical (bare goal string).
|
|
94
|
+
store.createGoal({ title: "Launch" })
|
|
95
|
+
const cText = readFileSync(ticketPath(c.key), "utf8")
|
|
96
|
+
writeFileSync(ticketPath(c.key), cText.replace("status: todo", "status: todo\ngoal: launch"))
|
|
97
|
+
|
|
98
|
+
// A file that doesn't parse at all, in a well-formed key directory.
|
|
99
|
+
mkdirSync(join(root, ".task", "tickets", "zzz9x"))
|
|
100
|
+
writeFileSync(ticketPath("zzz9x"), "---\nstatus: todoo\nposition: 0\ncreated: x\nupdated: x\n---\n\n# Bad status\n")
|
|
101
|
+
|
|
102
|
+
const found = checkBoards(root, false)
|
|
103
|
+
const messages = found.issues.map((issue) => issue.message)
|
|
104
|
+
assert.ok(messages.some((m) => /RGH-zz9zz, which doesn't exist/.test(m)), "dangling ref reported")
|
|
105
|
+
assert.ok(messages.some((m) => /not in canonical form/.test(m)), "drift reported")
|
|
106
|
+
assert.ok(messages.some((m) => /invalid status "todoo"/.test(m)), "parse error reported")
|
|
107
|
+
assert.ok(messages.some((m) => /not a valid timestamp/.test(m)) === false, "unparseable file isn't double-reported")
|
|
108
|
+
|
|
109
|
+
const fixed = checkBoards(root, true)
|
|
110
|
+
assert.ok(fixed.fixed.length >= 2, "fix rewrote the repairable files")
|
|
111
|
+
// The dangling ref is pruned, the real one kept; the drift is normalized.
|
|
112
|
+
assert.ok(readFileSync(ticketPath(b.key), "utf8").includes(`blocked_by: ["${a.key}"]\n`))
|
|
113
|
+
assert.match(readFileSync(ticketPath(c.key), "utf8"), /goal: "launch"/)
|
|
114
|
+
|
|
115
|
+
// What remains is exactly what needs a human: the unparseable file.
|
|
116
|
+
const after = checkBoards(root, false)
|
|
117
|
+
assert.deepEqual(
|
|
118
|
+
after.issues.filter((issue) => issue.fixable),
|
|
119
|
+
[],
|
|
120
|
+
)
|
|
121
|
+
assert.equal(after.issues.length, 1)
|
|
122
|
+
assert.match(after.issues[0].message, /invalid status/)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
test("merge artifacts across the archive are reported, never auto-fixed", () => {
|
|
126
|
+
const root = tempRoot()
|
|
127
|
+
const store = initProject(root, { name: "dup", prefix: "DUP" })
|
|
128
|
+
const t = store.create({ title: "Archived on one branch" })
|
|
129
|
+
store.update(t.key, { status: "done" })
|
|
130
|
+
store.archive(t.key)
|
|
131
|
+
// The other branch kept editing it — the merge keeps both directories.
|
|
132
|
+
mkdirSync(join(root, ".task", "tickets", t.key), { recursive: true })
|
|
133
|
+
writeFileSync(
|
|
134
|
+
join(root, ".task", "tickets", t.key, "ticket.md"),
|
|
135
|
+
readFileSync(join(root, ".task", "archive", t.key, "ticket.md")),
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
const result = checkBoards(root, true)
|
|
139
|
+
assert.ok(result.issues.some((issue) => /also exists in tickets\//.test(issue.message)))
|
|
140
|
+
assert.deepEqual(result.fixed, [])
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
test("a ticket parked in the archive while still open is flagged", () => {
|
|
144
|
+
const root = tempRoot()
|
|
145
|
+
const store = initProject(root, { name: "arc", prefix: "ARC" })
|
|
146
|
+
const t = store.create({ title: "Half archived", status: "todo" })
|
|
147
|
+
renameSync(
|
|
148
|
+
join(root, ".task", "tickets", t.key),
|
|
149
|
+
(mkdirSync(join(root, ".task", "archive"), { recursive: true }),
|
|
150
|
+
join(root, ".task", "archive", t.key)),
|
|
151
|
+
)
|
|
152
|
+
const result = checkBoards(root, false)
|
|
153
|
+
assert.ok(result.issues.some((issue) => /archived but still todo/.test(issue.message)))
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
test("unknown frontmatter keys are reported — and survive a rewrite round-trip", () => {
|
|
157
|
+
const root = tempRoot()
|
|
158
|
+
const store = initProject(root, { name: "extra", prefix: "EXT" })
|
|
159
|
+
const t = store.create({ title: "Carries a custom field", status: "todo" })
|
|
160
|
+
const path = join(root, ".task", "tickets", t.key, "ticket.md")
|
|
161
|
+
writeFileSync(
|
|
162
|
+
path,
|
|
163
|
+
readFileSync(path, "utf8").replace("status: todo", 'status: todo\nowner: "nick"'),
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
const result = checkBoards(root, false)
|
|
167
|
+
assert.ok(result.issues.some((issue) => /unknown frontmatter key "owner"/.test(issue.message)))
|
|
168
|
+
|
|
169
|
+
// The sharp edge this fixed: an ordinary update used to drop the key.
|
|
170
|
+
store.update(t.key, { title: "Renamed" })
|
|
171
|
+
const text = readFileSync(path, "utf8")
|
|
172
|
+
assert.match(text, /owner: "nick"/)
|
|
173
|
+
assert.match(text, /# Renamed/)
|
|
174
|
+
// And the parse carries it explicitly.
|
|
175
|
+
assert.deepEqual(parseTicket(text, "test").extras, [["owner", '"nick"']])
|
|
176
|
+
// Round-trip is bytes-stable, so check won't flag the rewritten file.
|
|
177
|
+
assert.equal(serializeTicket(parseTicket(text, "test")), text)
|
|
178
|
+
assert.deepEqual(checkBoards(root, false).issues.map((issue) => issue.message), [
|
|
179
|
+
'unknown frontmatter key "owner" — preserved on rewrite, but nothing reads it',
|
|
180
|
+
])
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
test("the pre-0.8 milestone key is refused by name — no migration machinery", () => {
|
|
184
|
+
const root = tempRoot()
|
|
185
|
+
const store = initProject(root, { name: "old", prefix: "OLD" })
|
|
186
|
+
const t = store.create({ title: "From an old board", status: "todo" })
|
|
187
|
+
const path = join(root, ".task", "tickets", t.key, "ticket.md")
|
|
188
|
+
writeFileSync(
|
|
189
|
+
path,
|
|
190
|
+
readFileSync(path, "utf8").replace("status: todo", 'status: todo\nmilestone: "launch"'),
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
// The strict parse refuses the retired spelling with the rename in the
|
|
194
|
+
// error; check surfaces exactly that, unfixable, and --fix leaves the file
|
|
195
|
+
// alone — renaming the key is the human's one edit.
|
|
196
|
+
const found = checkBoards(root, true)
|
|
197
|
+
const refusal = found.issues.find((issue) =>
|
|
198
|
+
/`milestone:` is the pre-0\.8 spelling.*rename the key to `goal:`/.test(issue.message),
|
|
199
|
+
)
|
|
200
|
+
assert.ok(refusal, "retired spelling refused by name")
|
|
201
|
+
assert.equal(refusal.fixable, false)
|
|
202
|
+
assert.match(readFileSync(path, "utf8"), /milestone: "launch"/)
|
|
203
|
+
|
|
204
|
+
writeFileSync(path, readFileSync(path, "utf8").replace('milestone: "launch"', 'goal: "launch"'))
|
|
205
|
+
store.createGoal({ title: "Launch" })
|
|
206
|
+
checkBoards(root, true)
|
|
207
|
+
assert.deepEqual(checkBoards(root, false).issues, [])
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
test("goal files are linted, and drift in them is fixable", () => {
|
|
211
|
+
const root = tempRoot()
|
|
212
|
+
const store = initProject(root, { name: "gol", prefix: "GOL" })
|
|
213
|
+
store.createGoal({ title: "Tidy", description: "fine" })
|
|
214
|
+
// A hand-dropped goal: parseable, bad timestamp, non-canonical spacing, and
|
|
215
|
+
// a slug the CLI would never mint.
|
|
216
|
+
writeFileSync(
|
|
217
|
+
join(root, ".task", "goals", "Bad Slug.md"),
|
|
218
|
+
"---\ncreated: not-a-date\nupdated: 2026-08-15T17:00:00.000Z\n---\n# Handmade\n\nbody\n",
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
const result = checkBoards(root, false)
|
|
222
|
+
const messages = result.issues.map((issue) => issue.message)
|
|
223
|
+
assert.ok(messages.some((m) => /not a valid goal slug/.test(m)))
|
|
224
|
+
assert.ok(messages.some((m) => /created is not a valid timestamp/.test(m)))
|
|
225
|
+
assert.ok(result.issues.some((issue) => issue.fixable), "spacing drift is fixable")
|
|
226
|
+
|
|
227
|
+
checkBoards(root, true)
|
|
228
|
+
assert.match(
|
|
229
|
+
readFileSync(join(root, ".task", "goals", "Bad Slug.md"), "utf8"),
|
|
230
|
+
/---\n\n# Handmade/,
|
|
231
|
+
)
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
test("comment files are linted too", () => {
|
|
235
|
+
const root = tempRoot()
|
|
236
|
+
const store = initProject(root, { name: "com", prefix: "COM" })
|
|
237
|
+
const t = store.create({ title: "Talked about" })
|
|
238
|
+
store.addComment(t.key, "fine", "claude")
|
|
239
|
+
const dir = join(root, ".task", "tickets", t.key, "comments")
|
|
240
|
+
// A hand-dropped comment: parseable, wrong created, non-canonical spacing.
|
|
241
|
+
writeFileSync(join(dir, "2026-08-15T170000-other.md"), '---\nauthor: "other"\ncreated: not-a-date\n---\nbody\n')
|
|
242
|
+
|
|
243
|
+
const result = checkBoards(root, false)
|
|
244
|
+
assert.ok(result.issues.some((issue) => /created is not a valid timestamp/.test(issue.message)))
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
test("config lints: retired claimPrefix refuses, boards is root-only, prefixes must agree", () => {
|
|
248
|
+
const root = tempRoot()
|
|
249
|
+
initProject(root, { name: "cfg", prefix: "CFG" })
|
|
250
|
+
const rootConfig = join(root, ".task", "config.json")
|
|
251
|
+
const raw = JSON.parse(readFileSync(rootConfig, "utf8")) as Record<string, unknown>
|
|
252
|
+
raw.claimPrefix = "claude/task/"
|
|
253
|
+
raw.boards = ["CFG", "SUB"]
|
|
254
|
+
writeFileSync(rootConfig, `${JSON.stringify(raw, null, 2)}\n`)
|
|
255
|
+
|
|
256
|
+
// A nested board that disagrees on the namespace and carries its own
|
|
257
|
+
// boards list — both are repo-level decisions the root board owns.
|
|
258
|
+
mkdirSync(join(root, "pkg"), { recursive: true })
|
|
259
|
+
initProject(join(root, "pkg"), { name: "sub", prefix: "SUB" })
|
|
260
|
+
const subConfig = join(root, "pkg", ".task", "config.json")
|
|
261
|
+
const subRaw = JSON.parse(readFileSync(subConfig, "utf8")) as Record<string, unknown>
|
|
262
|
+
subRaw.boards = ["SUB"]
|
|
263
|
+
subRaw.claims = { branchPrefix: "task/claim/" }
|
|
264
|
+
writeFileSync(subConfig, `${JSON.stringify(subRaw, null, 2)}\n`)
|
|
265
|
+
|
|
266
|
+
// The retired key with no claims.branchPrefix beside it: claimNamespace
|
|
267
|
+
// refuses by name, check surfaces that refusal, and --fix touches nothing —
|
|
268
|
+
// renaming the key is the human's one edit. The root's namespace being
|
|
269
|
+
// unreadable also means the sub-board mismatch lint has no reference to
|
|
270
|
+
// compare against; the root-only boards lint still fires.
|
|
271
|
+
const found = checkBoards(root, true)
|
|
272
|
+
const messages = found.issues.map((issue) => issue.message)
|
|
273
|
+
assert.ok(messages.some((m) => /`claimPrefix` in \.task\/config\.json is the pre-0\.8 spelling/.test(m)))
|
|
274
|
+
assert.ok(messages.some((m) => /"boards" is root-only/.test(m)))
|
|
275
|
+
assert.ok(found.issues.every((issue) => !issue.fixable || !/claimPrefix/.test(issue.message)))
|
|
276
|
+
const untouched = JSON.parse(readFileSync(rootConfig, "utf8")) as { claimPrefix?: string }
|
|
277
|
+
assert.equal(untouched.claimPrefix, "claude/task/")
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
test("a stray claimPrefix beside claims.branchPrefix is inert and flagged", () => {
|
|
281
|
+
const root = tempRoot()
|
|
282
|
+
initProject(root, { name: "dup", prefix: "DUP" })
|
|
283
|
+
const configPath = join(root, ".task", "config.json")
|
|
284
|
+
const raw = JSON.parse(readFileSync(configPath, "utf8")) as Record<string, unknown>
|
|
285
|
+
raw.claimPrefix = "old/ns/"
|
|
286
|
+
raw.claims = { branchPrefix: "new/ns/", maxOpenCount: 2 }
|
|
287
|
+
writeFileSync(configPath, `${JSON.stringify(raw, null, 2)}\n`)
|
|
288
|
+
|
|
289
|
+
const messages = checkBoards(root, false).issues.map((issue) => issue.message)
|
|
290
|
+
assert.ok(messages.some((m) => /`claims\.branchPrefix` wins; delete the leftover key/.test(m)))
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
test("numeric ticket dirs are called out as pre-TAS-42 — junk entries separately", () => {
|
|
294
|
+
const root = tempRoot()
|
|
295
|
+
const store = initProject(root, { name: "mig", prefix: "MIG" })
|
|
296
|
+
store.create({ title: "A modern ticket", status: "todo" })
|
|
297
|
+
|
|
298
|
+
// A pre-migration ticket, imported by merging a branch cut before the id
|
|
299
|
+
// cutover: numeric directory, numeric blocked_by.
|
|
300
|
+
mkdirSync(join(root, ".task", "tickets", "42"), { recursive: true })
|
|
301
|
+
writeFileSync(
|
|
302
|
+
join(root, ".task", "tickets", "42", "ticket.md"),
|
|
303
|
+
"---\nstatus: todo\nposition: 0\ncreated: 2026-08-15T16:00:33.758Z\nupdated: 2026-08-15T16:00:33.758Z\n---\n\n# Old-format ticket\n",
|
|
304
|
+
)
|
|
305
|
+
// And something that was never a ticket directory at all.
|
|
306
|
+
mkdirSync(join(root, ".task", "tickets", "Not A Key"), { recursive: true })
|
|
307
|
+
|
|
308
|
+
const messages = checkBoards(root, false).issues.map((issue) => issue.message)
|
|
309
|
+
assert.ok(
|
|
310
|
+
messages.some((m) => /numeric ticket directory — the pre-TAS-42 sequential-id format/.test(m)),
|
|
311
|
+
"pre-migration dir named with the migration fix",
|
|
312
|
+
)
|
|
313
|
+
assert.ok(
|
|
314
|
+
messages.some((m) => /ticket directories are named by their key/.test(m)),
|
|
315
|
+
"junk entry reported as unexpected",
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
// A modern file whose blocked_by still carries numbers is refused by the
|
|
319
|
+
// strict parser with the migration named — surfaced through check.
|
|
320
|
+
const modern = store.create({ title: "Bad link format", status: "todo" })
|
|
321
|
+
const path = join(root, ".task", "tickets", modern.key, "ticket.md")
|
|
322
|
+
writeFileSync(
|
|
323
|
+
path,
|
|
324
|
+
readFileSync(path, "utf8").replace("status: todo", "status: todo\nblocked_by: [7]"),
|
|
325
|
+
)
|
|
326
|
+
const after = checkBoards(root, false).issues.map((issue) => issue.message)
|
|
327
|
+
assert.ok(after.some((m) => /pre-TAS-42 id format, which is no longer read/.test(m)))
|
|
328
|
+
})
|