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.
@@ -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++;
@@ -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
- if (out) return resolve(dir, out);
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.29.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.29.0
9
+ version: 0.30.0
10
10
  source: extensions/spec-kit-docguard/skills/docguard-fix
11
11
  ---
12
- <!-- docguard:version: 0.29.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.29.0
10
+ version: 0.30.0
11
11
  source: extensions/spec-kit-docguard/skills/docguard-guard
12
12
  ---
13
- <!-- docguard:version: 0.29.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.29.0
9
+ version: 0.30.0
10
10
  source: extensions/spec-kit-docguard/skills/docguard-review
11
11
  ---
12
- <!-- docguard:version: 0.29.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.29.0
9
+ version: 0.30.0
10
10
  source: extensions/spec-kit-docguard/skills/docguard-score
11
11
  ---
12
- <!-- docguard:version: 0.29.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.29.0
7
+ version: 0.30.0
8
8
  source: extensions/spec-kit-docguard/skills/docguard-sync
9
9
  ---
10
- <!-- docguard:version: 0.29.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.29.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