@jefuriiij/synthra 0.1.19 → 0.1.21
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/CHANGELOG.md +51 -0
- package/dist/cli/index.js +94 -7
- package/dist/cli/index.js.map +1 -1
- package/dist/dashboard/index.js +1 -1
- package/dist/dashboard/index.js.map +1 -1
- package/dist/server/index.js +93 -6
- package/dist/server/index.js.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,57 @@ For older versions, see [GitHub Releases](https://github.com/jefuriiij/synthra/r
|
|
|
7
7
|
|
|
8
8
|
---
|
|
9
9
|
|
|
10
|
+
## [0.1.21] — 2026-06-06
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **HubL (HubSpot CMS) symbol extraction for `.html` and `.hubl` files.**
|
|
15
|
+
Previously `.html` files were content-indexed only — keyword search and
|
|
16
|
+
whole-file reads, no symbol-level granularity. On HubSpot projects this
|
|
17
|
+
meant the graph contributed nothing: zero `graph_continue`/`graph_read`
|
|
18
|
+
calls resolved to symbol slices all session. Now `.html` and `.hubl` files
|
|
19
|
+
run through a new **regex-based** parser (`parsers/hubl.ts`; there is no
|
|
20
|
+
tree-sitter grammar for HubL):
|
|
21
|
+
- `{% macro name(args) %}` → extracted as a `function` symbol
|
|
22
|
+
- `{% block name %}` → extracted as a `component` symbol
|
|
23
|
+
- `{% include / extends / import / from "path" %}` → import edges (relative
|
|
24
|
+
paths resolve to local templates; `.html`/`.hubl` added to the resolver's
|
|
25
|
+
extension list)
|
|
26
|
+
|
|
27
|
+
Plain HTML with no HubL tags is unaffected — the parser yields zero symbols
|
|
28
|
+
and zero imports, identical to before. No API, protocol, or policy-block
|
|
29
|
+
change. Roadmap item #12.
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## [0.1.20] — 2026-06-06
|
|
34
|
+
|
|
35
|
+
### Fixed
|
|
36
|
+
|
|
37
|
+
- **Gate (Moat) no longer blocks Grep/Glob queries the graph cannot answer with a symbol.**
|
|
38
|
+
Previously, the PreToolUse gate blocked whenever retrieval confidence was `medium` or `high`,
|
|
39
|
+
but confidence is driven by keyword and path hits too — not only by symbol matches. This meant
|
|
40
|
+
literal/attribute/CSS-selector patterns (`data-tour=`, `class=`, `: 100%`, `.filter-bar`,
|
|
41
|
+
`<div>`) and path-only Globs were blocked and redirected to `graph_read`, which has no symbol
|
|
42
|
+
slice to return for those queries, so Claude fell back to Grep or a whole-file Read anyway —
|
|
43
|
+
a wasted round-trip. Found across multiple dogfood sessions including well-indexed Svelte
|
|
44
|
+
repos. Two new guards close the gap:
|
|
45
|
+
- **Query-shape pre-filter** — Grep patterns that target markup, CSS, attributes, or string
|
|
46
|
+
literals are allowed through up front, before the retrieval step runs.
|
|
47
|
+
- **Symbol-hit requirement** — the gate now only blocks when retrieval matched a symbol whose
|
|
48
|
+
name the query mentions exactly. `RetrievalResult` gained a `symbolMatched` flag; the scorer
|
|
49
|
+
exposes `exactSym`.
|
|
50
|
+
|
|
51
|
+
Net effect: genuine symbol lookups still block (verified: `fetchWith429Retry`,
|
|
52
|
+
`MAX_ROWS_PER_TABLE`, `verifyPin`, `SOCKET_AUTH_SECRET`, `seedCredentials`); queries that
|
|
53
|
+
could never have been answered by the graph now allow through without the wasted redirect.
|
|
54
|
+
No API, protocol, or policy-block change — purely server-side gate behavior.
|
|
55
|
+
|
|
56
|
+
- **Gate and rank test coverage added** (`tests/gate.test.ts`, `tests/rank.test.ts`).
|
|
57
|
+
Chips at the v0.2 backlog item to fill vitest tests beyond `it.todo` placeholders.
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
10
61
|
## [0.1.19] — 2026-06-01
|
|
11
62
|
|
|
12
63
|
### Changed
|
package/dist/cli/index.js
CHANGED
|
@@ -18,7 +18,7 @@ var init_package = __esm({
|
|
|
18
18
|
"package.json"() {
|
|
19
19
|
package_default = {
|
|
20
20
|
name: "@jefuriiij/synthra",
|
|
21
|
-
version: "0.1.
|
|
21
|
+
version: "0.1.21",
|
|
22
22
|
publishConfig: {
|
|
23
23
|
access: "public"
|
|
24
24
|
},
|
|
@@ -2170,7 +2170,7 @@ function extractKeywords(content, _ext) {
|
|
|
2170
2170
|
}
|
|
2171
2171
|
|
|
2172
2172
|
// src/scanner/extract.ts
|
|
2173
|
-
var RESOLVE_EXTS = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".py", ".svelte", ".vue", ".dart"];
|
|
2173
|
+
var RESOLVE_EXTS = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".py", ".svelte", ".vue", ".dart", ".html", ".hubl"];
|
|
2174
2174
|
var INDEX_FILES = ["index.ts", "index.tsx", "index.js", "index.jsx", "__init__.py"];
|
|
2175
2175
|
function fileId(relPath) {
|
|
2176
2176
|
return `file:${relPath}`;
|
|
@@ -2574,6 +2574,57 @@ async function parseGo(f, source) {
|
|
|
2574
2574
|
);
|
|
2575
2575
|
}
|
|
2576
2576
|
|
|
2577
|
+
// src/scanner/parsers/hubl.ts
|
|
2578
|
+
var MACRO_RE = /\{%-?\s*macro\s+([A-Za-z_]\w*)\s*\(([^)]*)\)/g;
|
|
2579
|
+
var ENDMACRO_RE = /\{%-?\s*endmacro\b/g;
|
|
2580
|
+
var BLOCK_RE = /\{%-?\s*block\s+([A-Za-z_]\w*)/g;
|
|
2581
|
+
var ENDBLOCK_RE = /\{%-?\s*endblock\b/g;
|
|
2582
|
+
var IMPORT_RE = /\{%-?\s*(?:include|extends|import|from)\s+["']([^"']+)["']/g;
|
|
2583
|
+
function lineAt(source, index) {
|
|
2584
|
+
return source.slice(0, index).split(/\r?\n/).length;
|
|
2585
|
+
}
|
|
2586
|
+
function endLineAfter(source, fromIndex, endRe, startLine) {
|
|
2587
|
+
endRe.lastIndex = fromIndex;
|
|
2588
|
+
const m = endRe.exec(source);
|
|
2589
|
+
return m ? lineAt(source, m.index) : startLine;
|
|
2590
|
+
}
|
|
2591
|
+
function parseHubL(f, source) {
|
|
2592
|
+
const symbols = [];
|
|
2593
|
+
const imports = [];
|
|
2594
|
+
for (const m of source.matchAll(MACRO_RE)) {
|
|
2595
|
+
const name = m[1];
|
|
2596
|
+
if (!name) continue;
|
|
2597
|
+
const args = (m[2] ?? "").trim();
|
|
2598
|
+
const start = m.index ?? 0;
|
|
2599
|
+
const startLine = lineAt(source, start);
|
|
2600
|
+
symbols.push({
|
|
2601
|
+
name,
|
|
2602
|
+
kind: "function",
|
|
2603
|
+
startLine,
|
|
2604
|
+
endLine: endLineAfter(source, start + m[0].length, ENDMACRO_RE, startLine),
|
|
2605
|
+
signature: `macro ${name}(${args})`
|
|
2606
|
+
});
|
|
2607
|
+
}
|
|
2608
|
+
for (const m of source.matchAll(BLOCK_RE)) {
|
|
2609
|
+
const name = m[1];
|
|
2610
|
+
if (!name) continue;
|
|
2611
|
+
const start = m.index ?? 0;
|
|
2612
|
+
const startLine = lineAt(source, start);
|
|
2613
|
+
symbols.push({
|
|
2614
|
+
name,
|
|
2615
|
+
kind: "component",
|
|
2616
|
+
startLine,
|
|
2617
|
+
endLine: endLineAfter(source, start + m[0].length, ENDBLOCK_RE, startLine),
|
|
2618
|
+
signature: `block ${name}`
|
|
2619
|
+
});
|
|
2620
|
+
}
|
|
2621
|
+
for (const m of source.matchAll(IMPORT_RE)) {
|
|
2622
|
+
const spec = m[1];
|
|
2623
|
+
if (spec) imports.push(spec);
|
|
2624
|
+
}
|
|
2625
|
+
return { file: f, source, symbols, imports: Array.from(new Set(imports)), calls: [] };
|
|
2626
|
+
}
|
|
2627
|
+
|
|
2577
2628
|
// src/scanner/parsers/java.ts
|
|
2578
2629
|
var QUERY6 = `
|
|
2579
2630
|
(class_declaration name: (identifier) @class.name) @class
|
|
@@ -3012,6 +3063,9 @@ async function parseFile(f) {
|
|
|
3012
3063
|
return parseSvelte(f, source);
|
|
3013
3064
|
case ".vue":
|
|
3014
3065
|
return parseVue(f, source);
|
|
3066
|
+
case ".html":
|
|
3067
|
+
case ".hubl":
|
|
3068
|
+
return parseHubL(f, source);
|
|
3015
3069
|
case ".go":
|
|
3016
3070
|
return parseGo(f, source);
|
|
3017
3071
|
case ".rs":
|
|
@@ -3623,10 +3677,12 @@ function scoreFiles(inputs) {
|
|
|
3623
3677
|
}
|
|
3624
3678
|
const symbols = symbolsByFile.get(file.path) ?? [];
|
|
3625
3679
|
let symHits = 0;
|
|
3680
|
+
let exactSym = 0;
|
|
3626
3681
|
for (const sym of symbols) {
|
|
3627
3682
|
const name = sym.name.toLowerCase();
|
|
3628
3683
|
if (qTokens.has(name)) {
|
|
3629
3684
|
symHits += 3;
|
|
3685
|
+
exactSym += 1;
|
|
3630
3686
|
} else {
|
|
3631
3687
|
for (const t of qTokens) {
|
|
3632
3688
|
if (name.includes(t) || t.includes(name)) {
|
|
@@ -3651,7 +3707,7 @@ function scoreFiles(inputs) {
|
|
|
3651
3707
|
score2 += 5;
|
|
3652
3708
|
reasons.push("seed");
|
|
3653
3709
|
}
|
|
3654
|
-
scored.push({ file, score: score2, reasons });
|
|
3710
|
+
scored.push({ file, score: score2, reasons, symHits, exactSym });
|
|
3655
3711
|
}
|
|
3656
3712
|
const positivePaths = new Set(scored.filter((s) => s.score > 0).map((s) => s.file.path));
|
|
3657
3713
|
if (positivePaths.size > 0) {
|
|
@@ -3686,7 +3742,8 @@ async function retrieve(graph, query, options = {}) {
|
|
|
3686
3742
|
return {
|
|
3687
3743
|
files: [],
|
|
3688
3744
|
confidence: "low",
|
|
3689
|
-
reason: qTokens.length === 0 ? "empty query" : "empty graph"
|
|
3745
|
+
reason: qTokens.length === 0 ? "empty query" : "empty graph",
|
|
3746
|
+
symbolMatched: false
|
|
3690
3747
|
};
|
|
3691
3748
|
}
|
|
3692
3749
|
const rankInputs = {
|
|
@@ -3702,10 +3759,13 @@ async function retrieve(graph, query, options = {}) {
|
|
|
3702
3759
|
return {
|
|
3703
3760
|
files: [],
|
|
3704
3761
|
confidence: "low",
|
|
3705
|
-
reason: `no matches for ${JSON.stringify(qTokens)}
|
|
3762
|
+
reason: `no matches for ${JSON.stringify(qTokens)}`,
|
|
3763
|
+
symbolMatched: false
|
|
3706
3764
|
};
|
|
3707
3765
|
}
|
|
3708
|
-
const
|
|
3766
|
+
const topScored = positive.slice(0, topK);
|
|
3767
|
+
const top = topScored.map((s) => s.file);
|
|
3768
|
+
const symbolMatched = topScored.some((s) => s.exactSym > 0);
|
|
3709
3769
|
const topScore = positive[0]?.score ?? 0;
|
|
3710
3770
|
const secondScore = positive[1]?.score ?? 0;
|
|
3711
3771
|
let confidence;
|
|
@@ -3717,7 +3777,8 @@ async function retrieve(graph, query, options = {}) {
|
|
|
3717
3777
|
return {
|
|
3718
3778
|
files: top,
|
|
3719
3779
|
confidence,
|
|
3720
|
-
reason: `top: ${reasons}
|
|
3780
|
+
reason: `top: ${reasons}`,
|
|
3781
|
+
symbolMatched
|
|
3721
3782
|
};
|
|
3722
3783
|
}
|
|
3723
3784
|
|
|
@@ -4552,6 +4613,16 @@ function extractQuery(toolName, input) {
|
|
|
4552
4613
|
}
|
|
4553
4614
|
return null;
|
|
4554
4615
|
}
|
|
4616
|
+
function looksLikeNonSymbolQuery(pattern) {
|
|
4617
|
+
if (/<\/?[a-zA-Z]/.test(pattern)) return true;
|
|
4618
|
+
if (/[a-zA-Z][\w-]*-[\w-]*\s*=/.test(pattern)) return true;
|
|
4619
|
+
if (/\{/.test(pattern)) return true;
|
|
4620
|
+
if (/\\\.[a-zA-Z]/.test(pattern)) return true;
|
|
4621
|
+
if (/:\s*\d/.test(pattern) || /\d(?:px|rem|em|vh|vw)\b/.test(pattern) || /\d%/.test(pattern)) {
|
|
4622
|
+
return true;
|
|
4623
|
+
}
|
|
4624
|
+
return false;
|
|
4625
|
+
}
|
|
4555
4626
|
function recentlyTouchedMatchesQuery(recentPaths, queryTokens) {
|
|
4556
4627
|
const matches = [];
|
|
4557
4628
|
for (const path of recentPaths) {
|
|
@@ -4593,6 +4664,14 @@ async function handleGate(req, ctx) {
|
|
|
4593
4664
|
await logDecision(ctx, req.tool_name, null, res2.decision, res2.reason);
|
|
4594
4665
|
return res2;
|
|
4595
4666
|
}
|
|
4667
|
+
if (req.tool_name === "Grep" && looksLikeNonSymbolQuery(query)) {
|
|
4668
|
+
const res2 = {
|
|
4669
|
+
decision: "allow",
|
|
4670
|
+
reason: `"${query}" targets markup/CSS/attributes, not code symbols \u2014 letting Grep through (the graph indexes symbols).`
|
|
4671
|
+
};
|
|
4672
|
+
await logDecision(ctx, req.tool_name, query, res2.decision, res2.reason);
|
|
4673
|
+
return res2;
|
|
4674
|
+
}
|
|
4596
4675
|
const retrieval = await retrieve(ctx.graph, query);
|
|
4597
4676
|
if (retrieval.confidence === "low") {
|
|
4598
4677
|
const res2 = {
|
|
@@ -4613,6 +4692,14 @@ async function handleGate(req, ctx) {
|
|
|
4613
4692
|
await logDecision(ctx, req.tool_name, query, res2.decision, res2.reason);
|
|
4614
4693
|
return res2;
|
|
4615
4694
|
}
|
|
4695
|
+
if (!retrieval.symbolMatched) {
|
|
4696
|
+
const res2 = {
|
|
4697
|
+
decision: "allow",
|
|
4698
|
+
reason: `confidence=${retrieval.confidence} but only keyword/path matched (no symbol the query names) \u2014 graph_read can't slice it, letting ${req.tool_name} through.`
|
|
4699
|
+
};
|
|
4700
|
+
await logDecision(ctx, req.tool_name, query, res2.decision, res2.reason);
|
|
4701
|
+
return res2;
|
|
4702
|
+
}
|
|
4616
4703
|
const top = retrieval.files.slice(0, 3).map((f) => f.path).join(", ");
|
|
4617
4704
|
const res = {
|
|
4618
4705
|
decision: "block",
|