@wonsukchoi/crondex 0.17.0 → 0.18.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/README.md CHANGED
@@ -30,7 +30,10 @@ npx @wonsukchoi/crondex deploy ssl-cert-expiry-check --var host=example.com
30
30
 
31
31
  - `recommend "<what you want>"` — find the closest matching job (zero
32
32
  tokens, no network call, so an agent can check before writing one from
33
- scratch)
33
+ scratch). Matching handles plurals, a small catalog-grounded synonym set
34
+ (e.g. "notify"/"warn"/"remind" all match jobs tagged `reminder`), and
35
+ falls back to fuzzy (edit-distance) matching on typos when nothing
36
+ matches exactly.
34
37
  - `list [--category x] [--tag y]` / `categories` — browse everything
35
38
  - `show <id>` — print a job's full YAML
36
39
  - `add <id> [--dest path]` — copy it into your project to edit
package/lib/recommend.js CHANGED
@@ -16,13 +16,81 @@ export function stem(word) {
16
16
  return word;
17
17
  }
18
18
 
19
+ // Groups of interchangeable words, grounded in the catalog's actual vocabulary (its
20
+ // most common tags/description verbs) rather than a generic thesaurus — this closes
21
+ // the gap where a query uses a reasonable synonym the job authors didn't happen to
22
+ // write (e.g. "notify" vs. a job tagged "reminder"). Every word in a group maps to
23
+ // the group's first entry, applied on both the query and the job's own fields so
24
+ // either side using a different synonym still lines up.
25
+ const SYNONYM_GROUPS = [
26
+ ["alert", "warn", "notify", "notification", "remind", "reminder"],
27
+ ["check", "monitor", "watch", "scan", "inspect", "verify"],
28
+ ["audit", "review", "compliance"],
29
+ ["expire", "expiry", "expiration"],
30
+ ["backup", "snapshot"],
31
+ ["cost", "spend", "spending", "budget", "expense", "billing"],
32
+ ["fail", "failure", "error", "crash", "outage"],
33
+ ["delete", "remove", "cleanup", "purge", "clear"],
34
+ ["update", "upgrade", "refresh", "sync"],
35
+ ["duplicate", "dedupe", "dup"],
36
+ ["deadline", "due"],
37
+ ["schedule", "scheduling", "calendar", "planning"],
38
+ ["drift", "diff", "change", "changed"],
39
+ ["renew", "renewal"],
40
+ ["followup", "follow-up"],
41
+ ["invoice", "billing", "payment"],
42
+ ["stale", "outdated", "old"],
43
+ ];
44
+
45
+ const SYNONYM_MAP = new Map();
46
+ for (const group of SYNONYM_GROUPS) {
47
+ for (const word of group) SYNONYM_MAP.set(word, group[0]);
48
+ }
49
+
50
+ export function canonicalize(word) {
51
+ return SYNONYM_MAP.get(word) ?? word;
52
+ }
53
+
19
54
  export function tokenize(text) {
20
55
  return text
21
56
  .toLowerCase()
22
57
  .replace(/[^a-z0-9\s-]/g, " ")
23
58
  .split(/[\s-]+/)
24
59
  .filter((w) => w.length > 1 && !STOPWORDS.has(w))
25
- .map(stem);
60
+ .map(stem)
61
+ .map(canonicalize);
62
+ }
63
+
64
+ // Bounded edit distance — used only as a fallback when no exact token match exists,
65
+ // to catch typos/near-misses (e.g. "expiery" vs "expiry") without embeddings or a
66
+ // spellcheck dependency. Short-circuits past `max` so it stays cheap across a whole
67
+ // catalog scan.
68
+ export function editDistance(a, b, max = 2) {
69
+ if (Math.abs(a.length - b.length) > max) return max + 1;
70
+ const prev = new Array(b.length + 1);
71
+ const curr = new Array(b.length + 1);
72
+ for (let j = 0; j <= b.length; j++) prev[j] = j;
73
+ for (let i = 1; i <= a.length; i++) {
74
+ curr[0] = i;
75
+ let rowMin = curr[0];
76
+ for (let j = 1; j <= b.length; j++) {
77
+ curr[j] = a[i - 1] === b[j - 1] ? prev[j - 1] : 1 + Math.min(prev[j - 1], prev[j], curr[j - 1]);
78
+ rowMin = Math.min(rowMin, curr[j]);
79
+ }
80
+ if (rowMin > max) return max + 1;
81
+ for (let j = 0; j <= b.length; j++) prev[j] = curr[j];
82
+ }
83
+ return prev[b.length];
84
+ }
85
+
86
+ // A query token counts as a fuzzy hit against a field token if they're a short edit
87
+ // distance apart — only tried once no exact match exists (see scoreJob), and only for
88
+ // words long enough that a 1-2 character slip is plausibly a typo rather than a
89
+ // genuinely different short word ("ssl" vs "sql" would otherwise false-positive).
90
+ function isFuzzyMatch(queryToken, fieldToken) {
91
+ if (queryToken.length < 5 || fieldToken.length < 5) return false;
92
+ const maxDistance = queryToken.length >= 8 ? 2 : 1;
93
+ return editDistance(queryToken, fieldToken, maxDistance) <= maxDistance;
26
94
  }
27
95
 
28
96
  // id and name are near-duplicates of each other (id is just the slugified
@@ -33,7 +101,7 @@ export const RECOMMEND_WEIGHTS = { tags: 4, title: 3, category: 2, description:
33
101
 
34
102
  export function scoreJob(queryTokens, job) {
35
103
  const fields = {
36
- tags: job.tags.map((t) => stem(t.toLowerCase())),
104
+ tags: job.tags.map((t) => canonicalize(stem(t.toLowerCase()))),
37
105
  title: [...new Set([...tokenize(job.name), ...tokenize(job.id.replace(/-/g, " "))])],
38
106
  category: tokenize(job.category ?? ""),
39
107
  description: tokenize(job.description ?? ""),
@@ -45,6 +113,11 @@ export function scoreJob(queryTokens, job) {
45
113
  if (fields[field].includes(qt)) {
46
114
  score += weight;
47
115
  matched.add(qt);
116
+ } else if (fields[field].some((ft) => isFuzzyMatch(qt, ft))) {
117
+ // Fuzzy hits count for half weight (rounded up) — a real match still
118
+ // outranks a typo-distance one for the same field.
119
+ score += Math.ceil(weight / 2);
120
+ matched.add(qt);
48
121
  }
49
122
  }
50
123
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wonsukchoi/crondex",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "description": "A public directory of pre-made, agent-editable cron jobs.",
5
5
  "type": "module",
6
6
  "license": "MIT",