@oh-my-pi/pi-coding-agent 16.0.4 → 16.0.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.
- package/CHANGELOG.md +94 -0
- package/dist/cli.js +2027 -1396
- package/dist/types/advisor/advise-tool.d.ts +31 -19
- package/dist/types/autoresearch/tools/init-experiment.d.ts +13 -17
- package/dist/types/autoresearch/tools/log-experiment.d.ts +17 -19
- package/dist/types/autoresearch/tools/run-experiment.d.ts +3 -4
- package/dist/types/autoresearch/tools/update-notes.d.ts +4 -5
- package/dist/types/cli/args.d.ts +1 -0
- package/dist/types/cli/bench-cli.d.ts +6 -0
- package/dist/types/cli/ttsr-cli.d.ts +39 -0
- package/dist/types/commands/launch.d.ts +3 -0
- package/dist/types/commands/ttsr.d.ts +57 -0
- package/dist/types/commit/agentic/tools/analyze-file.d.ts +4 -5
- package/dist/types/commit/agentic/tools/git-file-diff.d.ts +4 -5
- package/dist/types/commit/agentic/tools/git-hunk.d.ts +5 -6
- package/dist/types/commit/agentic/tools/git-overview.d.ts +4 -5
- package/dist/types/commit/agentic/tools/propose-changelog.d.ts +23 -24
- package/dist/types/commit/agentic/tools/propose-commit.d.ts +11 -32
- package/dist/types/commit/agentic/tools/recent-commits.d.ts +3 -4
- package/dist/types/commit/agentic/tools/schemas.d.ts +6 -27
- package/dist/types/commit/agentic/tools/split-commit.d.ts +28 -49
- package/dist/types/commit/changelog/generate.d.ts +12 -13
- package/dist/types/commit/shared-llm.d.ts +10 -37
- package/dist/types/config/config-file.d.ts +4 -4
- package/dist/types/config/keybindings.d.ts +5 -0
- package/dist/types/config/models-config-schema.d.ts +625 -990
- package/dist/types/config/models-config.d.ts +229 -217
- package/dist/types/config/settings-schema.d.ts +144 -25
- package/dist/types/edit/hashline/params.d.ts +7 -11
- package/dist/types/edit/index.d.ts +2 -1
- package/dist/types/edit/modes/apply-patch.d.ts +4 -5
- package/dist/types/edit/modes/patch.d.ts +15 -24
- package/dist/types/edit/modes/replace.d.ts +16 -17
- package/dist/types/eval/js/index.d.ts +1 -0
- package/dist/types/extensibility/custom-commands/types.d.ts +6 -3
- package/dist/types/extensibility/custom-tools/types.d.ts +8 -5
- package/dist/types/extensibility/extensions/runner.d.ts +5 -2
- package/dist/types/extensibility/extensions/types.d.ts +14 -10
- package/dist/types/extensibility/hooks/types.d.ts +7 -4
- package/dist/types/extensibility/legacy-pi-ai-shim.d.ts +13 -5
- package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +17 -0
- package/dist/types/extensibility/shared-events.d.ts +22 -1
- package/dist/types/extensibility/typebox.d.ts +80 -58
- package/dist/types/goals/tools/goal-tool.d.ts +11 -24
- package/dist/types/index.d.ts +2 -0
- package/dist/types/lsp/index.d.ts +11 -26
- package/dist/types/lsp/types.d.ts +12 -28
- package/dist/types/main.d.ts +1 -0
- package/dist/types/mcp/client.d.ts +8 -0
- package/dist/types/modes/components/btw-panel.d.ts +1 -0
- package/dist/types/modes/components/custom-editor.d.ts +3 -1
- package/dist/types/modes/components/status-line/component.d.ts +1 -1
- package/dist/types/modes/components/status-line/context-thresholds.d.ts +0 -1
- package/dist/types/modes/controllers/btw-controller.d.ts +2 -0
- package/dist/types/modes/controllers/input-controller.d.ts +1 -0
- package/dist/types/modes/interactive-mode.d.ts +3 -0
- package/dist/types/modes/rpc/rpc-types.d.ts +1 -1
- package/dist/types/modes/setup-wizard/index.d.ts +1 -0
- package/dist/types/modes/setup-wizard/startup-splash.d.ts +7 -0
- package/dist/types/modes/theme/theme.d.ts +1 -1
- package/dist/types/modes/types.d.ts +3 -0
- package/dist/types/modes/utils/context-usage.d.ts +12 -0
- package/dist/types/sdk.d.ts +8 -1
- package/dist/types/session/agent-session.d.ts +24 -0
- package/dist/types/session/session-persistence.d.ts +4 -0
- package/dist/types/startup-splash.d.ts +12 -0
- package/dist/types/task/types.d.ts +47 -48
- package/dist/types/tools/ask.d.ts +26 -27
- package/dist/types/tools/ast-edit.d.ts +17 -17
- package/dist/types/tools/ast-grep.d.ts +12 -13
- package/dist/types/tools/bash.d.ts +20 -17
- package/dist/types/tools/browser.d.ts +46 -71
- package/dist/types/tools/checkpoint.d.ts +14 -15
- package/dist/types/tools/debug.d.ts +82 -145
- package/dist/types/tools/eval.d.ts +30 -40
- package/dist/types/tools/find.d.ts +17 -18
- package/dist/types/tools/gh.d.ts +49 -78
- package/dist/types/tools/image-gen.d.ts +20 -36
- package/dist/types/tools/inspect-image.d.ts +10 -11
- package/dist/types/tools/irc.d.ts +22 -33
- package/dist/types/tools/job.d.ts +11 -12
- package/dist/types/tools/learn.d.ts +21 -28
- package/dist/types/tools/manage-skill.d.ts +13 -22
- package/dist/types/tools/memory-edit.d.ts +15 -24
- package/dist/types/tools/memory-recall.d.ts +7 -8
- package/dist/types/tools/memory-reflect.d.ts +9 -10
- package/dist/types/tools/memory-retain.d.ts +13 -14
- package/dist/types/tools/read.d.ts +8 -8
- package/dist/types/tools/resolve.d.ts +11 -18
- package/dist/types/tools/review.d.ts +9 -15
- package/dist/types/tools/search-tool-bm25.d.ts +9 -10
- package/dist/types/tools/search.d.ts +16 -17
- package/dist/types/tools/ssh.d.ts +14 -15
- package/dist/types/tools/todo.d.ts +27 -43
- package/dist/types/tools/tts.d.ts +8 -9
- package/dist/types/tools/write.d.ts +9 -10
- package/dist/types/tui/code-cell.d.ts +2 -0
- package/dist/types/tui/index.d.ts +1 -0
- package/dist/types/tui/width-aware-text.d.ts +23 -0
- package/dist/types/utils/image-vision-fallback.d.ts +28 -0
- package/dist/types/utils/markit.d.ts +10 -1
- package/dist/types/web/search/index.d.ts +17 -28
- package/dist/types/web/search/providers/base.d.ts +1 -0
- package/dist/types/web/search/providers/gemini.d.ts +1 -0
- package/dist/types/web/search/providers/perplexity.d.ts +0 -2
- package/dist/types/web/search/types.d.ts +32 -26
- package/package.json +14 -13
- package/scripts/omp +1 -1
- package/src/advisor/__tests__/advisor.test.ts +103 -1
- package/src/advisor/advise-tool.ts +47 -11
- package/src/autoresearch/tools/init-experiment.ts +13 -16
- package/src/autoresearch/tools/log-experiment.ts +15 -18
- package/src/autoresearch/tools/run-experiment.ts +3 -3
- package/src/autoresearch/tools/update-notes.ts +4 -4
- package/src/cli/args.ts +1 -0
- package/src/cli/bench-cli.ts +30 -7
- package/src/cli/flag-tables.ts +8 -0
- package/src/cli/ttsr-cli.ts +995 -0
- package/src/cli-commands.ts +1 -0
- package/src/cli.ts +7 -1
- package/src/collab/host.ts +2 -2
- package/src/commands/launch.ts +3 -0
- package/src/commands/ttsr.ts +125 -0
- package/src/commit/agentic/tools/analyze-file.ts +4 -4
- package/src/commit/agentic/tools/git-file-diff.ts +4 -4
- package/src/commit/agentic/tools/git-hunk.ts +7 -5
- package/src/commit/agentic/tools/git-overview.ts +4 -4
- package/src/commit/agentic/tools/propose-changelog.ts +18 -15
- package/src/commit/agentic/tools/propose-commit.ts +6 -6
- package/src/commit/agentic/tools/recent-commits.ts +3 -3
- package/src/commit/agentic/tools/schemas.ts +8 -20
- package/src/commit/agentic/tools/split-commit.ts +19 -23
- package/src/commit/analysis/summary.ts +7 -5
- package/src/commit/changelog/generate.ts +15 -11
- package/src/commit/shared-llm.ts +17 -24
- package/src/config/config-file.ts +13 -15
- package/src/config/keybindings.ts +6 -0
- package/src/config/models-config-schema.ts +206 -179
- package/src/config/settings-schema.ts +118 -2
- package/src/discovery/builtin-rules/index.ts +2 -0
- package/src/discovery/builtin-rules/ts-import-type.md +2 -2
- package/src/discovery/builtin-rules/ts-no-any.md +11 -2
- package/src/discovery/builtin-rules/ts-no-inline-cast-access.md +55 -0
- package/src/edit/hashline/params.ts +12 -11
- package/src/edit/index.ts +5 -4
- package/src/edit/modes/apply-patch.ts +4 -4
- package/src/edit/modes/patch.ts +15 -18
- package/src/edit/modes/replace.ts +13 -17
- package/src/edit/renderer.ts +0 -1
- package/src/eval/agent-bridge.ts +11 -13
- package/src/eval/completion-bridge.ts +25 -17
- package/src/eval/js/context-manager.ts +17 -2
- package/src/eval/js/index.ts +1 -1
- package/src/eval/py/executor.ts +2 -2
- package/src/eval/py/runner.py +44 -0
- package/src/extensibility/custom-commands/loader.ts +5 -3
- package/src/extensibility/custom-commands/types.ts +6 -3
- package/src/extensibility/custom-tools/loader.ts +4 -2
- package/src/extensibility/custom-tools/types.ts +8 -5
- package/src/extensibility/extensions/loader.ts +4 -2
- package/src/extensibility/extensions/runner.ts +20 -2
- package/src/extensibility/extensions/types.ts +22 -8
- package/src/extensibility/hooks/loader.ts +5 -2
- package/src/extensibility/hooks/types.ts +7 -4
- package/src/extensibility/legacy-pi-ai-shim.ts +42 -5
- package/src/extensibility/legacy-pi-coding-agent-shim.ts +113 -0
- package/src/extensibility/plugins/legacy-pi-compat.ts +13 -13
- package/src/extensibility/shared-events.ts +24 -0
- package/src/extensibility/tool-proxy.ts +4 -1
- package/src/extensibility/typebox.ts +778 -251
- package/src/goals/guided-setup.ts +12 -3
- package/src/goals/tools/goal-tool.ts +6 -6
- package/src/index.ts +2 -0
- package/src/internal-urls/docs-index.generated.ts +15 -13
- package/src/lsp/types.ts +13 -27
- package/src/main.ts +29 -21
- package/src/mcp/client.ts +38 -13
- package/src/mcp/render.ts +102 -89
- package/src/modes/components/agent-hub.ts +11 -4
- package/src/modes/components/branch-summary-message.ts +1 -0
- package/src/modes/components/btw-panel.ts +5 -1
- package/src/modes/components/collab-prompt-message.ts +9 -7
- package/src/modes/components/compaction-summary-message.ts +1 -0
- package/src/modes/components/custom-editor.ts +18 -0
- package/src/modes/components/custom-message.ts +1 -0
- package/src/modes/components/footer.ts +6 -5
- package/src/modes/components/hook-message.ts +1 -0
- package/src/modes/components/read-tool-group.ts +9 -3
- package/src/modes/components/skill-message.ts +1 -0
- package/src/modes/components/status-line/component.ts +139 -15
- package/src/modes/components/status-line/context-thresholds.ts +0 -1
- package/src/modes/components/todo-reminder.ts +1 -0
- package/src/modes/components/tool-execution.ts +17 -10
- package/src/modes/components/ttsr-notification.ts +1 -0
- package/src/modes/components/user-message.ts +6 -6
- package/src/modes/controllers/btw-controller.ts +69 -1
- package/src/modes/controllers/event-controller.ts +2 -7
- package/src/modes/controllers/input-controller.ts +29 -0
- package/src/modes/controllers/selector-controller.ts +10 -3
- package/src/modes/interactive-mode.ts +42 -10
- package/src/modes/rpc/rpc-types.ts +1 -1
- package/src/modes/setup-wizard/index.ts +1 -0
- package/src/modes/setup-wizard/scenes/sign-in.ts +77 -5
- package/src/modes/setup-wizard/startup-splash.ts +107 -0
- package/src/modes/theme/theme.ts +133 -143
- package/src/modes/types.ts +3 -0
- package/src/modes/utils/context-usage.ts +37 -20
- package/src/modes/utils/hotkeys-markdown.ts +1 -0
- package/src/prompts/system/system-prompt.md +1 -0
- package/src/prompts/tools/image-attachment-describe-system.md +8 -0
- package/src/prompts/tools/image-attachment-describe.md +10 -0
- package/src/sdk.ts +35 -22
- package/src/session/agent-session.ts +715 -255
- package/src/session/session-history-format.ts +11 -2
- package/src/session/session-loader.ts +19 -32
- package/src/session/session-persistence.ts +27 -11
- package/src/session/snapcompact-inline.ts +1 -1
- package/src/slash-commands/builtin-registry.ts +4 -11
- package/src/ssh/connection-manager.ts +3 -2
- package/src/startup-splash.ts +19 -0
- package/src/task/executor.ts +12 -7
- package/src/task/types.ts +44 -41
- package/src/tool-discovery/tool-index.ts +17 -4
- package/src/tools/ask.ts +14 -14
- package/src/tools/ast-edit.ts +17 -14
- package/src/tools/ast-grep.ts +10 -9
- package/src/tools/bash.ts +15 -10
- package/src/tools/browser/launch.ts +13 -0
- package/src/tools/browser.ts +26 -32
- package/src/tools/checkpoint.ts +7 -7
- package/src/tools/debug.ts +72 -69
- package/src/tools/eval.ts +18 -19
- package/src/tools/find.ts +20 -13
- package/src/tools/gh.ts +29 -49
- package/src/tools/image-gen.ts +94 -57
- package/src/tools/inspect-image.ts +8 -9
- package/src/tools/irc.ts +12 -12
- package/src/tools/job.ts +6 -6
- package/src/tools/learn.ts +11 -14
- package/src/tools/manage-skill.ts +19 -23
- package/src/tools/memory-edit.ts +8 -8
- package/src/tools/memory-recall.ts +4 -4
- package/src/tools/memory-reflect.ts +5 -5
- package/src/tools/memory-retain.ts +9 -11
- package/src/tools/puppeteer/02_stealth_hairline.txt +1 -1
- package/src/tools/puppeteer/04_stealth_iframe.txt +4 -4
- package/src/tools/puppeteer/05_stealth_webgl.txt +1 -1
- package/src/tools/puppeteer/10_stealth_plugins.txt +6 -4
- package/src/tools/puppeteer/12_stealth_codecs.txt +2 -2
- package/src/tools/puppeteer/13_stealth_worker.txt +1 -1
- package/src/tools/read.ts +197 -19
- package/src/tools/report-tool-issue.ts +6 -6
- package/src/tools/resolve.ts +6 -6
- package/src/tools/review.ts +10 -12
- package/src/tools/search-tool-bm25.ts +5 -5
- package/src/tools/search.ts +20 -29
- package/src/tools/ssh.ts +8 -8
- package/src/tools/todo.ts +16 -19
- package/src/tools/tts.ts +16 -15
- package/src/tools/write.ts +5 -5
- package/src/tui/code-cell.ts +44 -3
- package/src/tui/index.ts +1 -0
- package/src/tui/width-aware-text.ts +58 -0
- package/src/utils/image-vision-fallback.ts +197 -0
- package/src/utils/markit.ts +17 -2
- package/src/web/search/index.ts +21 -9
- package/src/web/search/providers/base.ts +1 -0
- package/src/web/search/providers/gemini.ts +56 -18
- package/src/web/search/providers/perplexity.ts +373 -126
- package/src/web/search/types.ts +28 -48
|
@@ -0,0 +1,995 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TTSR CLI command handlers.
|
|
3
|
+
*
|
|
4
|
+
* `omp ttsr test` — feed a snippet (inline text, `--file`, or stdin) through the
|
|
5
|
+
* real TTSR matching pipeline (`TtsrManager.checkSnapshot` for regex conditions,
|
|
6
|
+
* `checkAstSnapshot` for ast-grep conditions) and report which rules would
|
|
7
|
+
* trigger. The match context (`--source`, `--tool`, `--path`) is honored so
|
|
8
|
+
* glob/AST/scope-scoped rules evaluate the same way they do in a live session.
|
|
9
|
+
*
|
|
10
|
+
* `omp ttsr list` — show every TTSR-registered rule the current project/user
|
|
11
|
+
* config would load, with its conditions, scope, and source.
|
|
12
|
+
*/
|
|
13
|
+
import * as fs from "node:fs";
|
|
14
|
+
import * as path from "node:path";
|
|
15
|
+
import { AstMatchStrictness, astMatch, FileType, type GlobMatch, glob } from "@oh-my-pi/pi-natives";
|
|
16
|
+
import { getProjectDir } from "@oh-my-pi/pi-utils/dirs";
|
|
17
|
+
import chalk from "chalk";
|
|
18
|
+
import { BUILTIN_DEFAULTS_PROVIDER_ID, type Rule, ruleCapability } from "../capability/rule";
|
|
19
|
+
import { bucketRules } from "../capability/rule-buckets";
|
|
20
|
+
import { Settings } from "../config/settings";
|
|
21
|
+
import type { TtsrSettings } from "../config/settings-schema";
|
|
22
|
+
import { initializeWithSettings, loadCapability } from "../discovery";
|
|
23
|
+
import { buildRuleFromMarkdown, createSourceMeta } from "../discovery/helpers";
|
|
24
|
+
import type { TtsrManager } from "../export/ttsr";
|
|
25
|
+
|
|
26
|
+
export type TtsrAction = "test" | "list" | "scan";
|
|
27
|
+
|
|
28
|
+
export const TTSR_ACTIONS: TtsrAction[] = ["test", "list", "scan"];
|
|
29
|
+
export const TTSR_SOURCES: TtsrMatchSource[] = ["text", "thinking", "tool"];
|
|
30
|
+
|
|
31
|
+
export type TtsrMatchSource = "text" | "thinking" | "tool";
|
|
32
|
+
|
|
33
|
+
interface TtsrMatchContext {
|
|
34
|
+
source: TtsrMatchSource;
|
|
35
|
+
toolName?: string;
|
|
36
|
+
filePaths?: string[];
|
|
37
|
+
streamKey?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface TtsrTestArgs {
|
|
41
|
+
/** Inline snippet text. */
|
|
42
|
+
snippet?: string;
|
|
43
|
+
/** Snippet file path, or `-` for stdin. */
|
|
44
|
+
file?: string;
|
|
45
|
+
/** Path to a rule markdown file to test in isolation (skips project loading). */
|
|
46
|
+
rule?: string;
|
|
47
|
+
/** TTSR match source; when omitted, inferred from --file (tool for source files, text otherwise). */
|
|
48
|
+
source?: TtsrMatchSource;
|
|
49
|
+
/** Tool name when `source === "tool"` (e.g. "edit", "write"). */
|
|
50
|
+
tool?: string;
|
|
51
|
+
/** Candidate file path used for scope/glob matching and AST language inference. */
|
|
52
|
+
filePath?: string;
|
|
53
|
+
/** Show every evaluated rule, not just triggered ones. */
|
|
54
|
+
verbose?: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface TtsrScanArgs {
|
|
58
|
+
/** Directory to glob and scan files in. */
|
|
59
|
+
directory?: string;
|
|
60
|
+
/** Path to a rule markdown file to test in isolation (skips project loading). */
|
|
61
|
+
rule?: string;
|
|
62
|
+
/** Respect gitignore files while discovering scan candidates. Defaults to true. */
|
|
63
|
+
gitignore?: boolean;
|
|
64
|
+
/** Maximum file size to scan in bytes; 0 disables the limit. */
|
|
65
|
+
maxBytes?: number;
|
|
66
|
+
/** Show details. */
|
|
67
|
+
verbose?: boolean;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface TtsrCommandArgs {
|
|
71
|
+
action: TtsrAction;
|
|
72
|
+
test?: TtsrTestArgs;
|
|
73
|
+
scan?: TtsrScanArgs;
|
|
74
|
+
json?: boolean;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface RuleMatchDetail {
|
|
78
|
+
name: string;
|
|
79
|
+
path: string;
|
|
80
|
+
sourceProvider?: string;
|
|
81
|
+
/** Conditions that matched the snippet. */
|
|
82
|
+
matched: { regex: string[]; ast: string[] };
|
|
83
|
+
/** All conditions defined on the rule (for verbose display). */
|
|
84
|
+
defined: { regex: string[]; ast: string[] };
|
|
85
|
+
skippedAst?: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
interface TestReport {
|
|
89
|
+
source: TtsrMatchSource;
|
|
90
|
+
tool?: string;
|
|
91
|
+
filePath?: string;
|
|
92
|
+
snippetPreview: string;
|
|
93
|
+
snippetBytes: number;
|
|
94
|
+
evaluated: number;
|
|
95
|
+
triggered: RuleMatchDetail[];
|
|
96
|
+
notTriggered: RuleMatchDetail[];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const STDIN_MARKER = "-";
|
|
100
|
+
/** Extensions treated as source files for default tool-context inference. */
|
|
101
|
+
const SOURCE_FILE_EXT =
|
|
102
|
+
/^\.(ts|tsx|js|jsx|mjs|cjs|rs|py|go|java|kt|swift|c|cc|cpp|h|hpp|rb|php|lua|css|scss|html|json|ya?ml|toml|md|mdc)$/i;
|
|
103
|
+
|
|
104
|
+
const BINARY_PROBE_BYTES = 8192;
|
|
105
|
+
const DEFAULT_MAX_SCAN_BYTES = 5 * 1024 * 1024;
|
|
106
|
+
|
|
107
|
+
type ReadSkipReason = "binary" | "large" | "unreadable";
|
|
108
|
+
|
|
109
|
+
interface ScanSkipSummary {
|
|
110
|
+
binary: number;
|
|
111
|
+
large: number;
|
|
112
|
+
unreadable: number;
|
|
113
|
+
noRelevantRules: number;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
interface ScanRegexCondition {
|
|
117
|
+
pattern: string;
|
|
118
|
+
regex: RegExp;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
interface ScanScopePlan {
|
|
122
|
+
toolName?: string;
|
|
123
|
+
pathGlob?: Bun.Glob;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
interface ScanRulePlan {
|
|
127
|
+
rule: Rule;
|
|
128
|
+
globalPathGlobs?: Bun.Glob[];
|
|
129
|
+
defaultToolScope: boolean;
|
|
130
|
+
scopes: ScanScopePlan[];
|
|
131
|
+
regexConditions: ScanRegexCondition[];
|
|
132
|
+
astConditions: string[];
|
|
133
|
+
astPrefilters: RegExp[];
|
|
134
|
+
astRequiresFullScan: boolean;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
interface ScanFileCandidate {
|
|
138
|
+
path: string;
|
|
139
|
+
size?: number;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function readSnippet(opts: { snippet?: string; file?: string }): Promise<string> {
|
|
143
|
+
if (opts.file) {
|
|
144
|
+
if (opts.file === STDIN_MARKER) {
|
|
145
|
+
return await Bun.stdin.text();
|
|
146
|
+
}
|
|
147
|
+
const resolved = path.resolve(opts.file);
|
|
148
|
+
const file = Bun.file(resolved);
|
|
149
|
+
if (!(await file.exists())) {
|
|
150
|
+
throw new Error(`Snippet file not found: ${resolved}`);
|
|
151
|
+
}
|
|
152
|
+
return await file.text();
|
|
153
|
+
}
|
|
154
|
+
if (opts.snippet !== undefined) return opts.snippet;
|
|
155
|
+
if (process.stdin.isTTY === false) return await Bun.stdin.text();
|
|
156
|
+
throw new Error("No snippet provided. Pass inline text, --file <path>, or pipe via --file -.");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function previewSnippet(text: string): string {
|
|
160
|
+
const single = text.replace(/\s+/g, " ").trim();
|
|
161
|
+
return single.length > 80 ? `${single.slice(0, 77)}…` : single;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function deriveLang(filePaths: string[] | undefined): string | undefined {
|
|
165
|
+
for (const filePath of filePaths ?? []) {
|
|
166
|
+
const ext = path.extname(filePath.replaceAll("\\", "/"));
|
|
167
|
+
if (ext.length > 1) return ext.slice(1).toLowerCase();
|
|
168
|
+
}
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function regexMatches(rule: Rule, snippet: string): Promise<string[]> {
|
|
173
|
+
const out: string[] = [];
|
|
174
|
+
for (const pattern of rule.condition ?? []) {
|
|
175
|
+
try {
|
|
176
|
+
if (new RegExp(pattern).test(snippet)) out.push(pattern);
|
|
177
|
+
} catch {
|
|
178
|
+
// Invalid regex — skip; the manager already warned at registration.
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return out;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function astMatches(rule: Rule, snippet: string, lang: string): Promise<string[]> {
|
|
185
|
+
const out: string[] = [];
|
|
186
|
+
for (const pattern of rule.astCondition ?? []) {
|
|
187
|
+
try {
|
|
188
|
+
const result = await astMatch({
|
|
189
|
+
patterns: [pattern],
|
|
190
|
+
source: snippet,
|
|
191
|
+
lang,
|
|
192
|
+
strictness: AstMatchStrictness.Smart,
|
|
193
|
+
limit: 1,
|
|
194
|
+
});
|
|
195
|
+
if (result.totalMatches > 0) out.push(pattern);
|
|
196
|
+
} catch {
|
|
197
|
+
// Treat as no match (manager logs at runtime).
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return out;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Run the snippet through the manager's real match paths and collect, for each
|
|
205
|
+
* triggered rule, which of its conditions fired. Returns triggered + the full
|
|
206
|
+
* evaluated set (so callers can render not-triggered entries too).
|
|
207
|
+
*/
|
|
208
|
+
async function evaluate(
|
|
209
|
+
manager: TtsrManager,
|
|
210
|
+
rules: readonly Rule[],
|
|
211
|
+
snippet: string,
|
|
212
|
+
context: TtsrMatchContext,
|
|
213
|
+
): Promise<{ triggered: RuleMatchDetail[]; notTriggered: RuleMatchDetail[] }> {
|
|
214
|
+
const regexHit = manager.checkSnapshot(snippet, context);
|
|
215
|
+
const astHit =
|
|
216
|
+
context.source === "tool" && context.filePaths && context.filePaths.length > 0
|
|
217
|
+
? await manager.checkAstSnapshot(snippet, context)
|
|
218
|
+
: [];
|
|
219
|
+
const hitNames = new Set<string>([...regexHit, ...astHit].map(r => r.name));
|
|
220
|
+
|
|
221
|
+
const lang = deriveLang(context.filePaths);
|
|
222
|
+
const astEligible = context.source === "tool" && !!lang;
|
|
223
|
+
|
|
224
|
+
const triggered: RuleMatchDetail[] = [];
|
|
225
|
+
const notTriggered: RuleMatchDetail[] = [];
|
|
226
|
+
for (const rule of rules) {
|
|
227
|
+
const regex = await regexMatches(rule, snippet);
|
|
228
|
+
const ast = astEligible ? await astMatches(rule, snippet, lang!) : [];
|
|
229
|
+
const detail: RuleMatchDetail = {
|
|
230
|
+
name: rule.name,
|
|
231
|
+
path: rule.path,
|
|
232
|
+
sourceProvider: rule._source?.provider,
|
|
233
|
+
matched: { regex, ast },
|
|
234
|
+
defined: { regex: rule.condition ?? [], ast: rule.astCondition ?? [] },
|
|
235
|
+
};
|
|
236
|
+
if (!astEligible && (rule.astCondition ?? []).length > 0) {
|
|
237
|
+
detail.skippedAst = "astCondition requires --source tool and a --path with a file extension";
|
|
238
|
+
}
|
|
239
|
+
(hitNames.has(rule.name) ? triggered : notTriggered).push(detail);
|
|
240
|
+
}
|
|
241
|
+
return { triggered, notTriggered };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function createTtsrManager(settings?: TtsrSettings): Promise<TtsrManager> {
|
|
245
|
+
const { TtsrManager } = await import("../export/ttsr");
|
|
246
|
+
return new TtsrManager(settings);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function filterTtsrRulesForScan(
|
|
250
|
+
rules: readonly Rule[],
|
|
251
|
+
options: { builtinRules?: boolean; disabledRules?: readonly string[] } = {},
|
|
252
|
+
): Rule[] {
|
|
253
|
+
const includeBuiltin = options.builtinRules !== false;
|
|
254
|
+
const disabled = new Set<string>();
|
|
255
|
+
for (const raw of options.disabledRules ?? []) {
|
|
256
|
+
const name = raw.trim();
|
|
257
|
+
if (name.length > 0) disabled.add(name);
|
|
258
|
+
}
|
|
259
|
+
return rules.filter(rule => {
|
|
260
|
+
if (disabled.has(rule.name)) return false;
|
|
261
|
+
if (!includeBuiltin && rule._source?.provider === BUILTIN_DEFAULTS_PROVIDER_ID) return false;
|
|
262
|
+
return (rule.condition && rule.condition.length > 0) || (rule.astCondition && rule.astCondition.length > 0);
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async function loadProjectTtsrRules(cwd: string): Promise<{ rules: Rule[]; manager: TtsrManager }> {
|
|
267
|
+
const settingsInstance = await Settings.init({ cwd });
|
|
268
|
+
initializeWithSettings(settingsInstance);
|
|
269
|
+
const ttsrSettings = settingsInstance.getGroup("ttsr");
|
|
270
|
+
const manager = await createTtsrManager(ttsrSettings);
|
|
271
|
+
const result = await loadCapability<Rule>(ruleCapability.id, { cwd });
|
|
272
|
+
bucketRules(result.items, manager, {
|
|
273
|
+
builtinRules: ttsrSettings.builtinRules,
|
|
274
|
+
disabledRules: ttsrSettings.disabledRules,
|
|
275
|
+
});
|
|
276
|
+
return { rules: manager.getRules(), manager };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function loadProjectScanRules(cwd: string): Promise<Rule[]> {
|
|
280
|
+
const settingsInstance = await Settings.init({ cwd });
|
|
281
|
+
initializeWithSettings(settingsInstance);
|
|
282
|
+
const ttsrSettings = settingsInstance.getGroup("ttsr");
|
|
283
|
+
if (!ttsrSettings.enabled) {
|
|
284
|
+
return [];
|
|
285
|
+
}
|
|
286
|
+
const result = await loadCapability<Rule>(ruleCapability.id, { cwd });
|
|
287
|
+
return filterTtsrRulesForScan(result.items, {
|
|
288
|
+
builtinRules: ttsrSettings.builtinRules,
|
|
289
|
+
disabledRules: ttsrSettings.disabledRules,
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async function readIsolatedRule(rulePath: string): Promise<Rule> {
|
|
294
|
+
const resolved = path.resolve(rulePath);
|
|
295
|
+
const file = Bun.file(resolved);
|
|
296
|
+
if (!(await file.exists())) {
|
|
297
|
+
throw new Error(`Rule file not found: ${resolved}`);
|
|
298
|
+
}
|
|
299
|
+
const content = await file.text();
|
|
300
|
+
const name = path.basename(resolved).replace(/\.(md|mdc)$/, "");
|
|
301
|
+
return buildRuleFromMarkdown(name, content, resolved, createSourceMeta("ttsr-cli", resolved, "project"), {
|
|
302
|
+
ruleName: name,
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async function loadIsolatedRule(rulePath: string): Promise<{ rules: Rule[]; manager: TtsrManager }> {
|
|
307
|
+
const rule = await readIsolatedRule(rulePath);
|
|
308
|
+
const manager = await createTtsrManager({
|
|
309
|
+
enabled: true,
|
|
310
|
+
contextMode: "discard",
|
|
311
|
+
interruptMode: "always",
|
|
312
|
+
repeatMode: "once",
|
|
313
|
+
repeatGap: 10,
|
|
314
|
+
builtinRules: true,
|
|
315
|
+
disabledRules: [],
|
|
316
|
+
});
|
|
317
|
+
if (!manager.addRule(rule)) {
|
|
318
|
+
throw new Error(
|
|
319
|
+
`Rule "${rule.name}" has no usable TTSR condition. Add a \`condition\` (regex) or \`astCondition\` (ast-grep pattern) to its frontmatter.`,
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
return { rules: manager.getRules(), manager };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async function loadIsolatedScanRule(rulePath: string): Promise<Rule[]> {
|
|
326
|
+
const rule = await readIsolatedRule(rulePath);
|
|
327
|
+
return filterTtsrRulesForScan([rule]);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
async function runTest(args: TtsrTestArgs, json: boolean, cwd: string): Promise<void> {
|
|
331
|
+
if (args.source && !TTSR_SOURCES.includes(args.source)) {
|
|
332
|
+
throw new Error(`Invalid --source: ${args.source}. Expected one of: ${TTSR_SOURCES.join(", ")}`);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const snippet = await readSnippet(args);
|
|
336
|
+
|
|
337
|
+
// Infer match context: when the user points --file at a source file and
|
|
338
|
+
// doesn't pick a source, default to tool/edit with that path so tool-scoped
|
|
339
|
+
// rules (the common case, e.g. tool:edit(*.ts)) match like they would live.
|
|
340
|
+
const filePath = args.filePath ?? (args.file && args.file !== STDIN_MARKER ? path.resolve(args.file) : undefined);
|
|
341
|
+
const source: TtsrMatchSource =
|
|
342
|
+
args.source ?? (filePath && SOURCE_FILE_EXT.test(path.extname(filePath)) ? "tool" : "text");
|
|
343
|
+
const tool = args.tool ?? (source === "tool" ? "edit" : undefined);
|
|
344
|
+
|
|
345
|
+
const context: TtsrMatchContext = {
|
|
346
|
+
source,
|
|
347
|
+
toolName: tool,
|
|
348
|
+
filePaths: filePath ? [filePath] : undefined,
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
const { rules, manager } = args.rule ? await loadIsolatedRule(args.rule) : await loadProjectTtsrRules(cwd);
|
|
352
|
+
|
|
353
|
+
if (rules.length === 0) {
|
|
354
|
+
const msg = args.rule
|
|
355
|
+
? "Rule registered but produced no TTSR entry."
|
|
356
|
+
: "No TTSR rules registered for this project. Add a `condition` or `astCondition` to a rule file, then re-run.";
|
|
357
|
+
if (json) {
|
|
358
|
+
process.stdout.write(`${JSON.stringify({ error: msg })}\n`);
|
|
359
|
+
} else {
|
|
360
|
+
process.stderr.write(`${chalk.yellow(msg)}\n`);
|
|
361
|
+
}
|
|
362
|
+
process.exit(1);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const { triggered, notTriggered } = await evaluate(manager, rules, snippet, context);
|
|
366
|
+
|
|
367
|
+
const report: TestReport = {
|
|
368
|
+
source,
|
|
369
|
+
tool,
|
|
370
|
+
filePath,
|
|
371
|
+
snippetPreview: previewSnippet(snippet),
|
|
372
|
+
snippetBytes: snippet.length,
|
|
373
|
+
evaluated: rules.length,
|
|
374
|
+
triggered,
|
|
375
|
+
notTriggered,
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
if (json) {
|
|
379
|
+
process.stdout.write(`${JSON.stringify(report)}\n`);
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
renderTestReport(report, args.verbose ?? false, args.rule !== undefined);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function renderTestReport(report: TestReport, verbose: boolean, isolated: boolean): void {
|
|
387
|
+
const ctxLabel = report.source === "tool" ? `tool:${report.tool ?? "?"}` : report.source;
|
|
388
|
+
const pathLabel = report.filePath ? ` path=${report.filePath}` : "";
|
|
389
|
+
process.stdout.write(
|
|
390
|
+
`${chalk.bold("TTSR test")} — source=${chalk.cyan(ctxLabel)}${pathLabel} snippet=${chalk.dim(`${report.snippetBytes}b`)}\n`,
|
|
391
|
+
);
|
|
392
|
+
process.stdout.write(`${chalk.dim(` "${report.snippetPreview}"`)}\n\n`);
|
|
393
|
+
|
|
394
|
+
if (report.triggered.length === 0) {
|
|
395
|
+
process.stdout.write(`${chalk.red("No rules triggered.")} (evaluated ${report.evaluated})\n`);
|
|
396
|
+
} else {
|
|
397
|
+
process.stdout.write(`${chalk.green.bold(`Triggered (${report.triggered.length})`)}\n`);
|
|
398
|
+
for (const detail of report.triggered) renderRuleDetail(detail, true);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
if (verbose && report.notTriggered.length > 0) {
|
|
402
|
+
process.stdout.write(`\n${chalk.dim(`Not triggered (${report.notTriggered.length})`)}\n`);
|
|
403
|
+
for (const detail of report.notTriggered) renderRuleDetail(detail, false);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
if (isolated && report.triggered.length === 0) {
|
|
407
|
+
process.exitCode = 1;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
function renderRuleDetail(detail: RuleMatchDetail, hit: boolean): void {
|
|
411
|
+
const mark = hit ? chalk.green("✓") : chalk.red("✗");
|
|
412
|
+
const condParts: string[] = [];
|
|
413
|
+
// For triggered rules, show which conditions fired. For not-triggered
|
|
414
|
+
// rules (verbose), show the rule's full condition set so users can see
|
|
415
|
+
// what would match.
|
|
416
|
+
const regex = hit ? detail.matched.regex : detail.defined.regex;
|
|
417
|
+
const ast = hit ? detail.matched.ast : detail.defined.ast;
|
|
418
|
+
if (regex.length > 0) {
|
|
419
|
+
condParts.push(`condition: ${regex.map(c => chalk.yellow(`/${c}/`)).join(", ")}`);
|
|
420
|
+
}
|
|
421
|
+
if (ast.length > 0) {
|
|
422
|
+
condParts.push(`astCondition: ${ast.map(c => chalk.magenta(c)).join(", ")}`);
|
|
423
|
+
}
|
|
424
|
+
if (detail.skippedAst) {
|
|
425
|
+
condParts.push(chalk.dim(`astCondition: ${detail.skippedAst}`));
|
|
426
|
+
}
|
|
427
|
+
const condLabel = condParts.length > 0 ? condParts.join(" ") : chalk.dim("no active conditions");
|
|
428
|
+
const provider = detail.sourceProvider ? chalk.dim(` [${detail.sourceProvider}]`) : "";
|
|
429
|
+
process.stdout.write(` ${mark} ${chalk.bold(detail.name)} ${condLabel}${provider}\n`);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
async function runList(json: boolean, cwd: string): Promise<void> {
|
|
433
|
+
const { rules } = await loadProjectTtsrRules(cwd);
|
|
434
|
+
|
|
435
|
+
if (json) {
|
|
436
|
+
process.stdout.write(
|
|
437
|
+
`${JSON.stringify(
|
|
438
|
+
rules.map(r => ({
|
|
439
|
+
name: r.name,
|
|
440
|
+
path: r.path,
|
|
441
|
+
provider: r._source?.provider,
|
|
442
|
+
condition: r.condition ?? [],
|
|
443
|
+
astCondition: r.astCondition ?? [],
|
|
444
|
+
scope: r.scope ?? [],
|
|
445
|
+
globs: r.globs ?? [],
|
|
446
|
+
description: r.description,
|
|
447
|
+
})),
|
|
448
|
+
)}\n`,
|
|
449
|
+
);
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
if (rules.length === 0) {
|
|
454
|
+
process.stdout.write(`${chalk.yellow("No TTSR rules registered for this project.")}\n`);
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
process.stdout.write(`${chalk.bold(`TTSR rules (${rules.length})`)}\n`);
|
|
459
|
+
for (const rule of rules) {
|
|
460
|
+
const condParts: string[] = [];
|
|
461
|
+
if ((rule.condition ?? []).length > 0) condParts.push(`condition: ${rule.condition!.join(", ")}`);
|
|
462
|
+
if ((rule.astCondition ?? []).length > 0) condParts.push(`astCondition: ${rule.astCondition!.join(", ")}`);
|
|
463
|
+
if ((rule.scope ?? []).length > 0) condParts.push(`scope: ${rule.scope!.join(", ")}`);
|
|
464
|
+
if ((rule.globs ?? []).length > 0) condParts.push(`globs: ${rule.globs!.join(", ")}`);
|
|
465
|
+
const provider = rule._source?.provider ? chalk.dim(` [${rule._source.provider}]`) : "";
|
|
466
|
+
process.stdout.write(
|
|
467
|
+
` ${chalk.bold(rule.name)}${provider} ${chalk.dim(condParts.join(" ") || "no conditions")}\n`,
|
|
468
|
+
);
|
|
469
|
+
if (rule.description) process.stdout.write(`${chalk.dim(` ${rule.description}`)}\n`);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function normalizeScanPath(pathValue: string): string {
|
|
474
|
+
return pathValue.replaceAll("\\", "/");
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function isWithinDirectory(child: string, parent: string): boolean {
|
|
478
|
+
const rel = path.relative(parent, child);
|
|
479
|
+
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function matchesScanGlob(glob: Bun.Glob, filePaths: string[] | undefined): boolean {
|
|
483
|
+
if (!filePaths || filePaths.length === 0) {
|
|
484
|
+
return false;
|
|
485
|
+
}
|
|
486
|
+
for (const filePath of filePaths) {
|
|
487
|
+
const normalized = normalizeScanPath(filePath);
|
|
488
|
+
if (glob.match(normalized)) {
|
|
489
|
+
return true;
|
|
490
|
+
}
|
|
491
|
+
const slashIndex = normalized.lastIndexOf("/");
|
|
492
|
+
const basename = slashIndex === -1 ? normalized : normalized.slice(slashIndex + 1);
|
|
493
|
+
if (basename !== normalized && glob.match(basename)) {
|
|
494
|
+
return true;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
return false;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function compileScanPathGlobs(globs: Rule["globs"]): Bun.Glob[] | undefined {
|
|
501
|
+
if (!globs || globs.length === 0) {
|
|
502
|
+
return undefined;
|
|
503
|
+
}
|
|
504
|
+
const compiled = globs
|
|
505
|
+
.map(globPattern => globPattern.trim())
|
|
506
|
+
.filter(globPattern => globPattern.length > 0)
|
|
507
|
+
.map(globPattern => new Bun.Glob(globPattern));
|
|
508
|
+
return compiled.length > 0 ? compiled : undefined;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function parseScanToolScopeToken(token: string): ScanScopePlan | undefined {
|
|
512
|
+
const match = /^(?:(?<prefix>tool)(?::(?<tool>[a-z0-9_-]+))?|(?<bare>[a-z0-9_-]+))(?:\((?<path>[^)]+)\))?$/i.exec(
|
|
513
|
+
token,
|
|
514
|
+
);
|
|
515
|
+
if (!match) {
|
|
516
|
+
return undefined;
|
|
517
|
+
}
|
|
518
|
+
const groups = match.groups;
|
|
519
|
+
const hasToolPrefix = groups?.prefix !== undefined;
|
|
520
|
+
const toolName = (groups?.tool ?? (hasToolPrefix ? undefined : groups?.bare))?.trim().toLowerCase();
|
|
521
|
+
const pathPattern = groups?.path?.trim();
|
|
522
|
+
return {
|
|
523
|
+
toolName,
|
|
524
|
+
pathGlob: pathPattern ? new Bun.Glob(pathPattern) : undefined,
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function escapeRegexLiteral(value: string): string {
|
|
529
|
+
return value.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&");
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function compileAstPrefilter(pattern: string): RegExp | undefined {
|
|
533
|
+
if (/\bas\s*\{/.test(pattern)) {
|
|
534
|
+
return /\bas\b(?:\s|\/\/[^\n]*(?:\n|$)|\/\*[\s\S]*?\*\/)*\{/;
|
|
535
|
+
}
|
|
536
|
+
const ignored = new Set(["if", "as", "const", "let", "var", "return", "true", "false", "null", "undefined"]);
|
|
537
|
+
const tokens = pattern
|
|
538
|
+
.match(/\b[A-Za-z_][A-Za-z0-9_]*\b/g)
|
|
539
|
+
?.filter(token => !ignored.has(token) && !/^[A-Z_]+$/.test(token))
|
|
540
|
+
.sort((a, b) => b.length - a.length);
|
|
541
|
+
const token = tokens?.[0];
|
|
542
|
+
return token ? new RegExp(`\\b${escapeRegexLiteral(token)}\\b`) : undefined;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function compileScanRulePlans(rules: Rule[]): ScanRulePlan[] {
|
|
546
|
+
return rules.map(rule => {
|
|
547
|
+
const scopes: ScanScopePlan[] = [];
|
|
548
|
+
let defaultToolScope = !rule.scope || rule.scope.length === 0;
|
|
549
|
+
for (const rawScope of rule.scope ?? []) {
|
|
550
|
+
const token = rawScope.trim();
|
|
551
|
+
const normalizedToken = token.toLowerCase();
|
|
552
|
+
if (token.length === 0 || normalizedToken === "text" || normalizedToken === "thinking") {
|
|
553
|
+
continue;
|
|
554
|
+
}
|
|
555
|
+
if (normalizedToken === "tool" || normalizedToken === "toolcall") {
|
|
556
|
+
scopes.push({});
|
|
557
|
+
continue;
|
|
558
|
+
}
|
|
559
|
+
const scope = parseScanToolScopeToken(token);
|
|
560
|
+
if (!scope) {
|
|
561
|
+
continue;
|
|
562
|
+
}
|
|
563
|
+
if (!scope.toolName && !scope.pathGlob) {
|
|
564
|
+
defaultToolScope = true;
|
|
565
|
+
continue;
|
|
566
|
+
}
|
|
567
|
+
scopes.push(scope);
|
|
568
|
+
}
|
|
569
|
+
const regexConditions: ScanRegexCondition[] = [];
|
|
570
|
+
for (const pattern of rule.condition ?? []) {
|
|
571
|
+
try {
|
|
572
|
+
regexConditions.push({ pattern, regex: new RegExp(pattern) });
|
|
573
|
+
} catch {
|
|
574
|
+
// Same behavior as TtsrManager: invalid regex conditions are unusable.
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
const astConditions = (rule.astCondition ?? [])
|
|
578
|
+
.map(pattern => pattern.trim())
|
|
579
|
+
.filter(pattern => pattern.length > 0);
|
|
580
|
+
const astPrefilters: RegExp[] = [];
|
|
581
|
+
let astRequiresFullScan = false;
|
|
582
|
+
for (const pattern of astConditions) {
|
|
583
|
+
const prefilter = compileAstPrefilter(pattern);
|
|
584
|
+
if (prefilter) {
|
|
585
|
+
astPrefilters.push(prefilter);
|
|
586
|
+
} else {
|
|
587
|
+
astRequiresFullScan = true;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
return {
|
|
591
|
+
rule,
|
|
592
|
+
globalPathGlobs: compileScanPathGlobs(rule.globs),
|
|
593
|
+
defaultToolScope,
|
|
594
|
+
scopes,
|
|
595
|
+
regexConditions,
|
|
596
|
+
astConditions,
|
|
597
|
+
astPrefilters,
|
|
598
|
+
astRequiresFullScan,
|
|
599
|
+
};
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function scanRulePlanMatchesPath(plan: ScanRulePlan, filePaths: string[]): boolean {
|
|
604
|
+
return !plan.globalPathGlobs || plan.globalPathGlobs.some(pathGlob => matchesScanGlob(pathGlob, filePaths));
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function scanRulePlanMatchesToolScope(plan: ScanRulePlan, filePaths: string[]): boolean {
|
|
608
|
+
if (!scanRulePlanMatchesPath(plan, filePaths)) {
|
|
609
|
+
return false;
|
|
610
|
+
}
|
|
611
|
+
if (plan.defaultToolScope) {
|
|
612
|
+
return true;
|
|
613
|
+
}
|
|
614
|
+
for (const scope of plan.scopes) {
|
|
615
|
+
if (scope.pathGlob && !matchesScanGlob(scope.pathGlob, filePaths)) {
|
|
616
|
+
continue;
|
|
617
|
+
}
|
|
618
|
+
if (!scope.toolName || scope.toolName === "edit" || scope.toolName === "write") {
|
|
619
|
+
return true;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
return false;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function scanRulePlanMayMatchAst(plan: ScanRulePlan, fileContent: string): boolean {
|
|
626
|
+
return (
|
|
627
|
+
plan.astConditions.length > 0 &&
|
|
628
|
+
(plan.astRequiresFullScan || plan.astPrefilters.some(prefilter => prefilter.test(fileContent)))
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
async function scanRulePlanMatchesContent(
|
|
633
|
+
plan: ScanRulePlan,
|
|
634
|
+
fileContent: string,
|
|
635
|
+
lang: string | undefined,
|
|
636
|
+
includeDetails: boolean,
|
|
637
|
+
): Promise<RuleMatchDetail | undefined> {
|
|
638
|
+
let regexHit = false;
|
|
639
|
+
const matchedRegex: string[] = [];
|
|
640
|
+
for (const condition of plan.regexConditions) {
|
|
641
|
+
condition.regex.lastIndex = 0;
|
|
642
|
+
if (condition.regex.test(fileContent)) {
|
|
643
|
+
regexHit = true;
|
|
644
|
+
if (includeDetails) {
|
|
645
|
+
matchedRegex.push(condition.pattern);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
const matchedAst: string[] = [];
|
|
651
|
+
let astHit = false;
|
|
652
|
+
if ((includeDetails || !regexHit) && lang && plan.astConditions.length > 0) {
|
|
653
|
+
if (includeDetails) {
|
|
654
|
+
matchedAst.push(...(await astMatches(plan.rule, fileContent, lang)));
|
|
655
|
+
astHit = matchedAst.length > 0;
|
|
656
|
+
} else {
|
|
657
|
+
try {
|
|
658
|
+
const result = await astMatch({
|
|
659
|
+
patterns: plan.astConditions,
|
|
660
|
+
source: fileContent,
|
|
661
|
+
lang,
|
|
662
|
+
strictness: AstMatchStrictness.Smart,
|
|
663
|
+
limit: 1,
|
|
664
|
+
});
|
|
665
|
+
astHit = result.matches.length > 0;
|
|
666
|
+
} catch {
|
|
667
|
+
astHit = false;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
if (!regexHit && !astHit) {
|
|
673
|
+
return undefined;
|
|
674
|
+
}
|
|
675
|
+
return {
|
|
676
|
+
name: plan.rule.name,
|
|
677
|
+
path: plan.rule.path,
|
|
678
|
+
sourceProvider: plan.rule._source?.provider,
|
|
679
|
+
matched: { regex: matchedRegex, ast: matchedAst },
|
|
680
|
+
defined: { regex: plan.rule.condition ?? [], ast: plan.rule.astCondition ?? [] },
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
async function scanAnyAstConditionMatches(
|
|
685
|
+
plans: ScanRulePlan[],
|
|
686
|
+
fileContent: string,
|
|
687
|
+
lang: string | undefined,
|
|
688
|
+
): Promise<boolean | undefined> {
|
|
689
|
+
if (!lang) {
|
|
690
|
+
return false;
|
|
691
|
+
}
|
|
692
|
+
const patterns = plans.flatMap(plan => plan.astConditions);
|
|
693
|
+
if (patterns.length === 0) {
|
|
694
|
+
return false;
|
|
695
|
+
}
|
|
696
|
+
try {
|
|
697
|
+
const result = await astMatch({
|
|
698
|
+
patterns,
|
|
699
|
+
source: fileContent,
|
|
700
|
+
lang,
|
|
701
|
+
strictness: AstMatchStrictness.Smart,
|
|
702
|
+
limit: 1,
|
|
703
|
+
});
|
|
704
|
+
if (result.matches.length > 0) {
|
|
705
|
+
return true;
|
|
706
|
+
}
|
|
707
|
+
return result.parseErrors && result.parseErrors.length > 0 ? undefined : false;
|
|
708
|
+
} catch {
|
|
709
|
+
return undefined;
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
async function discoverScanFiles(scanDir: string, cwd: string, gitignore: boolean): Promise<ScanFileCandidate[]> {
|
|
714
|
+
const globRoot = isWithinDirectory(scanDir, cwd) ? cwd : scanDir;
|
|
715
|
+
const relativeScanDir = normalizeScanPath(path.relative(globRoot, scanDir));
|
|
716
|
+
const pattern = relativeScanDir === "" ? "**/*" : `${relativeScanDir}/**/*`;
|
|
717
|
+
try {
|
|
718
|
+
const result = await glob({
|
|
719
|
+
pattern,
|
|
720
|
+
path: globRoot,
|
|
721
|
+
gitignore,
|
|
722
|
+
hidden: true,
|
|
723
|
+
fileType: FileType.File,
|
|
724
|
+
});
|
|
725
|
+
const candidates: ScanFileCandidate[] = [];
|
|
726
|
+
for (const match of result.matches as GlobMatch[]) {
|
|
727
|
+
const absPath = path.resolve(globRoot, match.path);
|
|
728
|
+
const filePath = normalizeScanPath(path.relative(scanDir, absPath));
|
|
729
|
+
if (
|
|
730
|
+
filePath.length === 0 ||
|
|
731
|
+
filePath.startsWith("..") ||
|
|
732
|
+
path.isAbsolute(filePath) ||
|
|
733
|
+
filePath === ".git" ||
|
|
734
|
+
filePath.startsWith(".git/")
|
|
735
|
+
) {
|
|
736
|
+
continue;
|
|
737
|
+
}
|
|
738
|
+
candidates.push({ path: filePath, size: match.size });
|
|
739
|
+
}
|
|
740
|
+
candidates.sort((a, b) => a.path.localeCompare(b.path));
|
|
741
|
+
return candidates;
|
|
742
|
+
} catch {
|
|
743
|
+
return [];
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
async function readScanFileText(
|
|
748
|
+
absPath: string,
|
|
749
|
+
maxBytes: number,
|
|
750
|
+
knownSize: number | undefined,
|
|
751
|
+
): Promise<{ content: string } | { skip: ReadSkipReason }> {
|
|
752
|
+
const file = Bun.file(absPath);
|
|
753
|
+
try {
|
|
754
|
+
const fileSize = knownSize ?? file.size;
|
|
755
|
+
if (maxBytes > 0 && fileSize > maxBytes) {
|
|
756
|
+
return { skip: "large" };
|
|
757
|
+
}
|
|
758
|
+
const probeBytes = Math.min(fileSize, BINARY_PROBE_BYTES);
|
|
759
|
+
if (probeBytes > 0) {
|
|
760
|
+
const prefix = new Uint8Array(await file.slice(0, probeBytes).arrayBuffer());
|
|
761
|
+
if (prefix.includes(0)) {
|
|
762
|
+
return { skip: "binary" };
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
return { content: await file.text() };
|
|
766
|
+
} catch {
|
|
767
|
+
return { skip: "unreadable" };
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
function countSkipped(skipped: ScanSkipSummary): number {
|
|
772
|
+
return skipped.binary + skipped.large + skipped.unreadable + skipped.noRelevantRules;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
async function runScan(args: TtsrScanArgs, json: boolean, cwd: string): Promise<void> {
|
|
776
|
+
const scanDir = args.directory ? path.resolve(cwd, args.directory) : cwd;
|
|
777
|
+
if (!fs.existsSync(scanDir)) {
|
|
778
|
+
if (json) {
|
|
779
|
+
process.stdout.write(`${JSON.stringify({ error: `Directory not found: ${scanDir}` })}\n`);
|
|
780
|
+
} else {
|
|
781
|
+
process.stderr.write(`${chalk.red(`error: scan directory not found: ${scanDir}`)}\n`);
|
|
782
|
+
}
|
|
783
|
+
process.exit(1);
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
const rules = args.rule ? await loadIsolatedScanRule(args.rule) : await loadProjectScanRules(cwd);
|
|
787
|
+
|
|
788
|
+
if (rules.length === 0) {
|
|
789
|
+
const msg = args.rule
|
|
790
|
+
? "Rule registered but produced no TTSR entry."
|
|
791
|
+
: "No TTSR rules registered for this project.";
|
|
792
|
+
if (json) {
|
|
793
|
+
process.stdout.write(`${JSON.stringify({ error: msg })}\n`);
|
|
794
|
+
} else {
|
|
795
|
+
process.stderr.write(`${chalk.yellow(msg)}\n`);
|
|
796
|
+
}
|
|
797
|
+
process.exit(1);
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
const scanRulePlans = compileScanRulePlans(rules).filter(
|
|
801
|
+
plan => plan.regexConditions.length > 0 || plan.astConditions.length > 0,
|
|
802
|
+
);
|
|
803
|
+
if (scanRulePlans.length === 0) {
|
|
804
|
+
const msg = args.rule
|
|
805
|
+
? "Rule registered but produced no usable TTSR condition."
|
|
806
|
+
: "No usable TTSR rules registered for this project.";
|
|
807
|
+
if (json) {
|
|
808
|
+
process.stdout.write(`${JSON.stringify({ error: msg })}\n`);
|
|
809
|
+
} else {
|
|
810
|
+
process.stderr.write(`${chalk.yellow(msg)}\n`);
|
|
811
|
+
}
|
|
812
|
+
process.exit(1);
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
const gitignore = args.gitignore ?? true;
|
|
816
|
+
const maxBytes = Math.max(0, args.maxBytes ?? DEFAULT_MAX_SCAN_BYTES);
|
|
817
|
+
const includeDetails = json || (args.verbose ?? false);
|
|
818
|
+
const files = await discoverScanFiles(scanDir, cwd, gitignore);
|
|
819
|
+
const emptySkipped: ScanSkipSummary = { binary: 0, large: 0, unreadable: 0, noRelevantRules: 0 };
|
|
820
|
+
if (files.length === 0) {
|
|
821
|
+
const msg = `No files found to scan in ${scanDir}`;
|
|
822
|
+
if (json) {
|
|
823
|
+
process.stdout.write(
|
|
824
|
+
`${JSON.stringify({
|
|
825
|
+
files: [],
|
|
826
|
+
summary: {
|
|
827
|
+
totalFiles: 0,
|
|
828
|
+
scannedFiles: 0,
|
|
829
|
+
matchedFiles: 0,
|
|
830
|
+
totalMatches: 0,
|
|
831
|
+
evaluatedRules: scanRulePlans.length,
|
|
832
|
+
skippedFiles: 0,
|
|
833
|
+
skipped: emptySkipped,
|
|
834
|
+
gitignore,
|
|
835
|
+
maxBytes,
|
|
836
|
+
},
|
|
837
|
+
})}\n`,
|
|
838
|
+
);
|
|
839
|
+
} else {
|
|
840
|
+
process.stdout.write(`${chalk.yellow(msg)}\n`);
|
|
841
|
+
}
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
const fileResults: Array<{ file: string; matches: RuleMatchDetail[] }> = [];
|
|
846
|
+
const skipped: ScanSkipSummary = { binary: 0, large: 0, unreadable: 0, noRelevantRules: 0 };
|
|
847
|
+
let scannedFiles = 0;
|
|
848
|
+
let matchedFiles = 0;
|
|
849
|
+
let totalMatches = 0;
|
|
850
|
+
|
|
851
|
+
for (const candidate of files) {
|
|
852
|
+
const file = candidate.path;
|
|
853
|
+
const absPath = path.resolve(scanDir, file);
|
|
854
|
+
const relToProj = path.relative(cwd, absPath).replaceAll("\\", "/");
|
|
855
|
+
const basename = path.basename(absPath);
|
|
856
|
+
const filePaths = [absPath.replaceAll("\\", "/"), relToProj, basename];
|
|
857
|
+
const relevantPlans = scanRulePlans.filter(plan => scanRulePlanMatchesToolScope(plan, filePaths));
|
|
858
|
+
if (relevantPlans.length === 0) {
|
|
859
|
+
skipped.noRelevantRules++;
|
|
860
|
+
continue;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
const readResult = await readScanFileText(absPath, maxBytes, candidate.size);
|
|
864
|
+
if ("skip" in readResult) {
|
|
865
|
+
skipped[readResult.skip]++;
|
|
866
|
+
continue;
|
|
867
|
+
}
|
|
868
|
+
const fileContent = readResult.content;
|
|
869
|
+
const lang = deriveLang(filePaths);
|
|
870
|
+
scannedFiles++;
|
|
871
|
+
|
|
872
|
+
const fileTriggeredDetails: RuleMatchDetail[] = [];
|
|
873
|
+
let fileMatchCount = 0;
|
|
874
|
+
const pendingAstPlans: ScanRulePlan[] = [];
|
|
875
|
+
for (const plan of relevantPlans) {
|
|
876
|
+
const detail = includeDetails
|
|
877
|
+
? await scanRulePlanMatchesContent(plan, fileContent, lang, true)
|
|
878
|
+
: await scanRulePlanMatchesContent(plan, fileContent, undefined, false);
|
|
879
|
+
if (detail) {
|
|
880
|
+
fileMatchCount++;
|
|
881
|
+
if (includeDetails) {
|
|
882
|
+
fileTriggeredDetails.push(detail);
|
|
883
|
+
}
|
|
884
|
+
continue;
|
|
885
|
+
}
|
|
886
|
+
if (!includeDetails && scanRulePlanMayMatchAst(plan, fileContent)) {
|
|
887
|
+
pendingAstPlans.push(plan);
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
if (!includeDetails && (await scanAnyAstConditionMatches(pendingAstPlans, fileContent, lang)) !== false) {
|
|
891
|
+
for (const plan of pendingAstPlans) {
|
|
892
|
+
const detail = await scanRulePlanMatchesContent(plan, fileContent, lang, false);
|
|
893
|
+
if (!detail) {
|
|
894
|
+
continue;
|
|
895
|
+
}
|
|
896
|
+
fileMatchCount++;
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
if (fileMatchCount > 0) {
|
|
901
|
+
matchedFiles++;
|
|
902
|
+
totalMatches += fileMatchCount;
|
|
903
|
+
if (includeDetails) {
|
|
904
|
+
fileResults.push({
|
|
905
|
+
file: relToProj,
|
|
906
|
+
matches: fileTriggeredDetails,
|
|
907
|
+
});
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
if (json) {
|
|
913
|
+
process.stdout.write(
|
|
914
|
+
`${JSON.stringify({
|
|
915
|
+
files: fileResults.map(fr => ({
|
|
916
|
+
filePath: fr.file,
|
|
917
|
+
matches: fr.matches.map(m => ({
|
|
918
|
+
name: m.name,
|
|
919
|
+
path: m.path,
|
|
920
|
+
matched: m.matched,
|
|
921
|
+
})),
|
|
922
|
+
})),
|
|
923
|
+
summary: {
|
|
924
|
+
totalFiles: files.length,
|
|
925
|
+
scannedFiles,
|
|
926
|
+
matchedFiles,
|
|
927
|
+
totalMatches,
|
|
928
|
+
evaluatedRules: scanRulePlans.length,
|
|
929
|
+
skippedFiles: countSkipped(skipped),
|
|
930
|
+
skipped,
|
|
931
|
+
gitignore,
|
|
932
|
+
maxBytes,
|
|
933
|
+
},
|
|
934
|
+
})}\n`,
|
|
935
|
+
);
|
|
936
|
+
} else {
|
|
937
|
+
process.stdout.write(
|
|
938
|
+
`${chalk.bold("TTSR scan")} — directory=${chalk.cyan(scanDir)} files=${chalk.dim(files.length)} scanned=${chalk.dim(scannedFiles)} rules=${chalk.dim(scanRulePlans.length)} gitignore=${chalk.dim(gitignore ? "on" : "off")} max-bytes=${chalk.dim(maxBytes === 0 ? "off" : String(maxBytes))}\n`,
|
|
939
|
+
);
|
|
940
|
+
if (countSkipped(skipped) > 0) {
|
|
941
|
+
process.stdout.write(
|
|
942
|
+
`${chalk.dim(` skipped: binary=${skipped.binary} large=${skipped.large} unreadable=${skipped.unreadable} no-relevant-rules=${skipped.noRelevantRules}`)}\n`,
|
|
943
|
+
);
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
if (matchedFiles === 0) {
|
|
947
|
+
process.stdout.write(
|
|
948
|
+
`${chalk.green.bold("No rule matches found.")} (evaluated ${rules.length} rules on ${scannedFiles}/${files.length} files)\n`,
|
|
949
|
+
);
|
|
950
|
+
} else {
|
|
951
|
+
process.stdout.write(
|
|
952
|
+
`${chalk.red.bold("Found violations/matches:")} (${totalMatches} matches across ${matchedFiles} files)\n`,
|
|
953
|
+
);
|
|
954
|
+
if (!includeDetails) {
|
|
955
|
+
process.stdout.write(`${chalk.dim(" rerun with --verbose to list matched files and conditions")}\n`);
|
|
956
|
+
return;
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
process.stdout.write("\n");
|
|
960
|
+
for (const fr of fileResults) {
|
|
961
|
+
process.stdout.write(`${chalk.bold.underline(fr.file)}\n`);
|
|
962
|
+
for (const detail of fr.matches) {
|
|
963
|
+
renderRuleDetail(detail, true);
|
|
964
|
+
}
|
|
965
|
+
process.stdout.write("\n");
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
export async function runTtsrCommand(cmd: TtsrCommandArgs): Promise<void> {
|
|
972
|
+
const cwd = getProjectDir();
|
|
973
|
+
if (cmd.action === "test") {
|
|
974
|
+
if (!cmd.test) {
|
|
975
|
+
process.stderr.write(`${chalk.red("error: `ttsr test` requires a snippet, --file, or piped stdin")}\n`);
|
|
976
|
+
process.exit(1);
|
|
977
|
+
}
|
|
978
|
+
await runTest(cmd.test, cmd.json ?? false, cwd);
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
981
|
+
if (cmd.action === "list") {
|
|
982
|
+
await runList(cmd.json ?? false, cwd);
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
985
|
+
if (cmd.action === "scan") {
|
|
986
|
+
if (!cmd.scan) {
|
|
987
|
+
process.stderr.write(`${chalk.red("error: scan arguments missing")}\n`);
|
|
988
|
+
process.exit(1);
|
|
989
|
+
}
|
|
990
|
+
await runScan(cmd.scan, cmd.json ?? false, cwd);
|
|
991
|
+
return;
|
|
992
|
+
}
|
|
993
|
+
process.stderr.write(`${chalk.red(`error: unknown ttsr action: ${cmd.action}`)}\n`);
|
|
994
|
+
process.exit(1);
|
|
995
|
+
}
|