roadmapsmith 0.9.10 → 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.10",
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',
@@ -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,29 @@ function validateTask(task, context, config, plugins) {
1216
1241
  passed = false;
1217
1242
  }
1218
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
+
1219
1267
  // Preserve already-checked tasks when the validator can't confirm implementation but also
1220
1268
  // can't find strong evidence against it. Two cases:
1221
1269
  // 1. No path/symbol hints at all — no machine-readable claims to evaluate.
@@ -1264,13 +1312,21 @@ function validateTasks(tasks, context, config, plugins) {
1264
1312
  result[task.id] = validateTask(task, context, config, plugins);
1265
1313
  }
1266
1314
 
1267
- // Post-pass: milestone tasks with "Blocked by: id-a, id-b" cannot pass
1268
- // 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).
1269
1318
  for (const task of tasks) {
1319
+ const blockedIds = [];
1270
1320
  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);
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);
1274
1330
  if (failingDeps.length > 0 && result[task.id] && result[task.id].passed) {
1275
1331
  result[task.id].passed = false;
1276
1332
  result[task.id].reasons = [`blocked by incomplete tasks: ${failingDeps.join(', ')}`];