guardlink 1.4.2 → 1.4.3
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/CHANGELOG.md +83 -9
- package/README.md +38 -1
- package/dist/agents/config.d.ts +7 -0
- package/dist/agents/config.d.ts.map +1 -1
- package/dist/agents/config.js.map +1 -1
- package/dist/agents/index.d.ts +1 -1
- package/dist/agents/index.d.ts.map +1 -1
- package/dist/agents/index.js +1 -1
- package/dist/agents/index.js.map +1 -1
- package/dist/agents/prompts.d.ts +14 -0
- package/dist/agents/prompts.d.ts.map +1 -1
- package/dist/agents/prompts.js +445 -2
- package/dist/agents/prompts.js.map +1 -1
- package/dist/analyze/format.d.ts +72 -0
- package/dist/analyze/format.d.ts.map +1 -0
- package/dist/analyze/format.js +176 -0
- package/dist/analyze/format.js.map +1 -0
- package/dist/analyze/index.d.ts +76 -0
- package/dist/analyze/index.d.ts.map +1 -1
- package/dist/analyze/index.js +165 -2
- package/dist/analyze/index.js.map +1 -1
- package/dist/analyze/prompts.d.ts +3 -2
- package/dist/analyze/prompts.d.ts.map +1 -1
- package/dist/analyze/prompts.js +16 -2
- package/dist/analyze/prompts.js.map +1 -1
- package/dist/analyzer/sarif.d.ts +3 -2
- package/dist/analyzer/sarif.d.ts.map +1 -1
- package/dist/analyzer/sarif.js +29 -3
- package/dist/analyzer/sarif.js.map +1 -1
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +380 -28
- package/dist/cli/index.js.map +1 -1
- package/dist/dashboard/data.d.ts +11 -0
- package/dist/dashboard/data.d.ts.map +1 -1
- package/dist/dashboard/data.js +12 -0
- package/dist/dashboard/data.js.map +1 -1
- package/dist/dashboard/diagrams.d.ts +81 -12
- package/dist/dashboard/diagrams.d.ts.map +1 -1
- package/dist/dashboard/diagrams.js +750 -362
- package/dist/dashboard/diagrams.js.map +1 -1
- package/dist/dashboard/generate.d.ts +5 -2
- package/dist/dashboard/generate.d.ts.map +1 -1
- package/dist/dashboard/generate.js +2516 -244
- package/dist/dashboard/generate.js.map +1 -1
- package/dist/diff/engine.d.ts +2 -1
- package/dist/diff/engine.d.ts.map +1 -1
- package/dist/diff/engine.js +3 -2
- package/dist/diff/engine.js.map +1 -1
- package/dist/init/index.d.ts.map +1 -1
- package/dist/init/index.js +24 -5
- package/dist/init/index.js.map +1 -1
- package/dist/init/migrate.d.ts +39 -0
- package/dist/init/migrate.d.ts.map +1 -0
- package/dist/init/migrate.js +45 -0
- package/dist/init/migrate.js.map +1 -0
- package/dist/init/templates.d.ts +8 -0
- package/dist/init/templates.d.ts.map +1 -1
- package/dist/init/templates.js +71 -9
- package/dist/init/templates.js.map +1 -1
- package/dist/mcp/lookup.d.ts +1 -0
- package/dist/mcp/lookup.d.ts.map +1 -1
- package/dist/mcp/lookup.js +138 -10
- package/dist/mcp/lookup.js.map +1 -1
- package/dist/mcp/server.d.ts +2 -1
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +20 -8
- package/dist/mcp/server.js.map +1 -1
- package/dist/parser/clear.js +1 -1
- package/dist/parser/clear.js.map +1 -1
- package/dist/parser/feature-filter.d.ts +42 -0
- package/dist/parser/feature-filter.d.ts.map +1 -0
- package/dist/parser/feature-filter.js +109 -0
- package/dist/parser/feature-filter.js.map +1 -0
- package/dist/parser/format.d.ts +24 -0
- package/dist/parser/format.d.ts.map +1 -0
- package/dist/parser/format.js +29 -0
- package/dist/parser/format.js.map +1 -0
- package/dist/parser/index.d.ts +2 -0
- package/dist/parser/index.d.ts.map +1 -1
- package/dist/parser/index.js +1 -0
- package/dist/parser/index.js.map +1 -1
- package/dist/parser/parse-file.d.ts.map +1 -1
- package/dist/parser/parse-file.js +3 -1
- package/dist/parser/parse-file.js.map +1 -1
- package/dist/parser/parse-line.d.ts +3 -0
- package/dist/parser/parse-line.d.ts.map +1 -1
- package/dist/parser/parse-line.js +78 -22
- package/dist/parser/parse-line.js.map +1 -1
- package/dist/parser/parse-project.js +19 -0
- package/dist/parser/parse-project.js.map +1 -1
- package/dist/parser/validate.d.ts +3 -0
- package/dist/parser/validate.d.ts.map +1 -1
- package/dist/parser/validate.js +7 -0
- package/dist/parser/validate.js.map +1 -1
- package/dist/report/index.d.ts +1 -0
- package/dist/report/index.d.ts.map +1 -1
- package/dist/report/index.js +1 -0
- package/dist/report/index.js.map +1 -1
- package/dist/report/report.d.ts.map +1 -1
- package/dist/report/report.js +924 -24
- package/dist/report/report.js.map +1 -1
- package/dist/report/sequence.d.ts +11 -0
- package/dist/report/sequence.d.ts.map +1 -0
- package/dist/report/sequence.js +140 -0
- package/dist/report/sequence.js.map +1 -0
- package/dist/tui/commands.d.ts +1 -0
- package/dist/tui/commands.d.ts.map +1 -1
- package/dist/tui/commands.js +83 -4
- package/dist/tui/commands.js.map +1 -1
- package/dist/tui/index.d.ts.map +1 -1
- package/dist/tui/index.js +7 -2
- package/dist/tui/index.js.map +1 -1
- package/dist/types/index.d.ts +57 -3
- package/dist/types/index.d.ts.map +1 -1
- package/dist/workspace/merge.d.ts.map +1 -1
- package/dist/workspace/merge.js +6 -2
- package/dist/workspace/merge.js.map +1 -1
- package/package.json +1 -1
package/dist/cli/index.js
CHANGED
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
* guardlink sarif [dir] Export SARIF 2.1.0 for GitHub / VS Code
|
|
13
13
|
* guardlink threat-report <prompt> AI-powered threat analysis (STRIDE, DREAD, PASTA, etc.)
|
|
14
14
|
* guardlink threat-reports List saved AI threat reports
|
|
15
|
+
* guardlink translate [prompt] Generate CERT-X-GEN pentest templates from threats
|
|
16
|
+
* guardlink ask <query> Ask questions about threats and codebase context
|
|
15
17
|
* guardlink annotate <prompt> Launch coding agent to add annotations
|
|
16
18
|
* guardlink config <action> Manage LLM provider configuration
|
|
17
19
|
* guardlink dashboard [dir] Generate interactive HTML dashboard
|
|
@@ -37,15 +39,17 @@
|
|
|
37
39
|
import { Command } from 'commander';
|
|
38
40
|
import { resolve, basename } from 'node:path';
|
|
39
41
|
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
40
|
-
import { parseProject, findDanglingRefs, findUnmitigatedExposures, findAcceptedWithoutAudit, findAcceptedExposures, clearAnnotations } from '../parser/index.js';
|
|
42
|
+
import { parseProject, findDanglingRefs, findUnmitigatedExposures, findAcceptedWithoutAudit, findAcceptedExposures, clearAnnotations, listFeatures, filterByFeature, getFeatureSummaries } from '../parser/index.js';
|
|
43
|
+
import { diagnosticIcon } from '../parser/format.js';
|
|
41
44
|
import { initProject, detectProject, promptAgentSelection, syncAgentFiles } from '../init/index.js';
|
|
45
|
+
import { ensurePromptMd } from '../init/migrate.js';
|
|
42
46
|
import { generateReport, generateMermaid } from '../report/index.js';
|
|
43
47
|
import { diffModels, formatDiff, formatDiffMarkdown, parseAtRef } from '../diff/index.js';
|
|
44
48
|
import { generateSarif } from '../analyzer/index.js';
|
|
45
49
|
import { startStdioServer } from '../mcp/index.js';
|
|
46
|
-
import { generateThreatReport, listThreatReports, loadThreatReportsForDashboard, buildConfig, FRAMEWORK_LABELS, FRAMEWORK_PROMPTS, serializeModel, buildUserMessage } from '../analyze/index.js';
|
|
50
|
+
import { generateThreatReport, listThreatReports, loadThreatReportsForDashboard, loadPentestData, serializePentestFindings, buildConfig, FRAMEWORK_LABELS, FRAMEWORK_PROMPTS, serializeModel, buildUserMessage } from '../analyze/index.js';
|
|
47
51
|
import { generateDashboardHTML } from '../dashboard/index.js';
|
|
48
|
-
import { AGENTS, agentFromOpts, launchAgent, launchAgentInline, buildAnnotatePrompt, resolveAnnotationMode } from '../agents/index.js';
|
|
52
|
+
import { AGENTS, agentFromOpts, launchAgent, launchAgentInline, buildAnnotatePrompt, buildTranslatePrompt, buildAskPrompt, resolveAnnotationMode } from '../agents/index.js';
|
|
49
53
|
import { resolveConfig, saveProjectConfig, saveGlobalConfig, loadProjectConfig, loadGlobalConfig, maskKey, describeConfigSource } from '../agents/config.js';
|
|
50
54
|
import { getReviewableExposures, applyReviewAction, formatExposureForReview, summarizeReview } from '../review/index.js';
|
|
51
55
|
import { populateMetadata, mergeReports, formatMergeSummary, diffMergedReports, formatDiffSummary, linkProject, addToWorkspace, removeFromWorkspace } from '../workspace/index.js';
|
|
@@ -95,7 +99,7 @@ function detectProjectName(root, explicit) {
|
|
|
95
99
|
program
|
|
96
100
|
.name('guardlink')
|
|
97
101
|
.description('GuardLink — Security annotations for code. Threat modeling that lives in your codebase.')
|
|
98
|
-
.version('1.4.
|
|
102
|
+
.version('1.4.3')
|
|
99
103
|
.addHelpText('before', gradient(['#00ff41', '#00d4ff'])(ASCII_LOGO));
|
|
100
104
|
// ─── init ────────────────────────────────────────────────────────────
|
|
101
105
|
program
|
|
@@ -196,9 +200,16 @@ program
|
|
|
196
200
|
.argument('[dir]', 'Project directory to scan', '.')
|
|
197
201
|
.option('-p, --project <n>', 'Project name', 'unknown')
|
|
198
202
|
.option('--not-annotated', 'List source files with no GuardLink annotations')
|
|
203
|
+
.option('--feature <names>', 'Filter status to specific feature(s) (comma-separated)')
|
|
199
204
|
.action(async (dir, opts) => {
|
|
200
205
|
const root = resolve(dir);
|
|
201
|
-
|
|
206
|
+
let { model, diagnostics } = await parseProject({ root, project: opts.project });
|
|
207
|
+
// Apply feature filter if specified
|
|
208
|
+
if (opts.feature) {
|
|
209
|
+
const featureNames = opts.feature.split(',').map(s => s.trim());
|
|
210
|
+
model = filterByFeature(model, featureNames);
|
|
211
|
+
console.log(`Filtered to feature(s): ${featureNames.map(f => `"${f}"`).join(', ')}\n`);
|
|
212
|
+
}
|
|
202
213
|
printDiagnostics(diagnostics);
|
|
203
214
|
printStatus(model);
|
|
204
215
|
if (opts.notAnnotated) {
|
|
@@ -244,6 +255,12 @@ program
|
|
|
244
255
|
console.error(` ${a.asset} → ${a.threat} [${a.severity || 'unset'}] (${a.location.file}:${a.location.line})`);
|
|
245
256
|
}
|
|
246
257
|
}
|
|
258
|
+
if ((model.confirmed || []).length > 0) {
|
|
259
|
+
console.error(`\n🔴 ${model.confirmed.length} confirmed exploitable finding(s) — verified, not false positives:`);
|
|
260
|
+
for (const c of model.confirmed) {
|
|
261
|
+
console.error(` ${c.asset} ← ${c.threat} [${c.severity || 'unset'}] (${c.location.file}:${c.location.line})`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
247
264
|
const errorCount = allDiags.filter(d => d.level === 'error').length;
|
|
248
265
|
const hasUnmitigated = unmitigated.length > 0;
|
|
249
266
|
if (errorCount === 0 && !hasUnmitigated && acceptedOnly.length === 0) {
|
|
@@ -275,17 +292,43 @@ program
|
|
|
275
292
|
.option('-f, --format <fmt>', 'Output format: md, json, or both (default: md)', 'md')
|
|
276
293
|
.option('--diagram-only', 'Output only the Mermaid diagram, no report wrapper')
|
|
277
294
|
.option('--json', 'Also output threat-model.json alongside the report (legacy; prefer --format)')
|
|
295
|
+
.option('--feature <names>', 'Filter report to specific feature(s) (comma-separated)')
|
|
278
296
|
.action(async (dir, opts) => {
|
|
279
297
|
const root = resolve(dir);
|
|
280
|
-
|
|
281
|
-
//
|
|
298
|
+
let { model, diagnostics } = await parseProject({ root, project: opts.project });
|
|
299
|
+
// Apply feature filter if specified
|
|
300
|
+
if (opts.feature) {
|
|
301
|
+
const featureNames = opts.feature.split(',').map(s => s.trim());
|
|
302
|
+
model = filterByFeature(model, featureNames);
|
|
303
|
+
console.error(`Filtered to feature(s): ${featureNames.map(f => `"${f}"`).join(', ')}`);
|
|
304
|
+
}
|
|
305
|
+
// Show errors if any. Per-annotation errors don't block the report —
|
|
306
|
+
// affected annotations are skipped, the rest of the model still renders.
|
|
282
307
|
const errors = diagnostics.filter(d => d.level === 'error');
|
|
283
|
-
if (errors.length > 0)
|
|
308
|
+
if (errors.length > 0)
|
|
284
309
|
printDiagnostics(errors);
|
|
285
|
-
console.error(`Fix errors above before generating report.\n`);
|
|
286
|
-
}
|
|
287
310
|
// Enrich with provenance metadata (git SHA, branch, workspace, schema version)
|
|
288
311
|
const enrichedModel = populateMetadata(model, root);
|
|
312
|
+
// Auto-create .guardlink/prompt.md if a v1.4.x project doesn't have it
|
|
313
|
+
// (`init` short-circuits when .guardlink/ exists, so upgrades skip the
|
|
314
|
+
// template). One-line hint on first creation so the user knows it's a
|
|
315
|
+
// feature; silent thereafter.
|
|
316
|
+
const migrationResult = ensurePromptMd(root);
|
|
317
|
+
if (migrationResult === 'created') {
|
|
318
|
+
console.error('• Created .guardlink/prompt.md — fill it in to customize the report\'s Application Overview.');
|
|
319
|
+
}
|
|
320
|
+
// Load project description from .guardlink/prompt.md if it exists
|
|
321
|
+
try {
|
|
322
|
+
const { readFile } = await import('node:fs/promises');
|
|
323
|
+
const promptPath = resolve(root, '.guardlink', 'prompt.md');
|
|
324
|
+
const promptContent = await readFile(promptPath, 'utf-8');
|
|
325
|
+
if (promptContent.trim()) {
|
|
326
|
+
enrichedModel.prompt = promptContent.trim();
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
catch {
|
|
330
|
+
// No prompt file — that's fine, report will use annotation-derived overview
|
|
331
|
+
}
|
|
289
332
|
if (opts.diagramOnly) {
|
|
290
333
|
// Just output Mermaid
|
|
291
334
|
const mermaid = generateMermaid(enrichedModel);
|
|
@@ -436,8 +479,11 @@ program
|
|
|
436
479
|
const { buildProjectContext, extractCodeSnippets } = await import('../analyze/index.js');
|
|
437
480
|
const projectContext = buildProjectContext(root);
|
|
438
481
|
const codeSnippets = extractCodeSnippets(root, model);
|
|
482
|
+
const pentestData = loadPentestData(root);
|
|
483
|
+
const pentestContext = serializePentestFindings(pentestData);
|
|
439
484
|
const systemPrompt = FRAMEWORK_PROMPTS[fw];
|
|
440
|
-
const userMessage = buildUserMessage(serialized, fw, customPrompt, projectContext || undefined, codeSnippets || undefined);
|
|
485
|
+
const userMessage = buildUserMessage(serialized, fw, customPrompt, projectContext || undefined, codeSnippets || undefined, pentestContext || undefined);
|
|
486
|
+
const hasPentest = pentestData.totalFindings > 0;
|
|
441
487
|
const analysisPrompt = `You are analyzing a codebase with GuardLink security annotations.
|
|
442
488
|
You have access to the full source code in the current directory.
|
|
443
489
|
|
|
@@ -455,7 +501,8 @@ ${userMessage}
|
|
|
455
501
|
3. Produce the full report as markdown
|
|
456
502
|
4. Be specific — reference actual files, functions, and line numbers from the codebase
|
|
457
503
|
5. Output ONLY the markdown report content — do NOT add any metadata comments, save confirmations, or file path messages
|
|
458
|
-
6. Do NOT include lines like "Generated by...", "Agent:", "Project:", or "The report file write was blocked..."
|
|
504
|
+
6. Do NOT include lines like "Generated by...", "Agent:", "Project:", or "The report file write was blocked..."${hasPentest ? `
|
|
505
|
+
7. The <pentest_findings> section contains CONFIRMED vulnerabilities from automated CXG security scans with real evidence. Cross-reference these against the threat model — mark confirmed findings as exploitable, identify which @exposes are now validated, and include a dedicated "Pentest Results" section summarizing all confirmed findings with their evidence and remediation guidance` : ''}`;
|
|
459
506
|
// Resolve agent: explicit flag > project config CLI agent
|
|
460
507
|
let agent = agentFromOpts(opts);
|
|
461
508
|
if (!agent) {
|
|
@@ -663,6 +710,164 @@ program
|
|
|
663
710
|
console.log('When done, run: guardlink parse');
|
|
664
711
|
}
|
|
665
712
|
});
|
|
713
|
+
// ─── translate ───────────────────────────────────────────────────────
|
|
714
|
+
program
|
|
715
|
+
.command('translate')
|
|
716
|
+
.description('Translate GuardLink threats into CERT-X-GEN pentest templates (generation only, no execution)')
|
|
717
|
+
.argument('[prompt...]', 'Optional translation instructions')
|
|
718
|
+
.option('-d, --dir <dir>', 'Project directory', '.')
|
|
719
|
+
.option('-p, --project <n>', 'Project name', 'unknown')
|
|
720
|
+
.option('--claude-code', 'Launch Claude Code in foreground')
|
|
721
|
+
.option('--codex', 'Launch Codex CLI in foreground')
|
|
722
|
+
.option('--gemini', 'Launch Gemini CLI in foreground')
|
|
723
|
+
.option('--cursor', 'Open Cursor IDE with prompt on clipboard')
|
|
724
|
+
.option('--windsurf', 'Open Windsurf IDE with prompt on clipboard')
|
|
725
|
+
.option('--clipboard', 'Copy translation prompt to clipboard only')
|
|
726
|
+
.option('--feature <names>', 'Filter to specific feature(s) (comma-separated)')
|
|
727
|
+
.action(async (promptParts, opts) => {
|
|
728
|
+
const root = resolve(opts.dir);
|
|
729
|
+
const project = detectProjectName(root, opts.project);
|
|
730
|
+
const userPrompt = promptParts.join(' ').trim();
|
|
731
|
+
let { model, diagnostics } = await parseProject({ root, project });
|
|
732
|
+
// Apply feature filter if specified
|
|
733
|
+
if (opts.feature) {
|
|
734
|
+
const featureNames = opts.feature.split(',').map(s => s.trim());
|
|
735
|
+
model = filterByFeature(model, featureNames);
|
|
736
|
+
console.error(`Filtered to feature(s): ${featureNames.map(f => `"${f}"`).join(', ')}`);
|
|
737
|
+
}
|
|
738
|
+
const errors = diagnostics.filter(d => d.level === 'error');
|
|
739
|
+
if (errors.length > 0)
|
|
740
|
+
printDiagnostics(errors);
|
|
741
|
+
if (model.annotations_parsed === 0) {
|
|
742
|
+
console.error('No annotations found. Run: guardlink init . && add annotations first.');
|
|
743
|
+
process.exit(1);
|
|
744
|
+
}
|
|
745
|
+
// Build translate prompt
|
|
746
|
+
const fullPrompt = buildTranslatePrompt(userPrompt, root, model);
|
|
747
|
+
// Resolve agent: explicit flag > project config > default (Claude Code)
|
|
748
|
+
let agent = agentFromOpts(opts);
|
|
749
|
+
if (!agent) {
|
|
750
|
+
const projCfg = loadProjectConfig(root);
|
|
751
|
+
if (projCfg?.aiMode === 'cli-agent' && projCfg?.cliAgent) {
|
|
752
|
+
agent = AGENTS.find(a => a.id === projCfg.cliAgent) || null;
|
|
753
|
+
}
|
|
754
|
+
if (!agent) {
|
|
755
|
+
agent = AGENTS.find(a => a.id === 'claude-code') || null;
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
if (!agent) {
|
|
759
|
+
console.error('No agent available. Use one of:');
|
|
760
|
+
for (const a of AGENTS) {
|
|
761
|
+
console.error(` ${a.flag.padEnd(16)} ${a.name}`);
|
|
762
|
+
}
|
|
763
|
+
process.exit(1);
|
|
764
|
+
}
|
|
765
|
+
console.log(`Launching ${agent.name} for CXG template translation...`);
|
|
766
|
+
console.log(`Threat model: ${model.annotations_parsed} annotations, ${model.exposures.length} exposures`);
|
|
767
|
+
if (agent.cmd) {
|
|
768
|
+
console.log(`${agent.name} will take over this terminal. Exit the agent to return.\n`);
|
|
769
|
+
}
|
|
770
|
+
const result = launchAgent(agent, fullPrompt, root);
|
|
771
|
+
if (result.clipboardCopied) {
|
|
772
|
+
console.log(`✓ Prompt copied to clipboard (${fullPrompt.length.toLocaleString()} chars)`);
|
|
773
|
+
}
|
|
774
|
+
if (result.error) {
|
|
775
|
+
console.error(`✗ ${result.error}`);
|
|
776
|
+
if (result.clipboardCopied) {
|
|
777
|
+
console.log('Prompt is on your clipboard — paste it manually.');
|
|
778
|
+
}
|
|
779
|
+
process.exit(1);
|
|
780
|
+
}
|
|
781
|
+
if (agent.cmd && result.launched) {
|
|
782
|
+
console.log(`\n✓ ${agent.name} session ended.`);
|
|
783
|
+
console.log(' Expected output location: .guardlink/cxg-templates/');
|
|
784
|
+
console.log(' Note: Templates are generated only, not executed.');
|
|
785
|
+
}
|
|
786
|
+
else if (agent.app && result.launched) {
|
|
787
|
+
console.log(`✓ ${agent.name} launched with project: ${project}`);
|
|
788
|
+
console.log('\nPaste (Cmd+V) the prompt in the AI chat panel.');
|
|
789
|
+
console.log('When done, review .guardlink/cxg-templates/');
|
|
790
|
+
}
|
|
791
|
+
else if (agent.id === 'clipboard') {
|
|
792
|
+
console.log('\nPaste the prompt into your preferred AI tool.');
|
|
793
|
+
console.log('When done, review .guardlink/cxg-templates/');
|
|
794
|
+
}
|
|
795
|
+
});
|
|
796
|
+
// ─── ask ─────────────────────────────────────────────────────────────
|
|
797
|
+
program
|
|
798
|
+
.command('ask')
|
|
799
|
+
.description('Ask questions about this project, its threat model, and security posture')
|
|
800
|
+
.argument('[query...]', 'Question to answer')
|
|
801
|
+
.option('-d, --dir <dir>', 'Project directory', '.')
|
|
802
|
+
.option('-p, --project <n>', 'Project name', 'unknown')
|
|
803
|
+
.option('--claude-code', 'Launch Claude Code in foreground')
|
|
804
|
+
.option('--codex', 'Launch Codex CLI in foreground')
|
|
805
|
+
.option('--gemini', 'Launch Gemini CLI in foreground')
|
|
806
|
+
.option('--cursor', 'Open Cursor IDE with prompt on clipboard')
|
|
807
|
+
.option('--windsurf', 'Open Windsurf IDE with prompt on clipboard')
|
|
808
|
+
.option('--clipboard', 'Copy ask prompt to clipboard only')
|
|
809
|
+
.action(async (queryParts, opts) => {
|
|
810
|
+
const root = resolve(opts.dir);
|
|
811
|
+
const project = detectProjectName(root, opts.project);
|
|
812
|
+
const query = queryParts.join(' ').trim();
|
|
813
|
+
if (!query) {
|
|
814
|
+
console.error('Usage: guardlink ask "<question>" [--claude-code|--codex|--gemini|--cursor|--windsurf|--clipboard]');
|
|
815
|
+
process.exit(1);
|
|
816
|
+
}
|
|
817
|
+
// Parse model if available; allow questions even for lightly-annotated projects
|
|
818
|
+
let model = null;
|
|
819
|
+
try {
|
|
820
|
+
const parsed = await parseProject({ root, project });
|
|
821
|
+
model = parsed.model;
|
|
822
|
+
}
|
|
823
|
+
catch {
|
|
824
|
+
model = null;
|
|
825
|
+
}
|
|
826
|
+
const fullPrompt = buildAskPrompt(query, root, model);
|
|
827
|
+
// Resolve agent: explicit flag > project config > default (Claude Code)
|
|
828
|
+
let agent = agentFromOpts(opts);
|
|
829
|
+
if (!agent) {
|
|
830
|
+
const projCfg = loadProjectConfig(root);
|
|
831
|
+
if (projCfg?.aiMode === 'cli-agent' && projCfg?.cliAgent) {
|
|
832
|
+
agent = AGENTS.find(a => a.id === projCfg.cliAgent) || null;
|
|
833
|
+
}
|
|
834
|
+
if (!agent) {
|
|
835
|
+
agent = AGENTS.find(a => a.id === 'claude-code') || null;
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
if (!agent) {
|
|
839
|
+
console.error('No agent available. Use one of:');
|
|
840
|
+
for (const a of AGENTS) {
|
|
841
|
+
console.error(` ${a.flag.padEnd(16)} ${a.name}`);
|
|
842
|
+
}
|
|
843
|
+
process.exit(1);
|
|
844
|
+
}
|
|
845
|
+
console.log(`Launching ${agent.name} for question answering...`);
|
|
846
|
+
if (agent.cmd) {
|
|
847
|
+
console.log(`${agent.name} will take over this terminal. Exit the agent to return.\n`);
|
|
848
|
+
}
|
|
849
|
+
const result = launchAgent(agent, fullPrompt, root);
|
|
850
|
+
if (result.clipboardCopied) {
|
|
851
|
+
console.log(`✓ Prompt copied to clipboard (${fullPrompt.length.toLocaleString()} chars)`);
|
|
852
|
+
}
|
|
853
|
+
if (result.error) {
|
|
854
|
+
console.error(`✗ ${result.error}`);
|
|
855
|
+
if (result.clipboardCopied) {
|
|
856
|
+
console.log('Prompt is on your clipboard — paste it manually.');
|
|
857
|
+
}
|
|
858
|
+
process.exit(1);
|
|
859
|
+
}
|
|
860
|
+
if (agent.cmd && result.launched) {
|
|
861
|
+
console.log(`\n✓ ${agent.name} session ended.`);
|
|
862
|
+
}
|
|
863
|
+
else if (agent.app && result.launched) {
|
|
864
|
+
console.log(`✓ ${agent.name} launched with project: ${project}`);
|
|
865
|
+
console.log('\nPaste (Cmd+V) the prompt in the AI chat panel.');
|
|
866
|
+
}
|
|
867
|
+
else if (agent.id === 'clipboard') {
|
|
868
|
+
console.log('\nPaste the prompt into your preferred AI tool.');
|
|
869
|
+
}
|
|
870
|
+
});
|
|
666
871
|
// ─── clear ───────────────────────────────────────────────────────────
|
|
667
872
|
program
|
|
668
873
|
.command('clear')
|
|
@@ -859,7 +1064,7 @@ program
|
|
|
859
1064
|
.command('config')
|
|
860
1065
|
.description('Manage LLM provider configuration')
|
|
861
1066
|
.argument('<action>', 'Action: set, show, clear')
|
|
862
|
-
.argument('[key]', 'Config key: provider, api-key, model, ai-mode, cli-agent')
|
|
1067
|
+
.argument('[key]', 'Config key: provider, api-key, model, ai-mode, cli-agent, redact-evidence')
|
|
863
1068
|
.argument('[value]', 'Value to set')
|
|
864
1069
|
.option('--global', 'Use global config (~/.config/guardlink/) instead of project')
|
|
865
1070
|
.action(async (action, key, value, opts) => {
|
|
@@ -872,12 +1077,13 @@ program
|
|
|
872
1077
|
const projCfg = isGlobal ? loadGlobalConfig() : loadProjectConfig(root);
|
|
873
1078
|
const aiMode = projCfg?.aiMode || 'api';
|
|
874
1079
|
const cliAgent = projCfg?.cliAgent;
|
|
875
|
-
|
|
1080
|
+
const redactEvidence = projCfg?.redactEvidence === true;
|
|
1081
|
+
console.log(`AI Mode: ${aiMode}${cliAgent ? ` (${cliAgent})` : ''}`);
|
|
876
1082
|
if (config) {
|
|
877
|
-
console.log(`Provider:
|
|
878
|
-
console.log(`Model:
|
|
879
|
-
console.log(`API Key:
|
|
880
|
-
console.log(`Source:
|
|
1083
|
+
console.log(`Provider: ${config.provider}`);
|
|
1084
|
+
console.log(`Model: ${config.model}`);
|
|
1085
|
+
console.log(`API Key: ${maskKey(config.apiKey)}`);
|
|
1086
|
+
console.log(`Source: ${source}`);
|
|
881
1087
|
}
|
|
882
1088
|
else if (aiMode !== 'cli-agent') {
|
|
883
1089
|
console.log('No LLM configuration found.');
|
|
@@ -891,6 +1097,7 @@ program
|
|
|
891
1097
|
console.log(' export GUARDLINK_LLM_KEY=sk-ant-...');
|
|
892
1098
|
console.log(' export GUARDLINK_LLM_PROVIDER=anthropic');
|
|
893
1099
|
}
|
|
1100
|
+
console.log(`Redact evidence: ${redactEvidence ? 'on (pentest evidence is surgically redacted at load)' : 'off (full evidence; see docs/handling-evidence.md)'}`);
|
|
894
1101
|
break;
|
|
895
1102
|
}
|
|
896
1103
|
case 'set': {
|
|
@@ -936,8 +1143,26 @@ program
|
|
|
936
1143
|
existing.cliAgent = value;
|
|
937
1144
|
existing.aiMode = 'cli-agent';
|
|
938
1145
|
break;
|
|
1146
|
+
case 'redact-evidence': {
|
|
1147
|
+
// Accept truthy/falsy spellings — `true`, `false`, `on`, `off`,
|
|
1148
|
+
// `1`, `0`. Reject anything else so a typo doesn't silently
|
|
1149
|
+
// enable redaction the user didn't want.
|
|
1150
|
+
const v = value.toLowerCase();
|
|
1151
|
+
if (['true', 'on', '1', 'yes'].includes(v)) {
|
|
1152
|
+
existing.redactEvidence = true;
|
|
1153
|
+
}
|
|
1154
|
+
else if (['false', 'off', '0', 'no'].includes(v)) {
|
|
1155
|
+
existing.redactEvidence = false;
|
|
1156
|
+
}
|
|
1157
|
+
else {
|
|
1158
|
+
console.error(`Invalid value for redact-evidence: ${value}`);
|
|
1159
|
+
console.error('Use: true | false (also accepted: on/off, 1/0, yes/no)');
|
|
1160
|
+
process.exit(1);
|
|
1161
|
+
}
|
|
1162
|
+
break;
|
|
1163
|
+
}
|
|
939
1164
|
default:
|
|
940
|
-
console.error(`Unknown config key: ${key}. Use: provider, api-key, model, ai-mode, cli-agent`);
|
|
1165
|
+
console.error(`Unknown config key: ${key}. Use: provider, api-key, model, ai-mode, cli-agent, redact-evidence`);
|
|
941
1166
|
process.exit(1);
|
|
942
1167
|
}
|
|
943
1168
|
if (isGlobal) {
|
|
@@ -975,19 +1200,27 @@ program
|
|
|
975
1200
|
.option('-p, --project <n>', 'Project name', 'unknown')
|
|
976
1201
|
.option('-o, --output <file>', 'Output file (default: threat-dashboard.html)')
|
|
977
1202
|
.option('--light', 'Default to light theme instead of dark')
|
|
1203
|
+
.option('--feature <names>', 'Filter dashboard to specific feature(s) (comma-separated)')
|
|
978
1204
|
.action(async (dir, opts) => {
|
|
979
1205
|
const root = resolve(dir);
|
|
980
1206
|
const project = detectProjectName(root, opts.project);
|
|
981
|
-
|
|
1207
|
+
let { model, diagnostics } = await parseProject({ root, project });
|
|
1208
|
+
// Apply feature filter if specified
|
|
1209
|
+
if (opts.feature) {
|
|
1210
|
+
const featureNames = opts.feature.split(',').map(s => s.trim());
|
|
1211
|
+
model = filterByFeature(model, featureNames);
|
|
1212
|
+
console.error(`Filtered to feature(s): ${featureNames.map(f => `"${f}"`).join(', ')}`);
|
|
1213
|
+
}
|
|
982
1214
|
const errors = diagnostics.filter(d => d.level === 'error');
|
|
983
1215
|
if (errors.length > 0)
|
|
984
1216
|
printDiagnostics(errors);
|
|
985
|
-
if (model.annotations_parsed === 0) {
|
|
1217
|
+
if (model.annotations_parsed === 0 && !opts.feature) {
|
|
986
1218
|
console.error('No annotations found. Add GuardLink annotations first.');
|
|
987
1219
|
process.exit(1);
|
|
988
1220
|
}
|
|
989
1221
|
const analyses = loadThreatReportsForDashboard(root);
|
|
990
|
-
|
|
1222
|
+
const pentestData = loadPentestData(root);
|
|
1223
|
+
let html = generateDashboardHTML(model, root, analyses, pentestData);
|
|
991
1224
|
// Switch default theme if requested
|
|
992
1225
|
if (opts.light) {
|
|
993
1226
|
html = html.replace('data-theme="dark"', 'data-theme="light"');
|
|
@@ -1216,6 +1449,94 @@ program
|
|
|
1216
1449
|
// Print full markdown summary to stdout (pipeable)
|
|
1217
1450
|
console.log(formatMergeSummary(merged));
|
|
1218
1451
|
});
|
|
1452
|
+
// ─── feature ──────────────────────────────────────────────────────────
|
|
1453
|
+
const featureCmd = program
|
|
1454
|
+
.command('feature')
|
|
1455
|
+
.description('Manage and inspect feature tags across the threat model');
|
|
1456
|
+
featureCmd
|
|
1457
|
+
.command('list')
|
|
1458
|
+
.description('List all features found in annotations')
|
|
1459
|
+
.argument('[dir]', 'Project directory to scan', '.')
|
|
1460
|
+
.option('-p, --project <n>', 'Project name', 'unknown')
|
|
1461
|
+
.option('--json', 'Output as JSON')
|
|
1462
|
+
.action(async (dir, opts) => {
|
|
1463
|
+
const root = resolve(dir);
|
|
1464
|
+
const project = detectProjectName(root, opts.project);
|
|
1465
|
+
const { model } = await parseProject({ root, project });
|
|
1466
|
+
const features = listFeatures(model);
|
|
1467
|
+
if (features.length === 0) {
|
|
1468
|
+
console.log('No @feature annotations found.');
|
|
1469
|
+
console.log('Tag code with: // @feature "Feature Name" -- "description"');
|
|
1470
|
+
return;
|
|
1471
|
+
}
|
|
1472
|
+
if (opts.json) {
|
|
1473
|
+
const summaries = getFeatureSummaries(model);
|
|
1474
|
+
console.log(JSON.stringify(summaries, null, 2));
|
|
1475
|
+
return;
|
|
1476
|
+
}
|
|
1477
|
+
const summaries = getFeatureSummaries(model);
|
|
1478
|
+
console.log(`Features in ${project}:\n`);
|
|
1479
|
+
for (const s of summaries) {
|
|
1480
|
+
const files = s.files.length === 1 ? '1 file' : `${s.files.length} files`;
|
|
1481
|
+
const exposureInfo = s.exposures > 0 ? ` | ${s.exposures} exposure(s)` : '';
|
|
1482
|
+
const confirmedInfo = s.confirmed > 0 ? ` | ${s.confirmed} confirmed` : '';
|
|
1483
|
+
console.log(` "${s.name}" (${files}, ${s.annotations} annotations${exposureInfo}${confirmedInfo})`);
|
|
1484
|
+
for (const f of s.files) {
|
|
1485
|
+
console.log(` → ${f}`);
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
console.log(`\n${features.length} feature(s) total`);
|
|
1489
|
+
});
|
|
1490
|
+
featureCmd
|
|
1491
|
+
.command('show')
|
|
1492
|
+
.description('Show detailed threat model for a specific feature')
|
|
1493
|
+
.argument('<name>', 'Feature name (case-insensitive)')
|
|
1494
|
+
.option('-d, --dir <dir>', 'Project directory', '.')
|
|
1495
|
+
.option('-p, --project <n>', 'Project name', 'unknown')
|
|
1496
|
+
.option('--json', 'Output as JSON')
|
|
1497
|
+
.action(async (name, opts) => {
|
|
1498
|
+
const root = resolve(opts.dir);
|
|
1499
|
+
const project = detectProjectName(root, opts.project);
|
|
1500
|
+
const { model } = await parseProject({ root, project });
|
|
1501
|
+
const filtered = filterByFeature(model, [name]);
|
|
1502
|
+
const totalAnnotations = filtered.assets.length + filtered.threats.length +
|
|
1503
|
+
filtered.controls.length + filtered.mitigations.length + filtered.exposures.length +
|
|
1504
|
+
filtered.confirmed.length + filtered.flows.length + filtered.boundaries.length;
|
|
1505
|
+
if (totalAnnotations === 0) {
|
|
1506
|
+
console.error(`No annotations found for feature "${name}".`);
|
|
1507
|
+
const available = listFeatures(model);
|
|
1508
|
+
if (available.length > 0) {
|
|
1509
|
+
console.error(`Available features: ${available.map(f => `"${f}"`).join(', ')}`);
|
|
1510
|
+
}
|
|
1511
|
+
process.exit(1);
|
|
1512
|
+
}
|
|
1513
|
+
if (opts.json) {
|
|
1514
|
+
console.log(JSON.stringify(filtered, null, 2));
|
|
1515
|
+
return;
|
|
1516
|
+
}
|
|
1517
|
+
console.log(`Feature: "${name}"\n`);
|
|
1518
|
+
console.log(` Assets: ${filtered.assets.length}`);
|
|
1519
|
+
console.log(` Threats: ${filtered.threats.length}`);
|
|
1520
|
+
console.log(` Controls: ${filtered.controls.length}`);
|
|
1521
|
+
console.log(` Mitigations: ${filtered.mitigations.length}`);
|
|
1522
|
+
console.log(` Exposures: ${filtered.exposures.length}`);
|
|
1523
|
+
if (filtered.confirmed.length > 0)
|
|
1524
|
+
console.log(` Confirmed: ${filtered.confirmed.length} 🔴`);
|
|
1525
|
+
console.log(` Flows: ${filtered.flows.length}`);
|
|
1526
|
+
console.log(` Boundaries: ${filtered.boundaries.length}`);
|
|
1527
|
+
if (filtered.exposures.length > 0) {
|
|
1528
|
+
console.log(`\n Exposures:`);
|
|
1529
|
+
for (const e of filtered.exposures) {
|
|
1530
|
+
console.log(` ${e.asset} → ${e.threat} [${e.severity || 'unset'}] (${e.location.file}:${e.location.line})`);
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
if (filtered.mitigations.length > 0) {
|
|
1534
|
+
console.log(`\n Mitigations:`);
|
|
1535
|
+
for (const m of filtered.mitigations) {
|
|
1536
|
+
console.log(` ${m.asset} against ${m.threat}${m.control ? ` using ${m.control}` : ''} (${m.location.file}:${m.location.line})`);
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
});
|
|
1219
1540
|
// ─── mcp ─────────────────────────────────────────────────────────────
|
|
1220
1541
|
program
|
|
1221
1542
|
.command('mcp')
|
|
@@ -1297,6 +1618,12 @@ program
|
|
|
1297
1618
|
console.log(EX(' // @mitigates api.auth against SQL Injection using Input Validation'));
|
|
1298
1619
|
console.log(EX(' // @mitigates db.users against Token Theft -- "Rotation implemented in v2"'));
|
|
1299
1620
|
console.log('');
|
|
1621
|
+
console.log(` ${V('@confirmed')} ${K('<threat>')} ${D('on')} ${K('<asset>')} ${D('[severity]')} ${D('[ext-refs]')} ${D('[-- "evidence"]')}`);
|
|
1622
|
+
console.log(D(' Mark a threat as verified exploitable (pentest, scan, or manual repro).'));
|
|
1623
|
+
console.log(D(' Not a false positive — use observed severity. Distinct from @exposes (hypothesis).'));
|
|
1624
|
+
console.log(EX(' // @confirmed SQL Injection on api.auth [critical] cwe:CWE-89 -- "Pen test: blind SQLi on /login"'));
|
|
1625
|
+
console.log(EX(' // @confirmed #secret-exposure on App.Config [critical] -- "Live key in repo; verified with provider"'));
|
|
1626
|
+
console.log('');
|
|
1300
1627
|
console.log(` ${V('@accepts')} ${K('<threat>')} ${D('on')} ${K('<asset>')} ${D('[-- "reason"]')}`);
|
|
1301
1628
|
console.log(D(' Explicitly accept a risk. Removes it from open findings.'));
|
|
1302
1629
|
console.log(D(' Use when the risk is known and intentionally not mitigated.'));
|
|
@@ -1347,6 +1674,21 @@ program
|
|
|
1347
1674
|
console.log(D(' Document a security assumption about an asset.'));
|
|
1348
1675
|
console.log(EX(' // @assumes api.gateway -- "Upstream WAF filters malformed requests"'));
|
|
1349
1676
|
console.log('');
|
|
1677
|
+
// ── FEATURE TAGGING ──
|
|
1678
|
+
console.log(H(' ── Feature Tagging ─────────────────────────────────────────'));
|
|
1679
|
+
console.log('');
|
|
1680
|
+
console.log(` ${V('@feature')} ${K('"Feature Name"')} ${D('[-- "description"]')}`);
|
|
1681
|
+
console.log(D(' Tag code with a feature name for filtering reports and dashboards.'));
|
|
1682
|
+
console.log(D(' A file can have multiple @feature tags. All annotations in that file'));
|
|
1683
|
+
console.log(D(' are associated with the tagged features.'));
|
|
1684
|
+
console.log(EX(' // @feature "SSO Login" -- "Single sign-on authentication flow"'));
|
|
1685
|
+
console.log(EX(' // @feature "Payment Processing"'));
|
|
1686
|
+
console.log(D(''));
|
|
1687
|
+
console.log(D(' Filter commands by feature:'));
|
|
1688
|
+
console.log(EX(' guardlink feature list'));
|
|
1689
|
+
console.log(EX(' guardlink report . --feature "SSO Login"'));
|
|
1690
|
+
console.log(EX(' guardlink dashboard . --feature "SSO Login,Payment Processing"'));
|
|
1691
|
+
console.log('');
|
|
1350
1692
|
console.log(` ${V('@comment')} ${D('[-- "description"]')}`);
|
|
1351
1693
|
console.log(D(' Free-form developer security note (no structural effect).'));
|
|
1352
1694
|
console.log(EX(' // @comment -- "TODO — add rate limiting before v2 launch"'));
|
|
@@ -1368,7 +1710,7 @@ program
|
|
|
1368
1710
|
// ── EXTERNAL REFERENCES ──
|
|
1369
1711
|
console.log(H(' ── External References ─────────────────────────────────────'));
|
|
1370
1712
|
console.log('');
|
|
1371
|
-
console.log(D(' Append space-separated refs after severity on @threat and @
|
|
1713
|
+
console.log(D(' Append space-separated refs after severity on @threat, @exposes, and @confirmed:'));
|
|
1372
1714
|
console.log(EX(' cwe:CWE-89 owasp:A03:2021 capec:CAPEC-66 attack:T1190'));
|
|
1373
1715
|
console.log('');
|
|
1374
1716
|
console.log(D(' Example:'));
|
|
@@ -1402,23 +1744,27 @@ else {
|
|
|
1402
1744
|
// ─── Helpers ─────────────────────────────────────────────────────────
|
|
1403
1745
|
function printDiagnostics(diagnostics) {
|
|
1404
1746
|
for (const d of diagnostics) {
|
|
1405
|
-
|
|
1406
|
-
console.error(`${prefix} ${d.file}:${d.line}: ${d.message}`);
|
|
1747
|
+
console.error(`${diagnosticIcon(d.level)} ${d.file}:${d.line}: ${d.message}`);
|
|
1407
1748
|
if (d.raw)
|
|
1408
1749
|
console.error(` → ${d.raw}`);
|
|
1409
1750
|
}
|
|
1410
1751
|
if (diagnostics.length > 0) {
|
|
1752
|
+
const fatals = diagnostics.filter(d => d.level === 'fatal').length;
|
|
1411
1753
|
const errors = diagnostics.filter(d => d.level === 'error').length;
|
|
1412
1754
|
const warnings = diagnostics.filter(d => d.level === 'warning').length;
|
|
1413
|
-
|
|
1755
|
+
const parts = [];
|
|
1756
|
+
if (fatals > 0)
|
|
1757
|
+
parts.push(`${fatals} fatal(s)`);
|
|
1758
|
+
parts.push(`${errors} error(s)`, `${warnings} warning(s)`);
|
|
1759
|
+
console.error(`\n${parts.join(', ')}\n`);
|
|
1414
1760
|
}
|
|
1415
1761
|
}
|
|
1416
1762
|
function printStatus(model) {
|
|
1417
1763
|
console.log(`GuardLink Status: ${model.project}`);
|
|
1418
1764
|
console.log(`${'─'.repeat(40)}`);
|
|
1419
1765
|
console.log(`Files scanned: ${model.source_files}`);
|
|
1420
|
-
console.log(`
|
|
1421
|
-
console.log(`
|
|
1766
|
+
console.log(` Files annotated: ${model.annotated_files.length}`);
|
|
1767
|
+
console.log(` Files unannotated: ${model.unannotated_files.length}`);
|
|
1422
1768
|
console.log(`Annotations: ${model.annotations_parsed}`);
|
|
1423
1769
|
console.log(`${'─'.repeat(40)}`);
|
|
1424
1770
|
console.log(`Assets: ${model.assets.length}`);
|
|
@@ -1426,6 +1772,8 @@ function printStatus(model) {
|
|
|
1426
1772
|
console.log(`Controls: ${model.controls.length}`);
|
|
1427
1773
|
console.log(`Mitigations: ${model.mitigations.length}`);
|
|
1428
1774
|
console.log(`Exposures: ${model.exposures.length}`);
|
|
1775
|
+
if ((model.confirmed || []).length > 0)
|
|
1776
|
+
console.log(`Confirmed: ${model.confirmed.length} 🔴`);
|
|
1429
1777
|
console.log(`Acceptances: ${model.acceptances.length}`);
|
|
1430
1778
|
console.log(`Transfers: ${model.transfers.length}`);
|
|
1431
1779
|
console.log(`Flows: ${model.flows.length}`);
|
|
@@ -1435,6 +1783,10 @@ function printStatus(model) {
|
|
|
1435
1783
|
console.log(`Ownership: ${model.ownership.length}`);
|
|
1436
1784
|
console.log(`Data handling: ${model.data_handling.length}`);
|
|
1437
1785
|
console.log(`Assumptions: ${model.assumptions.length}`);
|
|
1786
|
+
if (model.features.length > 0) {
|
|
1787
|
+
const uniqueFeatures = new Set(model.features.map(f => f.feature));
|
|
1788
|
+
console.log(`Features: ${uniqueFeatures.size} (${model.features.length} tag${model.features.length > 1 ? 's' : ''})`);
|
|
1789
|
+
}
|
|
1438
1790
|
console.log(`Comments: ${model.comments.length}`);
|
|
1439
1791
|
console.log(`Shields: ${model.shields.length}`);
|
|
1440
1792
|
}
|