File size: 6,024 Bytes
a6b96c2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
'use strict';
/**
 * One-shot script + library: bidirectional GSD slash-command namespace normalizer.
 *
 * - Default direction (transformContent): retired /gsd-<cmd> → /gsd:<cmd>
 *   (keeps monorepo sources, docs, and workflows in the active colon form).
 * - Reverse direction (transformContentToHyphen): /gsd:<cmd> / gsd:<cmd> → gsd-<cmd>
 *   (used during skill installation for runtimes that register skills under the
 *   canonical hyphen form established in #2808).
 *
 * Both directions only rewrite known commands from `commands/gsd/*.md` (longest-first
 * matching + word-boundary safety). Non-commands (gsd-sdk, gsd-tools, etc.) are
 * intentionally left untouched.
 *
 * The transforms are pure and exported for use by the installer and tests.
 */

const fs = require('node:fs');
const path = require('node:path');

const COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd');
const SEARCH_DIRS = [
  path.join(__dirname, '..', 'gsd-core', 'bin', 'lib'),
  path.join(__dirname, '..', 'gsd-core', 'workflows'),
  path.join(__dirname, '..', 'gsd-core', 'references'),
  path.join(__dirname, '..', 'gsd-core', 'templates'),
  path.join(__dirname, '..', 'gsd-core', 'contexts'),
  path.join(__dirname, '..', 'commands', 'gsd'),
  path.join(__dirname, '..', 'agents'),
  path.join(__dirname, '..', 'hooks'),
];

const TOP_LEVEL_FILES = [
  path.join(__dirname, '..', '.clinerules'),
];

const SKIP_DIRS = new Set(['node_modules', 'dist', '.turbo']);
const EXTENSIONS = new Set(['.md', '.cjs', '.js', '.ts', '.tsx']);

// Test files contain intentional fixture strings (e.g. inputs the sanitizer
// is expected to strip). Rewriting them changes test semantics.
function isTestFile(name) {
  return /\.test\.(c?js|tsx?)$/.test(name);
}

function buildPattern(cmdNames) {
  // Empty input would compile `/gsd-()(?=[^a-zA-Z0-9_-]|$)/g`, which the regex
  // engine still matches at any `/gsd-` token followed by a non-word boundary
  // (e.g. EOL, whitespace, punctuation) — rewriting it to a stray `/gsd:`.
  // Short-circuit so the caller can no-op on a missing/empty registry rather
  // than perform an unintended broad rewrite.
  if (!Array.isArray(cmdNames) || cmdNames.length === 0) return null;
  const sorted = [...cmdNames].sort((a, b) => b.length - a.length); // longest first to avoid partial matches
  return new RegExp(`/gsd-(${sorted.join('|')})(?=[^a-zA-Z0-9_-]|$)`, 'g');
}

/**
 * Pure transform: rewrite retired `/gsd-<cmd>` to `/gsd:<cmd>` for the given command names.
 * Returns the rewritten string. Identifiers not in `cmdNames` (e.g. `/gsd-sdk`,
 * `/gsd-tools`) are left untouched.
 */
function transformContent(src, cmdNames) {
  const pattern = buildPattern(cmdNames);
  if (!pattern) return src;
  return src.replace(pattern, (_, cmd) => `/gsd:${cmd}`);
}

/**
 * Build regex for the reverse direction (colon form → hyphen form).
 * Matches both "gsd:cmd" and "/gsd:cmd" (the leading / is preserved automatically
 * because it is not part of the match). Uses longest-first ordering plus
 * bidirectional word-boundary safety (negative lookbehind on the left, lookahead
 * on the right) so matches only occur at token boundaries.
 */
function buildColonPattern(cmdNames) {
  if (!Array.isArray(cmdNames) || cmdNames.length === 0) return null;
  const sorted = [...cmdNames].sort((a, b) => b.length - a.length);
  return new RegExp(`(?<![a-zA-Z0-9_-])gsd:(${sorted.join('|')})(?=[^a-zA-Z0-9_-]|$)`, 'g');
}

/**
 * Pure transform (reverse): rewrite `/gsd:<cmd>` / `gsd:<cmd>` to hyphen form
 * for known GSD commands.
 *
 * Non-command identifiers (e.g. gsd-sdk, gsd-tools) are left untouched, matching
 * the safety contract of the forward transform.
 */
function transformContentToHyphen(src, cmdNames) {
  const pattern = buildColonPattern(cmdNames);
  if (!pattern) return src;
  return src.replace(pattern, (_, cmd) => `gsd-${cmd}`);
}

function readCmdNames() {
  try {
    return fs.readdirSync(COMMANDS_DIR)
      .filter(f => f.endsWith('.md'))
      .map(f => f.replace(/\.md$/, ''));
  } catch (err) {
    // Only swallow the missing-directory case. Any other error (EACCES, ENOTDIR,
    // etc.) indicates a real misconfiguration and must propagate so callers are
    // not silently handed an empty registry while the real problem goes undetected.
    if (err.code !== 'ENOENT') throw err;
    // COMMANDS_DIR may not exist on installs that use skill-based runtimes or
    // global Claude installs (no local commands/gsd/ directory). Return [] so
    // callers that handle an empty array gracefully (buildPattern returns null,
    // transformContent is a no-op) are not broken by a missing directory.
    return [];
  }
}

function processFile(file, cmdNames) {
  const pattern = buildPattern(cmdNames);
  if (!pattern) return;
  let src;
  try { src = fs.readFileSync(file, 'utf-8'); } catch { return; }
  const replaced = transformContent(src, cmdNames);
  if (replaced !== src) {
    fs.writeFileSync(file, replaced, 'utf-8');
    const count = (src.match(pattern) || []).length;
    console.log(`  ${count} replacements: ${path.relative(path.join(__dirname, '..'), file)}`);
  }
}

function processDir(dir, cmdNames) {
  const pattern = buildPattern(cmdNames);
  if (!pattern) return;
  let entries;
  try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
  for (const e of entries) {
    const full = path.join(dir, e.name);
    if (e.isDirectory()) {
      if (SKIP_DIRS.has(e.name)) continue;
      processDir(full, cmdNames);
    } else if (EXTENSIONS.has(path.extname(e.name)) && !isTestFile(e.name)) {
      processFile(full, cmdNames);
    }
  }
}

if (require.main === module) {
  const cmdNames = readCmdNames();
  for (const dir of SEARCH_DIRS) {
    processDir(dir, cmdNames);
  }
  for (const file of TOP_LEVEL_FILES) {
    processFile(file, cmdNames);
  }
  console.log('Done.');
}

module.exports = {
  transformContent,
  transformContentToHyphen,
  buildPattern,
  buildColonPattern,
  readCmdNames,
  SKIP_DIRS
};