roadmapsmith 0.9.10 → 0.9.12

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.10",
3
+ "version": "0.9.12",
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,25 @@ const GENERIC_TASK_TOKENS = new Set([
50
50
  'phrases', 'conceptual',
51
51
  ]);
52
52
 
53
+ // Patterns that indicate the task describes work still to be done, not completed work.
54
+ // Regex form catches verb and noun forms ("Manejo") and two-word constructions ("Recovery path")
55
+ // that an exact-match Set would miss. When a task matches, code token overlap alone cannot pass
56
+ // it — either an Evidence line or high-confidence evidence (code + test) is required.
57
+ const CHANGE_VERB_PATTERNS = [
58
+ // Spanish — verb and noun forms of pending-work descriptions
59
+ /^(agregar|añadir|implementar|configurar|reemplazar|cambiar|corregir|manejar|manejo|proteger|sanitizar|validar|deshabilitar|mostrar|generar|expandir|reducir|completar|crear|eliminar|migrar|refactorizar|recovery\s+path)\b/i,
60
+ // English
61
+ /^(add|implement|configure|replace|change|fix|handle|protect|sanitize|validate|disable|show|generate|expand|reduce|complete|create|remove|migrate|refactor|recovery\s+path)\b/i,
62
+ ];
63
+
64
+ function taskDescribesChange(taskText) {
65
+ const normalized = String(taskText)
66
+ .replace(/^\*\*\[.*?\]\*\*\s*/, '')
67
+ .replace(/^\[.*?\]\s*/, '')
68
+ .trim();
69
+ return CHANGE_VERB_PATTERNS.some((p) => p.test(normalized));
70
+ }
71
+
53
72
  const CANONICAL_FILES = {
54
73
  security: 'SECURITY.md',
55
74
  readme: 'README.md',
@@ -179,6 +198,7 @@ function hasFileExtension(token) {
179
198
 
180
199
  function isLikelyPath(token) {
181
200
  if (token.includes('*') || token.includes('?')) return false; // glob/wildcard
201
+ if (/^\/api\//i.test(token)) return false; // HTTP API route paths are not file paths
182
202
  if (/^\.{1,2}\/|^\//.test(token)) {
183
203
  // Bare "/" or "./" with nothing after is not a real path (e.g. "API / ESC-POS" → "/")
184
204
  return /[A-Za-z0-9_]/.test(token);
@@ -1173,9 +1193,11 @@ function validateTask(task, context, config, plugins) {
1173
1193
  let confidence = 'low';
1174
1194
  if (authoritativeEvidence.passed) {
1175
1195
  confidence = authoritativeEvidence.confidence || 'medium';
1176
- } else if (meetsStrongThreshold) {
1196
+ } else if (meetsStrongThreshold && evidence.test) {
1197
+ // 'high' requires code + test — code + feature-surface alone is 'medium'
1198
+ // so that action-verb tasks (path hint exists but no test) stay gated.
1177
1199
  confidence = 'high';
1178
- } else if (strongEvidenceCount === 1 || hasDirectReferencePass || hasArtifactTaskPass || hasTrustedRuleEvidencePass) {
1200
+ } else if (meetsStrongThreshold || strongEvidenceCount === 1 || hasDirectReferencePass || hasArtifactTaskPass || hasTrustedRuleEvidencePass) {
1179
1201
  confidence = 'medium';
1180
1202
  }
1181
1203
 
@@ -1207,7 +1229,10 @@ function validateTask(task, context, config, plugins) {
1207
1229
  }
1208
1230
  }
1209
1231
 
1210
- if (task.warningText && passed && !authoritativeEvidence.passed && !meetsStrongThreshold) {
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) {
1211
1236
  passed = false;
1212
1237
  uniqueReasons.push(task.warningText);
1213
1238
  uniqueReasons = Array.from(new Set(uniqueReasons));
@@ -1216,6 +1241,27 @@ function validateTask(task, context, config, plugins) {
1216
1241
  passed = false;
1217
1242
  }
1218
1243
 
1244
+ // Action-verb gate (Causa 3): unchecked tasks that describe a change to be made
1245
+ // (Agregar, Configurar, Add, Fix, Manejo, Recovery path, …) cannot pass on code token overlap alone.
1246
+ // Requires either: an Evidence line (authoritativeEvidence.passed), high-confidence evidence
1247
+ // (code + test), grant-evidence from config (hasTrustedRuleEvidencePass), or canonical artifact
1248
+ // evidence (hasArtifactTaskPass — e.g. "Add SECURITY.md").
1249
+ if (
1250
+ !task.checked &&
1251
+ passed &&
1252
+ taskDescribesChange(task.text) &&
1253
+ !authoritativeEvidence.passed &&
1254
+ !hasTrustedRuleEvidencePass &&
1255
+ !hasArtifactTaskPass &&
1256
+ confidence !== 'high'
1257
+ ) {
1258
+ passed = false;
1259
+ const actionVerbReason = 'action task requires Evidence line or high-confidence evidence (code + test) to be marked complete';
1260
+ if (!uniqueReasons.includes(actionVerbReason)) {
1261
+ uniqueReasons.push(actionVerbReason);
1262
+ }
1263
+ }
1264
+
1219
1265
  // Preserve already-checked tasks when the validator can't confirm implementation but also
1220
1266
  // can't find strong evidence against it. Two cases:
1221
1267
  // 1. No path/symbol hints at all — no machine-readable claims to evaluate.
@@ -1264,13 +1310,21 @@ function validateTasks(tasks, context, config, plugins) {
1264
1310
  result[task.id] = validateTask(task, context, config, plugins);
1265
1311
  }
1266
1312
 
1267
- // Post-pass: milestone tasks with "Blocked by: id-a, id-b" cannot pass
1268
- // while any listed dependency is still failing.
1313
+ // Post-pass: tasks with "Blocked by: id-a, id-b" cannot pass while any listed dependency
1314
+ // is still failing. The IDs may appear inline in task.text OR as child bullet lines
1315
+ // (parsed by parser into task.blockedByIds).
1269
1316
  for (const task of tasks) {
1317
+ const blockedIds = [];
1270
1318
  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);
1319
+ if (m) {
1320
+ blockedIds.push(...m[1].split(/[\s,]+/).map((s) => s.trim()).filter(Boolean));
1321
+ }
1322
+ if (Array.isArray(task.blockedByIds) && task.blockedByIds.length > 0) {
1323
+ blockedIds.push(...task.blockedByIds);
1324
+ }
1325
+ if (blockedIds.length === 0) continue;
1326
+ const uniqueBlockedIds = Array.from(new Set(blockedIds));
1327
+ const failingDeps = uniqueBlockedIds.filter((id) => result[id] && !result[id].passed);
1274
1328
  if (failingDeps.length > 0 && result[task.id] && result[task.id].passed) {
1275
1329
  result[task.id].passed = false;
1276
1330
  result[task.id].reasons = [`blocked by incomplete tasks: ${failingDeps.join(', ')}`];