docguard-cli 0.34.9 → 0.36.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 +27 -15
- package/cli/commands/agent.mjs +27 -6
- package/cli/commands/ci.mjs +3 -0
- package/cli/commands/diagnose.mjs +8 -2
- package/cli/commands/feedback.mjs +83 -89
- package/cli/commands/fix.mjs +4 -0
- package/cli/commands/generate.mjs +3 -0
- package/cli/commands/guard.mjs +37 -20
- package/cli/commands/hooks.mjs +61 -40
- package/cli/commands/init.mjs +51 -5
- package/cli/commands/memory.mjs +29 -15
- package/cli/commands/report.mjs +12 -7
- package/cli/commands/score.mjs +39 -19
- package/cli/commands/sync.mjs +2 -0
- package/cli/commands/watch.mjs +113 -70
- package/cli/config.mjs +6 -3
- package/cli/docguard.mjs +12 -4
- package/cli/findings.mjs +13 -13
- package/cli/scanners/memory-plan.mjs +279 -134
- package/cli/scanners/project-type.mjs +6 -1
- package/cli/scanners/semantic-claims.mjs +176 -26
- package/cli/shared-diff.mjs +22 -1
- package/cli/shared-doc-roles.mjs +59 -0
- package/cli/shared-ignore.mjs +15 -2
- package/cli/shared-source.mjs +223 -1
- package/cli/validator-coverage.mjs +20 -0
- package/cli/validators/api-surface.mjs +94 -70
- package/cli/validators/architecture.mjs +19 -5
- package/cli/validators/diff-suspicion.mjs +45 -9
- package/cli/validators/docs-coverage.mjs +6 -5
- package/cli/validators/docs-diff.mjs +51 -7
- package/cli/validators/environment.mjs +3 -2
- package/cli/validators/freshness.mjs +140 -83
- package/cli/validators/schema-sync.mjs +3 -2
- package/cli/validators/security.mjs +58 -23
- package/cli/validators/structure.mjs +3 -1
- package/cli/validators/test-spec.mjs +3 -2
- package/cli/validators/todo-tracking.mjs +61 -28
- package/cli/validators/traceability.mjs +152 -38
- package/docs/configuration.md +41 -0
- package/extensions/spec-kit-docguard/extension.yml +2 -3
- 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/extensions/spec-kit-docguard/templates/extensions.yml +1 -2
- package/extensions/spec-kit-docguard/templates/github-workflows/docguard-guard.yml +74 -29
- package/package.json +1 -1
- package/schemas/docguard-config.schema.json +43 -1
- package/templates/ci/github-actions.yml +51 -11
package/cli/commands/guard.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { describeCheckCoverage, summarizeCheckCoverage } from '../validator-coverage.mjs';
|
|
2
|
+
import { applyDocRoles } from '../shared-doc-roles.mjs';
|
|
1
3
|
/**
|
|
2
4
|
* Guard Command — Validate project against its canonical documentation
|
|
3
5
|
* Runs all enabled validators and reports results.
|
|
@@ -8,7 +10,7 @@
|
|
|
8
10
|
*/
|
|
9
11
|
|
|
10
12
|
import { c, resolveSeverity, loadIgnorePatterns, resolveDocDirs } from '../shared.mjs';
|
|
11
|
-
import { walkFiles } from '../shared-ignore.mjs';
|
|
13
|
+
import { walkFiles, buildIgnoreFilter } from '../shared-ignore.mjs';
|
|
12
14
|
import { mkFinding, resultFromFindings } from '../findings.mjs';
|
|
13
15
|
import { loadValidatorSuppressions } from '../validator-markers.mjs';
|
|
14
16
|
import { detectAgentMode, isSpecKitInitialized } from '../ensure-skills.mjs';
|
|
@@ -239,28 +241,29 @@ function collectMarkdown(projectDir) {
|
|
|
239
241
|
/**
|
|
240
242
|
* Classify every discoverable Markdown file into a validation tier:
|
|
241
243
|
* canonical — in requiredFiles.canonical (structure + review-gated)
|
|
242
|
-
* tracked — under a doc home or root
|
|
244
|
+
* tracked — inventoried under a doc home or at root; individual detector scopes differ
|
|
243
245
|
* ignored — matched by .docguardignore
|
|
244
246
|
* unclassified — under NO tier; drift here is invisible (the Gap-1 trap)
|
|
245
247
|
*/
|
|
246
248
|
function computeDocCoverage(projectDir, config) {
|
|
247
|
-
const
|
|
249
|
+
const fileIgnored = loadIgnorePatterns(projectDir);
|
|
250
|
+
const configIgnored = buildIgnoreFilter(config.ignore || []);
|
|
251
|
+
const isIgnored = path => fileIgnored(path) || configIgnored(path);
|
|
248
252
|
const canonical = new Set(
|
|
249
253
|
((config.requiredFiles && config.requiredFiles.canonical) || []).map(p => p.replace(/\\/g, '/'))
|
|
250
254
|
);
|
|
251
255
|
// Any path declared in documentTypes is a KNOWN doc (even if optional) — not
|
|
252
256
|
// "untracked." This keeps the warning specific to genuinely-unenrolled files.
|
|
253
257
|
const known = new Set(Object.keys(config.documentTypes || {}).map(p => p.replace(/\\/g, '/')));
|
|
254
|
-
//
|
|
255
|
-
//
|
|
256
|
-
// for prefix matching.
|
|
258
|
+
// This is a document inventory, not a claim that every detector checks
|
|
259
|
+
// each tracked file. Keep individual check coverage separate.
|
|
257
260
|
const docHomePrefixes = resolveDocDirs(projectDir, config).map(d => d.replace(/\/?$/, '/'));
|
|
258
261
|
const all = collectMarkdown(projectDir);
|
|
259
262
|
let canonicalCount = 0, tracked = 0, ignored = 0;
|
|
260
263
|
const unclassified = [];
|
|
261
264
|
for (const rel of all) {
|
|
262
|
-
if (canonical.has(rel)) { canonicalCount++; continue; }
|
|
263
265
|
if (isIgnored(rel) || DOCGUARD_OWN_DOC_RE.test(rel)) { ignored++; continue; }
|
|
266
|
+
if (canonical.has(rel)) { canonicalCount++; continue; }
|
|
264
267
|
const inHome = docHomePrefixes.some(h => rel.startsWith(h));
|
|
265
268
|
const atRoot = !rel.includes('/');
|
|
266
269
|
if (inHome || atRoot || known.has(rel)) { tracked++; continue; }
|
|
@@ -270,6 +273,7 @@ function computeDocCoverage(projectDir, config) {
|
|
|
270
273
|
}
|
|
271
274
|
|
|
272
275
|
export function runGuardInternal(projectDir, config) {
|
|
276
|
+
config = applyDocRoles(projectDir, config);
|
|
273
277
|
const validators = config.validators || {};
|
|
274
278
|
const results = [];
|
|
275
279
|
|
|
@@ -286,8 +290,7 @@ export function runGuardInternal(projectDir, config) {
|
|
|
286
290
|
{ key: 'freshness', name: 'Freshness', fn: () => {
|
|
287
291
|
// v0.29: adapter now emits structured findings (FRS001–FRS005). The
|
|
288
292
|
// validator keeps its array-of-{status, code, doc, message} contract;
|
|
289
|
-
// messages
|
|
290
|
-
// them), so counts/exit codes are unchanged.
|
|
293
|
+
// messages describe review signals rather than asserting semantic drift.
|
|
291
294
|
const freshnessResults = validateFreshness(projectDir, config);
|
|
292
295
|
const findings = [];
|
|
293
296
|
let passed = 0;
|
|
@@ -298,14 +301,14 @@ export function runGuardInternal(projectDir, config) {
|
|
|
298
301
|
code: r.code || null,
|
|
299
302
|
validator: 'freshness',
|
|
300
303
|
severity: r.status === 'fail' ? 'error' : 'warn',
|
|
304
|
+
confidence: r.confidence || 'low',
|
|
301
305
|
message: r.message,
|
|
302
306
|
location: r.doc || null,
|
|
303
|
-
suggestion: r.
|
|
304
|
-
? { kind: 'fix', text: 'Commit the doc, or stamp it reviewed', pragma: '<!-- docguard:last-reviewed YYYY-MM-DD -->' }
|
|
305
|
-
: { kind: 'fix', text: 'Refresh the stale code-truth sections', command: 'docguard sync --write' },
|
|
307
|
+
suggestion: r.suggestion || { kind: 'review', text: 'Review the document against its intended scope. A history signal does not establish which side should change.' },
|
|
306
308
|
}));
|
|
307
309
|
}
|
|
308
|
-
|
|
310
|
+
const skipped = freshnessResults.filter(r => r.status === 'skip');
|
|
311
|
+
return { ...resultFromFindings(findings, { passed, total: passed + findings.length }), ...(passed + findings.length === 0 && skipped.length ? { applicability: { status: 'no-matches', reason: skipped.map(r => r.message).join('; ') } } : {}) };
|
|
309
312
|
}},
|
|
310
313
|
{ key: 'traceability', name: 'Traceability', fn: () => validateTraceability(projectDir, config) },
|
|
311
314
|
{ key: 'docsDiff', name: 'Docs-Diff', fn: () => validateDocsDiff(projectDir, config) },
|
|
@@ -358,7 +361,7 @@ export function runGuardInternal(projectDir, config) {
|
|
|
358
361
|
results.push({ ...result, name, key, durationMs, ...classifyResult(result) });
|
|
359
362
|
} catch (err) {
|
|
360
363
|
const durationMs = Math.round((performance.now() - start) * 100) / 100;
|
|
361
|
-
results.push({ name, key, status: 'fail', quality: 'LOW', errors: [err.message], warnings: [], passed: 0, total: 1, durationMs });
|
|
364
|
+
results.push({ name, key, status: 'fail', quality: 'LOW', applicability: { status: 'error', reason: 'Validator could not complete: ' + err.message }, errors: [err.message], warnings: [], passed: 0, total: 1, durationMs });
|
|
362
365
|
}
|
|
363
366
|
}
|
|
364
367
|
|
|
@@ -376,7 +379,7 @@ export function runGuardInternal(projectDir, config) {
|
|
|
376
379
|
results.push({ ...result, name: 'Canonical-Sync', key: 'canonicalSync', durationMs, ...classifyResult(result) });
|
|
377
380
|
} catch (err) {
|
|
378
381
|
const durationMs = Math.round((performance.now() - start) * 100) / 100;
|
|
379
|
-
results.push({ name: 'Canonical-Sync', key: 'canonicalSync', status: 'fail', quality: 'LOW', errors: [err.message], warnings: [], passed: 0, total: 1, durationMs });
|
|
382
|
+
results.push({ name: 'Canonical-Sync', key: 'canonicalSync', status: 'fail', quality: 'LOW', applicability: { status: 'error', reason: 'Validator could not complete: ' + err.message }, errors: [err.message], warnings: [], passed: 0, total: 1, durationMs });
|
|
380
383
|
}
|
|
381
384
|
}
|
|
382
385
|
|
|
@@ -391,7 +394,7 @@ export function runGuardInternal(projectDir, config) {
|
|
|
391
394
|
results.push({ ...result, name: 'Metrics-Consistency', key: 'metricsConsistency', durationMs, ...classifyResult(result) });
|
|
392
395
|
} catch (err) {
|
|
393
396
|
const durationMs = Math.round((performance.now() - start) * 100) / 100;
|
|
394
|
-
results.push({ name: 'Metrics-Consistency', key: 'metricsConsistency', status: 'fail', quality: 'LOW', errors: [err.message], warnings: [], passed: 0, total: 1, durationMs });
|
|
397
|
+
results.push({ name: 'Metrics-Consistency', key: 'metricsConsistency', status: 'fail', quality: 'LOW', applicability: { status: 'error', reason: 'Validator could not complete: ' + err.message }, errors: [err.message], warnings: [], passed: 0, total: 1, durationMs });
|
|
395
398
|
}
|
|
396
399
|
}
|
|
397
400
|
|
|
@@ -431,6 +434,11 @@ export function runGuardInternal(projectDir, config) {
|
|
|
431
434
|
}
|
|
432
435
|
}
|
|
433
436
|
|
|
437
|
+
for (const key of ['canonicalSync', 'metricsConsistency']) {
|
|
438
|
+
if (validators[key] === false && !results.some(r => r.key === key)) results.push({ key, name: key === 'canonicalSync' ? 'Canonical-Sync' : 'Metrics-Consistency', status: 'skipped', quality: null, errors: [], warnings: [], passed: 0, total: 0, durationMs: 0 });
|
|
439
|
+
}
|
|
440
|
+
for (const result of results) result.applicability = describeCheckCoverage(projectDir, config, result);
|
|
441
|
+
const checkCoverage = summarizeCheckCoverage(results);
|
|
434
442
|
const activeResults = results.filter(r => r.status !== 'skipped');
|
|
435
443
|
const totalErrors = activeResults.reduce((sum, r) => sum + r.errors.length, 0);
|
|
436
444
|
const totalWarnings = activeResults.reduce((sum, r) => sum + r.warnings.length, 0);
|
|
@@ -504,6 +512,7 @@ export function runGuardInternal(projectDir, config) {
|
|
|
504
512
|
effectiveWarnings,
|
|
505
513
|
baselineSuppressed,
|
|
506
514
|
coverage,
|
|
515
|
+
checkCoverage,
|
|
507
516
|
semanticClaims,
|
|
508
517
|
validators: results,
|
|
509
518
|
// Unknown keys in `docguard:validator … n/a` markers — typo protection so
|
|
@@ -671,8 +680,9 @@ export function runGuard(projectDir, config, flags) {
|
|
|
671
680
|
// Not applicable — nothing to validate. Render neutrally (NOT a green pass)
|
|
672
681
|
// so the reader can tell "checked and clean" apart from "nothing checked".
|
|
673
682
|
if (v.status === 'na') {
|
|
674
|
-
const
|
|
675
|
-
|
|
683
|
+
const showReason = flags.verbose || ['unsupported', 'partial', 'missing-prerequisite', 'error'].includes(v.applicability.status);
|
|
684
|
+
const reason = showReason ? ` ${c.dim}(${v.applicability.reason})${c.reset}` : '';
|
|
685
|
+
console.log(` ${c.dim}➖ ${v.name}${c.reset} ${c.dim}[${v.applicability.status}]${c.reset}${reason}`);
|
|
676
686
|
continue;
|
|
677
687
|
}
|
|
678
688
|
|
|
@@ -770,6 +780,8 @@ export function runGuard(projectDir, config, flags) {
|
|
|
770
780
|
if (Array.isArray(data.reportable) && data.reportable.length > 0) {
|
|
771
781
|
const n = data.reportable.length;
|
|
772
782
|
console.log(` ${c.dim}↪ ${n} finding(s) look uncertain (possible false positives). Review or report: ${c.cyan}${skill('feedback')}${c.reset}`);
|
|
783
|
+
} else if (Array.isArray(data.findings) && data.findings.length > 0) {
|
|
784
|
+
console.log(` ${c.dim}Disagree with a finding? Review a contribution: ${c.cyan}docguard feedback --code <CODE> --preview${c.reset}`);
|
|
773
785
|
}
|
|
774
786
|
|
|
775
787
|
// Read-only skills nudge (never writes — that's `init`'s job). If the agent
|
|
@@ -801,9 +813,14 @@ export function runGuard(projectDir, config, flags) {
|
|
|
801
813
|
}
|
|
802
814
|
}
|
|
803
815
|
}
|
|
816
|
+
if (data.checkCoverage) {
|
|
817
|
+
const counts = data.checkCoverage.counts;
|
|
818
|
+
console.log(' Check coverage: ' + Object.entries(counts).filter(([, count]) => count > 0).map(([status, count]) => count + ' ' + status.replaceAll('-', ' ')).join(' · '));
|
|
819
|
+
console.log(' Document inventory and passing checks do not establish factual accuracy.');
|
|
820
|
+
}
|
|
804
821
|
if (data.semanticClaims && data.semanticClaims.count > 0) {
|
|
805
822
|
console.log(`\n ${c.cyan}🔍 ${data.semanticClaims.count} documented claim(s) (counts/limits/enums) are unverified against code.${c.reset}`);
|
|
806
|
-
console.log(` ${c.dim}A
|
|
823
|
+
console.log(` ${c.dim}A passing guard means configured gates passed; these values remain unverified.${c.reset}`);
|
|
807
824
|
console.log(` ${c.dim}Confirm them: ${c.cyan}${skill('verify')} --semantic${c.reset}`);
|
|
808
825
|
}
|
|
809
826
|
|
|
@@ -873,7 +890,7 @@ export function runGuard(projectDir, config, flags) {
|
|
|
873
890
|
if (freshness && freshness.warnings) {
|
|
874
891
|
const staleDocs = freshness.warnings.filter(w => /\d+ code commits since/.test(w));
|
|
875
892
|
if (staleDocs.length >= 2) {
|
|
876
|
-
console.log(`\n ${c.yellow}↻ ${staleDocs.length}
|
|
893
|
+
console.log(`\n ${c.yellow}↻ ${staleDocs.length} documents have repository-history review signals. Review their scope and intended behavior before changing documentation or code.${c.reset}`);
|
|
877
894
|
}
|
|
878
895
|
}
|
|
879
896
|
}
|
package/cli/commands/hooks.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Creates git hooks that run guard/score before commits.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { existsSync,
|
|
6
|
+
import { existsSync, mkdirSync, chmodSync, readFileSync, unlinkSync } from 'node:fs';
|
|
7
7
|
|
|
8
8
|
// v0.16-P3: managed-block markers. Letting users extend the hook with their
|
|
9
9
|
// own commands (data-file guards, lint checks, etc.) without us clobbering
|
|
@@ -56,9 +56,28 @@ function spliceManagedBlock(existing, newBody) {
|
|
|
56
56
|
}
|
|
57
57
|
import { resolve, relative, basename } from 'node:path';
|
|
58
58
|
import { c } from '../shared.mjs';
|
|
59
|
+
import { safeWrite } from '../writers/generate-io.mjs';
|
|
59
60
|
import { getHooksDir } from '../shared-git.mjs';
|
|
60
61
|
import { listCanonicalDocs } from '../shared-ignore.mjs';
|
|
61
62
|
|
|
63
|
+
// Git enforcement is offline and fail-closed; agent nudges stay best-effort.
|
|
64
|
+
const ENFORCEMENT_RUNTIME = `
|
|
65
|
+
if ! command -v node >/dev/null 2>&1; then
|
|
66
|
+
echo "❌ Node.js runtime not found — operation blocked" >&2
|
|
67
|
+
exit 1
|
|
68
|
+
fi
|
|
69
|
+
|
|
70
|
+
# Git runs these hooks from the worktree root. Prefer its installed version.
|
|
71
|
+
if [ -x "./node_modules/.bin/docguard" ]; then
|
|
72
|
+
DOCGUARD="./node_modules/.bin/docguard"
|
|
73
|
+
elif command -v docguard >/dev/null 2>&1; then
|
|
74
|
+
DOCGUARD="docguard"
|
|
75
|
+
else
|
|
76
|
+
echo "❌ DocGuard not found locally or on PATH — operation blocked" >&2
|
|
77
|
+
exit 1
|
|
78
|
+
fi
|
|
79
|
+
`;
|
|
80
|
+
|
|
62
81
|
const HOOKS = {
|
|
63
82
|
'pre-commit': {
|
|
64
83
|
description: 'Run docguard guard before every commit',
|
|
@@ -70,20 +89,11 @@ const HOOKS = {
|
|
|
70
89
|
|
|
71
90
|
echo "🛡️ Running DocGuard guard..."
|
|
72
91
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
EXIT_CODE=$?
|
|
77
|
-
elif command -v docguard &> /dev/null; then
|
|
78
|
-
docguard guard
|
|
79
|
-
EXIT_CODE=$?
|
|
80
|
-
else
|
|
81
|
-
echo "⚠️ DocGuard not found. Skipping guard check."
|
|
82
|
-
echo " Install: npm install -g docguard"
|
|
83
|
-
exit 0
|
|
84
|
-
fi
|
|
92
|
+
${ENFORCEMENT_RUNTIME}
|
|
93
|
+
"$DOCGUARD" guard
|
|
94
|
+
EXIT_CODE=$?
|
|
85
95
|
|
|
86
|
-
if [ $EXIT_CODE -
|
|
96
|
+
if [ "$EXIT_CODE" -ne 0 ] && [ "$EXIT_CODE" -ne 2 ]; then
|
|
87
97
|
echo ""
|
|
88
98
|
echo "❌ DocGuard guard FAILED — commit blocked"
|
|
89
99
|
echo " Fix the errors above, then try again."
|
|
@@ -110,22 +120,28 @@ MIN_SCORE=60
|
|
|
110
120
|
|
|
111
121
|
echo "📊 Running DocGuard score check (minimum: $MIN_SCORE)..."
|
|
112
122
|
|
|
113
|
-
|
|
114
|
-
if command -v npx &> /dev/null; then
|
|
115
|
-
RESULT=$(npx docguard-cli score --format json 2>/dev/null)
|
|
116
|
-
elif command -v docguard &> /dev/null; then
|
|
117
|
-
RESULT=$(docguard score --format json 2>/dev/null)
|
|
118
|
-
else
|
|
119
|
-
echo "⚠️ DocGuard not found. Skipping score check."
|
|
120
|
-
exit 0
|
|
121
|
-
fi
|
|
123
|
+
${ENFORCEMENT_RUNTIME}
|
|
122
124
|
|
|
123
|
-
#
|
|
124
|
-
|
|
125
|
+
# Score has no warning exit status: any command failure blocks the push.
|
|
126
|
+
RESULT=$("$DOCGUARD" score --format json)
|
|
127
|
+
EXIT_CODE=$?
|
|
128
|
+
if [ "$EXIT_CODE" -ne 0 ]; then
|
|
129
|
+
echo "❌ DocGuard score failed (exit $EXIT_CODE) — push blocked" >&2
|
|
130
|
+
exit 1
|
|
131
|
+
fi
|
|
125
132
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
133
|
+
# Parse the complete JSON document, never a substring or a nested score.
|
|
134
|
+
SCORE=$(printf '%s' "$RESULT" | node -e '
|
|
135
|
+
try {
|
|
136
|
+
const result = JSON.parse(require("node:fs").readFileSync(0, "utf8"));
|
|
137
|
+
if (!result || Array.isArray(result) || !Number.isInteger(result.score) ||
|
|
138
|
+
result.score < 0 || result.score > 100) process.exit(1);
|
|
139
|
+
process.stdout.write(String(result.score));
|
|
140
|
+
} catch { process.exit(1); }
|
|
141
|
+
')
|
|
142
|
+
if [ "$?" -ne 0 ] || [ -z "$SCORE" ]; then
|
|
143
|
+
echo "❌ Could not determine a valid CDD score — push blocked" >&2
|
|
144
|
+
exit 1
|
|
129
145
|
fi
|
|
130
146
|
|
|
131
147
|
echo " CDD Score: $SCORE/100"
|
|
@@ -188,24 +204,29 @@ const PRE_COMMIT_AUTOFIX = `#!/bin/sh
|
|
|
188
204
|
# Install: docguard hooks --type pre-commit --auto-fix
|
|
189
205
|
# Remove: rm .git/hooks/pre-commit
|
|
190
206
|
|
|
191
|
-
|
|
192
|
-
if command -v docguard >/dev/null 2>&1; then RUN="docguard"; fi
|
|
207
|
+
${ENFORCEMENT_RUNTIME}
|
|
193
208
|
|
|
194
209
|
echo "🛡️ DocGuard: applying mechanical fixes…"
|
|
195
210
|
# 1. Deterministically remove stale documented endpoints (safe, no AI).
|
|
196
|
-
$
|
|
211
|
+
if ! "$DOCGUARD" fix --write; then
|
|
212
|
+
echo "❌ DocGuard fix failed — commit blocked" >&2
|
|
213
|
+
exit 1
|
|
214
|
+
fi
|
|
197
215
|
# 2. Re-stage anything DocGuard rewrote so the fix is part of THIS commit.
|
|
198
|
-
git add docs-canonical
|
|
216
|
+
if ! git add docs-canonical/; then
|
|
217
|
+
echo "❌ Could not stage DocGuard fixes — commit blocked" >&2
|
|
218
|
+
exit 1
|
|
219
|
+
fi
|
|
199
220
|
|
|
200
221
|
# 3. Validate.
|
|
201
|
-
$
|
|
222
|
+
"$DOCGUARD" guard
|
|
202
223
|
EXIT_CODE=$?
|
|
203
224
|
|
|
204
|
-
if [ $EXIT_CODE -
|
|
225
|
+
if [ "$EXIT_CODE" -ne 0 ] && [ "$EXIT_CODE" -ne 2 ]; then
|
|
205
226
|
echo ""
|
|
206
227
|
echo "❌ DocGuard guard FAILED — commit blocked."
|
|
207
228
|
echo " Remaining issues need an AI agent (content rewrites, not mechanical):"
|
|
208
|
-
echo " Run: $
|
|
229
|
+
echo " Run: $DOCGUARD diagnose (emits ready-to-paste agent fix prompts)"
|
|
209
230
|
echo " To skip: git commit --no-verify"
|
|
210
231
|
exit 1
|
|
211
232
|
elif [ $EXIT_CODE -eq 2 ]; then
|
|
@@ -306,7 +327,7 @@ export function runHooks(projectDir, config, flags) {
|
|
|
306
327
|
// re-install.
|
|
307
328
|
const spliced = spliceManagedBlock(existing, newContent);
|
|
308
329
|
if (spliced !== null) {
|
|
309
|
-
|
|
330
|
+
safeWrite(hookPath, spliced);
|
|
310
331
|
chmodSync(hookPath, 0o755);
|
|
311
332
|
console.log(` ${c.green}↻ ${name}${c.reset}: updated DocGuard managed block (preserved user content around it)`);
|
|
312
333
|
installed++;
|
|
@@ -330,7 +351,7 @@ export function runHooks(projectDir, config, flags) {
|
|
|
330
351
|
// --force path: write fresh managed-block version
|
|
331
352
|
}
|
|
332
353
|
|
|
333
|
-
|
|
354
|
+
safeWrite(hookPath, newContent);
|
|
334
355
|
chmodSync(hookPath, 0o755);
|
|
335
356
|
console.log(` ${c.green}✅ ${name}${c.reset}: ${desc}`);
|
|
336
357
|
installed++;
|
|
@@ -402,7 +423,7 @@ export function installClaudeNudge(projectDir, { remove = false } = {}) {
|
|
|
402
423
|
settings.hooks.PostToolUse = groups.filter(g => !isOurNudgeGroup(g));
|
|
403
424
|
if (settings.hooks.PostToolUse.length === 0) delete settings.hooks.PostToolUse;
|
|
404
425
|
if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
|
|
405
|
-
|
|
426
|
+
safeWrite(settingsPath, JSON.stringify(settings, null, 2) + '\n');
|
|
406
427
|
console.log(` ${c.yellow}🗑️ Removed the DocGuard nudge hook from .claude/settings.json${c.reset} ${c.dim}(everything else preserved)${c.reset}\n`);
|
|
407
428
|
return;
|
|
408
429
|
}
|
|
@@ -420,7 +441,7 @@ export function installClaudeNudge(projectDir, { remove = false } = {}) {
|
|
|
420
441
|
});
|
|
421
442
|
|
|
422
443
|
if (!existsSync(settingsDir)) mkdirSync(settingsDir, { recursive: true });
|
|
423
|
-
|
|
444
|
+
safeWrite(settingsPath, JSON.stringify(settings, null, 2) + '\n');
|
|
424
445
|
console.log(` ${c.green}✅ Installed the DocGuard nudge hook${c.reset} → .claude/settings.json (PostToolUse)`);
|
|
425
446
|
console.log(` ${c.dim}After an agent edits a canonical doc (or code the docs reference), it is${c.reset}`);
|
|
426
447
|
console.log(` ${c.dim}nudged toward docguard guard --changed-only / docguard impact.${c.reset}`);
|
|
@@ -472,7 +493,7 @@ export function runNudgeHook(projectDir) {
|
|
|
472
493
|
state[rel] = now;
|
|
473
494
|
try {
|
|
474
495
|
mkdirSync(resolve(projectDir, '.docguard'), { recursive: true });
|
|
475
|
-
|
|
496
|
+
safeWrite(statePath, JSON.stringify(state, null, 2) + '\n');
|
|
476
497
|
} catch { /* state is best-effort; still nudge */ }
|
|
477
498
|
|
|
478
499
|
process.stdout.write(JSON.stringify({ decision: 'block', reason }) + '\n');
|
package/cli/commands/init.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { assertDefaultDocWrites } from '../shared-doc-roles.mjs';
|
|
1
2
|
/**
|
|
2
3
|
* Init Command — Initialize CDD documentation from templates
|
|
3
4
|
*
|
|
@@ -9,7 +10,7 @@
|
|
|
9
10
|
* with a warning suggesting spec-kit installation.
|
|
10
11
|
*/
|
|
11
12
|
|
|
12
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync } from 'node:fs';
|
|
13
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, lstatSync, realpathSync, copyFileSync } from 'node:fs';
|
|
13
14
|
import { resolve, dirname } from 'node:path';
|
|
14
15
|
import { listCanonicalDocs } from '../shared-ignore.mjs';
|
|
15
16
|
import { fileURLToPath } from 'node:url';
|
|
@@ -17,14 +18,15 @@ import { createInterface } from 'node:readline';
|
|
|
17
18
|
import { execSync } from 'node:child_process';
|
|
18
19
|
import { c, PROFILES, CURRENT_SCHEMA_VERSION } from '../shared.mjs';
|
|
19
20
|
import { ensureSkills, detectAgentMode, detectAIAgent, isSpecKitAvailable, isSpecKitInitialized, getDetectedAgent, safeSpawnSpecify } from '../ensure-skills.mjs';
|
|
21
|
+
import { safeWrite } from '../writers/generate-io.mjs';
|
|
20
22
|
|
|
21
23
|
// v0.20: scaffolder names that can be passed via `init --with <name>` and
|
|
22
|
-
// dispatched to
|
|
23
|
-
//
|
|
24
|
+
// dispatched to their writers. CI scaffolding is distinct from the standalone
|
|
25
|
+
// `ci` validation gate; it copies the maintained workflow starter.
|
|
24
26
|
const SCAFFOLDER_DISPATCH = {
|
|
25
27
|
agents: async (dir, cfg, flags) => (await import('./agents.mjs')).runAgents(dir, cfg, flags),
|
|
26
28
|
hooks: async (dir, cfg, flags) => (await import('./hooks.mjs')).runHooks(dir, cfg, flags),
|
|
27
|
-
ci:
|
|
29
|
+
ci: (dir, cfg, flags) => scaffoldCI(dir, flags),
|
|
28
30
|
badge: async (dir, cfg, flags) => (await import('./badge.mjs')).runBadge(dir, cfg, flags),
|
|
29
31
|
llms: async (dir, cfg, flags) => (await import('./llms.mjs')).runLlms(dir, cfg, flags),
|
|
30
32
|
publish: async (dir, cfg, flags) => (await import('./publish.mjs')).runPublish(dir, cfg, flags),
|
|
@@ -79,6 +81,43 @@ const __filename = fileURLToPath(import.meta.url);
|
|
|
79
81
|
const __dirname = dirname(__filename);
|
|
80
82
|
const TEMPLATES_DIR = resolve(__dirname, '../../templates');
|
|
81
83
|
|
|
84
|
+
function scaffoldCI(projectDir, flags) {
|
|
85
|
+
// Resolve the user's root once (e.g. macOS /tmp), then refuse symlinks in
|
|
86
|
+
// every destination component, including the backup safeWrite will use.
|
|
87
|
+
const root = realpathSync(projectDir);
|
|
88
|
+
const target = resolve(root, '.github/workflows/docguard.yml');
|
|
89
|
+
let existing;
|
|
90
|
+
for (const [path, directory] of [
|
|
91
|
+
[resolve(root, '.github'), true],
|
|
92
|
+
[dirname(target), true],
|
|
93
|
+
[target, false],
|
|
94
|
+
[target + '.bak', false],
|
|
95
|
+
]) {
|
|
96
|
+
let stat;
|
|
97
|
+
try { stat = lstatSync(path); } catch (err) {
|
|
98
|
+
if (err.code === 'ENOENT') continue;
|
|
99
|
+
throw err;
|
|
100
|
+
}
|
|
101
|
+
if (stat.isSymbolicLink() || (directory ? !stat.isDirectory() : !stat.isFile())) {
|
|
102
|
+
throw new Error(`Unsafe CI scaffold path: ${path} (expected a real ${directory ? 'directory' : 'file'}, not a symlink)`);
|
|
103
|
+
}
|
|
104
|
+
if (!directory && stat.nlink > 1) throw new Error(`Unsafe CI scaffold path: ${path} (hard-linked file)`);
|
|
105
|
+
if (path === target) existing = stat;
|
|
106
|
+
}
|
|
107
|
+
if (existing && !flags.force) {
|
|
108
|
+
console.log(` ${c.dim}⏭️ .github/workflows/docguard.yml exists; use --force to overwrite${c.reset}`);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
// Copy literally: the starter's fixed package pin is release-tested, and
|
|
112
|
+
// GitHub expressions must not go through canonical-doc interpolation.
|
|
113
|
+
const content = readFileSync(resolve(TEMPLATES_DIR, 'ci/github-actions.yml'), 'utf-8');
|
|
114
|
+
// safeWrite's generic backup is best-effort and skips empty files. Here a
|
|
115
|
+
// forced replacement requires a successful backup, including empty files.
|
|
116
|
+
if (existing) copyFileSync(target, target + '.bak');
|
|
117
|
+
safeWrite(target, content);
|
|
118
|
+
console.log(` ${c.green}✅ .github/workflows/docguard.yml ${existing ? 'replaced (backup: docguard.yml.bak)' : 'created'}${c.reset}`);
|
|
119
|
+
}
|
|
120
|
+
|
|
82
121
|
/**
|
|
83
122
|
* v0.28 (field report #11): inject a `<!-- docguard:last-reviewed DATE -->`
|
|
84
123
|
* marker right after the first H1, so a canonical doc has a freshness signal the
|
|
@@ -160,6 +199,7 @@ function shouldRunGenerate(projectDir, flags) {
|
|
|
160
199
|
}
|
|
161
200
|
|
|
162
201
|
export async function runInit(projectDir, config, flags) {
|
|
202
|
+
if (true) assertDefaultDocWrites(config);
|
|
163
203
|
// v0.20: `--wizard` dispatches to the full interactive onboarding (formerly
|
|
164
204
|
// `docguard setup`). Done before profile validation so the wizard can ask
|
|
165
205
|
// for the profile itself if needed.
|
|
@@ -178,7 +218,13 @@ export async function runInit(projectDir, config, flags) {
|
|
|
178
218
|
console.log(`${c.dim} canonical docs from your code instead of dumping a blank skeleton.${c.reset}`);
|
|
179
219
|
console.log(`${c.dim} (Opt out: ${c.cyan}docguard init --skeleton${c.dim} for the blank-template path.)${c.reset}\n`);
|
|
180
220
|
const { runGenerate } = await import('./generate.mjs');
|
|
181
|
-
|
|
221
|
+
const result = await runGenerate(projectDir, config, { ...flags, plan: true });
|
|
222
|
+
// Smart init still honors explicit workflow/scaffolder requests after the
|
|
223
|
+
// plan, with the same ordering and stop-on-failure semantics as skeletons.
|
|
224
|
+
if (Array.isArray(flags.with) && flags.with.length > 0) {
|
|
225
|
+
await runScaffolders(projectDir, config, flags, flags.with);
|
|
226
|
+
}
|
|
227
|
+
return result;
|
|
182
228
|
}
|
|
183
229
|
|
|
184
230
|
const profileName = flags.profile || 'standard';
|
package/cli/commands/memory.mjs
CHANGED
|
@@ -21,10 +21,12 @@
|
|
|
21
21
|
* Zero NPM dependencies. Pure orchestration of existing diff helpers.
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
-
import { existsSync
|
|
24
|
+
import { existsSync } from 'node:fs';
|
|
25
25
|
import { resolve } from 'node:path';
|
|
26
26
|
import { c } from '../shared.mjs';
|
|
27
|
-
import {
|
|
27
|
+
import { createEvidenceReader, evidenceDocs, extractSemanticClaims, contentHash, gitEvidence, SEMANTIC_COVERAGE_LIMITATION } from '../scanners/semantic-claims.mjs';
|
|
28
|
+
import { safeWrite } from '../writers/generate-io.mjs';
|
|
29
|
+
import { buildScoreAssurance } from './score.mjs';
|
|
28
30
|
import { diffRoutes, diffEntities, diffEnvVars, diffTechStack } from './diff.mjs';
|
|
29
31
|
import { buildMemoryPlan } from '../scanners/memory-plan.mjs';
|
|
30
32
|
import { runGuardInternal } from './guard.mjs';
|
|
@@ -75,13 +77,17 @@ function extractConventions(agentsMd, capLines = 60) {
|
|
|
75
77
|
|
|
76
78
|
/**
|
|
77
79
|
* `docguard memory --pack` — write .docguard/context-pack.md: a compact,
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
* so it can't hallucinate and is always regenerable.
|
|
80
|
+
* session-start snapshot for an AI agent. Scanner output and copied prose
|
|
81
|
+
* remain unverified; hashes identify captured inputs, not factual accuracy.
|
|
81
82
|
*/
|
|
82
83
|
function runMemoryPack(projectDir, config, flags) {
|
|
83
|
-
const plan = buildMemoryPlan(projectDir, config);
|
|
84
|
+
const plan = buildMemoryPlan(projectDir, { ...config, diskCache: false });
|
|
84
85
|
const guard = runGuardInternal(projectDir, config);
|
|
86
|
+
const read = createEvidenceReader(projectDir);
|
|
87
|
+
const git = gitEvidence(projectDir);
|
|
88
|
+
const assurance = buildScoreAssurance(projectDir, config);
|
|
89
|
+
let claims = null;
|
|
90
|
+
try { claims = extractSemanticClaims(projectDir, config, read); } catch { /* unknown, not an empty verified set */ }
|
|
85
91
|
const lines = [];
|
|
86
92
|
|
|
87
93
|
lines.push(`# Context Pack — ${config.projectName}`);
|
|
@@ -91,6 +97,15 @@ function runMemoryPack(projectDir, config, flags) {
|
|
|
91
97
|
lines.push(`**Guard:** ${guard.status} — ${guard.passed}/${guard.total} checks (${guard.errors} error(s), ${guard.warnings} warning(s))`);
|
|
92
98
|
lines.push('');
|
|
93
99
|
|
|
100
|
+
lines.push('**Provenance:** snapshot only — not reviewed or verified');
|
|
101
|
+
lines.push(`- Git revision: ${git.revision ?? 'unknown'} · dirty: ${git.dirty ?? 'unknown'}`);
|
|
102
|
+
lines.push(`- Assurance: structural-only · factual accuracy: unknown · status: ${assurance.status}`);
|
|
103
|
+
lines.push(`- Unverified claims: ${assurance.unverifiedClaims ?? 'unknown'} extracted candidates (heuristic and capped; zero does not establish prose correctness)`);
|
|
104
|
+
lines.push(`- Claim evidence fingerprint: ${claims ? contentHash(JSON.stringify(claims.map(c => [c.stableId, c.evidence.snapshotHash]).sort())) : 'unknown'}`);
|
|
105
|
+
lines.push(`- Coverage limitation: ${SEMANTIC_COVERAGE_LIMITATION}`);
|
|
106
|
+
lines.push('Hashes capture current document and cited-source content. Regenerate after changes; timestamps and last-reviewed labels do not establish accuracy.');
|
|
107
|
+
lines.push('');
|
|
108
|
+
|
|
94
109
|
lines.push('## Code-truth surface');
|
|
95
110
|
lines.push('');
|
|
96
111
|
lines.push(`- Stack: ${plan.profile.languages.join(', ') || 'unknown'}${plan.profile.frameworks.length ? ` · ${plan.profile.frameworks.join(', ')}` : ''} · kind: ${plan.profile.kind}`);
|
|
@@ -98,17 +113,18 @@ function runMemoryPack(projectDir, config, flags) {
|
|
|
98
113
|
lines.push(`- Tests: ${plan.surface.tests.totalFiles} files, ${plan.surface.tests.totalCases} cases`);
|
|
99
114
|
lines.push('');
|
|
100
115
|
|
|
101
|
-
const canonicalDocs =
|
|
116
|
+
const canonicalDocs = evidenceDocs(projectDir, config);
|
|
102
117
|
if (canonicalDocs.length > 0) {
|
|
103
118
|
lines.push('## Canonical docs');
|
|
104
119
|
lines.push('');
|
|
105
120
|
for (const doc of canonicalDocs) {
|
|
106
121
|
let reviewed = '';
|
|
107
122
|
try {
|
|
108
|
-
const m =
|
|
123
|
+
const m = (read(doc).content || '').match(/docguard:last-reviewed\s+(\d{4}-\d{2}-\d{2})/);
|
|
109
124
|
if (m) reviewed = ` (last-reviewed ${m[1]})`;
|
|
110
125
|
} catch { /* ignore */ }
|
|
111
|
-
|
|
126
|
+
const evidence = read(doc).evidence;
|
|
127
|
+
lines.push(`- ${doc}${reviewed} — content: ${evidence.hash ?? `unknown (${evidence.reason})`}`);
|
|
112
128
|
}
|
|
113
129
|
lines.push('');
|
|
114
130
|
}
|
|
@@ -116,7 +132,7 @@ function runMemoryPack(projectDir, config, flags) {
|
|
|
116
132
|
const agentsPath = resolve(projectDir, 'AGENTS.md');
|
|
117
133
|
if (existsSync(agentsPath)) {
|
|
118
134
|
let conventions = [];
|
|
119
|
-
try { conventions = extractConventions(
|
|
135
|
+
try { conventions = extractConventions(read('AGENTS.md').content || ''); } catch { /* ignore */ }
|
|
120
136
|
if (conventions.length > 0) {
|
|
121
137
|
lines.push('## Project rules (from AGENTS.md)');
|
|
122
138
|
lines.push('');
|
|
@@ -128,7 +144,7 @@ function runMemoryPack(projectDir, config, flags) {
|
|
|
128
144
|
const driftPath = resolve(projectDir, 'DRIFT-LOG.md');
|
|
129
145
|
if (existsSync(driftPath)) {
|
|
130
146
|
try {
|
|
131
|
-
const drift =
|
|
147
|
+
const drift = read('DRIFT-LOG.md').content || '';
|
|
132
148
|
const entries = drift.match(/^##\s+.+$/gm) || [];
|
|
133
149
|
if (entries.length > 0) {
|
|
134
150
|
lines.push('## Known drift');
|
|
@@ -148,10 +164,8 @@ function runMemoryPack(projectDir, config, flags) {
|
|
|
148
164
|
console.log(content);
|
|
149
165
|
return;
|
|
150
166
|
}
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
const outPath = resolve(outDir, 'context-pack.md');
|
|
154
|
-
writeFileSync(outPath, content, 'utf-8');
|
|
167
|
+
const outPath = resolve(projectDir, '.docguard/context-pack.md');
|
|
168
|
+
safeWrite(outPath, content);
|
|
155
169
|
console.log(`${c.bold}🧠 DocGuard Context Pack${c.reset}`);
|
|
156
170
|
console.log(`${c.green}✅ Wrote ${outPath}${c.reset} ${c.dim}(${lines.length} lines — load at agent session start)${c.reset}`);
|
|
157
171
|
console.log('');
|
package/cli/commands/report.mjs
CHANGED
|
@@ -74,22 +74,25 @@ export function buildReport(projectDir, config) {
|
|
|
74
74
|
// Audit-critical (H3): evidence must disclose what a committed baseline
|
|
75
75
|
// is suppressing — "no findings" with a hidden baseline is false green.
|
|
76
76
|
baselineSuppressed: guardData.baselineSuppressed || 0,
|
|
77
|
+
checkCoverage: guardData.checkCoverage,
|
|
77
78
|
validators: (guardData.validators || [])
|
|
78
79
|
.filter(v => v.status !== 'skipped')
|
|
79
|
-
.map(v => ({ name: v.name, status: v.status })),
|
|
80
|
+
.map(v => ({ name: v.name, status: v.status, applicability: v.applicability })),
|
|
80
81
|
},
|
|
81
82
|
findings: findingsSummary,
|
|
82
83
|
score: {
|
|
83
84
|
score: scoreData.score,
|
|
84
85
|
grade: scoreData.grade,
|
|
85
86
|
categories: scoreData.categories,
|
|
87
|
+
scoreKind: scoreData.scoreKind,
|
|
88
|
+
assurance: scoreData.assurance,
|
|
86
89
|
},
|
|
87
90
|
alcoa: {
|
|
88
91
|
score: alcoa.score,
|
|
89
92
|
met: alcoa.met,
|
|
90
93
|
total: alcoa.total,
|
|
91
94
|
attributes: alcoa.attributes.map(a => ({
|
|
92
|
-
name: a.name, met: a.met, evidence: a.evidence, gap: a.gap,
|
|
95
|
+
name: a.name, met: a.met, status: a.status || (a.met ? 'met' : 'unmet'), evidence: a.evidence, gap: a.gap,
|
|
93
96
|
})),
|
|
94
97
|
},
|
|
95
98
|
fixHistory: {
|
|
@@ -109,7 +112,7 @@ export function buildReport(projectDir, config) {
|
|
|
109
112
|
return { ...payload, generatedAt: new Date().toISOString(), integrity };
|
|
110
113
|
}
|
|
111
114
|
|
|
112
|
-
function toMarkdown(r) {
|
|
115
|
+
export function toMarkdown(r) {
|
|
113
116
|
const lines = [];
|
|
114
117
|
const gitLine = r.git
|
|
115
118
|
? `commit \`${r.git.commit.slice(0, 12)}\`${r.git.branch ? ` (${r.git.branch})` : ' (detached HEAD)'}${r.git.dirty ? ' — **uncommitted changes present**' : ''}`
|
|
@@ -123,7 +126,8 @@ function toMarkdown(r) {
|
|
|
123
126
|
lines.push('');
|
|
124
127
|
lines.push('| Metric | Value |');
|
|
125
128
|
lines.push('|--------|-------|');
|
|
126
|
-
lines.push(`| CDD Score | ${r.score.score}/100 (${r.score.grade}) |`);
|
|
129
|
+
lines.push(`| CDD Score (structural maturity) | ${r.score.score}/100 (${r.score.grade}) |`);
|
|
130
|
+
lines.push(`| Factual accuracy | Unverified — ${r.score.assurance.unverifiedClaims ?? 'unknown number of'} extracted claim(s); discovery is heuristic |`);
|
|
127
131
|
lines.push(`| Guard | ${r.guard.status.toUpperCase()} — ${r.guard.passed}/${r.guard.total} checks, ${r.guard.errors} error(s), ${r.guard.warnings} warning(s) |`);
|
|
128
132
|
if (r.guard.baselineSuppressed > 0) {
|
|
129
133
|
lines.push(`| Baseline | ⚠️ ${r.guard.baselineSuppressed} pre-existing finding(s) suppressed by \`.docguard.baseline.json\` — not reflected in the counts above |`);
|
|
@@ -147,7 +151,7 @@ function toMarkdown(r) {
|
|
|
147
151
|
if (r.findings.length === 0) {
|
|
148
152
|
lines.push(r.guard.baselineSuppressed > 0
|
|
149
153
|
? `No new findings beyond the ${r.guard.baselineSuppressed} suppressed by the committed baseline (run \`docguard guard --no-baseline\` for the full picture).`
|
|
150
|
-
: 'No findings
|
|
154
|
+
: 'No findings were emitted by the configured checks. Factual accuracy remains unverified.');
|
|
151
155
|
} else {
|
|
152
156
|
lines.push('| Code | Severity | Count | Example |');
|
|
153
157
|
lines.push('|------|----------|------:|---------|');
|
|
@@ -159,10 +163,11 @@ function toMarkdown(r) {
|
|
|
159
163
|
|
|
160
164
|
lines.push('## ALCOA+ Attributes');
|
|
161
165
|
lines.push('');
|
|
162
|
-
lines.push('| Attribute |
|
|
166
|
+
lines.push('| Attribute | Status | Evidence / Gap |');
|
|
163
167
|
lines.push('|-----------|-----|----------------|');
|
|
164
168
|
for (const a of r.alcoa.attributes) {
|
|
165
|
-
|
|
169
|
+
const status = a.status === 'unverified' ? '🔍 unverified' : a.met ? '✅ met' : '❌ unmet';
|
|
170
|
+
lines.push(`| ${a.name} | ${status} | ${(a.met ? a.evidence : a.gap) || '—'} |`);
|
|
166
171
|
}
|
|
167
172
|
lines.push('');
|
|
168
173
|
|