roadmapsmith 0.9.9 → 0.9.11

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.9",
3
+ "version": "0.9.11",
4
4
  "description": "Evidence-backed ROADMAP.md generator and sync tool for AI coding agents.",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -86,6 +86,11 @@ function parseWarningLine(content) {
86
86
  return content.slice(WARNING_PREFIX.length).trim();
87
87
  }
88
88
 
89
+ function parseBlockedByLine(content) {
90
+ if (!/^blocked\s+by:/i.test(content)) return null;
91
+ return content.replace(/^blocked\s+by:\s*/i, '').trim();
92
+ }
93
+
89
94
  function parseRoadmap(content) {
90
95
  const lines = String(content || '').split(/\r?\n/);
91
96
  const managedRange = findManagedRange(lines);
@@ -111,6 +116,7 @@ function parseRoadmap(content) {
111
116
  let warningLineIndex = null;
112
117
  let warningText = null;
113
118
  const evidenceLines = [];
119
+ const blockedByIds = [];
114
120
  let lastChildLineIndex = index;
115
121
  for (let childIndex = index + 1; childIndex < lines.length; childIndex += 1) {
116
122
  const childLine = lines[childIndex];
@@ -145,6 +151,12 @@ function parseRoadmap(content) {
145
151
  warningLineIndex = childIndex;
146
152
  warningText = warningTextValue;
147
153
  }
154
+
155
+ const blockedByText = parseBlockedByLine(childBullet.content);
156
+ if (blockedByText !== null) {
157
+ const ids = blockedByText.split(/[\s,]+/).map((s) => s.trim()).filter(Boolean);
158
+ blockedByIds.push(...ids);
159
+ }
148
160
  }
149
161
 
150
162
  const id = markerId || slugify(text);
@@ -157,6 +169,7 @@ function parseRoadmap(content) {
157
169
  warningLineIndex,
158
170
  warningText,
159
171
  evidenceLines,
172
+ blockedByIds,
160
173
  markerId,
161
174
  noTest,
162
175
  indent,
@@ -50,6 +50,28 @@ const GENERIC_TASK_TOKENS = new Set([
50
50
  'phrases', 'conceptual',
51
51
  ]);
52
52
 
53
+ // Verbs that indicate the task describes work still to be done, not completed work.
54
+ // When a task starts with one of these verbs, code token overlap alone cannot pass it —
55
+ // either an Evidence line (authoritativeEvidence.passed) or test evidence is required.
56
+ const ACTION_VERBS = new Set([
57
+ 'agregar', 'mostrar', 'implementar', 'configurar', 'reemplazar', 'cambiar',
58
+ 'corregir', 'manejar', 'proteger', 'sanitizar', 'validar', 'deshabilitar',
59
+ 'generar', 'expandir', 'reducir', 'completar', 'crear', 'eliminar',
60
+ 'add', 'show', 'implement', 'configure', 'replace', 'change', 'fix',
61
+ 'handle', 'protect', 'sanitize', 'validate', 'disable', 'generate',
62
+ 'expand', 'reduce', 'complete', 'create', 'remove'
63
+ ]);
64
+
65
+ function taskStartsWithActionVerb(taskText) {
66
+ const normalized = String(taskText)
67
+ .replace(/\*\*([^*]+)\*\*/g, '$1')
68
+ .replace(/\[([^\]]+)\]/g, '$1')
69
+ .trim()
70
+ .toLowerCase();
71
+ const firstWord = normalized.split(/[\s,;:()[\]]+/)[0] || '';
72
+ return ACTION_VERBS.has(firstWord);
73
+ }
74
+
53
75
  const CANONICAL_FILES = {
54
76
  security: 'SECURITY.md',
55
77
  readme: 'README.md',
@@ -178,7 +200,11 @@ function hasFileExtension(token) {
178
200
  }
179
201
 
180
202
  function isLikelyPath(token) {
181
- if (/^\.{1,2}\/|^\//.test(token)) return true;
203
+ if (token.includes('*') || token.includes('?')) return false; // glob/wildcard
204
+ if (/^\.{1,2}\/|^\//.test(token)) {
205
+ // Bare "/" or "./" with nothing after is not a real path (e.g. "API / ESC-POS" → "/")
206
+ return /[A-Za-z0-9_]/.test(token);
207
+ }
182
208
  if (hasFileExtension(token)) return true;
183
209
  if (KNOWN_PATH_ROOTS.some((root) => token.startsWith(root))) return true;
184
210
  // The ">= 2 slashes" rule was intentionally removed: it caused conceptual slash phrases
@@ -262,14 +288,16 @@ function extractExplicitPaths(text) {
262
288
  for (const token of quoted) {
263
289
  const clean = token.slice(1, -1);
264
290
  if (clean.includes('*') || clean.includes('?')) continue; // glob
265
- if (clean.includes('/') || clean.includes('\\') || clean.includes('.')) {
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
- }
291
+ const hasSlash = clean.includes('/') || clean.includes('\\');
292
+ // Require a slash or a known file extension — rejects property access like err.message,
293
+ // fs.readFileSync, error.stack (whose extensions are not in KNOWN_FILE_EXTENSIONS).
294
+ if (!hasSlash && !hasKnownFileExtension(clean)) continue;
295
+ const lineMatch = LINE_REF_RE.exec(clean);
296
+ if (lineMatch && hasKnownFileExtension(lineMatch[1])) {
297
+ lineReferenceHints.add(lineMatch[1]);
298
+ results.add(lineMatch[1]);
299
+ } else {
300
+ results.add(clean);
273
301
  }
274
302
  }
275
303
 
@@ -288,7 +316,9 @@ function extractExplicitPaths(text) {
288
316
  }
289
317
  }
290
318
 
291
- const paths = Array.from(results).sort((left, right) => left.localeCompare(right));
319
+ const paths = Array.from(results)
320
+ .filter((p) => !p.includes('*') && !p.includes('?'))
321
+ .sort((left, right) => left.localeCompare(right));
292
322
  return { paths, lineReferenceHints };
293
323
  }
294
324
 
@@ -1060,7 +1090,9 @@ function validateTask(task, context, config, plugins) {
1060
1090
  code: filesFromCode.length > 0 || filesFromSymbols.length > 0,
1061
1091
  test: filesFromTests.length > 0,
1062
1092
  artifact: filesFromArtifacts.length > 0,
1063
- files: filesFromPaths,
1093
+ // Use only pure path hints (not line-reference hints) so that "file.ts:169" style hints
1094
+ // — which indicate WHERE to implement — do not contribute to feature-surface scoring.
1095
+ files: filesFromPurePathHints,
1064
1096
  symbols: filesFromSymbols,
1065
1097
  codeFiles: filesFromCode,
1066
1098
  weakPathFiles: filesFromWeakPathTokens,
@@ -1077,7 +1109,9 @@ function validateTask(task, context, config, plugins) {
1077
1109
  applyAuthoritativeEvidence(evidence, authoritativeEvidence, context.fileIndex);
1078
1110
 
1079
1111
  const reasons = [];
1080
- if (pathHints.length > 0 && filesFromPaths.length === 0) {
1112
+ // Suppress path hint failures when authoritative evidence already confirms the task —
1113
+ // a bad path hint (typo, moved file) should not override a passing Evidence line.
1114
+ if (pathHints.length > 0 && filesFromPaths.length === 0 && !authoritativeEvidence.passed) {
1081
1115
  reasons.push(`missing referenced file(s): ${pathHints.join(', ')}`);
1082
1116
  }
1083
1117
  if (Array.isArray(authoritativeEvidence.reasons) && authoritativeEvidence.reasons.length > 0) {
@@ -1182,8 +1216,23 @@ function validateTask(task, context, config, plugins) {
1182
1216
  uniqueReasons = Array.from(new Set(uniqueReasons));
1183
1217
  }
1184
1218
 
1185
- let passed = authoritativeEvidence.passed || hasDirectReferencePass || hasArtifactTaskPass || hasTrustedRuleEvidencePass || meetsStrongThreshold;
1186
- if (task.warningText && passed && !authoritativeEvidence.passed && !meetsStrongThreshold) {
1219
+ // hasDirectReferencePass is intentionally excluded: a path hint in task text indicates
1220
+ // WHERE to implement, not that implementation is done. Unchecked tasks need authoritative
1221
+ // evidence, artifact evidence, or strong code+test threshold to pass.
1222
+ // Already-checked tasks with found path hints are preserved via shouldPreserveCheckedTask.
1223
+ let passed = authoritativeEvidence.passed || hasArtifactTaskPass || hasTrustedRuleEvidencePass || meetsStrongThreshold;
1224
+
1225
+ if (!passed && !task.checked && hasDirectReferencePass) {
1226
+ const locationReason = 'file reference shows implementation location, not confirmed completion';
1227
+ if (!uniqueReasons.includes(locationReason)) {
1228
+ uniqueReasons.push(locationReason);
1229
+ }
1230
+ }
1231
+
1232
+ // Unchecked tasks with an existing ⚠️ warning are preserved as failing unless an Evidence line
1233
+ // explicitly confirms implementation. meetsStrongThreshold (token match) cannot override a
1234
+ // human/agent judgment that the feature is incomplete.
1235
+ if (task.warningText && !task.checked && passed && !authoritativeEvidence.passed) {
1187
1236
  passed = false;
1188
1237
  uniqueReasons.push(task.warningText);
1189
1238
  uniqueReasons = Array.from(new Set(uniqueReasons));
@@ -1192,15 +1241,43 @@ function validateTask(task, context, config, plugins) {
1192
1241
  passed = false;
1193
1242
  }
1194
1243
 
1244
+ // Action-verb gate (Causa 3): unchecked tasks whose text starts with a pending-work verb
1245
+ // (Agregar, Configurar, Add, Fix, …) cannot pass on code token overlap alone.
1246
+ // The verb signals "this is work to do", so the validator requires either:
1247
+ // a) an Evidence line confirming the work is done (authoritativeEvidence.passed), or
1248
+ // b) test evidence proving the feature is exercised, or
1249
+ // c) grant-evidence from a config rule (hasTrustedRuleEvidencePass), or
1250
+ // d) canonical artifact evidence (hasArtifactTaskPass — e.g. "Add SECURITY.md").
1251
+ if (
1252
+ !task.checked &&
1253
+ passed &&
1254
+ taskStartsWithActionVerb(task.text) &&
1255
+ !authoritativeEvidence.passed &&
1256
+ !hasTrustedRuleEvidencePass &&
1257
+ !hasArtifactTaskPass &&
1258
+ !evidence.test
1259
+ ) {
1260
+ passed = false;
1261
+ const actionVerbReason = 'action-verb task requires high-confidence evidence or Evidence line';
1262
+ if (!uniqueReasons.includes(actionVerbReason)) {
1263
+ uniqueReasons.push(actionVerbReason);
1264
+ }
1265
+ }
1266
+
1267
+ // Preserve already-checked tasks when the validator can't confirm implementation but also
1268
+ // can't find strong evidence against it. Two cases:
1269
+ // 1. No path/symbol hints at all — no machine-readable claims to evaluate.
1270
+ // 2. Path hints resolve to existing files but code/test evidence is absent —
1271
+ // file presence is not implementation; don't uncheck on that alone.
1272
+ // Symbol hints are NOT preserved: a missing symbol is a concrete falsifiable claim.
1195
1273
  const shouldPreserveCheckedTask =
1196
1274
  task.checked &&
1197
1275
  !passed &&
1198
1276
  !authoritativeEvidence.active &&
1199
- purePathHints.length === 0 &&
1200
1277
  symbolHints.length === 0 &&
1201
- !hasDirectReferencePass &&
1278
+ negativeSignalMatches.length === 0 &&
1202
1279
  evidence.structuralEvidence !== false &&
1203
- negativeSignalMatches.length === 0;
1280
+ (hasDirectReferencePass || purePathHints.length === 0);
1204
1281
  let preservedCheckedState = false;
1205
1282
  if (shouldPreserveCheckedTask) {
1206
1283
  passed = true;
@@ -1235,13 +1312,21 @@ function validateTasks(tasks, context, config, plugins) {
1235
1312
  result[task.id] = validateTask(task, context, config, plugins);
1236
1313
  }
1237
1314
 
1238
- // Post-pass: milestone tasks with "Blocked by: id-a, id-b" cannot pass
1239
- // while any listed dependency is still failing.
1315
+ // Post-pass: tasks with "Blocked by: id-a, id-b" cannot pass while any listed dependency
1316
+ // is still failing. The IDs may appear inline in task.text OR as child bullet lines
1317
+ // (parsed by parser into task.blockedByIds).
1240
1318
  for (const task of tasks) {
1319
+ const blockedIds = [];
1241
1320
  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);
1321
+ if (m) {
1322
+ blockedIds.push(...m[1].split(/[\s,]+/).map((s) => s.trim()).filter(Boolean));
1323
+ }
1324
+ if (Array.isArray(task.blockedByIds) && task.blockedByIds.length > 0) {
1325
+ blockedIds.push(...task.blockedByIds);
1326
+ }
1327
+ if (blockedIds.length === 0) continue;
1328
+ const uniqueBlockedIds = Array.from(new Set(blockedIds));
1329
+ const failingDeps = uniqueBlockedIds.filter((id) => result[id] && !result[id].passed);
1245
1330
  if (failingDeps.length > 0 && result[task.id] && result[task.id].passed) {
1246
1331
  result[task.id].passed = false;
1247
1332
  result[task.id].reasons = [`blocked by incomplete tasks: ${failingDeps.join(', ')}`];