docguard-cli 0.31.0 → 0.32.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.
- package/README.md +19 -3
- package/cli/commands/hooks.mjs +167 -2
- package/cli/commands/impact.mjs +213 -5
- package/cli/commands/mcp.mjs +179 -53
- package/cli/docguard.mjs +46 -3
- package/cli/findings.mjs +6 -0
- package/cli/scanners/agent-readability.mjs +6 -1
- package/cli/scanners/semantic-claims.mjs +10 -2
- package/cli/validators/architecture.mjs +8 -1
- package/cli/validators/cross-reference.mjs +124 -3
- package/cli/validators/reference-existence.mjs +172 -18
- package/cli/validators/traceability.mjs +63 -0
- 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 +2 -2
- package/package.json +1 -1
package/cli/commands/mcp.mjs
CHANGED
|
@@ -204,62 +204,69 @@ const TOOL_HANDLERS = {
|
|
|
204
204
|
};
|
|
205
205
|
|
|
206
206
|
/**
|
|
207
|
-
*
|
|
207
|
+
* Transport-agnostic JSON-RPC dispatch. Returns the response message for a
|
|
208
|
+
* request, or null for notifications (which get no response by spec). Both
|
|
209
|
+
* the stdio and HTTP transports route through this one dispatcher.
|
|
210
|
+
*/
|
|
211
|
+
function dispatchMessage(msg, projectDir) {
|
|
212
|
+
const result = (id, res) => ({ jsonrpc: '2.0', id, result: res });
|
|
213
|
+
const error = (id, code, message) => ({ jsonrpc: '2.0', id, error: { code, message } });
|
|
214
|
+
|
|
215
|
+
if (!msg || typeof msg !== 'object' || Array.isArray(msg) || msg.jsonrpc !== '2.0' || typeof msg.method !== 'string') {
|
|
216
|
+
return error(msg && msg.id !== undefined ? msg.id : null, E_INVALID_REQUEST, 'Invalid Request');
|
|
217
|
+
}
|
|
218
|
+
const { id, method, params } = msg;
|
|
219
|
+
const isNotification = id === undefined || id === null;
|
|
220
|
+
|
|
221
|
+
switch (method) {
|
|
222
|
+
case 'initialize':
|
|
223
|
+
return result(id, {
|
|
224
|
+
protocolVersion: typeof params?.protocolVersion === 'string' ? params.protocolVersion : PROTOCOL_VERSION,
|
|
225
|
+
capabilities: { tools: {} },
|
|
226
|
+
serverInfo: { name: 'docguard', version: _PKG.version },
|
|
227
|
+
});
|
|
228
|
+
case 'ping':
|
|
229
|
+
return result(id, {});
|
|
230
|
+
case 'tools/list':
|
|
231
|
+
return result(id, { tools: TOOLS });
|
|
232
|
+
case 'tools/call': {
|
|
233
|
+
const handler = TOOL_HANDLERS[params?.name];
|
|
234
|
+
if (!handler) return error(id, E_INVALID_PARAMS, `Unknown tool: ${params?.name}`);
|
|
235
|
+
// In-tool failures are tool RESULTS (isError), not protocol errors —
|
|
236
|
+
// one bad call must never take down the server or the session.
|
|
237
|
+
try {
|
|
238
|
+
const payload = handler(params?.arguments || {}, projectDir);
|
|
239
|
+
return result(id, { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] });
|
|
240
|
+
} catch (err) {
|
|
241
|
+
return result(id, { content: [{ type: 'text', text: String((err && err.message) || err) }], isError: true });
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
default:
|
|
245
|
+
// Notifications (initialized, cancelled, …) get no response by spec.
|
|
246
|
+
if (isNotification) return null;
|
|
247
|
+
return error(id, E_METHOD_NOT_FOUND, `Method not found: ${method}`);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Serve MCP until the transport closes. The returned promise keeps the
|
|
208
253
|
* dispatcher's `await` (and thus the process) alive for the server's lifetime.
|
|
254
|
+
* Default transport is stdio; `--transport http` serves the same tools over
|
|
255
|
+
* the MCP Streamable HTTP transport so one shared process can serve a team.
|
|
209
256
|
*/
|
|
210
|
-
export function runMcp(projectDir, _config,
|
|
257
|
+
export function runMcp(projectDir, _config, flags = {}) {
|
|
258
|
+
if (flags.transport === 'http') return runMcpHttp(projectDir, flags);
|
|
259
|
+
if (flags.transport && flags.transport !== 'stdio') {
|
|
260
|
+
process.stderr.write(`docguard mcp: unknown transport "${flags.transport}" (expected stdio or http)\n`);
|
|
261
|
+
process.exitCode = 1;
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
|
|
211
265
|
const send = (msg) => {
|
|
212
266
|
// A vanished client (EPIPE) is a normal shutdown, not a crash.
|
|
213
267
|
try { process.stdout.write(JSON.stringify(msg) + '\n'); }
|
|
214
268
|
catch { /* client gone — the readline close handler ends the server */ }
|
|
215
269
|
};
|
|
216
|
-
const reply = (id, result) => send({ jsonrpc: '2.0', id, result });
|
|
217
|
-
const replyError = (id, code, message) => send({ jsonrpc: '2.0', id, error: { code, message } });
|
|
218
|
-
|
|
219
|
-
const handleMessage = (msg) => {
|
|
220
|
-
if (!msg || typeof msg !== 'object' || Array.isArray(msg) || msg.jsonrpc !== '2.0' || typeof msg.method !== 'string') {
|
|
221
|
-
replyError(msg && msg.id !== undefined ? msg.id : null, E_INVALID_REQUEST, 'Invalid Request');
|
|
222
|
-
return;
|
|
223
|
-
}
|
|
224
|
-
const { id, method, params } = msg;
|
|
225
|
-
const isNotification = id === undefined || id === null;
|
|
226
|
-
|
|
227
|
-
switch (method) {
|
|
228
|
-
case 'initialize':
|
|
229
|
-
reply(id, {
|
|
230
|
-
protocolVersion: typeof params?.protocolVersion === 'string' ? params.protocolVersion : PROTOCOL_VERSION,
|
|
231
|
-
capabilities: { tools: {} },
|
|
232
|
-
serverInfo: { name: 'docguard', version: _PKG.version },
|
|
233
|
-
});
|
|
234
|
-
return;
|
|
235
|
-
case 'ping':
|
|
236
|
-
reply(id, {});
|
|
237
|
-
return;
|
|
238
|
-
case 'tools/list':
|
|
239
|
-
reply(id, { tools: TOOLS });
|
|
240
|
-
return;
|
|
241
|
-
case 'tools/call': {
|
|
242
|
-
const handler = TOOL_HANDLERS[params?.name];
|
|
243
|
-
if (!handler) {
|
|
244
|
-
replyError(id, E_INVALID_PARAMS, `Unknown tool: ${params?.name}`);
|
|
245
|
-
return;
|
|
246
|
-
}
|
|
247
|
-
// In-tool failures are tool RESULTS (isError), not protocol errors —
|
|
248
|
-
// one bad call must never take down the server or the session.
|
|
249
|
-
try {
|
|
250
|
-
const payload = handler(params?.arguments || {}, projectDir);
|
|
251
|
-
reply(id, { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] });
|
|
252
|
-
} catch (err) {
|
|
253
|
-
reply(id, { content: [{ type: 'text', text: String((err && err.message) || err) }], isError: true });
|
|
254
|
-
}
|
|
255
|
-
return;
|
|
256
|
-
}
|
|
257
|
-
default:
|
|
258
|
-
// Notifications (initialized, cancelled, …) get no response by spec.
|
|
259
|
-
if (isNotification) return;
|
|
260
|
-
replyError(id, E_METHOD_NOT_FOUND, `Method not found: ${method}`);
|
|
261
|
-
}
|
|
262
|
-
};
|
|
263
270
|
|
|
264
271
|
process.stderr.write(`docguard mcp v${_PKG.version} — serving ${TOOLS.length} tools on stdio (project: ${projectDir})\n`);
|
|
265
272
|
|
|
@@ -270,14 +277,133 @@ export function runMcp(projectDir, _config, _flags) {
|
|
|
270
277
|
if (!trimmed) return;
|
|
271
278
|
let msg;
|
|
272
279
|
try { msg = JSON.parse(trimmed); }
|
|
273
|
-
catch {
|
|
274
|
-
try {
|
|
275
|
-
|
|
280
|
+
catch { send({ jsonrpc: '2.0', id: null, error: { code: E_PARSE, message: 'Parse error' } }); return; }
|
|
281
|
+
try {
|
|
282
|
+
const resp = dispatchMessage(msg, projectDir);
|
|
283
|
+
if (resp) send(resp);
|
|
284
|
+
} catch (err) {
|
|
276
285
|
// Last-resort trap: a protocol-handler bug must not kill the server.
|
|
277
286
|
process.stderr.write(`docguard mcp: internal error: ${err && err.stack || err}\n`);
|
|
278
|
-
if (msg && msg.id !== undefined && msg.id !== null)
|
|
287
|
+
if (msg && msg.id !== undefined && msg.id !== null) {
|
|
288
|
+
send({ jsonrpc: '2.0', id: msg.id, error: { code: E_INTERNAL, message: 'Internal error' } });
|
|
289
|
+
}
|
|
279
290
|
}
|
|
280
291
|
});
|
|
281
292
|
rl.on('close', () => done());
|
|
282
293
|
});
|
|
283
294
|
}
|
|
295
|
+
|
|
296
|
+
// ── Streamable HTTP transport ───────────────────────────────────────────────
|
|
297
|
+
//
|
|
298
|
+
// Minimal spec-compliant subset, zero-dep (node:http):
|
|
299
|
+
// - POST <path>: JSON-RPC request/batch in, application/json out. A body of
|
|
300
|
+
// only notifications → 202 Accepted, empty.
|
|
301
|
+
// - GET <path>: 405 — this server does not offer a server-initiated SSE
|
|
302
|
+
// stream (clients that need one fall back to plain request/response).
|
|
303
|
+
// - DELETE <path>: 200 — the server is stateless; nothing to clean up.
|
|
304
|
+
// - `Mcp-Session-Id` is issued on initialize and accepted (not required)
|
|
305
|
+
// afterwards — stateless by design, like `--stateless` HTTP MCP servers.
|
|
306
|
+
//
|
|
307
|
+
// Security posture (Security → Production-readiness → Simplicity):
|
|
308
|
+
// - Default bind 127.0.0.1 (loopback-only).
|
|
309
|
+
// - Binding any non-loopback host REQUIRES --api-key / DOCGUARD_API_KEY —
|
|
310
|
+
// the server refuses to start otherwise, instead of warning and exposing
|
|
311
|
+
// read access to the whole network.
|
|
312
|
+
// - When an api-key is set, every request must carry it
|
|
313
|
+
// (`Authorization: Bearer <key>` or `X-API-Key: <key>`) → else 401.
|
|
314
|
+
// - Origin allow-list on loopback binds (DNS-rebinding guard per the MCP
|
|
315
|
+
// Streamable HTTP security notes): browser-originated cross-site requests
|
|
316
|
+
// are rejected; non-browser clients send no Origin and pass.
|
|
317
|
+
|
|
318
|
+
const HTTP_BODY_CAP = 4 * 1024 * 1024; // 4 MiB — guard payloads are large but bounded
|
|
319
|
+
|
|
320
|
+
function isLoopbackHost(host) {
|
|
321
|
+
return host === '127.0.0.1' || host === 'localhost' || host === '::1';
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async function runMcpHttp(projectDir, flags) {
|
|
325
|
+
const { createServer } = await import('node:http');
|
|
326
|
+
const { randomUUID } = await import('node:crypto');
|
|
327
|
+
|
|
328
|
+
const host = flags.host || '127.0.0.1';
|
|
329
|
+
const port = Number.isFinite(Number(flags.port)) && Number(flags.port) >= 0 ? Number(flags.port) : 8585;
|
|
330
|
+
const mountPath = flags.path || '/mcp';
|
|
331
|
+
const apiKey = flags.apiKey || process.env.DOCGUARD_API_KEY || '';
|
|
332
|
+
|
|
333
|
+
if (!isLoopbackHost(host) && !apiKey) {
|
|
334
|
+
process.stderr.write(
|
|
335
|
+
`docguard mcp: refusing to bind ${host} without an API key.\n` +
|
|
336
|
+
`Exposing the server beyond localhost requires --api-key <key> (or DOCGUARD_API_KEY).\n`);
|
|
337
|
+
process.exitCode = 1;
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const authorized = (req) => {
|
|
342
|
+
if (!apiKey) return true;
|
|
343
|
+
const auth = req.headers['authorization'] || '';
|
|
344
|
+
const xkey = req.headers['x-api-key'] || '';
|
|
345
|
+
return auth === `Bearer ${apiKey}` || xkey === apiKey;
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
const originAllowed = (req) => {
|
|
349
|
+
const origin = req.headers['origin'];
|
|
350
|
+
if (!origin) return true; // non-browser clients (MCP SDKs, curl) send none
|
|
351
|
+
try {
|
|
352
|
+
const o = new URL(origin);
|
|
353
|
+
return isLoopbackHost(o.hostname);
|
|
354
|
+
} catch { return false; }
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
const server = createServer((req, res) => {
|
|
358
|
+
const answer = (status, body, headers = {}) => {
|
|
359
|
+
res.writeHead(status, { 'content-type': 'application/json', ...headers });
|
|
360
|
+
res.end(body === undefined ? '' : JSON.stringify(body));
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
const url = (req.url || '').split('?')[0];
|
|
364
|
+
if (url !== mountPath) return answer(404, { error: 'not found' });
|
|
365
|
+
if (!originAllowed(req)) return answer(403, { error: 'origin not allowed' });
|
|
366
|
+
if (!authorized(req)) return answer(401, { error: 'unauthorized' }, { 'www-authenticate': 'Bearer' });
|
|
367
|
+
|
|
368
|
+
if (req.method === 'GET') return answer(405, { error: 'SSE stream not offered — POST JSON-RPC to this endpoint' }, { allow: 'POST, DELETE' });
|
|
369
|
+
if (req.method === 'DELETE') return answer(200, {}); // stateless — nothing to end
|
|
370
|
+
if (req.method !== 'POST') return answer(405, { error: 'method not allowed' }, { allow: 'POST, DELETE' });
|
|
371
|
+
|
|
372
|
+
let size = 0;
|
|
373
|
+
const chunks = [];
|
|
374
|
+
req.on('data', (c) => {
|
|
375
|
+
size += c.length;
|
|
376
|
+
if (size > HTTP_BODY_CAP) { answer(413, { error: 'payload too large' }); req.destroy(); return; }
|
|
377
|
+
chunks.push(c);
|
|
378
|
+
});
|
|
379
|
+
req.on('end', () => {
|
|
380
|
+
if (res.writableEnded) return;
|
|
381
|
+
let parsed;
|
|
382
|
+
try { parsed = JSON.parse(Buffer.concat(chunks).toString('utf-8')); }
|
|
383
|
+
catch { return answer(400, { jsonrpc: '2.0', id: null, error: { code: E_PARSE, message: 'Parse error' } }); }
|
|
384
|
+
|
|
385
|
+
try {
|
|
386
|
+
const messages = Array.isArray(parsed) ? parsed : [parsed];
|
|
387
|
+
const responses = messages.map((m) => dispatchMessage(m, projectDir)).filter(Boolean);
|
|
388
|
+
// New sessions get an id on initialize; we accept any/none afterwards.
|
|
389
|
+
const headers = messages.some((m) => m && m.method === 'initialize')
|
|
390
|
+
? { 'mcp-session-id': randomUUID() } : {};
|
|
391
|
+
if (responses.length === 0) return answer(202, undefined, headers); // notifications only
|
|
392
|
+
return answer(200, Array.isArray(parsed) ? responses : responses[0], headers);
|
|
393
|
+
} catch (err) {
|
|
394
|
+
process.stderr.write(`docguard mcp: internal error: ${err && err.stack || err}\n`);
|
|
395
|
+
return answer(500, { jsonrpc: '2.0', id: null, error: { code: E_INTERNAL, message: 'Internal error' } });
|
|
396
|
+
}
|
|
397
|
+
});
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
return new Promise((done) => {
|
|
401
|
+
server.listen(port, host, () => {
|
|
402
|
+
const addr = server.address();
|
|
403
|
+
process.stderr.write(
|
|
404
|
+
`docguard mcp v${_PKG.version} — Streamable HTTP on http://${host}:${addr.port}${mountPath} ` +
|
|
405
|
+
`(project: ${projectDir}${apiKey ? ', api-key required' : ', loopback only'})\n`);
|
|
406
|
+
});
|
|
407
|
+
server.on('close', () => done());
|
|
408
|
+
});
|
|
409
|
+
}
|
package/cli/docguard.mjs
CHANGED
|
@@ -29,7 +29,7 @@ import { runScore } from './commands/score.mjs';
|
|
|
29
29
|
import { runDiff } from './commands/diff.mjs';
|
|
30
30
|
import { runAgents } from './commands/agents.mjs';
|
|
31
31
|
import { runGenerate } from './commands/generate.mjs';
|
|
32
|
-
import { runHooks } from './commands/hooks.mjs';
|
|
32
|
+
import { runHooks, runNudgeHook } from './commands/hooks.mjs';
|
|
33
33
|
import { runBadge } from './commands/badge.mjs';
|
|
34
34
|
import { runCI } from './commands/ci.mjs';
|
|
35
35
|
import { runFix } from './commands/fix.mjs';
|
|
@@ -495,6 +495,32 @@ async function main() {
|
|
|
495
495
|
i++;
|
|
496
496
|
} else if (args[i] === '--no-fix') {
|
|
497
497
|
flags.noFix = true;
|
|
498
|
+
} else if (args[i] === '--no-indirect') {
|
|
499
|
+
// impact: skip the reverse-import-graph (indirect code→doc) analysis.
|
|
500
|
+
flags.indirect = false;
|
|
501
|
+
} else if (args[i] === '--prs') {
|
|
502
|
+
// impact: open-PR doc-conflict analysis (needs the gh CLI).
|
|
503
|
+
flags.prs = true;
|
|
504
|
+
} else if (args[i] === '--claude') {
|
|
505
|
+
// hooks: install/remove the Claude Code agent nudge hook.
|
|
506
|
+
flags.claude = true;
|
|
507
|
+
} else if (args[i] === '--transport' && args[i + 1]) {
|
|
508
|
+
// mcp: stdio (default) or http (Streamable HTTP, team-shared server).
|
|
509
|
+
flags.transport = args[i + 1];
|
|
510
|
+
i++;
|
|
511
|
+
} else if (args[i] === '--port' && args[i + 1]) {
|
|
512
|
+
flags.port = args[i + 1];
|
|
513
|
+
i++;
|
|
514
|
+
} else if (args[i] === '--host' && args[i + 1]) {
|
|
515
|
+
flags.host = args[i + 1];
|
|
516
|
+
i++;
|
|
517
|
+
} else if (args[i] === '--api-key' && args[i + 1]) {
|
|
518
|
+
flags.apiKey = args[i + 1];
|
|
519
|
+
i++;
|
|
520
|
+
} else if (args[i] === '--path' && args[i + 1]) {
|
|
521
|
+
// mcp --transport http: HTTP mount path (default /mcp).
|
|
522
|
+
flags.path = args[i + 1];
|
|
523
|
+
i++;
|
|
498
524
|
} else if (args[i] === '--signals') {
|
|
499
525
|
flags.signals = true;
|
|
500
526
|
} else if (args[i] === '--debate') {
|
|
@@ -545,7 +571,9 @@ async function main() {
|
|
|
545
571
|
// `agent` emits a machine task graph (JSON by default) — it must be banner-
|
|
546
572
|
// free and side-effect-free like the other read-only commands.
|
|
547
573
|
// `mcp`: stdout IS the JSON-RPC transport — any banner byte corrupts the stream.
|
|
548
|
-
|
|
574
|
+
// `nudge-hook`: stdout is the Claude Code hook feedback channel — any banner
|
|
575
|
+
// byte corrupts the JSON the hook runner parses.
|
|
576
|
+
const headless = jsonMode || flags.write || flags.checkOnly || flags.changedOnly || flags.quiet || flags.plan || command === 'agent' || command === 'mcp' || command === 'nudge-hook';
|
|
549
577
|
|
|
550
578
|
if (!headless) printBanner();
|
|
551
579
|
|
|
@@ -572,6 +600,9 @@ async function main() {
|
|
|
572
600
|
'verify',
|
|
573
601
|
// mcp serves read-only tools over stdio — scaffolding writes are off-limits.
|
|
574
602
|
'mcp',
|
|
603
|
+
// nudge-hook runs inside an agent's PostToolUse hook — it may write only
|
|
604
|
+
// its own .docguard/nudge-state.json throttle file, never scaffold skills.
|
|
605
|
+
'nudge-hook',
|
|
575
606
|
]);
|
|
576
607
|
|
|
577
608
|
// Silent auto-check: install skills/commands if missing. Skip entirely in
|
|
@@ -624,7 +655,9 @@ async function main() {
|
|
|
624
655
|
process.exit(1);
|
|
625
656
|
}
|
|
626
657
|
|
|
627
|
-
|
|
658
|
+
// `hooks --claude` is a first-class new surface (agent nudge hook), not the
|
|
659
|
+
// deprecated git-hooks alias — no deprecation warning for it.
|
|
660
|
+
if (DEPRECATED_COMMANDS[command] && !flags.quiet && !(command === 'hooks' && flags.claude)) {
|
|
628
661
|
const { since, replacement } = DEPRECATED_COMMANDS[command];
|
|
629
662
|
console.error(`${c.yellow}⚠ Deprecated since v${since}:${c.reset} ${c.cyan}docguard ${command}${c.reset} → use ${c.cyan}${replacement}${c.reset}`);
|
|
630
663
|
console.error(`${c.dim} The old form still works in v0.20.x but will be removed in v1.0. See MIGRATION-v0.20.md.${c.reset}`);
|
|
@@ -671,8 +704,18 @@ async function main() {
|
|
|
671
704
|
runAgent(projectDir, config, flags);
|
|
672
705
|
break;
|
|
673
706
|
case 'hooks':
|
|
707
|
+
if (flags.claude) {
|
|
708
|
+
// Agent nudge hook (.claude/settings.json) — direct path, no wizard.
|
|
709
|
+
runHooks(projectDir, config, flags);
|
|
710
|
+
break;
|
|
711
|
+
}
|
|
674
712
|
await runInit(projectDir, config, { ...flags, with: ['hooks'], skipPrompts: true });
|
|
675
713
|
break;
|
|
714
|
+
case 'nudge-hook':
|
|
715
|
+
// Runtime for the Claude Code PostToolUse hook. stdout is the machine
|
|
716
|
+
// channel (headless — see the jsonMode/banner gate above).
|
|
717
|
+
runNudgeHook(projectDir);
|
|
718
|
+
break;
|
|
676
719
|
case 'badge':
|
|
677
720
|
await runInit(projectDir, config, { ...flags, with: ['badge'], skipPrompts: true });
|
|
678
721
|
break;
|
package/cli/findings.mjs
CHANGED
|
@@ -614,6 +614,12 @@ export const CODES = {
|
|
|
614
614
|
help: 'A code-element reference in the doc matched source when the doc was last updated, but matches ZERO source instances at HEAD (two-revision check, arXiv 2212.01479). Excludes the two documented false-positive modes (removed-but-config-relevant flags, and symbols whose literal string was deleted while logic remains). Verify and update the reference.',
|
|
615
615
|
suppress: '<!-- docguard:ignore REF001 — still relevant, e.g. user-facing flag -->',
|
|
616
616
|
},
|
|
617
|
+
REF002: {
|
|
618
|
+
validator: 'reference-existence',
|
|
619
|
+
title: 'Code cites an ADR that has no document',
|
|
620
|
+
help: 'A code comment cites an Architecture Decision Record (e.g. ADR-012) that no ADR document defines — the citation is stale (renumbered, removed) or the ADR was never written. Numbers compare as integers, so ADR-0011 matches ADR-11. IETF RFC citations are deliberately not checked (external registry). Write the ADR, fix the number, or suppress on the citation line.',
|
|
621
|
+
suppress: '// docguard:ignore REF002 — your reason',
|
|
622
|
+
},
|
|
617
623
|
APS001: {
|
|
618
624
|
validator: 'api-doc-smells',
|
|
619
625
|
title: 'Bloated API documentation',
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
16
16
|
import { resolve, dirname } from 'node:path';
|
|
17
|
+
import { loadIgnorePatterns } from '../shared.mjs';
|
|
17
18
|
|
|
18
19
|
/** chars/4 — the standard rough token estimate; consistency matters more than precision. */
|
|
19
20
|
const estTokens = (s) => Math.ceil(s.length / 4);
|
|
@@ -31,9 +32,13 @@ function readIfExists(path) {
|
|
|
31
32
|
function canonicalDocs(projectDir) {
|
|
32
33
|
const dir = resolve(projectDir, 'docs-canonical');
|
|
33
34
|
if (!existsSync(dir)) return [];
|
|
35
|
+
// Honor .docguardignore — an excluded doc (e.g. a historical audit) must
|
|
36
|
+
// not drag down the readability metrics either (same rule as the
|
|
37
|
+
// semantic-claim extractor, bug-212).
|
|
38
|
+
const isIgnored = loadIgnorePatterns(projectDir);
|
|
34
39
|
try {
|
|
35
40
|
return readdirSync(dir)
|
|
36
|
-
.filter(f => f.toLowerCase().endsWith('.md'))
|
|
41
|
+
.filter(f => f.toLowerCase().endsWith('.md') && !isIgnored(`docs-canonical/${f}`))
|
|
37
42
|
.sort()
|
|
38
43
|
.map(f => ({ name: `docs-canonical/${f}`, content: readIfExists(resolve(dir, f)) }))
|
|
39
44
|
.filter(d => d.content !== null);
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
|
|
24
24
|
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
25
25
|
import { resolve, join } from 'node:path';
|
|
26
|
+
import { loadIgnorePatterns } from '../shared.mjs';
|
|
26
27
|
|
|
27
28
|
// Numbers are only claims when adjacent to a recognized unit.
|
|
28
29
|
const NUMBER_PATTERNS = [
|
|
@@ -49,17 +50,24 @@ const MAX_CLAIMS = 80;
|
|
|
49
50
|
|
|
50
51
|
/** Canonical docs + the root docs where limits/counts commonly live. */
|
|
51
52
|
function claimSourceDocs(projectDir) {
|
|
53
|
+
// Honor .docguardignore: a doc the user explicitly excluded from validation
|
|
54
|
+
// (e.g. a historical audit full of point-in-time counts) must not feed the
|
|
55
|
+
// "unverified claims" pool either — it inflated the count and buried the
|
|
56
|
+
// claims that ARE actionable (bug-212).
|
|
57
|
+
const isIgnored = loadIgnorePatterns(projectDir);
|
|
52
58
|
const docs = [];
|
|
53
59
|
const canonical = resolve(projectDir, 'docs-canonical');
|
|
54
60
|
if (existsSync(canonical)) {
|
|
55
61
|
try {
|
|
56
62
|
for (const f of readdirSync(canonical)) {
|
|
57
|
-
if (f.toLowerCase().endsWith('.md')
|
|
63
|
+
if (f.toLowerCase().endsWith('.md') && !isIgnored(`docs-canonical/${f}`)) {
|
|
64
|
+
docs.push(`docs-canonical/${f}`);
|
|
65
|
+
}
|
|
58
66
|
}
|
|
59
67
|
} catch { /* ignore */ }
|
|
60
68
|
}
|
|
61
69
|
for (const root of ['README.md', 'AGENTS.md']) {
|
|
62
|
-
if (existsSync(resolve(projectDir, root))) docs.push(root);
|
|
70
|
+
if (existsSync(resolve(projectDir, root)) && !isIgnored(root)) docs.push(root);
|
|
63
71
|
}
|
|
64
72
|
return docs;
|
|
65
73
|
}
|
|
@@ -143,7 +143,14 @@ function validateConfigLayers(projectDir, config, layers, acc) {
|
|
|
143
143
|
|
|
144
144
|
// ── Import Graph Builder ────────────────────────────────────────────────────
|
|
145
145
|
|
|
146
|
-
|
|
146
|
+
/**
|
|
147
|
+
* Build the project's JS/TS import graph. Exported for reuse by `impact`
|
|
148
|
+
* (indirect code→doc analysis walks this graph's reverse edges) — one graph
|
|
149
|
+
* builder, not two.
|
|
150
|
+
*
|
|
151
|
+
* @returns {{files: string[], edges: {from,to,dynamic}[], fileMap: Map<string,string[]>}}
|
|
152
|
+
*/
|
|
153
|
+
export function buildImportGraph(projectDir, config) {
|
|
147
154
|
const graph = { files: [], edges: [], fileMap: new Map() };
|
|
148
155
|
|
|
149
156
|
const allFiles = getFilesRecursive(projectDir, config, projectDir);
|
|
@@ -8,6 +8,13 @@
|
|
|
8
8
|
* - Markdown relative links: [text](./OTHER.md)
|
|
9
9
|
* [text](./OTHER.md#anchor)
|
|
10
10
|
* [text](#anchor-in-same-doc)
|
|
11
|
+
* [text](<path with spaces.md>)
|
|
12
|
+
* - Obsidian wikilinks: [[OTHER]] [[OTHER#Heading]] [[OTHER|alias]]
|
|
13
|
+
* Validated only when the repo shows wikilinks-are-files evidence
|
|
14
|
+
* (`.obsidian/` exists, or at least one wikilink target resolves) — some
|
|
15
|
+
* repos use [[name]] as a non-file convention (template placeholders,
|
|
16
|
+
* memory links) and must not be flagged. Image embeds `![[x.png]]` are
|
|
17
|
+
* never treated as doc links.
|
|
11
18
|
* - Bare anchor refs: see §3.2 ARCHITECTURE.md
|
|
12
19
|
* (Section 3.2 in DATA-MODEL.md)
|
|
13
20
|
* - Bracketed section refs: [Section X.Y]
|
|
@@ -29,6 +36,8 @@
|
|
|
29
36
|
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
30
37
|
import { resolve, join, dirname, basename, relative } from 'node:path';
|
|
31
38
|
import { mkFinding, resultFromFindings } from '../findings.mjs';
|
|
39
|
+
import { resolveDocDirs } from '../shared.mjs';
|
|
40
|
+
import { walkFiles } from '../shared-ignore.mjs';
|
|
32
41
|
|
|
33
42
|
/**
|
|
34
43
|
* Slugify a heading the way GitHub's markdown anchors work.
|
|
@@ -112,8 +121,13 @@ export function extractRefs(content, sourcePath) {
|
|
|
112
121
|
// ./RELATIVE.md
|
|
113
122
|
// ../OTHER.md#anchor
|
|
114
123
|
// #intra-doc-anchor
|
|
124
|
+
// <path with spaces.md> (CommonMark angle-bracket form)
|
|
115
125
|
// We DON'T match http(s) targets here.
|
|
116
126
|
const markdownLinkRe = /\[([^\]]+)\]\(((?!https?:|mailto:)[^)]+)\)/g;
|
|
127
|
+
// [[Target]] / [[Target#Heading]] / [[Target|alias]]. The (?<!!) guard
|
|
128
|
+
// excludes Obsidian image embeds ![[img.png]] — an embed is content, not a
|
|
129
|
+
// doc cross-reference.
|
|
130
|
+
const wikiLinkRe = /(?<!!)\[\[([^\]|#\n]+)(?:#([^\]|\n]*))?(?:\|[^\]\n]*)?\]\]/g;
|
|
117
131
|
|
|
118
132
|
for (let i = 0; i < lines.length; i++) {
|
|
119
133
|
const line = lines[i];
|
|
@@ -130,8 +144,14 @@ export function extractRefs(content, sourcePath) {
|
|
|
130
144
|
markdownLinkRe.lastIndex = 0;
|
|
131
145
|
while ((m = markdownLinkRe.exec(stripped)) !== null) {
|
|
132
146
|
const target = m[2].trim();
|
|
133
|
-
|
|
134
|
-
|
|
147
|
+
let cleanTarget;
|
|
148
|
+
if (target.startsWith('<') && target.includes('>')) {
|
|
149
|
+
// Angle-bracket form: the target is everything inside <…>, spaces allowed.
|
|
150
|
+
cleanTarget = target.slice(1, target.indexOf('>'));
|
|
151
|
+
} else {
|
|
152
|
+
// Drop any title text: [foo](bar "title") → bar
|
|
153
|
+
cleanTarget = target.split(/\s+/)[0];
|
|
154
|
+
}
|
|
135
155
|
const hashIdx = cleanTarget.indexOf('#');
|
|
136
156
|
let file, anchor;
|
|
137
157
|
if (hashIdx === 0) {
|
|
@@ -145,13 +165,55 @@ export function extractRefs(content, sourcePath) {
|
|
|
145
165
|
file = cleanTarget;
|
|
146
166
|
anchor = null;
|
|
147
167
|
}
|
|
168
|
+
// `./repo.md?x=1#setup` targets repo.md — the query never names a file.
|
|
169
|
+
if (file) file = file.split('?')[0];
|
|
148
170
|
refs.push({ source: sourcePath, file, anchor, raw: m[0], line: i + 1 });
|
|
149
171
|
}
|
|
172
|
+
|
|
173
|
+
wikiLinkRe.lastIndex = 0;
|
|
174
|
+
while ((m = wikiLinkRe.exec(stripped)) !== null) {
|
|
175
|
+
const target = m[1].trim();
|
|
176
|
+
if (!target) continue;
|
|
177
|
+
const anchor = m[2] !== undefined ? m[2].trim() : null;
|
|
178
|
+
refs.push({ source: sourcePath, file: target, anchor: anchor || null, raw: m[0], line: i + 1, wiki: true });
|
|
179
|
+
}
|
|
150
180
|
}
|
|
151
181
|
|
|
152
182
|
return refs;
|
|
153
183
|
}
|
|
154
184
|
|
|
185
|
+
/**
|
|
186
|
+
* Basename-stem → path index of every markdown file in the project's doc
|
|
187
|
+
* homes plus the already-collected canonical docs. Obsidian resolves
|
|
188
|
+
* wikilinks vault-wide by basename; this is the named-doc-dirs equivalent
|
|
189
|
+
* (never an arbitrary-subdir walk).
|
|
190
|
+
*/
|
|
191
|
+
function buildWikiIndex(projectDir, config, docs) {
|
|
192
|
+
const idx = new Map();
|
|
193
|
+
const add = (p) => {
|
|
194
|
+
const stem = basename(p).replace(/\.mdx?$/i, '').toLowerCase();
|
|
195
|
+
if (!idx.has(stem)) idx.set(stem, p);
|
|
196
|
+
};
|
|
197
|
+
for (const d of docs) add(d);
|
|
198
|
+
for (const d of resolveDocDirs(projectDir, config)) {
|
|
199
|
+
const abs = resolve(projectDir, d);
|
|
200
|
+
if (!existsSync(abs)) continue;
|
|
201
|
+
walkFiles(abs, (full) => { if (/\.mdx?$/i.test(full)) add(full); });
|
|
202
|
+
}
|
|
203
|
+
return idx;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Resolve a wikilink target: sibling path, project root, then vault index. */
|
|
207
|
+
function resolveWikiTarget(sourcePath, target, projectDir, wikiIndex) {
|
|
208
|
+
const withExt = /\.mdx?$/i.test(target) ? target : `${target}.md`;
|
|
209
|
+
for (const base of [dirname(sourcePath), projectDir]) {
|
|
210
|
+
const p = resolve(base, withExt);
|
|
211
|
+
if (existsSync(p)) return p;
|
|
212
|
+
}
|
|
213
|
+
const stem = basename(withExt).replace(/\.mdx?$/i, '').toLowerCase();
|
|
214
|
+
return wikiIndex.get(stem) || null;
|
|
215
|
+
}
|
|
216
|
+
|
|
155
217
|
/**
|
|
156
218
|
* Resolve a target file path relative to a source markdown file.
|
|
157
219
|
* Returns the absolute path or null if the file doesn't exist.
|
|
@@ -297,9 +359,10 @@ function collectCanonicalDocs(projectDir) {
|
|
|
297
359
|
* errors/warnings arrays from the same findings, so counts, exit codes, and
|
|
298
360
|
* existing tests are unaffected; guard just renders richer output.
|
|
299
361
|
*/
|
|
300
|
-
export function validateCrossReferences(projectDir,
|
|
362
|
+
export function validateCrossReferences(projectDir, config = {}) {
|
|
301
363
|
const findings = [];
|
|
302
364
|
const fixes = [];
|
|
365
|
+
const wikiRefs = []; // validated in a second pass — evidence gate needs the full set
|
|
303
366
|
let passed = 0;
|
|
304
367
|
let total = 0;
|
|
305
368
|
|
|
@@ -328,6 +391,10 @@ export function validateCrossReferences(projectDir, _config = {}) {
|
|
|
328
391
|
const docName = basename(docPath);
|
|
329
392
|
|
|
330
393
|
for (const ref of refs) {
|
|
394
|
+
if (ref.wiki) {
|
|
395
|
+
wikiRefs.push({ ...ref, docPath, docName });
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
331
398
|
total++;
|
|
332
399
|
|
|
333
400
|
// Resolve the target file (if any)
|
|
@@ -411,6 +478,60 @@ export function validateCrossReferences(projectDir, _config = {}) {
|
|
|
411
478
|
}
|
|
412
479
|
}
|
|
413
480
|
|
|
481
|
+
// ── Wikilink pass — only when the repo demonstrably uses [[x]] as FILE
|
|
482
|
+
// links: `.obsidian/` exists, or at least one wikilink target resolves.
|
|
483
|
+
// Repos using [[name]] as a non-file convention are skipped silently.
|
|
484
|
+
if (wikiRefs.length > 0) {
|
|
485
|
+
const wikiIndex = buildWikiIndex(projectDir, config, docs);
|
|
486
|
+
const resolved = wikiRefs.map(r => ({
|
|
487
|
+
r,
|
|
488
|
+
path: resolveWikiTarget(r.docPath, r.file, projectDir, wikiIndex),
|
|
489
|
+
}));
|
|
490
|
+
const evidence = existsSync(resolve(projectDir, '.obsidian')) || resolved.some(x => x.path);
|
|
491
|
+
if (evidence) {
|
|
492
|
+
for (const { r, path } of resolved) {
|
|
493
|
+
total++;
|
|
494
|
+
if (!path) {
|
|
495
|
+
findings.push(mkFinding({
|
|
496
|
+
code: 'XRF001',
|
|
497
|
+
validator: 'crossReference',
|
|
498
|
+
severity: 'warn',
|
|
499
|
+
message: `${r.docName}:${r.line} — broken wikilink: target "[[${r.file}]]" not found`,
|
|
500
|
+
location: `${relative(projectDir, r.docPath)}:${r.line}`,
|
|
501
|
+
suggestion: { kind: 'fix', text: 'Fix the wikilink target (or remove the dead link)' },
|
|
502
|
+
}));
|
|
503
|
+
continue;
|
|
504
|
+
}
|
|
505
|
+
if (r.anchor) {
|
|
506
|
+
let anchors = anchorIndex.get(path);
|
|
507
|
+
if (!anchors) {
|
|
508
|
+
try {
|
|
509
|
+
anchors = new Set(extractHeadings(readFileSync(path, 'utf-8')).map(h => h.anchor));
|
|
510
|
+
} catch { anchors = new Set(); }
|
|
511
|
+
anchorIndex.set(path, anchors);
|
|
512
|
+
}
|
|
513
|
+
// Obsidian anchors are heading TEXT ([[Doc#Quick Start]]); compare
|
|
514
|
+
// through the same slug pipeline as inline links.
|
|
515
|
+
const normalized = slugifyHeading(r.anchor);
|
|
516
|
+
if (!anchors.has(normalized) && !anchors.has(r.anchor)) {
|
|
517
|
+
const suggestion = suggestAnchor(normalized, anchors);
|
|
518
|
+
const hint = suggestion ? ` (did you mean #${suggestion}?)` : '';
|
|
519
|
+
findings.push(mkFinding({
|
|
520
|
+
code: 'XRF002',
|
|
521
|
+
validator: 'crossReference',
|
|
522
|
+
severity: 'warn',
|
|
523
|
+
message: `${r.docName}:${r.line} — broken anchor: "[[${r.file}#${r.anchor}]]" doesn't match any heading in ${basename(path)}${hint}`,
|
|
524
|
+
location: `${relative(projectDir, r.docPath)}:${r.line}`,
|
|
525
|
+
suggestion: { kind: 'review', text: 'Update the wikilink heading to match a real heading in the target doc' },
|
|
526
|
+
}));
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
passed++;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
414
535
|
return { ...resultFromFindings(findings, { passed, total }), fixes };
|
|
415
536
|
}
|
|
416
537
|
|