@nightlab-agent/still-true 0.3.0 → 0.5.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.
- package/README.md +7 -1
- package/package.json +1 -1
- package/src/cli.mjs +13 -6
- package/src/extract.mjs +41 -6
package/README.md
CHANGED
|
@@ -39,6 +39,11 @@ In a Markdown file with headings, it reads a section only if its heading starts
|
|
|
39
39
|
- `look: waits on ..., which is merged`: the note waits on something finished. It may be waiting for a release or a reply, so read it. This does not fail the run.
|
|
40
40
|
- `look: queued work on ..., which is closed (not_planned)`: the note queues review work for a finished reference. Read whether that work still belongs in the queue. This does not fail the run.
|
|
41
41
|
- `not checked`: it could not place the reference, or GitHub has no such issue, or it is private. Nothing was verified for that line.
|
|
42
|
+
- `no claim read`: a reference on a line it read, where it found no statement about that reference's state. `see #12` lands here, and so can a real statement in a sentence it could not parse. It is a list to read, not a verdict: no exit code changes for it. It does not cover statements that name no reference at all.
|
|
43
|
+
|
|
44
|
+
A statement about a reference's state may start with `only`, as in `only owner/repo#12 (merged 11-07, known)`. `owner/repo#12, merged 11-07` also reads one merged claim, including after a separate clause such as `Status: draft;`. These are narrow subject-and-state forms: `see owner/repo#12, merged 11-07` still gives no claim.
|
|
45
|
+
|
|
46
|
+
A standalone retraction field in parentheses or after a comma takes back its clause. A field immediately after a semicolon does too: `#12 is open; no longer true` gives no claim. The wording is `no longer` or `not` followed by `true`, `the case`, `accurate` or `current`, or just `outdated`, `wrong` or `incorrect`; filler such as `which is` or `anymore` is allowed. Extra prose such as `fixes outdated documentation`, quoted text and inline code do not retract. A semicolon retraction takes back the clause before it, back to the previous semicolon or sentence end, so `#12 is open, #13 is open; no longer true` retracts both; a claim in its own clause is still checked: `#12 is open; no longer true; #13 is open` still checks #13. A note containing only a retracted claim exits `0` normally and `3` with `--strict`, since nothing was verified; its reference appears under `no claim read`.
|
|
42
47
|
|
|
43
48
|
Queued work needs a reference at the start of a clause, optionally labelled PR, issue or pull request, then an optional commit hash and a counted review obligation such as `argent#1768 81004b3 owes one critic` or `argent#1768, one review owed`. Counts are a, an, one, two, three or a positive integer; the nouns are critic(s) or review(s). `still` may precede the obligation. One comma may separate the reference/hash from it, and a following wait may start with `and` or a comma. An open reference stays quiet. A reference alone, a past-tense obligation, or other unquoted trailing prose in that clause does not qualify. Quoted text and inline code are masked before matching: `argent#1768 owes one critic "not anymore"` and ``argent#1768 owes one critic `done` `` still qualify. A wholly quoted obligation stays out, as do historical and planning scopes. Blockquoted lines contribute no claims, including when the quote follows nested list markers. A quote does not silence the preceding paragraph or an independent list item after it; an immediately following unmarked prose line can still continue the quote.
|
|
44
49
|
|
|
@@ -66,7 +71,8 @@ Add `--json` for one JSON object on stdout, with no text lines. Exit codes stay
|
|
|
66
71
|
The object has `schema: 1`, `complete`, `reason` (`null` on success), `counts` and `claims`.
|
|
67
72
|
Counts are `checked`, `contradicted`, `looks` and `notChecked`; checked includes contradictions, looks and ok claims.
|
|
68
73
|
Each claim has `file`, a 1-based `line`, `reference` (resolved to `owner/repo#number` when possible), `said`, `actual` (`open`, `merged`, `closed` or `null` when unknown), `kind` (`contradiction`, `look`, `not checked` or `ok`), and `url` when known.
|
|
69
|
-
If no claim was found and a section was skipped, `skipped_sections` lists those sections, each with its file when you gave more than one. `complete` is still `true` and the exit is `0` without `--strict`, so a script that must know
|
|
74
|
+
If no claim was found and a section was skipped, `skipped_sections` lists those sections, each with its file when you gave more than one. `complete` is still `true` and the exit is `0` without `--strict`, so a script that must know something was verified should use `--strict`. Strict does not say every reference gave a claim: read `no claim read` for that.
|
|
75
|
+
`unread_references`, present only when there are some, lists `file`, `line` and `reference` for each reference that gave no claim. It does not change `counts` or the exit.
|
|
70
76
|
`said` can also be `queued`, for the review obligations above. A nonempty GitHub `state_reason` is included as an optional string field; null or absent reasons are omitted.
|
|
71
77
|
|
|
72
78
|
A failed read or unreadable file sets `complete: false` and gives the reason, keeping earlier results. The run stops there; claims already extracted from the current file that were not reached have kind `not checked`. Later files have no entries. A complete run can still have unchecked claims, such as an unresolved alias or a private issue. No claims gives an empty array and zero counts. `--json --help` returns the help text in a `help` field.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nightlab-agent/still-true",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
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/cli.mjs
CHANGED
|
@@ -43,13 +43,13 @@ export async function run(argv, deps) {
|
|
|
43
43
|
const { out, extract, readFile = fsRead, read, token, defaultRepo = originRepo, env = process.env } = deps;
|
|
44
44
|
const args = parseArgs(argv);
|
|
45
45
|
const print = (line) => { if (!args.json) out(line); };
|
|
46
|
-
const entries = [];
|
|
46
|
+
const entries = [], noClaim = [];
|
|
47
47
|
let checked = 0, contradicted = 0, looks = 0, unplaced = 0, failure = null;
|
|
48
48
|
const finish = (code, reason = null, extra = {}) => {
|
|
49
49
|
if (args.json) out(JSON.stringify({
|
|
50
50
|
schema: 1, complete: reason === null, reason,
|
|
51
51
|
counts: { checked, contradicted, looks, notChecked: entries.length - checked },
|
|
52
|
-
claims: entries, ...extra,
|
|
52
|
+
claims: entries, ...(noClaim.length ? { unread_references: noClaim } : {}), ...extra,
|
|
53
53
|
}));
|
|
54
54
|
return code;
|
|
55
55
|
};
|
|
@@ -77,8 +77,8 @@ export async function run(argv, deps) {
|
|
|
77
77
|
return finish(contradicted ? 1 : 2, reason);
|
|
78
78
|
}
|
|
79
79
|
// Record all claims in this file before reading GitHub, including those a failure may leave unchecked.
|
|
80
|
-
const here = [];
|
|
81
|
-
const claims = extract(text, { aliases: args.aliases, skipped: here }).map((claim) => {
|
|
80
|
+
const here = [], unreadHere = [];
|
|
81
|
+
const claims = extract(text, { aliases: args.aliases, skipped: here, unread: unreadHere }).map((claim) => {
|
|
82
82
|
const ref = resolveRef(claim.ref, { defaultRepo: repo, aliases: args.aliases });
|
|
83
83
|
const entry = args.json ? {
|
|
84
84
|
file, line: claim.line, reference: name(ref ?? claim.ref), said: claim.said,
|
|
@@ -127,6 +127,12 @@ export async function run(argv, deps) {
|
|
|
127
127
|
print(` "${claim.quote}"`);
|
|
128
128
|
}
|
|
129
129
|
}
|
|
130
|
+
const written = new Map();
|
|
131
|
+
for (const { line, ref } of unreadHere) {
|
|
132
|
+
noClaim.push({ file, line, reference: name(resolveRef(ref, { defaultRepo: repo, aliases: args.aliases }) ?? ref) });
|
|
133
|
+
written.set(line, [...(written.get(line) ?? []), name(ref)]);
|
|
134
|
+
}
|
|
135
|
+
for (const [line, refs] of written) print(`${file}:${line} no claim read: ${refs.join(', ')}`);
|
|
130
136
|
if (failure !== null) break;
|
|
131
137
|
}
|
|
132
138
|
} catch (e) { failure = e instanceof Error ? e.message : String(e); }
|
|
@@ -139,6 +145,7 @@ export async function run(argv, deps) {
|
|
|
139
145
|
if ((env.HTTPS_PROXY ?? env.https_proxy) && !env.NODE_USE_ENV_PROXY) print('You have a proxy set: run again with NODE_USE_ENV_PROXY=1.');
|
|
140
146
|
return finish(contradicted ? 1 : 2, failure);
|
|
141
147
|
}
|
|
148
|
+
const leftBehind = noClaim.length ? `${noClaim.length} reference${noClaim.length === 1 ? '' : 's'} with no claim read` : '';
|
|
142
149
|
const strictFail = (extra = {}) => {
|
|
143
150
|
const why = [];
|
|
144
151
|
if (unplaced) why.push(`${unplaced} claim${unplaced === 1 ? '' : 's'} not checked`);
|
|
@@ -147,12 +154,12 @@ export async function run(argv, deps) {
|
|
|
147
154
|
return finish(3, null, { ...extra, strict_failed: true });
|
|
148
155
|
};
|
|
149
156
|
if (!checked && !unplaced) {
|
|
150
|
-
print(
|
|
157
|
+
print(`no claims about PR or issue state found${leftBehind && `, ${leftBehind}`}`);
|
|
151
158
|
if (!skipped.length) return args.strict ? strictFail() : finish(0);
|
|
152
159
|
print(`skipped ${skipped.length} section${skipped.length === 1 ? '' : 's'}: ${skipped.join(', ')}. A section is read only if its heading, and every heading above it, apart from the file's title, starts with Now, Handoff, State, Status or Current.`);
|
|
153
160
|
return args.strict ? strictFail({ skipped_sections: skipped }) : finish(0, null, { skipped_sections: skipped });
|
|
154
161
|
}
|
|
155
|
-
const note = (looks ? `, ${looks} to look at` : '') + (unplaced ? `, ${unplaced} not checked` : '');
|
|
162
|
+
const note = (looks ? `, ${looks} to look at` : '') + (unplaced ? `, ${unplaced} not checked` : '') + (leftBehind && `, ${leftBehind}`);
|
|
156
163
|
print(`${checked} claim${checked === 1 ? '' : 's'} checked, ${contradicted} contradicted${note}`);
|
|
157
164
|
if (contradicted) return finish(1);
|
|
158
165
|
return args.strict && (unplaced || unread.length) ? strictFail() : finish(0);
|
package/src/extract.mjs
CHANGED
|
@@ -13,11 +13,18 @@ const QUEUED = new RegExp(`^(?:(?:PR|issue|pull request)\\s+)?(${TOKEN})(?:\\s+[
|
|
|
13
13
|
const LABEL_LIST = new RegExp(`(?:^|:\\s*)(open|merged|closed)(?:\\s+(?:PRs|issues|pull requests))?:\\s*(${TOKEN}(?:\\s*(?:,|and)\\s*${TOKEN})*)$`, 'i');
|
|
14
14
|
|
|
15
15
|
const REF = /^(?:([a-z0-9-]+)\/)?([a-z0-9_.-]+)?#([1-9][0-9]*)$/i;
|
|
16
|
+
const MENTION = /(?<![\w./#-])(?:[a-z0-9-]+\/)?[a-z0-9_.-]*#[1-9][0-9]*(?![\w-]|\.\w)/gi;
|
|
16
17
|
const PLANNED = /\b(?:plans?|planned|planning|proposed|intended|expected|ideas?|goals?|draft|to[- ]?dos?|next|later|someday|old|previous|history|example)\b/i;
|
|
17
18
|
const CURRENT_HEADING = /^(?:handoff|state|status|now|current)\b(?!-)/i;
|
|
18
19
|
const PREAMBLE = /^(?:if|unless|once|after|when|until|yesterday|old note|example|suppose|imagine)\b/i;
|
|
19
20
|
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;
|
|
20
21
|
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;
|
|
22
|
+
// The writer takes the claim back on the same line: "#12 is open (no longer true)".
|
|
23
|
+
// Only a field that is nothing but the retraction counts, so "(fixes outdated documentation)" keeps its claim.
|
|
24
|
+
const RETRACTION = /^(?:(?:no\s+longer|not)\s+(?:true|the\s+case|accurate|current)|outdated|wrong|incorrect)$/i;
|
|
25
|
+
const RETRACT_FILLER = /\b(?:which|that|this|it|is|was|now|anymore|any\s+more|see\s+below)\b/gi;
|
|
26
|
+
const isRetraction = (field) => RETRACTION.test(field.replace(RETRACT_FILLER, ' ').replace(/[.!]/g, ' ').replace(/\s+/g, ' ').trim());
|
|
27
|
+
const RETRACTED = { test: (body) => body.split(/[(),;]/).some(isRetraction) };
|
|
21
28
|
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;
|
|
22
29
|
const QUALIFIED_STATE = /^\s+(?:to|into|for|or|after)\b/i;
|
|
23
30
|
// A later comma field must look like a state fact, not "open question".
|
|
@@ -25,9 +32,9 @@ const COMMA_STATE_TAIL = /^(?:$|\s+(?:\d|today\b|yesterday\b|last\b|since\b|by\b
|
|
|
25
32
|
// An initial "open PR" needs a predicate to distinguish it from a command.
|
|
26
33
|
const OPEN_PREDICATE = /^\s+(?:is|has|needs|touches)\b/i;
|
|
27
34
|
const OPEN_POSSESSIVE = /(?:['’]s|\b(?:my|our|your|their|his|her|its))\s*$/i;
|
|
28
|
-
// Subjects may have a noun label or a possessive determiner, but objects of
|
|
35
|
+
// Subjects may have "only", a noun label or a possessive determiner, but objects of
|
|
29
36
|
// phrases such as "a PR for issue #12" are not the subject of that PR's state.
|
|
30
|
-
const SUBJECT_PREFIX = /^(?:(?:PR|issue|pull request)|(?:[\w'-]+\s+)+(?:PR|issue|pull request)|(?:the\s+)?[\w-]+['’]s|(?:my|our|your|their|his|her|its)(?:\s+own)?)?$/i;
|
|
37
|
+
const SUBJECT_PREFIX = /^(?:only|(?:PR|issue|pull request)|(?:[\w'-]+\s+)+(?:PR|issue|pull request)|(?:the\s+)?[\w-]+['’]s|(?:my|our|your|their|his|her|its)(?:\s+own)?)?$/i;
|
|
31
38
|
const OBJECT_PREFIX = /\b(?:for|of|on|about|after)\b/i;
|
|
32
39
|
|
|
33
40
|
function nextQuote(raw, i, quote) {
|
|
@@ -45,6 +52,12 @@ function endQuote(raw, quote) {
|
|
|
45
52
|
|
|
46
53
|
function clauses(raw) {
|
|
47
54
|
const result = [];
|
|
55
|
+
let afterSemicolon = false;
|
|
56
|
+
const add = (clause) => {
|
|
57
|
+
// Keep a standalone semicolon retraction with the clause it takes back.
|
|
58
|
+
if (afterSemicolon && isRetraction(unquoted(clause))) result[result.length - 1] += `; ${clause}`;
|
|
59
|
+
else result.push(clause);
|
|
60
|
+
};
|
|
48
61
|
let start = 0, quote = null, code = null, depth = 0;
|
|
49
62
|
for (let i = 0; i < raw.length; i++) {
|
|
50
63
|
const char = raw[i];
|
|
@@ -61,11 +74,12 @@ function clauses(raw) {
|
|
|
61
74
|
if (char === '(') depth++;
|
|
62
75
|
if (char === ')') depth--;
|
|
63
76
|
if (depth === 0 && (char === ';' || (/[.!?]/.test(char) && /\s|^$/.test(raw[i + 1] ?? '')))) {
|
|
64
|
-
|
|
77
|
+
add(raw.slice(start, char === ';' ? i : i + 1).trim());
|
|
78
|
+
afterSemicolon = char === ';';
|
|
65
79
|
start = i + 1;
|
|
66
80
|
}
|
|
67
81
|
}
|
|
68
|
-
|
|
82
|
+
add(raw.slice(start).trim());
|
|
69
83
|
return result.filter(Boolean);
|
|
70
84
|
}
|
|
71
85
|
|
|
@@ -106,7 +120,7 @@ const laterStep = (field) => (field.includes('#') || /\b(?:only|just|not|except|
|
|
|
106
120
|
|
|
107
121
|
function claimsFromClause(quote, line) {
|
|
108
122
|
const body = unquoted(quote);
|
|
109
|
-
if (!body.includes('#') || body.includes('?') || UNCERTAIN.test(body.replace(LATER_STEP_ONCE, laterStep)) || NONSTATE.test(body)) return [];
|
|
123
|
+
if (!body.includes('#') || body.includes('?') || UNCERTAIN.test(body.replace(LATER_STEP_ONCE, laterStep)) || NONSTATE.test(body) || RETRACTED.test(body)) return [];
|
|
110
124
|
const claims = [];
|
|
111
125
|
const add = (tokens, said) => {
|
|
112
126
|
for (const token of tokens) {
|
|
@@ -199,6 +213,24 @@ function claimsFromLine(raw, line) {
|
|
|
199
213
|
return claims;
|
|
200
214
|
}
|
|
201
215
|
|
|
216
|
+
const refKey = (ref) => `${ref.owner ?? ''}/${ref.repo ?? ''}#${ref.number}`.toLowerCase();
|
|
217
|
+
|
|
218
|
+
// References on read lines that gave no claim, once per line, in note order.
|
|
219
|
+
function unreadReferences(read, claims) {
|
|
220
|
+
const claimed = new Set(claims.map((claim) => `${claim.line} ${refKey(claim.ref)}`));
|
|
221
|
+
const unread = [];
|
|
222
|
+
for (const { raw, line } of read) {
|
|
223
|
+
for (const [token] of unquoted(raw).matchAll(MENTION)) {
|
|
224
|
+
const ref = makeClaim(token, '', '', line)?.ref;
|
|
225
|
+
const id = ref && `${line} ${refKey(ref)}`;
|
|
226
|
+
if (!ref || claimed.has(id)) continue;
|
|
227
|
+
claimed.add(id);
|
|
228
|
+
unread.push({ line, ref });
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return unread;
|
|
232
|
+
}
|
|
233
|
+
|
|
202
234
|
function closes(lines, from, open) {
|
|
203
235
|
let quote = open;
|
|
204
236
|
for (let i = from; i < lines.length && quote !== null; i++) quote = endQuote(lines[i], quote);
|
|
@@ -208,7 +240,7 @@ function closes(lines, from, open) {
|
|
|
208
240
|
// Aliases and the default repository are resolved by the reader. Preserve the
|
|
209
241
|
// identity written in the note, including refs that the reader cannot place.
|
|
210
242
|
export function extractClaims(text, options = {}) {
|
|
211
|
-
const claims = [];
|
|
243
|
+
const claims = [], read = [];
|
|
212
244
|
let paragraph = [], fence = null, quote = null, comment = false, sections = [], frontMatter = false, titled = false, planned = null;
|
|
213
245
|
const flush = (keepLabel = false) => {
|
|
214
246
|
// A wrapped condition, quotation or historical preamble can change the
|
|
@@ -300,6 +332,7 @@ export function extractClaims(text, options = {}) {
|
|
|
300
332
|
const title = paragraph.map((entry) => entry.text).filter(Boolean).join(' ');
|
|
301
333
|
// Preserve the conservative reading of multiline headings while naming them.
|
|
302
334
|
const currentTitle = paragraph.length === 1 ? lines[index - 1].trim() : '';
|
|
335
|
+
while (read.at(-1)?.line > index - paragraph.length) read.pop(); // Those lines were a heading after all.
|
|
303
336
|
paragraph = [];
|
|
304
337
|
enterSection(underline[1][0] === '=' ? 1 : 2, currentTitle, title);
|
|
305
338
|
} else if (underline && paragraph.length) {
|
|
@@ -313,6 +346,7 @@ export function extractClaims(text, options = {}) {
|
|
|
313
346
|
const bare = body.replace(/[*_`]/g, '');
|
|
314
347
|
const item = /^\s*(?:[-+*]|[0-9]+[.)])\s+/.test(raw);
|
|
315
348
|
if (item && paragraph.at(-1)?.blockquote) flush(true); // A new list is not a quote continuation.
|
|
349
|
+
if (readable() && !/^(?: {4}|\t)/.test(raw) && !paragraph.at(-1)?.blockquote) read.push({ raw, line: index + 1 });
|
|
316
350
|
if (!item && paragraph.at(-1)?.blockquote) { // Every unmarked line of a quoted paragraph stays in the quote.
|
|
317
351
|
paragraph.push({ claims: [], list: false, indent: Infinity, blockquote: true });
|
|
318
352
|
continue;
|
|
@@ -325,5 +359,6 @@ export function extractClaims(text, options = {}) {
|
|
|
325
359
|
}
|
|
326
360
|
}
|
|
327
361
|
flush();
|
|
362
|
+
options.unread?.push(...unreadReferences(read, claims));
|
|
328
363
|
return claims;
|
|
329
364
|
}
|