@phuc1403/musketeer 0.4.0 → 0.5.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 (25) hide show
  1. package/README.md +49 -49
  2. package/manifest.json +301 -292
  3. package/package.json +1 -1
  4. package/template/.claude/hooks/lib/characteristics/checker.cjs +357 -0
  5. package/template/.claude/hooks/validate-characteristics-hook.cjs +66 -0
  6. package/template/.claude/skills/architecture-characteristic-writer/SKILL.md +197 -99
  7. package/template/.claude/skills/architecture-characteristic-writer/assets/worksheet-template.md +18 -29
  8. package/template/.claude/skills/architecture-characteristic-writer/references/characteristics-catalog.md +22 -88
  9. package/template/.claude/skills/architecture-characteristic-writer/scripts/ranking-table.cjs +171 -0
  10. package/template/dotnet-scaffold/src/__SolutionName__.Api/obj/Debug/net10.0/__SolutionName__.Api.AssemblyInfo.cs +1 -1
  11. package/template/dotnet-scaffold/src/__SolutionName__.Api/obj/Debug/net10.0/__SolutionName__.Api.AssemblyInfoInputs.cache +1 -1
  12. package/template/dotnet-scaffold/src/__SolutionName__.Application/obj/Debug/net10.0/__SolutionName__.Application.AssemblyInfo.cs +1 -1
  13. package/template/dotnet-scaffold/src/__SolutionName__.Application/obj/Debug/net10.0/__SolutionName__.Application.AssemblyInfoInputs.cache +1 -1
  14. package/template/dotnet-scaffold/src/__SolutionName__.Domain/obj/Debug/net10.0/__SolutionName__.Domain.AssemblyInfo.cs +1 -1
  15. package/template/dotnet-scaffold/src/__SolutionName__.Domain/obj/Debug/net10.0/__SolutionName__.Domain.AssemblyInfoInputs.cache +1 -1
  16. package/template/dotnet-scaffold/src/__SolutionName__.Infrastructure/obj/Debug/net10.0/__SolutionName__.Infrastructure.AssemblyInfo.cs +1 -1
  17. package/template/dotnet-scaffold/src/__SolutionName__.Infrastructure/obj/Debug/net10.0/__SolutionName__.Infrastructure.AssemblyInfoInputs.cache +1 -1
  18. package/template/dotnet-scaffold/tests/__SolutionName__.Api.Tests/obj/Debug/net10.0/__SolutionName__.Api.Tests.AssemblyInfo.cs +1 -1
  19. package/template/dotnet-scaffold/tests/__SolutionName__.Api.Tests/obj/Debug/net10.0/__SolutionName__.Api.Tests.AssemblyInfoInputs.cache +1 -1
  20. package/template/dotnet-scaffold/tests/__SolutionName__.Application.Tests/obj/Debug/net10.0/__SolutionName__.Application.Tests.AssemblyInfo.cs +1 -1
  21. package/template/dotnet-scaffold/tests/__SolutionName__.Application.Tests/obj/Debug/net10.0/__SolutionName__.Application.Tests.AssemblyInfoInputs.cache +1 -1
  22. package/template/dotnet-scaffold/tests/__SolutionName__.Domain.Tests/obj/Debug/net10.0/__SolutionName__.Domain.Tests.AssemblyInfo.cs +1 -1
  23. package/template/dotnet-scaffold/tests/__SolutionName__.Domain.Tests/obj/Debug/net10.0/__SolutionName__.Domain.Tests.AssemblyInfoInputs.cache +1 -1
  24. package/template/dotnet-scaffold/tests/__SolutionName__.Infrastructure.Tests/obj/Debug/net10.0/__SolutionName__.Infrastructure.Tests.AssemblyInfo.cs +1 -1
  25. package/template/dotnet-scaffold/tests/__SolutionName__.Infrastructure.Tests/obj/Debug/net10.0/__SolutionName__.Infrastructure.Tests.AssemblyInfoInputs.cache +1 -1
@@ -0,0 +1,357 @@
1
+ 'use strict';
2
+
3
+ // Structural checker for the two architecture-characteristic documents.
4
+ //
5
+ // Checks only what is mechanically decidable. The ranking ORDER is a judgement
6
+ // call and is never questioned here; what is checked is that the table is
7
+ // complete, that every comparative reason names the row it actually sits above,
8
+ // and that the worksheet is a faithful derivation of the ranking's top 7.
9
+ //
10
+ // The catalog markdown is the single source of truth for which characteristics
11
+ // exist, which of them are implicit, and how the composites decompose. Nothing
12
+ // here restates it — edit the markdown and this checker follows.
13
+
14
+ const fs = require('fs');
15
+ const path = require('path');
16
+
17
+ const DEFAULT_CATALOG_PATH = path.join(
18
+ __dirname,
19
+ '..',
20
+ '..',
21
+ '..',
22
+ 'skills',
23
+ 'architecture-characteristic-writer',
24
+ 'references',
25
+ 'characteristics-catalog.md'
26
+ );
27
+
28
+ const DRIVING_LIMIT = 7;
29
+
30
+ // The ranking table is found by its header row rather than by the heading above
31
+ // it. A heading is prose the author may reword or duplicate; this signature is
32
+ // the table itself. Both the checker and scripts/ranking-table.cjs locate the
33
+ // table this way, so neither can end up working on a different table.
34
+ const RANKING_HEADER = /^\|\s*order\s*\|\s*characteristic\s*\|\s*reason\s*\|/i;
35
+
36
+ // "feasibility (cost/time)" and "**feasibility**" both normalise to "feasibility"
37
+ // so the checker never argues with formatting.
38
+ function normalise(cell) {
39
+ return String(cell)
40
+ .replace(/`([^`]*)`/g, '$1')
41
+ .replace(/\*\*/g, '')
42
+ .replace(/\([^)]*\)/g, '')
43
+ .replace(/\s+/g, ' ')
44
+ .trim()
45
+ .toLowerCase();
46
+ }
47
+
48
+ const isPlaceholder = (row) => row.some((c) => /\{[a-z0-9_]+\}/i.test(c)) || row.some((c) => c === '...');
49
+
50
+ const splitCells = (line) =>
51
+ line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map((c) => c.trim());
52
+
53
+ const isSeparator = (cells) => cells.every((c) => /^:?-+:?$/.test(c) || c === '');
54
+
55
+ // Returns the data rows of the first pipe table under a heading matching `re`,
56
+ // or of the whole document when `re` is null. Header and separator are dropped.
57
+ function parseTable(markdown, re) {
58
+ const lines = markdown.split(/\r?\n/);
59
+ let start = 0;
60
+
61
+ if (re) {
62
+ const i = lines.findIndex((l) => /^#{1,6}\s/.test(l) && re.test(l));
63
+ if (i === -1) return null;
64
+ start = i + 1;
65
+ const next = lines.slice(start).findIndex((l) => /^#{1,6}\s/.test(l));
66
+ if (next !== -1) lines.length = start + next;
67
+ }
68
+
69
+ const rows = [];
70
+ let seenHeader = false;
71
+
72
+ for (const line of lines.slice(start)) {
73
+ const trimmed = line.trim();
74
+ if (!trimmed.startsWith('|')) {
75
+ if (seenHeader && rows.length) break;
76
+ continue;
77
+ }
78
+ const cells = splitCells(trimmed);
79
+ if (!seenHeader) {
80
+ seenHeader = true;
81
+ continue;
82
+ }
83
+ if (isSeparator(cells)) continue;
84
+ rows.push(cells);
85
+ }
86
+
87
+ return seenHeader ? rows : null;
88
+ }
89
+
90
+ // Line bounds of the table whose header matches `headerRe`, covering the header
91
+ // through the last consecutive pipe line. Returns null when there is no match.
92
+ function locateTable(lines, headerRe) {
93
+ for (let i = 0; i < lines.length; i += 1) {
94
+ if (!lines[i].trim().startsWith('|') || !headerRe.test(lines[i].trim())) continue;
95
+ let end = i + 1;
96
+ while (end < lines.length && lines[end].trim().startsWith('|')) end += 1;
97
+ return { start: i, end };
98
+ }
99
+ return null;
100
+ }
101
+
102
+ // Data rows of the table located by `headerRe`, header and separator dropped.
103
+ function tableRows(markdown, headerRe) {
104
+ const lines = markdown.split(/\r?\n/);
105
+ const at = locateTable(lines, headerRe);
106
+ if (!at) return null;
107
+ return lines
108
+ .slice(at.start + 1, at.end)
109
+ .map(splitCells)
110
+ .filter((c) => !isSeparator(c));
111
+ }
112
+
113
+ // Reads the catalog markdown and derives every set the checker needs from it.
114
+ // Throws when the file is absent or a section is unreadable; the caller decides
115
+ // whether that is fatal.
116
+ function loadCatalog(catalogPath = DEFAULT_CATALOG_PATH) {
117
+ const markdown = fs.readFileSync(catalogPath, 'utf8');
118
+
119
+ const names = (re) => {
120
+ const rows = parseTable(markdown, re);
121
+ if (!rows) throw new Error(`no table found under a heading matching ${re}`);
122
+ return rows.filter((r) => r.length && !isPlaceholder(r)).map((r) => normalise(r[0]));
123
+ };
124
+
125
+ const common = names(/common/i);
126
+ const implicit = names(/implicit/i);
127
+
128
+ const compositeRows = parseTable(markdown, /composite/i);
129
+ if (!compositeRows) throw new Error('no Composite Architecture Characteristics table found');
130
+
131
+ const composites = {};
132
+ for (const row of compositeRows.filter((r) => r.length >= 2 && !isPlaceholder(r))) {
133
+ composites[normalise(row[0])] = row[1].split('+').map(normalise).filter(Boolean);
134
+ }
135
+
136
+ if (!common.length || !implicit.length) throw new Error('the Common or Implicit section is empty');
137
+
138
+ return { catalog: [...common, ...implicit], implicit, composites };
139
+ }
140
+
141
+ function checkRanking(markdown, { catalog }) {
142
+ const errors = [];
143
+ const found = tableRows(markdown, RANKING_HEADER);
144
+ if (!found) {
145
+ return {
146
+ errors: ['ranking: no table with a "| Order | Characteristic | Reason |" header row'],
147
+ order: [],
148
+ };
149
+ }
150
+ const raw = found.filter((r) => r.length >= 3);
151
+ const unfilled = raw.filter(isPlaceholder);
152
+ const rows = raw.filter((r) => !isPlaceholder(r));
153
+
154
+ // A scaffold is a blank form, not a broken document: every reason is still a
155
+ // placeholder, bar the last row whose reason is only the em dash.
156
+ const hasContent = (r) => !isPlaceholder(r) && !/^[—-]$/.test(String(r[2]).trim());
157
+ if (raw.length && !raw.some(hasContent)) return { errors: [], order: [] };
158
+ if (!rows.length) return { errors: ['ranking: no table rows found'], order: [] };
159
+
160
+ // Half-filled, so adjacency cannot be judged: a filled row's neighbour may be
161
+ // one of the rows still holding a placeholder. Ask for the rest first.
162
+ if (unfilled.length) {
163
+ return {
164
+ errors: [`ranking: ${unfilled.length} row(s) still hold the {why} placeholder, fill them in`],
165
+ order: [],
166
+ };
167
+ }
168
+
169
+ const order = rows.map((r) => normalise(r[1]));
170
+
171
+ // Rule 1 — the Order column counts 1..N with no gaps and no repeats.
172
+ rows.forEach((r, i) => {
173
+ const n = Number(String(r[0]).trim());
174
+ if (n !== i + 1) errors.push(`ranking row ${i + 1}: Order column reads "${r[0]}", expected ${i + 1}`);
175
+ });
176
+
177
+ // Rule 2 — the ranked set is exactly the catalog. Nothing missing, nothing
178
+ // invented, nothing ranked twice.
179
+ const seen = new Set();
180
+ order.forEach((name, i) => {
181
+ if (!catalog.includes(name)) errors.push(`ranking row ${i + 1}: "${name}" is not in the catalog`);
182
+ if (seen.has(name)) errors.push(`ranking row ${i + 1}: "${name}" is ranked more than once`);
183
+ seen.add(name);
184
+ });
185
+ for (const name of catalog) {
186
+ if (!seen.has(name)) errors.push(`ranking: "${name}" is missing`);
187
+ }
188
+
189
+ // Rules 3 and 4 — every reason names the row it actually sits above. This is
190
+ // what goes stale after a reorder, so it is checked by string, not by eye.
191
+ rows.forEach((r, i) => {
192
+ const reason = String(r[2]).trim();
193
+ const last = i === rows.length - 1;
194
+
195
+ if (last) {
196
+ if (!/^[—-]$/.test(reason)) {
197
+ errors.push(`ranking row ${i + 1}: last row has nothing below it, its Reason must be "—"`);
198
+ }
199
+ return;
200
+ }
201
+
202
+ const below = order[i + 1];
203
+ const m = reason.match(/^above\s+([^:]+):\s*(.*)$/i);
204
+
205
+ if (!m) {
206
+ errors.push(`ranking row ${i + 1} (${order[i]}): Reason must start with "Above ${below}:"`);
207
+ return;
208
+ }
209
+ if (normalise(m[1]) !== below) {
210
+ errors.push(
211
+ `ranking row ${i + 1} (${order[i]}): Reason compares against "${m[1].trim()}" but the row below is "${below}"`
212
+ );
213
+ }
214
+ if (m[2].trim().length < 15) {
215
+ errors.push(`ranking row ${i + 1} (${order[i]}): Reason has a prefix but no justification after it`);
216
+ }
217
+ });
218
+
219
+ return { errors, order };
220
+ }
221
+
222
+ function checkWorksheet(markdown, order, { implicit, composites }) {
223
+ const errors = [];
224
+ const driving = (parseTable(markdown, /driving/i) || []).filter((r) => r.length >= 2 && !isPlaceholder(r));
225
+ const implicitRows = (parseTable(markdown, /implicit/i) || []).filter(
226
+ (r) => r.length >= 1 && !isPlaceholder(r)
227
+ );
228
+
229
+ if (!driving.length) return ['worksheet: no Driving Characteristics rows found'];
230
+
231
+ const names = driving.map((r) => normalise(r[1] !== undefined && r.length >= 3 ? r[1] : r[0]));
232
+
233
+ // Rule 5 — no backfill. Composing shrinks the section; it never refills.
234
+ if (names.length > DRIVING_LIMIT) {
235
+ errors.push(`worksheet: ${names.length} driving characteristics, maximum is ${DRIVING_LIMIT}`);
236
+ }
237
+
238
+ // Listed twice, so the count is a lie and the top 7 cannot all be present.
239
+ // Said plainly here, or it surfaces later as a confusing pile of "missing".
240
+ const twice = new Set();
241
+ names.forEach((name, i) => {
242
+ if (names.indexOf(name) !== i) twice.add(name);
243
+ });
244
+ for (const name of twice) errors.push(`worksheet: "${name}" is listed more than once`);
245
+
246
+ // Without a readable ranking the remaining rules have nothing to derive from.
247
+ if (!order.length) return errors;
248
+
249
+ const top = order.slice(0, DRIVING_LIMIT);
250
+ const absorbed = new Set();
251
+
252
+ for (const name of names) {
253
+ const parts = composites[name];
254
+
255
+ if (parts) {
256
+ // Rule 7 — a composite stands only when the whole of it ranked top 7.
257
+ const missing = parts.filter((p) => !top.includes(p));
258
+ if (missing.length) {
259
+ errors.push(
260
+ `worksheet: "${name}" needs all of [${parts.join(', ')}] in the top 7, missing [${missing.join(', ')}]`
261
+ );
262
+ }
263
+ parts.forEach((p) => absorbed.add(p));
264
+
265
+ // Rule 8 — a composite and its own parts must never be listed side by side.
266
+ const both = parts.filter((p) => names.includes(p));
267
+ if (both.length) {
268
+ errors.push(`worksheet: "${name}" is listed alongside its own component [${both.join(', ')}]`);
269
+ }
270
+ continue;
271
+ }
272
+
273
+ // Rule 6 — everything driving traces back to the top 7.
274
+ if (!top.includes(name)) {
275
+ const rank = order.indexOf(name);
276
+ errors.push(
277
+ rank === -1
278
+ ? `worksheet: "${name}" is not in the ranking at all`
279
+ : `worksheet: "${name}" is ranked ${rank + 1}, outside the top 7`
280
+ );
281
+ }
282
+ }
283
+
284
+ const present = new Set([...names, ...absorbed]);
285
+
286
+ // Rule 6b — the top 7 is taken whole. Every one of them is either listed or
287
+ // swallowed by a composite; none may be quietly dropped.
288
+ for (const name of top) {
289
+ if (!present.has(name)) {
290
+ errors.push(`worksheet: "${name}" is ranked ${order.indexOf(name) + 1} but is missing from the worksheet`);
291
+ }
292
+ }
293
+
294
+ // Rule 9 — the implicit set is a pure derivation: the catalog's implicit
295
+ // characteristics, less any that reached the top 7, counting those a
296
+ // composite swallowed.
297
+ const expected = implicit.filter((c) => !present.has(c));
298
+ const actual = implicitRows.map((r) => normalise(r[0])).filter((c) => implicit.includes(c));
299
+
300
+ for (const c of expected) {
301
+ if (!actual.includes(c)) errors.push(`worksheet: implicit characteristic "${c}" is missing`);
302
+ }
303
+ for (const c of actual) {
304
+ if (!expected.includes(c)) {
305
+ errors.push(`worksheet: "${c}" is in the top 7, so it must not be listed as implicit`);
306
+ }
307
+ }
308
+
309
+ return errors;
310
+ }
311
+
312
+ // ranking and worksheet are file contents; either may be null when absent.
313
+ // The catalog sets default to the shipped markdown; pass `catalog` to inject.
314
+ function check({ ranking = null, worksheet = null, catalog = null, catalogPath } = {}) {
315
+ let sets = catalog;
316
+
317
+ if (!sets) {
318
+ try {
319
+ sets = loadCatalog(catalogPath);
320
+ } catch (err) {
321
+ return [`cannot read the characteristics catalog, so nothing can be checked: ${err.message}`];
322
+ }
323
+ }
324
+
325
+ let errors = [];
326
+ let order = [];
327
+
328
+ if (ranking !== null) {
329
+ const res = checkRanking(ranking, sets);
330
+ errors = errors.concat(res.errors);
331
+ order = res.order;
332
+ }
333
+
334
+ if (worksheet !== null) {
335
+ if (ranking === null) {
336
+ errors.push('worksheet: no ranking file to derive from, write the ranking first');
337
+ } else {
338
+ errors = errors.concat(checkWorksheet(worksheet, order, sets));
339
+ }
340
+ }
341
+
342
+ return errors;
343
+ }
344
+
345
+ module.exports = {
346
+ check,
347
+ checkRanking,
348
+ checkWorksheet,
349
+ parseTable,
350
+ locateTable,
351
+ splitCells,
352
+ isSeparator,
353
+ normalise,
354
+ loadCatalog,
355
+ RANKING_HEADER,
356
+ DEFAULT_CATALOG_PATH,
357
+ };
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env node
2
+ // PostToolUse validation hook for the two architecture-characteristic documents.
3
+ //
4
+ // .claude/settings.json calls this after every Write/Edit/MultiEdit. If the
5
+ // edited file is the ranking or the worksheet, both are checked together — the
6
+ // worksheet is a derivation of the ranking, so neither can be judged alone.
7
+ //
8
+ // Hook exit-code contract:
9
+ // exit 0 -> ok (stdout shown in transcript)
10
+ // exit 2 -> blocking error (stderr fed back to Claude to fix)
11
+ // An unrelated edit, a missing file, or an unparseable payload all exit 0.
12
+ //
13
+ // Only structure is checked. The ranking ORDER is the architect's call and is
14
+ // never second-guessed here.
15
+
16
+ const fs = require('fs');
17
+ const path = require('path');
18
+
19
+ const RANKING = 'docs/architecture-characteristics-ranking.md';
20
+ const WORKSHEET = 'docs/architecture-characteristics.md';
21
+
22
+ function read(root, rel) {
23
+ try {
24
+ return fs.readFileSync(path.join(root, rel), 'utf8');
25
+ } catch {
26
+ return null;
27
+ }
28
+ }
29
+
30
+ try {
31
+ const root = process.env.CLAUDE_PROJECT_DIR || process.cwd();
32
+ const payload = JSON.parse(fs.readFileSync(0, 'utf8'));
33
+ const edited = payload?.tool_input?.file_path;
34
+
35
+ if (!edited) process.exit(0);
36
+
37
+ // Windows resolves paths case-insensitively, so the same document can arrive
38
+ // spelled several ways. Comparing case-sensitively there would let an edit
39
+ // slip past the gate unchecked, which is worse than checking one file twice.
40
+ const fold = (p) => (process.platform === 'win32' ? p.toLowerCase() : p);
41
+ const rel = path.relative(root, path.resolve(root, edited)).split(path.sep).join('/');
42
+ if (fold(rel) !== fold(RANKING) && fold(rel) !== fold(WORKSHEET)) process.exit(0);
43
+
44
+ const { check } = require('./lib/characteristics/checker.cjs');
45
+ const ranking = read(root, RANKING);
46
+
47
+ // Editing the ranking alone, before any worksheet exists, is the normal path
48
+ // through Phase A: `read` returns null and the worksheet rules stay quiet.
49
+ const worksheet = read(root, WORKSHEET);
50
+
51
+ const errors = check({ ranking, worksheet });
52
+
53
+ if (errors.length) {
54
+ process.stderr.write(
55
+ `Architecture characteristics: ${errors.length} structural problem(s) in ${rel}\n\n` +
56
+ errors.map((e) => ` - ${e}`).join('\n') +
57
+ '\n\nFix the file. These are mechanical rules, not judgement calls: the ranking must ' +
58
+ 'cover the catalog exactly once each, every Reason must name the row directly below it, ' +
59
+ 'and the worksheet must derive from the top 7.\n'
60
+ );
61
+ process.exit(2);
62
+ }
63
+ } catch {
64
+ /* fail open — a structure checker must never be the thing that breaks a session */
65
+ }
66
+ process.exit(0);