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 +1 -1
- package/src/parser/index.js +13 -0
- package/src/validator/index.js +107 -22
package/package.json
CHANGED
package/src/parser/index.js
CHANGED
|
@@ -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,
|
package/src/validator/index.js
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1186
|
-
|
|
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
|
-
|
|
1278
|
+
negativeSignalMatches.length === 0 &&
|
|
1202
1279
|
evidence.structuralEvidence !== false &&
|
|
1203
|
-
|
|
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:
|
|
1239
|
-
//
|
|
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 (
|
|
1243
|
-
|
|
1244
|
-
|
|
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(', ')}`];
|