opencode-effect-enforcer 0.2.0
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/LICENSE +21 -0
- package/README.md +278 -0
- package/guidance/effect-first-development.md +1247 -0
- package/guidance/post__effect-and-the-near-inexpressible-majesty-of-layers.md +490 -0
- package/guidance/post__parse-dont-validate.md +109 -0
- package/guidance/progressive-disclosure-guidance.md +38 -0
- package/package.json +63 -0
- package/patterns/avoid-any.md +37 -0
- package/patterns/avoid-data-tagged-error.md +34 -0
- package/patterns/avoid-direct-json.md +51 -0
- package/patterns/avoid-direct-tag-checks.md +54 -0
- package/patterns/avoid-expect-in-if.md +52 -0
- package/patterns/avoid-mutable-state.md +70 -0
- package/patterns/avoid-native-fetch.md +61 -0
- package/patterns/avoid-node-imports.md +86 -0
- package/patterns/avoid-non-null-assertion.md +44 -0
- package/patterns/avoid-object-type.md +46 -0
- package/patterns/avoid-option-getorthrow.md +39 -0
- package/patterns/avoid-platform-coupling.md +43 -0
- package/patterns/avoid-process-env.md +43 -0
- package/patterns/avoid-react-hooks.md +73 -0
- package/patterns/avoid-schema-suffix.md +45 -0
- package/patterns/avoid-sync-fs.md +68 -0
- package/patterns/avoid-try-catch.md +47 -0
- package/patterns/avoid-ts-ignore.md +38 -0
- package/patterns/avoid-untagged-errors.md +67 -0
- package/patterns/avoid-yield-ref.md +46 -0
- package/patterns/casting-awareness.md +46 -0
- package/patterns/context-tag-extends.md +84 -0
- package/patterns/effect-catchall-default.md +61 -0
- package/patterns/effect-promise-vs-trypromise.md +47 -0
- package/patterns/effect-run-in-body.md +58 -0
- package/patterns/imperative-loops.md +76 -0
- package/patterns/prefer-arr-sort.md +52 -0
- package/patterns/prefer-duration-values.md +56 -0
- package/patterns/prefer-effect-fn.md +161 -0
- package/patterns/prefer-match-over-switch.md +48 -0
- package/patterns/prefer-option-over-null.md +56 -0
- package/patterns/prefer-redacted-config.md +70 -0
- package/patterns/prefer-schema-class.md +54 -0
- package/patterns/require-effect-concurrency.md +83 -0
- package/patterns/stream-large-files.md +63 -0
- package/patterns/throw-in-effect-gen.md +62 -0
- package/patterns/use-clock-service.md +45 -0
- package/patterns/use-command-executor-service.md +54 -0
- package/patterns/use-console-service.md +54 -0
- package/patterns/use-filesystem-service.md +59 -0
- package/patterns/use-http-client-service.md +77 -0
- package/patterns/use-path-service.md +53 -0
- package/patterns/use-random-service.md +45 -0
- package/patterns/use-temp-file-scoped.md +66 -0
- package/patterns/vm-in-wrong-file.md +51 -0
- package/patterns/yield-in-for-loop.md +61 -0
- package/skills/effect-ai-chat/SKILL.md +472 -0
- package/skills/effect-ai-language-model/SKILL.md +652 -0
- package/skills/effect-ai-prompt/SKILL.md +752 -0
- package/skills/effect-ai-provider/SKILL.md +668 -0
- package/skills/effect-ai-streaming/SKILL.md +418 -0
- package/skills/effect-ai-tool/SKILL.md +1132 -0
- package/skills/effect-atom-rpc/SKILL.md +488 -0
- package/skills/effect-atom-state/SKILL.md +640 -0
- package/skills/effect-batching/SKILL.md +614 -0
- package/skills/effect-cache/SKILL.md +570 -0
- package/skills/effect-cli/SKILL.md +523 -0
- package/skills/effect-command-executor/SKILL.md +675 -0
- package/skills/effect-concurrency-testing/SKILL.md +612 -0
- package/skills/effect-config/SKILL.md +580 -0
- package/skills/effect-context-witness/SKILL.md +274 -0
- package/skills/effect-domain-modeling/SKILL.md +1212 -0
- package/skills/effect-domain-predicates/SKILL.md +867 -0
- package/skills/effect-error-handling/SKILL.md +1581 -0
- package/skills/effect-fiber/SKILL.md +731 -0
- package/skills/effect-filesystem/SKILL.md +624 -0
- package/skills/effect-graph/SKILL.md +571 -0
- package/skills/effect-http-api/SKILL.md +1760 -0
- package/skills/effect-http-client/SKILL.md +989 -0
- package/skills/effect-http-server/SKILL.md +920 -0
- package/skills/effect-incremental-migration/SKILL.md +362 -0
- package/skills/effect-layer-design/SKILL.md +642 -0
- package/skills/effect-managed-runtime/SKILL.md +395 -0
- package/skills/effect-mcp-server/SKILL.md +608 -0
- package/skills/effect-observability/SKILL.md +719 -0
- package/skills/effect-optics/SKILL.md +554 -0
- package/skills/effect-parallelization/SKILL.md +668 -0
- package/skills/effect-path/SKILL.md +296 -0
- package/skills/effect-pattern-matching/SKILL.md +914 -0
- package/skills/effect-platform-abstraction/SKILL.md +1175 -0
- package/skills/effect-platform-layers/SKILL.md +514 -0
- package/skills/effect-pubsub-event-bus/SKILL.md +384 -0
- package/skills/effect-react-composition/SKILL.md +986 -0
- package/skills/effect-react-vm/SKILL.md +675 -0
- package/skills/effect-rpc-api/SKILL.md +624 -0
- package/skills/effect-rpc-client/SKILL.md +666 -0
- package/skills/effect-rpc-cluster/SKILL.md +1623 -0
- package/skills/effect-rpc-server/SKILL.md +767 -0
- package/skills/effect-scheduling/SKILL.md +124 -0
- package/skills/effect-schema-composition/SKILL.md +975 -0
- package/skills/effect-schema-v4/SKILL.md +691 -0
- package/skills/effect-scope/SKILL.md +682 -0
- package/skills/effect-service-implementation/SKILL.md +656 -0
- package/skills/effect-socket/SKILL.md +703 -0
- package/skills/effect-sql/SKILL.md +781 -0
- package/skills/effect-stream/SKILL.md +765 -0
- package/skills/effect-testing/SKILL.md +1331 -0
- package/skills/effect-typeclass-design/SKILL.md +161 -0
- package/skills/effect-wide-events/Article.md +66 -0
- package/skills/effect-wide-events/SKILL.md +95 -0
- package/skills/effect-workflow/SKILL.md +810 -0
- package/src/agent-policy.ts +22 -0
- package/src/enforcer.ts +104 -0
- package/src/frontmatter.ts +34 -0
- package/src/guidance.ts +66 -0
- package/src/index.ts +38 -0
- package/src/pattern-catalog.ts +115 -0
- package/src/pattern-matcher.ts +178 -0
- package/src/pattern.ts +97 -0
- package/src/skills.ts +29 -0
- package/src/write-projection.ts +66 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
const REQUEST_KEY = "opencode-effect-enforcer"
|
|
2
|
+
|
|
3
|
+
interface AgentPolicyTarget {
|
|
4
|
+
readonly request: {
|
|
5
|
+
readonly body: Record<string, unknown>
|
|
6
|
+
}
|
|
7
|
+
readonly permissions: Array<{
|
|
8
|
+
readonly action: string
|
|
9
|
+
readonly resource: string
|
|
10
|
+
readonly effect: "allow" | "ask" | "deny"
|
|
11
|
+
}>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Consume an agent's Effect Enforcer setting and apply its skill opt-out. */
|
|
15
|
+
export const consumeAgentOptOut = (agent: AgentPolicyTarget): boolean => {
|
|
16
|
+
const disabled = agent.request.body[REQUEST_KEY] === false
|
|
17
|
+
delete agent.request.body[REQUEST_KEY]
|
|
18
|
+
if (!disabled) return false
|
|
19
|
+
|
|
20
|
+
agent.permissions.push({ action: "skill", resource: "effect-*", effect: "deny" })
|
|
21
|
+
return true
|
|
22
|
+
}
|
package/src/enforcer.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { Plugin } from "@opencode-ai/plugin"
|
|
2
|
+
|
|
3
|
+
import { buildPatternFeedback } from "./guidance.ts"
|
|
4
|
+
import { matchesPattern } from "./pattern-matcher.ts"
|
|
5
|
+
import { Pattern } from "./pattern.ts"
|
|
6
|
+
import { actualProjection, type FileSnapshot, snapshotFile } from "./write-projection.ts"
|
|
7
|
+
|
|
8
|
+
interface PendingWrite {
|
|
9
|
+
readonly files: ReadonlyArray<FileSnapshot>
|
|
10
|
+
readonly fullFile: boolean
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const property = (value: unknown, key: string): unknown =>
|
|
14
|
+
typeof value === "object" && value !== null ? Reflect.get(value, key) : undefined
|
|
15
|
+
|
|
16
|
+
const stringProperty = (value: unknown, key: string): string | undefined => {
|
|
17
|
+
const current = property(value, key)
|
|
18
|
+
return typeof current === "string" ? current : undefined
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const patchPaths = (patchText: string): ReadonlyArray<string> => {
|
|
22
|
+
const paths: Array<string> = []
|
|
23
|
+
for (const line of patchText.split("\n")) {
|
|
24
|
+
const match = /^\*\*\* (?:Add|Update) File: (.+)$/.exec(line)
|
|
25
|
+
if (match?.[1] !== undefined) paths.push(match[1])
|
|
26
|
+
const move = /^\*\*\* Move to: (.+)$/.exec(line)
|
|
27
|
+
if (move?.[1] !== undefined) paths.push(move[1])
|
|
28
|
+
}
|
|
29
|
+
return [...new Set(paths)]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const inputPaths = (tool: string, input: unknown): ReadonlyArray<string> => {
|
|
33
|
+
if (tool === "write" || tool === "edit") {
|
|
34
|
+
const filePath = stringProperty(input, "filePath") ?? stringProperty(input, "path")
|
|
35
|
+
return filePath === undefined ? [] : [filePath]
|
|
36
|
+
}
|
|
37
|
+
if (tool === "apply_patch" || tool === "patch") {
|
|
38
|
+
const patchText = stringProperty(input, "patchText")
|
|
39
|
+
return patchText === undefined ? [] : patchPaths(patchText)
|
|
40
|
+
}
|
|
41
|
+
return []
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Register prospective snapshots and post-write Effect pattern feedback hooks. */
|
|
45
|
+
export const registerPatternEnforcement = async (
|
|
46
|
+
context: Plugin.Context,
|
|
47
|
+
patterns: ReadonlyArray<Pattern.Value>,
|
|
48
|
+
isEnabled: (agent: unknown) => boolean = () => true
|
|
49
|
+
): Promise<void> => {
|
|
50
|
+
const pending = new Map<string, PendingWrite>()
|
|
51
|
+
|
|
52
|
+
await context.tool.hook("execute.before", async (event) => {
|
|
53
|
+
try {
|
|
54
|
+
if (!isEnabled(event.agent)) {
|
|
55
|
+
pending.delete(event.id)
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
const paths = inputPaths(event.tool, event.input)
|
|
59
|
+
if (paths.length === 0) return
|
|
60
|
+
const session = await context.session.get({ sessionID: event.sessionID })
|
|
61
|
+
pending.set(event.id, {
|
|
62
|
+
files: await Promise.all(
|
|
63
|
+
paths.map((filePath) => snapshotFile(session.location.directory, filePath))
|
|
64
|
+
),
|
|
65
|
+
fullFile: event.tool === "write"
|
|
66
|
+
})
|
|
67
|
+
} catch {
|
|
68
|
+
pending.delete(event.id)
|
|
69
|
+
}
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
await context.tool.hook("execute.after", async (event) => {
|
|
73
|
+
const write = pending.get(event.id)
|
|
74
|
+
pending.delete(event.id)
|
|
75
|
+
if (!isEnabled(event.agent) || event.status !== "completed" || write === undefined) return
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
const feedback = (await Promise.all(write.files.map(async (file) => {
|
|
79
|
+
const projection = await actualProjection(file)
|
|
80
|
+
if (projection === undefined || projection.changedSpans?.length === 0) return undefined
|
|
81
|
+
const input = write.fullFile
|
|
82
|
+
? { ...projection, changedSpans: [{ start: 0, end: projection.content.length }] }
|
|
83
|
+
: projection
|
|
84
|
+
const matched = patterns.filter((pattern) =>
|
|
85
|
+
matchesPattern("edit", input, "after", pattern)
|
|
86
|
+
)
|
|
87
|
+
return matched.length === 0 ? undefined : buildPatternFeedback(matched, file.filePath)
|
|
88
|
+
}))).filter((message): message is string => message !== undefined)
|
|
89
|
+
if (feedback.length === 0) return
|
|
90
|
+
const message = feedback.join("\n\n---\n\n")
|
|
91
|
+
const content = event.result.content
|
|
92
|
+
event.result = {
|
|
93
|
+
...event.result,
|
|
94
|
+
content: Array.isArray(content)
|
|
95
|
+
? [...content, { type: "text", text: message }]
|
|
96
|
+
: typeof content === "string"
|
|
97
|
+
? `${content}\n\n${message}`
|
|
98
|
+
: message
|
|
99
|
+
}
|
|
100
|
+
} catch {
|
|
101
|
+
// Pattern feedback must never turn an advisory check into a failed write.
|
|
102
|
+
}
|
|
103
|
+
})
|
|
104
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import YAML from "yaml"
|
|
2
|
+
|
|
3
|
+
const FRONTMATTER_RE = /^---\n([\s\S]*?)\n---/
|
|
4
|
+
const SAFE_VALUE_RE = /^[\w\s.\-/]+$/
|
|
5
|
+
|
|
6
|
+
const isAlreadyQuoted = (value: string): boolean =>
|
|
7
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
8
|
+
|| (value.startsWith('"') && value.endsWith('"'))
|
|
9
|
+
|
|
10
|
+
const quoteYamlValue = (line: string): string => {
|
|
11
|
+
const match = line.match(/^(\s*)(\w[\w-]*):\s+(.+)$/)
|
|
12
|
+
if (match === null) return line
|
|
13
|
+
const [, indent, key, value] = match
|
|
14
|
+
if (indent === undefined || key === undefined || value === undefined) return line
|
|
15
|
+
if (isAlreadyQuoted(value) || SAFE_VALUE_RE.test(value)) return line
|
|
16
|
+
const escaped = value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')
|
|
17
|
+
return `${indent}${key}: "${escaped}"`
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Parse YAML frontmatter, including unquoted detector expressions used by the pattern catalog. */
|
|
21
|
+
export const parseFrontmatter = (content: string): Record<string, unknown> => {
|
|
22
|
+
const match = content.match(FRONTMATTER_RE)
|
|
23
|
+
if (match?.[1] === undefined) return {}
|
|
24
|
+
try {
|
|
25
|
+
const parsed: unknown = YAML.parse(match[1].split("\n").map(quoteYamlValue).join("\n"))
|
|
26
|
+
return typeof parsed === "object" && parsed !== null ? { ...parsed } : {}
|
|
27
|
+
} catch {
|
|
28
|
+
return {}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Remove YAML frontmatter and return the trimmed Markdown body. */
|
|
33
|
+
export const extractBody = (content: string): string =>
|
|
34
|
+
content.replace(/^---\n[\s\S]*?\n---\n?/, "").trim()
|
package/src/guidance.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { readdir, readFile } from "node:fs/promises"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
|
|
4
|
+
import { Pattern } from "./pattern.ts"
|
|
5
|
+
|
|
6
|
+
const LEVEL_ORDER: Record<Pattern.Value["level"], number> = {
|
|
7
|
+
critical: 0,
|
|
8
|
+
high: 1,
|
|
9
|
+
medium: 2,
|
|
10
|
+
warning: 3,
|
|
11
|
+
info: 4
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Load bundled guidance documents in deterministic filename order. */
|
|
15
|
+
export const loadGuidance = async (guidanceDirectory: string): Promise<string> => {
|
|
16
|
+
const entries = (await readdir(guidanceDirectory)).filter((entry) => entry.endsWith(".md"))
|
|
17
|
+
.toSorted()
|
|
18
|
+
const documents = (await Promise.all(
|
|
19
|
+
entries.map((entry) => readFile(path.join(guidanceDirectory, entry), "utf8"))
|
|
20
|
+
))
|
|
21
|
+
.map((content) => content.trim())
|
|
22
|
+
.filter((content) => content.length > 0)
|
|
23
|
+
return documents.join("\n\n---\n\n")
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const guidanceWithSkillHints = (pattern: Pattern.Value): string => {
|
|
27
|
+
const hints =
|
|
28
|
+
pattern.suggestedSkills?.map((skill) =>
|
|
29
|
+
`If you have not loaded the \`${skill}\` skill, load it before continuing.`
|
|
30
|
+
) ?? []
|
|
31
|
+
return hints.length === 0 ? pattern.guidance : `${pattern.guidance}\n\n${hints.join("\n")}`
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Compose one severity-ordered, model-visible review request for a completed write. */
|
|
35
|
+
export const buildPatternFeedback = (
|
|
36
|
+
patterns: ReadonlyArray<Pattern.Value>,
|
|
37
|
+
filePath: string
|
|
38
|
+
): string => {
|
|
39
|
+
const selected = [...patterns].toSorted((left, right) =>
|
|
40
|
+
LEVEL_ORDER[left.level] - LEVEL_ORDER[right.level]
|
|
41
|
+
)
|
|
42
|
+
return [
|
|
43
|
+
"opencode-effect-enforcer review request:",
|
|
44
|
+
`File: \`${filePath}\``,
|
|
45
|
+
"",
|
|
46
|
+
"I noticed potential Effect-pattern issues in the write you just completed.",
|
|
47
|
+
"Inspect this change now. If a warning is valid, revise the code before continuing.",
|
|
48
|
+
"If it is a false positive or intentional exception, briefly say so and continue.",
|
|
49
|
+
"",
|
|
50
|
+
"Matched patterns:",
|
|
51
|
+
...selected.map((pattern) => `- ${pattern.name} [${pattern.level}]: ${pattern.description}`),
|
|
52
|
+
"",
|
|
53
|
+
"Relevant guidance:",
|
|
54
|
+
...selected.map((pattern) => `## ${pattern.name}\n${guidanceWithSkillHints(pattern)}`)
|
|
55
|
+
].join("\n")
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Add OpenCode-specific operating instructions to the copied Effect guidance. */
|
|
59
|
+
export const withPluginPolicy = (guidance: string): string =>
|
|
60
|
+
[
|
|
61
|
+
guidance,
|
|
62
|
+
"OpenCode Effect Enforcer policy:",
|
|
63
|
+
"- The bundled effect-* skills are available through OpenCode's skill tool. Load at least four relevant skills before planning or writing Effect code.",
|
|
64
|
+
"- Treat post-write pattern feedback as an immediate review request. Fix valid findings before continuing.",
|
|
65
|
+
"- Pattern findings are advisory: explain intentional exceptions or false positives instead of changing correct code."
|
|
66
|
+
].join("\n\n---\n\n")
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { Plugin } from "@opencode-ai/plugin"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
import { fileURLToPath } from "node:url"
|
|
4
|
+
|
|
5
|
+
import { consumeAgentOptOut } from "./agent-policy.ts"
|
|
6
|
+
import { registerPatternEnforcement } from "./enforcer.ts"
|
|
7
|
+
import { loadGuidance, withPluginPolicy } from "./guidance.ts"
|
|
8
|
+
import { loadPatterns } from "./pattern-catalog.ts"
|
|
9
|
+
import { loadSkills } from "./skills.ts"
|
|
10
|
+
|
|
11
|
+
const packageDirectory = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
|
|
12
|
+
|
|
13
|
+
export default Plugin.define({
|
|
14
|
+
id: "opencode.effect-enforcer",
|
|
15
|
+
setup: async (context) => {
|
|
16
|
+
const disabledAgents = new Set<unknown>()
|
|
17
|
+
const [guidance, patterns, skills] = await Promise.all([
|
|
18
|
+
loadGuidance(path.join(packageDirectory, "guidance")),
|
|
19
|
+
loadPatterns(path.join(packageDirectory, "patterns")),
|
|
20
|
+
loadSkills(path.join(packageDirectory, "skills"))
|
|
21
|
+
])
|
|
22
|
+
|
|
23
|
+
await context.agent.transform((draft) => {
|
|
24
|
+
disabledAgents.clear()
|
|
25
|
+
for (const agent of draft.list()) {
|
|
26
|
+
if (consumeAgentOptOut(agent)) disabledAgents.add(agent.id)
|
|
27
|
+
}
|
|
28
|
+
})
|
|
29
|
+
await context.skill.transform((draft) => {
|
|
30
|
+
for (const skill of skills) draft.add(skill)
|
|
31
|
+
})
|
|
32
|
+
await context.session.hook("context", (event) => {
|
|
33
|
+
if (disabledAgents.has(event.agent)) return
|
|
34
|
+
event.system.push({ type: "text", text: withPluginPolicy(guidance) })
|
|
35
|
+
})
|
|
36
|
+
await registerPatternEnforcement(context, patterns, (agent) => !disabledAgents.has(agent))
|
|
37
|
+
}
|
|
38
|
+
})
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import type { Rule as AstGrepRuleDefinition } from "@ast-grep/napi"
|
|
2
|
+
import { readdir, readFile, stat } from "node:fs/promises"
|
|
3
|
+
import path from "node:path"
|
|
4
|
+
|
|
5
|
+
import { extractBody, parseFrontmatter } from "./frontmatter.ts"
|
|
6
|
+
import { Pattern, type PatternLevel } from "./pattern.ts"
|
|
7
|
+
|
|
8
|
+
const SKIPPED_FILES = ["CLAUDE", "AGENTS", "GEMINI", "README"]
|
|
9
|
+
const LEVELS: ReadonlySet<string> = new Set(["critical", "high", "medium", "warning", "info"])
|
|
10
|
+
|
|
11
|
+
const string = (value: unknown): string | undefined => typeof value === "string" ? value : undefined
|
|
12
|
+
const strings = (value: unknown): ReadonlyArray<string> | undefined =>
|
|
13
|
+
Array.isArray(value) && value.every((item) => typeof item === "string") ? value : undefined
|
|
14
|
+
const astRule = (value: unknown): value is AstGrepRuleDefinition =>
|
|
15
|
+
typeof value === "object" && value !== null && !Array.isArray(value)
|
|
16
|
+
const astRules = (value: unknown): ReadonlyArray<AstGrepRuleDefinition> | undefined => {
|
|
17
|
+
if (astRule(value)) return [value]
|
|
18
|
+
return Array.isArray(value) && value.length > 0 && value.every(astRule) ? value : undefined
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const astConstraints = (value: unknown): Record<string, AstGrepRuleDefinition> | undefined => {
|
|
22
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined
|
|
23
|
+
const entries = Object.entries(value)
|
|
24
|
+
return entries.every(([, rule]) => astRule(rule))
|
|
25
|
+
? Object.fromEntries(entries) as Record<string, AstGrepRuleDefinition>
|
|
26
|
+
: undefined
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const detector = (
|
|
30
|
+
raw: Record<string, unknown>
|
|
31
|
+
): Pattern.RegexDetector | Pattern.AstDetector | undefined => {
|
|
32
|
+
if (raw.detector === "ast") {
|
|
33
|
+
const rules = astRules(raw.rule) ?? astRules(raw.rules)
|
|
34
|
+
const constraints = astConstraints(raw.constraints)
|
|
35
|
+
if (rules !== undefined) return new Pattern.AstDetector({ patterns: [], rules, constraints })
|
|
36
|
+
const patterns = typeof raw.pattern === "string" ? [raw.pattern] : strings(raw.pattern)
|
|
37
|
+
if (patterns === undefined || patterns.length === 0) return undefined
|
|
38
|
+
const inside = string(raw.inside)
|
|
39
|
+
return new Pattern.AstDetector({ patterns, ...(inside === undefined ? {} : { inside }) })
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const expression = string(raw.pattern)
|
|
43
|
+
if (expression === undefined) return undefined
|
|
44
|
+
try {
|
|
45
|
+
new RegExp(expression)
|
|
46
|
+
} catch {
|
|
47
|
+
return undefined
|
|
48
|
+
}
|
|
49
|
+
return new Pattern.RegexDetector({
|
|
50
|
+
pattern: expression,
|
|
51
|
+
matchInComments: raw.matchInComments === true || raw.matchInComments === "true"
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const toPattern = (filePath: string, content: string): Pattern.Value | undefined => {
|
|
56
|
+
const raw = parseFrontmatter(content)
|
|
57
|
+
const name = string(raw.name)
|
|
58
|
+
const selectedDetector = detector(raw)
|
|
59
|
+
const toolRegex = string(raw.tool) ?? ".*"
|
|
60
|
+
if (name === undefined || selectedDetector === undefined) return undefined
|
|
61
|
+
try {
|
|
62
|
+
new RegExp(toolRegex)
|
|
63
|
+
} catch {
|
|
64
|
+
return undefined
|
|
65
|
+
}
|
|
66
|
+
const rawLevel = string(raw.level) ?? "info"
|
|
67
|
+
return new Pattern.Value({
|
|
68
|
+
name,
|
|
69
|
+
description: string(raw.description) ?? "",
|
|
70
|
+
event: string(raw.event)?.toLowerCase() === "after" ? "after" : "before",
|
|
71
|
+
toolRegex,
|
|
72
|
+
level: (LEVELS.has(rawLevel) ? rawLevel : "info") as PatternLevel,
|
|
73
|
+
glob: string(raw.glob),
|
|
74
|
+
ignoreGlob: strings(raw.ignoreGlob),
|
|
75
|
+
detector: selectedDetector,
|
|
76
|
+
guidance: extractBody(content),
|
|
77
|
+
suggestedSkills: strings(raw.suggestSkills),
|
|
78
|
+
sourcePath: filePath
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const isSkipped = (name: string): boolean =>
|
|
83
|
+
SKIPPED_FILES.some((prefix) =>
|
|
84
|
+
name.startsWith(prefix) || name.toLowerCase() === `${prefix.toLowerCase()}.md`
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
/** Recursively load valid Markdown pattern definitions in deterministic path order. */
|
|
88
|
+
export const loadPatterns = async (
|
|
89
|
+
patternsDirectory: string
|
|
90
|
+
): Promise<ReadonlyArray<Pattern.Value>> => {
|
|
91
|
+
const walk = async (directory: string): Promise<ReadonlyArray<Pattern.Value>> => {
|
|
92
|
+
let entries: ReadonlyArray<string>
|
|
93
|
+
try {
|
|
94
|
+
entries = await readdir(directory)
|
|
95
|
+
} catch {
|
|
96
|
+
return []
|
|
97
|
+
}
|
|
98
|
+
const nested = await Promise.all(entries.map(async (entry) => {
|
|
99
|
+
const target = path.join(directory, entry)
|
|
100
|
+
try {
|
|
101
|
+
const info = await stat(target)
|
|
102
|
+
if (info.isDirectory()) return walk(target)
|
|
103
|
+
if (!info.isFile() || !entry.endsWith(".md") || isSkipped(entry)) return []
|
|
104
|
+
const pattern = toPattern(target, await readFile(target, "utf8"))
|
|
105
|
+
return pattern === undefined ? [] : [pattern]
|
|
106
|
+
} catch {
|
|
107
|
+
return []
|
|
108
|
+
}
|
|
109
|
+
}))
|
|
110
|
+
return nested.flat()
|
|
111
|
+
}
|
|
112
|
+
return (await walk(patternsDirectory)).toSorted((left, right) =>
|
|
113
|
+
left.sourcePath.localeCompare(right.sourcePath)
|
|
114
|
+
)
|
|
115
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { Lang, type NapiConfig, parse, type Rule as AstGrepRuleDefinition } from "@ast-grep/napi"
|
|
2
|
+
import picomatch from "picomatch"
|
|
3
|
+
|
|
4
|
+
import { type MatcherInput, Pattern, type Span } from "./pattern.ts"
|
|
5
|
+
|
|
6
|
+
/** Blank JavaScript comments while preserving source offsets, strings, and line breaks. */
|
|
7
|
+
export const stripComments = (source: string): string => {
|
|
8
|
+
const output: Array<string> = []
|
|
9
|
+
let index = 0
|
|
10
|
+
const at = (current: number) => source.charAt(current)
|
|
11
|
+
const keep = () => {
|
|
12
|
+
output.push(at(index))
|
|
13
|
+
index += 1
|
|
14
|
+
}
|
|
15
|
+
const blank = () => {
|
|
16
|
+
output.push(at(index) === "\n" ? "\n" : " ")
|
|
17
|
+
index += 1
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
while (index < source.length) {
|
|
21
|
+
const current = at(index)
|
|
22
|
+
const next = at(index + 1)
|
|
23
|
+
if (current === "'" || current === '"') {
|
|
24
|
+
const quote = current
|
|
25
|
+
keep()
|
|
26
|
+
while (index < source.length && at(index) !== quote) {
|
|
27
|
+
if (at(index) === "\\" && index + 1 < source.length) keep()
|
|
28
|
+
keep()
|
|
29
|
+
}
|
|
30
|
+
if (index < source.length) keep()
|
|
31
|
+
continue
|
|
32
|
+
}
|
|
33
|
+
if (current === "`") {
|
|
34
|
+
keep()
|
|
35
|
+
while (index < source.length && at(index) !== "`") {
|
|
36
|
+
if (at(index) === "\\" && index + 1 < source.length) keep()
|
|
37
|
+
keep()
|
|
38
|
+
}
|
|
39
|
+
if (index < source.length) keep()
|
|
40
|
+
continue
|
|
41
|
+
}
|
|
42
|
+
if (current === "/" && next === "/") {
|
|
43
|
+
blank()
|
|
44
|
+
blank()
|
|
45
|
+
while (index < source.length && at(index) !== "\n") blank()
|
|
46
|
+
continue
|
|
47
|
+
}
|
|
48
|
+
if (current === "/" && next === "*") {
|
|
49
|
+
blank()
|
|
50
|
+
blank()
|
|
51
|
+
while (index < source.length) {
|
|
52
|
+
if (at(index) === "*" && at(index + 1) === "/") {
|
|
53
|
+
blank()
|
|
54
|
+
blank()
|
|
55
|
+
break
|
|
56
|
+
}
|
|
57
|
+
blank()
|
|
58
|
+
}
|
|
59
|
+
continue
|
|
60
|
+
}
|
|
61
|
+
keep()
|
|
62
|
+
}
|
|
63
|
+
return output.join("")
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const location = (source: string, start: number, end: number): Pattern.MatchLocation => {
|
|
67
|
+
const before = source.slice(0, start)
|
|
68
|
+
const previousBreak = before.lastIndexOf("\n")
|
|
69
|
+
return new Pattern.MatchLocation({
|
|
70
|
+
start,
|
|
71
|
+
end,
|
|
72
|
+
line: before.split("\n").length,
|
|
73
|
+
column: start - (previousBreak === -1 ? 0 : previousBreak + 1) + 1,
|
|
74
|
+
snippet: (source.slice(start, end).split("\n")[0] ?? "").trim()
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const language = (filePath: string): Lang | undefined => {
|
|
79
|
+
if (filePath.endsWith(".tsx") || filePath.endsWith(".jsx")) return Lang.Tsx
|
|
80
|
+
if (filePath.endsWith(".ts")) return Lang.TypeScript
|
|
81
|
+
if (filePath.endsWith(".js")) return Lang.JavaScript
|
|
82
|
+
return undefined
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const astLocations = (
|
|
86
|
+
detector: Pattern.AstDetector,
|
|
87
|
+
source: string,
|
|
88
|
+
filePath: string | undefined
|
|
89
|
+
): ReadonlyArray<Pattern.MatchLocation> => {
|
|
90
|
+
if (filePath === undefined) return []
|
|
91
|
+
const lang = language(filePath)
|
|
92
|
+
if (lang === undefined) return []
|
|
93
|
+
try {
|
|
94
|
+
const root = parse(lang, source).root()
|
|
95
|
+
const find = (matcher: string | NapiConfig) =>
|
|
96
|
+
root.findAll(matcher).map((node) =>
|
|
97
|
+
location(source, node.range().start.index, node.range().end.index)
|
|
98
|
+
)
|
|
99
|
+
const legacy = detector.patterns.flatMap((candidate) =>
|
|
100
|
+
find(
|
|
101
|
+
detector.inside === undefined ? candidate : {
|
|
102
|
+
rule: { pattern: candidate, inside: { pattern: detector.inside, stopBy: "end" } }
|
|
103
|
+
}
|
|
104
|
+
)
|
|
105
|
+
)
|
|
106
|
+
const rules = (detector.rules ?? []).flatMap((rule: AstGrepRuleDefinition) =>
|
|
107
|
+
find(
|
|
108
|
+
detector.constraints === undefined
|
|
109
|
+
? { rule }
|
|
110
|
+
: { rule, constraints: detector.constraints }
|
|
111
|
+
)
|
|
112
|
+
)
|
|
113
|
+
return [...legacy, ...rules]
|
|
114
|
+
} catch {
|
|
115
|
+
return []
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const regexLocations = (
|
|
120
|
+
detector: Pattern.RegexDetector,
|
|
121
|
+
source: string
|
|
122
|
+
): ReadonlyArray<Pattern.MatchLocation> => {
|
|
123
|
+
const matchSource = detector.matchInComments ? source : stripComments(source)
|
|
124
|
+
try {
|
|
125
|
+
const regex = new RegExp(detector.pattern, "g")
|
|
126
|
+
return [...matchSource.matchAll(regex)].flatMap((match) =>
|
|
127
|
+
match.index === undefined || match[0].length === 0
|
|
128
|
+
? []
|
|
129
|
+
: [location(source, match.index, match.index + match[0].length)]
|
|
130
|
+
)
|
|
131
|
+
} catch {
|
|
132
|
+
return []
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const intersects = (left: Span, right: Span): boolean =>
|
|
137
|
+
left.start < right.end && right.start < left.end
|
|
138
|
+
|
|
139
|
+
/** Find detector matches that satisfy event, tool, path, and changed-span constraints. */
|
|
140
|
+
export const findPatternMatches = (
|
|
141
|
+
toolName: string,
|
|
142
|
+
input: MatcherInput,
|
|
143
|
+
event: "before" | "after",
|
|
144
|
+
pattern: Pattern.Value
|
|
145
|
+
): ReadonlyArray<Pattern.MatchLocation> => {
|
|
146
|
+
try {
|
|
147
|
+
if (pattern.event !== event || !new RegExp(pattern.toolRegex).test(toolName)) return []
|
|
148
|
+
if (
|
|
149
|
+
pattern.glob !== undefined
|
|
150
|
+
&& (input.filePath === undefined || !picomatch(pattern.glob)(input.filePath))
|
|
151
|
+
) return []
|
|
152
|
+
const filePath = input.filePath
|
|
153
|
+
if (
|
|
154
|
+
filePath !== undefined
|
|
155
|
+
&& pattern.ignoreGlob?.some((glob) => picomatch(glob)(filePath)) === true
|
|
156
|
+
) {
|
|
157
|
+
return []
|
|
158
|
+
}
|
|
159
|
+
} catch {
|
|
160
|
+
return []
|
|
161
|
+
}
|
|
162
|
+
const matches = pattern.detector instanceof Pattern.AstDetector
|
|
163
|
+
? astLocations(pattern.detector, input.content, input.filePath)
|
|
164
|
+
: regexLocations(pattern.detector, input.content)
|
|
165
|
+
return input.changedSpans === undefined
|
|
166
|
+
? matches
|
|
167
|
+
: matches.filter((match) =>
|
|
168
|
+
input.changedSpans?.some((span) => intersects(match, span)) === true
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Determine whether a pattern matches normalized input. */
|
|
173
|
+
export const matchesPattern = (
|
|
174
|
+
toolName: string,
|
|
175
|
+
input: MatcherInput,
|
|
176
|
+
event: "before" | "after",
|
|
177
|
+
pattern: Pattern.Value
|
|
178
|
+
): boolean => findPatternMatches(toolName, input, event, pattern).length > 0
|
package/src/pattern.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { NapiConfig, Rule as AstGrepRuleDefinition } from "@ast-grep/napi"
|
|
2
|
+
|
|
3
|
+
/** Pattern severity controls feedback ordering. */
|
|
4
|
+
export type PatternLevel = "critical" | "high" | "medium" | "warning" | "info"
|
|
5
|
+
|
|
6
|
+
/** A half-open source span. */
|
|
7
|
+
export interface Span {
|
|
8
|
+
readonly start: number
|
|
9
|
+
readonly end: number
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Normalized content passed to pattern detectors. */
|
|
13
|
+
export interface MatcherInput {
|
|
14
|
+
readonly filePath?: string
|
|
15
|
+
readonly content: string
|
|
16
|
+
readonly changedSpans?: ReadonlyArray<Span>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Pattern definitions loaded from the bundled Markdown catalog. */
|
|
20
|
+
export namespace Pattern {
|
|
21
|
+
/** A source location matched by a detector. */
|
|
22
|
+
export class MatchLocation implements Span {
|
|
23
|
+
readonly start: number
|
|
24
|
+
readonly end: number
|
|
25
|
+
readonly line: number
|
|
26
|
+
readonly column: number
|
|
27
|
+
readonly snippet: string
|
|
28
|
+
|
|
29
|
+
constructor(fields: MatchLocation) {
|
|
30
|
+
this.start = fields.start
|
|
31
|
+
this.end = fields.end
|
|
32
|
+
this.line = fields.line
|
|
33
|
+
this.column = fields.column
|
|
34
|
+
this.snippet = fields.snippet
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A regular-expression detector. */
|
|
39
|
+
export class RegexDetector {
|
|
40
|
+
readonly pattern: string
|
|
41
|
+
readonly matchInComments: boolean
|
|
42
|
+
|
|
43
|
+
constructor(fields: RegexDetector) {
|
|
44
|
+
this.pattern = fields.pattern
|
|
45
|
+
this.matchInComments = fields.matchInComments
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** An ast-grep detector supporting legacy patterns and complete rules. */
|
|
50
|
+
export class AstDetector {
|
|
51
|
+
readonly patterns: ReadonlyArray<string>
|
|
52
|
+
readonly rules: ReadonlyArray<AstGrepRuleDefinition> | undefined
|
|
53
|
+
readonly constraints: NapiConfig["constraints"] | undefined
|
|
54
|
+
readonly inside: string | undefined
|
|
55
|
+
|
|
56
|
+
constructor(fields: {
|
|
57
|
+
readonly patterns: ReadonlyArray<string>
|
|
58
|
+
readonly rules?: ReadonlyArray<AstGrepRuleDefinition>
|
|
59
|
+
readonly constraints?: NapiConfig["constraints"]
|
|
60
|
+
readonly inside?: string
|
|
61
|
+
}) {
|
|
62
|
+
this.patterns = fields.patterns
|
|
63
|
+
this.rules = fields.rules
|
|
64
|
+
this.constraints = fields.constraints
|
|
65
|
+
this.inside = fields.inside
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** A complete enforcement pattern and its model-facing remediation guidance. */
|
|
70
|
+
export class Value {
|
|
71
|
+
readonly name: string
|
|
72
|
+
readonly description: string
|
|
73
|
+
readonly event: "before" | "after"
|
|
74
|
+
readonly toolRegex: string
|
|
75
|
+
readonly level: PatternLevel
|
|
76
|
+
readonly glob: string | undefined
|
|
77
|
+
readonly ignoreGlob: ReadonlyArray<string> | undefined
|
|
78
|
+
readonly detector: RegexDetector | AstDetector
|
|
79
|
+
readonly guidance: string
|
|
80
|
+
readonly suggestedSkills: ReadonlyArray<string> | undefined
|
|
81
|
+
readonly sourcePath: string
|
|
82
|
+
|
|
83
|
+
constructor(fields: Value) {
|
|
84
|
+
this.name = fields.name
|
|
85
|
+
this.description = fields.description
|
|
86
|
+
this.event = fields.event
|
|
87
|
+
this.toolRegex = fields.toolRegex
|
|
88
|
+
this.level = fields.level
|
|
89
|
+
this.glob = fields.glob
|
|
90
|
+
this.ignoreGlob = fields.ignoreGlob
|
|
91
|
+
this.detector = fields.detector
|
|
92
|
+
this.guidance = fields.guidance
|
|
93
|
+
this.suggestedSkills = fields.suggestedSkills
|
|
94
|
+
this.sourcePath = fields.sourcePath
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
package/src/skills.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { Skill } from "@opencode-ai/plugin"
|
|
2
|
+
import { readdir, readFile } from "node:fs/promises"
|
|
3
|
+
import path from "node:path"
|
|
4
|
+
|
|
5
|
+
import { extractBody, parseFrontmatter } from "./frontmatter.ts"
|
|
6
|
+
|
|
7
|
+
/** Load bundled directory skills as OpenCode V2 skill definitions. */
|
|
8
|
+
export const loadSkills = async (skillsDirectory: string): Promise<ReadonlyArray<Skill.Info>> => {
|
|
9
|
+
const entries = await readdir(skillsDirectory, { withFileTypes: true })
|
|
10
|
+
const skills = await Promise.all(
|
|
11
|
+
entries.filter((entry) => entry.isDirectory()).map(async (entry) => {
|
|
12
|
+
const location = path.join(skillsDirectory, entry.name, "SKILL.md")
|
|
13
|
+
const markdown = await readFile(location, "utf8")
|
|
14
|
+
const frontmatter = parseFrontmatter(markdown)
|
|
15
|
+
const displayName = typeof frontmatter.name === "string" ? frontmatter.name : entry.name
|
|
16
|
+
const description = typeof frontmatter.description === "string"
|
|
17
|
+
? frontmatter.description
|
|
18
|
+
: undefined
|
|
19
|
+
return Skill.Info.make({
|
|
20
|
+
id: Skill.ID.make(entry.name),
|
|
21
|
+
name: Skill.Name.make(displayName),
|
|
22
|
+
description,
|
|
23
|
+
location: Skill.Info.fields.location.make(location),
|
|
24
|
+
content: extractBody(markdown)
|
|
25
|
+
})
|
|
26
|
+
})
|
|
27
|
+
)
|
|
28
|
+
return skills.toSorted((left, right) => left.id.localeCompare(right.id))
|
|
29
|
+
}
|