@davesheffer/hunch 1.41.0 → 1.41.2

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.
@@ -42,12 +42,19 @@ export interface LanguageSpec {
42
42
  /** Ancestor shapes in which an ERROR node is a known limitation of THIS grammar
43
43
  * rather than a real syntax error. parse.ts forgives an error only when some
44
44
  * ancestor has type `node` AND that ancestor's own parent has type `parentIs` —
45
- * the pair is what keeps the tolerance narrow. Omit for a language with no known
46
- * grammar false positives; that spec then stays strictly fail-closed. */
45
+ * the pair is what keeps the tolerance narrow. `textPattern`, when present,
46
+ * must also match that ancestor's text. Omit for a language with no
47
+ * known grammar false positives; that spec then stays strictly fail-closed. */
47
48
  toleratedErrorScopes?: ReadonlyArray<{
48
49
  readonly node: string;
49
50
  readonly parentIs: string;
51
+ readonly textPattern?: RegExp;
50
52
  }>;
53
+ /** Optional same-length recovery used only when the original syntax tree is
54
+ * not parseable. A recovered tree may classify structure, but parse.ts reads
55
+ * every captured value from the original source at the tree's unchanged
56
+ * offsets. Keep replacements syntax-equivalent outside the grammar defect. */
57
+ parseErrorRecovery?: (source: string) => string;
51
58
  /** Patterns whose presence anywhere in the source mean "this isn't actually
52
59
  * {id} text yet — it's a template that renders to {id} later" (Go/Jinja/Helm
53
60
  * delimiters in a .yaml file, e.g.). parse.ts still runs the real parse — a
@@ -81,6 +81,19 @@ const TSX = {
81
81
  extensions: [".tsx", ".jsx"],
82
82
  grammarKey: "tsx",
83
83
  loadGrammar: () => loadNativeTreeSitter().tsx,
84
+ // tree-sitter-javascript rejects a bare ampersand in JSX text even though JSX
85
+ // accepts it (tree-sitter-javascript#366). The ERROR is a direct jsx_element
86
+ // child and its text begins at the ampersand. Require both facts plus text that
87
+ // cannot cross into a tag or expression; real JSX errors beside it remain fail-closed.
88
+ toleratedErrorScopes: [
89
+ ...TS_SHARED.toleratedErrorScopes,
90
+ { node: "ERROR", parentIs: "jsx_element", textPattern: /^&[^<>{}]*$/u },
91
+ ],
92
+ // More than one bare ampersand can make the grammar collapse the entire TSX
93
+ // tree instead of emitting the narrow ERROR above. `|` is accepted as JSX
94
+ // text and has the same length; in JS/TS syntax it preserves ampersand
95
+ // operators' arity, so malformed expressions remain malformed on the retry.
96
+ parseErrorRecovery: (source) => source.replaceAll("&", "|"),
84
97
  };
85
98
  const PY_QUERY = `
86
99
  (class_definition
@@ -56,11 +56,35 @@ export function parseSource(file, source, opts = {}) {
56
56
  throw error;
57
57
  return null;
58
58
  }
59
+ let parseable = isParseable(tree.rootNode, spec);
60
+ let usingRecoveredTree = false;
61
+ if (!parseable && spec.parseErrorRecovery) {
62
+ const recoveredSource = spec.parseErrorRecovery(source);
63
+ // Offsets from the recovery tree are used against the original source below.
64
+ // Refuse a misconfigured recovery rather than corrupt captured names/text.
65
+ if (recoveredSource.length === source.length) {
66
+ try {
67
+ const recoveredTree = parser.parse(recoveredSource, undefined, { bufferSize: Math.max(32 * 1024, recoveredSource.length * 2 + 1024) });
68
+ if (isParseable(recoveredTree.rootNode, spec)) {
69
+ tree = recoveredTree;
70
+ parseable = true;
71
+ usingRecoveredTree = true;
72
+ }
73
+ }
74
+ catch (error) {
75
+ if (opts.throwOnParseError)
76
+ throw error;
77
+ }
78
+ }
79
+ }
59
80
  const symbols = [];
60
81
  const imports = [];
61
82
  const calls = [];
62
83
  const relations = [];
63
84
  let namespace = null;
85
+ const originalText = (node) => usingRecoveredTree
86
+ ? source.slice(node.startIndex, node.endIndex)
87
+ : node.text;
64
88
  // group captures by their enclosing @*.def via a quick pass: we record names
65
89
  // keyed by the def node, then emit a symbol per def.
66
90
  const pendingDefs = new Map();
@@ -82,30 +106,32 @@ export function parseSource(file, source, opts = {}) {
82
106
  if (defNode) {
83
107
  const existing = pendingDefs.get(defNode.id);
84
108
  if (existing)
85
- existing.name = node.text;
109
+ existing.name = originalText(node);
86
110
  else
87
- pendingDefs.set(defNode.id, { kind: spec.defKindOf[spec.nameToDef[cname]], def: defNode, name: node.text });
111
+ pendingDefs.set(defNode.id, { kind: spec.defKindOf[spec.nameToDef[cname]], def: defNode, name: originalText(node) });
88
112
  }
89
113
  if (cname === "namespace.name")
90
- namespace = node.text;
114
+ namespace = originalText(node);
91
115
  }
92
116
  else if (cname === "import.src") {
93
- imports.push(node.text.replace(STR_QUOTES, ""));
117
+ imports.push(originalText(node).replace(STR_QUOTES, ""));
94
118
  }
95
119
  else if (cname === "call.id") {
96
- if (!spec.builtinFunctions?.has(node.text)) {
97
- calls.push({ callee: node.text, atByte: node.startIndex, endByte: node.endIndex, member: false });
120
+ const text = originalText(node);
121
+ if (!spec.builtinFunctions?.has(text)) {
122
+ calls.push({ callee: text, atByte: node.startIndex, endByte: node.endIndex, member: false });
98
123
  }
99
124
  }
100
125
  else if (cname === "call.member") {
101
126
  // skip builtin method names to avoid false edges to similarly-named symbols
102
- if (!spec.builtinMethods.has(node.text))
103
- calls.push({ callee: node.text, atByte: node.startIndex, endByte: node.endIndex, member: true });
127
+ const text = originalText(node);
128
+ if (!spec.builtinMethods.has(text))
129
+ calls.push({ callee: text, atByte: node.startIndex, endByte: node.endIndex, member: true });
104
130
  }
105
131
  else if (spec.relationKindOf?.[cname]) {
106
132
  const relation = spec.relationKindOf[cname];
107
133
  relations.push({
108
- target: node.text,
134
+ target: originalText(node),
109
135
  atByte: node.startIndex,
110
136
  endByte: node.endIndex,
111
137
  edgeType: relation.edgeType,
@@ -121,7 +147,7 @@ export function parseSource(file, source, opts = {}) {
121
147
  symbols.push({
122
148
  name: resolvedName, kind,
123
149
  startByte: def.startIndex, endByte: def.endIndex, loc,
124
- bodyText: def.text.slice(0, MAX_BODY_TEXT_CHARS),
150
+ bodyText: originalText(def).slice(0, MAX_BODY_TEXT_CHARS),
125
151
  });
126
152
  }
127
153
  // Every other successfully-parsed YAML file gets at least a file-root symbol
@@ -141,7 +167,7 @@ export function parseSource(file, source, opts = {}) {
141
167
  });
142
168
  }
143
169
  symbols.sort((a, b) => a.startByte - b.startByte);
144
- return { symbols, imports, calls, relations, namespace, parseable: templated || isParseable(tree.rootNode, spec) };
170
+ return { symbols, imports, calls, relations, namespace, parseable: templated || parseable };
145
171
  }
146
172
  /** True when every ERROR/MISSING node in the tree sits in an ancestor shape this
147
173
  * language declares as a known grammar limitation (LanguageSpec.toleratedErrorScopes).
@@ -177,9 +203,11 @@ function isParseable(root, spec) {
177
203
  return ok;
178
204
  }
179
205
  function inToleratedScope(node, scopes) {
180
- for (let ancestor = node.parent; ancestor; ancestor = ancestor.parent) {
206
+ for (let ancestor = node; ancestor; ancestor = ancestor.parent) {
181
207
  for (const scope of scopes) {
182
- if (ancestor.type === scope.node && ancestor.parent?.type === scope.parentIs)
208
+ if (ancestor.type === scope.node
209
+ && ancestor.parent?.type === scope.parentIs
210
+ && (!scope.textPattern || ancestor.text.search(scope.textPattern) !== -1))
183
211
  return true;
184
212
  }
185
213
  }
@@ -170,8 +170,14 @@ export function mergeLedgers(base, ours, theirs) {
170
170
  seen.set(k, e);
171
171
  order.push(e);
172
172
  } };
173
- for (const e of base?.events ?? [])
174
- add(e);
173
+ const ourIdentities = new Set(ours.events.map(eventIdentity));
174
+ const theirIdentities = new Set(theirs.events.map(eventIdentity));
175
+ // Base is a merge ancestor, not a source of history both clones have compacted away.
176
+ for (const e of base?.events ?? []) {
177
+ const key = eventIdentity(e);
178
+ if (ourIdentities.has(key) || theirIdentities.has(key) || (e.seq > ours.floor_seq && e.seq > theirs.floor_seq))
179
+ add(e);
180
+ }
175
181
  for (const e of ours.events)
176
182
  add(e);
177
183
  for (const e of theirs.events)
@@ -180,7 +186,6 @@ export function mergeLedgers(base, ours, theirs) {
180
186
  // side 1 iff theirs holds it and neither ours nor base does. Deciding by object identity
181
187
  // (`includes`) would instead put anything ours compacted away — base's object, which `add`
182
188
  // keeps — after its same-`at` siblings and silently reorder a batch both sides already had.
183
- const ourIdentities = new Set(ours.events.map(eventIdentity));
184
189
  const baseIdentities = new Set((base?.events ?? []).map(eventIdentity));
185
190
  const theirsOnly = (e) => {
186
191
  const k = eventIdentity(e);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davesheffer/hunch",
3
- "version": "1.41.0",
3
+ "version": "1.41.2",
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.0",
10
+ "version": "1.41.2",
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.0",
16
+ "version": "1.41.2",
17
17
  "runtimeHint": "npx",
18
18
  "packageArguments": [
19
19
  {