docguard-cli 0.29.0 → 0.30.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 +18 -1
- package/cli/commands/mcp.mjs +20 -0
- package/cli/commands/score.mjs +9 -4
- package/cli/commands/setup.mjs +0 -13
- package/cli/commands/trace.mjs +364 -1
- package/cli/commands/verify.mjs +93 -6
- package/cli/docguard.mjs +11 -3
- package/cli/findings.mjs +12 -0
- package/cli/scanners/instruction-audit.mjs +320 -0
- package/cli/scanners/speckit.mjs +346 -1
- package/cli/shared-git.mjs +8 -1
- 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 +2 -1
- package/schemas/docguard-config.schema.json +11 -0
- package/templates/ci/gitlab-component.yml +90 -0
package/cli/scanners/speckit.mjs
CHANGED
|
@@ -23,8 +23,12 @@
|
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
25
|
import { existsSync, readFileSync, readdirSync, statSync, copyFileSync, writeFileSync } from 'node:fs';
|
|
26
|
-
import { resolve, join, relative } from 'node:path';
|
|
26
|
+
import { resolve, join, relative, dirname, basename, extname } from 'node:path';
|
|
27
|
+
import { execFileSync } from 'node:child_process';
|
|
27
28
|
import { mkFinding, resultFromFindings } from '../findings.mjs';
|
|
29
|
+
import { isGitRepo } from '../shared-git.mjs';
|
|
30
|
+
import { walkFiles } from '../shared-ignore.mjs';
|
|
31
|
+
import { readScannable } from '../shared-source.mjs';
|
|
28
32
|
|
|
29
33
|
// ──── Spec Kit Mandatory Sections ────
|
|
30
34
|
// Based on spec-kit's spec-template.md, plan-template.md, tasks-template.md
|
|
@@ -294,6 +298,304 @@ function validateTasksQuality(tasksPath) {
|
|
|
294
298
|
return issues;
|
|
295
299
|
}
|
|
296
300
|
|
|
301
|
+
// ──── Phantom-Completion Detection (SPK008/SPK009, v0.30) ────
|
|
302
|
+
//
|
|
303
|
+
// A `- [x]` in tasks.md is a CLAIM that work landed. An agent (or human) that
|
|
304
|
+
// checks a task without landing the artifact corrupts the project's memory:
|
|
305
|
+
// every later session trusts the checkbox and skips the work. This check
|
|
306
|
+
// verifies the claim deterministically — "lie detection" for tasks.md.
|
|
307
|
+
//
|
|
308
|
+
// PRECISION-FIRST DESIGN — a false accusation of lying is worse than a miss:
|
|
309
|
+
// • Only tasks that make a FALSIFIABLE artifact claim can be flagged: the
|
|
310
|
+
// task line must name at least one repo-relative path (slashed, or with an
|
|
311
|
+
// explicit trailing `/` for directories). Prose-only tasks ("Review the
|
|
312
|
+
// approach"), bare filenames ("buildspec.yml" may describe the DOMAIN, not
|
|
313
|
+
// a deliverable), and tasks whose only reference is a task ID are counted
|
|
314
|
+
// as unverifiable and never flagged — flagging them is FP soup.
|
|
315
|
+
// • A task is phantom only when EVERY evidence tier comes up empty:
|
|
316
|
+
// a. any named path exists (project root or the feature dir);
|
|
317
|
+
// b. a named basename exists anywhere in the repo (file was moved);
|
|
318
|
+
// c. a backticked code symbol from the task line appears in source;
|
|
319
|
+
// d. sibling plan.md/spec.md name an existing deliverable that the task
|
|
320
|
+
// text also mentions;
|
|
321
|
+
// e. the task ID (T001…) appears in a source/test file annotation;
|
|
322
|
+
// f. the task ID appears in a git commit message (skipped silently when
|
|
323
|
+
// the project is not a git repo or git is unavailable).
|
|
324
|
+
// Evidence false-positives are SAFE (they suppress an accusation), so the
|
|
325
|
+
// tiers are deliberately generous.
|
|
326
|
+
|
|
327
|
+
const CHECKED_TASK_RE = /^\s*-\s*\[[xX]\]\s*(T\d{3,4})?\s*(.+)$/;
|
|
328
|
+
const MAX_PHANTOM_FINDINGS = 10;
|
|
329
|
+
|
|
330
|
+
/** Code-ish extensions whose content can carry task-ID / symbol evidence. */
|
|
331
|
+
const EVIDENCE_CODE_EXTS = new Set([
|
|
332
|
+
'.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx',
|
|
333
|
+
'.py', '.go', '.rs', '.java', '.rb', '.php', '.kt', '.swift',
|
|
334
|
+
'.c', '.h', '.cpp', '.hpp', '.cs', '.sh', '.sql',
|
|
335
|
+
'.yml', '.yaml', '.json', '.toml',
|
|
336
|
+
]);
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Parse checked tasks (`- [x] T001 …`) out of a tasks.md.
|
|
340
|
+
* Returns [{ id, text, line }] with 1-based line numbers. The task ID is also
|
|
341
|
+
* recovered from a leading bold/decorated form (`**T001**`) when the plain
|
|
342
|
+
* position doesn't match.
|
|
343
|
+
*/
|
|
344
|
+
export function parseCheckedTasks(content) {
|
|
345
|
+
const tasks = [];
|
|
346
|
+
const lines = content.split('\n');
|
|
347
|
+
for (let i = 0; i < lines.length; i++) {
|
|
348
|
+
const m = lines[i].match(CHECKED_TASK_RE);
|
|
349
|
+
if (!m) continue;
|
|
350
|
+
const text = m[2].trim();
|
|
351
|
+
let id = m[1] || null;
|
|
352
|
+
if (!id) {
|
|
353
|
+
const decorated = text.match(/^[*_~[(]*\s*(T\d{3,4})\b/);
|
|
354
|
+
if (decorated) id = decorated[1];
|
|
355
|
+
}
|
|
356
|
+
tasks.push({ id, text, line: i + 1 });
|
|
357
|
+
}
|
|
358
|
+
return tasks;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Extract path-like tokens from a task line.
|
|
363
|
+
*
|
|
364
|
+
* `claims` — slashed paths (or explicit `dir/` syntax): falsifiable
|
|
365
|
+
* deliverable claims. Only these can convict.
|
|
366
|
+
* `soft` — bare filenames (`CHANGELOG.md`) and extension-less slashed tokens
|
|
367
|
+
* (`cli/commands`): positive evidence ONLY, never grounds for flagging —
|
|
368
|
+
* prose mentions of foreign/domain filenames (and "and/or"-style prose
|
|
369
|
+
* slashes, which the charset+extension rules reject as claims) must not
|
|
370
|
+
* convict.
|
|
371
|
+
*
|
|
372
|
+
* Rejected outright (unverifiable or unsafe to resolve): tokens with spaces,
|
|
373
|
+
* globs/placeholders, absolute paths (URL routes like `/api/users` are not
|
|
374
|
+
* repo files), and `..` segments (never resolve outside the project).
|
|
375
|
+
*/
|
|
376
|
+
export function extractPathTokens(text) {
|
|
377
|
+
const claims = new Set();
|
|
378
|
+
const soft = new Set();
|
|
379
|
+
const consider = (tokRaw) => {
|
|
380
|
+
let tok = tokRaw.trim().replace(/^\.\//, '').replace(/:\d+(?::\d+)?$/, '');
|
|
381
|
+
if (!tok || tok.length > 200 || /\s/.test(tok)) return;
|
|
382
|
+
if (/[*?{}<>|]/.test(tok)) return;
|
|
383
|
+
if (tok.startsWith('/')) return;
|
|
384
|
+
if (/(^|\/)\.\.(\/|$)/.test(tok)) return;
|
|
385
|
+
const isDirSyntax = tok.endsWith('/');
|
|
386
|
+
tok = tok.replace(/\/+$/, '');
|
|
387
|
+
if (!tok || !/^\.?[\w@][\w@./-]*$/.test(tok)) return;
|
|
388
|
+
if (tok.includes('/')) {
|
|
389
|
+
const last = tok.slice(tok.lastIndexOf('/') + 1);
|
|
390
|
+
if (/\.[A-Za-z]\w{0,9}$/.test(last) || isDirSyntax) claims.add(tok);
|
|
391
|
+
else soft.add(tok);
|
|
392
|
+
} else if (/\.[A-Za-z]\w{0,9}$/.test(tok)) {
|
|
393
|
+
soft.add(tok); // bare filename — rejects version numbers like `18.0`
|
|
394
|
+
}
|
|
395
|
+
};
|
|
396
|
+
for (const m of text.matchAll(/`([^`]+)`/g)) consider(m[1]);
|
|
397
|
+
for (const m of text.matchAll(/(?:^|[\s("'[])((?:[\w@.-]+\/)+[\w@.-]+\/?)/g)) consider(m[1]);
|
|
398
|
+
return { claims, soft };
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Backticked identifier-like tokens (`globMatch(relPath, patterns)` → globMatch,
|
|
403
|
+
* `IGNORE_DIRS` → IGNORE_DIRS). Used as an evidence tier: a task that names a
|
|
404
|
+
* function/constant that exists in source was plainly not skipped. Min length 4
|
|
405
|
+
* keeps trivially-common words from being extracted at all (an over-match here
|
|
406
|
+
* only suppresses an accusation, so the filter is intentionally loose).
|
|
407
|
+
*/
|
|
408
|
+
export function extractSymbolTokens(text) {
|
|
409
|
+
const out = new Set();
|
|
410
|
+
for (const m of text.matchAll(/`([^`]+)`/g)) {
|
|
411
|
+
const tok = m[1].trim();
|
|
412
|
+
const call = tok.match(/^([A-Za-z_$][\w$]*)\s*\(/);
|
|
413
|
+
if (call && call[1].length >= 4) { out.add(call[1]); continue; }
|
|
414
|
+
if (/^[A-Za-z_$][\w$]*$/.test(tok) && tok.length >= 4) out.add(tok);
|
|
415
|
+
}
|
|
416
|
+
return out;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function escapeRe(s) {
|
|
420
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** Does any of the named tokens exist relative to root or the feature dir? */
|
|
424
|
+
function anyPathExists(projectDir, featureDir, tokens) {
|
|
425
|
+
for (const tok of tokens) {
|
|
426
|
+
if (existsSync(resolve(projectDir, tok))) return true;
|
|
427
|
+
if (featureDir && existsSync(resolve(featureDir, tok))) return true;
|
|
428
|
+
}
|
|
429
|
+
return false;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Existing deliverable paths named by the feature's sibling plan.md/spec.md.
|
|
434
|
+
* Returns { paths: Set<string>, basenames: Set<string> } — only tokens that
|
|
435
|
+
* exist on disk count (a plan mentioning a path is design, not evidence;
|
|
436
|
+
* a plan mentioning an EXISTING path ties the deliverable to reality).
|
|
437
|
+
*/
|
|
438
|
+
function collectSiblingArtifacts(projectDir, spec) {
|
|
439
|
+
const paths = new Set();
|
|
440
|
+
const basenames = new Set();
|
|
441
|
+
const featureDir = spec.tasksPath ? dirname(spec.tasksPath) : null;
|
|
442
|
+
for (const p of [spec.planPath, spec.specPath]) {
|
|
443
|
+
if (!p) continue;
|
|
444
|
+
let content;
|
|
445
|
+
try { content = readFileSync(p, 'utf-8'); } catch { continue; }
|
|
446
|
+
const { claims, soft } = extractPathTokens(content);
|
|
447
|
+
for (const tok of [...claims, ...soft]) {
|
|
448
|
+
if (existsSync(resolve(projectDir, tok)) || (featureDir && existsSync(resolve(featureDir, tok)))) {
|
|
449
|
+
paths.add(tok);
|
|
450
|
+
const base = basename(tok);
|
|
451
|
+
if (base.length >= 5) basenames.add(base); // ≥5 avoids `a.ts`-scale collisions
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
return { paths, basenames };
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* ONE repo walk that resolves every deferred needle at once: basenames of
|
|
460
|
+
* needed files (any extension, so a moved deliverable still evidences), and
|
|
461
|
+
* task-IDs / code symbols inside code-ish files. Dot-entries are skipped
|
|
462
|
+
* (so specs' own markdown never self-evidences) except .github, whose
|
|
463
|
+
* workflows are legitimate deliverables.
|
|
464
|
+
*/
|
|
465
|
+
function scanRepoForEvidence(projectDir, needles) {
|
|
466
|
+
const found = { basenames: new Set(), symbols: new Set(), ids: new Set() };
|
|
467
|
+
const symbolRes = new Map();
|
|
468
|
+
for (const sym of needles.symbols) symbolRes.set(sym, new RegExp(`\\b${escapeRe(sym)}\\b`));
|
|
469
|
+
const wantContent = needles.ids.size > 0 || symbolRes.size > 0;
|
|
470
|
+
walkFiles(projectDir, (absPath) => {
|
|
471
|
+
const base = basename(absPath);
|
|
472
|
+
if (needles.basenames.has(base)) found.basenames.add(base);
|
|
473
|
+
if (!wantContent) return;
|
|
474
|
+
if (!EVIDENCE_CODE_EXTS.has(extname(absPath).toLowerCase())) return;
|
|
475
|
+
if (found.ids.size === needles.ids.size && found.symbols.size === symbolRes.size) return;
|
|
476
|
+
const content = readScannable(absPath);
|
|
477
|
+
if (!content) return;
|
|
478
|
+
if (needles.ids.size > found.ids.size) {
|
|
479
|
+
for (const m of content.matchAll(/\bT\d{3,4}\b/g)) {
|
|
480
|
+
if (needles.ids.has(m[0])) found.ids.add(m[0]);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
for (const [sym, re] of symbolRes) {
|
|
484
|
+
if (!found.symbols.has(sym) && re.test(content)) found.symbols.add(sym);
|
|
485
|
+
}
|
|
486
|
+
}, { keepDot: (entry) => entry === '.github' });
|
|
487
|
+
return found;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Is the task ID referenced in a commit message? `--grep` narrows in git;
|
|
492
|
+
* the JS word-boundary re-check drops substring hits (T001 vs T0010).
|
|
493
|
+
* `--format=%B` (not --oneline) so a body-only mention still counts.
|
|
494
|
+
* Fails closed to `false` — a missing git binary or empty history just
|
|
495
|
+
* means this tier contributes no evidence.
|
|
496
|
+
*/
|
|
497
|
+
function taskIdInGitLog(projectDir, id) {
|
|
498
|
+
try {
|
|
499
|
+
const out = execFileSync(
|
|
500
|
+
'git',
|
|
501
|
+
['log', `--grep=${id}`, '--format=%B'],
|
|
502
|
+
{ cwd: projectDir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], maxBuffer: 8 * 1024 * 1024 }
|
|
503
|
+
);
|
|
504
|
+
return new RegExp(`\\b${id}(?!\\d)`).test(out);
|
|
505
|
+
} catch {
|
|
506
|
+
return false;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Run phantom-completion detection across all detected features.
|
|
512
|
+
*
|
|
513
|
+
* Two passes: cheap per-task tiers first (named paths on disk, sibling
|
|
514
|
+
* artifacts); tasks still unevidenced go into ONE batched repo scan
|
|
515
|
+
* (basenames + symbols + task-ID annotations), then a per-ID git-log lookup
|
|
516
|
+
* (cached; skipped silently when not a git repo).
|
|
517
|
+
*
|
|
518
|
+
* @returns {Array<{spec, relPath, checkedCount, unverifiableCount,
|
|
519
|
+
* phantoms: Array<{id, text, line, tiers: string[]}>}>}
|
|
520
|
+
*/
|
|
521
|
+
export function detectPhantomCompletions(projectDir, specs) {
|
|
522
|
+
const results = [];
|
|
523
|
+
const pending = [];
|
|
524
|
+
const siblingCache = new Map();
|
|
525
|
+
|
|
526
|
+
for (const spec of specs) {
|
|
527
|
+
if (!spec.hasTasks || !spec.tasksPath) continue;
|
|
528
|
+
let content;
|
|
529
|
+
try { content = readFileSync(spec.tasksPath, 'utf-8'); } catch { continue; } // SPK006 covers unreadable
|
|
530
|
+
const tasks = parseCheckedTasks(content);
|
|
531
|
+
const entry = {
|
|
532
|
+
spec,
|
|
533
|
+
relPath: relative(projectDir, spec.tasksPath),
|
|
534
|
+
checkedCount: tasks.length,
|
|
535
|
+
unverifiableCount: 0,
|
|
536
|
+
phantoms: [],
|
|
537
|
+
};
|
|
538
|
+
results.push(entry);
|
|
539
|
+
if (tasks.length === 0) continue;
|
|
540
|
+
|
|
541
|
+
const featureDir = dirname(spec.tasksPath);
|
|
542
|
+
for (const task of tasks) {
|
|
543
|
+
const { claims, soft } = extractPathTokens(task.text);
|
|
544
|
+
if (claims.size === 0) { entry.unverifiableCount++; continue; } // no falsifiable claim
|
|
545
|
+
// Tier a: any named path exists (claims convict; soft tokens only evidence)
|
|
546
|
+
if (anyPathExists(projectDir, featureDir, claims) || anyPathExists(projectDir, featureDir, soft)) continue;
|
|
547
|
+
// Tier d: sibling plan/spec name an existing deliverable this task mentions
|
|
548
|
+
if (!siblingCache.has(featureDir)) siblingCache.set(featureDir, collectSiblingArtifacts(projectDir, spec));
|
|
549
|
+
const sibling = siblingCache.get(featureDir);
|
|
550
|
+
const hasSiblingDocs = Boolean(spec.planPath || spec.specPath);
|
|
551
|
+
let rescued = false;
|
|
552
|
+
for (const p of sibling.paths) { if (task.text.includes(p)) { rescued = true; break; } }
|
|
553
|
+
if (!rescued) for (const b of sibling.basenames) { if (task.text.includes(b)) { rescued = true; break; } }
|
|
554
|
+
if (rescued) continue;
|
|
555
|
+
// Defer tiers b/c/e/f to the batched repo scan + git lookup
|
|
556
|
+
pending.push({
|
|
557
|
+
entry, task, hasSiblingDocs,
|
|
558
|
+
basenames: new Set([...claims].map((t) => basename(t)).concat([...soft].filter((t) => !t.includes('/')))),
|
|
559
|
+
symbols: extractSymbolTokens(task.text),
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
if (pending.length > 0) {
|
|
565
|
+
const needles = { basenames: new Set(), symbols: new Set(), ids: new Set() };
|
|
566
|
+
for (const p of pending) {
|
|
567
|
+
for (const b of p.basenames) needles.basenames.add(b);
|
|
568
|
+
for (const s of p.symbols) needles.symbols.add(s);
|
|
569
|
+
if (p.task.id) needles.ids.add(p.task.id);
|
|
570
|
+
}
|
|
571
|
+
const found = scanRepoForEvidence(projectDir, needles);
|
|
572
|
+
let repo = null; // lazy: only shell out when an ID actually needs the git tier
|
|
573
|
+
const gitCache = new Map();
|
|
574
|
+
for (const p of pending) {
|
|
575
|
+
if ([...p.basenames].some((b) => found.basenames.has(b))) continue; // tier b: moved file
|
|
576
|
+
if ([...p.symbols].some((s) => found.symbols.has(s))) continue; // tier c: symbol landed
|
|
577
|
+
if (p.task.id && found.ids.has(p.task.id)) continue; // tier e: source annotation
|
|
578
|
+
let gitTierRan = false;
|
|
579
|
+
if (p.task.id) {
|
|
580
|
+
if (repo === null) repo = isGitRepo(projectDir);
|
|
581
|
+
if (repo) {
|
|
582
|
+
gitTierRan = true;
|
|
583
|
+
if (!gitCache.has(p.task.id)) gitCache.set(p.task.id, taskIdInGitLog(projectDir, p.task.id));
|
|
584
|
+
if (gitCache.get(p.task.id)) continue; // tier f: commit trail
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
const tiers = ['named paths', 'repo file names'];
|
|
588
|
+
if (p.hasSiblingDocs) tiers.push('plan/spec artifacts');
|
|
589
|
+
if (p.symbols.size > 0) tiers.push('code symbols');
|
|
590
|
+
if (p.task.id) tiers.push('task-ID in source');
|
|
591
|
+
if (gitTierRan) tiers.push('git log');
|
|
592
|
+
p.entry.phantoms.push({ id: p.task.id, text: p.task.text, line: p.task.line, tiers });
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
return results;
|
|
597
|
+
}
|
|
598
|
+
|
|
297
599
|
// ──── CDD Mapping ────
|
|
298
600
|
|
|
299
601
|
const SPECKIT_CDD_MAP = {
|
|
@@ -391,6 +693,12 @@ export function generateFromSpecKit(projectDir, config, flags) {
|
|
|
391
693
|
* byte-identical to the legacy strings — resultFromFindings derives the
|
|
392
694
|
* errors/warnings arrays from the same findings, so counts, exit codes, and
|
|
393
695
|
* existing tests are unaffected; guard just renders richer output.
|
|
696
|
+
*
|
|
697
|
+
* v0.30: adds phantom-completion detection (SPK008, elision SPK009) — tasks
|
|
698
|
+
* marked [x] whose named deliverables don't exist and have no other
|
|
699
|
+
* implementation evidence (see the Phantom-Completion Detection section
|
|
700
|
+
* above for the tier design). Opt out per-project with
|
|
701
|
+
* `"specKit": { "phantomCheck": false }` in .docguard.json.
|
|
394
702
|
*/
|
|
395
703
|
export function validateSpecKitIntegration(projectDir, config) {
|
|
396
704
|
const findings = [];
|
|
@@ -527,6 +835,43 @@ export function validateSpecKitIntegration(projectDir, config) {
|
|
|
527
835
|
}
|
|
528
836
|
}
|
|
529
837
|
|
|
838
|
+
// ── Check 2d: Phantom completions — tasks checked [x] with no implementation evidence ──
|
|
839
|
+
// Opt out with `"specKit": { "phantomCheck": false }` in .docguard.json.
|
|
840
|
+
// Each tasks.md with at least one checked task counts as one check.
|
|
841
|
+
if (config?.specKit?.phantomCheck !== false) {
|
|
842
|
+
const phantomResults = detectPhantomCompletions(projectDir, speckit.specs);
|
|
843
|
+
const flagged = [];
|
|
844
|
+
for (const r of phantomResults) {
|
|
845
|
+
if (r.checkedCount === 0) continue;
|
|
846
|
+
total++;
|
|
847
|
+
if (r.phantoms.length === 0) { passed++; continue; }
|
|
848
|
+
for (const ph of r.phantoms) flagged.push({ r, ph });
|
|
849
|
+
}
|
|
850
|
+
for (const { r, ph } of flagged.slice(0, MAX_PHANTOM_FINDINGS)) {
|
|
851
|
+
const text = ph.text.length > 80 ? ph.text.slice(0, 77) + '...' : ph.text;
|
|
852
|
+
const label = ph.id ? `${ph.id} marked [x]` : 'task marked [x]';
|
|
853
|
+
findings.push(mkFinding({
|
|
854
|
+
code: 'SPK008',
|
|
855
|
+
validator: 'specKit',
|
|
856
|
+
severity: 'warn',
|
|
857
|
+
confidence: 'low',
|
|
858
|
+
message: `specs/${r.spec.name}/tasks.md: ${label} with no implementation evidence — "${text}" (checked: ${ph.tiers.join(', ')})`,
|
|
859
|
+
location: `${r.relPath}:${ph.line}`,
|
|
860
|
+
suggestion: { kind: 'review', text: 'Uncheck the task or land the implementation it claims — a checked task with no artifact is memory corruption for agents' },
|
|
861
|
+
}));
|
|
862
|
+
}
|
|
863
|
+
if (flagged.length > MAX_PHANTOM_FINDINGS) {
|
|
864
|
+
findings.push(mkFinding({
|
|
865
|
+
code: 'SPK009',
|
|
866
|
+
validator: 'specKit',
|
|
867
|
+
severity: 'warn',
|
|
868
|
+
message: `...and ${flagged.length - MAX_PHANTOM_FINDINGS} more checked tasks with no implementation evidence`,
|
|
869
|
+
location: null,
|
|
870
|
+
suggestion: { kind: 'review', text: 'Fix or uncheck the tasks above and re-run guard to surface the rest — or set specKit.phantomCheck=false in .docguard.json to disable' },
|
|
871
|
+
}));
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
|
|
530
875
|
// ── Check 3: Constitution → AGENTS.md mapping ──
|
|
531
876
|
if (speckit.constitution) {
|
|
532
877
|
total++;
|
package/cli/shared-git.mjs
CHANGED
|
@@ -167,7 +167,14 @@ export function getHooksDir(dir) {
|
|
|
167
167
|
).trim();
|
|
168
168
|
// --git-path returns a path relative to `dir` (cwd) or an absolute path;
|
|
169
169
|
// resolve() handles both.
|
|
170
|
-
|
|
170
|
+
//
|
|
171
|
+
// Guard `/dev/null`: when `core.hooksPath` is set to /dev/null (a common
|
|
172
|
+
// "disable all hooks" convention — and what Jules's sandbox VM does),
|
|
173
|
+
// git returns the literal `/dev/null`. resolve()-ing it and then writing
|
|
174
|
+
// `<hooksDir>/pre-commit` gives `ENOTDIR: /dev/null/pre-commit`. Treat it
|
|
175
|
+
// as "no usable hooks dir" and fall through to the `.git/hooks` check so
|
|
176
|
+
// hook install/list still works in that environment. (bug-200)
|
|
177
|
+
if (out && out !== '/dev/null') return resolve(dir, out);
|
|
171
178
|
} catch {
|
|
172
179
|
// git unavailable or not a repo — fall through to the literal-path check.
|
|
173
180
|
}
|
|
@@ -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.30.0"
|
|
7
7
|
description: "Canonical-Driven Development enforcement as a true spec-kit extension. LLM-first design with automated validators, 4 AI behavior skills, spec-kit skill chaining, and workflow hooks. One pinned runtime dependency (@babel/parser); pure Node.js otherwise."
|
|
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.30.0
|
|
10
10
|
source: extensions/spec-kit-docguard/skills/docguard-fix
|
|
11
11
|
---
|
|
12
|
-
<!-- docguard:version: 0.
|
|
12
|
+
<!-- docguard:version: 0.30.0 -->
|
|
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.30.0
|
|
11
11
|
source: extensions/spec-kit-docguard/skills/docguard-guard
|
|
12
12
|
---
|
|
13
|
-
<!-- docguard:version: 0.
|
|
13
|
+
<!-- docguard:version: 0.30.0 -->
|
|
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.30.0
|
|
10
10
|
source: extensions/spec-kit-docguard/skills/docguard-review
|
|
11
11
|
---
|
|
12
|
-
<!-- docguard:version: 0.
|
|
12
|
+
<!-- docguard:version: 0.30.0 -->
|
|
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.30.0
|
|
10
10
|
source: extensions/spec-kit-docguard/skills/docguard-score
|
|
11
11
|
---
|
|
12
|
-
<!-- docguard:version: 0.
|
|
12
|
+
<!-- docguard:version: 0.30.0 -->
|
|
13
13
|
|
|
14
14
|
# DocGuard Score Skill
|
|
15
15
|
|
|
@@ -4,10 +4,10 @@ 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.30.0
|
|
8
8
|
source: extensions/spec-kit-docguard/skills/docguard-sync
|
|
9
9
|
---
|
|
10
|
-
<!-- docguard:version: 0.
|
|
10
|
+
<!-- docguard:version: 0.30.0 -->
|
|
11
11
|
|
|
12
12
|
# DocGuard Sync Skill
|
|
13
13
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "docguard-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.30.1",
|
|
4
4
|
"description": "The enforcement tool for Canonical-Driven Development (CDD). Audit, generate, and guard your project documentation.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
"url": "https://github.com/raccioly/docguard"
|
|
40
40
|
},
|
|
41
41
|
"homepage": "https://github.com/raccioly/docguard#readme",
|
|
42
|
+
"mcpName": "io.github.raccioly/docguard",
|
|
42
43
|
"bugs": {
|
|
43
44
|
"url": "https://github.com/raccioly/docguard/issues"
|
|
44
45
|
},
|
|
@@ -188,6 +188,17 @@
|
|
|
188
188
|
},
|
|
189
189
|
"additionalProperties": true
|
|
190
190
|
},
|
|
191
|
+
"specKit": {
|
|
192
|
+
"type": "object",
|
|
193
|
+
"description": "Spec-Kit validator overrides.",
|
|
194
|
+
"properties": {
|
|
195
|
+
"phantomCheck": {
|
|
196
|
+
"type": "boolean",
|
|
197
|
+
"description": "If false, disable phantom-completion detection (SPK008/SPK009) — checked tasks in tasks.md whose named deliverables don't exist and carry no implementation evidence. On by default."
|
|
198
|
+
}
|
|
199
|
+
},
|
|
200
|
+
"additionalProperties": true
|
|
201
|
+
},
|
|
191
202
|
"collections": {
|
|
192
203
|
"type": "object",
|
|
193
204
|
"description": "Project-declared collections (field report #6): maps a documentation noun (e.g. \"extractors\") to a glob whose matching-file count is the source of truth. Metrics-Consistency then flags a documented count that disagrees (\"16 extractors\" in prose vs 19 files on disk), deterministically and with no LLM. A declared collection IS the opt-in binding, so — unlike the built-in checks/validators counts — it does not require the noun's line to mention \"docguard\". An unresolved glob (0 matches) is skipped, never asserting \"0\". Reserved nouns (checks, validators, tests) keep their built-in meaning. Complements surfaceSync (WHICH members drift) with a count check (HOW MANY). Example: { \"extractors\": \"src/extractors/*.py\", \"commands\": \"cli/commands/*.mjs\" }.",
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# DocGuard — GitLab CI/CD component (CI/CD Catalog style, spec:inputs syntax).
|
|
2
|
+
# Docs: https://docs.gitlab.com/ci/components/ and https://docs.gitlab.com/ci/inputs/
|
|
3
|
+
#
|
|
4
|
+
# This file STAGES the component. Actual catalog publishing requires a GitLab
|
|
5
|
+
# account and a dedicated component project (see packaging/submissions.md):
|
|
6
|
+
# 1. Create a GitLab project (e.g. gitlab.com/raccioly/docguard-component).
|
|
7
|
+
# 2. Copy this file to `templates/docguard.yml` at that project's root
|
|
8
|
+
# (components must live in a top-level templates/ directory).
|
|
9
|
+
# 3. Set the project as a CI/CD Catalog project (Settings > General > Visibility,
|
|
10
|
+
# "CI/CD Catalog project" toggle) and add a release job that tags a version.
|
|
11
|
+
#
|
|
12
|
+
# Consumers then include it as:
|
|
13
|
+
#
|
|
14
|
+
# include:
|
|
15
|
+
# - component: gitlab.com/raccioly/docguard-component/docguard@0.29.0
|
|
16
|
+
# inputs:
|
|
17
|
+
# command: guard
|
|
18
|
+
# fail_on_warning: true
|
|
19
|
+
#
|
|
20
|
+
# Or, without the catalog, this works today from any repo via a plain include:
|
|
21
|
+
#
|
|
22
|
+
# include:
|
|
23
|
+
# - remote: https://raw.githubusercontent.com/raccioly/docguard/v0.29.0/templates/ci/gitlab-component.yml
|
|
24
|
+
|
|
25
|
+
spec:
|
|
26
|
+
inputs:
|
|
27
|
+
command:
|
|
28
|
+
description: DocGuard command to run.
|
|
29
|
+
type: string
|
|
30
|
+
default: guard
|
|
31
|
+
options: [guard, score, ci]
|
|
32
|
+
threshold:
|
|
33
|
+
description: Minimum CDD score (0-100) to pass. Only enforced by the `ci` command; 0 disables.
|
|
34
|
+
type: number
|
|
35
|
+
default: 0
|
|
36
|
+
fail_on_warning:
|
|
37
|
+
description: Fail the job on warnings, not just errors.
|
|
38
|
+
type: boolean
|
|
39
|
+
default: false
|
|
40
|
+
version:
|
|
41
|
+
description: docguard-cli version to run (npm dist-tag or exact version).
|
|
42
|
+
type: string
|
|
43
|
+
default: '0.29.0'
|
|
44
|
+
node_image:
|
|
45
|
+
description: Node.js image for the job (needs git available, so prefer the non-alpine tags).
|
|
46
|
+
type: string
|
|
47
|
+
default: 'node:20'
|
|
48
|
+
---
|
|
49
|
+
docguard:
|
|
50
|
+
image: '$[[ inputs.node_image ]]'
|
|
51
|
+
stage: test
|
|
52
|
+
variables:
|
|
53
|
+
# Several validators diff against git history — avoid a shallow clone.
|
|
54
|
+
GIT_DEPTH: '0'
|
|
55
|
+
script:
|
|
56
|
+
- |
|
|
57
|
+
set +e
|
|
58
|
+
CMD="$[[ inputs.command ]]"
|
|
59
|
+
FAIL_ON_WARNING="$[[ inputs.fail_on_warning ]]"
|
|
60
|
+
THRESHOLD="$[[ inputs.threshold ]]"
|
|
61
|
+
FLAGS=""
|
|
62
|
+
if [ "$CMD" = "ci" ] && [ "$THRESHOLD" -gt 0 ]; then
|
|
63
|
+
FLAGS="$FLAGS --threshold $THRESHOLD"
|
|
64
|
+
fi
|
|
65
|
+
if [ "$CMD" = "ci" ] && [ "$FAIL_ON_WARNING" = "true" ]; then
|
|
66
|
+
FLAGS="$FLAGS --fail-on-warning"
|
|
67
|
+
fi
|
|
68
|
+
|
|
69
|
+
npx "docguard-cli@$[[ inputs.version ]]" "$CMD" $FLAGS
|
|
70
|
+
RC=$?
|
|
71
|
+
|
|
72
|
+
# guard exit code 2 = warnings only. Not a failure unless asked for.
|
|
73
|
+
if [ "$CMD" = "guard" ] && [ "$RC" = "2" ]; then
|
|
74
|
+
if [ "$FAIL_ON_WARNING" = "true" ]; then
|
|
75
|
+
echo "DocGuard guard found warnings (fail_on_warning is enabled)."
|
|
76
|
+
RC=1
|
|
77
|
+
else
|
|
78
|
+
echo "DocGuard guard found warnings — passing (set fail_on_warning: true to fail)."
|
|
79
|
+
RC=0
|
|
80
|
+
fi
|
|
81
|
+
fi
|
|
82
|
+
|
|
83
|
+
# SARIF report as a browsable artifact — best-effort, never changes the verdict.
|
|
84
|
+
npx "docguard-cli@$[[ inputs.version ]]" guard --format sarif > docguard.sarif || true
|
|
85
|
+
|
|
86
|
+
exit $RC
|
|
87
|
+
artifacts:
|
|
88
|
+
when: always
|
|
89
|
+
paths:
|
|
90
|
+
- docguard.sarif
|