@forwardimpact/libwiki 0.2.26 → 0.2.27

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": "@forwardimpact/libwiki",
3
- "version": "0.2.26",
3
+ "version": "0.2.27",
4
4
  "description": "Wiki lifecycle primitives — stable memory for agent teams so coordination persists across sessions.",
5
5
  "keywords": [
6
6
  "wiki",
@@ -2,6 +2,7 @@ import path from "node:path";
2
2
  import { yearMonth } from "@forwardimpact/libutil";
3
3
  import { parseClaims } from "../active-claims.js";
4
4
  import { countLines, countWords } from "../budget.js";
5
+ import { parseStatusRowId } from "../status.js";
5
6
  import {
6
7
  PRIORITY_INDEX_HEADING,
7
8
  WEEKLY_LOG_NAME_RE,
@@ -120,9 +121,12 @@ function readOptional(filePath, fs) {
120
121
 
121
122
  /**
122
123
  * Parse the rows inside STATUS.md's fenced block into audit subjects. Lines
123
- * outside the ``` fence (header prose) and blank lines are skipped.
124
+ * outside the ``` fence (header prose) and blank lines are skipped. Each row
125
+ * carries a `kind` from {@link parseStatusRowId} (`"spec"`, `"experiment"`, or
126
+ * `null` for an unrecognized id); spec-shaped rules read the positional
127
+ * `id`/`phase`/`status` fields, experiment rules read `cells`.
124
128
  * @param {string} statusText - The full STATUS.md contents.
125
- * @returns {Array<{lineNo: number, text: string, cells: string[], id: string, phase: string, status: string}>}
129
+ * @returns {Array<{lineNo: number, text: string, cells: string[], id: string, phase: string, status: string, kind: string|null}>}
126
130
  */
127
131
  function parseStatusRows(statusText) {
128
132
  const lines = statusText.split("\n");
@@ -136,6 +140,12 @@ function parseStatusRows(statusText) {
136
140
  }
137
141
  if (!inFence || line.trim() === "") continue;
138
142
  const cells = line.split("\t");
143
+ // Classify by id prefix so a malformed `exp:` row (e.g. wrong cell count)
144
+ // is still routed to the experiment rules, which flag it — rather than
145
+ // slipping through the spec-shaped rules. parseStatusRowId returns the
146
+ // structured fields only for a well-formed row; the rules read `cells`.
147
+ const isExp = typeof cells[0] === "string" && cells[0].startsWith("exp:");
148
+ const parsed = parseStatusRowId(cells[0], cells);
139
149
  rows.push({
140
150
  lineNo: i + 1,
141
151
  text: line,
@@ -143,6 +153,7 @@ function parseStatusRows(statusText) {
143
153
  id: cells[0],
144
154
  phase: cells[1],
145
155
  status: cells[2],
156
+ kind: isExp ? "experiment" : parsed ? parsed.kind : null,
146
157
  });
147
158
  }
148
159
  return rows;
@@ -1,24 +1,37 @@
1
1
  import { STATUS_ID_REGEX } from "../status.js";
2
2
 
3
- // Validate every row inside wiki/STATUS.md's code fence against the
4
- // `{id}<TAB>{phase}<TAB>{status}` shape. Rows are resolved by the `status-row`
5
- // scope in scopes.js; each subject carries `{ cells, id, phase, status, text }`.
3
+ // Validate every row inside wiki/STATUS.md's code fence. Rows are resolved by
4
+ // the `status-row` scope in scopes.js; each subject carries
5
+ // `{ cells, id, phase, status, kind, text }`. Two row kinds share the fence:
6
+ //
7
+ // spec `{id}<TAB>{phase}<TAB>{status}` — three cells
8
+ // experiment `exp:{issue}<TAB>{state}<TAB>{pin}<TAB>{plan-ref}` — four cells
9
+ //
10
+ // Spec-shaped rules run for every non-experiment row (`kind !== "experiment"`,
11
+ // which includes an unrecognized id so a malformed id still flags). Experiment
12
+ // rules run only for `kind === "experiment"`.
6
13
 
7
14
  const PHASES = new Set(["spec", "design", "plan"]);
8
15
  const STATUSES = new Set(["draft", "approved", "implemented", "cancelled"]);
16
+ const EXP_STATES = new Set(["registered", "approved", "cancelled"]);
17
+ const PIN_RE = /^[0-9a-f]{40}$/;
9
18
 
10
- const hasThreeCells = (s) => s.cells.length === 3;
19
+ const isSpecShaped = (s) => s.kind !== "experiment";
20
+ const isExperiment = (s) => s.kind === "experiment";
21
+ const hasThreeCells = (s) => isSpecShaped(s) && s.cells.length === 3;
22
+ const hasFourCells = (s) => isExperiment(s) && s.cells.length === 4;
11
23
 
12
24
  export const STATUS_ROW_RULES = [
13
25
  {
14
26
  id: "status-row.shape",
15
27
  scope: "status-row",
16
28
  severity: "fail",
29
+ when: isSpecShaped,
17
30
  check: (s) =>
18
- hasThreeCells(s) ? null : { actual: s.cells.length, text: s.text },
31
+ s.cells.length === 3 ? null : { actual: s.cells.length, text: s.text },
19
32
  message: (_s, r) =>
20
33
  `${r.actual} tab-separated field(s), expected 3: "${r.text}"`,
21
- hint: "each STATUS row is `{id}<TAB>{phase}<TAB>{status}`",
34
+ hint: "each spec STATUS row is `{id}<TAB>{phase}<TAB>{status}`",
22
35
  },
23
36
  {
24
37
  id: "status-row.id-format",
@@ -48,4 +61,76 @@ export const STATUS_ROW_RULES = [
48
61
  `Bad status '${r.status}' (expected draft|approved|implemented|cancelled)`,
49
62
  hint: "status is one of draft, approved, implemented, cancelled",
50
63
  },
64
+ {
65
+ id: "status-row.exp-shape",
66
+ scope: "status-row",
67
+ severity: "fail",
68
+ when: isExperiment,
69
+ check: (s) =>
70
+ s.cells.length === 4 ? null : { actual: s.cells.length, text: s.text },
71
+ message: (_s, r) =>
72
+ `${r.actual} tab-separated field(s), expected 4: "${r.text}"`,
73
+ hint: "each experiment row is `exp:{issue}<TAB>{state}<TAB>{pin}<TAB>{plan-ref}`",
74
+ },
75
+ {
76
+ // An experiment-kind row is classified by its `exp:` id prefix
77
+ // (scopes.js), so the spec `id-format` rule is skipped for it; this rule
78
+ // enforces the `exp:\d+` id so a non-numeric issue (e.g. `exp:abc`) flags
79
+ // rather than auditing clean — keeping the audit aligned with
80
+ // STATUS_ID_REGEX / parseStatusRowId.
81
+ id: "status-row.exp-id-format",
82
+ scope: "status-row",
83
+ severity: "fail",
84
+ when: isExperiment,
85
+ check: (s) => (/^exp:\d+$/.test(s.id) ? null : { id: s.id }),
86
+ message: (_s, r) => `Bad experiment id '${r.id}' (expected exp:NNN)`,
87
+ hint: "an experiment id is `exp:` followed by the issue number",
88
+ },
89
+ {
90
+ id: "status-row.exp-state",
91
+ scope: "status-row",
92
+ severity: "fail",
93
+ when: hasFourCells,
94
+ check: (s) => (EXP_STATES.has(s.cells[1]) ? null : { state: s.cells[1] }),
95
+ message: (_s, r) =>
96
+ `Bad experiment state '${r.state}' (expected registered|approved|cancelled)`,
97
+ hint: "experiment state is one of registered, approved, cancelled",
98
+ },
99
+ {
100
+ id: "status-row.exp-pin",
101
+ scope: "status-row",
102
+ severity: "fail",
103
+ when: hasFourCells,
104
+ // The pin is decidable per state, with no "ever approved" inference: a
105
+ // `registered` row has no pin (`-`); an `approved` row pins the 40-hex
106
+ // head; a `cancelled` row may carry the retained pin or `-` (it may or may
107
+ // not have been approved before cancellation), so both are accepted.
108
+ check: (s) => {
109
+ const [, state, pin] = s.cells;
110
+ if (state === "registered") {
111
+ return pin === "-" ? null : { state, pin, want: "-" };
112
+ }
113
+ if (state === "approved") {
114
+ return PIN_RE.test(pin) ? null : { state, pin, want: "a 40-hex SHA" };
115
+ }
116
+ if (state === "cancelled") {
117
+ return pin === "-" || PIN_RE.test(pin)
118
+ ? null
119
+ : { state, pin, want: "`-` or a 40-hex SHA" };
120
+ }
121
+ return null; // bad state already flagged by exp-state
122
+ },
123
+ message: (_s, r) =>
124
+ `Bad pin '${r.pin}' for state '${r.state}' (expected ${r.want})`,
125
+ hint: "registered pins `-`; approved pins a 40-hex SHA; cancelled pins either",
126
+ },
127
+ {
128
+ id: "status-row.exp-planref",
129
+ scope: "status-row",
130
+ severity: "fail",
131
+ when: hasFourCells,
132
+ check: (s) => (/^#\d+$/.test(s.cells[3]) ? null : { planRef: s.cells[3] }),
133
+ message: (_s, r) => `Bad plan-ref '${r.planRef}' (expected #NNN)`,
134
+ hint: "the plan-ref names the issue carrying the execution plan, e.g. #NNN",
135
+ },
51
136
  ];
package/src/status.js CHANGED
@@ -1,19 +1,46 @@
1
- // STATUS.md row ids may carry a `/<unit>` suffix denoting a
2
- // per-migration-unit sub-row of a master spec (`1370/libutil`, …). The master
3
- // `NNNN` row advances only when every sub-row reads `plan implemented`.
1
+ // STATUS.md rows come in two kinds. A spec row's id is four digits with an
2
+ // optional `/<unit>` suffix denoting a per-migration-unit sub-row of a master
3
+ // spec (`1370/libutil`, …); the master `NNNN` row advances only when every
4
+ // sub-row reads `plan implemented`. An experiment row's id is `exp:<issue>`
5
+ // and the row carries four tab cells — `exp:<issue><TAB><state><TAB><pin>
6
+ // <TAB><plan-ref>` — keying the merge-gate approval path for a spec-less
7
+ // experiment PR. The `exp:` namespace cannot match the spec id's `^\d{4}`
8
+ // anchor, so the two kinds never collide for any issue-number width.
4
9
 
5
- /** Matches a status-row id: four digits, optionally a `/<unit>` suffix. */
6
- export const STATUS_ID_REGEX = /^\d{4}(\/[a-z0-9-]+)?$/;
10
+ /** Matches a status-row id: a four-digit spec id (optional `/<unit>`) or `exp:<issue>`. */
11
+ export const STATUS_ID_REGEX = /^(\d{4}(\/[a-z0-9-]+)?|exp:\d+)$/;
7
12
 
8
13
  /**
9
- * Parse a status-row id into its master spec id and optional unit suffix.
10
- * @param {string} id - The id field of a STATUS.md row.
11
- * @returns {{ specId: string, unit: string|null }|null} Parsed parts, or null
12
- * when the id does not match {@link STATUS_ID_REGEX}.
14
+ * Classify a status-row id into its kind and parts. Experiment rows are
15
+ * identified by an `exp:` id together with a four-cell row; the optional
16
+ * `cells` array supplies that count (a bare `exp:` id without four cells is
17
+ * not a valid row and yields null).
18
+ * @param {string} id - The id field (cell 0) of a STATUS.md row.
19
+ * @param {string[]} [cells] - The full tab-separated cells of the row, when
20
+ * available. Required to classify an experiment row.
21
+ * @returns {(
22
+ * {kind: "spec", specId: string, unit: string|null} |
23
+ * {kind: "experiment", issue: string, state: string, pin: string, planRef: string} |
24
+ * null
25
+ * )} Parsed parts, or null when the id/row does not match a known kind.
13
26
  */
14
- export function parseStatusRowId(id) {
27
+ export function parseStatusRowId(id, cells) {
15
28
  if (typeof id !== "string" || !STATUS_ID_REGEX.test(id)) return null;
29
+ if (id.startsWith("exp:")) {
30
+ if (!Array.isArray(cells) || cells.length !== 4) return null;
31
+ return {
32
+ kind: "experiment",
33
+ issue: id.slice("exp:".length),
34
+ state: cells[1],
35
+ pin: cells[2],
36
+ planRef: cells[3],
37
+ };
38
+ }
16
39
  const slash = id.indexOf("/");
17
- if (slash === -1) return { specId: id, unit: null };
18
- return { specId: id.slice(0, slash), unit: id.slice(slash + 1) };
40
+ if (slash === -1) return { kind: "spec", specId: id, unit: null };
41
+ return {
42
+ kind: "spec",
43
+ specId: id.slice(0, slash),
44
+ unit: id.slice(slash + 1),
45
+ };
19
46
  }