@jwilger/pi-development-system 0.59.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.59.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,6 +38,39 @@ const PLACEHOLDER =
38
38
 
39
39
  const isSection = (name: string): name is Section => SECTIONS.some((s) => s === name);
40
40
 
41
+ const FENCE_LINE = /^\s*(`{3,}|~{3,})(.*)$/;
42
+ const HEADING = /^#{1,6}\s/;
43
+
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. */
62
+ const fencedFlags = (lines: readonly string[]): boolean[] => {
63
+ let open: { char: string; length: number } | undefined;
64
+ return lines.map((line) => {
65
+ if (open === undefined) {
66
+ open = opening(line);
67
+ return open !== undefined;
68
+ }
69
+ if (closes(line, open)) open = undefined;
70
+ return true;
71
+ });
72
+ };
73
+
41
74
  type Sections = { readonly text: Map<Section, string>; readonly duplicates: Section[] };
42
75
 
43
76
  /** Splits the body into section texts keyed by name; text may continue over several lines. */
@@ -45,8 +78,9 @@ function splitSections(lines: readonly string[]): Sections {
45
78
  const found = new Map<Section, string[]>();
46
79
  const duplicates: Section[] = [];
47
80
  let current: string[] | undefined;
48
- for (const line of lines) {
49
- const match = SECTION_LINE.exec(line);
81
+ const fenced = fencedFlags(lines);
82
+ for (const [at, line] of lines.entries()) {
83
+ const match = fenced[at] === true ? null : SECTION_LINE.exec(line);
50
84
  const name = match?.[1]?.trim();
51
85
  if (match !== null && name !== undefined && isSection(name)) {
52
86
  if (found.has(name)) duplicates.push(name);
@@ -60,12 +94,13 @@ function splitSections(lines: readonly string[]): Sections {
60
94
 
61
95
  type Located = { readonly id: string; readonly title: string; readonly lines: readonly string[] };
62
96
 
63
- /** Every `## <id> — <title>` block; any other `## ` heading ends the block before it. */
97
+ /** Every `## <id> — <title>` block; any other heading (outside code fences) ends the block before it. */
64
98
  function recordsIn(lines: readonly string[]): Located[] {
65
99
  const records: { id: string; title: string; lines: string[] }[] = [];
66
100
  let open: { id: string; title: string; lines: string[] } | undefined;
67
- for (const line of lines) {
68
- if (line.startsWith("## ")) {
101
+ const fenced = fencedFlags(lines);
102
+ for (const [at, line] of lines.entries()) {
103
+ if (fenced[at] !== true && HEADING.test(line)) {
69
104
  const header = HEADER.exec(line);
70
105
  open =
71
106
  header === null
@@ -77,13 +112,26 @@ function recordsIn(lines: readonly string[]): Located[] {
77
112
  return records;
78
113
  }
79
114
 
80
- const stripTicks = (text: string): string => text.replace(/^`+|`+$/g, "").trim();
115
+ /** A command or result without its backticks, or the body of a fenced block (its info string dropped). */
116
+ const stripTicks = (text: string): string => {
117
+ const lines = text.split("\n");
118
+ const fence = opening(lines[0] ?? "");
119
+ if (fence !== undefined) {
120
+ const body = lines.slice(1);
121
+ if (closes(body.at(-1) ?? "", fence)) body.pop();
122
+ return body.join("\n").trim();
123
+ }
124
+ return text.replace(/^`+|`+$/g, "").trim();
125
+ };
81
126
 
82
- const stepsOf = (text: string): string[] =>
83
- text.split("\n").flatMap((line) => {
84
- const item = STEP.exec(line)?.[1]?.trim();
127
+ const stepsOf = (text: string): string[] => {
128
+ const lines = text.split("\n");
129
+ const fenced = fencedFlags(lines);
130
+ return lines.flatMap((line, at) => {
131
+ const item = fenced[at] === true ? undefined : STEP.exec(line)?.[1]?.trim();
85
132
  return item === undefined || item === "" ? [] : [item];
86
133
  });
134
+ };
87
135
 
88
136
  function concrete(name: "Run" | "Expected", text: string): string | undefined {
89
137
  const bare = stripTicks(text);
@@ -130,10 +178,13 @@ function locate(markdown: string, id: string | undefined): Located | ParseError
130
178
  const records = recordsIn(markdown.split("\n"));
131
179
  const names = records.map((r) => r.id).join(", ");
132
180
  if (id !== undefined) {
133
- return (
134
- records.find((r) => r.id === id) ??
135
- parseError(`no task record "${id}" (found: ${names || "none"})`)
136
- );
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`);
137
188
  }
138
189
  const [only, ...rest] = records;
139
190
  if (only === undefined) return parseError(NO_HEADER);
@@ -1,6 +1,13 @@
1
1
  import type { Exec } from "../core/exec.ts";
2
2
  import { ok } from "../core/result.ts";
3
3
  import { unwritable } from "./item-format.ts";
4
+
5
+ /** Our status marker is a label; a user label with that name would change the status as a side effect. */
6
+ const unusable = (item: { title: string; labels: readonly string[] }): string | undefined =>
7
+ item.labels.some(isMarker)
8
+ ? '"in-progress" is reserved for the in-progress status; set status instead'
9
+ : unwritable(item);
10
+
4
11
  import {
5
12
  type ItemFilter,
6
13
  type NewWorkItem,
@@ -14,6 +21,8 @@ import {
14
21
 
15
22
  const FIELDS = "number,title,body,state,labels,comments";
16
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;
17
26
  const ISSUE_NUMBER = /^[1-9][0-9]*$/;
18
27
  const STATE_ARG = { open: "open", "in-progress": "open", done: "closed" } as const;
19
28
 
@@ -38,7 +47,7 @@ const isIssue = (value: unknown): value is GhIssue =>
38
47
 
39
48
  const statusOf = (state: string, labels: readonly string[]): WorkStatus => {
40
49
  if (state === "CLOSED") return "done";
41
- return labels.includes(IN_PROGRESS) ? "in-progress" : "open";
50
+ return labels.some(isMarker) ? "in-progress" : "open";
42
51
  };
43
52
 
44
53
  const toItem = (issue: GhIssue): WorkItem => {
@@ -48,7 +57,7 @@ const toItem = (issue: GhIssue): WorkItem => {
48
57
  title: issue.title,
49
58
  body: issue.body ?? "",
50
59
  status: statusOf(issue.state, names),
51
- labels: names.filter((n) => n !== IN_PROGRESS),
60
+ labels: names.filter((n) => !isMarker(n)),
52
61
  comments: (issue.comments ?? []).map((c) => c.body),
53
62
  };
54
63
  };
@@ -108,7 +117,7 @@ const view = async (gh: Gh, id: string): Promise<TrackerResult<WorkItem>> => {
108
117
  return raw.ok ? ok(toItem(raw.value)) : raw;
109
118
  };
110
119
 
111
- const LIST_LIMIT = 500;
120
+ const LIST_LIMIT = 1000;
112
121
 
113
122
  /** The `gh issue list` arguments that select exactly the wanted status where gh can, so the limit bites last. */
114
123
  const listArgs = (status: WorkStatus | undefined): string[] => {
@@ -132,9 +141,14 @@ const listIssues = async (gh: Gh, filter: ItemFilter): Promise<TrackerResult<Wor
132
141
  if (!out.ok) return out;
133
142
  const parsed = parseIssues(out.value);
134
143
  if (parsed === undefined) return trackerError("gh issue list: unexpected output");
135
- 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";
136
150
  return trackerError(
137
- `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}`,
138
152
  );
139
153
  }
140
154
  const items = parsed.map(toItem);
@@ -142,7 +156,7 @@ const listIssues = async (gh: Gh, filter: ItemFilter): Promise<TrackerResult<Wor
142
156
  };
143
157
 
144
158
  const createIssue = async (gh: Gh, input: NewWorkItem): Promise<TrackerResult<WorkItem>> => {
145
- const problem = unwritable({ title: input.title, labels: input.labels ?? [] });
159
+ const problem = unusable({ title: input.title, labels: input.labels ?? [] });
146
160
  if (problem !== undefined) return trackerError(problem);
147
161
  const labels = (input.labels ?? []).flatMap((l) => ["--label", l]);
148
162
  const out = await gh([
@@ -203,12 +217,12 @@ const updateIssue = async (
203
217
  id: string,
204
218
  patch: WorkItemPatch,
205
219
  ): Promise<TrackerResult<WorkItem>> => {
206
- const problem = unwritable({ title: patch.title ?? "x", labels: patch.labels ?? [] });
220
+ const problem = unusable({ title: patch.title ?? "x", labels: patch.labels ?? [] });
207
221
  if (problem !== undefined) return trackerError(problem);
208
222
  const raw = await viewRaw(gh, id);
209
223
  if (!raw.ok) return raw;
210
224
  const current = toItem(raw.value);
211
- const hadMarker = (raw.value.labels ?? []).some((l) => l.name === IN_PROGRESS);
225
+ const hadMarker = (raw.value.labels ?? []).some((l) => isMarker(l.name));
212
226
  if (patch.status === "in-progress") {
213
227
  // No --force: that would recolour the team's existing label. "Already exists" is success.
214
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,25 +27,21 @@ 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`;
33
36
  };
34
37
 
38
+ /** Written to pass our own markdownlint: no trailing space, no repeated heading text, no stacked blank lines. */
35
39
  export const renderItem = (item: WorkItem): string => {
36
- const comments = item.comments.map((c) => `\n### Comment\n\n${quote(c)}\n`).join("");
37
- return [
38
- `# ${item.title}`,
39
- "",
40
- `Status: ${item.status}`,
41
- `Labels: ${item.labels.join(", ")}`,
42
- "",
43
- item.body,
44
- COMMENTS + comments,
45
- ].join("\n");
40
+ const labels = item.labels.length === 0 ? "" : ` ${item.labels.join(", ")}`;
41
+ const top = `# ${item.title}\n\nStatus: ${item.status}\nLabels:${labels}`;
42
+ const body = item.body === "" ? [] : [item.body];
43
+ const comments = item.comments.map((c, n) => `\n### Comment ${n + 1}\n\n${quote(c)}\n`);
44
+ return `${[top, ...body, "## Comments"].join("\n\n")}\n${comments.join("")}`;
46
45
  };
47
46
 
48
47
  const isStatus = (value: string): value is WorkStatus =>
@@ -50,7 +49,7 @@ const isStatus = (value: string): value is WorkStatus =>
50
49
 
51
50
  const commentsOf = (section: string): string[] =>
52
51
  section
53
- .split(/^### Comment\n\n/m)
52
+ .split(/^### Comment(?: \d+)?\n\n/m)
54
53
  .slice(1)
55
54
  .map((c) => unquote(c.replace(/\n+$/, "")));
56
55
 
@@ -59,7 +58,7 @@ export const parseItem = (id: string, raw: string): TrackerResult<WorkItem> => {
59
58
  const marker = text.lastIndexOf(COMMENTS);
60
59
  const main = marker < 0 ? text : text.slice(0, marker);
61
60
  const comments = marker < 0 ? [] : commentsOf(text.slice(marker + COMMENTS.length));
62
- const head = /^# (.+)\n\nStatus: (.*)\nLabels: (.*)\n\n?/.exec(main);
61
+ const head = /^# (.+)\n\nStatus: (.*)\nLabels:[ ]?(.*)(?:\n\n|\n$|$)/.exec(main);
63
62
  if (head === null)
64
63
  return trackerError(`work item ${id}: expected "# title", Status and Labels lines`);
65
64
  const [whole, title = "", status = "", labels = ""] = head;
@@ -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)." })),