docguard-cli 0.28.0 → 0.30.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.es.md +102 -0
- package/README.md +80 -32
- package/README.pt-BR.md +101 -0
- package/STANDARD.md +20 -10
- package/cli/commands/agents.mjs +149 -0
- package/cli/commands/diff.mjs +6 -15
- package/cli/commands/generate.mjs +14 -1001
- package/cli/commands/guard.mjs +136 -8
- package/cli/commands/llms.mjs +67 -5
- package/cli/commands/mcp.mjs +263 -0
- package/cli/commands/memory.mjs +115 -0
- package/cli/commands/score.mjs +76 -12
- package/cli/commands/trace.mjs +364 -1
- package/cli/commands/verify.mjs +93 -6
- package/cli/docguard.mjs +42 -5
- package/cli/findings.mjs +511 -0
- package/cli/scanners/agent-readability.mjs +202 -0
- package/cli/scanners/instruction-audit.mjs +320 -0
- package/cli/scanners/semantic-claims.mjs +7 -1
- package/cli/scanners/speckit.mjs +443 -28
- package/cli/shared-ignore.mjs +148 -16
- package/cli/shared.mjs +45 -1
- package/cli/validators/api-surface.mjs +113 -26
- package/cli/validators/architecture.mjs +66 -43
- package/cli/validators/canonical-sync.mjs +59 -28
- package/cli/validators/changelog.mjs +41 -17
- package/cli/validators/cross-reference.mjs +28 -11
- package/cli/validators/doc-quality.mjs +78 -44
- package/cli/validators/docs-coverage.mjs +90 -63
- package/cli/validators/docs-diff.mjs +63 -64
- package/cli/validators/docs-sync.mjs +48 -33
- package/cli/validators/drift.mjs +40 -34
- package/cli/validators/environment.mjs +67 -27
- package/cli/validators/freshness.mjs +12 -5
- package/cli/validators/generated-staleness.mjs +26 -10
- package/cli/validators/metadata-sync.mjs +28 -25
- package/cli/validators/metrics-consistency.mjs +89 -47
- package/cli/validators/schema-sync.mjs +37 -32
- package/cli/validators/security.mjs +7 -20
- package/cli/validators/spec-kit.mjs +3 -0
- package/cli/validators/structure.mjs +58 -23
- package/cli/validators/surface-sync.mjs +34 -15
- package/cli/validators/test-spec.mjs +87 -29
- package/cli/validators/todo-tracking.mjs +83 -74
- package/cli/validators/traceability.mjs +67 -39
- package/cli/writers/doc-generators.mjs +853 -0
- package/cli/writers/generate-io.mjs +142 -0
- package/cli/writers/sarif.mjs +129 -0
- package/commands/docguard.fix.md +56 -53
- package/commands/docguard.guard.md +53 -47
- package/commands/docguard.review.md +49 -31
- package/docs/ai-integration.md +133 -134
- package/docs/commands.md +49 -3
- package/docs/configuration.md +38 -0
- package/docs/faq.md +15 -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 +2 -1
- package/schemas/docguard-config.schema.json +28 -0
- package/templates/ci/gitlab-component.yml +90 -0
- package/templates/commands/docguard.fix.md +33 -10
- package/templates/commands/docguard.guard.md +40 -26
- package/templates/commands/docguard.init.md +23 -11
- package/templates/commands/docguard.review.md +25 -8
- package/templates/commands/docguard.update.md +14 -4
package/cli/scanners/speckit.mjs
CHANGED
|
@@ -23,7 +23,12 @@
|
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
25
|
import { existsSync, readFileSync, readdirSync, statSync, copyFileSync, writeFileSync } from 'node:fs';
|
|
26
|
-
import { resolve, join } from 'node:path';
|
|
26
|
+
import { resolve, join, relative, dirname, basename, extname } from 'node:path';
|
|
27
|
+
import { execFileSync } from 'node:child_process';
|
|
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';
|
|
27
32
|
|
|
28
33
|
// ──── Spec Kit Mandatory Sections ────
|
|
29
34
|
// Based on spec-kit's spec-template.md, plan-template.md, tasks-template.md
|
|
@@ -293,6 +298,304 @@ function validateTasksQuality(tasksPath) {
|
|
|
293
298
|
return issues;
|
|
294
299
|
}
|
|
295
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
|
+
|
|
296
599
|
// ──── CDD Mapping ────
|
|
297
600
|
|
|
298
601
|
const SPECKIT_CDD_MAP = {
|
|
@@ -385,95 +688,207 @@ export function generateFromSpecKit(projectDir, config, flags) {
|
|
|
385
688
|
* - Checks constitution → AGENTS.md mapping
|
|
386
689
|
*
|
|
387
690
|
* @returns {{ errors: string[], warnings: string[], passed: number, total: number }}
|
|
691
|
+
*
|
|
692
|
+
* v0.29: migrated to structured findings (SPK001–SPK007). Messages are
|
|
693
|
+
* byte-identical to the legacy strings — resultFromFindings derives the
|
|
694
|
+
* errors/warnings arrays from the same findings, so counts, exit codes, and
|
|
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.
|
|
388
702
|
*/
|
|
389
703
|
export function validateSpecKitIntegration(projectDir, config) {
|
|
390
|
-
const
|
|
704
|
+
const findings = [];
|
|
705
|
+
let passed = 0;
|
|
706
|
+
let total = 0;
|
|
391
707
|
|
|
392
708
|
const speckit = detectSpecKit(projectDir);
|
|
393
709
|
|
|
394
710
|
// If no Spec Kit detected, suggest it
|
|
395
711
|
if (!speckit.detected) {
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
712
|
+
total++;
|
|
713
|
+
findings.push(mkFinding({
|
|
714
|
+
code: 'SPK001',
|
|
715
|
+
validator: 'specKit',
|
|
716
|
+
severity: 'warn',
|
|
717
|
+
message: 'No Spec Kit artifacts detected. Consider `specify init` for spec-driven development (github.com/github/spec-kit)',
|
|
718
|
+
location: null,
|
|
719
|
+
suggestion: { kind: 'review', text: 'Adopt spec-driven development by initializing Spec Kit', command: 'specify init' },
|
|
720
|
+
}));
|
|
721
|
+
return resultFromFindings(findings, { passed, total });
|
|
401
722
|
}
|
|
402
723
|
|
|
403
724
|
// ── Check 1: .specify/ directory exists ──
|
|
404
|
-
|
|
725
|
+
total++;
|
|
405
726
|
if (speckit.specifyDir) {
|
|
406
|
-
|
|
727
|
+
passed++;
|
|
407
728
|
} else {
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
729
|
+
findings.push(mkFinding({
|
|
730
|
+
code: 'SPK002',
|
|
731
|
+
validator: 'specKit',
|
|
732
|
+
severity: 'warn',
|
|
733
|
+
message: 'Spec Kit artifacts found but .specify/ directory missing. Run `specify init` to create standard structure',
|
|
734
|
+
location: '.specify',
|
|
735
|
+
suggestion: { kind: 'fix', text: 'Create the standard Spec Kit structure', command: 'specify init' },
|
|
736
|
+
}));
|
|
411
737
|
}
|
|
412
738
|
|
|
413
739
|
// ── Check 2: Validate each spec's quality ──
|
|
414
740
|
for (const spec of speckit.specs) {
|
|
415
741
|
// 2a: spec.md quality
|
|
416
742
|
if (spec.hasSpec && spec.specPath) {
|
|
417
|
-
|
|
743
|
+
total++;
|
|
744
|
+
const loc = relative(projectDir, spec.specPath);
|
|
418
745
|
try {
|
|
419
746
|
const issues = validateSpecQuality(spec.specPath);
|
|
420
747
|
if (issues.length === 0) {
|
|
421
|
-
|
|
748
|
+
passed++;
|
|
422
749
|
} else {
|
|
423
750
|
for (const issue of issues) {
|
|
424
|
-
|
|
751
|
+
findings.push(mkFinding({
|
|
752
|
+
code: 'SPK003',
|
|
753
|
+
validator: 'specKit',
|
|
754
|
+
severity: 'warn',
|
|
755
|
+
message: `specs/${spec.name}/spec.md: ${issue}`,
|
|
756
|
+
location: loc,
|
|
757
|
+
suggestion: { kind: 'fix', text: 'Bring the spec up to the spec-kit spec-template.md shape (sections, FR-/SC- IDs)' },
|
|
758
|
+
}));
|
|
425
759
|
}
|
|
426
760
|
}
|
|
427
761
|
} catch {
|
|
428
|
-
|
|
762
|
+
findings.push(mkFinding({
|
|
763
|
+
code: 'SPK006',
|
|
764
|
+
validator: 'specKit',
|
|
765
|
+
severity: 'warn',
|
|
766
|
+
message: `specs/${spec.name}/spec.md: Could not read file`,
|
|
767
|
+
location: loc,
|
|
768
|
+
suggestion: { kind: 'review', text: 'Check the file exists and is readable (permissions/encoding)' },
|
|
769
|
+
}));
|
|
429
770
|
}
|
|
430
771
|
}
|
|
431
772
|
|
|
432
773
|
// 2b: plan.md quality
|
|
433
774
|
if (spec.hasPlan && spec.planPath) {
|
|
434
|
-
|
|
775
|
+
total++;
|
|
776
|
+
const loc = relative(projectDir, spec.planPath);
|
|
435
777
|
try {
|
|
436
778
|
const issues = validatePlanQuality(spec.planPath);
|
|
437
779
|
if (issues.length === 0) {
|
|
438
|
-
|
|
780
|
+
passed++;
|
|
439
781
|
} else {
|
|
440
782
|
for (const issue of issues) {
|
|
441
|
-
|
|
783
|
+
findings.push(mkFinding({
|
|
784
|
+
code: 'SPK004',
|
|
785
|
+
validator: 'specKit',
|
|
786
|
+
severity: 'warn',
|
|
787
|
+
message: `specs/${spec.name}/plan.md: ${issue}`,
|
|
788
|
+
location: loc,
|
|
789
|
+
suggestion: { kind: 'fix', text: 'Add the missing section per spec-kit plan-template.md' },
|
|
790
|
+
}));
|
|
442
791
|
}
|
|
443
792
|
}
|
|
444
793
|
} catch {
|
|
445
|
-
|
|
794
|
+
findings.push(mkFinding({
|
|
795
|
+
code: 'SPK006',
|
|
796
|
+
validator: 'specKit',
|
|
797
|
+
severity: 'warn',
|
|
798
|
+
message: `specs/${spec.name}/plan.md: Could not read file`,
|
|
799
|
+
location: loc,
|
|
800
|
+
suggestion: { kind: 'review', text: 'Check the file exists and is readable (permissions/encoding)' },
|
|
801
|
+
}));
|
|
446
802
|
}
|
|
447
803
|
}
|
|
448
804
|
|
|
449
805
|
// 2c: tasks.md quality
|
|
450
806
|
if (spec.hasTasks && spec.tasksPath) {
|
|
451
|
-
|
|
807
|
+
total++;
|
|
808
|
+
const loc = relative(projectDir, spec.tasksPath);
|
|
452
809
|
try {
|
|
453
810
|
const issues = validateTasksQuality(spec.tasksPath);
|
|
454
811
|
if (issues.length === 0) {
|
|
455
|
-
|
|
812
|
+
passed++;
|
|
456
813
|
} else {
|
|
457
814
|
for (const issue of issues) {
|
|
458
|
-
|
|
815
|
+
findings.push(mkFinding({
|
|
816
|
+
code: 'SPK005',
|
|
817
|
+
validator: 'specKit',
|
|
818
|
+
severity: 'warn',
|
|
819
|
+
message: `specs/${spec.name}/tasks.md: ${issue}`,
|
|
820
|
+
location: loc,
|
|
821
|
+
suggestion: { kind: 'fix', text: 'Add a phased breakdown with T-IDs per spec-kit tasks-template.md' },
|
|
822
|
+
}));
|
|
459
823
|
}
|
|
460
824
|
}
|
|
461
825
|
} catch {
|
|
462
|
-
|
|
826
|
+
findings.push(mkFinding({
|
|
827
|
+
code: 'SPK006',
|
|
828
|
+
validator: 'specKit',
|
|
829
|
+
severity: 'warn',
|
|
830
|
+
message: `specs/${spec.name}/tasks.md: Could not read file`,
|
|
831
|
+
location: loc,
|
|
832
|
+
suggestion: { kind: 'review', text: 'Check the file exists and is readable (permissions/encoding)' },
|
|
833
|
+
}));
|
|
463
834
|
}
|
|
464
835
|
}
|
|
465
836
|
}
|
|
466
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
|
+
|
|
467
875
|
// ── Check 3: Constitution → AGENTS.md mapping ──
|
|
468
876
|
if (speckit.constitution) {
|
|
469
|
-
|
|
877
|
+
total++;
|
|
470
878
|
const agentsPath = resolve(projectDir, 'AGENTS.md');
|
|
471
879
|
if (existsSync(agentsPath)) {
|
|
472
|
-
|
|
880
|
+
passed++;
|
|
473
881
|
} else {
|
|
474
|
-
|
|
882
|
+
findings.push(mkFinding({
|
|
883
|
+
code: 'SPK007',
|
|
884
|
+
validator: 'specKit',
|
|
885
|
+
severity: 'warn',
|
|
886
|
+
message: 'constitution.md exists but no AGENTS.md found. Create one for AI agent rules',
|
|
887
|
+
location: 'AGENTS.md',
|
|
888
|
+
suggestion: { kind: 'fix', text: 'Create an AGENTS.md that references the constitution', command: 'docguard init' },
|
|
889
|
+
}));
|
|
475
890
|
}
|
|
476
891
|
}
|
|
477
892
|
|
|
478
|
-
return
|
|
893
|
+
return resultFromFindings(findings, { passed, total });
|
|
479
894
|
}
|