roadmapsmith 0.9.0 → 0.9.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/README.md CHANGED
@@ -1,4 +1,8 @@
1
- # roadmap-skill
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/PapiScholz/roadmapsmith/main/assets/roadmapsmith-logo.png" alt="RoadmapSmith logo" width="180">
3
+ </p>
4
+
5
+ <h1 align="center">RoadmapSmith</h1>
2
6
 
3
7
  Production-grade roadmap generator and sync tool for agent-driven projects.
4
8
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "roadmapsmith",
3
- "version": "0.9.0",
3
+ "version": "0.9.3",
4
4
  "description": "Evidence-backed ROADMAP.md generator and sync tool for AI coding agents.",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -2,7 +2,7 @@
2
2
 
3
3
  const { slugify } = require('../utils');
4
4
 
5
- const TASK_LINE_RE = /^(\s*)- \[( |x|X)\] (.*?)(?:\s*<!--\s*rs:task=([a-z0-9-]+)\s*-->)?\s*$/;
5
+ const TASK_LINE_RE = /^(\s*)- \[( |x|X)\] (.*?)(?:\s*<!--\s*rs:task=([a-z0-9-]+)([^>]*)-->)?\s*$/;
6
6
  const WARNING_RE = /^\s*-\s+⚠️ attempted but validation failed:\s*(.+?)\s*$/;
7
7
  const HEADING_RE = /^#{2,3}\s+(.*)$/;
8
8
 
@@ -29,7 +29,9 @@ function parseRoadmap(content) {
29
29
  const indent = taskMatch[1] || '';
30
30
  const checked = taskMatch[2].toLowerCase() === 'x';
31
31
  const text = taskMatch[3].trim();
32
- const markerId = taskMatch[4] || null;
32
+ const markerId = taskMatch[4] || null;
33
+ const markerFlags = taskMatch[5] || '';
34
+ const noTest = /\brs:no-test\b/i.test(markerFlags);
33
35
 
34
36
  let warningLineIndex = null;
35
37
  let warningText = null;
@@ -49,11 +51,12 @@ function parseRoadmap(content) {
49
51
  checked,
50
52
  lineIndex: index,
51
53
  warningLineIndex,
52
- warningText,
53
- markerId,
54
- indent,
55
- section
56
- });
54
+ warningText,
55
+ markerId,
56
+ noTest,
57
+ indent,
58
+ section
59
+ });
57
60
  }
58
61
 
59
62
  return {
@@ -8,9 +8,11 @@ const { escapeRegExp, tokenize } = require('../utils');
8
8
 
9
9
  const CONFIDENCE_RANK = { low: 0, medium: 1, high: 2 };
10
10
 
11
- const CODE_EXTENSIONS = new Set([
12
- '.js', '.cjs', '.mjs', '.ts', '.tsx', '.jsx', '.py', '.go', '.rs', '.java', '.kt', '.swift', '.rb', '.php', '.cs'
13
- ]);
11
+ const CODE_EXTENSIONS = new Set([
12
+ '.js', '.cjs', '.mjs', '.ts', '.tsx', '.jsx', '.py', '.go', '.rs', '.java', '.kt', '.swift', '.rb', '.php', '.cs'
13
+ ]);
14
+ const TRANSLATION_DIR_SEGMENTS = ['locale', 'locales', 'i18n', 'translations'];
15
+ const DEFAULT_EXCLUDED_PATH_PREFIXES = ['.claude/', '.agent/', 'roadmap-skill/'];
14
16
 
15
17
  // "docs" omitted from DOC_HINTS — it is a path prefix in scan tasks, not a doc-authoring keyword.
16
18
  const DOC_HINTS = ['readme', 'changelog', 'documentation', 'spec', 'diagram', 'runbook'];
@@ -76,18 +78,69 @@ const NAMESPACE_STRUCTURAL_PATTERNS = {
76
78
  // Test fixture directories contain synthetic code created to drive test scenarios,
77
79
  // not real implementations. Including them pollutes the evidence pool with vocabulary
78
80
  // that was deliberately seeded for testing purposes (e.g. namespace-vocab fixtures).
79
- function isFixturePath(relativePath) {
80
- return /(?:^|[/\\])fixtures[/\\]/.test(relativePath);
81
- }
82
-
83
- function readFileIndex(projectRoot, files) {
84
- const index = [];
85
- for (const relativePath of files) {
86
- if (SELF_REFERENTIAL_FILES.has(relativePath)) continue;
87
- if (isFixturePath(relativePath)) continue;
88
-
89
- const absolutePath = path.resolve(projectRoot, relativePath);
90
- const ext = path.extname(relativePath).toLowerCase();
81
+ function isFixturePath(relativePath) {
82
+ return /(?:^|[/\\])fixtures[/\\]/.test(relativePath);
83
+ }
84
+
85
+ function normalizePathForMatch(rawPath) {
86
+ return String(rawPath || '').replace(/\\/g, '/').toLowerCase();
87
+ }
88
+
89
+ function shouldExcludeByDefaultPath(relativePath, config) {
90
+ const normalized = normalizePathForMatch(relativePath);
91
+ if (DEFAULT_EXCLUDED_PATH_PREFIXES.some((prefix) => normalized.startsWith(prefix))) {
92
+ return true;
93
+ }
94
+
95
+ const configuredSkillsDir = config && typeof config.skillsDir === 'string'
96
+ ? normalizePathForMatch(config.skillsDir).replace(/^\.?\//, '')
97
+ : '';
98
+ if (configuredSkillsDir && (normalized === configuredSkillsDir || normalized.startsWith(configuredSkillsDir + '/'))) {
99
+ return true;
100
+ }
101
+ return false;
102
+ }
103
+
104
+ function isTranslationPath(relativePath) {
105
+ const normalized = normalizePathForMatch(relativePath);
106
+ const segments = normalized.split('/').filter(Boolean);
107
+ return segments.some((segment) => TRANSLATION_DIR_SEGMENTS.includes(segment));
108
+ }
109
+
110
+ function looksLikeTranslationJson(content) {
111
+ let parsed;
112
+ try {
113
+ parsed = JSON.parse(content);
114
+ } catch {
115
+ return false;
116
+ }
117
+
118
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
119
+ return false;
120
+ }
121
+
122
+ const values = Object.values(parsed);
123
+ if (values.length === 0) return false;
124
+ const stringValues = values.filter((value) => typeof value === 'string').length;
125
+ return stringValues / values.length >= 0.8;
126
+ }
127
+
128
+ function isMostlyUiStrings(content) {
129
+ const lines = String(content).split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
130
+ if (lines.length < 5) return false;
131
+ const stringLikeLines = lines.filter((line) => /^['"`][^'"`]{1,200}['"`],?$/.test(line) || /^[A-Za-z0-9_.-]+\s*:\s*['"`][^'"`]{1,200}['"`],?$/.test(line)).length;
132
+ return stringLikeLines / lines.length > 0.8;
133
+ }
134
+
135
+ function readFileIndex(projectRoot, files, config) {
136
+ const index = [];
137
+ for (const relativePath of files) {
138
+ if (SELF_REFERENTIAL_FILES.has(relativePath)) continue;
139
+ if (isFixturePath(relativePath)) continue;
140
+ if (shouldExcludeByDefaultPath(relativePath, config)) continue;
141
+
142
+ const absolutePath = path.resolve(projectRoot, relativePath);
143
+ const ext = path.extname(relativePath).toLowerCase();
91
144
  let content = '';
92
145
  try {
93
146
  const buffer = fs.readFileSync(absolutePath);
@@ -95,12 +148,16 @@ function readFileIndex(projectRoot, files) {
95
148
  continue;
96
149
  }
97
150
  content = buffer.toString('utf8');
98
- } catch {
99
- continue;
100
- }
101
-
102
- index.push({
103
- relativePath,
151
+ } catch {
152
+ continue;
153
+ }
154
+
155
+ if (isTranslationPath(relativePath)) continue;
156
+ if (ext === '.json' && looksLikeTranslationJson(content)) continue;
157
+ if (isMostlyUiStrings(content)) continue;
158
+
159
+ index.push({
160
+ relativePath,
104
161
  absolutePath,
105
162
  ext,
106
163
  content,
@@ -240,7 +297,7 @@ function findFilesByPathHints(pathHints, fileIndex) {
240
297
  return Array.from(new Set(matches)).sort((left, right) => left.localeCompare(right));
241
298
  }
242
299
 
243
- function findFilesBySymbols(symbolHints, fileIndex) {
300
+ function findFilesBySymbols(symbolHints, fileIndex) {
244
301
  const matches = new Set();
245
302
  for (const symbol of symbolHints) {
246
303
  const regex = new RegExp(`\\b${escapeRegExp(symbol)}\\b`, 'i');
@@ -254,7 +311,47 @@ function findFilesBySymbols(symbolHints, fileIndex) {
254
311
  }
255
312
  }
256
313
  return Array.from(matches).sort((left, right) => left.localeCompare(right));
257
- }
314
+ }
315
+
316
+ function findFilesByTaskPathTokens(taskText, fileIndex, pathDerivedTokens = new Set()) {
317
+ const tokens = tokenize(taskText)
318
+ .filter((token) => token.length >= 6 && !GENERIC_TASK_TOKENS.has(token) && !pathDerivedTokens.has(token))
319
+ .slice(0, 10);
320
+ if (tokens.length === 0) return [];
321
+
322
+ const matches = new Set();
323
+ for (const file of fileIndex) {
324
+ const pathSegments = normalizePathForMatch(file.relativePath).split('/').filter(Boolean);
325
+ for (const token of tokens) {
326
+ if (pathSegments.some((segment) => segment === token || segment.includes(token))) {
327
+ matches.add(file.relativePath);
328
+ break;
329
+ }
330
+ }
331
+ if (matches.size >= 20) break;
332
+ }
333
+ return Array.from(matches).sort((left, right) => left.localeCompare(right));
334
+ }
335
+
336
+ function mergeRuleEvidence(baseEvidence, ruleEvidence) {
337
+ if (!ruleEvidence || typeof ruleEvidence !== 'object') return baseEvidence;
338
+ const merged = { ...baseEvidence };
339
+
340
+ for (const [key, value] of Object.entries(ruleEvidence)) {
341
+ if (Array.isArray(value)) {
342
+ const existing = Array.isArray(merged[key]) ? merged[key] : [];
343
+ merged[key] = Array.from(new Set([...existing, ...value]));
344
+ continue;
345
+ }
346
+ if (typeof value === 'boolean') {
347
+ merged[key] = Boolean(merged[key]) || value;
348
+ continue;
349
+ }
350
+ merged[key] = value;
351
+ }
352
+
353
+ return merged;
354
+ }
258
355
 
259
356
  // Tokens extracted from a referenced file path (e.g. "roadmap-skill" from
260
357
  // "roadmap-skill.config.json") must not be reused as code evidence signals.
@@ -484,29 +581,30 @@ function checkNamespaceStructuralEvidence(taskId, taskText, fileIndex) {
484
581
  };
485
582
  }
486
583
 
487
- function evaluateRule(rule, task, context) {
488
- if (!rule) {
489
- return { passed: true, reasons: [], evidence: {} };
490
- }
584
+ function evaluateRule(rule, task, context) {
585
+ if (!rule) {
586
+ return { passed: true, reasons: [], evidence: {}, overrideResult: false };
587
+ }
491
588
 
492
589
  if (rule.when) {
493
590
  const regexp = new RegExp(rule.when, 'i');
494
- if (!regexp.test(task.text)) {
495
- return { passed: true, reasons: [], evidence: {} };
496
- }
497
- }
591
+ if (!regexp.test(task.text)) {
592
+ return { passed: true, reasons: [], evidence: {}, overrideResult: false };
593
+ }
594
+ }
498
595
 
499
596
  if (typeof rule.check === 'function') {
500
597
  const custom = rule.check(task, context);
501
- if (!custom) {
502
- return { passed: true, reasons: [], evidence: {} };
503
- }
504
- return {
505
- passed: custom.passed !== false,
506
- reasons: Array.isArray(custom.reasons) ? custom.reasons : [],
507
- evidence: custom.evidence || {}
508
- };
509
- }
598
+ if (!custom) {
599
+ return { passed: true, reasons: [], evidence: {}, overrideResult: false };
600
+ }
601
+ return {
602
+ passed: custom.passed !== false,
603
+ reasons: Array.isArray(custom.reasons) ? custom.reasons : [],
604
+ evidence: custom.evidence || {},
605
+ overrideResult: rule.overrideResult === true || custom.overrideResult === true
606
+ };
607
+ }
510
608
 
511
609
  const reasons = [];
512
610
  const evidence = {};
@@ -539,20 +637,46 @@ function evaluateRule(rule, task, context) {
539
637
  }
540
638
  }
541
639
 
542
- if (rule.type === 'test' && context.testFrameworks.length === 0) {
543
- reasons.push(rule.message || 'test framework not detected');
544
- }
545
-
546
- return {
547
- passed: reasons.length === 0,
548
- reasons,
549
- evidence
550
- };
551
- }
552
-
553
- function buildValidationContext(projectRoot, config, plugins) {
554
- const files = walkFiles(projectRoot);
555
- const fileIndex = readFileIndex(projectRoot, files);
640
+ if (rule.type === 'test' && context.testFrameworks.length === 0) {
641
+ reasons.push(rule.message || 'test framework not detected');
642
+ }
643
+
644
+ if (rule.type === 'grant-evidence') {
645
+ const evidenceTargets = Array.isArray(rule.evidence) ? rule.evidence : [rule.evidence].filter(Boolean);
646
+ for (const key of evidenceTargets) {
647
+ evidence[key] = true;
648
+ }
649
+ if (Array.isArray(rule.files) && rule.files.length > 0) {
650
+ evidence.files = rule.files;
651
+ }
652
+ if (Array.isArray(rule.symbols) && rule.symbols.length > 0) {
653
+ evidence.symbols = rule.symbols;
654
+ }
655
+ if (Array.isArray(rule.codeFiles) && rule.codeFiles.length > 0) {
656
+ evidence.codeFiles = rule.codeFiles;
657
+ evidence.code = true;
658
+ }
659
+ if (Array.isArray(rule.testFiles) && rule.testFiles.length > 0) {
660
+ evidence.testFiles = rule.testFiles;
661
+ evidence.test = true;
662
+ }
663
+ if (Array.isArray(rule.artifactFiles) && rule.artifactFiles.length > 0) {
664
+ evidence.artifactFiles = rule.artifactFiles;
665
+ evidence.artifact = true;
666
+ }
667
+ }
668
+
669
+ return {
670
+ passed: reasons.length === 0,
671
+ reasons,
672
+ evidence,
673
+ overrideResult: rule.overrideResult === true
674
+ };
675
+ }
676
+
677
+ function buildValidationContext(projectRoot, config, plugins) {
678
+ const files = walkFiles(projectRoot);
679
+ const fileIndex = readFileIndex(projectRoot, files, config);
556
680
  const testFrameworks = detectTestFrameworks(projectRoot, files);
557
681
 
558
682
  return {
@@ -575,10 +699,11 @@ function validateTask(task, context, config, plugins) {
575
699
  // Combine path hints AND standalone filenames for token exclusion so that tokens
576
700
  // derived from any referenced filename (e.g. "roadmap-skill" from
577
701
  // "roadmap-skill.config.json") are excluded from code evidence scoring.
578
- const pathDerivedTokens = extractPathDerivedTokens([...pathHints, ...standaloneFilenames]);
579
- const filesFromCode = findCodeEvidence(task.text, context.fileIndex, pathDerivedTokens);
580
- const filesFromTests = findTestEvidence(task.text, context.fileIndex);
581
- const { files: filesFromArtifacts, heuristicArtifacts } = findArtifactEvidence(task.text, context.fileIndex);
702
+ const pathDerivedTokens = extractPathDerivedTokens([...pathHints, ...standaloneFilenames]);
703
+ const filesFromCode = findCodeEvidence(task.text, context.fileIndex, pathDerivedTokens);
704
+ const filesFromWeakPathTokens = findFilesByTaskPathTokens(task.text, context.fileIndex, pathDerivedTokens);
705
+ const filesFromTests = findTestEvidence(task.text, context.fileIndex);
706
+ const { files: filesFromArtifacts, heuristicArtifacts } = findArtifactEvidence(task.text, context.fileIndex);
582
707
 
583
708
  const structuralCheck = checkNamespaceStructuralEvidence(task.id, task.text, context.fileIndex);
584
709
 
@@ -587,9 +712,10 @@ function validateTask(task, context, config, plugins) {
587
712
  test: filesFromTests.length > 0,
588
713
  artifact: filesFromArtifacts.length > 0,
589
714
  files: filesFromPaths,
590
- symbols: filesFromSymbols,
591
- codeFiles: filesFromCode,
592
- testFiles: filesFromTests,
715
+ symbols: filesFromSymbols,
716
+ codeFiles: filesFromCode,
717
+ weakPathFiles: filesFromWeakPathTokens,
718
+ testFiles: filesFromTests,
593
719
  artifactFiles: filesFromArtifacts,
594
720
  heuristicArtifacts,
595
721
  structuralEvidence: structuralCheck.applicable ? structuralCheck.passed : null,
@@ -610,49 +736,74 @@ function validateTask(task, context, config, plugins) {
610
736
  reasons.push(structuralCheck.reason || `no structural evidence for namespace "${extractTaskNamespace(task.id)}"`);
611
737
  }
612
738
 
613
- const hasEvidence = evidence.code || evidence.test || evidence.artifact || evidence.files.length > 0;
614
- if (!hasEvidence && !structuralCheck.applicable) {
615
- reasons.push('no code, test, or artifact evidence found');
616
- } else if (!hasEvidence && structuralCheck.applicable && structuralCheck.passed) {
617
- reasons.push('no code, test, or artifact evidence found');
618
- }
619
-
620
- const requiresTest = context.testFrameworks.length > 0 && isCodeTask(task.text) && !isDocTask(task.text);
621
- if (requiresTest && !evidence.test) {
622
- reasons.push('missing test evidence');
623
- }
624
-
625
- const configuredRules = Array.isArray(config.validators) ? config.validators : [];
626
- const pluginRules = collectPluginContributions(plugins || [], 'registerValidators', context);
627
- for (const rule of [...configuredRules, ...pluginRules]) {
628
- const ruleResult = evaluateRule(rule, task, context);
629
- if (!ruleResult.passed) {
630
- reasons.push(...ruleResult.reasons);
631
- }
632
- }
633
-
634
- const uniqueReasons = Array.from(new Set(reasons));
635
- const attempted = hasEvidence || pathHints.length > 0 || symbolHints.length > 0;
636
-
637
- const evidenceCount = [evidence.code, evidence.test, evidence.artifact].filter(Boolean).length;
638
- const confidence = evidenceCount >= 2 ? 'high' : evidenceCount === 1 ? 'medium' : 'low';
739
+ const hasEvidence = evidence.code || evidence.test || evidence.artifact || evidence.files.length > 0;
740
+ const hasWeakEvidence = filesFromWeakPathTokens.length > 0;
741
+ if (!hasEvidence && !hasWeakEvidence && !structuralCheck.applicable) {
742
+ reasons.push('no code, test, or artifact evidence found');
743
+ } else if (!hasEvidence && !hasWeakEvidence && structuralCheck.applicable && structuralCheck.passed) {
744
+ reasons.push('no code, test, or artifact evidence found');
745
+ }
746
+
747
+ const requiresTest = !task.noTest && context.testFrameworks.length > 0 && isCodeTask(task.text) && !isDocTask(task.text);
748
+ if (requiresTest && !evidence.test) {
749
+ reasons.push('missing test evidence');
750
+ }
751
+
752
+ const configuredRules = Array.isArray(config.validators) ? config.validators : [];
753
+ const pluginRules = collectPluginContributions(plugins || [], 'registerValidators', context);
754
+ let overrideResult = null;
755
+ for (const rule of [...configuredRules, ...pluginRules]) {
756
+ const ruleResult = evaluateRule(rule, task, context);
757
+ if (ruleResult.evidence && Object.keys(ruleResult.evidence).length > 0) {
758
+ Object.assign(evidence, mergeRuleEvidence(evidence, ruleResult.evidence));
759
+ }
760
+ if (!ruleResult.passed) {
761
+ reasons.push(...ruleResult.reasons);
762
+ }
763
+ if (ruleResult.overrideResult) {
764
+ overrideResult = ruleResult;
765
+ }
766
+ }
767
+
768
+ const hasStrongEvidence = evidence.code || evidence.test || evidence.artifact || evidence.files.length > 0;
769
+ if (hasStrongEvidence) {
770
+ const noEvidenceReason = 'no code, test, or artifact evidence found';
771
+ const idx = reasons.indexOf(noEvidenceReason);
772
+ if (idx >= 0) {
773
+ reasons.splice(idx, 1);
774
+ }
775
+ }
776
+
777
+ let uniqueReasons = Array.from(new Set(reasons));
778
+
779
+ if (overrideResult) {
780
+ uniqueReasons = Array.isArray(overrideResult.reasons) ? Array.from(new Set(overrideResult.reasons)) : [];
781
+ }
782
+
783
+ const attempted = hasStrongEvidence || hasWeakEvidence || pathHints.length > 0 || symbolHints.length > 0;
784
+
785
+ const evidenceCount = [evidence.code, evidence.test, evidence.artifact].filter(Boolean).length;
786
+ let confidence = evidenceCount >= 2 ? 'high' : evidenceCount === 1 ? 'medium' : 'low';
787
+ if (confidence === 'low' && hasWeakEvidence) {
788
+ confidence = 'low';
789
+ }
639
790
 
640
791
  // True when the only passing evidence is artifact/doc files and the task is not a doc task.
641
792
  // Used by auditValidation to flag implementation tasks that pass solely via documentation.
642
793
  const evidenceIsDocOnly = !evidence.code && !evidence.test && evidence.artifact && !isDocTask(task.text);
643
794
 
644
- return {
645
- taskId: task.id,
646
- passed: uniqueReasons.length === 0,
647
- confidence,
648
- reasons: uniqueReasons,
795
+ return {
796
+ taskId: task.id,
797
+ passed: overrideResult ? overrideResult.passed !== false : uniqueReasons.length === 0,
798
+ confidence,
799
+ reasons: uniqueReasons,
649
800
  evidence,
650
801
  evidenceIsDocOnly,
651
802
  requiresTest,
652
- hasEvidence,
653
- attempted
654
- };
655
- }
803
+ hasEvidence: hasStrongEvidence || hasWeakEvidence,
804
+ attempted
805
+ };
806
+ }
656
807
 
657
808
  function validateTasks(tasks, context, config, plugins) {
658
809
  const result = {};