@davesheffer/hunch 1.41.5 → 1.41.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,5 @@
1
1
  import { currentForTopic, rejectedForTopic } from "./topics.js";
2
+ import { normalizeEol } from "./eol.js";
2
3
  const MARKER = /<!--\s*hunch:topic\s+([A-Za-z0-9._/-]+)(?:\s+(dec_[A-Za-z0-9]+))?\s*-->/g;
3
4
  /** Expand the leading whitespace of a line to columns. A tab advances to the
4
5
  * NEXT 4-column tab stop (CommonMark), not a flat 4 columns: after two
@@ -238,7 +239,7 @@ export function parseDocAnchors(text) {
238
239
  // a live, pinned anchor. Normalizing both CRLF and lone CR once here keeps
239
240
  // fencedRanges/inlineSpanRanges/MARKER offsets consistent with each other
240
241
  // and with the line numbers reported below.
241
- text = text.replace(/\r\n?/g, "\n");
242
+ text = normalizeEol(text);
242
243
  const out = [];
243
244
  const skip = [...fencedRanges(text), ...inlineSpanRanges(text)];
244
245
  MARKER.lastIndex = 0;
@@ -0,0 +1,10 @@
1
+ /** Canonical line endings for parsers that do not expose source offsets. */
2
+ export declare function normalizeEol(text: string): string;
3
+ /** Remove the CR retained by split("\n") without changing source offsets. */
4
+ export declare function lineContent(line: string): string;
5
+ /** Read logical lines while keeping offsets in the original UTF-16 source. */
6
+ export declare function sourceLines(text: string): Generator<{
7
+ content: string;
8
+ start: number;
9
+ end: number;
10
+ }>;
@@ -0,0 +1,23 @@
1
+ /** Canonical line endings for parsers that do not expose source offsets. */
2
+ export function normalizeEol(text) {
3
+ return text.replace(/\r\n?|\n/g, "\n");
4
+ }
5
+ /** Remove the CR retained by split("\n") without changing source offsets. */
6
+ export function lineContent(line) {
7
+ return line.endsWith("\r") ? line.slice(0, -1) : line;
8
+ }
9
+ /** Read logical lines while keeping offsets in the original UTF-16 source. */
10
+ export function* sourceLines(text) {
11
+ let start = 0;
12
+ for (let i = 0; i < text.length; i++) {
13
+ if (text[i] !== "\r" && text[i] !== "\n")
14
+ continue;
15
+ const end = i;
16
+ if (text[i] === "\r" && text[i + 1] === "\n")
17
+ i++;
18
+ yield { content: text.slice(start, end), start, end };
19
+ start = i + 1;
20
+ }
21
+ yield { content: text.slice(start), start, end: text.length };
22
+ }
23
+ //# sourceMappingURL=eol.js.map
@@ -20,15 +20,15 @@
20
20
  */
21
21
  import { execFileSync } from "node:child_process";
22
22
  import { parseGroundingCounts, stripCountsMatch, renderCountsMatch } from "./groundingLag.js";
23
- // `\r?\n` (not a bare `\n`) throughout: on a CRLF worktree every diff3 marker
24
- // line is itself `\r\n`-terminated, and a bare `\n` fails to match ANY of
25
- // them. `^...$` with /m anchor each marker at its own line start rather than
23
+ import { normalizeEol } from "./eol.js";
24
+ // The entry point normalizes EOL before matching. `^...$` with /m anchors
25
+ // each marker at its own line start rather than
26
26
  // requiring a specific preceding/following literal newline, so a hunk whose
27
27
  // ours or theirs side is EMPTY (one side deleted the line, the other edited
28
28
  // it) still matches — with a literal `\n` requirement there, git's real
29
29
  // output for that shape has no such newline to match, so the whole hunk
30
30
  // silently fails to match at all.
31
- const CONFLICT_RE = /^<<<<<<< ours\r?\n([\s\S]*?)^\|\|\|\|\|\|\| base\r?\n[\s\S]*?^=======\r?\n([\s\S]*?)^>>>>>>> theirs[^\n]*\r?\n?/gm;
31
+ const CONFLICT_RE = /^<<<<<<< ours\n([\s\S]*?)^\|\|\|\|\|\|\| base\n[\s\S]*?^=======\n([\s\S]*?)^>>>>>>> theirs[^\n]*\n?/gm;
32
32
  /** `null` when the hunk isn't confined to the counts sentence; otherwise the
33
33
  * resolved sentence text, with each field taken as max(ours, theirs). */
34
34
  function resolveCountsOnlyHunk(ours, theirs) {
@@ -52,8 +52,10 @@ function resolveCountsOnlyHunk(ours, theirs) {
52
52
  export function resolveGroundingConflicts(diff3Text) {
53
53
  if (!diff3Text.includes("<<<<<<< ours"))
54
54
  return { conflict: false, text: diff3Text };
55
+ const eol = diff3Text.includes("\r\n") ? "\r\n" : diff3Text.includes("\r") ? "\r" : "\n";
56
+ const normalized = normalizeEol(diff3Text);
55
57
  let allResolved = true;
56
- const resolved = diff3Text.replace(CONFLICT_RE, (whole, ours, theirs) => {
58
+ const resolved = normalized.replace(CONFLICT_RE, (whole, ours, theirs) => {
57
59
  const merged = resolveCountsOnlyHunk(ours, theirs);
58
60
  if (merged === null) {
59
61
  allResolved = false;
@@ -66,7 +68,7 @@ export function resolveGroundingConflicts(diff3Text) {
66
68
  // marker survives, this is a real conflict, full stop.
67
69
  if (!allResolved || resolved.includes("<<<<<<< ours"))
68
70
  return { conflict: true, text: diff3Text };
69
- return { conflict: false, text: resolved };
71
+ return { conflict: false, text: eol === "\n" ? resolved : resolved.replace(/\n/g, eol) };
70
72
  }
71
73
  /** Run `git merge-file --diff3` on real files and resolve the result.
72
74
  * `write: null` means git itself errored (e.g. one side is binary) — never
@@ -1,25 +1,4 @@
1
- /**
2
- * Deterministic text scan for Kubernetes manifest cross-resource references —
3
- * NOT a tree-sitter walk. Confirmed directly (not assumed): parsing a realistic
4
- * templated Deployment with this repo's own tree-sitter-yaml bundle showed a
5
- * same-line templated scalar (`name: {{ include "x" . }}`) parses cleanly, but
6
- * a block-form injection on its own line (`labels:\n {{- include ... }}` — the
7
- * pattern real charts use for metadata.labels and spec.selector) collapses the
8
- * WHOLE REST OF THE DOCUMENT'S error recovery into a flat, unstructured ERROR
9
- * node. Real charts place such an injection early (right after metadata.name),
10
- * so it would poison parsing for every field-path this module needs further
11
- * down the same document. parseSource() (parse.ts) also never exposes its
12
- * tree-sitter tree to callers at all -- helm.ts's own text-scan precedent
13
- * exists for the same reason.
14
- *
15
- * This is a line-oriented, indentation-tracking scanner (not flat regex, unlike
16
- * helm.ts's `{{ }}`-action scan) because K8s field-paths are nested block
17
- * structure (`spec.template.spec.containers[].env[].valueFrom.secretKeyRef.name`)
18
- * that a flat scan can't reconstruct. It never interprets `{{ }}` as YAML syntax
19
- * at all -- a `{{ ... }}` on a line's value side is just that line's raw value
20
- * text, same-line or block-form alike -- so it is immune to the exact failure
21
- * mode that broke tree-sitter.
22
- */
1
+ import { sourceLines } from "../core/eol.js";
23
2
  // ReplicaSet is included alongside the pod-spec-embedding/networking kinds
24
3
  // specifically because it's the dominant real-world bearer of
25
4
  // ownerReferences (Deployment -> ReplicaSet -> Pod is the common chain) --
@@ -42,14 +21,9 @@ function wildcardPath(path) {
42
21
  function framePath(frames) {
43
22
  return frames.map((f) => f.key).filter(Boolean).join(".").replace(/\.\[/g, "[");
44
23
  }
45
- // `\r?` before `$`: without it, a CRLF-terminated line (the Git-for-Windows
46
- // `core.autocrlf=true` default -- every .yaml file in a Windows checkout) never
47
- // matches at all, since JS `.` never matches `\r` and `$` (no /m flag) only
48
- // matches at the true end of the string. That silently zeroes out this whole
49
- // module's output on any Windows clone, with no error. `.*?` (lazy, not `.*`
50
- // greedy) so `\r?` gets first claim on a trailing `\r` instead of the value
51
- // capture swallowing it.
52
- const KEY_LINE = /^(\s*)(-\s+)?([A-Za-z0-9_.\/-]+):[ \t]*(.*?)\r?$/;
24
+ // sourceLines removes line terminators while retaining offsets in the original
25
+ // source, so every line parser sees the same content on LF, CRLF, and CR input.
26
+ const KEY_LINE = /^(\s*)(-\s+)?([A-Za-z0-9_.\/-]+):[ \t]*(.*?)$/;
53
27
  // A line that is ENTIRELY a `{{ ... }}` template action (no `key:` prefix at
54
28
  // all) -- e.g. a block-form injection appearing as a SIBLING after other
55
29
  // literal keys under the same mapping (`app: my-app` then, on its own later
@@ -70,7 +44,7 @@ const BARE_TEMPLATE_LINE = /^\s*(?:-\s+)?\{\{[\s\S]*$/;
70
44
  // branch, which used ORDINARY popping and lost the list-item frame entirely
71
45
  // -- reparenting the item's real children one level up (`containers.name`
72
46
  // instead of `containers[0].name`), silently dropping every reference under it.
73
- const BARE_LIST_MARKER = /^(\s*)-[ \t]*\r?$/;
47
+ const BARE_LIST_MARKER = /^(\s*)-[ \t]*$/;
74
48
  // A list item whose ENTIRE content is a block-scalar header (`- |`, `- |-`,
75
49
  // `- >`, `- |2`, `- | # comment`) -- the idiomatic way a chart inlines a shell
76
50
  // script into `args:`/`command:`. The item's value is opaque TEXT, not YAML,
@@ -84,7 +58,7 @@ const BARE_LIST_MARKER = /^(\s*)-[ \t]*\r?$/;
84
58
  // match. The rest is captured (not `[|>]` inline) so the header can be run
85
59
  // through stripTrailingComment before BLOCK_SCALAR_HEADER, exactly as a
86
60
  // key line's value is.
87
- const BLOCK_SCALAR_LIST_ITEM = /^(\s*)-[ \t]+(.*?)\r?$/;
61
+ const BLOCK_SCALAR_LIST_ITEM = /^(\s*)-[ \t]+(.*?)$/;
88
62
  // A YAML block-scalar header (`|`, `>`, plus an optional chomping indicator
89
63
  // `+`/`-` and/or an explicit indent digit, in either order: `|`, `|-`, `>+`,
90
64
  // `|2`, `|2-`, `|-2`). When a key's value is JUST this header, the real
@@ -161,7 +135,6 @@ function scanFieldPaths(text, baseChar) {
161
135
  // (selector/labels) cares, and it checks this set itself.
162
136
  const valuelessKeyParents = new Set();
163
137
  const stack = [];
164
- let charOffset = baseChar;
165
138
  // Indent of the `-` of a `- |` list item whose block-scalar body is being
166
139
  // skipped, or null when no such skip is active. The body ends at the first
167
140
  // line indented at or shallower than that dash (YAML's own rule for where a
@@ -229,13 +202,11 @@ function scanFieldPaths(text, baseChar) {
229
202
  unresolvedContainers.add(framePath(stack.slice(0, depth)));
230
203
  }
231
204
  };
232
- for (const line of text.split("\n")) {
233
- const lineStartChar = charOffset;
234
- charOffset += line.length + 1; // +1 for the \n split() consumed
205
+ for (const { content: line, start } of sourceLines(text)) {
206
+ const lineStartChar = baseChar + start;
235
207
  // Inside a `- |` item's body: these lines are script/config TEXT, so they
236
- // must never be read as field structure. Runs after the charOffset
237
- // bookkeeping above so skipped lines still advance the offset exactly --
238
- // every atChar/endChar after the block scalar depends on it.
208
+ // must never be read as field structure. sourceLines retains original
209
+ // offsets even when skipped lines use CRLF.
239
210
  if (blockScalarSkipIndent !== null) {
240
211
  // A blank line is part of the block scalar (YAML lets a scalar body
241
212
  // contain empty lines at any indent), never its terminator.
@@ -671,12 +642,14 @@ function buildDocument(text, docStartChar, entries, unresolvedContainers, valuel
671
642
  // deliberately NOT a separator -- real YAML doesn't treat it as one either,
672
643
  // and it still isn't here: the dashes must be followed by whitespace or end
673
644
  // of line.
674
- const DOC_SEPARATOR = /^(?:---(?:[ \t]+.*)?|\.\.\.)[ \t]*\r?$/m;
645
+ const DOC_SEPARATOR = /^(?:---(?:[ \t]+.*)?|\.\.\.)[ \t]*$/;
675
646
  export function extractK8sManifest(source) {
676
647
  const docs = [];
677
648
  const boundaries = [];
678
- for (const m of source.matchAll(new RegExp(DOC_SEPARATOR, "gm")))
679
- boundaries.push(m.index, m.index + m[0].length);
649
+ for (const line of sourceLines(source)) {
650
+ if (DOC_SEPARATOR.test(line.content))
651
+ boundaries.push(line.start, line.end);
652
+ }
680
653
  const starts = [0, ...boundaries.filter((_, i) => i % 2 === 1)];
681
654
  const ends = [...boundaries.filter((_, i) => i % 2 === 0), source.length];
682
655
  for (let i = 0; i < starts.length; i++) {
@@ -15,6 +15,7 @@
15
15
  import { readFileSync, existsSync, lstatSync, realpathSync } from "node:fs";
16
16
  import { isAbsolute, join, relative, resolve, sep } from "node:path";
17
17
  import { writeFileAtomic } from "../core/io.js";
18
+ import { lineContent } from "../core/eol.js";
18
19
  import { gitTrackedPaths, gitUntrackCached, isGitRepoRoot } from "../extractors/git.js";
19
20
  const MARK = "# >>> hunch (derived runtime index — regenerable from .hunch/*.json) >>>";
20
21
  const END = "# <<< hunch <<<";
@@ -116,7 +117,7 @@ export function assertSafeTopLevelConfigFile(root, name) {
116
117
  }
117
118
  return path;
118
119
  }
119
- const cleanLine = (line) => line.replace(/\r$/, "").trim();
120
+ const cleanLine = (line) => lineContent(line).trim();
120
121
  /** Locate a managed block as whole lines: the marker line and the FIRST end-marker
121
122
  * line after it. `null` when either is missing — a hand-damaged block is never
122
123
  * guessed at (con_8460b6770f). Indices are into `text.split("\n")`, whose items
@@ -20,6 +20,7 @@ import { homedir } from "node:os";
20
20
  import { hooksDir, gitCommonDir, isLinkedWorktree, mainWorktreeRoot } from "../extractors/git.js";
21
21
  import { initiatorChildEnv } from "../synthesis/initiator.js";
22
22
  import { HUNCH_NPX_PACKAGE_SPEC, HUNCH_PACKAGE_NAME } from "../core/version.js";
23
+ import { normalizeEol } from "../core/eol.js";
23
24
  const MARK = "# >>> hunch post-commit >>>";
24
25
  const ENDMARK = "# <<< hunch post-commit <<<";
25
26
  const GIT_CONTEXT = { checkoutType: "$3", portable: false };
@@ -73,7 +74,7 @@ const BLOCK_CLOSE = /^(?:fi|esac|done)\b|^\}|[;\s](?:fi|esac|done|\})\s*;?\s*$/;
73
74
  * or a trailing top-level `if … else … fi` whose every branch ends in
74
75
  * `exec`/`exit` — the shape of the pre-commit framework's generated hook. */
75
76
  export function terminalExitLine(content) {
76
- const lines = content.replace(/\r/g, "").split("\n");
77
+ const lines = normalizeEol(content).split("\n");
77
78
  let depth = 0;
78
79
  for (const raw of lines) {
79
80
  const line = raw.trim();
@@ -386,7 +387,7 @@ function blockFlags(line) {
386
387
  function builtFlags(blk, mark, liveRoot) {
387
388
  const { re } = blockSubcommand(mark);
388
389
  const found = new Set();
389
- for (const line of blk.replace(/\r/g, "").split("\n")) {
390
+ for (const line of normalizeEol(blk).split("\n")) {
390
391
  if (line.trim().startsWith("#"))
391
392
  continue;
392
393
  const m = re.exec(line);
@@ -417,7 +418,7 @@ function inspectBlock(file, mark, root) {
417
418
  const { re, words } = blockSubcommand(mark);
418
419
  const text = readText(file) ?? "";
419
420
  const at = text.indexOf(mark);
420
- const body = at < 0 ? [] : text.slice(at).replace(/\r/g, "").split("\n").slice(1);
421
+ const body = at < 0 ? [] : normalizeEol(text.slice(at)).split("\n").slice(1);
421
422
  let first = null;
422
423
  for (const line of body) {
423
424
  if (/^# <<< hunch /.test(line))
@@ -511,7 +512,7 @@ function worktreeTops(root) {
511
512
  const out = gitRun(["worktree", "list", "--porcelain"], root).stdout;
512
513
  if (!out)
513
514
  return { linked: [], count: 0 };
514
- const entries = out.replace(/\r/g, "").split(/\n\s*\n/).map((e) => e.split("\n"));
515
+ const entries = normalizeEol(out).split(/\n\s*\n/).map((e) => e.split("\n"));
515
516
  const parsed = entries
516
517
  .map((lines) => ({
517
518
  path: lines.find((l) => l.startsWith("worktree "))?.slice("worktree ".length) ?? "",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davesheffer/hunch",
3
- "version": "1.41.5",
3
+ "version": "1.41.6",
4
4
  "mcpName": "io.github.davesheffer/hunch",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Dave Sheffer <dave.sheffer1@gmail.com>",
package/server.json CHANGED
@@ -7,13 +7,13 @@
7
7
  "source": "github"
8
8
  },
9
9
  "websiteUrl": "https://www.hunchmemory.com",
10
- "version": "1.41.5",
10
+ "version": "1.41.6",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "registryBaseUrl": "https://registry.npmjs.org",
15
15
  "identifier": "@davesheffer/hunch",
16
- "version": "1.41.5",
16
+ "version": "1.41.6",
17
17
  "runtimeHint": "npx",
18
18
  "packageArguments": [
19
19
  {