docguard-cli 0.20.0 → 0.21.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -2
- package/cli/commands/demo.mjs +241 -0
- package/cli/commands/init.mjs +81 -8
- package/cli/docguard.mjs +22 -1
- package/cli/ensure-skills.mjs +50 -8
- package/extensions/spec-kit-docguard/extension.yml +1 -1
- package/extensions/spec-kit-docguard/skills/docguard-fix/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/skills/docguard-guard/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/skills/docguard-review/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/skills/docguard-score/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/skills/docguard-sync/SKILL.md +1 -1
- package/package.json +1 -1
- package/templates/demo-fixture/.docguard.json +8 -0
- package/templates/demo-fixture/.env.example +5 -0
- package/templates/demo-fixture/AGENTS.md +14 -0
- package/templates/demo-fixture/CHANGELOG.md +13 -0
- package/templates/demo-fixture/DRIFT-LOG.md +3 -0
- package/templates/demo-fixture/README.md +17 -0
- package/templates/demo-fixture/docs-canonical/API-REFERENCE.md +36 -0
- package/templates/demo-fixture/docs-canonical/ARCHITECTURE.md +30 -0
- package/templates/demo-fixture/docs-canonical/DATA-MODEL.md +30 -0
- package/templates/demo-fixture/docs-canonical/ENVIRONMENT.md +20 -0
- package/templates/demo-fixture/docs-canonical/SECURITY.md +15 -0
- package/templates/demo-fixture/docs-canonical/TEST-SPEC.md +10 -0
- package/templates/demo-fixture/package.json +10 -0
- package/templates/demo-fixture/src/api.mjs +18 -0
- package/templates/demo-fixture/src/notifier.mjs +23 -0
- package/templates/demo-fixture/src/scheduler.mjs +8 -0
- package/templates/demo-fixture/src/worker.mjs +15 -0
package/README.md
CHANGED
|
@@ -12,6 +12,14 @@
|
|
|
12
12
|
|
|
13
13
|
---
|
|
14
14
|
|
|
15
|
+
> **✨ See what DocGuard catches in 30 seconds — no install, no setup:**
|
|
16
|
+
> ```bash
|
|
17
|
+
> npx docguard-cli demo
|
|
18
|
+
> ```
|
|
19
|
+
> Runs against a baked-in sample project with intentional drift and shows you the findings + a clear path to fixing them.
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
15
23
|
## Table of Contents
|
|
16
24
|
|
|
17
25
|
- [What is DocGuard?](#what-is-docguard)
|
|
@@ -51,7 +59,7 @@ DocGuard is an official [GitHub Spec Kit](https://github.com/github/spec-kit) co
|
|
|
51
59
|
|
|
52
60
|
```mermaid
|
|
53
61
|
graph TD
|
|
54
|
-
CLI["CLI Entry<br/>docguard.mjs"] --> Commands["Commands (
|
|
62
|
+
CLI["CLI Entry<br/>docguard.mjs"] --> Commands["Commands (14)"]
|
|
55
63
|
Commands --> guard["guard"]
|
|
56
64
|
Commands --> generate["generate"]
|
|
57
65
|
Commands --> score["score"]
|
|
@@ -235,7 +243,7 @@ This installs DocGuard's slash commands (`/docguard.guard`, `/docguard.review`,
|
|
|
235
243
|
|
|
236
244
|
## Usage
|
|
237
245
|
|
|
238
|
-
DocGuard ships **
|
|
246
|
+
DocGuard ships **14 commands** (the "Daily 5" + 9 situational tools, including the zero-install `demo`). Six additional one-shot scaffolders are accessed via `docguard init --with <name>`. Eight v0.19 commands continue to work as deprecation aliases through v0.20.x — see [MIGRATION-v0.20.md](docs-implementation/MIGRATION-v0.20.md).
|
|
239
247
|
|
|
240
248
|
**The Daily 5** — what you'll reach for 95% of the time:
|
|
241
249
|
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Demo Command — v0.21.
|
|
3
|
+
*
|
|
4
|
+
* The 30-second "ah-ha" experience for devs shopping for doc tools.
|
|
5
|
+
*
|
|
6
|
+
* npx docguard-cli demo
|
|
7
|
+
*
|
|
8
|
+
* Spins up a baked-in fixture project (`templates/demo-fixture/`) — a 4-service
|
|
9
|
+
* payments API with INTENTIONAL doc drift — runs guard against it, and prints
|
|
10
|
+
* a curated narrative with real-world-impact annotations + a clear install CTA.
|
|
11
|
+
*
|
|
12
|
+
* Zero install required, zero damage to the user's environment: the fixture
|
|
13
|
+
* is copied to a temp directory, git-initialized there, and cleaned up on exit.
|
|
14
|
+
*
|
|
15
|
+
* Why this exists: per SURFACE-AUDIT v0.21 plan, the #2 friction point for
|
|
16
|
+
* adoption was "no demo path — devs have to install, init, write docs, run
|
|
17
|
+
* guard just to see what we do." This command compresses that to 30 seconds.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { mkdtempSync, rmSync, cpSync, existsSync, writeFileSync } from 'node:fs';
|
|
21
|
+
import { resolve, dirname, join } from 'node:path';
|
|
22
|
+
import { tmpdir } from 'node:os';
|
|
23
|
+
import { fileURLToPath } from 'node:url';
|
|
24
|
+
import { spawnSync } from 'node:child_process';
|
|
25
|
+
import { c } from '../shared.mjs';
|
|
26
|
+
import { runGuardInternal, classifyResult } from './guard.mjs';
|
|
27
|
+
import { runScoreInternal } from './score.mjs';
|
|
28
|
+
import { loadConfig } from '../docguard.mjs';
|
|
29
|
+
|
|
30
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
31
|
+
const __dirname = dirname(__filename);
|
|
32
|
+
const FIXTURE_SRC = resolve(__dirname, '../../templates/demo-fixture');
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Each warning pattern gets a 1-2 line "real-world impact" gloss. Keyed by
|
|
36
|
+
* a regex on the warning text; the first match wins. Falls back to the
|
|
37
|
+
* generic gloss for unrecognized warnings (so this dictionary stays
|
|
38
|
+
* resilient as validators evolve).
|
|
39
|
+
*
|
|
40
|
+
* The point: turn validator-speak ("Missing 'Setup Steps' section") into
|
|
41
|
+
* adopter-speak ("New devs spend an hour figuring out how to run this").
|
|
42
|
+
*/
|
|
43
|
+
const IMPACT_GLOSS = [
|
|
44
|
+
{
|
|
45
|
+
re: /env var.*not documented|missing.*Environment Variables/i,
|
|
46
|
+
impact: 'New devs hit cryptic "X is undefined" runtime errors at boot. CI bypasses the missing var entirely.',
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
re: /[Aa]rchitecture|service.*not (in|mentioned)|not in [Aa]rchitecture/,
|
|
50
|
+
impact: 'Your AI agent reads the architecture doc and gives wrong answers about how the system works.',
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
re: /missing.*Usage|missing.*License|README/,
|
|
54
|
+
impact: 'First-time visitors bounce. The README is the storefront — empty sections = lost trust.',
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
re: /endpoint|route|API-REFERENCE/i,
|
|
58
|
+
impact: 'Clients call a documented endpoint that no longer exists, or worse — miss a new endpoint entirely.',
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
re: /Test-Spec|test.*directory|test files/,
|
|
62
|
+
impact: 'Your TEST-SPEC doesn\'t reflect reality. New tests get written in the wrong place.',
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
re: /Unreleased.*section|Changelog/,
|
|
66
|
+
impact: 'Release automation can\'t auto-detect what\'s pending. Versioning becomes manual guesswork.',
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
re: /Spec.?Kit/i,
|
|
70
|
+
impact: 'Specs aren\'t structured for AI agents to use. You miss the multiplier on spec-driven development.',
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
re: /Config file.*not mentioned/,
|
|
74
|
+
impact: 'Devs see an unknown config file and don\'t know if it\'s safe to delete or required.',
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
re: /unlinked doc|not in your requiredFiles/,
|
|
78
|
+
impact: 'Doc lives in canonical/ but isn\'t in the manifest — guard skips it, drift accumulates silently.',
|
|
79
|
+
},
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
function getImpact(warning) {
|
|
83
|
+
for (const { re, impact } of IMPACT_GLOSS) {
|
|
84
|
+
if (re.test(warning)) return impact;
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Set up a temp copy of the fixture, git-init it, return the path.
|
|
91
|
+
*/
|
|
92
|
+
function setupFixture() {
|
|
93
|
+
const dir = mkdtempSync(join(tmpdir(), 'docguard-demo-'));
|
|
94
|
+
cpSync(FIXTURE_SRC, dir, { recursive: true });
|
|
95
|
+
// Initialize git so any history-aware validators (Freshness, Drift-Comments)
|
|
96
|
+
// can run without erroring. Identity is set locally so commit succeeds on
|
|
97
|
+
// CI runners that have no global git identity.
|
|
98
|
+
const opts = { cwd: dir, stdio: 'ignore' };
|
|
99
|
+
spawnSync('git', ['init', '-q', '-b', 'main'], opts);
|
|
100
|
+
spawnSync('git', ['config', 'user.email', 'demo@docguard.dev'], opts);
|
|
101
|
+
spawnSync('git', ['config', 'user.name', 'docguard-demo'], opts);
|
|
102
|
+
spawnSync('git', ['add', '-A'], opts);
|
|
103
|
+
spawnSync('git', ['commit', '-q', '-m', 'fixture'], opts);
|
|
104
|
+
return dir;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Pretty-print a curated guard run.
|
|
109
|
+
*/
|
|
110
|
+
function presentResults(guardData, scoreData) {
|
|
111
|
+
const allWarnings = [];
|
|
112
|
+
for (const v of guardData.validators) {
|
|
113
|
+
for (const w of (v.warnings || [])) {
|
|
114
|
+
allWarnings.push({ validator: v.name, message: w, severity: v.severity });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
console.log(`\n${c.bold}🔍 What DocGuard found in your fixture:${c.reset}`);
|
|
119
|
+
console.log(`${c.dim} Validators run: ${guardData.validators.length} · Warnings: ${allWarnings.length} · Time: ~0.5s${c.reset}\n`);
|
|
120
|
+
|
|
121
|
+
// Pick up to 5 warnings showing VARIETY across validators (not 5 from the
|
|
122
|
+
// same one). Dedupe by validator name; within each validator group, pick
|
|
123
|
+
// the highest-severity warning. Then rank the picks by severity.
|
|
124
|
+
const sev = { high: 0, medium: 1, low: 2 };
|
|
125
|
+
const byValidator = new Map();
|
|
126
|
+
for (const w of allWarnings) {
|
|
127
|
+
const prev = byValidator.get(w.validator);
|
|
128
|
+
if (!prev || (sev[w.severity] ?? 1) < (sev[prev.severity] ?? 1)) {
|
|
129
|
+
byValidator.set(w.validator, w);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const ranked = [...byValidator.values()].sort((a, b) => {
|
|
133
|
+
return (sev[a.severity] ?? 1) - (sev[b.severity] ?? 1);
|
|
134
|
+
});
|
|
135
|
+
const top = ranked.slice(0, 5);
|
|
136
|
+
|
|
137
|
+
for (let i = 0; i < top.length; i++) {
|
|
138
|
+
const w = top[i];
|
|
139
|
+
const sev = w.severity === 'high' ? `${c.red}[HIGH]${c.reset}`
|
|
140
|
+
: w.severity === 'low' ? `${c.dim}[LOW]${c.reset}`
|
|
141
|
+
: `${c.yellow}[MED]${c.reset}`;
|
|
142
|
+
console.log(` ${c.bold}${i + 1}.${c.reset} ${sev} ${c.cyan}${w.validator}${c.reset}`);
|
|
143
|
+
console.log(` ${c.dim}${w.message}${c.reset}`);
|
|
144
|
+
const impact = getImpact(w.message);
|
|
145
|
+
if (impact) {
|
|
146
|
+
console.log(` ${c.green}→${c.reset} ${impact}`);
|
|
147
|
+
}
|
|
148
|
+
console.log('');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (allWarnings.length > top.length) {
|
|
152
|
+
console.log(` ${c.dim}... and ${allWarnings.length - top.length} more. Run \`docguard guard\` in your repo to see everything.${c.reset}\n`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Score line
|
|
156
|
+
if (scoreData && typeof scoreData.score === 'number') {
|
|
157
|
+
const grade = scoreData.score >= 90 ? 'A' : scoreData.score >= 80 ? 'B' : scoreData.score >= 70 ? 'C' : scoreData.score >= 60 ? 'D' : 'F';
|
|
158
|
+
const color = scoreData.score >= 80 ? c.green : scoreData.score >= 60 ? c.yellow : c.red;
|
|
159
|
+
console.log(`${c.bold}📊 CDD Maturity Score:${c.reset} ${color}${scoreData.score}/100 (${grade})${c.reset}`);
|
|
160
|
+
console.log(`${c.dim} ↑ This is the fixture's score. Yours will hopefully be higher.${c.reset}\n`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function printCTA() {
|
|
165
|
+
console.log(`${c.bold}🛠️ Fixing drift like this:${c.reset}`);
|
|
166
|
+
console.log(` ${c.cyan}docguard fix --write${c.reset} ${c.dim}— patches the mechanical stuff (version refs, counts, anchors)${c.reset}`);
|
|
167
|
+
console.log(` ${c.cyan}docguard sync --write${c.reset} ${c.dim}— refreshes code-truth sections to match the codebase${c.reset}`);
|
|
168
|
+
console.log(` ${c.cyan}docguard diagnose${c.reset} ${c.dim}— generates an AI prompt for the prose drift (Claude/GPT/Cursor)${c.reset}\n`);
|
|
169
|
+
|
|
170
|
+
console.log(`${c.bold}🚀 Try it on YOUR project:${c.reset}`);
|
|
171
|
+
console.log(` ${c.green}npm install -g docguard-cli${c.reset}`);
|
|
172
|
+
console.log(` ${c.green}cd your-project${c.reset}`);
|
|
173
|
+
console.log(` ${c.green}docguard init${c.reset} ${c.dim}— scans existing code and proposes canonical docs${c.reset}`);
|
|
174
|
+
console.log(` ${c.green}docguard guard${c.reset} ${c.dim}— see what we catch${c.reset}\n`);
|
|
175
|
+
|
|
176
|
+
console.log(`${c.dim}Or stay zero-install:${c.reset}`);
|
|
177
|
+
console.log(` ${c.green}npx docguard-cli init${c.reset}`);
|
|
178
|
+
console.log(` ${c.green}npx docguard-cli guard${c.reset}\n`);
|
|
179
|
+
|
|
180
|
+
console.log(`${c.bold}📚 Learn more:${c.reset} ${c.cyan}https://github.com/raccioly/docguard${c.reset}`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Public entry point — `docguard demo`.
|
|
185
|
+
*
|
|
186
|
+
* @param {string} _projectDir — ignored; demo uses its own temp fixture
|
|
187
|
+
* @param {object} _config — ignored
|
|
188
|
+
* @param {object} flags — supports --quiet (skip banner) and --keep (don't cleanup fixture)
|
|
189
|
+
*/
|
|
190
|
+
export function runDemo(_projectDir, _config, flags = {}) {
|
|
191
|
+
if (!flags.quiet) {
|
|
192
|
+
console.log(`\n${c.bold}🎬 DocGuard Demo${c.reset} ${c.dim}— see what we catch in 30 seconds${c.reset}`);
|
|
193
|
+
console.log(`${c.dim} No install. No setup. We're running against a sample 4-service payments API${c.reset}`);
|
|
194
|
+
console.log(`${c.dim} with intentional drift between code and docs.${c.reset}\n`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (!existsSync(FIXTURE_SRC)) {
|
|
198
|
+
console.error(`${c.red}Demo fixture not found at ${FIXTURE_SRC}.${c.reset}`);
|
|
199
|
+
console.error(`${c.dim}If this is a packaging bug, please file an issue.${c.reset}`);
|
|
200
|
+
process.exit(1);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
let fixture;
|
|
204
|
+
try {
|
|
205
|
+
fixture = setupFixture();
|
|
206
|
+
if (!flags.quiet) console.log(`${c.dim} Fixture ready at ${fixture}${c.reset}\n`);
|
|
207
|
+
} catch (err) {
|
|
208
|
+
if (fixture) rmSync(fixture, { recursive: true, force: true });
|
|
209
|
+
console.error(`${c.red}Failed to set up demo fixture: ${err.message}${c.reset}`);
|
|
210
|
+
process.exit(1);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Run guard + score against the fixture
|
|
214
|
+
let guardData, scoreData;
|
|
215
|
+
try {
|
|
216
|
+
// Load full config (defaults + fixture's .docguard.json) — same path the
|
|
217
|
+
// real `docguard guard` uses. The fixture ships its own .docguard.json
|
|
218
|
+
// so this hydrates the right project name + profile.
|
|
219
|
+
const config = loadConfig(fixture);
|
|
220
|
+
guardData = runGuardInternal(fixture, config);
|
|
221
|
+
scoreData = runScoreInternal(fixture, config);
|
|
222
|
+
} catch (err) {
|
|
223
|
+
rmSync(fixture, { recursive: true, force: true });
|
|
224
|
+
console.error(`${c.red}Demo guard run failed: ${err.message}${c.reset}`);
|
|
225
|
+
process.exit(1);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
presentResults(guardData, scoreData);
|
|
229
|
+
printCTA();
|
|
230
|
+
|
|
231
|
+
// Cleanup unless --keep
|
|
232
|
+
if (!flags.keep) {
|
|
233
|
+
rmSync(fixture, { recursive: true, force: true });
|
|
234
|
+
if (!flags.quiet) console.log(`${c.dim} Fixture cleaned up.${c.reset}`);
|
|
235
|
+
} else {
|
|
236
|
+
console.log(`${c.dim} Fixture kept at ${fixture} (--keep)${c.reset}`);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Always exit 0 — the demo is informational, never a failure
|
|
240
|
+
process.exit(0);
|
|
241
|
+
}
|
package/cli/commands/init.mjs
CHANGED
|
@@ -15,7 +15,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
15
15
|
import { createInterface } from 'node:readline';
|
|
16
16
|
import { execSync } from 'node:child_process';
|
|
17
17
|
import { c, PROFILES } from '../shared.mjs';
|
|
18
|
-
import { ensureSkills, detectAgentMode, detectAIAgent, isSpecKitAvailable, isSpecKitInitialized, getDetectedAgent } from '../ensure-skills.mjs';
|
|
18
|
+
import { ensureSkills, detectAgentMode, detectAIAgent, isSpecKitAvailable, isSpecKitInitialized, getDetectedAgent, safeSpawnSpecify } from '../ensure-skills.mjs';
|
|
19
19
|
|
|
20
20
|
// v0.20: scaffolder names that can be passed via `init --with <name>` and
|
|
21
21
|
// dispatched to the corresponding standalone runner. Each name maps to its
|
|
@@ -92,6 +92,61 @@ function askQuestion(prompt) {
|
|
|
92
92
|
|
|
93
93
|
// ── Init Command ─────────────────────────────────────────────────────────
|
|
94
94
|
|
|
95
|
+
/**
|
|
96
|
+
* v0.21 — Smart first-run detection.
|
|
97
|
+
*
|
|
98
|
+
* Heuristic: if the user is running `docguard init` against a project that
|
|
99
|
+
* already has substantial source code (cli/, src/, lib/, app/, or 10+ source
|
|
100
|
+
* files at depth 1-2) AND has no docs-canonical/ yet, switch from the
|
|
101
|
+
* skeleton-first path to the "scan and propose" path — i.e. dispatch to
|
|
102
|
+
* `docguard generate --plan` which reverse-engineers canonical docs from
|
|
103
|
+
* existing code.
|
|
104
|
+
*
|
|
105
|
+
* Rationale: blank skeletons feel useless for existing projects (the dev
|
|
106
|
+
* has to write everything from scratch). The scan path delivers immediate
|
|
107
|
+
* value: "here's what your project actually does, mapped to canonical doc
|
|
108
|
+
* shape." That's a 30-second wow for the 80% of adopters who arrive with
|
|
109
|
+
* an existing codebase.
|
|
110
|
+
*
|
|
111
|
+
* Opt out: `docguard init --skeleton` forces the blank-template path
|
|
112
|
+
* (preserves the v0.20 behavior for greenfield projects and CI flows).
|
|
113
|
+
*
|
|
114
|
+
* @returns {boolean} true if smart-detection fired and dispatched
|
|
115
|
+
*/
|
|
116
|
+
function shouldRunGenerate(projectDir, flags) {
|
|
117
|
+
if (flags.skeleton) return false; // explicit opt-out
|
|
118
|
+
if (flags.skipPrompts) return false; // non-interactive (CI) keeps deterministic skeleton path
|
|
119
|
+
if (flags.wizard) return false; // wizard has its own scan step
|
|
120
|
+
if (flags.profile) return false; // explicit profile = user knows what they want
|
|
121
|
+
|
|
122
|
+
// If canonical docs already exist, this is a re-init, not a first-run.
|
|
123
|
+
const canonicalDir = resolve(projectDir, 'docs-canonical');
|
|
124
|
+
if (existsSync(canonicalDir)) {
|
|
125
|
+
try {
|
|
126
|
+
const entries = readdirSync(canonicalDir).filter(f => f.endsWith('.md'));
|
|
127
|
+
if (entries.length > 0) return false;
|
|
128
|
+
} catch { /* fall through */ }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Existing-code signals: any of cli/, src/, lib/, app/ as a directory.
|
|
132
|
+
const codeDirs = ['cli', 'src', 'lib', 'app'];
|
|
133
|
+
for (const d of codeDirs) {
|
|
134
|
+
if (existsSync(resolve(projectDir, d))) return true;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Fallback: count source files at top level (Python / Rust / Go projects
|
|
138
|
+
// often don't use src/ — files live at the root).
|
|
139
|
+
try {
|
|
140
|
+
const exts = ['.py', '.rs', '.go', '.java', '.rb', '.ts', '.tsx', '.mjs', '.js'];
|
|
141
|
+
const topLevel = readdirSync(projectDir).filter(f => {
|
|
142
|
+
return exts.some(e => f.endsWith(e));
|
|
143
|
+
});
|
|
144
|
+
if (topLevel.length >= 10) return true;
|
|
145
|
+
} catch { /* fall through */ }
|
|
146
|
+
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
|
|
95
150
|
export async function runInit(projectDir, config, flags) {
|
|
96
151
|
// v0.20: `--wizard` dispatches to the full interactive onboarding (formerly
|
|
97
152
|
// `docguard setup`). Done before profile validation so the wizard can ask
|
|
@@ -101,6 +156,19 @@ export async function runInit(projectDir, config, flags) {
|
|
|
101
156
|
return runSetup(projectDir, config, flags);
|
|
102
157
|
}
|
|
103
158
|
|
|
159
|
+
// v0.21: smart first-run — for existing projects without canonical docs,
|
|
160
|
+
// dispatch to `generate --plan` (the "scan and propose" path). Opt out
|
|
161
|
+
// with --skeleton or by setting --profile/--skip-prompts/--wizard explicitly.
|
|
162
|
+
if (shouldRunGenerate(projectDir, flags)) {
|
|
163
|
+
console.log(`${c.bold}🔍 DocGuard Init — Smart Mode${c.reset}`);
|
|
164
|
+
console.log(`${c.dim} Detected existing project with code but no canonical docs.${c.reset}`);
|
|
165
|
+
console.log(`${c.dim} Switching to "scan and propose" mode — DocGuard will reverse-engineer${c.reset}`);
|
|
166
|
+
console.log(`${c.dim} canonical docs from your code instead of dumping a blank skeleton.${c.reset}`);
|
|
167
|
+
console.log(`${c.dim} (Opt out: ${c.cyan}docguard init --skeleton${c.dim} for the blank-template path.)${c.reset}\n`);
|
|
168
|
+
const { runGenerate } = await import('./generate.mjs');
|
|
169
|
+
return runGenerate(projectDir, config, { ...flags, plan: true });
|
|
170
|
+
}
|
|
171
|
+
|
|
104
172
|
const profileName = flags.profile || 'standard';
|
|
105
173
|
const profile = PROFILES[profileName];
|
|
106
174
|
|
|
@@ -310,17 +378,22 @@ poetry.lock
|
|
|
310
378
|
} else if (specKitAvailable && !specKitInitialized) {
|
|
311
379
|
console.log(`\n ${c.bold}🌱 Spec Kit Integration${c.reset}`);
|
|
312
380
|
|
|
313
|
-
// Detect which AI agent is in use (matches spec-kit's --ai flag)
|
|
381
|
+
// Detect which AI agent is in use (matches spec-kit's --ai flag).
|
|
382
|
+
// v0.21.1 (issue #190): the returned value is allowlist-validated inside
|
|
383
|
+
// getDetectedAgent, so an attacker-controlled `.specify/init-options.json`
|
|
384
|
+
// can no longer inject shell metacharacters here.
|
|
314
385
|
const detectedAgent = detectAIAgent(projectDir);
|
|
315
|
-
const
|
|
316
|
-
?
|
|
317
|
-
: '--ai generic --ai-commands-dir .agent/commands/';
|
|
386
|
+
const aiArgs = detectedAgent
|
|
387
|
+
? ['--ai', detectedAgent]
|
|
388
|
+
: ['--ai', 'generic', '--ai-commands-dir', '.agent/commands/'];
|
|
318
389
|
|
|
319
390
|
console.log(` ${c.dim}Running specify init (agent: ${detectedAgent || 'generic'})...${c.reset}`);
|
|
320
391
|
try {
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
392
|
+
// v0.21.1 (issue #190): execFileSync via safeSpawnSpecify — args pass
|
|
393
|
+
// through as an array, no shell interpolation.
|
|
394
|
+
const scriptArgs = process.platform === 'win32' ? ['--script', 'ps'] : ['--script', 'sh'];
|
|
395
|
+
safeSpawnSpecify(
|
|
396
|
+
['init', '--here', '--force', ...aiArgs, '--ai-skills', '--ignore-agent-tools', '--no-git', ...scriptArgs],
|
|
324
397
|
{ cwd: projectDir, encoding: 'utf-8', stdio: 'pipe', timeout: 30000 }
|
|
325
398
|
);
|
|
326
399
|
console.log(` ${c.green}✅${c.reset} Spec Kit initialized ${c.dim}(.specify/, spec-kit skills, agent: ${detectedAgent || 'generic'})${c.reset}`);
|
package/cli/docguard.mjs
CHANGED
|
@@ -44,6 +44,7 @@ import { runUpgrade } from './commands/upgrade.mjs';
|
|
|
44
44
|
import { runImpact } from './commands/impact.mjs';
|
|
45
45
|
import { runExplain } from './commands/explain.mjs';
|
|
46
46
|
import { runMemory } from './commands/memory.mjs';
|
|
47
|
+
import { runDemo } from './commands/demo.mjs';
|
|
47
48
|
import { ensureSkills } from './ensure-skills.mjs';
|
|
48
49
|
|
|
49
50
|
// ── Shared constants (imported to break circular dependencies) ──────────
|
|
@@ -282,14 +283,18 @@ function printHelp() {
|
|
|
282
283
|
console.log(`${c.bold}Usage:${c.reset}
|
|
283
284
|
docguard <command> [options]
|
|
284
285
|
|
|
286
|
+
${c.bold}First-time? Try the demo (no install, no setup):${c.reset}
|
|
287
|
+
${c.green}npx docguard-cli demo${c.reset} ${c.dim}— 30-second tour against a sample project${c.reset}
|
|
288
|
+
|
|
285
289
|
${c.bold}The Daily 5${c.reset} ${c.dim}— what you'll reach for 95% of the time${c.reset}
|
|
286
|
-
${c.green}init${c.reset} Bootstrap a project (
|
|
290
|
+
${c.green}init${c.reset} Bootstrap a project — auto-detects existing code and scans (${c.cyan}--skeleton${c.reset} for blank templates, ${c.cyan}--wizard${c.reset} for guided, ${c.cyan}--with <name>${c.reset} for scaffolders)
|
|
287
291
|
${c.green}guard${c.reset} Validate against canonical docs (23 validators)
|
|
288
292
|
${c.green}diff${c.reset} Show gaps between docs and code (add ${c.cyan}--since <ref>${c.reset} for changed-file impact)
|
|
289
293
|
${c.green}sync${c.reset} Refresh code-truth doc sections — keeps memory always up to date
|
|
290
294
|
${c.green}score${c.reset} CDD maturity score (0-100; ${c.cyan}--diff${c.reset} for delta between refs)
|
|
291
295
|
|
|
292
296
|
${c.bold}Tools (situational, but day-to-day useful)${c.reset}
|
|
297
|
+
${c.green}demo${c.reset} Zero-install tour: see what DocGuard catches against a sample project in 30s
|
|
293
298
|
${c.green}diagnose${c.reset} AI orchestrator — guard → emit fix prompts in one command
|
|
294
299
|
${c.green}fix${c.reset} Generate AI fix instructions for specific docs
|
|
295
300
|
${c.green}generate${c.reset} Reverse-engineer canonical docs from existing code (${c.cyan}--plan${c.reset} for AI scan)
|
|
@@ -471,6 +476,15 @@ async function main() {
|
|
|
471
476
|
// onboarding flow (previously `docguard setup`). `setup` keeps
|
|
472
477
|
// working as a deprecation alias.
|
|
473
478
|
flags.wizard = true;
|
|
479
|
+
} else if (args[i] === '--skeleton') {
|
|
480
|
+
// v0.21: `docguard init --skeleton` opts out of smart "scan and propose"
|
|
481
|
+
// detection and forces the blank-template path. Useful for greenfield
|
|
482
|
+
// projects and CI flows that want deterministic output.
|
|
483
|
+
flags.skeleton = true;
|
|
484
|
+
} else if (args[i] === '--keep') {
|
|
485
|
+
// v0.21: `docguard demo --keep` doesn't delete the temp fixture after
|
|
486
|
+
// running (useful for poking around what DocGuard set up).
|
|
487
|
+
flags.keep = true;
|
|
474
488
|
} else if (!args[i].startsWith('--') && i > 0) {
|
|
475
489
|
// Positional args go into flags.args for commands that take them (e.g.
|
|
476
490
|
// `docguard trace --reverse <path>`). Skip the command itself (i === 0).
|
|
@@ -657,6 +671,13 @@ async function main() {
|
|
|
657
671
|
case 'memory':
|
|
658
672
|
runMemory(projectDir, config, flags);
|
|
659
673
|
break;
|
|
674
|
+
case 'demo':
|
|
675
|
+
// v0.21: zero-install "ah-ha" moment — runs guard against a baked-in
|
|
676
|
+
// fixture (templates/demo-fixture/) and prints curated drift findings
|
|
677
|
+
// with real-world-impact annotations. No state changes outside the
|
|
678
|
+
// temp fixture dir, which is cleaned up on exit.
|
|
679
|
+
runDemo(projectDir, config, flags);
|
|
680
|
+
break;
|
|
660
681
|
default:
|
|
661
682
|
console.error(`${c.red}Unknown command: ${command}${c.reset}`);
|
|
662
683
|
console.log(`Run ${c.cyan}docguard --help${c.reset} for usage.`);
|
package/cli/ensure-skills.mjs
CHANGED
|
@@ -13,9 +13,32 @@
|
|
|
13
13
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync } from 'node:fs';
|
|
14
14
|
import { resolve, dirname } from 'node:path';
|
|
15
15
|
import { fileURLToPath } from 'node:url';
|
|
16
|
-
import { execSync } from 'node:child_process';
|
|
16
|
+
import { execSync, execFileSync } from 'node:child_process';
|
|
17
17
|
import { c } from './shared.mjs';
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* v0.21.1 (security): cross-platform safe spawn for the `specify` CLI.
|
|
21
|
+
*
|
|
22
|
+
* On POSIX, runs the `specify` binary directly with argv passed as an array
|
|
23
|
+
* — no shell interpolation possible. On Windows, the equivalent is via
|
|
24
|
+
* `cmd.exe /c specify.cmd ...` since `specify` is shipped as a .cmd shim by
|
|
25
|
+
* `pip install`. Args are still passed as an array so cmd.exe doesn't
|
|
26
|
+
* re-parse them.
|
|
27
|
+
*
|
|
28
|
+
* Replaces the pre-v0.21.1 pattern of `execSync(\`specify init ... \${flag} ...\`)`
|
|
29
|
+
* which was shell-interpolated and vulnerable to command injection via
|
|
30
|
+
* `.specify/init-options.json`'s `ai` field (issue #190).
|
|
31
|
+
*/
|
|
32
|
+
export function safeSpawnSpecify(args, opts) {
|
|
33
|
+
if (!Array.isArray(args)) {
|
|
34
|
+
throw new TypeError('safeSpawnSpecify(args, opts): args must be an array');
|
|
35
|
+
}
|
|
36
|
+
if (process.platform === 'win32') {
|
|
37
|
+
return execFileSync('cmd.exe', ['/c', 'specify.cmd', ...args], opts);
|
|
38
|
+
}
|
|
39
|
+
return execFileSync('specify', args, opts);
|
|
40
|
+
}
|
|
41
|
+
|
|
19
42
|
const __filename = fileURLToPath(import.meta.url);
|
|
20
43
|
const __dirname = dirname(__filename);
|
|
21
44
|
|
|
@@ -77,12 +100,27 @@ export function detectAgentMode(projectDir) {
|
|
|
77
100
|
* @param {string} projectDir - The project root directory
|
|
78
101
|
* @returns {string | null}
|
|
79
102
|
*/
|
|
103
|
+
// v0.21.1 (security): allowlist for the spec-kit --ai flag value. Source
|
|
104
|
+
// values come from `.specify/init-options.json` which is attacker-writable
|
|
105
|
+
// in any compromised project. Without this filter, a value like
|
|
106
|
+
// `"claude; touch /tmp/pwned;"` would shell-execute on every `docguard init`.
|
|
107
|
+
//
|
|
108
|
+
// Set conservatively from spec-kit's published agent list. New agents
|
|
109
|
+
// require a code change to be accepted — by design.
|
|
110
|
+
const VALID_AI_AGENT = /^[a-zA-Z0-9_-]{1,32}$/;
|
|
111
|
+
|
|
80
112
|
export function getDetectedAgent(projectDir) {
|
|
81
113
|
const initOptions = resolve(projectDir, '.specify', 'init-options.json');
|
|
82
114
|
if (existsSync(initOptions)) {
|
|
83
115
|
try {
|
|
84
116
|
const opts = JSON.parse(readFileSync(initOptions, 'utf-8'));
|
|
85
|
-
|
|
117
|
+
const ai = opts.ai;
|
|
118
|
+
if (typeof ai !== 'string') return null;
|
|
119
|
+
// v0.21.1 (issue #190): reject anything outside the allowlist. Without
|
|
120
|
+
// this, a malicious `.specify/init-options.json` could inject shell
|
|
121
|
+
// metacharacters through to the `specify init` exec call.
|
|
122
|
+
if (!VALID_AI_AGENT.test(ai)) return null;
|
|
123
|
+
return ai;
|
|
86
124
|
} catch { /* ignore */ }
|
|
87
125
|
}
|
|
88
126
|
return null;
|
|
@@ -193,13 +231,17 @@ export function ensureSpecKit(projectDir, flags = {}) {
|
|
|
193
231
|
console.log(` ${c.cyan}🌱 Spec Kit detected — auto-initializing SDD workflow...${c.reset}`);
|
|
194
232
|
}
|
|
195
233
|
try {
|
|
234
|
+
// v0.21.1 (issue #190): switched from shell-interpolated execSync to
|
|
235
|
+
// execFileSync via safeSpawnSpecify. detectAIAgent now also enforces
|
|
236
|
+
// the [a-zA-Z0-9_-]{1,32} allowlist on values read from .specify/
|
|
237
|
+
// init-options.json — defense in depth.
|
|
196
238
|
const detectedAgent = detectAIAgent(projectDir);
|
|
197
|
-
const
|
|
198
|
-
?
|
|
199
|
-
: '--ai generic --ai-commands-dir .agent/commands/';
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
239
|
+
const aiArgs = detectedAgent
|
|
240
|
+
? ['--ai', detectedAgent]
|
|
241
|
+
: ['--ai', 'generic', '--ai-commands-dir', '.agent/commands/'];
|
|
242
|
+
const scriptArgs = process.platform === 'win32' ? ['--script', 'ps'] : ['--script', 'sh'];
|
|
243
|
+
safeSpawnSpecify(
|
|
244
|
+
['init', '--here', '--force', ...aiArgs, '--ai-skills', '--ignore-agent-tools', '--no-git', ...scriptArgs],
|
|
203
245
|
{ cwd: projectDir, encoding: 'utf-8', stdio: 'pipe', timeout: 30000 }
|
|
204
246
|
);
|
|
205
247
|
if (!silent) {
|
|
@@ -3,7 +3,7 @@ schema_version: "1.0"
|
|
|
3
3
|
extension:
|
|
4
4
|
id: "docguard"
|
|
5
5
|
name: "DocGuard — CDD Enforcement"
|
|
6
|
-
version: "0.
|
|
6
|
+
version: "0.21.1"
|
|
7
7
|
description: "Canonical-Driven Development enforcement as a true spec-kit extension. LLM-first design with 19 automated validators, 4 AI behavior skills, spec-kit skill chaining, and workflow hooks. Zero NPM runtime dependencies."
|
|
8
8
|
author: "Ricardo Accioly"
|
|
9
9
|
repository: "https://github.com/raccioly/docguard"
|
|
@@ -6,10 +6,10 @@ description: AI-driven documentation repair with structured research workflow, t
|
|
|
6
6
|
compatibility: Requires DocGuard CLI installed (npm i -g docguard-cli or npx docguard-cli)
|
|
7
7
|
metadata:
|
|
8
8
|
author: docguard
|
|
9
|
-
version: 0.
|
|
9
|
+
version: 0.21.1
|
|
10
10
|
source: extensions/spec-kit-docguard/skills/docguard-fix
|
|
11
11
|
---
|
|
12
|
-
<!-- docguard:version: 0.
|
|
12
|
+
<!-- docguard:version: 0.21.1 -->
|
|
13
13
|
|
|
14
14
|
# DocGuard Fix Skill
|
|
15
15
|
|
|
@@ -7,10 +7,10 @@ description: Run DocGuard guard validation against Canonical-Driven Development
|
|
|
7
7
|
compatibility: Requires DocGuard CLI installed (npm i -g docguard-cli or npx docguard-cli)
|
|
8
8
|
metadata:
|
|
9
9
|
author: docguard
|
|
10
|
-
version: 0.
|
|
10
|
+
version: 0.21.1
|
|
11
11
|
source: extensions/spec-kit-docguard/skills/docguard-guard
|
|
12
12
|
---
|
|
13
|
-
<!-- docguard:version: 0.
|
|
13
|
+
<!-- docguard:version: 0.21.1 -->
|
|
14
14
|
|
|
15
15
|
# DocGuard Guard Skill
|
|
16
16
|
|
|
@@ -6,10 +6,10 @@ description: Cross-document consistency analysis and quality assessment. Perform
|
|
|
6
6
|
compatibility: Requires DocGuard CLI installed (npm i -g docguard-cli or npx docguard-cli)
|
|
7
7
|
metadata:
|
|
8
8
|
author: docguard
|
|
9
|
-
version: 0.
|
|
9
|
+
version: 0.21.1
|
|
10
10
|
source: extensions/spec-kit-docguard/skills/docguard-review
|
|
11
11
|
---
|
|
12
|
-
<!-- docguard:version: 0.
|
|
12
|
+
<!-- docguard:version: 0.21.1 -->
|
|
13
13
|
|
|
14
14
|
# DocGuard Review Skill
|
|
15
15
|
|
|
@@ -6,10 +6,10 @@ description: CDD maturity assessment with category-aware improvement roadmap. Ru
|
|
|
6
6
|
compatibility: Requires DocGuard CLI installed (npm i -g docguard-cli or npx docguard-cli)
|
|
7
7
|
metadata:
|
|
8
8
|
author: docguard
|
|
9
|
-
version: 0.
|
|
9
|
+
version: 0.21.1
|
|
10
10
|
source: extensions/spec-kit-docguard/skills/docguard-score
|
|
11
11
|
---
|
|
12
|
-
<!-- docguard:version: 0.
|
|
12
|
+
<!-- docguard:version: 0.21.1 -->
|
|
13
13
|
|
|
14
14
|
# DocGuard Score Skill
|
|
15
15
|
|
|
@@ -4,7 +4,7 @@ description: Keep canonical documentation ALWAYS UP TO DATE. Refreshes code-trut
|
|
|
4
4
|
compatibility: Requires DocGuard CLI installed (npm i -g docguard-cli or npx docguard-cli)
|
|
5
5
|
metadata:
|
|
6
6
|
author: docguard
|
|
7
|
-
version: 0.
|
|
7
|
+
version: 0.21.1
|
|
8
8
|
source: extensions/spec-kit-docguard/skills/docguard-sync
|
|
9
9
|
---
|
|
10
10
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# Required at boot — see docs-canonical/ENVIRONMENT.md for context.
|
|
2
|
+
DATABASE_URL=postgres://acme:devpass@localhost:5432/payments
|
|
3
|
+
STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxxxxxxxxxx
|
|
4
|
+
# Intentional drift: JWT_SECRET is used in code but NOT documented in ENVIRONMENT.md
|
|
5
|
+
JWT_SECRET=change-me-in-prod
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# AGENTS.md — Acme Payments
|
|
2
|
+
|
|
3
|
+
> Rules for AI agents working in this repo.
|
|
4
|
+
|
|
5
|
+
## Project context
|
|
6
|
+
Acme Payments is a payments microservice. Money is involved — assume every change needs a test.
|
|
7
|
+
|
|
8
|
+
## Architecture
|
|
9
|
+
Three services: API, Worker, Scheduler. See `docs-canonical/ARCHITECTURE.md` for the canonical map.
|
|
10
|
+
|
|
11
|
+
## Style
|
|
12
|
+
- ES modules (`.mjs`)
|
|
13
|
+
- Async/await throughout, no callbacks
|
|
14
|
+
- Throw `PaymentError` for domain errors
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
> Note: This demo CHANGELOG is intentionally missing an `[Unreleased]` section
|
|
4
|
+
> so DocGuard's Changelog validator has something to flag.
|
|
5
|
+
|
|
6
|
+
## [1.4.0] - 2026-04-12
|
|
7
|
+
|
|
8
|
+
- Add scheduled retry for failed charges
|
|
9
|
+
- Bump Stripe SDK to v15
|
|
10
|
+
|
|
11
|
+
## [1.3.0] - 2026-03-28
|
|
12
|
+
|
|
13
|
+
- Initial public release
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Acme Payments
|
|
2
|
+
|
|
3
|
+
A payments microservice. Handles charges, refunds, balance lookups.
|
|
4
|
+
|
|
5
|
+
## Stack
|
|
6
|
+
- Node.js (ES modules)
|
|
7
|
+
- PostgreSQL
|
|
8
|
+
- Stripe for card processing
|
|
9
|
+
|
|
10
|
+
## Quick start
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
npm install
|
|
14
|
+
npm start
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
See `docs-canonical/` for the system specs.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# API Reference
|
|
2
|
+
|
|
3
|
+
> All requests require `Authorization: Bearer <jwt>` unless noted.
|
|
4
|
+
|
|
5
|
+
## Charges
|
|
6
|
+
|
|
7
|
+
### POST /charge
|
|
8
|
+
Create a new charge.
|
|
9
|
+
|
|
10
|
+
**Request body**
|
|
11
|
+
```json
|
|
12
|
+
{ "amount_cents": 1000, "currency": "USD", "customer_id": "cus_..." }
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
**Response** — `201 Created` with the charge object.
|
|
16
|
+
|
|
17
|
+
### POST /refund
|
|
18
|
+
Refund a previous charge.
|
|
19
|
+
|
|
20
|
+
**Request body**
|
|
21
|
+
```json
|
|
22
|
+
{ "charge_id": "ch_...", "amount_cents": 1000 }
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Balance
|
|
26
|
+
|
|
27
|
+
### GET /balance/:customer_id
|
|
28
|
+
Look up a customer's current balance.
|
|
29
|
+
|
|
30
|
+
**Response**
|
|
31
|
+
```json
|
|
32
|
+
{ "customer_id": "cus_...", "available_cents": 12345 }
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
<!-- Demo drift: code also exposes POST /webhooks (Stripe callbacks) but it's
|
|
36
|
+
missing from this reference. DocGuard's API-Surface validator catches it. -->
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# ARCHITECTURE — Acme Payments
|
|
2
|
+
|
|
3
|
+
> The system has **3 services**. (Demo drift: code actually has 4 — see `src/`.)
|
|
4
|
+
|
|
5
|
+
## Components
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
┌───────────┐ queue ┌──────────┐ cron ┌────────────┐
|
|
9
|
+
│ API │ ────────> │ Worker │ <─────── │ Scheduler │
|
|
10
|
+
│ (HTTP) │ │ (jobs) │ │ (timers) │
|
|
11
|
+
└───────────┘ └──────────┘ └────────────┘
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
### API
|
|
15
|
+
Handles HTTP requests. Routes live in `src/api.mjs`.
|
|
16
|
+
|
|
17
|
+
### Worker
|
|
18
|
+
Consumes the job queue. Long-running tasks (capture, refund settlement).
|
|
19
|
+
|
|
20
|
+
### Scheduler
|
|
21
|
+
Cron-style triggers for retries and reconciliation.
|
|
22
|
+
|
|
23
|
+
## Data flow
|
|
24
|
+
1. Client POSTs to `/charge` → API validates → enqueues `process_charge` job
|
|
25
|
+
2. Worker dequeues → calls Stripe → writes result to DB
|
|
26
|
+
3. Scheduler reruns failed charges hourly
|
|
27
|
+
|
|
28
|
+
## See also
|
|
29
|
+
- `DATA-MODEL.md` for the persistence layer
|
|
30
|
+
- `SECURITY.md` for auth + secrets handling
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Data Model
|
|
2
|
+
|
|
3
|
+
## charges
|
|
4
|
+
|
|
5
|
+
| Column | Type | Notes |
|
|
6
|
+
|--------|------|-------|
|
|
7
|
+
| `id` | `uuid` (PK) | |
|
|
8
|
+
| `customer_id` | `text` | |
|
|
9
|
+
| `amount_cents` | `bigint` | |
|
|
10
|
+
| `currency` | `text` | ISO-4217 |
|
|
11
|
+
| `status` | `text` | `pending` / `succeeded` / `failed` |
|
|
12
|
+
| `stripe_id` | `text` | nullable |
|
|
13
|
+
| `created_at` | `timestamptz` | default now() |
|
|
14
|
+
|
|
15
|
+
## refunds
|
|
16
|
+
|
|
17
|
+
| Column | Type |
|
|
18
|
+
|--------|------|
|
|
19
|
+
| `id` | `uuid` (PK) |
|
|
20
|
+
| `charge_id` | `uuid` (FK → charges) |
|
|
21
|
+
| `amount_cents` | `bigint` |
|
|
22
|
+
| `created_at` | `timestamptz` |
|
|
23
|
+
|
|
24
|
+
## customers
|
|
25
|
+
|
|
26
|
+
| Column | Type |
|
|
27
|
+
|--------|------|
|
|
28
|
+
| `id` | `text` (PK, `cus_...`) |
|
|
29
|
+
| `email` | `text` |
|
|
30
|
+
| `created_at` | `timestamptz` |
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Environment
|
|
2
|
+
|
|
3
|
+
Required environment variables.
|
|
4
|
+
|
|
5
|
+
| Variable | Required | Description |
|
|
6
|
+
|----------|----------|-------------|
|
|
7
|
+
| `DATABASE_URL` | Yes | Postgres connection string |
|
|
8
|
+
| `STRIPE_SECRET_KEY` | Yes | Stripe API key (server-side) |
|
|
9
|
+
| `REDIS_URL` | Yes | Redis URL for the job queue |
|
|
10
|
+
|
|
11
|
+
<!-- Demo drift: REDIS_URL is documented here but missing from .env.example.
|
|
12
|
+
Also, JWT_SECRET is in .env.example + used in code, but not listed here.
|
|
13
|
+
DocGuard's Environment validator catches both. -->
|
|
14
|
+
|
|
15
|
+
## Local development
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
cp .env.example .env
|
|
19
|
+
# Fill in the values above
|
|
20
|
+
```
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Security
|
|
2
|
+
|
|
3
|
+
## Authentication
|
|
4
|
+
HTTP API requires `Authorization: Bearer <jwt>`. Tokens are signed with `JWT_SECRET`.
|
|
5
|
+
|
|
6
|
+
## Secrets
|
|
7
|
+
- `STRIPE_SECRET_KEY` — never logged
|
|
8
|
+
- `JWT_SECRET` — rotated quarterly
|
|
9
|
+
|
|
10
|
+
## Threat model
|
|
11
|
+
- Card data is never stored locally — Stripe-tokenized only
|
|
12
|
+
- `JWT_SECRET` rotation invalidates outstanding sessions (acceptable for an internal API)
|
|
13
|
+
|
|
14
|
+
## Audit log
|
|
15
|
+
Every charge / refund writes to the `audit_events` table.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Test Spec
|
|
2
|
+
|
|
3
|
+
## Coverage rules
|
|
4
|
+
- Every route in `src/api.mjs` must have an integration test in `tests/api/`
|
|
5
|
+
- Every worker job must have a unit test in `tests/worker/`
|
|
6
|
+
|
|
7
|
+
## Layers
|
|
8
|
+
- **Unit** — pure logic, no I/O
|
|
9
|
+
- **Integration** — hits a local Postgres + Stripe in test mode
|
|
10
|
+
- **E2E** — against a staging stack (rare; only for release candidates)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// API service — handles HTTP routes.
|
|
2
|
+
import { createServer } from 'node:http';
|
|
3
|
+
|
|
4
|
+
// Routes intentionally include /webhooks (not in API-REFERENCE.md — demo drift)
|
|
5
|
+
const ROUTES = {
|
|
6
|
+
'POST /charge': createCharge,
|
|
7
|
+
'POST /refund': createRefund,
|
|
8
|
+
'GET /balance/:customer': getBalance,
|
|
9
|
+
'POST /webhooks': handleStripeWebhook, // ← undocumented
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
async function createCharge(req) { /* ... */ }
|
|
13
|
+
async function createRefund(req) { /* ... */ }
|
|
14
|
+
async function getBalance(req) { /* ... */ }
|
|
15
|
+
async function handleStripeWebhook(req) { /* ... */ }
|
|
16
|
+
|
|
17
|
+
const PORT = process.env.PORT || 3000;
|
|
18
|
+
createServer((req, res) => { /* router */ }).listen(PORT);
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Notifier service — emails / Slack alerts on big charges or failures.
|
|
2
|
+
// ⚠️ Demo drift: ARCHITECTURE.md only mentions 3 services (API, Worker, Scheduler).
|
|
3
|
+
// This fourth one (Notifier) is in code but missing from the architecture doc.
|
|
4
|
+
// DocGuard's Docs-Diff + Docs-Coverage validators surface this.
|
|
5
|
+
|
|
6
|
+
import { Stripe } from './lib/stripe.mjs';
|
|
7
|
+
|
|
8
|
+
export async function notifyLargeCharge(charge) {
|
|
9
|
+
if (charge.amount_cents > 100000) {
|
|
10
|
+
await sendSlack(`💰 Large charge: $${charge.amount_cents / 100}`);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function notifyFailure(charge, error) {
|
|
15
|
+
await sendEmail({
|
|
16
|
+
to: 'oncall@acme.dev',
|
|
17
|
+
subject: `Charge failed: ${charge.id}`,
|
|
18
|
+
body: error.message,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function sendSlack(text) { /* ... */ }
|
|
23
|
+
async function sendEmail(opts) { /* ... */ }
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Scheduler service — cron-style triggers.
|
|
2
|
+
import { enqueue } from './queue.mjs';
|
|
3
|
+
|
|
4
|
+
// Retry failed charges hourly
|
|
5
|
+
setInterval(async () => {
|
|
6
|
+
const failed = await db.query('SELECT id FROM charges WHERE status = $1', ['failed']);
|
|
7
|
+
for (const row of failed.rows) await enqueue({ type: 'process_charge', charge_id: row.id });
|
|
8
|
+
}, 60 * 60 * 1000);
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Worker service — consumes job queue.
|
|
2
|
+
import { connect } from './queue.mjs';
|
|
3
|
+
|
|
4
|
+
const handlers = {
|
|
5
|
+
process_charge: async (job) => { /* call Stripe */ },
|
|
6
|
+
process_refund: async (job) => { /* call Stripe refund API */ },
|
|
7
|
+
settle_refund: async (job) => { /* mark refund as settled */ },
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const queue = await connect(process.env.REDIS_URL);
|
|
11
|
+
queue.consume(async (job) => {
|
|
12
|
+
const handler = handlers[job.type];
|
|
13
|
+
if (!handler) throw new Error(`Unknown job: ${job.type}`);
|
|
14
|
+
await handler(job);
|
|
15
|
+
});
|