@davesheffer/hunch 1.43.0 → 1.45.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 +16 -16
- package/dist/cli/index.js +267 -89
- package/dist/core/agenthook.d.ts +1 -0
- package/dist/core/agenthook.js +11 -0
- package/dist/core/changeProof.js +2 -1
- package/dist/core/commitrepair.d.ts +9 -3
- package/dist/core/commitrepair.js +23 -6
- package/dist/core/correction.d.ts +1 -1
- package/dist/core/correction.js +4 -3
- package/dist/core/docanchors.d.ts +11 -2
- package/dist/core/docanchors.js +232 -65
- package/dist/core/drift.d.ts +1 -1
- package/dist/core/drift.js +10 -3
- package/dist/core/eol.d.ts +2 -0
- package/dist/core/eol.js +6 -0
- package/dist/core/escalations.d.ts +20 -0
- package/dist/core/escalations.js +47 -0
- package/dist/core/hookText.js +1 -1
- package/dist/core/procstart.d.ts +15 -4
- package/dist/core/procstart.js +56 -18
- package/dist/core/runbookDraft.d.ts +7 -0
- package/dist/core/runbookDraft.js +43 -0
- package/dist/core/taskTouched.d.ts +2 -0
- package/dist/core/taskTouched.js +1 -1
- package/dist/extractors/git.d.ts +36 -6
- package/dist/extractors/git.js +86 -9
- package/dist/extractors/indexer.js +53 -14
- package/dist/extractors/k8sManifest.d.ts +31 -1
- package/dist/extractors/k8sManifest.js +133 -8
- package/dist/integrations/claudemd.d.ts +2 -0
- package/dist/integrations/claudemd.js +5 -3
- package/dist/integrations/gitignore.d.ts +23 -0
- package/dist/integrations/gitignore.js +48 -0
- package/dist/integrations/hooks.d.ts +15 -0
- package/dist/integrations/hooks.js +49 -1
- package/dist/integrations/mergeDriver.d.ts +2 -0
- package/dist/integrations/mergeDriver.js +2 -2
- package/dist/integrations/providers.d.ts +16 -0
- package/dist/integrations/providers.js +35 -6
- package/dist/integrations/scaffold.d.ts +5 -0
- package/dist/integrations/scaffold.js +13 -8
- package/dist/integrations/uninstall.d.ts +40 -0
- package/dist/integrations/uninstall.js +435 -0
- package/dist/mcp/leanToolList.d.ts +9 -0
- package/dist/mcp/leanToolList.js +89 -0
- package/dist/mcp/server.d.ts +2 -1
- package/dist/mcp/server.js +62 -6
- package/dist/serve/mcpHttp.js +2 -0
- package/dist/serve/writelock.d.ts +7 -0
- package/dist/serve/writelock.js +30 -3
- package/dist/store/changeLedger.d.ts +5 -2
- package/dist/store/changeLedger.js +88 -37
- package/dist/store/hunchStore.js +4 -1
- package/package.json +3 -6
- package/server.json +2 -2
- package/tooling/md1-benchmark.mjs +1 -1
- package/tooling/competitive-watch.mjs +0 -142
package/dist/core/agenthook.d.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
export declare const HOOK_PROVIDERS: readonly ["claude", "codex", "vscode", "windsurf", "antigravity", "cursor"];
|
|
9
9
|
export type HookProvider = (typeof HOOK_PROVIDERS)[number];
|
|
10
|
+
export declare function deliversPromptEvent(provider: HookProvider): boolean;
|
|
10
11
|
export type HunchHookEvent = "PreToolUse" | "PostToolUse" | "PostToolUseFailure" | "UserPromptSubmit" | "SessionStart" | "SubagentStart" | "PreCompact" | "Stop";
|
|
11
12
|
/** One file section of a Codex `apply_patch` payload. */
|
|
12
13
|
export interface HunchPatchFile {
|
package/dist/core/agenthook.js
CHANGED
|
@@ -6,6 +6,17 @@
|
|
|
6
6
|
* the same small, fail-open shape regardless of the assistant that emitted it.
|
|
7
7
|
*/
|
|
8
8
|
export const HOOK_PROVIDERS = ["claude", "codex", "vscode", "windsurf", "antigravity", "cursor"];
|
|
9
|
+
/** Whether a provider's hooks deliver the user's prompt text (a UserPromptSubmit
|
|
10
|
+
* equivalent). Prompt-scoped behavior needs it; a provider without one gets that
|
|
11
|
+
* behavior at its session/orientation event instead. Antigravity's PreInvocation
|
|
12
|
+
* payload carries only conversation/invocation counters, no turn text. A Record,
|
|
13
|
+
* so a new provider must declare its answer. */
|
|
14
|
+
const PROMPT_EVENT = {
|
|
15
|
+
claude: true, codex: true, vscode: true, windsurf: true, cursor: true, antigravity: false,
|
|
16
|
+
};
|
|
17
|
+
export function deliversPromptEvent(provider) {
|
|
18
|
+
return PROMPT_EVENT[provider];
|
|
19
|
+
}
|
|
9
20
|
function obj(value) {
|
|
10
21
|
return value && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
11
22
|
}
|
package/dist/core/changeProof.js
CHANGED
|
@@ -313,7 +313,8 @@ export function deriveChangeProof(root, store, baseRef, resultRef = "HEAD", opti
|
|
|
313
313
|
// A diff cut at MAX_DIFF_BYTES is a prefix: content-matched blocking rules over the
|
|
314
314
|
// files it omits fail closed rather than read as compliant (dec_20db57c576).
|
|
315
315
|
diffStatus: diff.gaps.length ? { incomplete: `the guard diff exceeded ${MAX_DIFF_BYTES} bytes and was cut`, truncated: true } : undefined,
|
|
316
|
-
|
|
316
|
+
// Freshness as of the base: the change under proof must not make its own guard stale (#426).
|
|
317
|
+
lastChange: (path) => lastChangeAt(root, change.base_revision, path),
|
|
317
318
|
});
|
|
318
319
|
const allStrictBlockerIds = sortedUnique([
|
|
319
320
|
...guardReport.direct.filter((item) => item.strictBlocks).map((item) => item.id),
|
|
@@ -30,9 +30,15 @@ export interface CommitRepairPlan {
|
|
|
30
30
|
* doesn't resolve at all ("unresolvable") is out of repair scope, and a git
|
|
31
31
|
* failure ("unknown") must never be mistaken for repair-eligible. */
|
|
32
32
|
export declare function orphanedCommitDecisions(decisions: readonly Decision[], status: (sha: string) => CommitRepairStatus): Decision[];
|
|
33
|
-
/** Match each orphaned decision to exactly one candidate
|
|
34
|
-
*
|
|
35
|
-
|
|
33
|
+
/** Match each orphaned decision to exactly one candidate. A candidate with the
|
|
34
|
+
* same `git patch-id --stable` as the orphaned commit (a rebase- or
|
|
35
|
+
* single-commit squash-merge of it) is the strong match and wins. Only when
|
|
36
|
+
* no candidate shares the patch-id does it fall back to the one candidate
|
|
37
|
+
* whose changed files are a superset of the decision's related_files
|
|
38
|
+
* (non-empty, no globs) -- needed for a squash of several commits, whose
|
|
39
|
+
* patch-id never equals any one of them, but weak: shared files like
|
|
40
|
+
* CHANGELOG.md often have one wrong superset (#450). */
|
|
41
|
+
export declare function planCommitRepair(orphaned: readonly Decision[], candidates: readonly CommitCandidate[], patchIdOf?: (sha: string) => string | null): CommitRepairPlan;
|
|
36
42
|
/** Whether a queued rewrite is still worth ASKING about: the decision it
|
|
37
43
|
* names still exists, still cites the commit the entry expects to replace (a
|
|
38
44
|
* decision that already moved on would make repairDecisionCommit's own
|
|
@@ -4,9 +4,10 @@
|
|
|
4
4
|
* A squash-merge produces one new commit whose parent is the pre-merge target
|
|
5
5
|
* tip — the original per-commit SHAs it squashed are never ancestors of it.
|
|
6
6
|
* This module matches an orphaned decision to the ONE newly-merged commit
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* repair.ts's
|
|
7
|
+
* with the same patch-id, or failing that the ONE whose changed files are a
|
|
8
|
+
* superset of the decision's related_files. Zero or multiple qualifying
|
|
9
|
+
* candidates means "don't guess" — same discipline as repair.ts's
|
|
10
|
+
* rename-repair.
|
|
10
11
|
*/
|
|
11
12
|
import { createHash } from "node:crypto";
|
|
12
13
|
import { replaceExact } from "./refrepair.js";
|
|
@@ -33,11 +34,27 @@ export function orphanedCommitDecisions(decisions, status) {
|
|
|
33
34
|
return status(d.commit) === "orphaned";
|
|
34
35
|
});
|
|
35
36
|
}
|
|
36
|
-
/** Match each orphaned decision to exactly one candidate
|
|
37
|
-
*
|
|
38
|
-
|
|
37
|
+
/** Match each orphaned decision to exactly one candidate. A candidate with the
|
|
38
|
+
* same `git patch-id --stable` as the orphaned commit (a rebase- or
|
|
39
|
+
* single-commit squash-merge of it) is the strong match and wins. Only when
|
|
40
|
+
* no candidate shares the patch-id does it fall back to the one candidate
|
|
41
|
+
* whose changed files are a superset of the decision's related_files
|
|
42
|
+
* (non-empty, no globs) -- needed for a squash of several commits, whose
|
|
43
|
+
* patch-id never equals any one of them, but weak: shared files like
|
|
44
|
+
* CHANGELOG.md often have one wrong superset (#450). */
|
|
45
|
+
export function planCommitRepair(orphaned, candidates, patchIdOf = () => null) {
|
|
39
46
|
const rewrites = [];
|
|
40
47
|
for (const d of orphaned) {
|
|
48
|
+
const patchId = patchIdOf(d.commit);
|
|
49
|
+
if (patchId) {
|
|
50
|
+
const same = candidates.filter((c) => patchIdOf(c.sha) === patchId);
|
|
51
|
+
if (same.length === 1) {
|
|
52
|
+
rewrites.push({ id: d.id, from: d.commit, to: same[0].sha });
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (same.length > 1)
|
|
56
|
+
continue; // ambiguous: never guess
|
|
57
|
+
}
|
|
41
58
|
const files = (d.related_files ?? []).filter(isExactPath);
|
|
42
59
|
if (!files.length)
|
|
43
60
|
continue;
|
|
@@ -30,7 +30,7 @@ export interface CorrectionInput {
|
|
|
30
30
|
* enough — any agent can mint and consume one. Determines the TIER, never whether
|
|
31
31
|
* the write lands: an un-vouched correction still records immediately and still
|
|
32
32
|
* surfaces at edit time and in CI. Only the authority to DENY waits for a countersign
|
|
33
|
-
* (`hunch review --confirm <id>`). */
|
|
33
|
+
* (`hunch review --confirm <id>`). Omitted means false. */
|
|
34
34
|
vouched?: boolean;
|
|
35
35
|
}
|
|
36
36
|
/** Default rationales, exported so a later human countersign can replace the testimony
|
package/dist/core/correction.js
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
* agent hook so they are unit-testable without spinning either up:
|
|
6
6
|
* - looksLikeCorrection(): does a user prompt read like "no / that's wrong /
|
|
7
7
|
* never do X" — the cue to nudge the agent to persist it.
|
|
8
|
-
* - buildCorrectionConstraint(): mint the Constraint record (human-confirmed
|
|
9
|
-
* scoped conservatively) that the pre-edit hook + CI guard then enforce.
|
|
8
|
+
* - buildCorrectionConstraint(): mint the Constraint record (human-confirmed
|
|
9
|
+
* only when vouched, scoped conservatively) that the pre-edit hook + CI guard then enforce.
|
|
10
10
|
*/
|
|
11
11
|
import { isAbsolute } from "node:path";
|
|
12
12
|
import { constraintId } from "./ids.js";
|
|
@@ -108,7 +108,8 @@ export function buildCorrectionConstraint(input, now) {
|
|
|
108
108
|
// still recorded immediately and still held against every assistant at edit time and
|
|
109
109
|
// in CI — Never Twice keeps its promise. What waits for a countersign is only the
|
|
110
110
|
// authority to DENY.
|
|
111
|
-
|
|
111
|
+
// Fails closed: an omitted flag is testimony, never a signature.
|
|
112
|
+
const vouched = input.vouched === true;
|
|
112
113
|
if (!vouched && severity === "blocking")
|
|
113
114
|
severity = "warning";
|
|
114
115
|
return {
|
|
@@ -22,9 +22,18 @@ export interface DocAnchor {
|
|
|
22
22
|
line: number;
|
|
23
23
|
}
|
|
24
24
|
/** Parse every hunch:topic marker out of a markdown document. Markers inside
|
|
25
|
-
*
|
|
26
|
-
*
|
|
25
|
+
* code blocks or inline code spans are examples, not declarations, and are
|
|
26
|
+
* skipped. */
|
|
27
27
|
export declare function parseDocAnchors(text: string): DocAnchor[];
|
|
28
|
+
/** parseDocAnchors plus what drift reports about the document: a fence that
|
|
29
|
+
* never closes hides every marker below it, almost always by mistake (#381). */
|
|
30
|
+
export declare function scanDocAnchors(text: string): {
|
|
31
|
+
anchors: DocAnchor[];
|
|
32
|
+
unclosedFence: {
|
|
33
|
+
line: number;
|
|
34
|
+
hidden: number;
|
|
35
|
+
} | null;
|
|
36
|
+
};
|
|
28
37
|
/** Pre-edit grounding for a markdown document that carries topic anchors: the
|
|
29
38
|
* CURRENT decision per declared topic (graph over prose), what it rejected,
|
|
30
39
|
* and a stale-pin warning the editor can heal inline. Empty when no anchor
|
package/dist/core/docanchors.js
CHANGED
|
@@ -58,14 +58,18 @@ function thematicBreakSuffixes(probe) {
|
|
|
58
58
|
}
|
|
59
59
|
return ok;
|
|
60
60
|
}
|
|
61
|
-
/** Character ranges covered by
|
|
62
|
-
* documentation EXAMPLE of a marker never registers as a live anchor.
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
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
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
121
|
-
|
|
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 =
|
|
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
|
-
|
|
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 <
|
|
210
|
+
while (i < rest.length && rest[i] === " " && i - base < 4)
|
|
145
211
|
i++;
|
|
146
|
-
|
|
147
|
-
if (
|
|
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 =
|
|
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 <
|
|
227
|
+
while (d < rest.length && rest[d] >= "0" && rest[d] <= "9" && d - i < 9)
|
|
160
228
|
d++;
|
|
161
|
-
if (
|
|
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 <
|
|
236
|
+
while (s < rest.length && rest[s] === " ")
|
|
169
237
|
s++;
|
|
170
238
|
const gap = s - (i + markerLen);
|
|
171
|
-
const emptyItem = s >=
|
|
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 ?
|
|
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 =
|
|
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
|
-
|
|
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:
|
|
277
|
+
open = { kind: "fence", ch, len: m[1].length, start: lineStart, base, q, line: lineNo };
|
|
194
278
|
}
|
|
195
|
-
// A fence line
|
|
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
|
-
|
|
198
|
-
|
|
282
|
+
const setext = inParagraph && /^=+[ \t]*$/.test(content);
|
|
283
|
+
inParagraph = !m && !thematic && !setext && content.trim() !== "" && !/^#{1,6}(?:[ \t]|$)/.test(content);
|
|
199
284
|
}
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
233
|
-
*
|
|
388
|
+
* code blocks or inline code spans are examples, not declarations, and are
|
|
389
|
+
* skipped. */
|
|
234
390
|
export function parseDocAnchors(text) {
|
|
235
|
-
|
|
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
|
-
//
|
|
241
|
-
// and with the line numbers reported below.
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
const
|
|
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
|
-
|
|
417
|
+
}
|
|
418
|
+
anchors.push({ topic: m[1], pin: m[2] ?? null, line: text.slice(0, at).split("\n").length });
|
|
252
419
|
}
|
|
253
|
-
return
|
|
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
|
package/dist/core/drift.d.ts
CHANGED
|
@@ -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;
|
package/dist/core/drift.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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);
|
package/dist/core/eol.d.ts
CHANGED
|
@@ -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;
|