matcher.spec.ts 3.5 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374
  1. import { describe, expect, it } from 'vitest'
  2. import { matcherDiagnostic, matchesMatcher } from '@deepseek-ai/dsh-hook-protocol'
  3. describe('matchesMatcher — match-all sentinels (both dialects)', () => {
  4. for (const mode of ['claude', 'codex'] as const) {
  5. it(`${mode}: absent / empty / '*' match everything`, () => {
  6. expect(matchesMatcher(undefined, 'Bash', mode)).toBe(true)
  7. expect(matchesMatcher('', 'anything', mode)).toBe(true)
  8. expect(matchesMatcher('*', 'whatever', mode)).toBe(true)
  9. })
  10. }
  11. })
  12. describe('matchesMatcher — claude dialect (literal-or-regex)', () => {
  13. it('a pure word-char pattern is a LITERAL exact match (not substring)', () => {
  14. expect(matchesMatcher('Bash', 'Bash', 'claude')).toBe(true)
  15. // literal exact: "Bash" must NOT match "BashOutput" (a regex would, substring)
  16. expect(matchesMatcher('Bash', 'BashOutput', 'claude')).toBe(false)
  17. })
  18. it('a pipe pattern is literal ALTERNATION (exact match any alternative)', () => {
  19. expect(matchesMatcher('Edit|Write', 'Edit', 'claude')).toBe(true)
  20. expect(matchesMatcher('Edit|Write', 'Write', 'claude')).toBe(true)
  21. expect(matchesMatcher('Edit|Write', 'Read', 'claude')).toBe(false)
  22. // still exact per-alternative, not substring
  23. expect(matchesMatcher('Edit|Write', 'EditFile', 'claude')).toBe(false)
  24. })
  25. it('a non-word pattern falls through to regex (unanchored)', () => {
  26. expect(matchesMatcher('^Bash$', 'Bash', 'claude')).toBe(true)
  27. expect(matchesMatcher('Bash.*', 'BashOutput', 'claude')).toBe(true)
  28. expect(matchesMatcher('.*\\.ts$', 'foo.ts', 'claude')).toBe(true)
  29. expect(matchesMatcher('.*\\.ts$', 'foo.js', 'claude')).toBe(false)
  30. })
  31. })
  32. describe('matchesMatcher — codex dialect (always regex)', () => {
  33. it('a word pattern is an unanchored regex (substring matches, unlike claude literal)', () => {
  34. expect(matchesMatcher('Bash', 'Bash', 'codex')).toBe(true)
  35. // codex has NO literal fast path: "Bash" is /Bash/, so it DOES match a substring
  36. expect(matchesMatcher('Bash', 'BashOutput', 'codex')).toBe(true)
  37. })
  38. it('regex alternation and anchors work', () => {
  39. expect(matchesMatcher('Edit|Write', 'Edit', 'codex')).toBe(true)
  40. expect(matchesMatcher('^Bash$', 'Bash', 'codex')).toBe(true)
  41. expect(matchesMatcher('^Bash$', 'BashOutput', 'codex')).toBe(false)
  42. })
  43. })
  44. describe('matchesMatcher — invalid regex is a non-match (never throws)', () => {
  45. it('an unbalanced pattern matches nothing rather than throwing', () => {
  46. // '(' is not the claude-literal charset, so it goes to the regex path and is invalid.
  47. expect(() => matchesMatcher('(', 'x', 'claude')).not.toThrow()
  48. expect(matchesMatcher('(', 'x', 'claude')).toBe(false)
  49. expect(matchesMatcher('[', 'x', 'codex')).toBe(false)
  50. })
  51. })
  52. describe('matcherDiagnostic — parse-time diagnostics', () => {
  53. it('accepts match-all sentinels, Claude literals, and valid regexes', () => {
  54. expect(matcherDiagnostic(undefined, 'claude')).toBeUndefined()
  55. expect(matcherDiagnostic('', 'codex')).toBeUndefined()
  56. expect(matcherDiagnostic('*', 'codex')).toBeUndefined()
  57. expect(matcherDiagnostic('Edit|Write', 'claude')).toBeUndefined()
  58. expect(matcherDiagnostic('^Bash$', 'claude')).toBeUndefined()
  59. expect(matcherDiagnostic('Edit|Write', 'codex')).toBeUndefined()
  60. })
  61. it('returns a stable diagnostic for invalid regexes in either dialect', () => {
  62. expect(matcherDiagnostic('(', 'claude')).toBe('invalid claude regex matcher "("')
  63. expect(matcherDiagnostic('[', 'codex')).toBe('invalid codex regex matcher "["')
  64. })
  65. })