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.
Files changed (118) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +278 -0
  3. package/guidance/effect-first-development.md +1247 -0
  4. package/guidance/post__effect-and-the-near-inexpressible-majesty-of-layers.md +490 -0
  5. package/guidance/post__parse-dont-validate.md +109 -0
  6. package/guidance/progressive-disclosure-guidance.md +38 -0
  7. package/package.json +63 -0
  8. package/patterns/avoid-any.md +37 -0
  9. package/patterns/avoid-data-tagged-error.md +34 -0
  10. package/patterns/avoid-direct-json.md +51 -0
  11. package/patterns/avoid-direct-tag-checks.md +54 -0
  12. package/patterns/avoid-expect-in-if.md +52 -0
  13. package/patterns/avoid-mutable-state.md +70 -0
  14. package/patterns/avoid-native-fetch.md +61 -0
  15. package/patterns/avoid-node-imports.md +86 -0
  16. package/patterns/avoid-non-null-assertion.md +44 -0
  17. package/patterns/avoid-object-type.md +46 -0
  18. package/patterns/avoid-option-getorthrow.md +39 -0
  19. package/patterns/avoid-platform-coupling.md +43 -0
  20. package/patterns/avoid-process-env.md +43 -0
  21. package/patterns/avoid-react-hooks.md +73 -0
  22. package/patterns/avoid-schema-suffix.md +45 -0
  23. package/patterns/avoid-sync-fs.md +68 -0
  24. package/patterns/avoid-try-catch.md +47 -0
  25. package/patterns/avoid-ts-ignore.md +38 -0
  26. package/patterns/avoid-untagged-errors.md +67 -0
  27. package/patterns/avoid-yield-ref.md +46 -0
  28. package/patterns/casting-awareness.md +46 -0
  29. package/patterns/context-tag-extends.md +84 -0
  30. package/patterns/effect-catchall-default.md +61 -0
  31. package/patterns/effect-promise-vs-trypromise.md +47 -0
  32. package/patterns/effect-run-in-body.md +58 -0
  33. package/patterns/imperative-loops.md +76 -0
  34. package/patterns/prefer-arr-sort.md +52 -0
  35. package/patterns/prefer-duration-values.md +56 -0
  36. package/patterns/prefer-effect-fn.md +161 -0
  37. package/patterns/prefer-match-over-switch.md +48 -0
  38. package/patterns/prefer-option-over-null.md +56 -0
  39. package/patterns/prefer-redacted-config.md +70 -0
  40. package/patterns/prefer-schema-class.md +54 -0
  41. package/patterns/require-effect-concurrency.md +83 -0
  42. package/patterns/stream-large-files.md +63 -0
  43. package/patterns/throw-in-effect-gen.md +62 -0
  44. package/patterns/use-clock-service.md +45 -0
  45. package/patterns/use-command-executor-service.md +54 -0
  46. package/patterns/use-console-service.md +54 -0
  47. package/patterns/use-filesystem-service.md +59 -0
  48. package/patterns/use-http-client-service.md +77 -0
  49. package/patterns/use-path-service.md +53 -0
  50. package/patterns/use-random-service.md +45 -0
  51. package/patterns/use-temp-file-scoped.md +66 -0
  52. package/patterns/vm-in-wrong-file.md +51 -0
  53. package/patterns/yield-in-for-loop.md +61 -0
  54. package/skills/effect-ai-chat/SKILL.md +472 -0
  55. package/skills/effect-ai-language-model/SKILL.md +652 -0
  56. package/skills/effect-ai-prompt/SKILL.md +752 -0
  57. package/skills/effect-ai-provider/SKILL.md +668 -0
  58. package/skills/effect-ai-streaming/SKILL.md +418 -0
  59. package/skills/effect-ai-tool/SKILL.md +1132 -0
  60. package/skills/effect-atom-rpc/SKILL.md +488 -0
  61. package/skills/effect-atom-state/SKILL.md +640 -0
  62. package/skills/effect-batching/SKILL.md +614 -0
  63. package/skills/effect-cache/SKILL.md +570 -0
  64. package/skills/effect-cli/SKILL.md +523 -0
  65. package/skills/effect-command-executor/SKILL.md +675 -0
  66. package/skills/effect-concurrency-testing/SKILL.md +612 -0
  67. package/skills/effect-config/SKILL.md +580 -0
  68. package/skills/effect-context-witness/SKILL.md +274 -0
  69. package/skills/effect-domain-modeling/SKILL.md +1212 -0
  70. package/skills/effect-domain-predicates/SKILL.md +867 -0
  71. package/skills/effect-error-handling/SKILL.md +1581 -0
  72. package/skills/effect-fiber/SKILL.md +731 -0
  73. package/skills/effect-filesystem/SKILL.md +624 -0
  74. package/skills/effect-graph/SKILL.md +571 -0
  75. package/skills/effect-http-api/SKILL.md +1760 -0
  76. package/skills/effect-http-client/SKILL.md +989 -0
  77. package/skills/effect-http-server/SKILL.md +920 -0
  78. package/skills/effect-incremental-migration/SKILL.md +362 -0
  79. package/skills/effect-layer-design/SKILL.md +642 -0
  80. package/skills/effect-managed-runtime/SKILL.md +395 -0
  81. package/skills/effect-mcp-server/SKILL.md +608 -0
  82. package/skills/effect-observability/SKILL.md +719 -0
  83. package/skills/effect-optics/SKILL.md +554 -0
  84. package/skills/effect-parallelization/SKILL.md +668 -0
  85. package/skills/effect-path/SKILL.md +296 -0
  86. package/skills/effect-pattern-matching/SKILL.md +914 -0
  87. package/skills/effect-platform-abstraction/SKILL.md +1175 -0
  88. package/skills/effect-platform-layers/SKILL.md +514 -0
  89. package/skills/effect-pubsub-event-bus/SKILL.md +384 -0
  90. package/skills/effect-react-composition/SKILL.md +986 -0
  91. package/skills/effect-react-vm/SKILL.md +675 -0
  92. package/skills/effect-rpc-api/SKILL.md +624 -0
  93. package/skills/effect-rpc-client/SKILL.md +666 -0
  94. package/skills/effect-rpc-cluster/SKILL.md +1623 -0
  95. package/skills/effect-rpc-server/SKILL.md +767 -0
  96. package/skills/effect-scheduling/SKILL.md +124 -0
  97. package/skills/effect-schema-composition/SKILL.md +975 -0
  98. package/skills/effect-schema-v4/SKILL.md +691 -0
  99. package/skills/effect-scope/SKILL.md +682 -0
  100. package/skills/effect-service-implementation/SKILL.md +656 -0
  101. package/skills/effect-socket/SKILL.md +703 -0
  102. package/skills/effect-sql/SKILL.md +781 -0
  103. package/skills/effect-stream/SKILL.md +765 -0
  104. package/skills/effect-testing/SKILL.md +1331 -0
  105. package/skills/effect-typeclass-design/SKILL.md +161 -0
  106. package/skills/effect-wide-events/Article.md +66 -0
  107. package/skills/effect-wide-events/SKILL.md +95 -0
  108. package/skills/effect-workflow/SKILL.md +810 -0
  109. package/src/agent-policy.ts +22 -0
  110. package/src/enforcer.ts +104 -0
  111. package/src/frontmatter.ts +34 -0
  112. package/src/guidance.ts +66 -0
  113. package/src/index.ts +38 -0
  114. package/src/pattern-catalog.ts +115 -0
  115. package/src/pattern-matcher.ts +178 -0
  116. package/src/pattern.ts +97 -0
  117. package/src/skills.ts +29 -0
  118. 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
+ }
@@ -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()
@@ -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
+ }