passalong 0.2.2 → 0.3.1

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/src/guide.js CHANGED
@@ -4,8 +4,43 @@
4
4
  // lists of strings, so a deliberately tiny parser covers it and stays readable when hand-edited.
5
5
  import { randomBytes } from "node:crypto";
6
6
 
7
+ /**
8
+ * A guide is a draft or it is published, and `consumed` is the author's shelf — off the board, out
9
+ * of the free tier's count, reversible. `promoted` is the one true legacy: an author lifecycle laid
10
+ * over a transfer that already reports itself, since a pull count says how travelled a guide is
11
+ * without anyone having to grade it.
12
+ *
13
+ * This list is what is **accepted**, and it is not what may be **set** — that is `SETTABLE` below.
14
+ * The distinction is load-bearing. A status lives in frontmatter, inside markdown files in other
15
+ * people's repositories, and `validate()` rejects a status it does not know, so dropping a value
16
+ * from here would make a guide shared a month ago fail to re-share today.
17
+ */
7
18
  export const STATUSES = ["draft", "published", "consumed", "promoted"];
8
19
 
20
+ /**
21
+ * What a write may choose. `promoted` is absent: it cannot be set on a guide that does not already
22
+ * carry it, from any surface.
23
+ *
24
+ * Two reasons, and the second is the sharper one. A status that can still be set reads as a
25
+ * feature to whoever meets it next, however many deprecation notices sit around it. And
26
+ * `quota.ts` counts `published` and `promoted` as the statuses that occupy room — so while
27
+ * `promoted` remains settable, promoting every guide is an unlimited free tier. Closing the write
28
+ * closes that, and does it without touching what the free tier counts, which still has to include
29
+ * the guides already carrying the status.
30
+ */
31
+ export const SETTABLE = ["draft", "published", "consumed"];
32
+ /**
33
+ * What a guide is for. The receiving agent behaves completely differently depending on the
34
+ * answer, so it is a field rather than something inferred from the body.
35
+ *
36
+ * transfer work that is finished here and should be repeated there — follow the Steps
37
+ * bug something broken there — do NOT follow anything; fix it
38
+ *
39
+ * A guide with no `kind` is a transfer guide: every guide written before this existed is one,
40
+ * and defaulting the other way would turn them all into bug reports.
41
+ */
42
+ export const KINDS = ["transfer", "bug"];
43
+
9
44
  // Body sections in the order a guide should present them. The heading text is what the
10
45
  // receiving agent keys on, so keep these stable.
11
46
  export const SECTIONS = [
@@ -17,7 +52,86 @@ export const SECTIONS = [
17
52
  "Gotchas",
18
53
  ];
19
54
 
55
+ /**
56
+ * The product areas a bug report offers, mirrored in apps/api/src/guide.ts the same way this
57
+ * file's parsing rules are.
58
+ *
59
+ * Offered, not enforced: nothing rejects an area that is not on this list. A team that ships a
60
+ * sixth surface should be able to file against it that afternoon rather than after a release, so
61
+ * this is what a menu shows and what turns a slug back into words — not a gate.
62
+ */
63
+ export const AREAS = [
64
+ { slug: "web", label: "Web App" },
65
+ { slug: "mobile", label: "Mobile App" },
66
+ { slug: "storefront", label: "Storefront Editor" },
67
+ { slug: "landing", label: "Landing Page" },
68
+ { slug: "auth", label: "Auth Page" },
69
+ ];
70
+
71
+ /** How badly it is broken. Same accept-anything rule as AREAS. */
72
+ export const SEVERITIES = [
73
+ { slug: "s1", label: "Blocker" },
74
+ { slug: "s2", label: "Major" },
75
+ { slug: "s3", label: "Minor" },
76
+ { slug: "s4", label: "Cosmetic" },
77
+ ];
78
+
79
+ /**
80
+ * A bug report's sections. The repro is under `Reproduce`, never `Steps`.
81
+ *
82
+ * That distinction is the whole reason `kind` exists. `Steps` is an instruction to execute —
83
+ * "follow its Steps" is what the MCP server tells every agent that pulls a guide — and steps that
84
+ * reproduce a defect are the one list that must never be run as a remedy. An agent handed a bug
85
+ * with its repro under `Steps` will faithfully reproduce the bug, check the Verification, find it
86
+ * false because the bug is real, and report that the guide does not work.
87
+ */
88
+ export const BUG_SECTIONS = ["Problem", "Reproduce", "Verification", "Gotchas"];
89
+
90
+ /**
91
+ * One line at the top of every bug, saying what the document is.
92
+ *
93
+ * `kind: bug` in the frontmatter is the machine-readable answer and `Reproduce` is a heading no
94
+ * one executes by mistake, but neither is a sentence. This is, and it is inside the document, so
95
+ * it reaches a reader that fetched a share link over plain HTTP and has never heard of Passalong.
96
+ */
97
+ export const BUG_LEAD =
98
+ "> **Bug report.** The steps under Reproduce show the problem \u2014 they are not a fix to " +
99
+ "apply. Fix what Problem describes, then check Verification.";
100
+
101
+ /** The sections a guide of this kind presents, in order. */
102
+ export function sectionsFor(kind) {
103
+ return kind === "bug" ? BUG_SECTIONS : SECTIONS;
104
+ }
105
+
106
+ /** The sections it cannot be published without. */
107
+ const REQUIRED = { transfer: ["Problem", "Steps"], bug: ["Problem", "Reproduce"] };
108
+
20
109
  // Fields that hold a list of strings. Everything else is a plain string.
110
+ /**
111
+ * One tag, in the one style tags are written in: lowercase, words joined by a hyphen.
112
+ *
113
+ * Nothing used to normalise them, so `custom-fields` and `additional_information` could sit on the
114
+ * same guide and the same idea exist twice under two spellings. A tag is a controlled vocabulary,
115
+ * not prose — its whole value is that two people who mean the same thing write the same string.
116
+ * Mirrors `tag()` in apps/api/src/guide.ts.
117
+ */
118
+ export function tag(raw) {
119
+ return String(raw ?? "")
120
+ .trim()
121
+ .toLowerCase()
122
+ .replace(/[\s_]+/g, "-")
123
+ .replace(/[^a-z0-9-]/g, "")
124
+ .replace(/-{2,}/g, "-")
125
+ .slice(0, 32)
126
+ .replace(/^-+|-+$/g, "");
127
+ }
128
+
129
+ /** A guide's tags: normalised, emptied of blanks, deduped once two spellings become one. */
130
+ export function tagList(raw) {
131
+ const list = Array.isArray(raw) ? raw : raw === undefined || raw === null ? [] : [raw];
132
+ return [...new Set(list.map(tag).filter(Boolean))];
133
+ }
134
+
21
135
  const LIST_FIELDS = new Set(["stack_assumptions", "tags"]);
22
136
 
23
137
  // IDs are short enough to type and say out loud. 8 chars from a 31-letter alphabet with the
@@ -40,11 +154,45 @@ function scalar(raw) {
40
154
  return v;
41
155
  }
42
156
 
157
+ /**
158
+ * Split a flow sequence's inner text on the commas *between* items, not the ones inside them.
159
+ * `quote()` wraps any value containing a comma, so a plain `split(",")` tears those items apart —
160
+ * and the pieces keep their stray quotes, because `scalar()` only unwraps a value quoted at both
161
+ * ends. Serialising a list and parsing it back has to return the same list.
162
+ */
163
+ function items(inner) {
164
+ const out = [];
165
+ let buf = "";
166
+ let quoted = null;
167
+ for (let i = 0; i < inner.length; i++) {
168
+ const c = inner[i];
169
+ if (quoted) {
170
+ // `quote()` escapes an inner double quote as \", which does not close the value.
171
+ if (c === "\\" && quoted === '"' && inner[i + 1] === '"') {
172
+ buf += c + inner[i + 1];
173
+ i++;
174
+ continue;
175
+ }
176
+ buf += c;
177
+ if (c === quoted) quoted = null;
178
+ continue;
179
+ }
180
+ if (c === '"' || c === "'") quoted = c;
181
+ if (c === ",") {
182
+ out.push(buf);
183
+ buf = "";
184
+ continue;
185
+ }
186
+ buf += c;
187
+ }
188
+ out.push(buf);
189
+ return out;
190
+ }
191
+
43
192
  function inlineList(raw) {
44
193
  const inner = raw.trim().slice(1, -1).trim();
45
194
  if (inner === "") return [];
46
- return inner
47
- .split(",")
195
+ return items(inner)
48
196
  .map(scalar)
49
197
  .filter((s) => s !== "");
50
198
  }
@@ -56,7 +204,9 @@ export function parseFrontmatter(text) {
56
204
  let listKey = null;
57
205
  for (const line of lines) {
58
206
  if (line.trim() === "" || line.trim().startsWith("#")) continue;
59
- const item = /^\s+-\s*(.*)$/.exec(line);
207
+ // YAML lets a block sequence sit flush with its key, so the indent is optional. Requiring it
208
+ // dropped `- item` lines silently: they match no key either, and the field stayed empty.
209
+ const item = /^\s*-\s*(.*)$/.exec(line);
60
210
  if (item && listKey) {
61
211
  meta[listKey].push(scalar(item[1]));
62
212
  continue;
@@ -76,6 +226,9 @@ export function parseFrontmatter(text) {
76
226
  }
77
227
  }
78
228
  for (const k of LIST_FIELDS) if (meta[k] === undefined) meta[k] = [];
229
+ // Both ends of this module: what it reads and what it writes are in one style, so a guide
230
+ // written before there was a rule comes back normalised and goes out normalised.
231
+ meta.tags = tagList(meta.tags);
79
232
  return meta;
80
233
  }
81
234
 
@@ -88,14 +241,23 @@ function quote(v) {
88
241
  const META_ORDER = [
89
242
  "id",
90
243
  "title",
244
+ "kind",
91
245
  "created",
92
246
  "author",
93
247
  "source_context",
248
+ // Where it came from, in the two senses a guide has one: `source_context` is the repo and branch
249
+ // it was written in, `parent` is the guide it was written *out of*.
250
+ "parent",
94
251
  "status",
95
252
  "team",
96
253
  "to",
97
254
  "stack_assumptions",
98
255
  "tags",
256
+ // Bug reports only. `report` is the parent a set of issues was filed under; the other two are
257
+ // where it is and how badly it is broken.
258
+ "report",
259
+ "area",
260
+ "severity",
99
261
  ];
100
262
 
101
263
  export function serializeFrontmatter(meta) {
@@ -105,7 +267,7 @@ export function serializeFrontmatter(meta) {
105
267
  ];
106
268
  const out = [];
107
269
  for (const key of keys) {
108
- const v = meta[key];
270
+ const v = key === "tags" ? tagList(meta[key]) : meta[key];
109
271
  if (Array.isArray(v)) {
110
272
  out.push(v.length ? `${key}: [${v.map(quote).join(", ")}]` : `${key}: []`);
111
273
  } else {
@@ -143,19 +305,60 @@ export function sections(body) {
143
305
  return out;
144
306
  }
145
307
 
308
+ /**
309
+ * Image targets no reader will ever load. Mirrors `unreachableImages` in apps/api/src/guide.ts,
310
+ * which is the authority and refuses the publish; this lets a local share fail before the network.
311
+ *
312
+ * Only a foreign scheme counts — `attachment://`, `blob:`, `file:`, `data:`. `https:` is left alone
313
+ * because embedding an image you host is legitimate, and a relative path is left alone because it
314
+ * has always been accepted.
315
+ */
316
+ export function unreachableImages(markdown) {
317
+ const bad = [...String(markdown).matchAll(/!\[[^\]]*\]\(\s*([^)\s]+)/g)]
318
+ .map((m) => m[1].trim())
319
+ .filter((target) => {
320
+ const scheme = /^([a-z][a-z0-9+.\-]*):/i.exec(target);
321
+ return Boolean(scheme) && !/^https?$/i.test(scheme[1]);
322
+ });
323
+ return [...new Set(bad)];
324
+ }
325
+
146
326
  /** Problems that make a guide unfit to publish. Empty array means it is fine. */
147
327
  export function validate({ meta, body }) {
148
328
  const errors = [];
149
329
  if (!meta.title) errors.push("frontmatter needs a title");
150
330
  if (meta.id && !ID_RE.test(meta.id)) errors.push(`id "${meta.id}" is not a valid passalong id`);
331
+ // A parent is optional, and a self-parent is always a mistake worth refusing.
332
+ //
333
+ // A malformed one is not refused, though, and that is deliberate. `parent:` is a field name this
334
+ // format did not reserve until now, so a guide written months ago may already carry one meaning
335
+ // something else entirely — and a guide shared then has to still re-share today. Rejecting the
336
+ // value would make that document unpublishable by its own author, which is the failure the
337
+ // STATUSES comment above describes. The server drops a parent it cannot resolve, so the worst
338
+ // case is lineage that silently does not apply rather than work nobody can hand on.
339
+ if (meta.parent && meta.id && meta.parent === meta.id)
340
+ errors.push("a guide cannot follow itself");
151
341
  if (meta.status && !STATUSES.includes(meta.status)) {
152
342
  errors.push(`status must be one of ${STATUSES.join(", ")}`);
153
343
  }
344
+ const kind = meta.kind || "transfer";
345
+ if (!KINDS.includes(kind)) errors.push(`kind must be one of ${KINDS.join(", ")}`);
154
346
  const have = sections(body);
155
- for (const s of ["Problem", "Steps"]) {
347
+ for (const s of REQUIRED[kind] || REQUIRED.transfer) {
156
348
  if (!have[s]) errors.push(`missing "## ${s}" section`);
157
349
  }
350
+ // A bug whose repro sits under `Steps` is the failure mode `kind` exists to prevent, and it is
351
+ // worth catching at publish rather than letting an agent discover it by reproducing the bug.
352
+ if (kind === "bug" && have.Steps) {
353
+ errors.push('a bug uses "## Reproduce", not "## Steps" — an agent executes Steps');
354
+ }
158
355
  if (body.includes("<!-- passalong:")) errors.push("template placeholders are still in the body");
356
+ // An agent that has a file id but never called attach_screenshot writes the id as if it were a
357
+ // link. It publishes, and stores a guide whose screenshot was never uploaded anywhere.
358
+ for (const target of unreachableImages(body))
359
+ errors.push(
360
+ `${target} is not a URL a reader can load — attach the image first and use the URL it returns`,
361
+ );
159
362
  return errors;
160
363
  }
161
364
 
@@ -170,8 +373,85 @@ export function stamp(guide, defaults = {}) {
170
373
  return { meta, body: guide.body };
171
374
  }
172
375
 
376
+ /**
377
+ * One bug, as the guide it becomes.
378
+ *
379
+ * This is the filing shape: fields in, a valid document out, so nothing that files a bug has to
380
+ * remember which heading the repro goes under. The hub's form assembles its own because it has
381
+ * more to say — a device string, screenshots, and a rich text field it converts — but the section
382
+ * names and the order are these, and `validate()` is the thing both answer to.
383
+ */
384
+ export function bugGuide({
385
+ title = "",
386
+ problem = "",
387
+ reproduce = "",
388
+ verification = "",
389
+ gotchas = "",
390
+ severity = "s3",
391
+ area = "",
392
+ report = "",
393
+ environment = "",
394
+ status = "published",
395
+ evidence = [],
396
+ } = {}) {
397
+ /**
398
+ * Screenshots, as markdown inside the document.
399
+ *
400
+ * They go under Problem rather than a heading of their own, because for a visual defect the
401
+ * picture *is* the problem statement and a bug's sections are a fixed set — a seventh heading
402
+ * would change the shape every reader keys on to add a line that already belongs in the first
403
+ * one.
404
+ *
405
+ * Naming them in the body is also the only thing that binds them: `claimShots` claims whatever
406
+ * the markdown points at, scoped to the author, so a URL listed anywhere else would upload
407
+ * evidence that no guide owns and the nightly sweep deletes.
408
+ */
409
+ const shots = (Array.isArray(evidence) ? evidence : [evidence])
410
+ .map((s) => String(s ?? "").trim())
411
+ .filter(Boolean)
412
+ // A caller that already has the markdown line from `attach_screenshot` passes it through
413
+ // whole; one holding just the URL gets it wrapped. Both reach the same document.
414
+ .map((s) => (s.startsWith("![") ? s : `![evidence](${s})`));
415
+ const body = [
416
+ // Written into the document, not added by whatever served it.
417
+ //
418
+ // get_guide says this in front of the markdown, but that only reaches an MCP client. A share
419
+ // link is plain markdown any agent can fetch over HTTP, and the route that serves it cannot
420
+ // decorate the response: `api.byLink` pulls guides *through* that route and re-serialises what
421
+ // it gets, so anything added there would be written to disk and published back on the next
422
+ // share. A line in the body survives that round trip unchanged and reaches every reader.
423
+ BUG_LEAD,
424
+ "",
425
+ "## Problem",
426
+ problem.trim() || "_No description given._",
427
+ ];
428
+ if (shots.length) body.push("", ...shots);
429
+ body.push(
430
+ "",
431
+ "## Reproduce",
432
+ reproduce.trim() || "_Not recorded — the description above is what there is._",
433
+ );
434
+ // Both are optional and both are worse than absent when empty: a Verification heading with
435
+ // nothing under it says nobody knows what fixed looks like, on the section a fixer reads first.
436
+ if (verification.trim()) body.push("", "## Verification", verification.trim());
437
+ if (gotchas.trim()) body.push("", "## Gotchas", gotchas.trim());
438
+
439
+ const meta = {
440
+ title,
441
+ kind: "bug",
442
+ status,
443
+ tags: ["bug", environment, area].filter(Boolean),
444
+ };
445
+ if (report) meta.report = report;
446
+ if (area) meta.area = area;
447
+ if (severity) meta.severity = severity;
448
+ if (environment) meta.source_context = environment;
449
+ return serialize({ meta, body: body.join("\n") });
450
+ }
451
+
173
452
  /** A draft with the section skeleton. Placeholders are HTML comments so they vanish when rendered. */
174
453
  export function template(meta = {}) {
454
+ if (meta.kind === "bug") return bugTemplate(meta);
175
455
  const body = [
176
456
  "## Problem",
177
457
  "<!-- passalong: What was broken or needed, in two or three sentences. -->",
@@ -205,6 +485,43 @@ export function template(meta = {}) {
205
485
  });
206
486
  }
207
487
 
488
+ /**
489
+ * One bug, as a guide. Four sections and no `Steps` anywhere in it.
490
+ *
491
+ * `Verification` carries its usual meaning and does its usual job: it is what the person who
492
+ * fixes this runs to show they did, and it is what they are answering when they give a verdict.
493
+ * For a bug that is simply the behaviour that should have happened.
494
+ */
495
+ function bugTemplate(meta = {}) {
496
+ const body = [
497
+ "## Problem",
498
+ "<!-- passalong: What is broken, and what it stops someone doing. -->",
499
+ "",
500
+ "## Reproduce",
501
+ "<!-- passalong: How to see it. These steps produce the bug — they are not a fix to apply. -->",
502
+ "",
503
+ "## Verification",
504
+ "<!-- passalong: What should happen instead, as something the fixer can check. -->",
505
+ "",
506
+ "## Gotchas",
507
+ "<!-- passalong: Anything already ruled out, or that made it hard to pin down. -->",
508
+ ].join("\n");
509
+ return serialize({
510
+ meta: {
511
+ title: "",
512
+ kind: "bug",
513
+ author: "",
514
+ source_context: "",
515
+ status: "draft",
516
+ severity: "s3",
517
+ stack_assumptions: [],
518
+ tags: ["bug"],
519
+ ...meta,
520
+ },
521
+ body,
522
+ });
523
+ }
524
+
208
525
  /** Strip the template comments so an untouched section reads as empty rather than as a prompt. */
209
526
  export function stripPlaceholders(body) {
210
527
  return body.replace(/^<!-- passalong:.*?-->\n?/gm, "").trim();