roadmapsmith 0.9.8 → 0.9.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "roadmapsmith",
3
- "version": "0.9.8",
3
+ "version": "0.9.9",
4
4
  "description": "Evidence-backed ROADMAP.md generator and sync tool for AI coding agents.",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/src/io.js CHANGED
@@ -159,7 +159,7 @@ function detectTestFrameworks(projectRoot, files) {
159
159
  if (files.some((file) => file.endsWith('pyproject.toml')) || files.some((file) => file.endsWith('pytest.ini'))) {
160
160
  frameworks.add('pytest');
161
161
  }
162
- if (files.some((file) => /(^|\/)test_.*\.py$/.test(file)) || files.some((file) => /(^|\/)tests\//.test(file))) {
162
+ if (files.some((file) => /(^|\/)test_[^/]*\.py$/.test(file)) || files.some((file) => /(^|\/)tests\//.test(file))) {
163
163
  frameworks.add('python-tests');
164
164
  }
165
165
  if (files.some((file) => file.endsWith('go.mod'))) {
@@ -203,7 +203,7 @@ function upsertManagedBlock(existingContent, managedBody) {
203
203
  if (existing.trim().length === 0) {
204
204
  return [MANAGED_START, ...bodyLines, MANAGED_END].join('\n');
205
205
  }
206
- return `${existing.replace(/\s+$/, '')}\n\n${MANAGED_START}\n${managedBody}\n${MANAGED_END}`;
206
+ return `${existing.trimEnd()}\n\n${MANAGED_START}\n${managedBody}\n${MANAGED_END}`;
207
207
  }
208
208
 
209
209
  const prefix = lines.slice(0, range.start + 1);
package/src/sync/index.js CHANGED
@@ -45,7 +45,13 @@ function applySync(content, parsedTasks, results) {
45
45
  }
46
46
 
47
47
  if (warningIndex != null && warningIndex >= 0 && warningIndex < lines.length) {
48
- lines[warningIndex] = warningText;
48
+ const existingReason = lines[warningIndex].split('validation failed:')[1];
49
+ const newReason = reason || 'validation failed';
50
+ // Preserve existing warning when it's more descriptive than the new generic message.
51
+ const existingIsMoreSpecific = existingReason && existingReason.trim().length > newReason.length;
52
+ if (!existingIsMoreSpecific) {
53
+ lines[warningIndex] = warningText;
54
+ }
49
55
  } else {
50
56
  lines.splice(lastChildLineIndex + 1, 0, warningText);
51
57
  offset += 1;
@@ -249,23 +249,47 @@ function collectPathishTokens(text) {
249
249
  return tokens.filter(Boolean);
250
250
  }
251
251
 
252
+ // LINE_REF_RE matches "path/file.ext:NN" or "path/file.ext:NN-MM" — indicates WHERE
253
+ // to implement, not that implementation exists. Paths matching this pattern are added
254
+ // to lineReferenceHints and excluded from hasDirectReferencePass scoring.
255
+ const LINE_REF_RE = /^(.+?):(\d+)(?:-\d+)?$/;
256
+
252
257
  function extractExplicitPaths(text) {
253
258
  const results = new Set();
259
+ const lineReferenceHints = new Set();
260
+
254
261
  const quoted = String(text).match(/`([^`]+)`/g) || [];
255
262
  for (const token of quoted) {
256
263
  const clean = token.slice(1, -1);
264
+ if (clean.includes('*') || clean.includes('?')) continue; // glob
257
265
  if (clean.includes('/') || clean.includes('\\') || clean.includes('.')) {
258
- results.add(clean);
266
+ const lineMatch = LINE_REF_RE.exec(clean);
267
+ if (lineMatch && hasKnownFileExtension(lineMatch[1])) {
268
+ lineReferenceHints.add(lineMatch[1]);
269
+ results.add(lineMatch[1]);
270
+ } else {
271
+ results.add(clean);
272
+ }
259
273
  }
260
274
  }
261
275
 
262
- const pathTokens = String(text).match(/([A-Za-z0-9_.-]+\/[A-Za-z0-9_./-]+)/g) || [];
263
- for (const raw of pathTokens) {
264
- const token = stripTrailingPathPunctuation(raw);
265
- if (isLikelyPath(token)) results.add(token);
276
+ for (const word of String(text).split(/\s+/)) {
277
+ if (!word.includes('/')) continue;
278
+ const token = stripTrailingPathPunctuation(word);
279
+ if (token.includes('*') || token.includes('?')) continue; // glob
280
+ const lineMatch = LINE_REF_RE.exec(token);
281
+ if (lineMatch && hasKnownFileExtension(lineMatch[1])) {
282
+ if (isLikelyPath(lineMatch[1])) {
283
+ lineReferenceHints.add(lineMatch[1]);
284
+ results.add(lineMatch[1]);
285
+ }
286
+ } else if (isLikelyPath(token)) {
287
+ results.add(token);
288
+ }
266
289
  }
267
290
 
268
- return Array.from(results).sort((left, right) => left.localeCompare(right));
291
+ const paths = Array.from(results).sort((left, right) => left.localeCompare(right));
292
+ return { paths, lineReferenceHints };
269
293
  }
270
294
 
271
295
  // Standalone filenames (no slash) mentioned in task prose — e.g. "roadmap-skill.config.json",
@@ -644,7 +668,7 @@ function extractEvidencePaths(evidenceText) {
644
668
 
645
669
  function evidenceLineHasPassingSummary(evidenceText) {
646
670
  const text = String(evidenceText || '');
647
- if (/\b\d+\s*\/\s*\d+\s+tests?\s+passing\b/i.test(text)) {
671
+ if (/\b\d+(?:\s*\/\s*\d+)?\s+tests?\s+passing\b/i.test(text)) {
648
672
  return true;
649
673
  }
650
674
  if (/\b(?:vitest|jest|npm test|pnpm test|yarn test|bun test)\b.*\b(?:pass(?:ing|ed)?|green|success(?:ful|fully)?)\b/i.test(text)) {
@@ -1009,12 +1033,16 @@ function buildValidationContext(projectRoot, config, plugins) {
1009
1033
  }
1010
1034
 
1011
1035
  function validateTask(task, context, config, plugins) {
1012
- const pathHints = extractExplicitPaths(task.text);
1036
+ const { paths: pathHints, lineReferenceHints } = extractExplicitPaths(task.text);
1037
+ // Paths that are line-reference hints (file.ts:NN) indicate WHERE to implement,
1038
+ // not that implementation exists. They are excluded from hasDirectReferencePass.
1039
+ const purePathHints = pathHints.filter((p) => !lineReferenceHints.has(p));
1013
1040
  const standaloneFilenames = extractStandaloneFilenames(task.text);
1014
1041
  const symbolHints = extractSymbolHints(task.text);
1015
1042
  const authoritativeEvidence = evaluateAuthoritativeEvidence(task, context.fileIndex);
1016
1043
 
1017
1044
  const filesFromPaths = findFilesByPathHints(pathHints, context.fileIndex);
1045
+ const filesFromPurePathHints = findFilesByPathHints(purePathHints, context.fileIndex);
1018
1046
  const filesFromSymbols = findFilesBySymbols(symbolHints, context.fileIndex);
1019
1047
  // Combine path hints AND standalone filenames for token exclusion so that tokens
1020
1048
  // derived from any referenced filename (e.g. "roadmap-skill" from
@@ -1107,7 +1135,7 @@ function validateTask(task, context, config, plugins) {
1107
1135
  }
1108
1136
  }
1109
1137
 
1110
- if (requiresTest && !evidence.test && !authoritativeEvidence.passed) {
1138
+ if (requiresTest && !evidence.test && !authoritativeEvidence.passed && filesFromPurePathHints.length === 0) {
1111
1139
  reasons.push('missing test evidence');
1112
1140
  }
1113
1141
 
@@ -1120,7 +1148,8 @@ function validateTask(task, context, config, plugins) {
1120
1148
  const attempted = hasStrongEvidence || hasWeakEvidence || pathHints.length > 0 || symbolHints.length > 0 || authoritativeEvidence.active;
1121
1149
  const { categories: strongEvidenceCategories } = countStrongEvidenceCategories(task.text, evidence);
1122
1150
  const strongEvidenceCount = strongEvidenceCategories.length;
1123
- const hasDirectReferencePass = filesFromPaths.length > 0 || filesFromSymbols.length > 0;
1151
+ // Only pure path hints (not line-reference hints like file.ts:169) count as direct evidence.
1152
+ const hasDirectReferencePass = filesFromPurePathHints.length > 0 || filesFromSymbols.length > 0;
1124
1153
  const hasArtifactTaskPass = evidence.artifact && (
1125
1154
  isDocTask(task.text) ||
1126
1155
  evidence.heuristicArtifacts.length > 0 ||
@@ -1167,7 +1196,7 @@ function validateTask(task, context, config, plugins) {
1167
1196
  task.checked &&
1168
1197
  !passed &&
1169
1198
  !authoritativeEvidence.active &&
1170
- pathHints.length === 0 &&
1199
+ purePathHints.length === 0 &&
1171
1200
  symbolHints.length === 0 &&
1172
1201
  !hasDirectReferencePass &&
1173
1202
  evidence.structuralEvidence !== false &&
@@ -1198,11 +1227,27 @@ function validateTask(task, context, config, plugins) {
1198
1227
  };
1199
1228
  }
1200
1229
 
1230
+ const BLOCKED_BY_RE = /\bBlocked\s+by:\s*([^\n.]+)/i;
1231
+
1201
1232
  function validateTasks(tasks, context, config, plugins) {
1202
1233
  const result = {};
1203
1234
  for (const task of tasks) {
1204
1235
  result[task.id] = validateTask(task, context, config, plugins);
1205
1236
  }
1237
+
1238
+ // Post-pass: milestone tasks with "Blocked by: id-a, id-b" cannot pass
1239
+ // while any listed dependency is still failing.
1240
+ for (const task of tasks) {
1241
+ const m = BLOCKED_BY_RE.exec(task.text);
1242
+ if (!m) continue;
1243
+ const blockedIds = m[1].split(/[\s,]+/).map((s) => s.trim()).filter(Boolean);
1244
+ const failingDeps = blockedIds.filter((id) => result[id] && !result[id].passed);
1245
+ if (failingDeps.length > 0 && result[task.id] && result[task.id].passed) {
1246
+ result[task.id].passed = false;
1247
+ result[task.id].reasons = [`blocked by incomplete tasks: ${failingDeps.join(', ')}`];
1248
+ }
1249
+ }
1250
+
1206
1251
  return result;
1207
1252
  }
1208
1253