@nightlab-agent/still-true 0.1.0 → 0.1.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/README.md CHANGED
@@ -18,7 +18,7 @@ Give it one or more Markdown files. For a bare `#12` it uses `--repo owner/name`
18
18
 
19
19
  Set `GITHUB_TOKEN` for private repositories and a higher rate limit. Without one, GitHub allows 60 requests an hour. Needs Node 20 or newer. Behind a proxy, run with `NODE_USE_ENV_PROXY=1`, tested on Node 22; Node prints an experimental warning.
20
20
 
21
- In a Markdown file with headings, it reads a section only if its heading starts with Now, Handoff, State, Status or Current, and any heading above it does too, or is the file's level 1 title. Plans and history stay out. Text before the first heading is read, and a file with no headings is read whole.
21
+ In a Markdown file with headings, it reads a section only if its heading starts with Now, Handoff, State, Status or Current, and any heading above it does too, or is the file's first level 1 heading. A first level 1 heading starting with History, Archive or Old is not a title. Plans and history stay out. Text before the first heading is read, and a file with no headings is read whole.
22
22
 
23
23
  ## What each line means
24
24
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nightlab-agent/still-true",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Check a handoff note against GitHub: finds what it says about PRs and issues and prints the claims that are no longer true. Exits 1 on a contradiction.",
5
5
  "type": "module",
6
6
  "bin": { "still-true": "bin/still-true.js" },
package/src/extract.mjs CHANGED
@@ -9,10 +9,12 @@ const WAIT = new RegExp(`(?:^|\\s)(?:waits?|waiting)\\s+(?:on|for)\\s+(${TOKEN}|
9
9
  const LABEL_LIST = new RegExp(`(?:^|:\\s*)(open|merged|closed)(?:\\s+(?:PRs|issues|pull requests))?:\\s*(${TOKEN}(?:\\s*(?:,|and)\\s*${TOKEN})*)$`, 'i');
10
10
 
11
11
  const REF = /^(?:([a-z0-9-]+)\/)?([a-z0-9_.-]+)?#([1-9][0-9]*)$/i;
12
+ const PLANNED = /\b(?:plans?|planned|planning|proposed|intended|expected|ideas?|goals?|draft|to-?dos?|next|later|someday|old|previous|history|example)\b/i;
12
13
  const CURRENT_HEADING = /^(?:handoff|state|status|now|current)\b(?!-)/i;
13
14
  const PREAMBLE = /^(?:if|unless|once|after|when|until|yesterday|old note|example|suppose|imagine)\b/i;
14
15
  const UNCERTAIN = /\b(?:probably|maybe|might|should|could|would|if|unless|when|until|need|get|think|believe|locally|re-?opened)\b|\bonce\s+(?!at\b)\S|n['’]t\b|\bby\s+(?:Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday|tomorrow|next)\b/i;
15
16
  const NONSTATE = /\b(?:(?:not|never|no longer)\s+(?:(?:yet|still|currently|already)\s+)?(?:open|merged|closed)|(?:was|were|used to be)\s+(?:(?:still|currently|already)\s+)?(?:open|closed)|(?:open|merged|closed)\s+or\s+(?:open|merged|closed))\b/i;
17
+ const NOT_WAITING = /\b(?:(?:not|never|no\s+longer|no\s+more|stop(?:s|ped)?|done|finished|quit|gave\s+up|nothing(?:\s+is)?|without|instead\s+of)\s+(?:(?:yet|still|currently|really)\s+)?(?:waits?|waiting)|(?:n['’]t)\s+(?:waits?|waiting))\b/i;
16
18
  const QUALIFIED_STATE = /^\s+(?:to|into|for|or|after)\b/i;
17
19
  // A later comma field must look like a state fact, not "open question".
18
20
  const COMMA_STATE_TAIL = /^(?:$|\s+(?:\d|today\b|yesterday\b|last\b|since\b|by\b|as\s+a\s+duplicate\b))/i;
@@ -140,7 +142,7 @@ function claimsFromClause(quote, line) {
140
142
  context.list = false;
141
143
  }
142
144
  const waiting = WAIT.exec(tail);
143
- if (waiting) add(REF.test(waiting[1]) ? [waiting[1]] : context.subjects, 'waiting');
145
+ if (waiting && !NOT_WAITING.test(text)) add(REF.test(waiting[1]) ? [waiting[1]] : context.subjects, 'waiting');
144
146
  const before = BEFORE_REF.exec(text);
145
147
  if (before && (!subject || before.index < subject[1].length) &&
146
148
  (before[1].toLowerCase() !== 'open' || (before[2] &&
@@ -179,11 +181,17 @@ function claimsFromLine(raw, line) {
179
181
  return claims;
180
182
  }
181
183
 
184
+ function closes(lines, from, open) {
185
+ let quote = open;
186
+ for (let i = from; i < lines.length && quote !== null; i++) quote = endQuote(lines[i], quote);
187
+ return quote === null;
188
+ }
189
+
182
190
  // Aliases and the default repository are resolved by the reader. Preserve the
183
191
  // identity written in the note, including refs that the reader cannot place.
184
192
  export function extractClaims(text, _options = {}) {
185
193
  const claims = [];
186
- let paragraph = [], fence = null, quote = null, comment = false, sections = [], frontMatter = false;
194
+ let paragraph = [], fence = null, quote = null, comment = false, sections = [], frontMatter = false, titled = false, planned = null;
187
195
  const flush = () => {
188
196
  // A wrapped condition, quotation or historical preamble can change the
189
197
  // following line. Separate list items may contain independent nonclaims.
@@ -191,8 +199,13 @@ export function extractClaims(text, _options = {}) {
191
199
  // A historical label ("Old note:") covers the whole list under it.
192
200
  const old = paragraph.findIndex((entry) => !entry.list && !entry.claims.length && entry.label);
193
201
  paragraph.forEach((entry, i) => {
194
- if (old >= 0 && i > old && entry.list) return;
195
- if (!blocks(paragraph[i - 1]) && !blocks(paragraph[i + 1])) claims.push(...entry.claims);
202
+ // A planning list item covers the items nested under it, blank lines included.
203
+ if (planned !== null && entry.indent <= planned) planned = null;
204
+ const nested = planned !== null;
205
+ if (!nested && entry.parent) planned = entry.indent;
206
+ if (nested || (old >= 0 && i > old && entry.list)) return;
207
+ const after = paragraph[i + 1]; // A planning item says nothing of the line before it.
208
+ if (!blocks(paragraph[i - 1]) && (!blocks(after) || after.parent)) claims.push(...entry.claims);
196
209
  });
197
210
  paragraph = [];
198
211
  };
@@ -200,7 +213,8 @@ export function extractClaims(text, _options = {}) {
200
213
  sections = sections.filter((section) => section.level < level);
201
214
  // Unknown headings may introduce plans, examples or history. Only a
202
215
  // current section (and current ancestors) permits extraction.
203
- sections.push({ level, current: CURRENT_HEADING.test(title) });
216
+ sections.push({ level, current: CURRENT_HEADING.test(title), title: level === 1 && !titled && !/^(?:history|archive|old)\b/i.test(title) });
217
+ titled ||= level === 1;
204
218
  };
205
219
 
206
220
  const lines = text.replace(/^\uFEFF/, '').split(/\r\n|\n|\r/);
@@ -220,13 +234,20 @@ export function extractClaims(text, _options = {}) {
220
234
  }
221
235
  if (comment || raw.includes('<!--')) {
222
236
  comment = !raw.includes('-->') || raw.lastIndexOf('<!--') > raw.lastIndexOf('-->');
223
- paragraph.push({ claims: [], list: false });
237
+ paragraph.push({ claims: [], list: false, indent: Infinity });
224
238
  continue;
225
239
  }
226
240
  const quoted = quote !== null;
227
241
  quote = endQuote(raw, quote);
242
+ // A mark inside text that nothing closes was a stray.
243
+ if (!quoted && quote !== null && raw.trim().length > 1 && !closes(lines, index + 1, quote)) quote = null;
228
244
  if (quoted || quote !== null) {
229
- paragraph.push({ claims: [], list: false });
245
+ // A heading inside a quote still changes the section the quote ends in.
246
+ const inner = /^ {0,3}(#{1,6})\s+(.+?)(?:\s+#+)?\s*$/.exec(raw);
247
+ const rule = /^ {0,3}(=+|-+)\s*$/.exec(raw);
248
+ if (inner) enterSection(inner[1].length, inner[2]);
249
+ else if (rule && lines[index - 1]?.trim()) enterSection(rule[1][0] === '=' ? 1 : 2, lines[index - 1].trim());
250
+ paragraph.push({ claims: [], list: false, indent: Infinity });
230
251
  continue;
231
252
  }
232
253
  const opening = /^ {0,3}(`{3,}|~{3,})/.exec(raw);
@@ -238,19 +259,27 @@ export function extractClaims(text, _options = {}) {
238
259
  } else if (heading) {
239
260
  flush();
240
261
  enterSection(heading[1].length, heading[2]);
241
- } else if (underline && paragraph.length) {
262
+ } else if (underline && paragraph.length && !(underline[1][0] === '-' && paragraph.some((entry) => entry.item))) {
242
263
  const title = paragraph.length === 1 ? lines[index - 1].trim() : '';
243
264
  paragraph = [];
244
265
  enterSection(underline[1][0] === '=' ? 1 : 2, title);
266
+ } else if (underline && paragraph.length) {
267
+ flush(); // A rule after a list item, not a heading underline.
245
268
  } else if (!raw.trim()) {
246
269
  flush();
247
270
  } else {
248
- // A level 1 title above a current section does not switch it off.
249
- const readable = sections.every((section, i) => section.current || (section.level === 1 && i < sections.length - 1));
271
+ // The file's level 1 title above a current section does not switch it off.
272
+ const readable = sections.every((section, i) => section.current || (section.title && i < sections.length - 1));
250
273
  const claims = readable ? claimsFromLine(raw, index + 1) : [];
251
- const list = /^ {0,3}(?:[-+*]|[0-9]+[.)])\s+/.test(raw) && !/:\s*$/.test(raw);
252
- const label = /:\s*$/.test(raw) && PREAMBLE.test(raw.trim().replace(/^(?:[-+*]|[0-9]+[.)])\s+/, ''));
253
- paragraph.push({ claims, list, label });
274
+ const body = raw.trim().replace(/^(?:[-+*]|[0-9]+[.)])\s+/, '');
275
+ const label = /:\s*$/.test(raw) && PREAMBLE.test(body);
276
+ const bare = body.replace(/[*_`]/g, '');
277
+ const item = /^\s*(?:[-+*]|[0-9]+[.)])\s+/.test(raw);
278
+ const colon = /:\s*$/.test(bare);
279
+ const parent = item && colon && PLANNED.test(bare);
280
+ // A list item that opens a current list does not silence its first child.
281
+ const list = item && (!colon || CURRENT_HEADING.test(bare));
282
+ paragraph.push({ claims, list, item, label, parent, indent: /^\s*/.exec(raw)[0].length });
254
283
  }
255
284
  }
256
285
  flush();