@gobing-ai/spur 0.3.41 → 0.3.43
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/.claude-plugin/marketplace.json +15 -0
- package/README.md +15 -1
- package/config/templates/AGENTS.md +5 -3
- package/config/workflows/task-pipeline.yaml +113 -12
- package/package.json +15 -12
- package/plugins/README.md +655 -0
- package/plugins/sp/README.md +621 -0
- package/plugins/sp/agents/expert-spur.md +109 -0
- package/plugins/sp/agents/super-coder.md +126 -0
- package/plugins/sp/agents/super-planner.md +283 -0
- package/plugins/sp/agents/super-reviewer.md +154 -0
- package/plugins/sp/commands/dev-arch.md +30 -0
- package/plugins/sp/commands/dev-brainstorm.md +40 -0
- package/plugins/sp/commands/dev-changelog.md +28 -0
- package/plugins/sp/commands/dev-daily.md +29 -0
- package/plugins/sp/commands/dev-debug.md +29 -0
- package/plugins/sp/commands/dev-dogfood.md +33 -0
- package/plugins/sp/commands/dev-featurechange.md +130 -0
- package/plugins/sp/commands/dev-find-conflict.md +51 -0
- package/plugins/sp/commands/dev-find-issue.md +76 -0
- package/plugins/sp/commands/dev-find-next.md +60 -0
- package/plugins/sp/commands/dev-fixall.md +33 -0
- package/plugins/sp/commands/dev-gitmsg.md +28 -0
- package/plugins/sp/commands/dev-gtd.md +97 -0
- package/plugins/sp/commands/dev-handover.md +26 -0
- package/plugins/sp/commands/dev-idea.md +50 -0
- package/plugins/sp/commands/dev-next.md +31 -0
- package/plugins/sp/commands/dev-parallel.md +30 -0
- package/plugins/sp/commands/dev-plan.md +46 -0
- package/plugins/sp/commands/dev-refine.md +48 -0
- package/plugins/sp/commands/dev-refineall.md +65 -0
- package/plugins/sp/commands/dev-refresh.md +32 -0
- package/plugins/sp/commands/dev-reverse.md +32 -0
- package/plugins/sp/commands/dev-review.md +31 -0
- package/plugins/sp/commands/dev-run.md +63 -0
- package/plugins/sp/commands/dev-runall.md +83 -0
- package/plugins/sp/commands/dev-simplify.md +39 -0
- package/plugins/sp/commands/dev-unit.md +29 -0
- package/plugins/sp/commands/dev-verify.md +48 -0
- package/plugins/sp/commands/dev-verifyall.md +72 -0
- package/plugins/sp/commands/dev-wrap.md +48 -0
- package/plugins/sp/commands/dev-wrapall.md +51 -0
- package/plugins/sp/commands/rule-add.md +18 -0
- package/plugins/sp/commands/rule-refine.md +18 -0
- package/plugins/sp/commands/rule-scan.md +18 -0
- package/plugins/sp/commands/spur-init.md +22 -0
- package/plugins/sp/commands/workflow-add.md +18 -0
- package/plugins/sp/commands/workflow-refine.md +18 -0
- package/plugins/sp/hooks/agent-hint.ts +60 -0
- package/plugins/sp/hooks/careful-guard.ts +147 -0
- package/plugins/sp/hooks/context-post-tool.ts +325 -0
- package/plugins/sp/hooks/context-session-start.ts +182 -0
- package/plugins/sp/hooks/context-session-stop.ts +95 -0
- package/plugins/sp/hooks/hooks.json +51 -0
- package/plugins/sp/hooks/pi/guard-extension.ts +341 -0
- package/plugins/sp/hooks/task-write-guard.ts +84 -0
- package/plugins/sp/plugin.json +8 -0
- package/plugins/sp/scripts/batch-preflight.ts +204 -0
- package/plugins/sp/scripts/daily-summary/daily-summary.ts +606 -0
- package/plugins/sp/scripts/daily-summary/logger.ts +28 -0
- package/plugins/sp/scripts/dogfood-testing/detect-pipeline-driving.ts +363 -0
- package/plugins/sp/scripts/dogfood-testing/validate-report.ts +168 -0
- package/plugins/sp/scripts/feature-sync-bounded.ts +451 -0
- package/plugins/sp/scripts/stage-registry-adapter.ts +1341 -0
- package/plugins/sp/scripts/task-size-precheck.ts +202 -0
- package/plugins/sp/scripts/validate-commands.ts +651 -0
- package/plugins/sp/scripts/validate-flag-contracts.ts +875 -0
- package/plugins/sp/skills/brainstorm/SKILL.md +361 -0
- package/plugins/sp/skills/brainstorm/agents/openai.yaml +14 -0
- package/plugins/sp/skills/brainstorm/examples/ideation-example.md +209 -0
- package/plugins/sp/skills/brainstorm/metadata.openclaw +7 -0
- package/plugins/sp/skills/brainstorm/references/grilling-interview.md +68 -0
- package/plugins/sp/skills/brainstorm/references/workflows.md +255 -0
- package/plugins/sp/skills/branch-workflow/SKILL.md +109 -0
- package/plugins/sp/skills/branch-workflow/references/branch-lifecycle.md +69 -0
- package/plugins/sp/skills/branch-workflow/references/worktree-patterns.md +100 -0
- package/plugins/sp/skills/code-implementation/SKILL.md +203 -0
- package/plugins/sp/skills/code-implementation/references/debugging.md +64 -0
- package/plugins/sp/skills/code-implementation/references/implementation-patterns.md +113 -0
- package/plugins/sp/skills/code-improvement/SKILL.md +231 -0
- package/plugins/sp/skills/code-improvement/references/deepening-signals.md +205 -0
- package/plugins/sp/skills/code-review/SKILL.md +112 -0
- package/plugins/sp/skills/code-review/references/receiving-code-review.md +70 -0
- package/plugins/sp/skills/code-review/references/review-lenses.md +117 -0
- package/plugins/sp/skills/code-review/references/self-review-checklist.md +53 -0
- package/plugins/sp/skills/code-review/references/verification-before-completion.md +68 -0
- package/plugins/sp/skills/code-simplification/SKILL.md +225 -0
- package/plugins/sp/skills/code-testing/SKILL.md +108 -0
- package/plugins/sp/skills/code-testing/references/stacks/bun-ts.md +165 -0
- package/plugins/sp/skills/code-testing/references/stacks/go.md +88 -0
- package/plugins/sp/skills/code-testing/references/stacks/python.md +117 -0
- package/plugins/sp/skills/code-testing/references/test-loop-breaker.md +37 -0
- package/plugins/sp/skills/code-testing/references/test-output-discipline.md +52 -0
- package/plugins/sp/skills/code-testing/references/unit-testing.md +212 -0
- package/plugins/sp/skills/code-verification/SKILL.md +535 -0
- package/plugins/sp/skills/code-verification/references/code-improvement.md +39 -0
- package/plugins/sp/skills/code-verification/references/secu-review.md +94 -0
- package/plugins/sp/skills/code-verification/references/verdict-schema.md +141 -0
- package/plugins/sp/skills/conflict-finding/SKILL.md +254 -0
- package/plugins/sp/skills/conflict-finding/references/authority-resolution.md +258 -0
- package/plugins/sp/skills/conflict-finding/references/comparison-protocol.md +277 -0
- package/plugins/sp/skills/conflict-finding/references/finding-contract.md +346 -0
- package/plugins/sp/skills/conflict-finding/references/remediation-routing.md +276 -0
- package/plugins/sp/skills/daily-summary/SKILL.md +171 -0
- package/plugins/sp/skills/daily-summary/agents/openai.yaml +13 -0
- package/plugins/sp/skills/doc-evolve/SKILL.md +175 -0
- package/plugins/sp/skills/doc-evolve/references/operations.md +93 -0
- package/plugins/sp/skills/dogfood-testing/SKILL.md +608 -0
- package/plugins/sp/skills/dogfood-testing/references/monitor-ledger.md +211 -0
- package/plugins/sp/skills/dogfood-testing/references/report-template.md +351 -0
- package/plugins/sp/skills/doubt-driven-development/SKILL.md +136 -0
- package/plugins/sp/skills/functional-review/SKILL.md +364 -0
- package/plugins/sp/skills/functional-review/references/verdict-schema.md +133 -0
- package/plugins/sp/skills/indexed-context/SKILL.md +164 -0
- package/plugins/sp/skills/issue-finding/SKILL.md +442 -0
- package/plugins/sp/skills/issue-finding/agents/openai.yaml +15 -0
- package/plugins/sp/skills/issue-finding/examples/expected-findings.json +27 -0
- package/plugins/sp/skills/issue-finding/examples/session-test-loop.jsonl +23 -0
- package/plugins/sp/skills/issue-finding/references/session-formats.md +151 -0
- package/plugins/sp/skills/next-feature/SKILL.md +125 -0
- package/plugins/sp/skills/next-feature/references/handoff-routing.md +92 -0
- package/plugins/sp/skills/next-feature/references/proposal-contract.md +49 -0
- package/plugins/sp/skills/next-feature/references/ranking-rubric.md +52 -0
- package/plugins/sp/skills/next-feature/references/signal-derivation.md +67 -0
- package/plugins/sp/skills/next-router/SKILL.md +181 -0
- package/plugins/sp/skills/next-router/references/messages.md +90 -0
- package/plugins/sp/skills/next-router/references/routing-table.md +216 -0
- package/plugins/sp/skills/parallel-execution/SKILL.md +148 -0
- package/plugins/sp/skills/parallel-execution/references/dispatch-surface.md +109 -0
- package/plugins/sp/skills/parallel-execution/references/fan-out-patterns.md +101 -0
- package/plugins/sp/skills/parallel-execution/references/result-synthesis.md +94 -0
- package/plugins/sp/skills/reverse-engineering/SKILL.md +375 -0
- package/plugins/sp/skills/reverse-engineering/agents/openai.yaml +18 -0
- package/plugins/sp/skills/source-driven-development/SKILL.md +124 -0
- package/plugins/sp/skills/spec-decomposition/SKILL.md +117 -0
- package/plugins/sp/skills/spec-decomposition/references/decomposition.md +539 -0
- package/plugins/sp/skills/spur-cli/SKILL.md +133 -0
- package/plugins/sp/skills/spur-cli/references/agent.md +179 -0
- package/plugins/sp/skills/spur-cli/references/features/acceptance-criteria.md +82 -0
- package/plugins/sp/skills/spur-cli/references/features/hierarchy-mece.md +177 -0
- package/plugins/sp/skills/spur-cli/references/features/roadmap-priority.md +84 -0
- package/plugins/sp/skills/spur-cli/references/features/verbs.md +122 -0
- package/plugins/sp/skills/spur-cli/references/features.md +234 -0
- package/plugins/sp/skills/spur-cli/references/init.md +119 -0
- package/plugins/sp/skills/spur-cli/references/message.md +98 -0
- package/plugins/sp/skills/spur-cli/references/rules/authoring-rules.md +212 -0
- package/plugins/sp/skills/spur-cli/references/rules/fine-tuning.md +148 -0
- package/plugins/sp/skills/spur-cli/references/rules/operations.md +228 -0
- package/plugins/sp/skills/spur-cli/references/rules/validation-and-extension.md +129 -0
- package/plugins/sp/skills/spur-cli/references/rules.md +209 -0
- package/plugins/sp/skills/spur-cli/references/serve.md +58 -0
- package/plugins/sp/skills/spur-cli/references/tasks/l3-guard-cheatsheet.md +98 -0
- package/plugins/sp/skills/spur-cli/references/tasks/section-editing.md +95 -0
- package/plugins/sp/skills/spur-cli/references/tasks/verbs.md +361 -0
- package/plugins/sp/skills/spur-cli/references/tasks.md +280 -0
- package/plugins/sp/skills/spur-cli/references/team.md +133 -0
- package/plugins/sp/skills/spur-cli/references/workflows/authoring-workflows.md +226 -0
- package/plugins/sp/skills/spur-cli/references/workflows/operations.md +211 -0
- package/plugins/sp/skills/spur-cli/references/workflows/validation-and-extension.md +129 -0
- package/plugins/sp/skills/spur-cli/references/workflows.md +355 -0
- package/plugins/sp/skills/spur-dev/SKILL.md +224 -0
- package/plugins/sp/skills/spur-dev/references/ac-style-guide.md +196 -0
- package/plugins/sp/skills/spur-dev/references/cross-cutting.md +680 -0
- package/plugins/sp/skills/spur-dev/references/decision-brief.md +80 -0
- package/plugins/sp/skills/spur-dev/references/dev-operations.md +546 -0
- package/plugins/sp/skills/spur-dev/references/done-housekeeping.md +164 -0
- package/plugins/sp/skills/spur-dev/references/execution-batch.md +790 -0
- package/plugins/sp/skills/spur-dev/references/execution-workflow.md +352 -0
- package/plugins/sp/skills/spur-dev/references/feature-link-helper.md +191 -0
- package/plugins/sp/skills/spur-dev/references/flag-glossary.md +417 -0
- package/plugins/sp/skills/spur-dev/references/gate-checklists.md +171 -0
- package/plugins/sp/skills/spur-dev/references/glossary.md +95 -0
- package/plugins/sp/skills/spur-dev/references/idea-evaluation.md +80 -0
- package/plugins/sp/skills/spur-dev/references/inline-pipeline-driver.md +120 -0
- package/plugins/sp/skills/spur-dev/references/planning-workflow.md +302 -0
- package/plugins/sp/skills/spur-dev/references/product-planning.md +206 -0
- package/plugins/sp/skills/spur-dev/references/section-batching.md +24 -0
- package/plugins/sp/skills/sys-architecture/SKILL.md +155 -0
- package/plugins/sp/skills/sys-architecture/references/decision-method.md +137 -0
- package/plugins/sp/skills/sys-architecture/references/upkeep-survey.md +75 -0
- package/plugins/sp/skills/sys-debugging/SKILL.md +228 -0
- package/plugins/sp/skills/sys-debugging/references/debugging-protocol.md +62 -0
- package/plugins/sp/skills/test-driven-development/SKILL.md +248 -0
- package/plugins/sp/skills/wayfinder/SKILL.md +292 -0
- package/spur.js +6035 -4963
|
@@ -0,0 +1,651 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* validate-commands — thin-wrapper contract validator for plugin sp commands
|
|
3
|
+
* (feature O, task 0309; ADR-032).
|
|
4
|
+
*
|
|
5
|
+
* Validates each plugins/sp/commands/<name>.md directly against the
|
|
6
|
+
* thin-wrapper contract — no registry, no generation. The .md files are the
|
|
7
|
+
* hand-editable SSOT; superskill owns per-platform emission.
|
|
8
|
+
*
|
|
9
|
+
* Gates:
|
|
10
|
+
* (a) heading whitelist — H1 title + the per-contract ordered section headings
|
|
11
|
+
* (b) frontmatter schema — description, argument-hint, allowed-tools; dev-only extras
|
|
12
|
+
* (c) target resolution — sp:<skill> refs, workflow files, procedure anchors
|
|
13
|
+
* (d) allowed-tools coherence — Skill present iff body contains Skill() call
|
|
14
|
+
* (e) dev-command argument contract — syntax-only hint, Argument Flags table columns,
|
|
15
|
+
* single glossary reference, and bidirectional hint↔table parity
|
|
16
|
+
*
|
|
17
|
+
* Exit non-zero listing every violation on stderr.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
21
|
+
import { join, resolve } from 'node:path';
|
|
22
|
+
|
|
23
|
+
// ─── Types ──────────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
export interface Violation {
|
|
26
|
+
/** Command basename without extension (dev-run). */
|
|
27
|
+
readonly command: string;
|
|
28
|
+
/** Gate tag (a|b|c|d). */
|
|
29
|
+
readonly gate: 'a' | 'b' | 'c' | 'd' | 'e';
|
|
30
|
+
/** Human-readable description of the violation. */
|
|
31
|
+
readonly message: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ValidationResult {
|
|
35
|
+
readonly violations: readonly Violation[];
|
|
36
|
+
readonly fileCount: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ─── Parsing ────────────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
/** GitHub-style heading slug for anchor resolution. */
|
|
42
|
+
function slugify(heading: string): string {
|
|
43
|
+
return heading
|
|
44
|
+
.toLowerCase()
|
|
45
|
+
.replace(/[^\p{Letter}\p{Number} -]/gu, '')
|
|
46
|
+
.replaceAll(' ', '-');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface ParsedCommand {
|
|
50
|
+
readonly name: string;
|
|
51
|
+
readonly title: string;
|
|
52
|
+
readonly headings: readonly string[];
|
|
53
|
+
readonly description: string | undefined;
|
|
54
|
+
readonly argumentHint: string | undefined;
|
|
55
|
+
readonly allowedTools: string[] | undefined;
|
|
56
|
+
readonly body: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function parseCommand(filePath: string, name: string): ParsedCommand {
|
|
60
|
+
const raw = readFileSync(filePath, 'utf8');
|
|
61
|
+
|
|
62
|
+
// Extract frontmatter between --- delimiters
|
|
63
|
+
const fmMatch = raw.match(/^---\n([\s\S]*?)\n---/);
|
|
64
|
+
const body = fmMatch ? raw.slice(fmMatch[0].length).trim() : raw;
|
|
65
|
+
const fm = fmMatch ? fmMatch[1] : '';
|
|
66
|
+
|
|
67
|
+
const description = extractYamlField(fm, 'description');
|
|
68
|
+
const argumentHint = extractYamlField(fm, 'argument-hint');
|
|
69
|
+
const allowedTools = extractYamlList(fm, 'allowed-tools');
|
|
70
|
+
|
|
71
|
+
const lines = body.split('\n');
|
|
72
|
+
const title = lines[0]?.startsWith('# ') ? lines[0].slice(2).trim() : '';
|
|
73
|
+
|
|
74
|
+
// Collect headings at EVERY level (not just `## `) so lifecycle prose cannot
|
|
75
|
+
// hide under a `### Behavior` subheading — gate (a) asserts the set is exactly
|
|
76
|
+
// {Usage, Implementation}. Fenced code blocks are skipped so a shell comment
|
|
77
|
+
// (`# regenerate …`) in an example is never mistaken for a heading; commands
|
|
78
|
+
// are hand-authored now, so that false positive would be a real trap.
|
|
79
|
+
const headings: string[] = [];
|
|
80
|
+
let inFence = false;
|
|
81
|
+
for (const [index, line] of lines.entries()) {
|
|
82
|
+
if (line.startsWith('```')) {
|
|
83
|
+
inFence = !inFence;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (inFence) continue;
|
|
87
|
+
if (index === 0 && line.startsWith('# ')) continue; // H1 title, not a section heading
|
|
88
|
+
if (line.startsWith('#')) {
|
|
89
|
+
headings.push(line.replace(/^#+\s*/, '').trim());
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return { name, title, headings, description, argumentHint, allowedTools, body };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Extract a plain YAML string field (handles quoted and unquoted). */
|
|
97
|
+
function extractYamlField(fm: string, key: string): string | undefined {
|
|
98
|
+
const re = new RegExp(`^${key}:\\s*(.+)$`, 'm');
|
|
99
|
+
const m = fm.match(re);
|
|
100
|
+
if (!m) return undefined;
|
|
101
|
+
const value = m[1].trim();
|
|
102
|
+
// Unquote double-quoted YAML scalars
|
|
103
|
+
if (value.startsWith('"') && value.endsWith('"')) {
|
|
104
|
+
return value.slice(1, -1).replace(/\\"/g, '"');
|
|
105
|
+
}
|
|
106
|
+
return value;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Extract a YAML flow list ["A", "B"]. */
|
|
110
|
+
function extractYamlList(fm: string, key: string): string[] | undefined {
|
|
111
|
+
const re = new RegExp(`^${key}:\\s*\\[(.*)\\]$`, 'm');
|
|
112
|
+
const m = fm.match(re);
|
|
113
|
+
if (!m) return undefined;
|
|
114
|
+
return [...m[1].matchAll(/"([^"]*)"/g)].map((q) => q[1]);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const ALLOWED_HEADINGS: Record<string, true> = { Usage: true, Implementation: true };
|
|
118
|
+
|
|
119
|
+
/** The heading set every non-dev command must carry — exactly these, no more, no fewer. */
|
|
120
|
+
const REQUIRED_HEADINGS = ['Usage', 'Implementation'] as const;
|
|
121
|
+
|
|
122
|
+
/** Dev commands carry an ordered Argument Flags → Usage → Implementation section sequence. */
|
|
123
|
+
const DEV_REQUIRED_HEADINGS = ['Argument Flags', 'Usage', 'Implementation'] as const;
|
|
124
|
+
|
|
125
|
+
const DEV_REQUIRED_SET: Record<string, true> = {
|
|
126
|
+
'Argument Flags': true,
|
|
127
|
+
Implementation: true,
|
|
128
|
+
Usage: true,
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/** Canonical glossary reference every dev command carries exactly once. */
|
|
132
|
+
const GLOSSARY_REF = '../skills/spur-dev/references/flag-glossary.md';
|
|
133
|
+
|
|
134
|
+
function isDevCommand(name: string): boolean {
|
|
135
|
+
return name.startsWith('dev-');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function checkHeadingWhitelist(cmd: ParsedCommand): readonly Violation[] {
|
|
139
|
+
const violations: Violation[] = [];
|
|
140
|
+
const dev = isDevCommand(cmd.name);
|
|
141
|
+
const required = dev ? DEV_REQUIRED_HEADINGS : REQUIRED_HEADINGS;
|
|
142
|
+
const allowedSet = dev ? DEV_REQUIRED_SET : ALLOWED_HEADINGS;
|
|
143
|
+
const allowedList = dev ? DEV_REQUIRED_HEADINGS : REQUIRED_HEADINGS;
|
|
144
|
+
|
|
145
|
+
// Forbidden: anything outside the allowed set, at any heading level.
|
|
146
|
+
for (const h of cmd.headings) {
|
|
147
|
+
if (!allowedSet[h]) {
|
|
148
|
+
violations.push({
|
|
149
|
+
command: cmd.name,
|
|
150
|
+
gate: 'a',
|
|
151
|
+
message: `forbidden heading "${h}" (allowed: ${allowedList.map((x) => `## ${x}`).join(', ')})`,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Missing: a wrapper that simply omits a required section must not pass.
|
|
157
|
+
for (const req of required) {
|
|
158
|
+
if (!cmd.headings.includes(req)) {
|
|
159
|
+
violations.push({
|
|
160
|
+
command: cmd.name,
|
|
161
|
+
gate: 'a',
|
|
162
|
+
message: `missing required heading "## ${req}"`,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Duplicated: two `## Usage` sections is a malformed wrapper, not a thin one.
|
|
168
|
+
for (const req of required) {
|
|
169
|
+
if (cmd.headings.filter((h) => h === req).length > 1) {
|
|
170
|
+
violations.push({
|
|
171
|
+
command: cmd.name,
|
|
172
|
+
gate: 'a',
|
|
173
|
+
message: `duplicate heading "## ${req}"`,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Dev commands enforce order: Argument Flags immediately before Usage, Usage before Implementation.
|
|
179
|
+
if (dev) {
|
|
180
|
+
const indexes: Record<string, number> = {};
|
|
181
|
+
for (const [i, h] of cmd.headings.entries()) {
|
|
182
|
+
if (DEV_REQUIRED_SET[h] && indexes[h] === undefined) indexes[h] = i;
|
|
183
|
+
}
|
|
184
|
+
if (
|
|
185
|
+
indexes['Argument Flags'] !== undefined &&
|
|
186
|
+
indexes.Usage !== undefined &&
|
|
187
|
+
indexes['Argument Flags'] + 1 !== indexes.Usage
|
|
188
|
+
) {
|
|
189
|
+
violations.push({
|
|
190
|
+
command: cmd.name,
|
|
191
|
+
gate: 'a',
|
|
192
|
+
message: 'heading order: "## Argument Flags" must immediately precede "## Usage"',
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
if (
|
|
196
|
+
indexes.Usage !== undefined &&
|
|
197
|
+
indexes.Implementation !== undefined &&
|
|
198
|
+
indexes.Usage >= indexes.Implementation
|
|
199
|
+
) {
|
|
200
|
+
violations.push({
|
|
201
|
+
command: cmd.name,
|
|
202
|
+
gate: 'a',
|
|
203
|
+
message: 'heading order: "## Usage" must precede "## Implementation"',
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return violations;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ─── Gate (b): frontmatter schema ───────────────────────────────────────────
|
|
212
|
+
|
|
213
|
+
function checkFrontmatterSchema(cmd: ParsedCommand): readonly Violation[] {
|
|
214
|
+
const violations: Violation[] = [];
|
|
215
|
+
if (!cmd.description) {
|
|
216
|
+
violations.push({ command: cmd.name, gate: 'b', message: 'missing frontmatter description' });
|
|
217
|
+
}
|
|
218
|
+
if (!cmd.argumentHint) {
|
|
219
|
+
violations.push({ command: cmd.name, gate: 'b', message: 'missing frontmatter argument-hint' });
|
|
220
|
+
}
|
|
221
|
+
if (!cmd.allowedTools) {
|
|
222
|
+
violations.push({ command: cmd.name, gate: 'b', message: 'missing frontmatter allowed-tools' });
|
|
223
|
+
}
|
|
224
|
+
return violations;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ─── Gate (c): target resolution ────────────────────────────────────────────
|
|
228
|
+
|
|
229
|
+
function checkTargetResolution(cmd: ParsedCommand, skillsDir: string, root: string): readonly Violation[] {
|
|
230
|
+
const violations: Violation[] = [];
|
|
231
|
+
|
|
232
|
+
// Extract ## Implementation section content
|
|
233
|
+
const implPart = cmd.body.split(/^## Implementation\n/m)[1] ?? '';
|
|
234
|
+
const implSection = implPart.split(/\n## (?!Usage)/m)[0].trim();
|
|
235
|
+
|
|
236
|
+
// Skill() calls: Skill(skill="sp:NAME",
|
|
237
|
+
// The name charset must stay at least as wide as any name a skill directory can
|
|
238
|
+
// take: a ref this pattern does not match is silently not collected, so the gate
|
|
239
|
+
// would pass a genuinely unresolved reference rather than report it.
|
|
240
|
+
const skillCalls = [...implSection.matchAll(/Skill\(skill="sp:([a-z][a-z0-9-]*)"/g)];
|
|
241
|
+
for (const ref of skillCalls) {
|
|
242
|
+
const skillName = ref[1];
|
|
243
|
+
const skillPath = join(skillsDir, skillName, 'SKILL.md');
|
|
244
|
+
if (!existsSync(skillPath)) {
|
|
245
|
+
violations.push({
|
|
246
|
+
command: cmd.name,
|
|
247
|
+
gate: 'c',
|
|
248
|
+
message: `unresolved skill reference sp:${skillName} (missing ${skillPath})`,
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Workflow file refs — patterns like .spur/workflows/<name>.yaml
|
|
254
|
+
const workflowRefs = [...implSection.matchAll(/\.spur\/workflows\/([^\s)"\]]+\.yaml)/g)];
|
|
255
|
+
for (const ref of workflowRefs) {
|
|
256
|
+
const wfPath = join(root, '.spur', 'workflows', ref[1]);
|
|
257
|
+
if (!existsSync(wfPath)) {
|
|
258
|
+
violations.push({
|
|
259
|
+
command: cmd.name,
|
|
260
|
+
gate: 'c',
|
|
261
|
+
message: `unresolved workflow file ${ref[0]} (missing ${wfPath})`,
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Procedure reference + anchor — markdown links with anchors in Implementation
|
|
267
|
+
const anchorRefs = [...implSection.matchAll(/\[([^\]]*)\]\(([^)]*#([^)]*))\)/g)];
|
|
268
|
+
for (const ref of anchorRefs) {
|
|
269
|
+
const filePart = ref[2].split('#')[0];
|
|
270
|
+
const anchor = ref[3];
|
|
271
|
+
// Resolve relative paths from commands/ directory
|
|
272
|
+
const resolved = resolve(join(root, 'plugins', 'sp', 'commands'), filePart);
|
|
273
|
+
if (!existsSync(resolved)) {
|
|
274
|
+
violations.push({
|
|
275
|
+
command: cmd.name,
|
|
276
|
+
gate: 'c',
|
|
277
|
+
message: `unresolved procedure reference ${filePart} (missing ${resolved})`,
|
|
278
|
+
});
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
// Check anchor exists
|
|
282
|
+
const content = readFileSync(resolved, 'utf8');
|
|
283
|
+
const headings = content.split('\n').filter((l) => l.startsWith('#'));
|
|
284
|
+
const slugAnchors = headings.map((l) => slugify(l.replace(/^#+\s*/, '').trim()));
|
|
285
|
+
// Honor explicit `**Anchor:** `#id`` directives (the glossary convention in
|
|
286
|
+
// flag-glossary.md) so a shared-flag entry can expose a stable `#flag-<name>`
|
|
287
|
+
// anchor independent of its heading text.
|
|
288
|
+
const explicitAnchors = [...content.matchAll(/^\*\*Anchor:\*\*\s*`#([^`]+)`/gm)].map((m) => m[1]);
|
|
289
|
+
const anchors = [...slugAnchors, ...explicitAnchors];
|
|
290
|
+
if (!anchors.includes(anchor)) {
|
|
291
|
+
violations.push({
|
|
292
|
+
command: cmd.name,
|
|
293
|
+
gate: 'c',
|
|
294
|
+
message: `unresolved anchor #${anchor} in ${filePart}`,
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return violations;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ─── Gate (d): allowed-tools coherence ──────────────────────────────────────
|
|
303
|
+
|
|
304
|
+
function checkAllowedToolsCoherence(cmd: ParsedCommand): readonly Violation[] {
|
|
305
|
+
const violations: Violation[] = [];
|
|
306
|
+
if (!cmd.allowedTools) return violations;
|
|
307
|
+
|
|
308
|
+
const hasSkillTool = cmd.allowedTools.includes('Skill');
|
|
309
|
+
const hasSkillCall = cmd.body.includes('Skill(');
|
|
310
|
+
|
|
311
|
+
if (hasSkillTool && !hasSkillCall) {
|
|
312
|
+
violations.push({
|
|
313
|
+
command: cmd.name,
|
|
314
|
+
gate: 'd',
|
|
315
|
+
message: 'allowed-tools includes Skill but body contains no Skill() call',
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
if (!hasSkillTool && hasSkillCall) {
|
|
319
|
+
violations.push({
|
|
320
|
+
command: cmd.name,
|
|
321
|
+
gate: 'd',
|
|
322
|
+
message: 'body contains Skill() call but allowed-tools does not include Skill',
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return violations;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// ─── Gate (e): dev-command argument contract ────────────────────────────────
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Extract the `## Argument Flags` section body (between the heading and the next
|
|
333
|
+
* level-two heading or end of body). Returns the raw section text.
|
|
334
|
+
*/
|
|
335
|
+
function extractArgumentFlagsSection(body: string): string {
|
|
336
|
+
const marker = /^## Argument Flags\n/m;
|
|
337
|
+
const start = body.search(marker);
|
|
338
|
+
if (start < 0) return '';
|
|
339
|
+
const after = body.slice(start);
|
|
340
|
+
// Cut at the next level-two heading (## ) that is not the Argument Flags header itself.
|
|
341
|
+
const nextH2 = after.slice(after.indexOf('\n') + 1).search(/^## /m);
|
|
342
|
+
if (nextH2 < 0) return after;
|
|
343
|
+
return after.slice(0, after.indexOf('\n') + 1 + nextH2);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Parse a markdown table in a section. Returns the header cells and data rows.
|
|
348
|
+
* Header row is the first `|`-delimited row following the heading; the separator
|
|
349
|
+
* row (`| --- |`) is skipped; data rows are every subsequent `|`-delimited line
|
|
350
|
+
* until a blank line or non-table line.
|
|
351
|
+
*/
|
|
352
|
+
interface MarkdownTable {
|
|
353
|
+
header: string[];
|
|
354
|
+
rows: string[][];
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function parseMarkdownTable(section: string): MarkdownTable | null {
|
|
358
|
+
const lines = section.split('\n');
|
|
359
|
+
const tableLines: string[] = [];
|
|
360
|
+
let seenHeader = false;
|
|
361
|
+
for (const line of lines) {
|
|
362
|
+
const trimmed = line.trim();
|
|
363
|
+
if (!trimmed.startsWith('|')) {
|
|
364
|
+
if (seenHeader) break;
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
tableLines.push(trimmed);
|
|
368
|
+
seenHeader = true;
|
|
369
|
+
}
|
|
370
|
+
if (tableLines.length < 2) return null;
|
|
371
|
+
// Split on unescaped pipes only, then unescape `\|` → `|` per cell.
|
|
372
|
+
// Standard GFM: `\|` in a table cell renders as a literal `|`.
|
|
373
|
+
const split = (row: string): string[] =>
|
|
374
|
+
row
|
|
375
|
+
.replace(/^\|/, '')
|
|
376
|
+
.replace(/\|$/, '')
|
|
377
|
+
.split(/(?<!\\)\|/)
|
|
378
|
+
.map((c) => c.trim().replace(/\\\|/g, '|'));
|
|
379
|
+
const header = split(tableLines[0]);
|
|
380
|
+
// Skip the separator row (--- cells).
|
|
381
|
+
const dataRows = tableLines
|
|
382
|
+
.slice(1)
|
|
383
|
+
.filter((r) => !split(r).every((c) => /^:?-{2,}:?$/.test(c) || c === ''))
|
|
384
|
+
.map(split);
|
|
385
|
+
return { header, rows: dataRows };
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Extract flag and positional tokens from a canonical (syntax-only) argument-hint.
|
|
390
|
+
* Positionals: `<...>` tokens. Flags: `--flag` optionally with `<value>` / alternatives.
|
|
391
|
+
*/
|
|
392
|
+
function extractHintTokens(hint: string): { positionals: string[]; flags: string[] } {
|
|
393
|
+
const positionals = [...hint.matchAll(/<[^>]+>/g)].map((m) => m[0]);
|
|
394
|
+
// Flags: capture the `--flag` literal, ignoring the value placeholder that follows.
|
|
395
|
+
const flags = [...hint.matchAll(/(--[a-z][a-z0-9-]*)/g)].map((m) => m[1]);
|
|
396
|
+
// Deduplicate while preserving order.
|
|
397
|
+
return {
|
|
398
|
+
positionals: [...new Set(positionals)],
|
|
399
|
+
flags: [...new Set(flags)],
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Extract the flag / positional tokens declared in an Argument Flags table. The
|
|
405
|
+
* first cell of each row is the token: `<positional>` or `--flag` (with optional
|
|
406
|
+
* value placeholder and alternatives). For parity we compare the leading token
|
|
407
|
+
* (`--flag` or `<positional>`), since hint parity is on those literals.
|
|
408
|
+
*/
|
|
409
|
+
function extractTableTokens(rows: string[][]): { positionals: string[]; flags: string[] } {
|
|
410
|
+
const positionals: string[] = [];
|
|
411
|
+
const flags: string[] = [];
|
|
412
|
+
for (const row of rows) {
|
|
413
|
+
const cell = row[0] ?? '';
|
|
414
|
+
const pos = [...cell.matchAll(/<[^>]+>/g)].map((m) => m[0]);
|
|
415
|
+
const flg = cell.match(/--[a-z][a-z0-9-]*/);
|
|
416
|
+
if (pos.length > 0) positionals.push(...pos);
|
|
417
|
+
if (flg) flags.push(flg[0]); // Not `else if` — a cell like `--mode <full|implement>` has both.
|
|
418
|
+
}
|
|
419
|
+
return {
|
|
420
|
+
positionals: [...new Set(positionals)],
|
|
421
|
+
flags: [...new Set(flags)],
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** Flags whose table-row description marks them as alias / compat / no-op / deprecated. */
|
|
426
|
+
const COMPAT_MARKERS = /\b(alias|compat|no-op|deprecated|compatibility)\b/i;
|
|
427
|
+
|
|
428
|
+
function checkDevArgumentContract(cmd: ParsedCommand): readonly Violation[] {
|
|
429
|
+
if (!isDevCommand(cmd.name)) return [];
|
|
430
|
+
const violations: Violation[] = [];
|
|
431
|
+
const hint = cmd.argumentHint ?? '';
|
|
432
|
+
|
|
433
|
+
// 1. Syntax-only hint: no Markdown links (`](`) and no `<...>`-wrapped prose.
|
|
434
|
+
if (hint.includes('](')) {
|
|
435
|
+
violations.push({
|
|
436
|
+
command: cmd.name,
|
|
437
|
+
gate: 'e',
|
|
438
|
+
message: `argument-hint contains a Markdown link "](" — hint must be syntax only`,
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// 2. Argument Flags table: exactly one table with exactly Flag | Description | Default.
|
|
443
|
+
const section = extractArgumentFlagsSection(cmd.body);
|
|
444
|
+
const table = parseMarkdownTable(section);
|
|
445
|
+
if (!table) {
|
|
446
|
+
violations.push({
|
|
447
|
+
command: cmd.name,
|
|
448
|
+
gate: 'e',
|
|
449
|
+
message: '## Argument Flags section must contain a markdown table',
|
|
450
|
+
});
|
|
451
|
+
return violations;
|
|
452
|
+
}
|
|
453
|
+
const expectedCols = ['Flag', 'Description', 'Default'];
|
|
454
|
+
if (table.header.length !== 3 || !expectedCols.every((c, i) => table.header[i] === c)) {
|
|
455
|
+
violations.push({
|
|
456
|
+
command: cmd.name,
|
|
457
|
+
gate: 'e',
|
|
458
|
+
message: `Argument Flags table columns must be exactly "Flag | Description | Default" (got "${table.header.join(' | ')}")`,
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Every row must have a non-blank Default cell (deterministic default rule).
|
|
463
|
+
for (const [idx, row] of table.rows.entries()) {
|
|
464
|
+
const defaultCell = row[2];
|
|
465
|
+
if (defaultCell === undefined || defaultCell === '' || /^\s*$/.test(defaultCell)) {
|
|
466
|
+
violations.push({
|
|
467
|
+
command: cmd.name,
|
|
468
|
+
gate: 'e',
|
|
469
|
+
message: `Argument Flags row ${idx + 1} ("${row[0] ?? ''}") has a blank Default cell`,
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// 3. Exactly one canonical glossary reference.
|
|
475
|
+
const glossaryCount = (cmd.body.match(new RegExp(GLOSSARY_REF.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g')) ?? [])
|
|
476
|
+
.length;
|
|
477
|
+
if (glossaryCount !== 1) {
|
|
478
|
+
violations.push({
|
|
479
|
+
command: cmd.name,
|
|
480
|
+
gate: 'e',
|
|
481
|
+
message: `expected exactly one glossary reference to ${GLOSSARY_REF}, found ${glossaryCount}`,
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// 4. Bidirectional hint ↔ table parity.
|
|
486
|
+
const hintTokens = extractHintTokens(hint);
|
|
487
|
+
const tableTokens = extractTableTokens(table.rows);
|
|
488
|
+
|
|
489
|
+
// Forward: every hint positional and flag must appear in the table.
|
|
490
|
+
for (const pos of hintTokens.positionals) {
|
|
491
|
+
if (!tableTokens.positionals.includes(pos)) {
|
|
492
|
+
violations.push({
|
|
493
|
+
command: cmd.name,
|
|
494
|
+
gate: 'e',
|
|
495
|
+
message: `hint positional "${pos}" has no matching row in the Argument Flags table`,
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
for (const flag of hintTokens.flags) {
|
|
500
|
+
if (!tableTokens.flags.includes(flag)) {
|
|
501
|
+
violations.push({
|
|
502
|
+
command: cmd.name,
|
|
503
|
+
gate: 'e',
|
|
504
|
+
message: `hint flag "${flag}" has no matching row in the Argument Flags table`,
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// Reverse: every table flag must appear in the hint, unless the row marks it
|
|
510
|
+
// as an alias / compat / no-op / deprecated spelling (compatibility surface).
|
|
511
|
+
for (const flag of tableTokens.flags) {
|
|
512
|
+
if (hintTokens.flags.includes(flag)) continue;
|
|
513
|
+
const row = table.rows.find((r) => (r[0] ?? '').includes(flag));
|
|
514
|
+
const desc = row?.[1] ?? '';
|
|
515
|
+
if (!COMPAT_MARKERS.test(desc)) {
|
|
516
|
+
violations.push({
|
|
517
|
+
command: cmd.name,
|
|
518
|
+
gate: 'e',
|
|
519
|
+
message: `table flag "${flag}" is absent from the hint and is not marked alias/compat/no-op/deprecated`,
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
return violations;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// ─── Core validation ────────────────────────────────────────────────────────
|
|
528
|
+
|
|
529
|
+
export function validate(root: string = process.cwd()): ValidationResult {
|
|
530
|
+
const commandsDir = join(root, 'plugins', 'sp', 'commands');
|
|
531
|
+
const skillsDir = join(root, 'plugins', 'sp', 'skills');
|
|
532
|
+
|
|
533
|
+
const files = readdirSync(commandsDir)
|
|
534
|
+
.filter((f) => f.endsWith('.md'))
|
|
535
|
+
.sort();
|
|
536
|
+
const allViolations: Violation[] = [];
|
|
537
|
+
|
|
538
|
+
for (const file of files) {
|
|
539
|
+
const name = file.replace(/\.md$/, '');
|
|
540
|
+
const filePath = join(commandsDir, file);
|
|
541
|
+
const cmd = parseCommand(filePath, name);
|
|
542
|
+
|
|
543
|
+
allViolations.push(...checkHeadingWhitelist(cmd));
|
|
544
|
+
allViolations.push(...checkFrontmatterSchema(cmd));
|
|
545
|
+
allViolations.push(...checkTargetResolution(cmd, skillsDir, root));
|
|
546
|
+
allViolations.push(...checkAllowedToolsCoherence(cmd));
|
|
547
|
+
allViolations.push(...checkDevArgumentContract(cmd));
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
return { violations: allViolations, fileCount: files.length };
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// ─── CLI ────────────────────────────────────────────────────────────────────
|
|
554
|
+
|
|
555
|
+
export interface CliArgs {
|
|
556
|
+
readonly check: boolean;
|
|
557
|
+
readonly help: boolean;
|
|
558
|
+
readonly json: boolean;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
export function parseCliArgs(argv: string[]): CliArgs {
|
|
562
|
+
const args = new Set(argv);
|
|
563
|
+
return {
|
|
564
|
+
check: args.has('--check'),
|
|
565
|
+
help: args.has('--help') || args.has('-h'),
|
|
566
|
+
json: args.has('--json'),
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
export function renderHelp(): string {
|
|
571
|
+
return [
|
|
572
|
+
'validate-commands — enforce the thin-wrapper contract on plugins/sp/commands/*.md',
|
|
573
|
+
'',
|
|
574
|
+
'Usage: bun plugins/sp/scripts/validate-commands.ts [--json]',
|
|
575
|
+
'',
|
|
576
|
+
'Options:',
|
|
577
|
+
' --json Output JSON (default: human-readable)',
|
|
578
|
+
' --help Show this help',
|
|
579
|
+
'',
|
|
580
|
+
'Gates:',
|
|
581
|
+
' (a) heading whitelist — H1 title + per-contract ordered section headings',
|
|
582
|
+
' (b) frontmatter schema — description, argument-hint, allowed-tools present',
|
|
583
|
+
' (c) target resolution — sp:<skill>, workflow, procedure anchor exist on disk',
|
|
584
|
+
' (d) allowed-tools coherence — Skill <-> Skill() call',
|
|
585
|
+
' (e) dev argument contract — syntax-only hint, Argument Flags table columns,',
|
|
586
|
+
' single glossary reference, and bidirectional hint↔table parity (dev-* only)',
|
|
587
|
+
].join('\n');
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
export interface CliResult {
|
|
591
|
+
readonly exitCode: number;
|
|
592
|
+
readonly stdout: string;
|
|
593
|
+
readonly stderr: string;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
export function runCli(
|
|
597
|
+
argv: string[],
|
|
598
|
+
opts?: {
|
|
599
|
+
validateFn?: (root: string) => ValidationResult;
|
|
600
|
+
},
|
|
601
|
+
): CliResult {
|
|
602
|
+
const args = parseCliArgs(argv);
|
|
603
|
+
|
|
604
|
+
if (args.help) {
|
|
605
|
+
return { exitCode: 0, stdout: renderHelp(), stderr: '' };
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
const v = (opts?.validateFn ?? validate)(process.cwd());
|
|
609
|
+
|
|
610
|
+
if (args.json) {
|
|
611
|
+
return { exitCode: v.violations.length > 0 ? 1 : 0, stdout: JSON.stringify(v), stderr: '' };
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
if (v.violations.length === 0) {
|
|
615
|
+
const msg = `${v.fileCount} commands pass all 5 thin-wrapper gates.\n`;
|
|
616
|
+
return { exitCode: 0, stdout: msg, stderr: '' };
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
const lines = v.violations.map((vi) => `(${vi.gate})\t${vi.command}\t${vi.message}`);
|
|
620
|
+
const stderr = [
|
|
621
|
+
`${v.violations.length} violation(s) across ${v.fileCount} commands:`,
|
|
622
|
+
...lines,
|
|
623
|
+
'',
|
|
624
|
+
'Fix the commands and re-run. Commands are hand-editable — no regeneration step.',
|
|
625
|
+
].join('\n');
|
|
626
|
+
|
|
627
|
+
return { exitCode: 1, stdout: '', stderr };
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* Entry-point boot — runs the CLI using process.argv. Tests inject exit /
|
|
632
|
+
* stdout/stderr spies instead of spawning a subprocess.
|
|
633
|
+
*/
|
|
634
|
+
export function bootMain(
|
|
635
|
+
argv: string[] = process.argv.slice(2),
|
|
636
|
+
opts?: {
|
|
637
|
+
exit?: (code?: number) => void;
|
|
638
|
+
stdout?: { write(s: string): boolean };
|
|
639
|
+
stderr?: { write(s: string): boolean };
|
|
640
|
+
run?: (argv: string[]) => CliResult;
|
|
641
|
+
},
|
|
642
|
+
): void {
|
|
643
|
+
const result = (opts?.run ?? runCli)(argv);
|
|
644
|
+
if (result.stdout) (opts?.stdout ?? process.stdout).write(result.stdout);
|
|
645
|
+
if (result.stderr) (opts?.stderr ?? process.stderr).write(result.stderr);
|
|
646
|
+
(opts?.exit ?? process.exit)(result.exitCode);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
if (import.meta.main) {
|
|
650
|
+
bootMain();
|
|
651
|
+
}
|