@forwardimpact/libwiki 0.1.5 → 0.2.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.
@@ -1,4 +1,4 @@
1
- import { mkdirSync } from "node:fs";
1
+ import { mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
2
2
  import { spawnSync } from "node:child_process";
3
3
  import path from "node:path";
4
4
  import fsAsync from "node:fs/promises";
@@ -6,6 +6,11 @@ import { Finder } from "@forwardimpact/libutil";
6
6
  import { createScriptConfig } from "@forwardimpact/libconfig";
7
7
  import { WikiRepo } from "../wiki-repo.js";
8
8
  import { listSkills } from "../skill-roster.js";
9
+ import {
10
+ ACTIVE_CLAIMS_HEADING,
11
+ ACTIVE_CLAIMS_TABLE_HEADER,
12
+ ACTIVE_CLAIMS_TABLE_SEPARATOR,
13
+ } from "../constants.js";
9
14
 
10
15
  /** Resolve the wiki clone URL. Honors the FIT_WIKI_URL env var as an explicit override (for sandboxed environments where `origin` is rewritten to a local proxy that does not serve wiki repos); otherwise derives the URL by appending `.wiki.git` to the parent repo's `origin` remote. */
11
16
  export function deriveWikiUrl(parentDir) {
@@ -21,18 +26,96 @@ export function deriveWikiUrl(parentDir) {
21
26
  return base + ".wiki.git";
22
27
  }
23
28
 
24
- /** Clone the wiki if not already present (URL derived from origin remote), copy git identity from the parent repo, and create metric directories for each kata skill. */
25
- export async function runInitCommand(values, _args, cli) {
26
- const logger = { debug() {} };
27
- const finder = new Finder(fsAsync, logger, process);
28
- const projectRoot = finder.findProjectRoot(process.cwd());
29
+ function scaffoldActiveClaims(memoryPath) {
30
+ if (!existsSync(memoryPath)) return false;
31
+ const text = readFileSync(memoryPath, "utf-8");
32
+ if (new RegExp(`^${ACTIVE_CLAIMS_HEADING}$`, "m").test(text)) return false;
29
33
 
30
- const wikiDir = path.resolve(projectRoot, values["wiki-root"] ?? "wiki");
31
- const skillsDir = path.resolve(
32
- projectRoot,
33
- values["skills-dir"] ?? path.join(".claude", "skills"),
34
- );
34
+ const block = [
35
+ "",
36
+ ACTIVE_CLAIMS_HEADING,
37
+ "",
38
+ "In-flight work claimed by an agent. Row present = active; row absent = settled.",
39
+ "Writers: `fit-wiki claim`, `fit-wiki release`. Reader: `fit-wiki boot`.",
40
+ "",
41
+ ACTIVE_CLAIMS_TABLE_HEADER,
42
+ ACTIVE_CLAIMS_TABLE_SEPARATOR,
43
+ "| *None* | — | — | — | — | — |",
44
+ "",
45
+ ].join("\n");
46
+
47
+ const lines = text.split("\n");
48
+ const storyboardIdx = lines.findIndex((l) => l.trim() === "## Storyboard");
49
+ if (storyboardIdx === -1) {
50
+ writeFileSync(memoryPath, text.replace(/\n*$/, "") + "\n" + block + "\n");
51
+ return true;
52
+ }
53
+ lines.splice(storyboardIdx, 0, ...block.split("\n"), "");
54
+ writeFileSync(memoryPath, lines.join("\n"));
55
+ return true;
56
+ }
57
+
58
+ const AUDIT_HOOK_COMMAND = "bunx fit-wiki audit";
35
59
 
60
+ function readSettings(settingsPath) {
61
+ if (!existsSync(settingsPath)) return {};
62
+ try {
63
+ return JSON.parse(readFileSync(settingsPath, "utf-8"));
64
+ } catch {
65
+ process.stderr.write(
66
+ `init: ${settingsPath} is not valid JSON; skipping stop-hook install\n`,
67
+ );
68
+ return null;
69
+ }
70
+ }
71
+
72
+ function hasAuditHook(settings) {
73
+ if (!settings.hooks || !Array.isArray(settings.hooks.Stop)) return false;
74
+ for (const group of settings.hooks.Stop) {
75
+ if (!group || !Array.isArray(group.hooks)) continue;
76
+ if (
77
+ group.hooks.some(
78
+ (h) =>
79
+ h &&
80
+ typeof h.command === "string" &&
81
+ h.command.includes("fit-wiki audit"),
82
+ )
83
+ ) {
84
+ return true;
85
+ }
86
+ }
87
+ return false;
88
+ }
89
+
90
+ function addAuditHook(settings) {
91
+ if (!settings.hooks) settings.hooks = {};
92
+ if (!Array.isArray(settings.hooks.Stop)) settings.hooks.Stop = [];
93
+ if (settings.hooks.Stop.length === 0) {
94
+ settings.hooks.Stop.push({
95
+ hooks: [{ type: "command", command: AUDIT_HOOK_COMMAND }],
96
+ });
97
+ return;
98
+ }
99
+ if (!Array.isArray(settings.hooks.Stop[0].hooks)) {
100
+ settings.hooks.Stop[0].hooks = [];
101
+ }
102
+ settings.hooks.Stop[0].hooks.push({
103
+ type: "command",
104
+ command: AUDIT_HOOK_COMMAND,
105
+ });
106
+ }
107
+
108
+ function installStopHook(settingsPath) {
109
+ const settings = readSettings(settingsPath);
110
+ if (settings === null) return false;
111
+ if (hasAuditHook(settings)) return false;
112
+ addAuditHook(settings);
113
+ mkdirSync(path.dirname(settingsPath), { recursive: true });
114
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
115
+ return true;
116
+ }
117
+
118
+ async function maybeCloneWiki(projectRoot, wikiDir) {
36
119
  const wikiUrl = deriveWikiUrl(projectRoot);
37
120
  if (!wikiUrl) {
38
121
  process.stderr.write(
@@ -40,25 +123,56 @@ export async function runInitCommand(values, _args, cli) {
40
123
  );
41
124
  return;
42
125
  }
43
-
44
126
  const config = await createScriptConfig("wiki");
45
127
  const repo = new WikiRepo({
46
128
  wikiDir,
47
129
  parentDir: projectRoot,
48
130
  resolveToken: () => config.ghToken(),
49
131
  });
50
-
51
132
  const cloneResult = repo.ensureCloned(wikiUrl);
52
- if (!cloneResult.cloned) {
53
- process.stderr.write("init: could not clone wiki, skipping\n");
54
- return;
133
+ if (cloneResult.cloned) {
134
+ repo.inheritIdentity();
135
+ } else {
136
+ process.stderr.write(
137
+ "init: could not clone wiki, continuing with local-only steps\n",
138
+ );
139
+ }
140
+ }
141
+
142
+ /** Clone the wiki if not already present, scaffold Active Claims in MEMORY.md, install the audit Stop-hook, and create per-skill metric directories. */
143
+ export async function runInitCommand(values, _args, _cli) {
144
+ const logger = { debug() {} };
145
+ const finder = new Finder(fsAsync, logger, process);
146
+ const projectRoot = finder.findProjectRoot(process.cwd());
147
+
148
+ const wikiDir = path.resolve(projectRoot, values["wiki-root"] ?? "wiki");
149
+ const skillsDir = path.resolve(
150
+ projectRoot,
151
+ values["skills-dir"] ?? path.join(".claude", "skills"),
152
+ );
153
+ const settingsPath = path.resolve(projectRoot, ".claude", "settings.json");
154
+
155
+ await maybeCloneWiki(projectRoot, wikiDir);
156
+
157
+ if (existsSync(skillsDir)) {
158
+ for (const slug of listSkills({ skillsDir })) {
159
+ mkdirSync(path.join(wikiDir, "metrics", slug), { recursive: true });
160
+ }
55
161
  }
56
162
 
57
- repo.inheritIdentity();
163
+ if (existsSync(wikiDir)) {
164
+ const memoryPath = path.join(wikiDir, "MEMORY.md");
165
+ if (scaffoldActiveClaims(memoryPath)) {
166
+ process.stdout.write(
167
+ `init: scaffolded ${ACTIVE_CLAIMS_HEADING} in ${memoryPath}\n`,
168
+ );
169
+ }
170
+ }
58
171
 
59
- const skills = listSkills({ skillsDir });
60
- for (const slug of skills) {
61
- mkdirSync(path.join(wikiDir, "metrics", slug), { recursive: true });
172
+ if (installStopHook(settingsPath)) {
173
+ process.stdout.write(
174
+ `init: installed Stop-hook audit entry in ${settingsPath}\n`,
175
+ );
62
176
  }
63
177
 
64
178
  process.stdout.write(`init: wiki ready at ${wikiDir}\n`);
@@ -0,0 +1,102 @@
1
+ import fsAsync from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { Finder } from "@forwardimpact/libutil";
4
+ import {
5
+ weeklyLogPath,
6
+ rotateIfOverBudget,
7
+ appendEntry,
8
+ } from "../weekly-log.js";
9
+ import { DECISION_HEADING } from "../constants.js";
10
+
11
+ function projectRootForCommand() {
12
+ const logger = { debug() {} };
13
+ const finder = new Finder(fsAsync, logger, process);
14
+ return finder.findProjectRoot(process.cwd());
15
+ }
16
+
17
+ function commonContext(values) {
18
+ const agent = values.agent || process.env.LIBEVAL_AGENT_PROFILE;
19
+ if (!agent) {
20
+ process.stderr.write(
21
+ "log requires --agent <name> or LIBEVAL_AGENT_PROFILE env var\n",
22
+ );
23
+ process.exit(2);
24
+ }
25
+ const projectRoot = projectRootForCommand();
26
+ const wikiRoot = values["wiki-root"] || path.join(projectRoot, "wiki");
27
+ const today = values.today || new Date().toISOString().slice(0, 10);
28
+ return { agent, wikiRoot, today };
29
+ }
30
+
31
+ function ensureDateHeading(body, today) {
32
+ const heading = `## ${today}`;
33
+ if (body.startsWith(heading) || body.startsWith("## ")) return body;
34
+ return `${heading}\n\n${body}`;
35
+ }
36
+
37
+ function runDecision(values) {
38
+ const { agent, wikiRoot, today } = commonContext(values);
39
+ const surveyed = values.surveyed || "—";
40
+ const chosen = values.chosen || "—";
41
+ const rationale = values.rationale || "—";
42
+ const alternatives = values.alternatives || "—";
43
+ const body = [
44
+ `## ${today}`,
45
+ "",
46
+ DECISION_HEADING,
47
+ "",
48
+ `**Surveyed:** ${surveyed}`,
49
+ "",
50
+ `**Alternatives:** ${alternatives}`,
51
+ "",
52
+ `**Chosen:** ${chosen}`,
53
+ "",
54
+ `**Rationale:** ${rationale}`,
55
+ "",
56
+ ].join("\n");
57
+ const lineCount = body.split("\n").length;
58
+ rotateIfOverBudget(wikiRoot, agent, today, lineCount);
59
+ const target = weeklyLogPath(wikiRoot, agent, today);
60
+ appendEntry(target, body, agent, today);
61
+ process.stdout.write(`logged decision to ${target}\n`);
62
+ }
63
+
64
+ function runNote(values) {
65
+ const { agent, wikiRoot, today } = commonContext(values);
66
+ if (!values.field || !values.body) {
67
+ process.stderr.write("log note requires --field and --body\n");
68
+ process.exit(2);
69
+ }
70
+ const body = ensureDateHeading(
71
+ `### ${values.field}\n\n${values.body}\n`,
72
+ today,
73
+ );
74
+ const lineCount = body.split("\n").length;
75
+ rotateIfOverBudget(wikiRoot, agent, today, lineCount);
76
+ const target = weeklyLogPath(wikiRoot, agent, today);
77
+ appendEntry(target, body, agent, today);
78
+ process.stdout.write(`logged note to ${target}\n`);
79
+ }
80
+
81
+ function runDone(values) {
82
+ const { agent, wikiRoot, today } = commonContext(values);
83
+ const body = `### Closed\n\nRun closed ${today}.\n`;
84
+ const lineCount = body.split("\n").length;
85
+ rotateIfOverBudget(wikiRoot, agent, today, lineCount);
86
+ const target = weeklyLogPath(wikiRoot, agent, today);
87
+ appendEntry(target, body, agent, today);
88
+ process.stdout.write(`closed entry in ${target}\n`);
89
+ }
90
+
91
+ const SUBS = { decision: runDecision, note: runNote, done: runDone };
92
+
93
+ /** Dispatch `log {decision|note|done}` to the matching sub-handler. */
94
+ export function runLogCommand(values, args, cli) {
95
+ const sub = args[0];
96
+ const handler = SUBS[sub];
97
+ if (!handler) {
98
+ cli.usageError("log requires subcommand: decision | note | done");
99
+ process.exit(2);
100
+ }
101
+ handler(values);
102
+ }
@@ -4,6 +4,7 @@ import fsAsync from "node:fs/promises";
4
4
  import { Finder } from "@forwardimpact/libutil";
5
5
  import { scanMarkers } from "../marker-scanner.js";
6
6
  import { renderBlock, BlockRenderError } from "../block-renderer.js";
7
+ import { renderIssueList } from "../issue-list-renderer.js";
7
8
 
8
9
  function currentStoryboardPath() {
9
10
  const now = new Date();
@@ -12,8 +13,34 @@ function currentStoryboardPath() {
12
13
  return `wiki/storyboard-${yyyy}-M${mm}.md`;
13
14
  }
14
15
 
15
- /** Re-render all XmR chart blocks in a storyboard file by scanning markers and splicing updated content. */
16
- export function runRefreshCommand(values, args, cli) {
16
+ function renderForBlock(block, projectRoot) {
17
+ if (block.kind === "xmr") {
18
+ return renderBlock({
19
+ metric: block.metric,
20
+ csvPath: block.csvPath,
21
+ projectRoot,
22
+ });
23
+ }
24
+ if (block.kind === "issue-list") {
25
+ return renderIssueList({
26
+ topic: block.topic,
27
+ state: block.state,
28
+ window: block.window,
29
+ });
30
+ }
31
+ return null;
32
+ }
33
+
34
+ function spliceBlock(lines, block, rendered) {
35
+ lines.splice(
36
+ block.openLine + 1,
37
+ block.closeLine - block.openLine - 1,
38
+ ...rendered,
39
+ );
40
+ }
41
+
42
+ /** Re-render XmR chart blocks and issue-list blocks in a storyboard file. */
43
+ export function runRefreshCommand(values, args, _cli) {
17
44
  const logger = { debug() {} };
18
45
  const finder = new Finder(fsAsync, logger, process);
19
46
  const projectRoot = finder.findProjectRoot(process.cwd());
@@ -24,7 +51,6 @@ export function runRefreshCommand(values, args, cli) {
24
51
  );
25
52
  const text = readFileSync(storyboardPath, "utf-8");
26
53
  const blocks = scanMarkers(text);
27
-
28
54
  if (blocks.length === 0) return;
29
55
 
30
56
  const lines = text.split("\n");
@@ -33,16 +59,9 @@ export function runRefreshCommand(values, args, cli) {
33
59
  for (let i = blocks.length - 1; i >= 0; i--) {
34
60
  const block = blocks[i];
35
61
  try {
36
- const rendered = renderBlock({
37
- metric: block.metric,
38
- csvPath: block.csvPath,
39
- projectRoot,
40
- });
41
- lines.splice(
42
- block.openLine + 1,
43
- block.closeLine - block.openLine - 1,
44
- ...rendered,
45
- );
62
+ const rendered = renderForBlock(block, projectRoot);
63
+ if (!rendered) continue;
64
+ spliceBlock(lines, block, rendered);
46
65
  spliced = true;
47
66
  } catch (err) {
48
67
  if (!(err instanceof BlockRenderError)) throw err;
@@ -53,4 +72,9 @@ export function runRefreshCommand(values, args, cli) {
53
72
  }
54
73
 
55
74
  if (spliced) writeFileSync(storyboardPath, lines.join("\n"));
75
+ if (values && values.format === "json") {
76
+ process.stdout.write(
77
+ JSON.stringify({ blocks: blocks.length, spliced }) + "\n",
78
+ );
79
+ }
56
80
  }
@@ -0,0 +1,25 @@
1
+ import fsAsync from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { Finder } from "@forwardimpact/libutil";
4
+ import { rotateIfOverBudget } from "../weekly-log.js";
5
+
6
+ /** Force-rotate the current weekly log to a sealed part file. */
7
+ export function runRotateCommand(values, _args, cli) {
8
+ const agent = values.agent || process.env.LIBEVAL_AGENT_PROFILE;
9
+ if (!agent) {
10
+ cli.usageError("rotate requires --agent or LIBEVAL_AGENT_PROFILE");
11
+ process.exit(2);
12
+ }
13
+ const logger = { debug() {} };
14
+ const finder = new Finder(fsAsync, logger, process);
15
+ const projectRoot = finder.findProjectRoot(process.cwd());
16
+ const wikiRoot = values["wiki-root"] || path.join(projectRoot, "wiki");
17
+ const today = values.today || new Date().toISOString().slice(0, 10);
18
+
19
+ const result = rotateIfOverBudget(wikiRoot, agent, today, 0, { force: true });
20
+ if (result.rotated) {
21
+ process.stdout.write(`rotated ${result.fromPath} → ${result.toPath}\n`);
22
+ } else {
23
+ process.stdout.write(`no rotation needed for ${agent}\n`);
24
+ }
25
+ }
package/src/constants.js CHANGED
@@ -1,3 +1,21 @@
1
1
  export const MEMO_INBOX_MARKER = "<!-- memo:inbox -->";
2
2
  export const INBOX_HEADING = "## Message Inbox";
3
3
  export const BROADCAST_TARGET = "all";
4
+
5
+ export const MEMORY_FILE = "MEMORY.md";
6
+ export const ACTIVE_CLAIMS_HEADING = "## Active Claims";
7
+ export const ACTIVE_CLAIMS_TABLE_HEADER =
8
+ "| agent | target | branch | pr | claimed_at | expires_at |";
9
+ export const ACTIVE_CLAIMS_TABLE_SEPARATOR =
10
+ "| --- | --- | --- | --- | --- | --- |";
11
+ export const PRIORITY_INDEX_HEADING = "## Cross-Cutting Priorities";
12
+ export const PRIORITY_INDEX_TABLE_HEADER =
13
+ "| Item | Agents | Owner | Status | Added |";
14
+ export const DECISION_HEADING = "### Decision";
15
+
16
+ // Cap derivation: ≤2.5% of a 1M-token context window = 25k tokens;
17
+ // ≈42 tokens/line empirical proxy → ~500 lines. See spec 1060
18
+ // design-a.md § Decision area 2 for the full anchor.
19
+ export const WEEKLY_LOG_LINE_BUDGET = 500;
20
+ export const SUMMARY_LINE_BUDGET = 80;
21
+ export const CUTOVER_ISO_WEEK = "2026-W23";
package/src/index.js CHANGED
@@ -5,8 +5,32 @@ export {
5
5
  MEMO_INBOX_MARKER,
6
6
  INBOX_HEADING,
7
7
  BROADCAST_TARGET,
8
+ MEMORY_FILE,
9
+ ACTIVE_CLAIMS_HEADING,
10
+ ACTIVE_CLAIMS_TABLE_HEADER,
11
+ ACTIVE_CLAIMS_TABLE_SEPARATOR,
12
+ PRIORITY_INDEX_HEADING,
13
+ PRIORITY_INDEX_TABLE_HEADER,
14
+ DECISION_HEADING,
15
+ WEEKLY_LOG_LINE_BUDGET,
16
+ SUMMARY_LINE_BUDGET,
17
+ CUTOVER_ISO_WEEK,
8
18
  } from "./constants.js";
9
19
  export { scanMarkers } from "./marker-scanner.js";
10
20
  export { renderBlock } from "./block-renderer.js";
21
+ export { renderIssueList } from "./issue-list-renderer.js";
11
22
  export { WikiRepo } from "./wiki-repo.js";
12
23
  export { listSkills } from "./skill-roster.js";
24
+ export {
25
+ parseClaims,
26
+ appendClaim,
27
+ removeClaim,
28
+ filterExpired,
29
+ } from "./active-claims.js";
30
+ export {
31
+ isoWeek,
32
+ weeklyLogPath,
33
+ rotateIfOverBudget,
34
+ appendEntry,
35
+ } from "./weekly-log.js";
36
+ export { buildDigest } from "./boot.js";
@@ -0,0 +1,69 @@
1
+ import { spawnSync } from "node:child_process";
2
+
3
+ function defaultGh(args) {
4
+ return spawnSync("gh", args, {
5
+ encoding: "utf-8",
6
+ stdio: ["ignore", "pipe", "pipe"],
7
+ });
8
+ }
9
+
10
+ function daysAgo(today, n) {
11
+ const d = today instanceof Date ? new Date(today.getTime()) : new Date(today);
12
+ d.setUTCDate(d.getUTCDate() - n);
13
+ return d.toISOString().slice(0, 10);
14
+ }
15
+
16
+ /** Render an issue-list block for an obstacles/experiments marker. Returns markdown lines. */
17
+ export function renderIssueList({
18
+ topic,
19
+ state,
20
+ window,
21
+ today = new Date(),
22
+ gh = defaultGh,
23
+ }) {
24
+ const ghState = state === "closed" ? "closed" : "open";
25
+ const result = gh([
26
+ "issue",
27
+ "list",
28
+ "--label",
29
+ topic.replace(/s$/, ""),
30
+ "--state",
31
+ ghState,
32
+ "--json",
33
+ "number,title,labels,closedAt",
34
+ "--limit",
35
+ "100",
36
+ ]);
37
+ if (result.status !== 0) {
38
+ process.stderr.write(
39
+ `refresh: gh issue list failed for ${topic}:${state}\n`,
40
+ );
41
+ return [];
42
+ }
43
+ let issues;
44
+ try {
45
+ issues = JSON.parse(result.stdout || "[]");
46
+ } catch {
47
+ process.stderr.write(
48
+ `refresh: gh issue list JSON parse failed for ${topic}:${state}\n`,
49
+ );
50
+ return [];
51
+ }
52
+
53
+ if (state === "closed") {
54
+ const windowDays = window
55
+ ? Number.parseInt(window.replace("d", ""), 10)
56
+ : 7;
57
+ const cutoff = daysAgo(today, windowDays);
58
+ issues = issues.filter(
59
+ (i) => i.closedAt && i.closedAt.slice(0, 10) >= cutoff,
60
+ );
61
+ }
62
+
63
+ const lines = [];
64
+ for (const issue of issues) {
65
+ const tag = topic === "experiments" ? "Exp" : "Obs";
66
+ lines.push(`- **${tag} #${issue.number} — ${issue.title}**`);
67
+ }
68
+ return lines;
69
+ }
@@ -1,43 +1,90 @@
1
- const OPEN_RE = /^<!--\s*xmr:([^:\s]+):([^\s]+)\s*-->\s*$/;
2
- const CLOSE_RE = /^<!--\s*\/xmr\s*-->\s*$/;
1
+ const XMR_OPEN_RE = /^<!--\s*xmr:([^:\s]+):([^\s]+)\s*-->\s*$/;
2
+ const ISSUE_OPEN_RE =
3
+ /^<!--\s*(obstacles|experiments):(open|closed)(?::(\d+d))?\s*-->\s*$/;
4
+ const XMR_CLOSE_RE = /^<!--\s*\/xmr\s*-->\s*$/;
5
+ const ISSUE_CLOSE_RE = /^<!--\s*\/(obstacles|experiments)\s*-->\s*$/;
3
6
 
4
- /** Scan text for paired xmr open/close HTML comment markers and return their line positions and metadata. */
7
+ function openLabel(open) {
8
+ return open.kind === "xmr" ? open.metric : open.topic;
9
+ }
10
+
11
+ function warnDangling(open) {
12
+ process.stderr.write(
13
+ `dangling-marker ${openLabel(open)} at line ${open.openLine + 1}\n`,
14
+ );
15
+ }
16
+
17
+ function tryOpen(line, i) {
18
+ const xmrMatch = line.match(XMR_OPEN_RE);
19
+ if (xmrMatch) {
20
+ return {
21
+ kind: "xmr",
22
+ metric: xmrMatch[1],
23
+ csvPath: xmrMatch[2],
24
+ openLine: i,
25
+ };
26
+ }
27
+ const issueMatch = line.match(ISSUE_OPEN_RE);
28
+ if (issueMatch) {
29
+ return {
30
+ kind: "issue-list",
31
+ topic: issueMatch[1],
32
+ state: issueMatch[2],
33
+ window: issueMatch[3] || null,
34
+ openLine: i,
35
+ };
36
+ }
37
+ return null;
38
+ }
39
+
40
+ function closePair(open, i) {
41
+ if (open.kind === "xmr") {
42
+ return {
43
+ kind: "xmr",
44
+ metric: open.metric,
45
+ csvPath: open.csvPath,
46
+ openLine: open.openLine,
47
+ closeLine: i,
48
+ };
49
+ }
50
+ return {
51
+ kind: "issue-list",
52
+ topic: open.topic,
53
+ state: open.state,
54
+ window: open.window,
55
+ openLine: open.openLine,
56
+ closeLine: i,
57
+ };
58
+ }
59
+
60
+ function matchClose(line, open) {
61
+ if (!open) return false;
62
+ if (open.kind === "xmr") return XMR_CLOSE_RE.test(line);
63
+ const m = line.match(ISSUE_CLOSE_RE);
64
+ return Boolean(m && open.kind === "issue-list" && open.topic === m[1]);
65
+ }
66
+
67
+ /** Scan text for paired marker blocks (xmr or issue-list). Returns positions and metadata. */
5
68
  export function scanMarkers(text) {
6
69
  const lines = text.split("\n");
7
70
  const pairs = [];
8
71
  let open = null;
9
72
 
10
73
  for (let i = 0; i < lines.length; i++) {
11
- const openMatch = lines[i].match(OPEN_RE);
12
- if (openMatch) {
13
- if (open) {
14
- process.stderr.write(
15
- `dangling-marker ${open.metric} at line ${open.openLine + 1}\n`,
16
- );
17
- }
18
- open = { metric: openMatch[1], csvPath: openMatch[2], openLine: i };
74
+ const line = lines[i];
75
+ const newOpen = tryOpen(line, i);
76
+ if (newOpen) {
77
+ if (open) warnDangling(open);
78
+ open = newOpen;
19
79
  continue;
20
80
  }
21
-
22
- if (CLOSE_RE.test(lines[i])) {
23
- if (open) {
24
- pairs.push({
25
- metric: open.metric,
26
- csvPath: open.csvPath,
27
- openLine: open.openLine,
28
- closeLine: i,
29
- });
30
- open = null;
31
- }
32
- continue;
81
+ if (matchClose(line, open)) {
82
+ pairs.push(closePair(open, i));
83
+ open = null;
33
84
  }
34
85
  }
35
86
 
36
- if (open) {
37
- process.stderr.write(
38
- `dangling-marker ${open.metric} at line ${open.openLine + 1}\n`,
39
- );
40
- }
87
+ if (open) warnDangling(open);
41
88
 
42
89
  return pairs;
43
90
  }