@davesheffer/hunch 1.43.0 → 1.44.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.
Files changed (47) hide show
  1. package/README.md +10 -0
  2. package/dist/cli/index.js +114 -19
  3. package/dist/core/changeProof.js +2 -1
  4. package/dist/core/commitrepair.d.ts +9 -3
  5. package/dist/core/commitrepair.js +23 -6
  6. package/dist/core/correction.d.ts +1 -1
  7. package/dist/core/correction.js +4 -3
  8. package/dist/core/docanchors.d.ts +11 -2
  9. package/dist/core/docanchors.js +232 -65
  10. package/dist/core/drift.d.ts +1 -1
  11. package/dist/core/drift.js +10 -3
  12. package/dist/core/eol.d.ts +2 -0
  13. package/dist/core/eol.js +6 -0
  14. package/dist/core/procstart.d.ts +15 -4
  15. package/dist/core/procstart.js +56 -18
  16. package/dist/core/runbookDraft.d.ts +7 -0
  17. package/dist/core/runbookDraft.js +43 -0
  18. package/dist/core/taskTouched.d.ts +2 -0
  19. package/dist/core/taskTouched.js +1 -1
  20. package/dist/extractors/git.d.ts +36 -6
  21. package/dist/extractors/git.js +86 -9
  22. package/dist/extractors/indexer.js +53 -14
  23. package/dist/extractors/k8sManifest.d.ts +31 -1
  24. package/dist/extractors/k8sManifest.js +133 -8
  25. package/dist/integrations/claudemd.d.ts +2 -0
  26. package/dist/integrations/claudemd.js +4 -2
  27. package/dist/integrations/gitignore.d.ts +7 -0
  28. package/dist/integrations/gitignore.js +18 -0
  29. package/dist/integrations/hooks.d.ts +15 -0
  30. package/dist/integrations/hooks.js +49 -1
  31. package/dist/integrations/mergeDriver.d.ts +2 -0
  32. package/dist/integrations/mergeDriver.js +2 -2
  33. package/dist/integrations/providers.d.ts +7 -0
  34. package/dist/integrations/providers.js +4 -4
  35. package/dist/integrations/scaffold.d.ts +5 -0
  36. package/dist/integrations/scaffold.js +12 -7
  37. package/dist/integrations/uninstall.d.ts +40 -0
  38. package/dist/integrations/uninstall.js +435 -0
  39. package/dist/mcp/server.d.ts +2 -1
  40. package/dist/mcp/server.js +58 -4
  41. package/dist/serve/writelock.d.ts +7 -0
  42. package/dist/serve/writelock.js +30 -3
  43. package/dist/store/changeLedger.d.ts +5 -2
  44. package/dist/store/changeLedger.js +88 -37
  45. package/dist/store/hunchStore.js +4 -1
  46. package/package.json +3 -3
  47. package/server.json +2 -2
@@ -58,14 +58,18 @@ function thematicBreakSuffixes(probe) {
58
58
  }
59
59
  return ok;
60
60
  }
61
- /** Character ranges covered by fenced code blocks (``` or ~~~), so a
62
- * documentation EXAMPLE of a marker never registers as a live anchor.
63
- * CommonMark-lite: a fence of N chars (≤3 leading spaces) closes only on a
64
- * line of ≥N of the same char and nothing else; an unclosed fence runs to
65
- * EOF; a backtick fence's info string may not itself contain a backtick.
66
- * The "≤3 leading spaces" is measured relative to the enclosing LIST ITEM's
67
- * content offset, so a fence indented under `1. step` (issue #331) is still a
68
- * fence and not an indented code block.
61
+ /** Character ranges covered by code blocks — fenced (``` or ~~~) and indented —
62
+ * so a documentation EXAMPLE of a marker never registers as a live anchor.
63
+ * A hand scanner, not a markdown parser: it answers only "is this offset inside
64
+ * code as a renderer shows it?", line by line and linear in each line's length.
65
+ *
66
+ * Fences, CommonMark-lite: a fence of N chars (≤3 leading spaces) closes only on
67
+ * a line of ≥N of the same char and nothing else; an unclosed fence runs to EOF
68
+ * (reported as `unclosed`, which drift surfaces — issue #381); a backtick
69
+ * fence's info string may not itself contain a backtick. The "≤3 leading
70
+ * spaces" is measured relative to the enclosing LIST ITEM's content offset, so a
71
+ * fence indented under `1. step` (issue #331) is still a fence and not an
72
+ * indented code block.
69
73
  *
70
74
  * A fence hosted in a list item ends where the ITEM ends: at the first
71
75
  * non-blank line dedented below the item's content base. That is what the
@@ -75,134 +79,286 @@ function thematicBreakSuffixes(probe) {
75
79
  * reader sees, not with the author's intent. In sloppy docs that differs from
76
80
  * a flat fence scan; the rendering is the tiebreaker.
77
81
  *
82
+ * Also as a renderer reads them (issue #381):
83
+ * - indented code: ≥4 columns past the container's content, not continuing a
84
+ * paragraph, runs until a line dedents below that (blank lines inside it);
85
+ * - blockquotes: `>` prefixes are peeled first, so `> ```` opens a fence inside
86
+ * the quote, and a line that leaves the quote ends it;
87
+ * - HTML blocks (CommonMark types 1–6: script/pre/style/textarea, comments,
88
+ * `<?`, `<!X`, CDATA, block-level tags): their lines are raw HTML, so a ``` or
89
+ * an indented line inside one opens no code block. A marker inside an HTML
90
+ * block stays LIVE: it is an HTML comment there too, not shown as an example;
91
+ * - lazy continuation: a paragraph line dedented out of its list item or quote
92
+ * that starts no block of its own keeps that item/quote open.
93
+ *
78
94
  * Tabs expand to the next 4-column tab stop (not a flat 4), so `\t``` ` sits
79
95
  * at column 4 exactly as a renderer places it. Offsets always come from the
80
96
  * ORIGINAL line, never from the expanded probe.
81
97
  *
82
- * Deliberate limits: no lazy continuations and no blockquote containers — a
83
- * `>` prefix is still read as ordinary text. With no list open the item stack
84
- * is empty, the base is 0 and behaviour is the plain CommonMark-lite one.
85
- * Expects LF-normalized text — see parseDocAnchors's normalization; a caller
86
- * that skips it re-opens the CRLF fence-detection bug. */
87
- function fencedRanges(text) {
88
- const FENCE = /^ {0,3}(`{3,}|~{3,})(.*)$/;
98
+ * Deliberate limits, each a case where it can still disagree with a renderer:
99
+ * HTML block type 7 (a lone arbitrary tag line) reads as text; a blockquote
100
+ * nested INSIDE a list item is read as a top-level quote; after a tab right
101
+ * after a `>`, later tabs on that line expand from the quote's content, not
102
+ * the line. With no container
103
+ * open the base is 0 and behaviour is the plain CommonMark-lite one.
104
+ * Expects LF-normalized, BOM-free text — see parseDocAnchors's normalization;
105
+ * a caller that skips it re-opens the CRLF fence-detection bug. */
106
+ function codeRanges(text) {
89
107
  const ranges = [];
90
108
  let open = null;
91
- // Content offsets (columns) of the currently open list items, outermost first.
109
+ // Open list items, outermost first: the quote depth each lives in and its content column.
92
110
  const items = [];
111
+ let curQ = 0; // quote depth of the current block
93
112
  let offset = 0;
113
+ let lineNo = 0;
94
114
  // Whether the previous line was a paragraph-continuation line: non-blank,
95
115
  // and not itself a container/leaf opener. Only a bullet or a `1.`/`1)` with
96
116
  // non-empty content may interrupt such a paragraph (CommonMark), so prose
97
117
  // like "see item\n2. the second point" stays one paragraph.
98
118
  let inParagraph = false;
119
+ const closeOpen = (lineStart) => {
120
+ if (open?.kind === "fence")
121
+ ranges.push([open.start, lineStart - 1]);
122
+ else if (open?.kind === "indent")
123
+ ranges.push([open.start, open.end]);
124
+ open = null;
125
+ };
99
126
  for (const line of text.split("\n")) {
127
+ lineNo++;
128
+ const lineStart = offset;
129
+ offset += line.length + 1;
100
130
  // Matching-only copy: tabs advance to the next 4-column tab stop.
101
131
  const probe = expandTabs(line);
102
- const indent = probe.length - probe.replace(/^ +/, "").length;
103
- const blank = probe.trim() === "";
104
132
  if (open) {
105
- if (!blank && indent < open.base) {
106
- // The list item holding the fence ended, which ends the fence too.
107
- ranges.push([open.start, offset - 1]);
108
- open = null;
133
+ const held = peelQuotes(probe, open.q);
134
+ if (held.q < open.q) {
135
+ closeOpen(lineStart); // the quote holding the block ended, which ends the block too
109
136
  }
110
137
  else {
111
- const m = FENCE.exec(probe.slice(Math.min(open.base, indent)));
112
- if (m && m[1][0] === open.ch && m[1].length >= open.len && m[2].trim() === "") {
113
- ranges.push([open.start, offset + line.length]);
114
- open = null;
138
+ const rest = held.rest;
139
+ const indent = leadingSpaces(rest);
140
+ const blank = rest.trim() === "";
141
+ if (open.kind === "fence") {
142
+ if (!blank && indent < open.base) {
143
+ closeOpen(lineStart); // the list item holding the fence ended, which ends the fence too
144
+ }
145
+ else {
146
+ const m = FENCE.exec(rest.slice(Math.min(open.base, indent)));
147
+ if (m && m[1][0] === open.ch && m[1].length >= open.len && m[2].trim() === "") {
148
+ ranges.push([open.start, lineStart + line.length]);
149
+ open = null;
150
+ }
151
+ continue; // the container stacks are frozen while a fence is open
152
+ }
153
+ }
154
+ else if (open.kind === "indent") {
155
+ if (blank)
156
+ continue;
157
+ if (indent >= open.base + 4) {
158
+ open.end = lineStart + line.length;
159
+ continue;
160
+ }
161
+ closeOpen(lineStart);
162
+ }
163
+ else {
164
+ if (open.end === "blank" ? blank : open.end.test(rest)) {
165
+ open = null;
166
+ inParagraph = false;
167
+ }
168
+ continue;
115
169
  }
116
- offset += line.length + 1;
117
- continue; // the item stack is frozen while a fence is open
118
170
  }
119
171
  }
120
- if (blank) {
121
- offset += line.length + 1;
172
+ const { q, rest } = peelQuotes(probe, Infinity);
173
+ const indent = leadingSpaces(rest);
174
+ if (rest.trim() === "") {
175
+ while (items.length && items.at(-1).q > q)
176
+ items.pop();
177
+ curQ = q;
122
178
  inParagraph = false;
123
179
  continue; // a blank line neither opens nor closes an item here
124
180
  }
181
+ const isBreak = thematicBreakSuffixes(rest);
182
+ // Lazy continuation: a paragraph line that falls out of its item or quote but
183
+ // starts no block of its own still belongs to that paragraph, so every
184
+ // container stays open (`- foo\nbar` — bar is item text).
185
+ const top = items.at(-1);
186
+ const leaves = q < curQ || (top !== undefined && top.q === q && top.base > indent);
187
+ if (inParagraph && leaves && !startsBlock(rest, indent, isBreak))
188
+ continue;
125
189
  // A dedent that closes an item also closes the paragraph inside it, so the
126
190
  // line is a fresh block start: `1. one\n2. two` is two sibling items, not
127
191
  // item one's paragraph being "interrupted" by a `2.`.
128
- let popped = false;
129
- while (items.length && items.at(-1) > indent) {
192
+ let popped = q !== curQ;
193
+ while (items.length && (items.at(-1).q !== q || items.at(-1).base > indent)) {
130
194
  items.pop();
131
195
  popped = true;
132
196
  }
133
- const isBreak = thematicBreakSuffixes(probe);
197
+ curQ = q;
134
198
  // Walk the line with an INDEX, peeling one list marker per step. Each step
135
199
  // is O(marker) and the cheap checks are O(1), so a line costs O(length)
136
200
  // however many markers it carries.
137
- let base = items.at(-1) ?? 0;
201
+ let base = items.at(-1)?.base ?? 0;
138
202
  let opened = false;
139
203
  let thematic = false;
204
+ let indented = false;
205
+ let at = base;
140
206
  for (;;) {
141
207
  // Leading spaces of this nesting level: ≤3, else it is indented content
142
208
  // (and ` ---` is paragraph text or code, never a thematic break).
143
209
  let i = base;
144
- while (i < probe.length && probe[i] === " " && i - base < 4)
210
+ while (i < rest.length && rest[i] === " " && i - base < 4)
145
211
  i++;
146
- const lead = i - base;
147
- if (lead > 3)
212
+ at = i;
213
+ if (i - base > 3) {
214
+ indented = true;
148
215
  break;
216
+ }
149
217
  if (isBreak[i]) {
150
218
  thematic = true;
151
219
  break;
152
220
  } // thematic break, not a list marker
153
- const c = probe[i];
221
+ const c = rest[i];
154
222
  let markerLen = 0;
155
223
  if (c === "-" || c === "*" || c === "+")
156
224
  markerLen = 1;
157
225
  else if (c !== undefined && c >= "0" && c <= "9") {
158
226
  let d = i;
159
- while (d < probe.length && probe[d] >= "0" && probe[d] <= "9" && d - i < 9)
227
+ while (d < rest.length && rest[d] >= "0" && rest[d] <= "9" && d - i < 9)
160
228
  d++;
161
- if (probe[d] === "." || probe[d] === ")")
229
+ if (rest[d] === "." || rest[d] === ")")
162
230
  markerLen = d - i + 1;
163
231
  }
164
232
  if (!markerLen)
165
233
  break;
166
234
  // Spaces between the marker and the item's content.
167
235
  let s = i + markerLen;
168
- while (s < probe.length && probe[s] === " ")
236
+ while (s < rest.length && rest[s] === " ")
169
237
  s++;
170
238
  const gap = s - (i + markerLen);
171
- const emptyItem = s >= probe.length;
239
+ const emptyItem = s >= rest.length;
172
240
  if (!emptyItem && gap === 0)
173
241
  break; // `-foo` / `1.foo` is not a marker
174
242
  if (!opened && !popped && inParagraph && items.length === 0) {
175
243
  // Interrupting a paragraph: only a bullet, or `1.`/`1)`, and never with
176
244
  // empty content. `2. the second point` mid-prose stays paragraph text.
177
- const ordinal = markerLen > 1 ? probe.slice(i, i + markerLen - 1) : "";
245
+ const ordinal = markerLen > 1 ? rest.slice(i, i + markerLen - 1) : "";
178
246
  if (emptyItem || (ordinal !== "" && ordinal !== "1"))
179
247
  break;
180
248
  }
181
249
  // ≥5 spaces after the marker starts an indented code block, so the
182
250
  // item's content begins one column after the marker instead.
183
251
  const w = gap >= 1 && gap <= 4 ? gap : 1;
184
- base = base + lead + markerLen + w;
185
- items.push(base);
252
+ base = i + markerLen + w;
253
+ items.push({ q, base });
186
254
  opened = true;
187
255
  continue; // `- 1. x` nests, and "- ```js" opens a fence on the marker line
188
256
  }
189
- const m = FENCE.exec(probe.slice(base));
257
+ if (indented) {
258
+ // An indented code block cannot interrupt a paragraph: there it is text.
259
+ if (!inParagraph || opened) {
260
+ open = { kind: "indent", start: lineStart, end: lineStart + line.length, base, q };
261
+ inParagraph = false;
262
+ }
263
+ continue;
264
+ }
265
+ const content = rest.slice(at);
266
+ const html = htmlBlockEnd(content);
267
+ if (html) {
268
+ if (html === "blank" || !html.test(content))
269
+ open = { kind: "html", end: html, q };
270
+ inParagraph = false;
271
+ continue;
272
+ }
273
+ const m = FENCE.exec(rest.slice(base));
190
274
  if (m) {
191
275
  const ch = m[1][0];
192
276
  if (!(ch === "`" && m[2].includes("`")))
193
- open = { ch, len: m[1].length, start: offset, base };
277
+ open = { kind: "fence", ch, len: m[1].length, start: lineStart, base, q, line: lineNo };
194
278
  }
195
- // A fence line or a thematic break ends the paragraph it follows; ordinary
279
+ // A fence line, a thematic break or a heading (ATX, or a `===` setext
280
+ // underline beneath a paragraph) ends the paragraph it follows; ordinary
196
281
  // text (including an item's own content) continues or starts one.
197
- inParagraph = !m && !thematic;
198
- offset += line.length + 1;
282
+ const setext = inParagraph && /^=+[ \t]*$/.test(content);
283
+ inParagraph = !m && !thematic && !setext && content.trim() !== "" && !/^#{1,6}(?:[ \t]|$)/.test(content);
199
284
  }
200
- if (open)
201
- ranges.push([open.start, text.length]);
202
- return ranges;
285
+ let unclosed = null;
286
+ const last = open;
287
+ if (last?.kind === "fence") {
288
+ ranges.push([last.start, text.length]);
289
+ unclosed = { start: last.start, line: last.line };
290
+ }
291
+ else if (last?.kind === "indent")
292
+ ranges.push([last.start, last.end]);
293
+ return { ranges, unclosed };
294
+ }
295
+ const FENCE = /^ {0,3}(`{3,}|~{3,})(.*)$/;
296
+ const leadingSpaces = (s) => s.length - s.replace(/^ +/, "").length;
297
+ /** Peel up to `max` blockquote markers (`>`, ≤3 spaces before it, one optional
298
+ * space after) off a tab-expanded line: the quote depth and what follows. */
299
+ function peelQuotes(probe, max) {
300
+ let q = 0;
301
+ let i = 0;
302
+ let col = 0;
303
+ let pad = 0;
304
+ while (q < max) {
305
+ let j = i;
306
+ while (j < probe.length && probe[j] === " " && j - i < 3)
307
+ j++;
308
+ if (probe[j] !== ">")
309
+ break;
310
+ col += j - i + 1;
311
+ i = j + 1;
312
+ pad = 0;
313
+ if (probe[i] === " ") {
314
+ i++;
315
+ col++;
316
+ }
317
+ else if (probe[i] === "\t") {
318
+ // The tab's first column is the quote's optional space; the rest of it is
319
+ // content indentation (CommonMark 5.1). `>\t<!-- … -->` is a paragraph, not code.
320
+ const width = 4 - (col % 4);
321
+ i++;
322
+ col += width;
323
+ pad = width - 1;
324
+ }
325
+ q++;
326
+ }
327
+ return q ? { q, rest: " ".repeat(pad) + expandTabs(probe.slice(i)) } : { q, rest: probe };
328
+ }
329
+ /** Whether a line (≤3 columns in) opens a block of its own, which a lazy
330
+ * continuation line by definition does not. */
331
+ function startsBlock(rest, indent, isBreak) {
332
+ if (indent > 3)
333
+ return false;
334
+ const s = rest.slice(indent);
335
+ return isBreak[indent] === true || FENCE.test(rest) || /^#{1,6}(?:[ \t]|$)/.test(s) || s[0] === ">"
336
+ || htmlBlockEnd(s) !== null || /^(?:[-*+]|\d{1,9}[.)])(?:[ \t]|$)/.test(s);
337
+ }
338
+ const HTML_BLOCK_TAGS = new Set(("address article aside base basefont blockquote body caption center col colgroup dd details dialog dir div dl dt "
339
+ + "fieldset figcaption figure footer form frame frameset h1 h2 h3 h4 h5 h6 head header hr html iframe legend li link main menu menuitem "
340
+ + "nav noframes ol optgroup option p param search section summary table tbody td tfoot th thead title tr track ul").split(" "));
341
+ /** The end condition of the HTML block (CommonMark types 1–6) a line opens:
342
+ * a pattern that closes it on a later (or the same) line, "blank" for a block
343
+ * that runs to the next blank line, or null when the line opens none. */
344
+ function htmlBlockEnd(s) {
345
+ if (s[0] !== "<")
346
+ return null;
347
+ if (/^<(?:script|pre|style|textarea)(?:[\s>]|$)/i.test(s))
348
+ return /<\/(?:script|pre|style|textarea)>/i;
349
+ if (s.startsWith("<!--"))
350
+ return /-->/;
351
+ if (s.startsWith("<?"))
352
+ return /\?>/;
353
+ if (s.startsWith("<![CDATA["))
354
+ return /\]\]>/;
355
+ if (/^<![A-Za-z]/.test(s))
356
+ return />/;
357
+ const tag = /^<\/?([A-Za-z][A-Za-z0-9-]*)(?:[\s>]|\/>|$)/.exec(s);
358
+ return tag && HTML_BLOCK_TAGS.has(tag[1].toLowerCase()) ? "blank" : null;
203
359
  }
204
360
  /** Character ranges covered by inline code spans (`…`), same rationale as
205
- * fencedRanges: prose quoting a marker in backticks is showing an example.
361
+ * codeRanges: prose quoting a marker in backticks is showing an example.
206
362
  * CommonMark-lite: an opener run pairs with the next run of the SAME length
207
363
  * on the same line; unpaired runs never open a span.
208
364
  * Expects LF-normalized text — see parseDocAnchors's normalization.
@@ -229,28 +385,39 @@ function inlineSpanRanges(text) {
229
385
  return ranges;
230
386
  }
231
387
  /** Parse every hunch:topic marker out of a markdown document. Markers inside
232
- * fenced code blocks or inline code spans are examples, not declarations,
233
- * and are skipped. */
388
+ * code blocks or inline code spans are examples, not declarations, and are
389
+ * skipped. */
234
390
  export function parseDocAnchors(text) {
235
- // fencedRanges is CRLF-sensitive (its fence-line regex's `.` excludes \r, so
391
+ return scanDocAnchors(text).anchors;
392
+ }
393
+ /** parseDocAnchors plus what drift reports about the document: a fence that
394
+ * never closes hides every marker below it, almost always by mistake (#381). */
395
+ export function scanDocAnchors(text) {
396
+ // codeRanges is CRLF-sensitive (its fence-line regex's `.` excludes \r, so
236
397
  // "```\r" never matched at all on a CRLF checkout); inlineSpanRanges is
237
398
  // lone-CR-sensitive (split("\n") doesn't split a bare CR — see its
238
399
  // docblock). Either way an example marker inside a fence/span registered as
239
400
  // a live, pinned anchor. Normalizing both CRLF and lone CR once here keeps
240
- // fencedRanges/inlineSpanRanges/MARKER offsets consistent with each other
241
- // and with the line numbers reported below.
242
- text = normalizeEol(text);
243
- const out = [];
244
- const skip = [...fencedRanges(text), ...inlineSpanRanges(text)];
401
+ // codeRanges/inlineSpanRanges/MARKER offsets consistent with each other
402
+ // and with the line numbers reported below. A leading BOM is dropped too, so
403
+ // line 1 starts at column 0 (#381); it is one char on line 1, so line numbers hold.
404
+ text = normalizeEol(text).replace(/^\uFEFF/, "");
405
+ const anchors = [];
406
+ const code = codeRanges(text);
407
+ const skip = [...code.ranges, ...inlineSpanRanges(text)];
408
+ let hidden = 0;
245
409
  MARKER.lastIndex = 0;
246
410
  let m;
247
411
  while ((m = MARKER.exec(text))) {
248
412
  const at = m.index;
249
- if (skip.some(([s, e]) => at >= s && at <= e))
413
+ if (skip.some(([s, e]) => at >= s && at <= e)) {
414
+ if (code.unclosed && at > code.unclosed.start)
415
+ hidden++;
250
416
  continue;
251
- out.push({ topic: m[1], pin: m[2] ?? null, line: text.slice(0, at).split("\n").length });
417
+ }
418
+ anchors.push({ topic: m[1], pin: m[2] ?? null, line: text.slice(0, at).split("\n").length });
252
419
  }
253
- return out;
420
+ return { anchors, unclosedFence: code.unclosed && hidden ? { line: code.unclosed.line, hidden } : null };
254
421
  }
255
422
  const clip = (s, n = 220) => (s.length > n ? s.slice(0, n - 1).trimEnd() + "…" : s);
256
423
  /** Pre-edit grounding for a markdown document that carries topic anchors: the
@@ -1,5 +1,5 @@
1
1
  import type { HunchStore } from "../store/hunchStore.js";
2
- export declare const DRIFT_KINDS: readonly ["dead-ref", "supersede", "doc-stale", "anchor-stale", "doc-anchor-stale", "doc-anchor-dangling", "wiki-stale", "finding-stale", "premise-stale", "commit-unresolvable", "madr-stale", "madr-edited", "madr-orphan"];
2
+ export declare const DRIFT_KINDS: readonly ["dead-ref", "supersede", "doc-stale", "anchor-stale", "doc-anchor-stale", "doc-anchor-dangling", "doc-fence-unclosed", "wiki-stale", "finding-stale", "premise-stale", "commit-unresolvable", "madr-stale", "madr-edited", "madr-orphan"];
3
3
  export type DriftKind = typeof DRIFT_KINDS[number];
4
4
  export interface DriftFinding {
5
5
  kind: DriftKind;
@@ -22,12 +22,12 @@ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
22
22
  import { toPosixTarget } from "./paths.js";
23
23
  import { currentForTopic, isLive } from "./topics.js";
24
24
  import { evaluatePremises } from "./premises.js";
25
- import { parseDocAnchors } from "./docanchors.js";
25
+ import { scanDocAnchors } from "./docanchors.js";
26
26
  import { markdownDocs, STALE_MARKER, SRC_REF } from "./docscan.js";
27
27
  import { computeWikiDrift } from "../wiki/wiki.js";
28
28
  import { computeMadrDrift } from "../integrations/madrManifest.js";
29
29
  import { commitsExist, isGitRepo } from "../extractors/git.js";
30
- export const DRIFT_KINDS = ["dead-ref", "supersede", "doc-stale", "anchor-stale", "doc-anchor-stale", "doc-anchor-dangling", "wiki-stale", "finding-stale", "premise-stale", "commit-unresolvable", "madr-stale", "madr-edited", "madr-orphan"];
30
+ export const DRIFT_KINDS = ["dead-ref", "supersede", "doc-stale", "anchor-stale", "doc-anchor-stale", "doc-anchor-dangling", "doc-fence-unclosed", "wiki-stale", "finding-stale", "premise-stale", "commit-unresolvable", "madr-stale", "madr-edited", "madr-orphan"];
31
31
  export function computeDrift(store, root, deps = {}) {
32
32
  const findings = [];
33
33
  const decisions = store.recs("decisions");
@@ -157,7 +157,14 @@ export function computeDrift(store, root, deps = {}) {
157
157
  // been superseded (doc-anchor-stale, the CI-gateable one) or that doesn't
158
158
  // exist (doc-anchor-dangling). Unpinned markers only ground the pre-edit
159
159
  // hook — an explicit pin is the ONLY thing that can fire drift here.
160
- for (const a of parseDocAnchors(text)) {
160
+ // A fence that never closes hides every marker below it (doc-fence-unclosed,
161
+ // advisory): almost always an authoring mistake that disables real anchors.
162
+ const scan = scanDocAnchors(text);
163
+ if (scan.unclosedFence) {
164
+ const { line, hidden } = scan.unclosedFence;
165
+ findings.push({ kind: "doc-fence-unclosed", id: doc.rel, detail: `fence opened at line ${line} never closes; ${hidden} marker(s) below it are hidden` });
166
+ }
167
+ for (const a of scan.anchors) {
161
168
  if (!a.pin)
162
169
  continue;
163
170
  const pinned = byId.get(a.pin);
@@ -2,6 +2,8 @@
2
2
  export declare function normalizeEol(text: string): string;
3
3
  /** Remove the CR retained by split("\n") without changing source offsets. */
4
4
  export declare function lineContent(line: string): string;
5
+ /** Length of the line break (CRLF, CR, or LF) starting at `at`, or 0. */
6
+ export declare function eolLengthAt(text: string, at: number): number;
5
7
  /** Read logical lines while keeping offsets in the original UTF-16 source. */
6
8
  export declare function sourceLines(text: string): Generator<{
7
9
  content: string;
package/dist/core/eol.js CHANGED
@@ -6,6 +6,12 @@ export function normalizeEol(text) {
6
6
  export function lineContent(line) {
7
7
  return line.endsWith("\r") ? line.slice(0, -1) : line;
8
8
  }
9
+ /** Length of the line break (CRLF, CR, or LF) starting at `at`, or 0. */
10
+ export function eolLengthAt(text, at) {
11
+ if (text[at] === "\r")
12
+ return text[at + 1] === "\n" ? 2 : 1;
13
+ return text[at] === "\n" ? 1 : 0;
14
+ }
9
15
  /** Read logical lines while keeping offsets in the original UTF-16 source. */
10
16
  export function* sourceLines(text) {
11
17
  let start = 0;
@@ -66,10 +66,14 @@ export declare function ownerInstance(ownerStart: string | undefined, pid: numbe
66
66
  * held nonce and could age-steal its live lock. */
67
67
  export declare const heldLockNonces: Set<string>;
68
68
  /** What a contender should do about a SAME-HOST lock owner.
69
- * - `stale` — provably not the owner's process any more: take it over.
70
- * - `live` — authoritative, however old the file looks: never steal it.
71
- * - `age` — nothing is proven; fall back to the caller's own age rule. */
72
- export type SameHostVerdict = "stale" | "live" | "age";
69
+ * - `stale` — provably not the owner's process any more: take it over.
70
+ * - `live` — authoritative, however old the file looks: never steal it.
71
+ * - `unproven` — a live pid whose process instance no token can compare (macOS,
72
+ * Windows, a legacy record). Treated as live; a caller with a
73
+ * better probe may still disprove it (src/serve/writelock.ts
74
+ * asks the OS when that process was created, issue #287).
75
+ * - `age` — nothing is proven; fall back to the caller's own age rule. */
76
+ export type SameHostVerdict = "stale" | "live" | "unproven" | "age";
73
77
  /** The parts of a lock record this decision reads. Both lock formats supply it. */
74
78
  export interface SameHostOwner {
75
79
  pid: number;
@@ -91,4 +95,11 @@ export interface SameHostEnv {
91
95
  * process state beyond what `env` hands it.
92
96
  */
93
97
  export declare function judgeSameHostOwner(owner: SameHostOwner, env: SameHostEnv): SameHostVerdict;
98
+ /** Wall-clock ms at which the process now holding `pid` was created, from the
99
+ * OS, or null when it cannot be read. SPAWNS (PowerShell on Windows, `ps` on
100
+ * macOS), so it is for rare paths only — a lock that already looks stale —
101
+ * never for every acquisition. Precision is a second on `ps`. Null on every
102
+ * other platform: linux derives `lstart` from boot time, which a forward clock
103
+ * step shifts for live processes too (see the header). */
104
+ export declare function processStartedAt(pid: number): number | null;
94
105
  export {};
@@ -23,19 +23,23 @@
23
23
  * puts two writers inside the mutex. Reproduced by simply `utimes`-ing a held
24
24
  * lock 30 s into the past. Nothing in this file may read a clock.
25
25
  *
26
- * REMAINING GAP. Only linux exposes a spawn-free, stable process-instance id
27
- * (`/proc/<pid>/stat` field 22 plus the boot id and the pid/time namespaces). On
28
- * macOS and Windows a DIFFERENT live process that recycled the owner's pid
29
- * therefore stays "not proven" and keeps the lock until the age rule applies —
30
- * today's behaviour, and the safe direction. `ps -o lstart` and PowerShell's
31
- * `Get-Process ... StartTime` were rejected: both cost a spawn per probe on the
32
- * contended path, and both return locale/TZ-dependent strings we would have to
33
- * parse back into a comparable identity. Gating such a probe on age does not
34
- * rescue them either: the CONTENDER can read the current holder's start time,
35
- * but a verdict needs something to compare it WITH — either a start time the
36
- * OWNER recorded when it took the lock (which puts that spawn on every writing
37
- * process, not just the contended path) or a start-time-vs-mtime comparison,
38
- * i.e. exactly the cross-clock arithmetic rejected above.
26
+ * OFF LINUX (issue #287). Only linux exposes a spawn-free, stable process-instance
27
+ * id (`/proc/<pid>/stat` field 22 plus the boot id and the pid/time namespaces), so
28
+ * on macOS and Windows a DIFFERENT live process that recycled the owner's pid is
29
+ * "unproven" here, which this table treats as live. The write lock closes that
30
+ * forever-deadlock on its own rare path, once a lock already looks stale: it asks
31
+ * the OS when the process now holding the pid was CREATED (`processStartedAt`, a
32
+ * spawn) and compares that with the `at` the owner recorded when it took the lock.
33
+ * A process created after the lock was taken cannot have taken it. Both instants
34
+ * are the HOST's wall clock, read on this same host, so the filesystem clock never
35
+ * enters; only a backward clock step of more than the slack between the owner's
36
+ * start and its acquisition could fake it. macOS and Windows record that creation
37
+ * instant absolutely; linux does not (`ps lstart` is boot time + uptime, so a
38
+ * forward clock step after boot moves every live process's "creation" forward and
39
+ * would fake a reuse), so on linux the probe reports nothing and a tokenless
40
+ * record keeps main's pid rule — linux writers record a token instead. The earlier objection to such a probe —
41
+ * that it needs either an owner-recorded start time (a spawn on every write) or a
42
+ * start-vs-mtime comparison — missed that `at` is already in every record.
39
43
  *
40
44
  * UNSUPPORTED TOPOLOGIES. A linux-written token carries its boot id and its pid
41
45
  * and time namespaces, so a lock written under another boot or another namespace
@@ -51,12 +55,14 @@
51
55
  * while our own token is memoized from before the checkpoint — so a restored
52
56
  * holder may judge itself against a token that no longer describes it. Tokenless
53
57
  * records (a legacy pre-token hunch, any non-linux writer, an unreadable /proc)
54
- * get main's unchanged pid rule — a live same-host pid keeps its lock, a dead one
55
- * is stealable — with exactly one exception, the own-pid + foreign-nonce row
58
+ * get main's unchanged pid rule — a live same-host pid keeps its lock (unless the
59
+ * write lock's creation-time check above proves the pid reused), a dead one is
60
+ * stealable — with exactly one exception, the own-pid + foreign-nonce row
56
61
  * described in `judgeSameHostOwner`, which gets the AGE rule instead of main's
57
62
  * "live forever".
58
63
  */
59
64
  import { readFileSync, readlinkSync } from "node:fs";
65
+ import { spawnSync } from "node:child_process";
60
66
  /** `starttime` in clock ticks since boot, as the exact decimal STRING the kernel
61
67
  * printed — it is an identity component, never a quantity, so it is never
62
68
  * converted to a Number (which would lose precision above 2^53 and invite
@@ -237,8 +243,40 @@ export function judgeSameHostOwner(owner, env) {
237
243
  // that a live writer may still hold.
238
244
  if (owner.pid === env.selfPid && owner.nonce !== undefined)
239
245
  return "age";
240
- // A nonce-less own-pid record stays live: there is nothing to compare, and a
241
- // live pid is never stolen on a guess.
242
- return "live";
246
+ // 7. A live pid no token can compare — another process's, or a nonce-less
247
+ // record of our own pid. Never stolen on a guess: "unproven" is "live" to a
248
+ // caller that has nothing better. The write lock asks the OS for that
249
+ // process's creation time, which can prove the pid was reused (issue #287).
250
+ return "unproven";
251
+ }
252
+ /** Wall-clock ms at which the process now holding `pid` was created, from the
253
+ * OS, or null when it cannot be read. SPAWNS (PowerShell on Windows, `ps` on
254
+ * macOS), so it is for rare paths only — a lock that already looks stale —
255
+ * never for every acquisition. Precision is a second on `ps`. Null on every
256
+ * other platform: linux derives `lstart` from boot time, which a forward clock
257
+ * step shifts for live processes too (see the header). */
258
+ export function processStartedAt(pid) {
259
+ if (!Number.isSafeInteger(pid) || pid <= 0)
260
+ return null;
261
+ try {
262
+ if (process.platform === "win32") {
263
+ // Epoch milliseconds, not a formatted date: a custom format string follows the
264
+ // culture's time separator (fi-FI prints `09.04.19`), which Date.parse rejects.
265
+ const r = spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", `([DateTimeOffset](Get-Process -Id ${pid} -ErrorAction Stop).StartTime).ToUnixTimeMilliseconds()`], { encoding: "utf8", timeout: 3_000, windowsHide: true });
266
+ const ms = r.status === 0 && /^\d+$/.test(r.stdout.trim()) ? Number(r.stdout.trim()) : NaN;
267
+ return Number.isFinite(ms) ? ms : null;
268
+ }
269
+ if (process.platform !== "darwin")
270
+ return null;
271
+ const r = spawnSync("ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf8", timeout: 3_000, env: { ...process.env, LC_ALL: "C", LANG: "C" } });
272
+ const m = /^\s*\w{3}\s+(\w{3})\s+(\d{1,2})\s+(\d{1,2}):(\d{2}):(\d{2})\s+(\d{4})\s*$/.exec(r.status === 0 ? r.stdout : "");
273
+ const month = m ? ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"].indexOf(m[1]) : -1;
274
+ if (!m || month < 0)
275
+ return null;
276
+ return new Date(Number(m[6]), month, Number(m[2]), Number(m[3]), Number(m[4]), Number(m[5])).getTime(); // ps prints local time
277
+ }
278
+ catch {
279
+ return null;
280
+ }
243
281
  }
244
282
  //# sourceMappingURL=procstart.js.map
@@ -0,0 +1,7 @@
1
+ export declare function runbookDraft(commits: readonly {
2
+ subject: string;
3
+ files: readonly string[];
4
+ }[], rangeFiles: readonly string[]): {
5
+ steps: string[];
6
+ files: string[];
7
+ };