roadmapsmith 0.9.8 → 0.9.10

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.10",
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;
@@ -178,7 +178,11 @@ function hasFileExtension(token) {
178
178
  }
179
179
 
180
180
  function isLikelyPath(token) {
181
- if (/^\.{1,2}\/|^\//.test(token)) return true;
181
+ if (token.includes('*') || token.includes('?')) return false; // glob/wildcard
182
+ if (/^\.{1,2}\/|^\//.test(token)) {
183
+ // Bare "/" or "./" with nothing after is not a real path (e.g. "API / ESC-POS" → "/")
184
+ return /[A-Za-z0-9_]/.test(token);
185
+ }
182
186
  if (hasFileExtension(token)) return true;
183
187
  if (KNOWN_PATH_ROOTS.some((root) => token.startsWith(root))) return true;
184
188
  // The ">= 2 slashes" rule was intentionally removed: it caused conceptual slash phrases
@@ -249,23 +253,51 @@ function collectPathishTokens(text) {
249
253
  return tokens.filter(Boolean);
250
254
  }
251
255
 
256
+ // LINE_REF_RE matches "path/file.ext:NN" or "path/file.ext:NN-MM" — indicates WHERE
257
+ // to implement, not that implementation exists. Paths matching this pattern are added
258
+ // to lineReferenceHints and excluded from hasDirectReferencePass scoring.
259
+ const LINE_REF_RE = /^(.+?):(\d+)(?:-\d+)?$/;
260
+
252
261
  function extractExplicitPaths(text) {
253
262
  const results = new Set();
263
+ const lineReferenceHints = new Set();
264
+
254
265
  const quoted = String(text).match(/`([^`]+)`/g) || [];
255
266
  for (const token of quoted) {
256
267
  const clean = token.slice(1, -1);
257
- if (clean.includes('/') || clean.includes('\\') || clean.includes('.')) {
268
+ if (clean.includes('*') || clean.includes('?')) continue; // glob
269
+ const hasSlash = clean.includes('/') || clean.includes('\\');
270
+ // Require a slash or a known file extension — rejects property access like err.message,
271
+ // fs.readFileSync, error.stack (whose extensions are not in KNOWN_FILE_EXTENSIONS).
272
+ if (!hasSlash && !hasKnownFileExtension(clean)) continue;
273
+ const lineMatch = LINE_REF_RE.exec(clean);
274
+ if (lineMatch && hasKnownFileExtension(lineMatch[1])) {
275
+ lineReferenceHints.add(lineMatch[1]);
276
+ results.add(lineMatch[1]);
277
+ } else {
258
278
  results.add(clean);
259
279
  }
260
280
  }
261
281
 
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);
282
+ for (const word of String(text).split(/\s+/)) {
283
+ if (!word.includes('/')) continue;
284
+ const token = stripTrailingPathPunctuation(word);
285
+ if (token.includes('*') || token.includes('?')) continue; // glob
286
+ const lineMatch = LINE_REF_RE.exec(token);
287
+ if (lineMatch && hasKnownFileExtension(lineMatch[1])) {
288
+ if (isLikelyPath(lineMatch[1])) {
289
+ lineReferenceHints.add(lineMatch[1]);
290
+ results.add(lineMatch[1]);
291
+ }
292
+ } else if (isLikelyPath(token)) {
293
+ results.add(token);
294
+ }
266
295
  }
267
296
 
268
- return Array.from(results).sort((left, right) => left.localeCompare(right));
297
+ const paths = Array.from(results)
298
+ .filter((p) => !p.includes('*') && !p.includes('?'))
299
+ .sort((left, right) => left.localeCompare(right));
300
+ return { paths, lineReferenceHints };
269
301
  }
270
302
 
271
303
  // Standalone filenames (no slash) mentioned in task prose — e.g. "roadmap-skill.config.json",
@@ -644,7 +676,7 @@ function extractEvidencePaths(evidenceText) {
644
676
 
645
677
  function evidenceLineHasPassingSummary(evidenceText) {
646
678
  const text = String(evidenceText || '');
647
- if (/\b\d+\s*\/\s*\d+\s+tests?\s+passing\b/i.test(text)) {
679
+ if (/\b\d+(?:\s*\/\s*\d+)?\s+tests?\s+passing\b/i.test(text)) {
648
680
  return true;
649
681
  }
650
682
  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 +1041,16 @@ function buildValidationContext(projectRoot, config, plugins) {
1009
1041
  }
1010
1042
 
1011
1043
  function validateTask(task, context, config, plugins) {
1012
- const pathHints = extractExplicitPaths(task.text);
1044
+ const { paths: pathHints, lineReferenceHints } = extractExplicitPaths(task.text);
1045
+ // Paths that are line-reference hints (file.ts:NN) indicate WHERE to implement,
1046
+ // not that implementation exists. They are excluded from hasDirectReferencePass.
1047
+ const purePathHints = pathHints.filter((p) => !lineReferenceHints.has(p));
1013
1048
  const standaloneFilenames = extractStandaloneFilenames(task.text);
1014
1049
  const symbolHints = extractSymbolHints(task.text);
1015
1050
  const authoritativeEvidence = evaluateAuthoritativeEvidence(task, context.fileIndex);
1016
1051
 
1017
1052
  const filesFromPaths = findFilesByPathHints(pathHints, context.fileIndex);
1053
+ const filesFromPurePathHints = findFilesByPathHints(purePathHints, context.fileIndex);
1018
1054
  const filesFromSymbols = findFilesBySymbols(symbolHints, context.fileIndex);
1019
1055
  // Combine path hints AND standalone filenames for token exclusion so that tokens
1020
1056
  // derived from any referenced filename (e.g. "roadmap-skill" from
@@ -1032,7 +1068,9 @@ function validateTask(task, context, config, plugins) {
1032
1068
  code: filesFromCode.length > 0 || filesFromSymbols.length > 0,
1033
1069
  test: filesFromTests.length > 0,
1034
1070
  artifact: filesFromArtifacts.length > 0,
1035
- files: filesFromPaths,
1071
+ // Use only pure path hints (not line-reference hints) so that "file.ts:169" style hints
1072
+ // — which indicate WHERE to implement — do not contribute to feature-surface scoring.
1073
+ files: filesFromPurePathHints,
1036
1074
  symbols: filesFromSymbols,
1037
1075
  codeFiles: filesFromCode,
1038
1076
  weakPathFiles: filesFromWeakPathTokens,
@@ -1049,7 +1087,9 @@ function validateTask(task, context, config, plugins) {
1049
1087
  applyAuthoritativeEvidence(evidence, authoritativeEvidence, context.fileIndex);
1050
1088
 
1051
1089
  const reasons = [];
1052
- if (pathHints.length > 0 && filesFromPaths.length === 0) {
1090
+ // Suppress path hint failures when authoritative evidence already confirms the task —
1091
+ // a bad path hint (typo, moved file) should not override a passing Evidence line.
1092
+ if (pathHints.length > 0 && filesFromPaths.length === 0 && !authoritativeEvidence.passed) {
1053
1093
  reasons.push(`missing referenced file(s): ${pathHints.join(', ')}`);
1054
1094
  }
1055
1095
  if (Array.isArray(authoritativeEvidence.reasons) && authoritativeEvidence.reasons.length > 0) {
@@ -1107,7 +1147,7 @@ function validateTask(task, context, config, plugins) {
1107
1147
  }
1108
1148
  }
1109
1149
 
1110
- if (requiresTest && !evidence.test && !authoritativeEvidence.passed) {
1150
+ if (requiresTest && !evidence.test && !authoritativeEvidence.passed && filesFromPurePathHints.length === 0) {
1111
1151
  reasons.push('missing test evidence');
1112
1152
  }
1113
1153
 
@@ -1120,7 +1160,8 @@ function validateTask(task, context, config, plugins) {
1120
1160
  const attempted = hasStrongEvidence || hasWeakEvidence || pathHints.length > 0 || symbolHints.length > 0 || authoritativeEvidence.active;
1121
1161
  const { categories: strongEvidenceCategories } = countStrongEvidenceCategories(task.text, evidence);
1122
1162
  const strongEvidenceCount = strongEvidenceCategories.length;
1123
- const hasDirectReferencePass = filesFromPaths.length > 0 || filesFromSymbols.length > 0;
1163
+ // Only pure path hints (not line-reference hints like file.ts:169) count as direct evidence.
1164
+ const hasDirectReferencePass = filesFromPurePathHints.length > 0 || filesFromSymbols.length > 0;
1124
1165
  const hasArtifactTaskPass = evidence.artifact && (
1125
1166
  isDocTask(task.text) ||
1126
1167
  evidence.heuristicArtifacts.length > 0 ||
@@ -1153,7 +1194,19 @@ function validateTask(task, context, config, plugins) {
1153
1194
  uniqueReasons = Array.from(new Set(uniqueReasons));
1154
1195
  }
1155
1196
 
1156
- let passed = authoritativeEvidence.passed || hasDirectReferencePass || hasArtifactTaskPass || hasTrustedRuleEvidencePass || meetsStrongThreshold;
1197
+ // hasDirectReferencePass is intentionally excluded: a path hint in task text indicates
1198
+ // WHERE to implement, not that implementation is done. Unchecked tasks need authoritative
1199
+ // evidence, artifact evidence, or strong code+test threshold to pass.
1200
+ // Already-checked tasks with found path hints are preserved via shouldPreserveCheckedTask.
1201
+ let passed = authoritativeEvidence.passed || hasArtifactTaskPass || hasTrustedRuleEvidencePass || meetsStrongThreshold;
1202
+
1203
+ if (!passed && !task.checked && hasDirectReferencePass) {
1204
+ const locationReason = 'file reference shows implementation location, not confirmed completion';
1205
+ if (!uniqueReasons.includes(locationReason)) {
1206
+ uniqueReasons.push(locationReason);
1207
+ }
1208
+ }
1209
+
1157
1210
  if (task.warningText && passed && !authoritativeEvidence.passed && !meetsStrongThreshold) {
1158
1211
  passed = false;
1159
1212
  uniqueReasons.push(task.warningText);
@@ -1163,15 +1216,20 @@ function validateTask(task, context, config, plugins) {
1163
1216
  passed = false;
1164
1217
  }
1165
1218
 
1219
+ // Preserve already-checked tasks when the validator can't confirm implementation but also
1220
+ // can't find strong evidence against it. Two cases:
1221
+ // 1. No path/symbol hints at all — no machine-readable claims to evaluate.
1222
+ // 2. Path hints resolve to existing files but code/test evidence is absent —
1223
+ // file presence is not implementation; don't uncheck on that alone.
1224
+ // Symbol hints are NOT preserved: a missing symbol is a concrete falsifiable claim.
1166
1225
  const shouldPreserveCheckedTask =
1167
1226
  task.checked &&
1168
1227
  !passed &&
1169
1228
  !authoritativeEvidence.active &&
1170
- pathHints.length === 0 &&
1171
1229
  symbolHints.length === 0 &&
1172
- !hasDirectReferencePass &&
1230
+ negativeSignalMatches.length === 0 &&
1173
1231
  evidence.structuralEvidence !== false &&
1174
- negativeSignalMatches.length === 0;
1232
+ (hasDirectReferencePass || purePathHints.length === 0);
1175
1233
  let preservedCheckedState = false;
1176
1234
  if (shouldPreserveCheckedTask) {
1177
1235
  passed = true;
@@ -1198,11 +1256,27 @@ function validateTask(task, context, config, plugins) {
1198
1256
  };
1199
1257
  }
1200
1258
 
1259
+ const BLOCKED_BY_RE = /\bBlocked\s+by:\s*([^\n.]+)/i;
1260
+
1201
1261
  function validateTasks(tasks, context, config, plugins) {
1202
1262
  const result = {};
1203
1263
  for (const task of tasks) {
1204
1264
  result[task.id] = validateTask(task, context, config, plugins);
1205
1265
  }
1266
+
1267
+ // Post-pass: milestone tasks with "Blocked by: id-a, id-b" cannot pass
1268
+ // while any listed dependency is still failing.
1269
+ for (const task of tasks) {
1270
+ const m = BLOCKED_BY_RE.exec(task.text);
1271
+ if (!m) continue;
1272
+ const blockedIds = m[1].split(/[\s,]+/).map((s) => s.trim()).filter(Boolean);
1273
+ const failingDeps = blockedIds.filter((id) => result[id] && !result[id].passed);
1274
+ if (failingDeps.length > 0 && result[task.id] && result[task.id].passed) {
1275
+ result[task.id].passed = false;
1276
+ result[task.id].reasons = [`blocked by incomplete tasks: ${failingDeps.join(', ')}`];
1277
+ }
1278
+ }
1279
+
1206
1280
  return result;
1207
1281
  }
1208
1282