@jwilger/pi-development-system 0.60.0 → 0.61.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jwilger/pi-development-system",
3
- "version": "0.60.0",
3
+ "version": "0.61.0",
4
4
  "description": "A pi extension package representing a seasoned approach to software development using a full AI SDLC.",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -38,15 +38,35 @@ const PLACEHOLDER =
38
38
 
39
39
  const isSection = (name: string): name is Section => SECTIONS.some((s) => s === name);
40
40
 
41
- const FENCE = /^\s*(?:```|~~~)/;
41
+ const FENCE_LINE = /^\s*(`{3,}|~{3,})(.*)$/;
42
42
  const HEADING = /^#{1,6}\s/;
43
43
 
44
- /** For each line, whether it sits inside (or on the edge of) a fenced code block. */
44
+ /** Opening fence: its character and length. Backtick fences cannot carry backticks in the info string. */
45
+ const opening = (line: string): { char: string; length: number } | undefined => {
46
+ const m = FENCE_LINE.exec(line);
47
+ const run = m?.[1];
48
+ if (run === undefined || (run.startsWith("`") && (m?.[2] ?? "").includes("`"))) return undefined;
49
+ return { char: run.slice(0, 1), length: run.length };
50
+ };
51
+
52
+ /** A closing fence repeats the opening character at least as many times and carries no text. */
53
+ const closes = (line: string, open: { char: string; length: number }): boolean => {
54
+ const m = FENCE_LINE.exec(line);
55
+ const run = m?.[1];
56
+ return (
57
+ run?.startsWith(open.char) === true && run.length >= open.length && (m?.[2] ?? "").trim() === ""
58
+ );
59
+ };
60
+
61
+ /** For each line, whether it sits inside (or on the edge of) a fenced code block, as CommonMark reads fences. */
45
62
  const fencedFlags = (lines: readonly string[]): boolean[] => {
46
- let open = false;
63
+ let open: { char: string; length: number } | undefined;
47
64
  return lines.map((line) => {
48
- if (!FENCE.test(line)) return open;
49
- open = !open;
65
+ if (open === undefined) {
66
+ open = opening(line);
67
+ return open !== undefined;
68
+ }
69
+ if (closes(line, open)) open = undefined;
50
70
  return true;
51
71
  });
52
72
  };
@@ -95,9 +115,10 @@ function recordsIn(lines: readonly string[]): Located[] {
95
115
  /** A command or result without its backticks, or the body of a fenced block (its info string dropped). */
96
116
  const stripTicks = (text: string): string => {
97
117
  const lines = text.split("\n");
98
- if (FENCE.test(lines[0] ?? "")) {
118
+ const fence = opening(lines[0] ?? "");
119
+ if (fence !== undefined) {
99
120
  const body = lines.slice(1);
100
- if (FENCE.test(body.at(-1) ?? "")) body.pop();
121
+ if (closes(body.at(-1) ?? "", fence)) body.pop();
101
122
  return body.join("\n").trim();
102
123
  }
103
124
  return text.replace(/^`+|`+$/g, "").trim();
@@ -157,10 +178,13 @@ function locate(markdown: string, id: string | undefined): Located | ParseError
157
178
  const records = recordsIn(markdown.split("\n"));
158
179
  const names = records.map((r) => r.id).join(", ");
159
180
  if (id !== undefined) {
160
- return (
161
- records.find((r) => r.id === id) ??
162
- parseError(`no task record "${id}" (found: ${names || "none"})`)
163
- );
181
+ const named = records.filter((r) => r.id === id);
182
+ const [first] = named;
183
+ if (first === undefined)
184
+ return parseError(`no task record "${id}" (found: ${names || "none"})`);
185
+ return named.length === 1
186
+ ? first
187
+ : parseError(`task record "${id}" appears ${named.length} times; ids must be unique`);
164
188
  }
165
189
  const [only, ...rest] = records;
166
190
  if (only === undefined) return parseError(NO_HEADER);
@@ -4,7 +4,7 @@ import { unwritable } from "./item-format.ts";
4
4
 
5
5
  /** Our status marker is a label; a user label with that name would change the status as a side effect. */
6
6
  const unusable = (item: { title: string; labels: readonly string[] }): string | undefined =>
7
- item.labels.includes("in-progress")
7
+ item.labels.some(isMarker)
8
8
  ? '"in-progress" is reserved for the in-progress status; set status instead'
9
9
  : unwritable(item);
10
10
 
@@ -21,6 +21,8 @@ import {
21
21
 
22
22
  const FIELDS = "number,title,body,state,labels,comments";
23
23
  const IN_PROGRESS = "in-progress";
24
+ /** GitHub label names are case-insensitive, so `In-Progress` is our marker too. */
25
+ const isMarker = (name: string): boolean => name.toLowerCase() === IN_PROGRESS;
24
26
  const ISSUE_NUMBER = /^[1-9][0-9]*$/;
25
27
  const STATE_ARG = { open: "open", "in-progress": "open", done: "closed" } as const;
26
28
 
@@ -45,7 +47,7 @@ const isIssue = (value: unknown): value is GhIssue =>
45
47
 
46
48
  const statusOf = (state: string, labels: readonly string[]): WorkStatus => {
47
49
  if (state === "CLOSED") return "done";
48
- return labels.includes(IN_PROGRESS) ? "in-progress" : "open";
50
+ return labels.some(isMarker) ? "in-progress" : "open";
49
51
  };
50
52
 
51
53
  const toItem = (issue: GhIssue): WorkItem => {
@@ -55,7 +57,7 @@ const toItem = (issue: GhIssue): WorkItem => {
55
57
  title: issue.title,
56
58
  body: issue.body ?? "",
57
59
  status: statusOf(issue.state, names),
58
- labels: names.filter((n) => n !== IN_PROGRESS),
60
+ labels: names.filter((n) => !isMarker(n)),
59
61
  comments: (issue.comments ?? []).map((c) => c.body),
60
62
  };
61
63
  };
@@ -115,7 +117,7 @@ const view = async (gh: Gh, id: string): Promise<TrackerResult<WorkItem>> => {
115
117
  return raw.ok ? ok(toItem(raw.value)) : raw;
116
118
  };
117
119
 
118
- const LIST_LIMIT = 500;
120
+ const LIST_LIMIT = 1000;
119
121
 
120
122
  /** The `gh issue list` arguments that select exactly the wanted status where gh can, so the limit bites last. */
121
123
  const listArgs = (status: WorkStatus | undefined): string[] => {
@@ -139,9 +141,14 @@ const listIssues = async (gh: Gh, filter: ItemFilter): Promise<TrackerResult<Wor
139
141
  if (!out.ok) return out;
140
142
  const parsed = parseIssues(out.value);
141
143
  if (parsed === undefined) return trackerError("gh issue list: unexpected output");
142
- if (parsed.length >= LIST_LIMIT) {
144
+ // gh lists newest first. Cutting off old closed issues is harmless; cutting off open work is not.
145
+ if (parsed.length >= LIST_LIMIT && filter.status !== "done") {
146
+ const advice =
147
+ filter.status === undefined
148
+ ? "; ask for one status to narrow it"
149
+ : "; this adapter lists at most that many";
143
150
  return trackerError(
144
- `gh issue list returned ${LIST_LIMIT} issues, so the list may be cut short; ask for one status (open, in-progress or done) to narrow it`,
151
+ `gh issue list returned ${LIST_LIMIT} issues, so the list may be cut short${advice}`,
145
152
  );
146
153
  }
147
154
  const items = parsed.map(toItem);
@@ -215,7 +222,7 @@ const updateIssue = async (
215
222
  const raw = await viewRaw(gh, id);
216
223
  if (!raw.ok) return raw;
217
224
  const current = toItem(raw.value);
218
- const hadMarker = (raw.value.labels ?? []).some((l) => l.name === IN_PROGRESS);
225
+ const hadMarker = (raw.value.labels ?? []).some((l) => isMarker(l.name));
219
226
  if (patch.status === "in-progress") {
220
227
  // No --force: that would recolour the team's existing label. "Already exists" is success.
221
228
  const made = await gh(["label", "create", IN_PROGRESS]);
@@ -7,6 +7,9 @@ import {
7
7
  type WorkStatus,
8
8
  } from "./types.ts";
9
9
 
10
+ /** Every line terminator `.` in the item header pattern cannot cross, plus NEL for safety. */
11
+ const LINE_BREAK = /[\r\n\u2028\u2029\u0085]/;
12
+
10
13
  const COMMENTS = "\n## Comments\n";
11
14
 
12
15
  /** Comment lines are quoted so nothing a comment says can look like one of our headings. */
@@ -24,9 +27,9 @@ const unquote = (text: string): string =>
24
27
 
25
28
  /** What the file format cannot hold, or undefined when the item can be written and read back. */
26
29
  export const unwritable = (item: Pick<WorkItem, "title" | "labels">): string | undefined => {
27
- if (/[\r\n]/.test(item.title)) return "a work item title must be a single line";
30
+ if (LINE_BREAK.test(item.title)) return "a work item title must be a single line";
28
31
  if (item.title.trim() === "") return "a work item title cannot be blank";
29
- const bad = item.labels.find((l) => /[\r\n,]/.test(l) || l.trim() === "");
32
+ const bad = item.labels.find((l) => LINE_BREAK.test(l) || l.includes(",") || l.trim() === "");
30
33
  return bad === undefined
31
34
  ? undefined
32
35
  : `label ${JSON.stringify(bad)} is blank or holds a comma or line break`;
@@ -24,7 +24,8 @@ const Parameters = Type.Object({
24
24
  ),
25
25
  status: Type.Optional(
26
26
  Type.Union([Type.Literal("open"), Type.Literal("in-progress"), Type.Literal("done")], {
27
- description: "Status (update), or a filter (list).",
27
+ description:
28
+ "Status (update), or a filter (list). On GitHub, listing done shows the newest 1000 closed issues.",
28
29
  }),
29
30
  ),
30
31
  labels: Type.Optional(Type.Array(Type.String(), { description: "Labels (create, update)." })),