roadmapsmith 0.9.7 → 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.7",
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)) {
@@ -800,9 +824,6 @@ function findNegativeImplementationSignals(candidatePaths, fileIndex) {
800
824
 
801
825
  const indexedFiles = new Map(fileIndex.map((file) => [file.relativePath, file]));
802
826
  const negativeSignals = [
803
- /\bTODO\b/i,
804
- /\bFIXME\b/i,
805
- /\bdisabled\b/i,
806
827
  /\bnot implemented\b/i,
807
828
  /throw\s+new\s+Error\s*\(\s*['"`][^'"`]*not implemented/i
808
829
  ];
@@ -1012,12 +1033,16 @@ function buildValidationContext(projectRoot, config, plugins) {
1012
1033
  }
1013
1034
 
1014
1035
  function validateTask(task, context, config, plugins) {
1015
- 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));
1016
1040
  const standaloneFilenames = extractStandaloneFilenames(task.text);
1017
1041
  const symbolHints = extractSymbolHints(task.text);
1018
1042
  const authoritativeEvidence = evaluateAuthoritativeEvidence(task, context.fileIndex);
1019
1043
 
1020
1044
  const filesFromPaths = findFilesByPathHints(pathHints, context.fileIndex);
1045
+ const filesFromPurePathHints = findFilesByPathHints(purePathHints, context.fileIndex);
1021
1046
  const filesFromSymbols = findFilesBySymbols(symbolHints, context.fileIndex);
1022
1047
  // Combine path hints AND standalone filenames for token exclusion so that tokens
1023
1048
  // derived from any referenced filename (e.g. "roadmap-skill" from
@@ -1110,7 +1135,7 @@ function validateTask(task, context, config, plugins) {
1110
1135
  }
1111
1136
  }
1112
1137
 
1113
- if (requiresTest && !evidence.test && !authoritativeEvidence.passed) {
1138
+ if (requiresTest && !evidence.test && !authoritativeEvidence.passed && filesFromPurePathHints.length === 0) {
1114
1139
  reasons.push('missing test evidence');
1115
1140
  }
1116
1141
 
@@ -1123,7 +1148,8 @@ function validateTask(task, context, config, plugins) {
1123
1148
  const attempted = hasStrongEvidence || hasWeakEvidence || pathHints.length > 0 || symbolHints.length > 0 || authoritativeEvidence.active;
1124
1149
  const { categories: strongEvidenceCategories } = countStrongEvidenceCategories(task.text, evidence);
1125
1150
  const strongEvidenceCount = strongEvidenceCategories.length;
1126
- 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;
1127
1153
  const hasArtifactTaskPass = evidence.artifact && (
1128
1154
  isDocTask(task.text) ||
1129
1155
  evidence.heuristicArtifacts.length > 0 ||
@@ -1166,6 +1192,23 @@ function validateTask(task, context, config, plugins) {
1166
1192
  passed = false;
1167
1193
  }
1168
1194
 
1195
+ const shouldPreserveCheckedTask =
1196
+ task.checked &&
1197
+ !passed &&
1198
+ !authoritativeEvidence.active &&
1199
+ purePathHints.length === 0 &&
1200
+ symbolHints.length === 0 &&
1201
+ !hasDirectReferencePass &&
1202
+ evidence.structuralEvidence !== false &&
1203
+ negativeSignalMatches.length === 0;
1204
+ let preservedCheckedState = false;
1205
+ if (shouldPreserveCheckedTask) {
1206
+ passed = true;
1207
+ confidence = 'low';
1208
+ uniqueReasons = [];
1209
+ preservedCheckedState = true;
1210
+ }
1211
+
1169
1212
  // True when the only passing evidence is artifact/doc files and the task is not a doc task.
1170
1213
  // Used by auditValidation to flag implementation tasks that pass solely via documentation.
1171
1214
  const evidenceIsDocOnly = !evidence.code && !evidence.test && evidence.artifact && !isDocTask(task.text);
@@ -1179,15 +1222,32 @@ function validateTask(task, context, config, plugins) {
1179
1222
  evidenceIsDocOnly,
1180
1223
  requiresTest,
1181
1224
  hasEvidence: hasStrongEvidence || hasWeakEvidence,
1182
- attempted
1225
+ attempted,
1226
+ preservedCheckedState
1183
1227
  };
1184
1228
  }
1185
1229
 
1230
+ const BLOCKED_BY_RE = /\bBlocked\s+by:\s*([^\n.]+)/i;
1231
+
1186
1232
  function validateTasks(tasks, context, config, plugins) {
1187
1233
  const result = {};
1188
1234
  for (const task of tasks) {
1189
1235
  result[task.id] = validateTask(task, context, config, plugins);
1190
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
+
1191
1251
  return result;
1192
1252
  }
1193
1253
 
@@ -1237,6 +1297,9 @@ function applyMinimumConfidence(results, minimumConfidence) {
1237
1297
  const minRank = CONFIDENCE_RANK[minimumConfidence] ?? 0;
1238
1298
  if (minRank === 0) return;
1239
1299
  for (const result of Object.values(results)) {
1300
+ if (result.preservedCheckedState) {
1301
+ continue;
1302
+ }
1240
1303
  if ((CONFIDENCE_RANK[result.confidence] ?? 0) < minRank) {
1241
1304
  result.passed = false;
1242
1305
  result.reasons = [