@hegemonart/get-design-done 1.27.6 → 1.28.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 (71) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +6 -3
  3. package/CHANGELOG.md +113 -0
  4. package/agents/design-verifier.md +17 -0
  5. package/package.json +5 -4
  6. package/reference/accessibility.md +4 -0
  7. package/reference/audit-scoring.md +14 -0
  8. package/reference/color-theory.md +279 -0
  9. package/reference/composition.md +349 -0
  10. package/reference/contrast-advanced.md +205 -0
  11. package/reference/design-system-guidance.md +2 -0
  12. package/reference/form-patterns.md +2 -0
  13. package/reference/i18n.md +554 -0
  14. package/reference/iconography.md +2 -0
  15. package/reference/motion-interpolate.md +1 -0
  16. package/reference/palette-catalog.md +2 -0
  17. package/reference/proportion-systems.md +267 -0
  18. package/reference/registry.json +42 -0
  19. package/reference/rtl-cjk-cultural.md +2 -0
  20. package/reference/schemas/mcp-gdd-tools.schema.json +381 -0
  21. package/reference/style-vocabulary.md +2 -0
  22. package/reference/typography.md +4 -0
  23. package/reference/visual-hierarchy-layout.md +4 -0
  24. package/scripts/install.cjs +42 -0
  25. package/scripts/lib/gsd-health-mirror/index.cjs +105 -0
  26. package/scripts/lib/gsd-health-mirror/index.d.cts +14 -0
  27. package/scripts/lib/install/mcp-register.cjs +235 -0
  28. package/scripts/lib/install/mcp-register.d.cts +64 -0
  29. package/scripts/lib/intel-store/index.cjs +55 -0
  30. package/scripts/lib/intel-store/index.d.cts +11 -0
  31. package/scripts/lib/mcp-tools-lint/index.cjs +216 -0
  32. package/scripts/lib/mcp-tools-lint/index.d.cts +74 -0
  33. package/scripts/lib/reflections-reader/index.cjs +107 -0
  34. package/scripts/lib/reflections-reader/index.d.cts +18 -0
  35. package/scripts/lib/roadmap-reader/index.cjs +81 -0
  36. package/scripts/lib/roadmap-reader/index.d.cts +13 -0
  37. package/scripts/lib/snapshot-reader/index.cjs +70 -0
  38. package/scripts/lib/snapshot-reader/index.d.cts +28 -0
  39. package/scripts/mcp-servers/gdd-mcp/README.md +66 -0
  40. package/scripts/mcp-servers/gdd-mcp/schemas/gdd_cycle_recap.schema.json +30 -0
  41. package/scripts/mcp-servers/gdd-mcp/schemas/gdd_decisions_list.schema.json +32 -0
  42. package/scripts/mcp-servers/gdd-mcp/schemas/gdd_events_tail.schema.json +22 -0
  43. package/scripts/mcp-servers/gdd-mcp/schemas/gdd_health.schema.json +30 -0
  44. package/scripts/mcp-servers/gdd-mcp/schemas/gdd_intel_get.schema.json +24 -0
  45. package/scripts/mcp-servers/gdd-mcp/schemas/gdd_learnings_digest.schema.json +22 -0
  46. package/scripts/mcp-servers/gdd-mcp/schemas/gdd_phase_current.schema.json +22 -0
  47. package/scripts/mcp-servers/gdd-mcp/schemas/gdd_phases_list.schema.json +31 -0
  48. package/scripts/mcp-servers/gdd-mcp/schemas/gdd_plans_list.schema.json +33 -0
  49. package/scripts/mcp-servers/gdd-mcp/schemas/gdd_reflections_latest.schema.json +21 -0
  50. package/scripts/mcp-servers/gdd-mcp/schemas/gdd_status.schema.json +23 -0
  51. package/scripts/mcp-servers/gdd-mcp/schemas/gdd_telemetry_query.schema.json +23 -0
  52. package/scripts/mcp-servers/gdd-mcp/server.ts +317 -0
  53. package/scripts/mcp-servers/gdd-mcp/tools/gdd_cycle_recap.ts +37 -0
  54. package/scripts/mcp-servers/gdd-mcp/tools/gdd_decisions_list.ts +33 -0
  55. package/scripts/mcp-servers/gdd-mcp/tools/gdd_events_tail.ts +26 -0
  56. package/scripts/mcp-servers/gdd-mcp/tools/gdd_health.ts +19 -0
  57. package/scripts/mcp-servers/gdd-mcp/tools/gdd_intel_get.ts +32 -0
  58. package/scripts/mcp-servers/gdd-mcp/tools/gdd_learnings_digest.ts +23 -0
  59. package/scripts/mcp-servers/gdd-mcp/tools/gdd_phase_current.ts +29 -0
  60. package/scripts/mcp-servers/gdd-mcp/tools/gdd_phases_list.ts +26 -0
  61. package/scripts/mcp-servers/gdd-mcp/tools/gdd_plans_list.ts +39 -0
  62. package/scripts/mcp-servers/gdd-mcp/tools/gdd_reflections_latest.ts +25 -0
  63. package/scripts/mcp-servers/gdd-mcp/tools/gdd_status.ts +31 -0
  64. package/scripts/mcp-servers/gdd-mcp/tools/gdd_telemetry_query.ts +27 -0
  65. package/scripts/mcp-servers/gdd-mcp/tools/index.ts +75 -0
  66. package/scripts/mcp-servers/gdd-mcp/tools/shared.ts +134 -0
  67. package/skills/explore/SKILL.md +31 -0
  68. package/skills/health/SKILL.md +36 -0
  69. package/skills/next/SKILL.md +28 -3
  70. package/skills/progress/SKILL.md +21 -6
  71. package/skills/resume/SKILL.md +26 -1
@@ -0,0 +1,235 @@
1
+ 'use strict';
2
+ // scripts/lib/install/mcp-register.cjs
3
+ // ---------------------------------------------------------------------------
4
+ // Plan 27.7-04 — registers `gdd-mcp` with the two harnesses that matter
5
+ // (Claude Code, Codex) and detects existing registration. Idempotent;
6
+ // graceful absent-CLI fallback (D-07).
7
+ //
8
+ // Pure library — no side effects on require. Invoked by:
9
+ // - scripts/install.cjs --register-mcp (opt-in; default off per D-07)
10
+ // - skills/health/SKILL.md check-mcp-registration step (read-only detect)
11
+ //
12
+ // spawnFn injection allows tests to mock child_process.spawnSync without
13
+ // touching real CLIs in CI.
14
+ //
15
+ // Threat model: scripts/install.cjs --register-mcp writes to harness user-
16
+ // level config. Command args are hardcoded in HARNESSES (no command-
17
+ // injection surface); `--` separator before MCP_NAME prevents flag
18
+ // injection (T-27.7-04-06).
19
+
20
+ const { spawnSync } = require('node:child_process');
21
+
22
+ const MCP_NAME = 'gdd-mcp';
23
+
24
+ const HARNESSES = Object.freeze({
25
+ claude: Object.freeze({
26
+ binary: 'claude',
27
+ addArgs: Object.freeze(['mcp', 'add', MCP_NAME, '-s', 'user', '--', MCP_NAME]),
28
+ listArgs: Object.freeze(['mcp', 'list']),
29
+ listMatchPattern: /\bgdd-mcp\b/,
30
+ }),
31
+ codex: Object.freeze({
32
+ binary: 'codex',
33
+ addArgs: Object.freeze(['mcp', 'add', MCP_NAME, '--', MCP_NAME]),
34
+ listArgs: Object.freeze(['mcp', 'list']),
35
+ listMatchPattern: /\bgdd-mcp\b/,
36
+ }),
37
+ });
38
+
39
+ /**
40
+ * Build the command tuple for a given harness + mode.
41
+ * Currently only 'register' (add) is supported in command-build; 'detect'
42
+ * uses listArgs internally, 'unregister' is reserved for future work.
43
+ */
44
+ function buildHarnessCommand(harness, mode = 'register') {
45
+ const h = HARNESSES[harness];
46
+ if (!h) throw new Error('Unknown harness: ' + harness);
47
+ if (mode === 'register') {
48
+ return { binary: h.binary, args: Array.from(h.addArgs) };
49
+ }
50
+ if (mode === 'detect') {
51
+ return { binary: h.binary, args: Array.from(h.listArgs) };
52
+ }
53
+ throw new Error('Unsupported mode: ' + mode);
54
+ }
55
+
56
+ /**
57
+ * Detect whether the harness CLI is on PATH. Runs `<binary> --version` and
58
+ * returns true iff exit code is 0. Catches ENOENT (binary missing).
59
+ */
60
+ function detectHarnessPresent(harness, spawnFn = spawnSync) {
61
+ const h = HARNESSES[harness];
62
+ if (!h) throw new Error('Unknown harness: ' + harness);
63
+ let result;
64
+ try {
65
+ result = spawnFn(h.binary, ['--version'], {
66
+ stdio: 'pipe',
67
+ encoding: 'utf8',
68
+ });
69
+ } catch (_e) {
70
+ return false;
71
+ }
72
+ if (!result) return false;
73
+ if (result.error && result.error.code === 'ENOENT') return false;
74
+ return result.status === 0;
75
+ }
76
+
77
+ /**
78
+ * Detect whether gdd-mcp is already registered with the given harness.
79
+ * Runs `<binary> mcp list` and matches against listMatchPattern.
80
+ */
81
+ function isAlreadyRegistered(harness, spawnFn = spawnSync) {
82
+ const h = HARNESSES[harness];
83
+ if (!h) throw new Error('Unknown harness: ' + harness);
84
+ let result;
85
+ try {
86
+ result = spawnFn(h.binary, Array.from(h.listArgs), {
87
+ stdio: 'pipe',
88
+ encoding: 'utf8',
89
+ });
90
+ } catch (_e) {
91
+ return false;
92
+ }
93
+ if (!result || result.status !== 0) return false;
94
+ const stdout = (result.stdout || '').toString();
95
+ return h.listMatchPattern.test(stdout);
96
+ }
97
+
98
+ /**
99
+ * Register gdd-mcp with the given harness.
100
+ *
101
+ * @param {object} opts
102
+ * @param {'claude'|'codex'} opts.harness
103
+ * @param {'register'|'unregister'|'detect'} [opts.mode='register']
104
+ * @param {boolean} [opts.dryRun=false]
105
+ * @param {Function} [opts.spawnFn] child_process.spawnSync substitute
106
+ * @returns {object} {harness, action, detected, command, applied,
107
+ * idempotent_skip, notice?, stdout?, stderr?,
108
+ * exit_code?, dry_run?}
109
+ */
110
+ function registerMcp({ harness, mode = 'register', dryRun = false, spawnFn = spawnSync } = {}) {
111
+ if (!HARNESSES[harness]) {
112
+ throw new Error('Unknown harness: ' + harness + ' (expected one of: ' + Object.keys(HARNESSES).join(', ') + ')');
113
+ }
114
+ if (mode !== 'register' && mode !== 'detect' && mode !== 'unregister') {
115
+ throw new Error('Unsupported mode: ' + mode);
116
+ }
117
+
118
+ // Step 1 — detect harness CLI on PATH
119
+ if (!detectHarnessPresent(harness, spawnFn)) {
120
+ return {
121
+ harness,
122
+ action: mode,
123
+ detected: false,
124
+ command: null,
125
+ applied: false,
126
+ idempotent_skip: false,
127
+ notice: harness + ' CLI not on PATH — skipping ' + MCP_NAME + ' registration',
128
+ };
129
+ }
130
+
131
+ // Step 2 — idempotency check: already registered?
132
+ if (isAlreadyRegistered(harness, spawnFn)) {
133
+ return {
134
+ harness,
135
+ action: mode,
136
+ detected: true,
137
+ command: null,
138
+ applied: false,
139
+ idempotent_skip: true,
140
+ };
141
+ }
142
+
143
+ // Step 3 — build + dispatch add command
144
+ const { binary, args } = buildHarnessCommand(harness, 'register');
145
+ const commandStr = binary + ' ' + args.join(' ');
146
+
147
+ if (dryRun) {
148
+ return {
149
+ harness,
150
+ action: mode,
151
+ detected: true,
152
+ command: commandStr,
153
+ applied: false,
154
+ idempotent_skip: false,
155
+ dry_run: true,
156
+ };
157
+ }
158
+
159
+ let result;
160
+ try {
161
+ result = spawnFn(binary, args, { stdio: 'pipe', encoding: 'utf8' });
162
+ } catch (e) {
163
+ return {
164
+ harness,
165
+ action: mode,
166
+ detected: true,
167
+ command: commandStr,
168
+ applied: false,
169
+ idempotent_skip: false,
170
+ stderr: (e && e.message) || String(e),
171
+ exit_code: null,
172
+ };
173
+ }
174
+ const stdout = (result && result.stdout) || '';
175
+ const stderr = (result && result.stderr) || '';
176
+ const exit_code = result ? result.status : null;
177
+ return {
178
+ harness,
179
+ action: mode,
180
+ detected: true,
181
+ command: commandStr,
182
+ applied: exit_code === 0,
183
+ idempotent_skip: false,
184
+ stdout: stdout.toString(),
185
+ stderr: stderr.toString(),
186
+ exit_code,
187
+ };
188
+ }
189
+
190
+ /**
191
+ * Detect overall MCP registration state across all known harnesses.
192
+ *
193
+ * @param {object} [opts]
194
+ * @param {Function} [opts.spawnFn]
195
+ * @returns {{harnesses: Array, summary: string}}
196
+ */
197
+ function detectMcpRegistration({ spawnFn = spawnSync } = {}) {
198
+ const harnessIds = Object.keys(HARNESSES);
199
+ const results = harnessIds.map((harness) => {
200
+ const present = detectHarnessPresent(harness, spawnFn);
201
+ let registered;
202
+ if (present) {
203
+ registered = isAlreadyRegistered(harness, spawnFn);
204
+ } else {
205
+ registered = undefined;
206
+ }
207
+ return { harness, present, registered };
208
+ });
209
+
210
+ const anyPresent = results.some((r) => r.present);
211
+ const registeredHarnesses = results.filter((r) => r.registered === true).map((r) => r.harness);
212
+
213
+ let summary;
214
+ if (!anyPresent) {
215
+ summary = 'unknown (claude/codex CLI not found)';
216
+ } else if (registeredHarnesses.length === 0) {
217
+ summary = 'not registered';
218
+ } else if (registeredHarnesses.length === harnessIds.length) {
219
+ summary = 'registered with ' + registeredHarnesses.join('+');
220
+ } else {
221
+ summary = 'registered with ' + registeredHarnesses.join('+');
222
+ }
223
+
224
+ return { harnesses: results, summary };
225
+ }
226
+
227
+ module.exports = {
228
+ registerMcp,
229
+ detectMcpRegistration,
230
+ detectHarnessPresent,
231
+ isAlreadyRegistered,
232
+ buildHarnessCommand,
233
+ HARNESSES,
234
+ MCP_NAME,
235
+ };
@@ -0,0 +1,64 @@
1
+ // scripts/lib/install/mcp-register.d.cts
2
+ // ---------------------------------------------------------------------------
3
+ // Plan 27.7-04 — TypeScript ambient declarations for the mcp-register lib.
4
+ // Sibling .d.cts kept in sync with mcp-register.cjs (Phase 27.6 lesson —
5
+ // precautionary for TS consumers).
6
+
7
+ import type { spawnSync } from 'node:child_process';
8
+
9
+ export interface Harness {
10
+ readonly binary: string;
11
+ readonly addArgs: readonly string[];
12
+ readonly listArgs: readonly string[];
13
+ readonly listMatchPattern: RegExp;
14
+ }
15
+
16
+ export const HARNESSES: Readonly<Record<'claude' | 'codex', Harness>>;
17
+ export const MCP_NAME: string;
18
+
19
+ export type HarnessId = 'claude' | 'codex';
20
+ export type RegisterMode = 'register' | 'unregister' | 'detect';
21
+
22
+ export interface RegisterMcpResult {
23
+ harness: HarnessId;
24
+ action: RegisterMode;
25
+ detected: boolean;
26
+ command: string | null;
27
+ applied: boolean;
28
+ idempotent_skip: boolean;
29
+ notice?: string;
30
+ stdout?: string;
31
+ stderr?: string;
32
+ exit_code?: number | null;
33
+ dry_run?: boolean;
34
+ }
35
+
36
+ export interface HarnessDetectEntry {
37
+ harness: HarnessId;
38
+ present: boolean;
39
+ registered: boolean | undefined;
40
+ }
41
+
42
+ export interface DetectResult {
43
+ harnesses: HarnessDetectEntry[];
44
+ summary: string;
45
+ }
46
+
47
+ export type SpawnFn = typeof spawnSync;
48
+
49
+ export interface RegisterMcpOptions {
50
+ harness: HarnessId;
51
+ mode?: RegisterMode;
52
+ dryRun?: boolean;
53
+ spawnFn?: SpawnFn;
54
+ }
55
+
56
+ export interface DetectMcpRegistrationOptions {
57
+ spawnFn?: SpawnFn;
58
+ }
59
+
60
+ export function registerMcp(opts: RegisterMcpOptions): RegisterMcpResult;
61
+ export function detectMcpRegistration(opts?: DetectMcpRegistrationOptions): DetectResult;
62
+ export function detectHarnessPresent(harness: HarnessId, spawnFn?: SpawnFn): boolean;
63
+ export function isAlreadyRegistered(harness: HarnessId, spawnFn?: SpawnFn): boolean;
64
+ export function buildHarnessCommand(harness: HarnessId, mode?: RegisterMode): { binary: string; args: string[] };
@@ -0,0 +1,55 @@
1
+ 'use strict';
2
+ // scripts/lib/intel-store/index.cjs — Plan 27.7-02
3
+ //
4
+ // Slice reader over <rootDir>/.design/intel/<slice_id>.json. Different
5
+ // surface from scripts/lib/design-search.cjs (which does cross-cycle
6
+ // FTS/grep recall) — see CONTEXT.md Warning #7.
7
+ //
8
+ // Surface:
9
+ // class IntelNotFoundError extends Error — code='directory_not_found'
10
+ // async readSlice(rootDir, sliceId) — parsed slice | null
11
+ // listSlices(rootDir) — string[] of slice ids
12
+
13
+ const fs = require('node:fs');
14
+ const path = require('node:path');
15
+
16
+ class IntelNotFoundError extends Error {
17
+ constructor(dir) {
18
+ super('source directory not found: ' + dir);
19
+ this.name = 'IntelNotFoundError';
20
+ this.code = 'directory_not_found';
21
+ this.dir = dir;
22
+ }
23
+ }
24
+
25
+ /** Read slice <rootDir>/.design/intel/<sliceId>.json. Returns parsed
26
+ * JSON or `null` if the slice file is missing. Throws
27
+ * IntelNotFoundError when the intel directory itself is absent. */
28
+ async function readSlice(rootDir, sliceId) {
29
+ const dir = path.join(rootDir, '.design', 'intel');
30
+ if (!fs.existsSync(dir)) {
31
+ throw new IntelNotFoundError(dir);
32
+ }
33
+ const file = path.join(dir, sliceId + '.json');
34
+ if (!fs.existsSync(file)) return null;
35
+ const body = await fs.promises.readFile(file, 'utf8');
36
+ return JSON.parse(body);
37
+ }
38
+
39
+ /** List slice ids (file basenames without extension) under .design/intel/.
40
+ * Throws IntelNotFoundError when the directory is absent. */
41
+ function listSlices(rootDir) {
42
+ const dir = path.join(rootDir, '.design', 'intel');
43
+ if (!fs.existsSync(dir)) {
44
+ throw new IntelNotFoundError(dir);
45
+ }
46
+ const entries = fs.readdirSync(dir);
47
+ const ids = [];
48
+ for (const name of entries) {
49
+ if (!name.endsWith('.json')) continue;
50
+ ids.push(name.slice(0, -'.json'.length));
51
+ }
52
+ return ids;
53
+ }
54
+
55
+ module.exports = { readSlice, listSlices, IntelNotFoundError };
@@ -0,0 +1,11 @@
1
+ // scripts/lib/intel-store/index.d.cts — TypeScript ambient declarations
2
+ // for the intel-store CJS module. Plan 27.7-02.
3
+
4
+ export class IntelNotFoundError extends Error {
5
+ code: 'directory_not_found';
6
+ dir: string;
7
+ constructor(dir: string);
8
+ }
9
+
10
+ export function readSlice(rootDir: string, sliceId: string): Promise<unknown | null>;
11
+ export function listSlices(rootDir: string): string[];
@@ -0,0 +1,216 @@
1
+ 'use strict';
2
+ // scripts/lib/mcp-tools-lint/index.cjs
3
+ // ---------------------------------------------------------------------------
4
+ // Plan 27.7-03 — static lint for the gdd-mcp tools directory.
5
+ //
6
+ // 4 invariants enforced (origin: Phase 27.7 CONTEXT decisions):
7
+ //
8
+ // forbid-fs-path (D-06): No direct `node:fs`/`node:path` (or bare `fs`/
9
+ // `path`) imports inside individual tool .ts
10
+ // files. Tools must be thin wrappers — all
11
+ // filesystem I/O routes through scripts/lib/*
12
+ // helpers (gdd-state, intel-store, etc.). The
13
+ // `index.ts` and `shared.ts` siblings ARE
14
+ // infrastructure and are exempt.
15
+ //
16
+ // max-loc (D-06): Each tool .ts file ≤ 30 non-blank-non-comment
17
+ // LOC. Exempt: index.ts, shared.ts.
18
+ //
19
+ // no-write-names (D-04): Hard-block every write-verb tool name. A tool
20
+ // name matching /_(create|update|delete|append|
21
+ // clear|write|set)(_|$)/ is rejected. The MCP
22
+ // server is read-only by design.
23
+ //
24
+ // tool-count-cap (D-03): ≤ 12 files matching `gdd_*.ts` glob in the
25
+ // tools directory. Hard cap. Adding a 13th tool
26
+ // requires re-scoping in a new plan.
27
+ //
28
+ // Public API:
29
+ // lintMcpToolsDir({dir, maxLoc?, toolCap?, exemptions?}) →
30
+ // { violations: LintViolation[], summary: { files_scanned, violations_count } }
31
+ //
32
+ // Consumed by tests/gdd-mcp-tools-lint.test.cjs and
33
+ // tests/phase-27-7-baseline.test.cjs (Plan 27.7-07).
34
+
35
+ const fs = require('node:fs');
36
+ const path = require('node:path');
37
+
38
+ const DEFAULT_EXEMPTIONS = new Set(['index.ts', 'shared.ts']);
39
+ const DEFAULT_MAX_LOC = 30;
40
+ const DEFAULT_TOOL_CAP = 12;
41
+ const TOOL_FILE_GLOB = /^gdd_[a-z0-9_]+\.ts$/;
42
+
43
+ const FORBIDDEN_IMPORT_PATTERNS = Object.freeze([
44
+ /from\s+['"]node:fs['"]/,
45
+ /from\s+['"]node:fs\/promises['"]/,
46
+ /from\s+['"]node:path['"]/,
47
+ /from\s+['"]fs['"]/,
48
+ /from\s+['"]path['"]/,
49
+ /require\s*\(\s*['"]node:fs['"]\s*\)/,
50
+ /require\s*\(\s*['"]node:fs\/promises['"]\s*\)/,
51
+ /require\s*\(\s*['"]fs['"]\s*\)/,
52
+ /require\s*\(\s*['"]node:path['"]\s*\)/,
53
+ /require\s*\(\s*['"]path['"]\s*\)/,
54
+ ]);
55
+
56
+ // Write-verb pattern: matches when the verb is preceded by `_` and either
57
+ // followed by `_` or end-of-string. e.g. `gdd_decision_append` matches;
58
+ // `gdd_appendix_list` does NOT (the verb must be the trailing token of a
59
+ // `_`-separated name).
60
+ const WRITE_NAME_PATTERN = /_(create|update|delete|append|clear|write|set)(?:_|$)/;
61
+
62
+ const RULES = Object.freeze([
63
+ 'forbid-fs-path',
64
+ 'max-loc',
65
+ 'no-write-names',
66
+ 'tool-count-cap',
67
+ ]);
68
+
69
+ /**
70
+ * Count non-blank-non-comment lines.
71
+ * - Blank lines (whitespace-only) → excluded.
72
+ * - Lines whose first non-whitespace char is `//` (line comment) → excluded.
73
+ * - Lines starting with `/*` or `*` (block comment opener/continuation) → excluded.
74
+ */
75
+ function countLoc(text) {
76
+ return text.split('\n').filter((line) => {
77
+ const t = line.trim();
78
+ if (t.length === 0) return false;
79
+ if (t.startsWith('//')) return false;
80
+ if (t.startsWith('/*')) return false;
81
+ if (t.startsWith('*')) return false;
82
+ return true;
83
+ }).length;
84
+ }
85
+
86
+ /**
87
+ * Scan source text line-by-line for the FORBIDDEN_IMPORT_PATTERNS.
88
+ * Returns [{rule, line, message}, …] (file is filled by the caller).
89
+ */
90
+ function scanForbiddenImports(text) {
91
+ const violations = [];
92
+ const lines = text.split('\n');
93
+ for (let i = 0; i < lines.length; i++) {
94
+ const line = lines[i];
95
+ for (const re of FORBIDDEN_IMPORT_PATTERNS) {
96
+ const m = line.match(re);
97
+ if (m) {
98
+ violations.push({
99
+ rule: 'forbid-fs-path',
100
+ line: i + 1,
101
+ message: 'forbidden import: ' + m[0],
102
+ });
103
+ break; // one violation per line is enough.
104
+ }
105
+ }
106
+ }
107
+ return violations;
108
+ }
109
+
110
+ /**
111
+ * Extract the `export const name = '…'` value. Tolerates `"` or `'` quotes
112
+ * and arbitrary whitespace. Returns {name, line} or null.
113
+ */
114
+ function extractToolName(text) {
115
+ const lines = text.split('\n');
116
+ const re = /export\s+const\s+name\s*(?::\s*[A-Za-z<>{}\s,|]+)?\s*=\s*['"]([^'"]+)['"]/;
117
+ for (let i = 0; i < lines.length; i++) {
118
+ const m = lines[i].match(re);
119
+ if (m) return { name: m[1], line: i + 1 };
120
+ }
121
+ return null;
122
+ }
123
+
124
+ /**
125
+ * Main entry point. Scans `dir` for *.ts files, applies the 4 rules,
126
+ * and returns a structured result.
127
+ *
128
+ * @param {{dir: string, maxLoc?: number, toolCap?: number, exemptions?: Set<string>}} opts
129
+ * @returns {{violations: Array<{file: string, rule: string, line: number, message: string}>, summary: {files_scanned: number, violations_count: number}}}
130
+ */
131
+ function lintMcpToolsDir(opts) {
132
+ if (!opts || typeof opts.dir !== 'string' || opts.dir.length === 0) {
133
+ throw new Error('lintMcpToolsDir: opts.dir is required');
134
+ }
135
+ const dir = opts.dir;
136
+ const maxLoc = typeof opts.maxLoc === 'number' ? opts.maxLoc : DEFAULT_MAX_LOC;
137
+ const toolCap =
138
+ typeof opts.toolCap === 'number' ? opts.toolCap : DEFAULT_TOOL_CAP;
139
+ const exemptions =
140
+ opts.exemptions instanceof Set ? opts.exemptions : DEFAULT_EXEMPTIONS;
141
+
142
+ const violations = [];
143
+
144
+ const entries = fs.readdirSync(dir);
145
+ const tsFiles = entries.filter((e) => e.endsWith('.ts'));
146
+
147
+ // Rule D — tool-count-cap (matches `gdd_*.ts` files only; index/shared
148
+ // never count toward the cap).
149
+ const toolFiles = entries.filter((e) => TOOL_FILE_GLOB.test(e));
150
+ if (toolFiles.length > toolCap) {
151
+ violations.push({
152
+ file: dir,
153
+ rule: 'tool-count-cap',
154
+ line: 0,
155
+ message: 'count=' + toolFiles.length + ' > cap=' + toolCap,
156
+ });
157
+ }
158
+
159
+ // Rules A, B, C — per-file scans.
160
+ for (const fname of tsFiles) {
161
+ const text = fs.readFileSync(path.join(dir, fname), 'utf8');
162
+ const isExempt = exemptions.has(fname);
163
+
164
+ // Rule A — forbid-fs-path (skip exemptions).
165
+ if (!isExempt) {
166
+ const fsViolations = scanForbiddenImports(text);
167
+ for (const v of fsViolations) {
168
+ violations.push({ file: fname, ...v });
169
+ }
170
+ }
171
+
172
+ // Rule B — max-loc (skip exemptions).
173
+ if (!isExempt) {
174
+ const loc = countLoc(text);
175
+ if (loc > maxLoc) {
176
+ violations.push({
177
+ file: fname,
178
+ rule: 'max-loc',
179
+ line: 0,
180
+ message: 'loc=' + loc + ' > max=' + maxLoc,
181
+ });
182
+ }
183
+ }
184
+
185
+ // Rule C — no-write-names. Applies to ALL ts files including
186
+ // exemptions (you should not even define a write-named symbol in
187
+ // index.ts or shared.ts — that would be a different bug).
188
+ const ext = extractToolName(text);
189
+ if (ext && WRITE_NAME_PATTERN.test(ext.name)) {
190
+ violations.push({
191
+ file: fname,
192
+ rule: 'no-write-names',
193
+ line: ext.line,
194
+ message: 'write tool name: ' + ext.name,
195
+ });
196
+ }
197
+ }
198
+
199
+ return {
200
+ violations,
201
+ summary: {
202
+ files_scanned: tsFiles.length,
203
+ violations_count: violations.length,
204
+ },
205
+ };
206
+ }
207
+
208
+ module.exports = {
209
+ lintMcpToolsDir,
210
+ RULES,
211
+ DEFAULT_EXEMPTIONS,
212
+ DEFAULT_MAX_LOC,
213
+ DEFAULT_TOOL_CAP,
214
+ FORBIDDEN_IMPORT_PATTERNS,
215
+ WRITE_NAME_PATTERN,
216
+ };
@@ -0,0 +1,74 @@
1
+ // scripts/lib/mcp-tools-lint/index.d.cts — ambient types for the .cjs lib.
2
+ //
3
+ // The runtime consumer is tests/gdd-mcp-tools-lint.test.cjs (CommonJS, so
4
+ // types are not strictly required). This .d.cts ships the Phase 27.6
5
+ // convention (any .cjs lib that may be imported from a .ts file gets a
6
+ // sibling .d.cts) so that a future TypeScript consumer (e.g. a /lint:gdd
7
+ // slash command) gets correct types without a follow-up patch.
8
+
9
+ /** One detected lint failure. */
10
+ export interface LintViolation {
11
+ /** Filename (relative to scan dir) or the dir itself for cap violations. */
12
+ file: string;
13
+ rule: 'forbid-fs-path' | 'max-loc' | 'no-write-names' | 'tool-count-cap';
14
+ /** 1-based source line; 0 for whole-file or whole-directory violations. */
15
+ line: number;
16
+ /** Human-readable diagnostic, e.g. `loc=42 > max=30`. */
17
+ message: string;
18
+ }
19
+
20
+ /** Counts for the scan as a whole. */
21
+ export interface LintSummary {
22
+ files_scanned: number;
23
+ violations_count: number;
24
+ }
25
+
26
+ /** Return shape of {@link lintMcpToolsDir}. */
27
+ export interface LintResult {
28
+ violations: LintViolation[];
29
+ summary: LintSummary;
30
+ }
31
+
32
+ /** Inputs to {@link lintMcpToolsDir}. Only `dir` is required. */
33
+ export interface LintOptions {
34
+ /** Directory to scan. Tool files match `gdd_*.ts`. */
35
+ dir: string;
36
+ /** Max non-blank-non-comment LOC per tool file. Defaults to 30. */
37
+ maxLoc?: number;
38
+ /** Max number of `gdd_*.ts` tool files. Defaults to 12. */
39
+ toolCap?: number;
40
+ /**
41
+ * Filenames in `dir` exempt from forbid-fs-path + max-loc rules.
42
+ * Defaults to {'index.ts', 'shared.ts'}.
43
+ */
44
+ exemptions?: Set<string>;
45
+ }
46
+
47
+ /**
48
+ * Scan a directory of MCP tool .ts files and apply the 4 invariant rules.
49
+ * Pure-static — never executes the modules.
50
+ *
51
+ * - Rule A (`forbid-fs-path`): no fs/path imports in tool files (D-06).
52
+ * - Rule B (`max-loc`): each tool ≤ {@link LintOptions.maxLoc} LOC (D-06).
53
+ * - Rule C (`no-write-names`): no tool name with write-verb substring (D-04).
54
+ * - Rule D (`tool-count-cap`): ≤ {@link LintOptions.toolCap} tool files (D-03).
55
+ */
56
+ export function lintMcpToolsDir(opts: LintOptions): LintResult;
57
+
58
+ /** Ordered list of all rule names this module enforces. */
59
+ export const RULES: readonly LintViolation['rule'][];
60
+
61
+ /** Default exempt filenames (index.ts + shared.ts). */
62
+ export const DEFAULT_EXEMPTIONS: Set<string>;
63
+
64
+ /** Default value for the LOC ceiling. */
65
+ export const DEFAULT_MAX_LOC: number;
66
+
67
+ /** Default value for the tool-count cap. */
68
+ export const DEFAULT_TOOL_CAP: number;
69
+
70
+ /** The regexes Rule A scans for, line by line. */
71
+ export const FORBIDDEN_IMPORT_PATTERNS: readonly RegExp[];
72
+
73
+ /** The regex Rule C matches against the extracted `export const name`. */
74
+ export const WRITE_NAME_PATTERN: RegExp;