@polycode-projects/seonix 0.9.1 → 0.10.1
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 +33 -12
- package/bin/cli.mjs +92 -11
- package/package.json +4 -9
- package/scripts/build-roslyn.mjs +11 -0
- package/src/ask-browser.bundle.js +136 -18
- package/src/browser.mjs +9 -3
- package/src/codegraph.mjs +126 -18
- package/src/cs_roslyn.mjs +13 -7
- package/src/cs_treesitter.mjs +58 -3
- package/src/extract.mjs +98 -23
- package/src/extract_lang.mjs +4 -2
- package/src/interfaces.mjs +110 -16
- package/src/jsts_tsc.mjs +51 -0
- package/src/roslyn_build.mjs +59 -0
- package/src/schema-docs.mjs +33 -3
- package/src/server.mjs +51 -7
- package/src/summary.mjs +14 -3
- package/src/telemetry.mjs +31 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// roslyn_build.mjs — shared build logic for the Roslyn C# semantic extractor
|
|
2
|
+
// (roslyn/publish/roslyn-extract.dll). Used by two callers: the lazy-build path
|
|
3
|
+
// in cs_roslyn.mjs (triggered on the first real C# extraction attempt in a
|
|
4
|
+
// process, not on `npm install`) and the explicit `npm run build:roslyn`
|
|
5
|
+
// prewarm command (e.g. baking the dll into a Docker image ahead of time).
|
|
6
|
+
//
|
|
7
|
+
// Best-effort and silent-by-default: no dotnet on PATH -> skip, tree-sitter
|
|
8
|
+
// (src/cs_treesitter.mjs) covers the absent case. A `dotnet publish` failure
|
|
9
|
+
// (e.g. no network for the first NuGet restore) is a warning, never thrown.
|
|
10
|
+
import { access } from "node:fs/promises";
|
|
11
|
+
import { spawn } from "node:child_process";
|
|
12
|
+
import { dirname, join } from "node:path";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
|
|
15
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
export const ROOT = join(here, "..");
|
|
17
|
+
export const ROSLYN_DIR = join(ROOT, "roslyn");
|
|
18
|
+
export const DLL = join(ROSLYN_DIR, "publish", "roslyn-extract.dll");
|
|
19
|
+
|
|
20
|
+
function run(cmd, args, opts = {}) {
|
|
21
|
+
return new Promise((resolve) => {
|
|
22
|
+
const child = spawn(cmd, args, {
|
|
23
|
+
env: { ...process.env, DOTNET_CLI_TELEMETRY_OPTOUT: "1", DOTNET_NOLOGO: "1" },
|
|
24
|
+
...opts,
|
|
25
|
+
});
|
|
26
|
+
let out = "";
|
|
27
|
+
let err = "";
|
|
28
|
+
child.stdout?.on("data", (d) => (out += d));
|
|
29
|
+
child.stderr?.on("data", (d) => (err += d));
|
|
30
|
+
child.on("close", (code) => resolve({ code: code ?? -1, out, err }));
|
|
31
|
+
child.on("error", () => resolve({ code: -1, out, err: "" }));
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function exists(p) {
|
|
36
|
+
try { await access(p); return true; } catch { return false; }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Build the Roslyn dll if it's missing and `dotnet` is on PATH.
|
|
40
|
+
* @returns {Promise<boolean>} true if the dll is present after this call
|
|
41
|
+
* (already built, or built now); false otherwise (no dotnet, or publish
|
|
42
|
+
* failed — caller falls back to tree-sitter). */
|
|
43
|
+
export async function ensureRoslynBuilt({ quiet = false } = {}) {
|
|
44
|
+
if (await exists(DLL)) return true;
|
|
45
|
+
|
|
46
|
+
const dotnetCheck = await run("dotnet", ["--version"]);
|
|
47
|
+
if (dotnetCheck.code !== 0) return false; // no .NET SDK — tree-sitter fallback covers C#
|
|
48
|
+
|
|
49
|
+
const publish = await run("dotnet", ["publish", "-c", "Release", "-o", "publish"], { cwd: ROSLYN_DIR });
|
|
50
|
+
if (publish.code !== 0) {
|
|
51
|
+
if (!quiet) {
|
|
52
|
+
console.warn("[seonix] Roslyn C# extractor build failed — falling back to tree-sitter for C#.");
|
|
53
|
+
console.warn(`[seonix] dotnet publish exited ${publish.code}: ${publish.err.slice(-300)}`);
|
|
54
|
+
}
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
if (!quiet) console.log("[seonix] Roslyn C# extractor built (full semantic call/type resolution enabled).");
|
|
58
|
+
return true;
|
|
59
|
+
}
|
package/src/schema-docs.mjs
CHANGED
|
@@ -71,9 +71,11 @@ export const PREDICATE_DOCS = Object.freeze([
|
|
|
71
71
|
"Module → Module. An import-backed, module-granular \"this file calls into that " +
|
|
72
72
|
"file\" edge — not resolved to a specific symbol (see callsSymbol for that)." },
|
|
73
73
|
{ prop: "mgx:callsSymbol", kind: "callsSymbol", description:
|
|
74
|
-
"Function/Method → Function/Class. A call site resolved to exactly one
|
|
75
|
-
"definition in the repository — unambiguous names only
|
|
76
|
-
"
|
|
74
|
+
"Function/Method → Function/Class/Method. A call site resolved to exactly one " +
|
|
75
|
+
"same-named definition in the repository — unambiguous names only (method targets " +
|
|
76
|
+
"resolve through a tiered simple-name fallback: same-module wins, interface members " +
|
|
77
|
+
"are excluded when one implementation remains); an ambiguous or external call is " +
|
|
78
|
+
"dropped rather than guessed (no wrong edge)." },
|
|
77
79
|
{ prop: "seon:declaresMethod", kind: "defines", description:
|
|
78
80
|
"Module → Function/Class/Method/Attribute/GlobalVariable. What a module's top-level " +
|
|
79
81
|
"scope actually declares." },
|
|
@@ -113,6 +115,23 @@ export const PREDICATE_DOCS = Object.freeze([
|
|
|
113
115
|
"split) and any doc-comment prose, used by the second-pass prose-to-symbol " +
|
|
114
116
|
"cross-reference index (PLAN_PROSE_INDEX.md) to resolve free-text object terms that " +
|
|
115
117
|
"don't exact-match an identifier." },
|
|
118
|
+
// GATED (un-typed interfaces, PLAN_UNTYPED_INTERFACES.md; ROADMAP backlog #15): documented
|
|
119
|
+
// here so "what does serves mean" is answerable, but `gated: true` means ingestSchemaDocs
|
|
120
|
+
// only materialises the SchemaPredicate individual when the predicate is ACTUALLY present
|
|
121
|
+
// in this build's vocabulary — i.e. only on an interfaces-ON graph that found at least one
|
|
122
|
+
// route. A default/OFF graph never has "mgx:serves"/"mgx:callsHttp" in its vocabulary, so
|
|
123
|
+
// the guard keeps it out, preserving byte-identity (see the extended guard test in
|
|
124
|
+
// schema-docs.test.mjs).
|
|
125
|
+
{ prop: "mgx:serves", kind: "serves", gated: true, description:
|
|
126
|
+
"Route → Function/Method. Route-declaration → handler-symbol — the HTTP boundary a " +
|
|
127
|
+
"syntax-only AST graph misses entirely (a route string is not a node, and a handler " +
|
|
128
|
+
"passed by reference at registration is not a call/import edge). Un-typed interfaces " +
|
|
129
|
+
"(PLAN_UNTYPED_INTERFACES.md), opt-in via SEONIX_INTERFACES=1 / [interfaces]." },
|
|
130
|
+
{ prop: "mgx:callsHttp", kind: "callsHttp", gated: true, description:
|
|
131
|
+
"Function/Method → Route. Client call-site → Route, the fuzzy other half of the same " +
|
|
132
|
+
"un-typed-interface chain: an HTTP client call's URL literal (fetch/axios/HttpClient) " +
|
|
133
|
+
"matched to a Route's normalised path template (verb agreement, segment-wildcard " +
|
|
134
|
+
"matching, match_threshold-gated) — completes client → route → handler." },
|
|
116
135
|
|
|
117
136
|
// ---- attributes (individual-scoped facts, not edges) ----
|
|
118
137
|
{ prop: "seon:startsAt", kind: "attribute", description:
|
|
@@ -240,6 +259,16 @@ export function ingestSchemaDocs(entities) {
|
|
|
240
259
|
}
|
|
241
260
|
}
|
|
242
261
|
|
|
262
|
+
// Present-predicate guard (ROADMAP backlog #15): a `gated: true` PREDICATE_DOCS entry (the
|
|
263
|
+
// un-typed-interfaces mgx:serves/mgx:callsHttp docs) only earns a SchemaPredicate individual
|
|
264
|
+
// when its prop is ACTUALLY present in this build's vocabulary — vocabulary entries for these
|
|
265
|
+
// props are themselves only emitted by interfaces.mjs when the gated feature is on AND found
|
|
266
|
+
// at least one match, so a default/OFF graph's vocabulary never contains them and this guard
|
|
267
|
+
// keeps their SchemaPredicate individuals out too, preserving byte-identity. Non-gated entries
|
|
268
|
+
// are unconditional, exactly as before.
|
|
269
|
+
const vocabProps = new Set((entities.vocabulary || []).map((v) => v?.prop));
|
|
270
|
+
const predicatePresent = (p) => !p.gated || vocabProps.has(p.prop);
|
|
271
|
+
|
|
243
272
|
entities.individuals ||= [];
|
|
244
273
|
const existingIds = new Set(entities.individuals.map((i) => i?.id));
|
|
245
274
|
for (const c of CLASS_DOCS) {
|
|
@@ -247,6 +276,7 @@ export function ingestSchemaDocs(entities) {
|
|
|
247
276
|
if (!existingIds.has(ind.id)) { entities.individuals.push(ind); existingIds.add(ind.id); }
|
|
248
277
|
}
|
|
249
278
|
for (const p of PREDICATE_DOCS) {
|
|
279
|
+
if (!predicatePresent(p)) continue;
|
|
250
280
|
const ind = predicateIndividual(p);
|
|
251
281
|
if (!existingIds.has(ind.id)) { entities.individuals.push(ind); existingIds.add(ind.id); }
|
|
252
282
|
}
|
package/src/server.mjs
CHANGED
|
@@ -20,7 +20,7 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
|
20
20
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
21
21
|
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
22
22
|
import { loadConfig, ToolError } from "./config.mjs";
|
|
23
|
-
import { createTelemetry } from "./telemetry.mjs";
|
|
23
|
+
import { createTelemetry, truncateStr, looksLikeMiss } from "./telemetry.mjs";
|
|
24
24
|
import * as defaultSource from "./source.mjs";
|
|
25
25
|
import {
|
|
26
26
|
parseEntities,
|
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
siteOf,
|
|
32
32
|
renderMembers,
|
|
33
33
|
renderSubclasses,
|
|
34
|
+
renderExternalSubclasses,
|
|
34
35
|
renderArchitecture,
|
|
35
36
|
renderTestsFor,
|
|
36
37
|
renderUntested,
|
|
@@ -47,6 +48,7 @@ import {
|
|
|
47
48
|
renderContextMore,
|
|
48
49
|
renderCalls,
|
|
49
50
|
callHint,
|
|
51
|
+
edgesOfKind,
|
|
50
52
|
renderFileHistory,
|
|
51
53
|
renderMethodHistory,
|
|
52
54
|
renderClassHistory,
|
|
@@ -319,10 +321,24 @@ export async function dispatchTool(name, args, { config, source = defaultSource
|
|
|
319
321
|
const graph = await loadGraph(config, source);
|
|
320
322
|
const { match, candidates } = resolveOrThrow(graph, symbol, "symbol");
|
|
321
323
|
const site = siteOf(match);
|
|
322
|
-
|
|
324
|
+
// Path-vs-symbol UX (WH_ESTATE_AUDIT §2b): a Module match must never fall through to the
|
|
325
|
+
// span-slicer — some extractors stamp modules with a thin 1-2-line span, which used to
|
|
326
|
+
// render a near-empty, useless "snippet" for `seonix_snippet <file>`. Whether or not a
|
|
327
|
+
// span exists, answer with the actionable alternatives: the symbols the module defines
|
|
328
|
+
// (each snippet-able directly), seonix_describe, or reading the file itself.
|
|
329
|
+
if (String(match.class || "") === "Module" || !site) {
|
|
330
|
+
const defined = edgesOfKind(graph, "defines")
|
|
331
|
+
.filter((e) => e.subject === match.id)
|
|
332
|
+
.map((e) => e.objectLabel || e.object);
|
|
333
|
+
const cap = 12;
|
|
334
|
+
const list = defined.length
|
|
335
|
+
? ` It defines: ${defined.slice(0, cap).join(", ")}${defined.length > cap ? `, +${defined.length - cap} more` : ""}.`
|
|
336
|
+
: "";
|
|
323
337
|
throw new ToolError(
|
|
324
|
-
`"${match.label}"
|
|
325
|
-
|
|
338
|
+
`"${match.label}" is a ${match.class || "Module"} — seonix_snippet takes a symbol ` +
|
|
339
|
+
`(a function/class name or Class.Method), not a file path.${list}` +
|
|
340
|
+
` seonix_snippet one of those, seonix_describe "${match.label}" for the module's full contents,` +
|
|
341
|
+
" or Read the file itself for raw source.",
|
|
326
342
|
);
|
|
327
343
|
}
|
|
328
344
|
// repo root = the dir containing .seonix/ (graphFile = <repo>/.seonix/graph.json)
|
|
@@ -389,8 +405,23 @@ export async function dispatchTool(name, args, { config, source = defaultSource
|
|
|
389
405
|
const symbol = String(args?.class || "").trim();
|
|
390
406
|
if (!symbol) throw new ToolError("class is required");
|
|
391
407
|
const graph = await loadGraph(config, source);
|
|
392
|
-
|
|
393
|
-
|
|
408
|
+
// Honest resolution (wh dogfood T14): exact match first; a fuzzy hit must never be
|
|
409
|
+
// presented as if it were the asked-for class.
|
|
410
|
+
const { match, candidates, exact } = resolveSymbol(graph, symbol);
|
|
411
|
+
if (exact) return renderSubclasses(graph, match);
|
|
412
|
+
// No individual with that exact name — an external/framework base may still appear
|
|
413
|
+
// as a supertype LABEL on inherits edges; if so, that's the real answer.
|
|
414
|
+
const external = renderExternalSubclasses(graph, symbol);
|
|
415
|
+
if (external) return external;
|
|
416
|
+
if (!match) {
|
|
417
|
+
throw new ToolError(
|
|
418
|
+
`no entity matching class "${symbol}" in the code-map graph. ` +
|
|
419
|
+
"Try a repo-relative path (e.g. django/utils/text.py), a basename, or seonix_search for a fuzzy lookup.",
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
// Fuzzy-only candidates: answer for the best match but NAME the substitution up front.
|
|
423
|
+
const others = candidates.length ? ` (other near matches: ${candidates.map((c) => c.label).join(", ")})` : "";
|
|
424
|
+
return `no exact match for "${symbol}"; nearest is "${match.label}"${others}:\n${renderSubclasses(graph, match)}`;
|
|
394
425
|
}
|
|
395
426
|
if (name === "seonix_architecture") {
|
|
396
427
|
const graph = await loadGraph(config, source);
|
|
@@ -425,7 +456,16 @@ export async function dispatchTool(name, args, { config, source = defaultSource
|
|
|
425
456
|
`${cands ? `: ${cands}` : ""}. Re-ask with a more specific query.`,
|
|
426
457
|
);
|
|
427
458
|
}
|
|
428
|
-
|
|
459
|
+
// Actionable caller-miss (wh dogfood C10/C11/T9): an empty symbol-level call answer used to
|
|
460
|
+
// name the traversal but not the cause or the next move. Port seonix_calls' honest wording:
|
|
461
|
+
// method-level call edges can be sparse for a language, so a miss is weak evidence — point
|
|
462
|
+
// at the module-level fallback instead of leaving the agent at a dead end.
|
|
463
|
+
const missNote = (tmct_ask?.miss === true && /callsSymbol/.test(String(tmct_ask?.traversal || "")))
|
|
464
|
+
? "\nnote: method-level call edges (callsSymbol) can be sparse for this language — an empty answer " +
|
|
465
|
+
"is not proof of no callers. Fall back to module level: seonix_impact {\"module\":\"<file>\",\"depth\":1} " +
|
|
466
|
+
"for direct dependents, or ask \"which modules import <file>\"."
|
|
467
|
+
: "";
|
|
468
|
+
return `${content}${missNote}\n\n---seonix_ask---\n${JSON.stringify(tmct_ask, null, 2)}`;
|
|
429
469
|
}
|
|
430
470
|
if (
|
|
431
471
|
name === "seonix_tests_for" || name === "seonix_history" || name === "seonix_callers" ||
|
|
@@ -486,8 +526,12 @@ export function buildServer({ config = loadConfig(), source = defaultSource, env
|
|
|
486
526
|
const text = await dispatchTool(name, args || {}, { config, source });
|
|
487
527
|
tel?.record({
|
|
488
528
|
tool: name,
|
|
529
|
+
// wh dogfood backlog #6: telemetry recorded only tool+size — add the query/args text
|
|
530
|
+
// (truncated, correlation only), wall-clock duration, and a best-effort hit/miss flag.
|
|
531
|
+
query: { raw: truncateStr(JSON.stringify(args || {})) },
|
|
489
532
|
response: { count: text ? text.split("\n").length : 0, truncated: /truncated/i.test(text) },
|
|
490
533
|
perf: { ms_total: Date.now() - t0 },
|
|
534
|
+
quality: { miss: looksLikeMiss(text) },
|
|
491
535
|
cost: { returned_chars: text.length, returned_tokens_est: Math.ceil(text.length / 4) },
|
|
492
536
|
});
|
|
493
537
|
return { content: [{ type: "text", text }] };
|
package/src/summary.mjs
CHANGED
|
@@ -92,6 +92,7 @@ export function hubModules(entities, { prefix = "", top = 5 } = {}) {
|
|
|
92
92
|
export function buildSummary(entities, stats) {
|
|
93
93
|
const {
|
|
94
94
|
mode = "single", repos = [], skipped = [], languages = {}, graphBytes = 0,
|
|
95
|
+
store = "json", // "sqlite" ONLY when graph.db was actually built (SEONIX_STORE exported + write ok)
|
|
95
96
|
} = stats || {};
|
|
96
97
|
|
|
97
98
|
// per-class individual counts (the fix for the "7,345 = total?" misread)
|
|
@@ -133,9 +134,15 @@ export function buildSummary(entities, stats) {
|
|
|
133
134
|
}
|
|
134
135
|
|
|
135
136
|
// effective history depth for the run = the deepest pass across repos
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
137
|
+
// (running max, not Math.max(...spread) — one arg per repo, and spread-into-call is banned
|
|
138
|
+
// over anything repo-scaled; see test/edges-of-kind.test.mjs)
|
|
139
|
+
let nameDepth = 0;
|
|
140
|
+
let symbolDepth = 0;
|
|
141
|
+
for (const r of repos) {
|
|
142
|
+
const d = r.historyDepth || { name: 0, symbol: 0 };
|
|
143
|
+
if ((d.name || 0) > nameDepth) nameDepth = d.name || 0;
|
|
144
|
+
if ((d.symbol || 0) > symbolDepth) symbolDepth = d.symbol || 0;
|
|
145
|
+
}
|
|
139
146
|
|
|
140
147
|
const repoRows = repos.map((r) => ({
|
|
141
148
|
name: r.name,
|
|
@@ -156,6 +163,7 @@ export function buildSummary(entities, stats) {
|
|
|
156
163
|
capChars: V8_STRING_CAP,
|
|
157
164
|
capPct: `~${((graphBytes / V8_STRING_CAP) * 100).toFixed(1)}%`,
|
|
158
165
|
},
|
|
166
|
+
store,
|
|
159
167
|
history: { tier: historyTier(nameDepth), depth: { name: nameDepth, symbol: symbolDepth } },
|
|
160
168
|
classes,
|
|
161
169
|
edges: { total: edgeTotal, byPredicate },
|
|
@@ -174,6 +182,9 @@ export function renderSummaryMd(summary) {
|
|
|
174
182
|
lines.push("# seonix index summary", "");
|
|
175
183
|
lines.push(`- mode: ${summary.mode}`);
|
|
176
184
|
lines.push(`- graph: ${fmt(summary.graph.bytes)} bytes (${summary.graph.capPct} of the V8 string cap)`);
|
|
185
|
+
// Store visibility: "sqlite" only when graph.db was actually built this run — a SEONIX_STORE
|
|
186
|
+
// that was set but never exported (invisible to child processes) reads `store: json` here.
|
|
187
|
+
lines.push(`- store: ${(summary.store || "json") === "sqlite" ? "sqlite (graph.db + graph.json; graph.json authoritative)" : "json (graph.json only)"}`);
|
|
177
188
|
lines.push(`- history: tier=${summary.history.tier} depth name=${summary.history.depth.name} symbol=${summary.history.depth.symbol}`);
|
|
178
189
|
const totalInds = Object.values(summary.classes).reduce((a, b) => a + b, 0);
|
|
179
190
|
lines.push(`- individuals: ${fmt(totalInds)} total`);
|
package/src/telemetry.mjs
CHANGED
|
@@ -20,6 +20,37 @@ const DROP_KEYS = new Set(["text", "content", "snippet"]);
|
|
|
20
20
|
* question, which is the correlation key and is not file content). */
|
|
21
21
|
const MAX_STR = 500;
|
|
22
22
|
|
|
23
|
+
/** Truncate a string to ~`max` chars (default 200) for a query/args preview field —
|
|
24
|
+
* the wh dogfood's "record the query/args text" ask, kept short deliberately (this is
|
|
25
|
+
* a correlation aid, not the file-content-safe `query.raw` exemption in redact() below,
|
|
26
|
+
* which some surfaces (locate/digest) still pass a short literal query through). Pure. */
|
|
27
|
+
export function truncateStr(s, max = 200) {
|
|
28
|
+
const str = String(s ?? "");
|
|
29
|
+
return str.length > max ? str.slice(0, max) : str;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Best-effort, cheap-to-tell hit/miss classifier over a tool's RENDERED text output
|
|
33
|
+
* (wh dogfood backlog #6: telemetry too thin to tell a query-mix's hit rate without
|
|
34
|
+
* re-reading prose at analysis time). Matches the honest-miss phrasings the
|
|
35
|
+
* codegraph.mjs renderers already use ("no entity matching", "none recorded", "cannot
|
|
36
|
+
* map", "unknown kind/tool/argument", the tmct_ask envelope's own `"miss": true`) plus
|
|
37
|
+
* the generic "no <kind> matches/found" shape. NOT exhaustive — a renderer can grow a
|
|
38
|
+
* new miss phrasing this never learns — so `true` is fairly reliable, `false` only
|
|
39
|
+
* means "no miss marker seen", not "definitely a hit". Pure. */
|
|
40
|
+
const MISS_PATTERNS = [
|
|
41
|
+
/no entity matching/i,
|
|
42
|
+
/no [a-z]+ (matches|found)/i,
|
|
43
|
+
/:\s*no [a-z][a-z /-]* recorded\b/i,
|
|
44
|
+
/\bnone recorded\b/i,
|
|
45
|
+
/\bcannot map\b/i,
|
|
46
|
+
/^unknown (kind|tool|argument)/i,
|
|
47
|
+
/"miss"\s*:\s*true/,
|
|
48
|
+
];
|
|
49
|
+
export function looksLikeMiss(text) {
|
|
50
|
+
if (typeof text !== "string" || !text) return false;
|
|
51
|
+
return MISS_PATTERNS.some((re) => re.test(text));
|
|
52
|
+
}
|
|
53
|
+
|
|
23
54
|
/** Enabled iff `SEONIX_TELEMETRY==="1"` OR `[telemetry] enabled=true` in the toml.
|
|
24
55
|
* The env wins BOTH directions: `SEONIX_TELEMETRY==="0"` force-disables even when the
|
|
25
56
|
* toml turns it on. Default OFF (anything but "1"/"0" in the env falls through to toml). */
|