roadmapsmith 0.9.2 → 0.9.4

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
 
@@ -120,6 +124,12 @@ Create `roadmap-skill.config.json`:
120
124
  "type": "file-exists",
121
125
  "when": "migration",
122
126
  "path": "db/migrations"
127
+ },
128
+ {
129
+ "type": "grant-evidence",
130
+ "whenId": "^p0-electron-builder-windows$",
131
+ "evidence": ["test"],
132
+ "testFiles": ["test/electron-builder.test.js"]
123
133
  }
124
134
  ],
125
135
  "customSections": [
@@ -147,6 +157,20 @@ Create `roadmap-skill.config.json`:
147
157
  }
148
158
  ```
149
159
 
160
+ Task markers can include `rs:no-test` to disable the test-evidence requirement for one task:
161
+
162
+ ```markdown
163
+ - [ ] Add Windows autostart script <!-- rs:task=p0-windows-autostart rs:no-test -->
164
+ ```
165
+
166
+ Validator rules are backward compatible:
167
+
168
+ - `when` matches task text.
169
+ - `whenId` matches the stable `rs:task` ID.
170
+ - `grant-evidence` can grant `code`, `test`, or `artifact` evidence without `overrideResult`.
171
+ - `overrideResult: true` is only needed when a rule should replace automatic failures.
172
+ - Tests that read a referenced file with `fs.readFileSync`, `fs.readFile`, `readFileSync`, or `readFile` can count as test evidence for tasks that explicitly mention that file.
173
+
150
174
  ## Plugin API
151
175
 
152
176
  Plugin module path(s) are loaded from `config.plugins` in deterministic order.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "roadmapsmith",
3
- "version": "0.9.2",
3
+ "version": "0.9.4",
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
 
@@ -30,6 +30,8 @@ function parseRoadmap(content) {
30
30
  const checked = taskMatch[2].toLowerCase() === 'x';
31
31
  const text = taskMatch[3].trim();
32
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;
@@ -51,6 +53,7 @@ function parseRoadmap(content) {
51
53
  warningLineIndex,
52
54
  warningText,
53
55
  markerId,
56
+ noTest,
54
57
  indent,
55
58
  section
56
59
  });
@@ -11,6 +11,8 @@ const CONFIDENCE_RANK = { low: 0, medium: 1, high: 2 };
11
11
  const CODE_EXTENSIONS = new Set([
12
12
  '.js', '.cjs', '.mjs', '.ts', '.tsx', '.jsx', '.py', '.go', '.rs', '.java', '.kt', '.swift', '.rb', '.php', '.cs'
13
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'];
@@ -80,11 +82,62 @@ function isFixturePath(relativePath) {
80
82
  return /(?:^|[/\\])fixtures[/\\]/.test(relativePath);
81
83
  }
82
84
 
83
- function readFileIndex(projectRoot, files) {
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) {
84
136
  const index = [];
85
137
  for (const relativePath of files) {
86
138
  if (SELF_REFERENTIAL_FILES.has(relativePath)) continue;
87
139
  if (isFixturePath(relativePath)) continue;
140
+ if (shouldExcludeByDefaultPath(relativePath, config)) continue;
88
141
 
89
142
  const absolutePath = path.resolve(projectRoot, relativePath);
90
143
  const ext = path.extname(relativePath).toLowerCase();
@@ -99,6 +152,10 @@ function readFileIndex(projectRoot, files) {
99
152
  continue;
100
153
  }
101
154
 
155
+ if (isTranslationPath(relativePath)) continue;
156
+ if (ext === '.json' && looksLikeTranslationJson(content)) continue;
157
+ if (isMostlyUiStrings(content)) continue;
158
+
102
159
  index.push({
103
160
  relativePath,
104
161
  absolutePath,
@@ -256,6 +313,46 @@ function findFilesBySymbols(symbolHints, fileIndex) {
256
313
  return Array.from(matches).sort((left, right) => left.localeCompare(right));
257
314
  }
258
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
+ }
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.
261
358
  // Those tokens appear in any file that mentions the same path — creating circular
@@ -314,12 +411,45 @@ function findCodeEvidence(taskText, fileIndex, pathDerivedTokens = new Set()) {
314
411
  return matches.slice(0, 20);
315
412
  }
316
413
 
317
- function findTestEvidence(taskText, fileIndex) {
414
+ function normalizeReferencedPath(rawPath) {
415
+ return String(rawPath || '').replace(/\\/g, '/').replace(/^\.\//, '').toLowerCase();
416
+ }
417
+
418
+ function referencedPathMatches(readRef, referencedPath) {
419
+ const normalizedRef = normalizeReferencedPath(readRef);
420
+ const normalizedHint = normalizeReferencedPath(referencedPath);
421
+ if (!normalizedRef || !normalizedHint) return false;
422
+ if (normalizedRef === normalizedHint || normalizedRef.endsWith('/' + normalizedHint)) {
423
+ return true;
424
+ }
425
+ return path.basename(normalizedRef) === normalizedHint || normalizedRef === path.basename(normalizedHint);
426
+ }
427
+
428
+ function extractTestReadReferences(content) {
429
+ const refs = [];
430
+ const lines = String(content || '').split(/\r?\n/);
431
+ for (const line of lines) {
432
+ if (!/\b(?:fs\.)?readFile(?:Sync)?\s*\(/.test(line)) {
433
+ continue;
434
+ }
435
+ const stringLiterals = line.match(/['"`]([^'"`]+)['"`]/g) || [];
436
+ for (const literal of stringLiterals) {
437
+ const value = literal.slice(1, -1);
438
+ if (hasKnownFileExtension(value) || value.includes('/') || value.includes('\\')) {
439
+ refs.push(value);
440
+ }
441
+ }
442
+ }
443
+ return refs;
444
+ }
445
+
446
+ function findTestEvidence(taskText, fileIndex, referencedPaths = []) {
318
447
  const tokens = tokenize(taskText)
319
448
  .filter((token) => token.length >= 3 && !GENERIC_TASK_TOKENS.has(token) && !token.endsWith('/'))
320
449
  .slice(0, 8);
321
450
 
322
- if (tokens.length === 0) return [];
451
+ const pathRefs = Array.from(new Set(referencedPaths)).filter(Boolean);
452
+ if (tokens.length === 0 && pathRefs.length === 0) return [];
323
453
 
324
454
  // Only tokens of length >= 4 are used for import-reference matching.
325
455
  // Very short tokens (e.g. "app", "web") are too generic: they appear as substrings in
@@ -355,6 +485,14 @@ function findTestEvidence(taskText, fileIndex) {
355
485
  const lowered = file.content.toLowerCase();
356
486
  if (lowered.includes(tokens[0])) {
357
487
  matches.push(file.relativePath);
488
+ continue;
489
+ }
490
+ }
491
+
492
+ if (pathRefs.length > 0) {
493
+ const readRefs = extractTestReadReferences(file.content);
494
+ if (readRefs.some((readRef) => pathRefs.some((pathRef) => referencedPathMatches(readRef, pathRef)))) {
495
+ matches.push(file.relativePath);
358
496
  }
359
497
  }
360
498
  }
@@ -486,25 +624,33 @@ function checkNamespaceStructuralEvidence(taskId, taskText, fileIndex) {
486
624
 
487
625
  function evaluateRule(rule, task, context) {
488
626
  if (!rule) {
489
- return { passed: true, reasons: [], evidence: {} };
627
+ return { passed: true, reasons: [], evidence: {}, overrideResult: false };
490
628
  }
491
629
 
492
630
  if (rule.when) {
493
631
  const regexp = new RegExp(rule.when, 'i');
494
632
  if (!regexp.test(task.text)) {
495
- return { passed: true, reasons: [], evidence: {} };
633
+ return { passed: true, reasons: [], evidence: {}, overrideResult: false };
634
+ }
635
+ }
636
+
637
+ if (rule.whenId) {
638
+ const regexp = new RegExp(rule.whenId, 'i');
639
+ if (!regexp.test(task.id)) {
640
+ return { passed: true, reasons: [], evidence: {}, overrideResult: false };
496
641
  }
497
642
  }
498
643
 
499
644
  if (typeof rule.check === 'function') {
500
645
  const custom = rule.check(task, context);
501
646
  if (!custom) {
502
- return { passed: true, reasons: [], evidence: {} };
647
+ return { passed: true, reasons: [], evidence: {}, overrideResult: false };
503
648
  }
504
649
  return {
505
650
  passed: custom.passed !== false,
506
651
  reasons: Array.isArray(custom.reasons) ? custom.reasons : [],
507
- evidence: custom.evidence || {}
652
+ evidence: custom.evidence || {},
653
+ overrideResult: rule.overrideResult === true || custom.overrideResult === true
508
654
  };
509
655
  }
510
656
 
@@ -543,16 +689,42 @@ function evaluateRule(rule, task, context) {
543
689
  reasons.push(rule.message || 'test framework not detected');
544
690
  }
545
691
 
692
+ if (rule.type === 'grant-evidence') {
693
+ const evidenceTargets = Array.isArray(rule.evidence) ? rule.evidence : [rule.evidence].filter(Boolean);
694
+ for (const key of evidenceTargets) {
695
+ evidence[key] = true;
696
+ }
697
+ if (Array.isArray(rule.files) && rule.files.length > 0) {
698
+ evidence.files = rule.files;
699
+ }
700
+ if (Array.isArray(rule.symbols) && rule.symbols.length > 0) {
701
+ evidence.symbols = rule.symbols;
702
+ }
703
+ if (Array.isArray(rule.codeFiles) && rule.codeFiles.length > 0) {
704
+ evidence.codeFiles = rule.codeFiles;
705
+ evidence.code = true;
706
+ }
707
+ if (Array.isArray(rule.testFiles) && rule.testFiles.length > 0) {
708
+ evidence.testFiles = rule.testFiles;
709
+ evidence.test = true;
710
+ }
711
+ if (Array.isArray(rule.artifactFiles) && rule.artifactFiles.length > 0) {
712
+ evidence.artifactFiles = rule.artifactFiles;
713
+ evidence.artifact = true;
714
+ }
715
+ }
716
+
546
717
  return {
547
718
  passed: reasons.length === 0,
548
719
  reasons,
549
- evidence
720
+ evidence,
721
+ overrideResult: rule.overrideResult === true
550
722
  };
551
723
  }
552
724
 
553
725
  function buildValidationContext(projectRoot, config, plugins) {
554
726
  const files = walkFiles(projectRoot);
555
- const fileIndex = readFileIndex(projectRoot, files);
727
+ const fileIndex = readFileIndex(projectRoot, files, config);
556
728
  const testFrameworks = detectTestFrameworks(projectRoot, files);
557
729
 
558
730
  return {
@@ -577,7 +749,8 @@ function validateTask(task, context, config, plugins) {
577
749
  // "roadmap-skill.config.json") are excluded from code evidence scoring.
578
750
  const pathDerivedTokens = extractPathDerivedTokens([...pathHints, ...standaloneFilenames]);
579
751
  const filesFromCode = findCodeEvidence(task.text, context.fileIndex, pathDerivedTokens);
580
- const filesFromTests = findTestEvidence(task.text, context.fileIndex);
752
+ const filesFromWeakPathTokens = findFilesByTaskPathTokens(task.text, context.fileIndex, pathDerivedTokens);
753
+ const filesFromTests = findTestEvidence(task.text, context.fileIndex, [...pathHints, ...standaloneFilenames]);
581
754
  const { files: filesFromArtifacts, heuristicArtifacts } = findArtifactEvidence(task.text, context.fileIndex);
582
755
 
583
756
  const structuralCheck = checkNamespaceStructuralEvidence(task.id, task.text, context.fileIndex);
@@ -589,6 +762,7 @@ function validateTask(task, context, config, plugins) {
589
762
  files: filesFromPaths,
590
763
  symbols: filesFromSymbols,
591
764
  codeFiles: filesFromCode,
765
+ weakPathFiles: filesFromWeakPathTokens,
592
766
  testFiles: filesFromTests,
593
767
  artifactFiles: filesFromArtifacts,
594
768
  heuristicArtifacts,
@@ -611,31 +785,56 @@ function validateTask(task, context, config, plugins) {
611
785
  }
612
786
 
613
787
  const hasEvidence = evidence.code || evidence.test || evidence.artifact || evidence.files.length > 0;
614
- if (!hasEvidence && !structuralCheck.applicable) {
788
+ const hasWeakEvidence = filesFromWeakPathTokens.length > 0;
789
+ if (!hasEvidence && !hasWeakEvidence && !structuralCheck.applicable) {
615
790
  reasons.push('no code, test, or artifact evidence found');
616
- } else if (!hasEvidence && structuralCheck.applicable && structuralCheck.passed) {
791
+ } else if (!hasEvidence && !hasWeakEvidence && structuralCheck.applicable && structuralCheck.passed) {
617
792
  reasons.push('no code, test, or artifact evidence found');
618
793
  }
619
794
 
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
-
795
+ const requiresTest = !task.noTest && context.testFrameworks.length > 0 && isCodeTask(task.text) && !isDocTask(task.text);
625
796
  const configuredRules = Array.isArray(config.validators) ? config.validators : [];
626
797
  const pluginRules = collectPluginContributions(plugins || [], 'registerValidators', context);
798
+ let overrideResult = null;
627
799
  for (const rule of [...configuredRules, ...pluginRules]) {
628
800
  const ruleResult = evaluateRule(rule, task, context);
801
+ if (ruleResult.evidence && Object.keys(ruleResult.evidence).length > 0) {
802
+ Object.assign(evidence, mergeRuleEvidence(evidence, ruleResult.evidence));
803
+ }
629
804
  if (!ruleResult.passed) {
630
805
  reasons.push(...ruleResult.reasons);
631
806
  }
807
+ if (ruleResult.overrideResult) {
808
+ overrideResult = ruleResult;
809
+ }
632
810
  }
633
811
 
634
- const uniqueReasons = Array.from(new Set(reasons));
635
- const attempted = hasEvidence || pathHints.length > 0 || symbolHints.length > 0;
812
+ const hasStrongEvidence = evidence.code || evidence.test || evidence.artifact || evidence.files.length > 0;
813
+ if (hasStrongEvidence) {
814
+ const noEvidenceReason = 'no code, test, or artifact evidence found';
815
+ const idx = reasons.indexOf(noEvidenceReason);
816
+ if (idx >= 0) {
817
+ reasons.splice(idx, 1);
818
+ }
819
+ }
820
+
821
+ if (requiresTest && !evidence.test) {
822
+ reasons.push('missing test evidence');
823
+ }
824
+
825
+ let uniqueReasons = Array.from(new Set(reasons));
826
+
827
+ if (overrideResult) {
828
+ uniqueReasons = Array.isArray(overrideResult.reasons) ? Array.from(new Set(overrideResult.reasons)) : [];
829
+ }
830
+
831
+ const attempted = hasStrongEvidence || hasWeakEvidence || pathHints.length > 0 || symbolHints.length > 0;
636
832
 
637
833
  const evidenceCount = [evidence.code, evidence.test, evidence.artifact].filter(Boolean).length;
638
- const confidence = evidenceCount >= 2 ? 'high' : evidenceCount === 1 ? 'medium' : 'low';
834
+ let confidence = evidenceCount >= 2 ? 'high' : evidenceCount === 1 ? 'medium' : 'low';
835
+ if (confidence === 'low' && hasWeakEvidence) {
836
+ confidence = 'low';
837
+ }
639
838
 
640
839
  // True when the only passing evidence is artifact/doc files and the task is not a doc task.
641
840
  // Used by auditValidation to flag implementation tasks that pass solely via documentation.
@@ -643,13 +842,13 @@ function validateTask(task, context, config, plugins) {
643
842
 
644
843
  return {
645
844
  taskId: task.id,
646
- passed: uniqueReasons.length === 0,
845
+ passed: overrideResult ? overrideResult.passed !== false : uniqueReasons.length === 0,
647
846
  confidence,
648
847
  reasons: uniqueReasons,
649
848
  evidence,
650
849
  evidenceIsDocOnly,
651
850
  requiresTest,
652
- hasEvidence,
851
+ hasEvidence: hasStrongEvidence || hasWeakEvidence,
653
852
  attempted
654
853
  };
655
854
  }