knodin 0.6.0 → 0.7.3
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 +26 -3
- package/dist/bin/cli.js +168 -12
- package/dist/bin/launcher.js +11 -0
- package/dist/src/agent-integration.js +3 -1
- package/dist/src/cli-args.js +8 -1
- package/dist/src/cli-model.js +35 -1
- package/dist/src/codeflow-replay.js +80 -0
- package/dist/src/competitive-cold-mcp.js +40 -0
- package/dist/src/competitive-manifest.js +106 -25
- package/dist/src/competitive-runner.js +37 -3
- package/dist/src/competitive-sandbox.js +1 -1
- package/dist/src/diagnostics.js +449 -0
- package/dist/src/engine/git-history.js +289 -0
- package/dist/src/engine/index.js +417 -70
- package/dist/src/engine/scip-import.js +408 -0
- package/dist/src/execution-profile.js +203 -0
- package/dist/src/failure-diagnosis.js +69 -10
- package/dist/src/hook-manager-integration.js +156 -0
- package/dist/src/init.js +319 -33
- package/dist/src/lifecycle-health.js +42 -4
- package/dist/src/output-telemetry.js +4 -0
- package/dist/src/progressive-evidence.js +473 -0
- package/dist/src/pure-compression-cli.js +101 -0
- package/dist/src/release-preflight.js +510 -0
- package/dist/src/repository-management.js +142 -0
- package/dist/src/response-budget.js +11 -1
- package/dist/src/server.js +22 -2
- package/dist/src/structural-fast-path.js +303 -0
- package/dist/src/structural-snapshot.js +33 -0
- package/dist/src/tools/knodin-tools.js +105 -14
- package/dist/src/update-ceremony.js +158 -0
- package/docs/CLI.md +22 -0
- package/docs/COMMAND-OUTPUT-COMPRESSION.md +31 -15
- package/docs/CONTAINED-EXECUTION.md +77 -0
- package/docs/DIAGNOSTICS.md +45 -0
- package/docs/DOCTOR-AND-UPDATES.md +5 -2
- package/docs/GIT-HISTORY-REVIEW.md +39 -0
- package/docs/MCP.md +15 -0
- package/docs/PROGRESSIVE-EVIDENCE.md +37 -0
- package/docs/REPOSITORIES-AND-WORKTREES.md +30 -0
- package/docs/SCIP-IMPORT.md +57 -0
- package/docs/SIGNED-UPDATES.md +5 -0
- package/docs/TELEMETRY.md +4 -0
- package/docs/releases/0.7.0.md +24 -0
- package/docs/releases/0.7.1.md +21 -0
- package/docs/releases/0.7.2.md +21 -0
- package/docs/releases/0.7.3.md +23 -0
- package/package.json +33 -2
|
@@ -397,18 +397,16 @@ function truncateUtf8(value, byteBudget) {
|
|
|
397
397
|
return output;
|
|
398
398
|
}
|
|
399
399
|
function sourceSnippet(repo, file, line, contextLines, remainingBytes) {
|
|
400
|
-
if (remainingBytes <= 0)
|
|
401
|
-
return { snippet: null, truncated: true };
|
|
402
400
|
const absolute = path.join(repo, file);
|
|
403
401
|
let lines;
|
|
404
402
|
try {
|
|
405
403
|
const stat = fs.statSync(absolute);
|
|
406
404
|
if (!stat.isFile() || stat.size > MAX_SOURCE_FILE_BYTES)
|
|
407
|
-
return { snippet: null, truncated: true };
|
|
405
|
+
return { snippet: null, truncated: true, omittedBytes: 0 };
|
|
408
406
|
lines = fs.readFileSync(absolute, "utf8").split(/\r?\n/);
|
|
409
407
|
}
|
|
410
408
|
catch {
|
|
411
|
-
return { snippet: null, truncated: true };
|
|
409
|
+
return { snippet: null, truncated: true, omittedBytes: 0 };
|
|
412
410
|
}
|
|
413
411
|
const startLine = Math.max(1, line - contextLines);
|
|
414
412
|
const endLine = Math.min(lines.length, line + contextLines);
|
|
@@ -416,12 +414,16 @@ function sourceSnippet(repo, file, line, contextLines, remainingBytes) {
|
|
|
416
414
|
.slice(startLine - 1, endLine)
|
|
417
415
|
.map((value, index) => `${startLine + index}: ${value.slice(0, 2_000)}`)
|
|
418
416
|
.join("\n");
|
|
417
|
+
const contentBytes = Buffer.byteLength(content);
|
|
418
|
+
if (remainingBytes <= 0)
|
|
419
|
+
return { snippet: null, truncated: true, omittedBytes: contentBytes };
|
|
419
420
|
const bounded = truncateUtf8(content, remainingBytes);
|
|
420
421
|
if (!bounded)
|
|
421
|
-
return { snippet: null, truncated: true };
|
|
422
|
+
return { snippet: null, truncated: true, omittedBytes: contentBytes };
|
|
422
423
|
return {
|
|
423
424
|
snippet: { file, startLine, endLine, content: bounded },
|
|
424
425
|
truncated: bounded !== content,
|
|
426
|
+
omittedBytes: contentBytes - Buffer.byteLength(bounded),
|
|
425
427
|
};
|
|
426
428
|
}
|
|
427
429
|
function diagnosisSource(repo, request) {
|
|
@@ -464,7 +466,7 @@ function diagnosisSource(repo, request) {
|
|
|
464
466
|
}
|
|
465
467
|
async function relationsForOwner(graph, repo, file, owner, limit) {
|
|
466
468
|
if (!owner?.symbol)
|
|
467
|
-
return { tests: [], upstream: [], downstream: [] };
|
|
469
|
+
return { tests: [], upstream: [], downstream: [], omitted: 0 };
|
|
468
470
|
const selector = owner.identity ? { identity: owner.identity, file } : { file };
|
|
469
471
|
const query = (pattern, options) => graph.query(pattern, owner.symbol ?? "", repo, undefined, limit, pattern === "impact" ? 2 : undefined, undefined, selector, options);
|
|
470
472
|
const [tests, upstream, downstream] = await Promise.all([
|
|
@@ -472,11 +474,17 @@ async function relationsForOwner(graph, repo, file, owner, limit) {
|
|
|
472
474
|
query("impact", { direction: "upstream", includeTests: true }),
|
|
473
475
|
query("impact", { direction: "downstream", includeTests: true }),
|
|
474
476
|
]);
|
|
475
|
-
|
|
477
|
+
const mapped = {
|
|
476
478
|
tests: relationRows(tests, limit),
|
|
477
479
|
upstream: relationRows(upstream, limit),
|
|
478
480
|
downstream: relationRows(downstream, limit),
|
|
479
481
|
};
|
|
482
|
+
return {
|
|
483
|
+
...mapped,
|
|
484
|
+
omitted: Math.max(0, tests.count - mapped.tests.length) +
|
|
485
|
+
Math.max(0, upstream.count - mapped.upstream.length) +
|
|
486
|
+
Math.max(0, downstream.count - mapped.downstream.length),
|
|
487
|
+
};
|
|
480
488
|
}
|
|
481
489
|
function ownerEvidence(owner) {
|
|
482
490
|
if (!owner?.symbol)
|
|
@@ -502,10 +510,13 @@ async function diagnoseResolvedReference(graph, repo, reference, file, options)
|
|
|
502
510
|
file,
|
|
503
511
|
package: owningPackage(repo, file),
|
|
504
512
|
owner: ownerEvidence(owner),
|
|
505
|
-
|
|
513
|
+
tests: relationships.tests,
|
|
514
|
+
upstream: relationships.upstream,
|
|
515
|
+
downstream: relationships.downstream,
|
|
506
516
|
recentChanges: options.recentCommitLimit > 0 ? recentChanges(repo, file, options.recentCommitLimit) : [],
|
|
507
517
|
},
|
|
508
518
|
source: sourceSnippet(repo, file, evidenceLineNumber, options.contextLines, options.remainingContextBytes),
|
|
519
|
+
omittedRelations: relationships.omitted,
|
|
509
520
|
};
|
|
510
521
|
}
|
|
511
522
|
function diagnosisLimitations(inputComplete, referenceCount, contextTruncated, freshnessState) {
|
|
@@ -531,17 +542,22 @@ function diagnosisStatus(resolvedCount, unresolvedCount, inputComplete, contextT
|
|
|
531
542
|
}
|
|
532
543
|
export async function diagnoseFailure(graph, repo, request) {
|
|
533
544
|
const maxDiagnostics = integer("maxDiagnostics", request.maxDiagnostics, DEFAULT_DIAGNOSTICS, 1, MAX_DIAGNOSTICS);
|
|
545
|
+
const diagnosticOffset = integer("diagnosticOffset", request.diagnosticOffset, 0, 0, 1_000_000);
|
|
534
546
|
const contextLines = integer("contextLines", request.contextLines, 2, 0, 10);
|
|
535
547
|
const contextByteBudget = integer("contextByteBudget", request.contextByteBudget, DEFAULT_CONTEXT_BYTES, 256, MAX_CONTEXT_BYTES);
|
|
536
548
|
const relationLimit = integer("relationLimit", request.relationLimit, DEFAULT_RELATIONS, 1, MAX_RELATIONS);
|
|
537
549
|
const recentCommitLimit = integer("recentCommitLimit", request.recentCommitLimit, 3, 0, 10);
|
|
538
550
|
const input = diagnosisSource(repo, request);
|
|
539
|
-
const
|
|
551
|
+
const detectedReferences = extractDiagnosticReferences(input.content, Number.MAX_SAFE_INTEGER);
|
|
552
|
+
const references = detectedReferences.slice(diagnosticOffset, diagnosticOffset + maxDiagnostics);
|
|
553
|
+
const omittedDiagnostics = Math.max(0, detectedReferences.length - diagnosticOffset - references.length);
|
|
540
554
|
const diagnostics = [];
|
|
541
555
|
const unresolved = [];
|
|
542
556
|
const snippets = [];
|
|
543
557
|
let contextBytes = 0;
|
|
544
558
|
let contextTruncated = false;
|
|
559
|
+
let omittedRelations = 0;
|
|
560
|
+
let omittedContextBytes = 0;
|
|
545
561
|
for (const reference of references) {
|
|
546
562
|
const resolved = await resolveReferencePath(graph, repo, reference);
|
|
547
563
|
if (!resolved.file) {
|
|
@@ -563,10 +579,28 @@ export async function diagnoseFailure(graph, repo, request) {
|
|
|
563
579
|
contextBytes += Buffer.byteLength(resolvedDiagnosis.source.snippet.content);
|
|
564
580
|
}
|
|
565
581
|
contextTruncated ||= resolvedDiagnosis.source.truncated;
|
|
582
|
+
omittedRelations += resolvedDiagnosis.omittedRelations;
|
|
583
|
+
omittedContextBytes += resolvedDiagnosis.source.omittedBytes;
|
|
566
584
|
diagnostics.push(resolvedDiagnosis.diagnostic);
|
|
567
585
|
}
|
|
568
586
|
const health = await graph.status(repo, { audit: "cached" });
|
|
569
|
-
const status = diagnosisStatus(diagnostics.length, unresolved.length, input.complete, contextTruncated, health.freshness.state);
|
|
587
|
+
const status = diagnosisStatus(diagnostics.length, unresolved.length + omittedDiagnostics, input.complete, contextTruncated, health.freshness.state);
|
|
588
|
+
const omissionReasons = [];
|
|
589
|
+
if (omittedDiagnostics > 0)
|
|
590
|
+
omissionReasons.push("diagnostic-item-budget");
|
|
591
|
+
if (omittedRelations > 0)
|
|
592
|
+
omissionReasons.push("relation-item-budget");
|
|
593
|
+
if (contextTruncated)
|
|
594
|
+
omissionReasons.push("context-byte-budget");
|
|
595
|
+
if (!input.complete)
|
|
596
|
+
omissionReasons.push("input-byte-budget");
|
|
597
|
+
const confidenceReasons = [
|
|
598
|
+
...(diagnostics.length === 0 ? ["no-safe-source-reference"] : []),
|
|
599
|
+
...(unresolved.length > 0 ? ["unresolved-source-reference"] : []),
|
|
600
|
+
...omissionReasons,
|
|
601
|
+
...(health.freshness.state !== "fresh" ? [`freshness-${health.freshness.state}`] : []),
|
|
602
|
+
];
|
|
603
|
+
const returnedRelations = diagnostics.reduce((total, diagnostic) => total + diagnostic.tests.length + diagnostic.upstream.length + diagnostic.downstream.length, 0);
|
|
570
604
|
return {
|
|
571
605
|
schemaVersion: 1,
|
|
572
606
|
status,
|
|
@@ -585,6 +619,31 @@ export async function diagnoseFailure(graph, repo, request) {
|
|
|
585
619
|
snippets,
|
|
586
620
|
},
|
|
587
621
|
freshness: health.freshness,
|
|
622
|
+
confidence: {
|
|
623
|
+
level: diagnostics.length === 0 ? "refused" : status === "resolved" ? "exact" : "qualified",
|
|
624
|
+
reasons: confidenceReasons,
|
|
625
|
+
},
|
|
626
|
+
omissions: {
|
|
627
|
+
diagnostics: omittedDiagnostics,
|
|
628
|
+
relations: omittedRelations,
|
|
629
|
+
contextBytes: omittedContextBytes,
|
|
630
|
+
reasons: omissionReasons,
|
|
631
|
+
continuation: omittedDiagnostics > 0 && input.artifactId
|
|
632
|
+
? { artifactId: input.artifactId, nextDiagnostic: diagnosticOffset + references.length }
|
|
633
|
+
: null,
|
|
634
|
+
},
|
|
635
|
+
telemetry: {
|
|
636
|
+
commandRerun: false,
|
|
637
|
+
referencesDetected: detectedReferences.length,
|
|
638
|
+
resolved: diagnostics.length,
|
|
639
|
+
unresolved: unresolved.length,
|
|
640
|
+
caps: {
|
|
641
|
+
diagnostics: maxDiagnostics,
|
|
642
|
+
relationsPerKind: relationLimit,
|
|
643
|
+
contextBytes: contextByteBudget,
|
|
644
|
+
},
|
|
645
|
+
returned: { diagnostics: diagnostics.length, relations: returnedRelations, contextBytes },
|
|
646
|
+
},
|
|
588
647
|
limitations: diagnosisLimitations(input.complete, references.length, contextTruncated, health.freshness.state),
|
|
589
648
|
};
|
|
590
649
|
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { isMap, parseDocument } from "yaml";
|
|
4
|
+
export const KNODIN_LEFTHOOK_COMMAND = "knodin-refresh";
|
|
5
|
+
const MANAGED_COMMENT = "Managed locally by knodin init; repository hook configuration is not modified.";
|
|
6
|
+
const LEFTHOOK_CONFIGS = [
|
|
7
|
+
{ main: "lefthook.yml", local: "lefthook-local.yml" },
|
|
8
|
+
{ main: "lefthook.yaml", local: "lefthook-local.yaml" },
|
|
9
|
+
{ main: ".lefthook.yml", local: ".lefthook-local.yml" },
|
|
10
|
+
{ main: ".lefthook.yaml", local: ".lefthook-local.yaml" },
|
|
11
|
+
{ main: ".config/lefthook.yml", local: ".config/lefthook-local.yml" },
|
|
12
|
+
{ main: ".config/lefthook.yaml", local: ".config/lefthook-local.yaml" },
|
|
13
|
+
];
|
|
14
|
+
const HOOK_COMMANDS = {
|
|
15
|
+
"post-commit": { run: ".knodin/hooks/manager-dispatch.sh post-commit" },
|
|
16
|
+
"post-checkout": {
|
|
17
|
+
run: ".knodin/hooks/manager-dispatch.sh post-checkout {1} {2} {3}",
|
|
18
|
+
},
|
|
19
|
+
"post-merge": { run: ".knodin/hooks/manager-dispatch.sh post-merge {1}" },
|
|
20
|
+
"post-rewrite": {
|
|
21
|
+
run: ".knodin/hooks/manager-dispatch.sh post-rewrite {1}",
|
|
22
|
+
use_stdin: true,
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
export const LEFTHOOK_DISPATCHER = `#!/bin/sh
|
|
26
|
+
# knodin manager-native lifecycle dispatcher. Generated by knodin init.
|
|
27
|
+
set -u
|
|
28
|
+
[ "\${KNODIN_WRAPPER_ACTIVE:-0}" = "1" ] && exit 0
|
|
29
|
+
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || true)"
|
|
30
|
+
[ -n "$REPO_ROOT" ] || exit 0
|
|
31
|
+
BACKGROUND="$REPO_ROOT/.knodin/hooks/background-index.sh"
|
|
32
|
+
[ -x "$BACKGROUND" ] || exit 0
|
|
33
|
+
HOOK="\${1:-}"
|
|
34
|
+
shift || true
|
|
35
|
+
case "$HOOK" in
|
|
36
|
+
post-commit)
|
|
37
|
+
set -- commit
|
|
38
|
+
;;
|
|
39
|
+
post-checkout)
|
|
40
|
+
[ "$#" -ge 2 ] || exit 0
|
|
41
|
+
set -- checkout "$1" "$2"
|
|
42
|
+
;;
|
|
43
|
+
post-merge)
|
|
44
|
+
BEFORE="$(git rev-parse ORIG_HEAD 2>/dev/null || true)"
|
|
45
|
+
AFTER="$(git rev-parse HEAD 2>/dev/null || true)"
|
|
46
|
+
[ -n "$BEFORE" ] && [ -n "$AFTER" ] || exit 0
|
|
47
|
+
set -- merge "$BEFORE" "$AFTER"
|
|
48
|
+
;;
|
|
49
|
+
post-rewrite)
|
|
50
|
+
INPUT="$(mktemp "$REPO_ROOT/.knodin/hooks/rewrite-input.XXXXXX")" || exit 0
|
|
51
|
+
cat > "$INPUT"
|
|
52
|
+
set -- rewrite "$INPUT"
|
|
53
|
+
;;
|
|
54
|
+
*) exit 0 ;;
|
|
55
|
+
esac
|
|
56
|
+
nohup "$BACKGROUND" "$@" >/dev/null 2>&1 &
|
|
57
|
+
exit 0
|
|
58
|
+
`;
|
|
59
|
+
function lefthookFiles(repo) {
|
|
60
|
+
for (const candidate of LEFTHOOK_CONFIGS) {
|
|
61
|
+
if (fs.existsSync(path.join(repo, candidate.main)))
|
|
62
|
+
return candidate;
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
function assertRegularOrMissing(target) {
|
|
67
|
+
try {
|
|
68
|
+
const stat = fs.lstatSync(target);
|
|
69
|
+
if (!stat.isFile() || stat.isSymbolicLink())
|
|
70
|
+
throw new Error(`knodin init: refusing non-regular hook-manager config: ${target}`);
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
if (error.code !== "ENOENT")
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function atomicWrite(target, content, mode) {
|
|
78
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
79
|
+
const temporary = `${target}.${process.pid}.${Date.now()}.tmp`;
|
|
80
|
+
fs.writeFileSync(temporary, content, { encoding: "utf-8", mode, flag: "wx" });
|
|
81
|
+
fs.renameSync(temporary, target);
|
|
82
|
+
if (mode !== undefined)
|
|
83
|
+
fs.chmodSync(target, mode);
|
|
84
|
+
}
|
|
85
|
+
function ensureMap(document, keys) {
|
|
86
|
+
const existing = document.getIn(keys, true);
|
|
87
|
+
if (existing !== undefined && !isMap(existing))
|
|
88
|
+
throw new Error(`knodin init: cannot merge Lefthook integration because ${keys.join(".")} is not a map`);
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Add a local-only Lefthook overlay. The repository-owned main configuration is
|
|
92
|
+
* never edited; an existing local overlay is parsed and merged by key.
|
|
93
|
+
*/
|
|
94
|
+
export function installHookManagerIntegration(repoPath) {
|
|
95
|
+
const repo = path.resolve(repoPath);
|
|
96
|
+
const files = lefthookFiles(repo);
|
|
97
|
+
if (!files)
|
|
98
|
+
return null;
|
|
99
|
+
const localPath = path.join(repo, files.local);
|
|
100
|
+
assertRegularOrMissing(localPath);
|
|
101
|
+
const existing = fs.existsSync(localPath) ? fs.readFileSync(localPath, "utf-8") : "";
|
|
102
|
+
const document = parseDocument(existing || "{}\n", { keepSourceTokens: true });
|
|
103
|
+
if (document.errors.length > 0)
|
|
104
|
+
throw new Error(`knodin init: cannot parse ${files.local}: ${document.errors[0]?.message}`);
|
|
105
|
+
if (!isMap(document.contents))
|
|
106
|
+
throw new Error(`knodin init: cannot merge ${files.local}: root must be a map`);
|
|
107
|
+
if (!existing)
|
|
108
|
+
document.commentBefore = MANAGED_COMMENT;
|
|
109
|
+
for (const [hook, command] of Object.entries(HOOK_COMMANDS)) {
|
|
110
|
+
ensureMap(document, [hook]);
|
|
111
|
+
ensureMap(document, [hook, "commands"]);
|
|
112
|
+
document.setIn([hook, "commands", KNODIN_LEFTHOOK_COMMAND], command);
|
|
113
|
+
}
|
|
114
|
+
atomicWrite(localPath, document.toString({ lineWidth: 0 }));
|
|
115
|
+
const dispatcher = path.join(repo, ".knodin", "hooks", "manager-dispatch.sh");
|
|
116
|
+
assertRegularOrMissing(dispatcher);
|
|
117
|
+
atomicWrite(dispatcher, LEFTHOOK_DISPATCHER, 0o755);
|
|
118
|
+
return {
|
|
119
|
+
manager: "lefthook",
|
|
120
|
+
localConfig: files.local,
|
|
121
|
+
dispatcher: ".knodin/hooks/manager-dispatch.sh",
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
export function inspectLefthookIntegration(repoPath) {
|
|
125
|
+
const repo = path.resolve(repoPath);
|
|
126
|
+
const files = lefthookFiles(repo);
|
|
127
|
+
if (!files)
|
|
128
|
+
return { configured: false, localConfig: null };
|
|
129
|
+
try {
|
|
130
|
+
const document = parseDocument(fs.readFileSync(path.join(repo, files.local), "utf-8"));
|
|
131
|
+
if (document.errors.length > 0)
|
|
132
|
+
return { configured: false, localConfig: files.local };
|
|
133
|
+
for (const [hook, command] of Object.entries(HOOK_COMMANDS)) {
|
|
134
|
+
const run = document.getIn([hook, "commands", KNODIN_LEFTHOOK_COMMAND, "run"]);
|
|
135
|
+
if (run !== command.run)
|
|
136
|
+
return { configured: false, localConfig: files.local };
|
|
137
|
+
if ("use_stdin" in command &&
|
|
138
|
+
document.getIn([hook, "commands", KNODIN_LEFTHOOK_COMMAND, "use_stdin"]) !== true)
|
|
139
|
+
return { configured: false, localConfig: files.local };
|
|
140
|
+
}
|
|
141
|
+
const dispatcher = path.join(repo, ".knodin", "hooks", "manager-dispatch.sh");
|
|
142
|
+
const stat = fs.statSync(dispatcher);
|
|
143
|
+
return {
|
|
144
|
+
configured: stat.isFile() &&
|
|
145
|
+
(stat.mode & 0o111) !== 0 &&
|
|
146
|
+
fs.readFileSync(dispatcher, "utf-8") === LEFTHOOK_DISPATCHER,
|
|
147
|
+
localConfig: files.local,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
return { configured: false, localConfig: files.local };
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
export function isActiveLefthookHook(content) {
|
|
155
|
+
return /\bcall_lefthook\b|\blefthook(?:\s|")/.test(content);
|
|
156
|
+
}
|