@planu/cli 5.3.42 → 5.3.44
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/CHANGELOG.md +19 -0
- package/dist/config/minimal-implementation-gate.json +31 -0
- package/dist/config/pii-patterns.json +31 -0
- package/dist/engine/minimality/analyzer.js +43 -1
- package/dist/engine/pii-detector/core.js +71 -10
- package/dist/engine/readiness-checker.js +21 -7
- package/dist/engine/scope-boundaries/contradiction-checker.js +1 -1
- package/dist/engine/scope-boundaries/scope-validator.js +2 -0
- package/dist/engine/spec-quality-scorer.js +7 -4
- package/dist/engine/validator/analyzer.js +63 -2
- package/dist/tools/check-readiness.js +36 -2
- package/dist/tools/response-helpers.js +20 -1
- package/dist/types/minimal-implementation-gate.d.ts +5 -0
- package/dist/types/privacy.d.ts +9 -0
- package/package.json +1 -1
- package/planu-plugin.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,22 @@
|
|
|
1
|
+
## [5.3.44] - 2026-08-24
|
|
2
|
+
|
|
3
|
+
### Bug Fixes
|
|
4
|
+
- fix(spec-1596): align check-readiness validator test double with getSpecFresh
|
|
5
|
+
- fix(spec-1594): gate example-only PII identifiers behind boundary-aware collection cue
|
|
6
|
+
- fix(spec-1593): gate suppress-family verbs so no-op consequence criteria are not misflagged
|
|
7
|
+
- fix(spec-1595): make strict readiness report reflect edited spec body content
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
## [5.3.43] - 2026-08-24
|
|
11
|
+
|
|
12
|
+
### Bug Fixes
|
|
13
|
+
- fix(spec-1293): gate minimality install-command patterns by provenance/negation context
|
|
14
|
+
- fix(spec-1277): match PII vocabulary by identifier segments and exact equality
|
|
15
|
+
- fix(spec-1225): make formatSuccess idempotent to prevent double-wrapped success titles
|
|
16
|
+
- fix(spec-1299): scan only comment tokens for debt markers, ignore string literals
|
|
17
|
+
- fix(spec-1298): exclude generic test/path tokens from scope-boundary matching
|
|
18
|
+
|
|
19
|
+
|
|
1
20
|
## [5.3.42] - 2026-08-23
|
|
2
21
|
|
|
3
22
|
### Bug Fixes
|
|
@@ -114,5 +114,36 @@
|
|
|
114
114
|
"excludedPathPatterns": ["dist/**", "coverage/**", "node_modules/**", "pnpm-lock.yaml", "*.snap"],
|
|
115
115
|
"debtEvidence": {
|
|
116
116
|
"requiredFields": ["specId", "files", "ceiling", "upgradeTrigger", "reviewRationale"]
|
|
117
|
+
},
|
|
118
|
+
"provenance": {
|
|
119
|
+
"nonProposingSectionKeywords": [
|
|
120
|
+
"problem",
|
|
121
|
+
"reproducer",
|
|
122
|
+
"observed",
|
|
123
|
+
"expected",
|
|
124
|
+
"goal",
|
|
125
|
+
"user outcome",
|
|
126
|
+
"out of scope",
|
|
127
|
+
"non-goal",
|
|
128
|
+
"forbidden",
|
|
129
|
+
"anticipated test break",
|
|
130
|
+
"edge case",
|
|
131
|
+
"failure mode",
|
|
132
|
+
"challenge resolution"
|
|
133
|
+
],
|
|
134
|
+
"negationCues": [
|
|
135
|
+
"do not",
|
|
136
|
+
"don't",
|
|
137
|
+
"must not",
|
|
138
|
+
"no new",
|
|
139
|
+
"add no",
|
|
140
|
+
"without adding",
|
|
141
|
+
"prohibit",
|
|
142
|
+
"no dependency",
|
|
143
|
+
"not add",
|
|
144
|
+
"never add",
|
|
145
|
+
"avoid adding",
|
|
146
|
+
"reuse "
|
|
147
|
+
]
|
|
117
148
|
}
|
|
118
149
|
}
|
|
@@ -170,6 +170,37 @@
|
|
|
170
170
|
"Tenant",
|
|
171
171
|
"Visitor"
|
|
172
172
|
],
|
|
173
|
+
"collectionCueVerbs": [
|
|
174
|
+
"collect",
|
|
175
|
+
"store",
|
|
176
|
+
"process",
|
|
177
|
+
"persist",
|
|
178
|
+
"save",
|
|
179
|
+
"record",
|
|
180
|
+
"provide",
|
|
181
|
+
"must have",
|
|
182
|
+
"includ",
|
|
183
|
+
"contain",
|
|
184
|
+
"track",
|
|
185
|
+
"log",
|
|
186
|
+
"updat",
|
|
187
|
+
"requir",
|
|
188
|
+
"verif",
|
|
189
|
+
"authenticat",
|
|
190
|
+
"scan"
|
|
191
|
+
],
|
|
192
|
+
"provenance": {
|
|
193
|
+
"nonAffirmingSectionKeywords": [
|
|
194
|
+
"example",
|
|
195
|
+
"examples",
|
|
196
|
+
"reproducer",
|
|
197
|
+
"test fixture",
|
|
198
|
+
"test fixtures",
|
|
199
|
+
"vocabulary",
|
|
200
|
+
"out of scope",
|
|
201
|
+
"out-of-scope"
|
|
202
|
+
]
|
|
203
|
+
},
|
|
173
204
|
"frameworks": {
|
|
174
205
|
"GDPR": {
|
|
175
206
|
"defaultRetentionDays": 730,
|
|
@@ -24,6 +24,33 @@ function trimEvidence(value, maxChars) {
|
|
|
24
24
|
function lineForMatch(content, index) {
|
|
25
25
|
return content.slice(0, index).split('\n').length;
|
|
26
26
|
}
|
|
27
|
+
function lineTextAt(content, index) {
|
|
28
|
+
const start = content.lastIndexOf('\n', index - 1) + 1;
|
|
29
|
+
const stop = content.indexOf('\n', index);
|
|
30
|
+
return content.slice(start, stop < 0 ? content.length : stop);
|
|
31
|
+
}
|
|
32
|
+
function sectionHeadingForIndex(content, index) {
|
|
33
|
+
const linesBefore = content.slice(0, index).split('\n');
|
|
34
|
+
for (let i = linesBefore.length - 1; i >= 0; i -= 1) {
|
|
35
|
+
const line = linesBefore[i] ?? '';
|
|
36
|
+
if (/^#{1,6}\s/.test(line)) {
|
|
37
|
+
return line
|
|
38
|
+
.replace(/^#{1,6}\s+/, '')
|
|
39
|
+
.trim()
|
|
40
|
+
.toLowerCase();
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
function isNonProposingContext(content, index, provenance) {
|
|
46
|
+
const heading = sectionHeadingForIndex(content, index);
|
|
47
|
+
if (heading !== null &&
|
|
48
|
+
provenance.nonProposingSectionKeywords.some((keyword) => heading.includes(keyword.toLowerCase()))) {
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
const line = lineTextAt(content, index).toLowerCase();
|
|
52
|
+
return provenance.negationCues.some((cue) => line.includes(cue.toLowerCase()));
|
|
53
|
+
}
|
|
27
54
|
function ruleBlocksDone(rule, specRisk) {
|
|
28
55
|
if (rule.severity !== 'blocker') {
|
|
29
56
|
return false;
|
|
@@ -36,9 +63,24 @@ function ruleBlocksDone(rule, specRisk) {
|
|
|
36
63
|
function makeEvidence(input, rule) {
|
|
37
64
|
return [...(input.evidence ?? []), { source: 'policy', key: rule.id }];
|
|
38
65
|
}
|
|
66
|
+
function findProposingMatchIndex(content, pattern, provenance) {
|
|
67
|
+
const lowerContent = content.toLowerCase();
|
|
68
|
+
const lowerPattern = pattern.toLowerCase();
|
|
69
|
+
let searchFrom = 0;
|
|
70
|
+
for (;;) {
|
|
71
|
+
const index = lowerContent.indexOf(lowerPattern, searchFrom);
|
|
72
|
+
if (index < 0) {
|
|
73
|
+
return -1;
|
|
74
|
+
}
|
|
75
|
+
if (provenance === undefined || !isNonProposingContext(content, index, provenance)) {
|
|
76
|
+
return index;
|
|
77
|
+
}
|
|
78
|
+
searchFrom = index + 1;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
39
81
|
function scanText(args) {
|
|
40
82
|
for (const pattern of args.rule.patterns) {
|
|
41
|
-
const index = args.content.
|
|
83
|
+
const index = findProposingMatchIndex(args.content, pattern, args.policy.provenance);
|
|
42
84
|
if (index < 0) {
|
|
43
85
|
continue;
|
|
44
86
|
}
|
|
@@ -13,13 +13,31 @@ function normalizeField(name) {
|
|
|
13
13
|
return name.toLowerCase().replace(/[_\-\s.]/g, '');
|
|
14
14
|
}
|
|
15
15
|
/**
|
|
16
|
-
*
|
|
17
|
-
*
|
|
16
|
+
* Splits an identifier into its snake/kebab/dotted/camelCase segments,
|
|
17
|
+
* lowercased and deduplicated, preserving the original boundaries.
|
|
18
18
|
*/
|
|
19
|
-
function
|
|
19
|
+
function splitIdentifierSegments(value) {
|
|
20
|
+
const withCamelBoundaries = value.replace(/([a-z0-9])([A-Z])/g, '$1 $2');
|
|
21
|
+
const segments = new Set();
|
|
22
|
+
for (const part of withCamelBoundaries.split(/[_\-\s.]+/)) {
|
|
23
|
+
const lower = part.toLowerCase();
|
|
24
|
+
if (lower.length > 0) {
|
|
25
|
+
segments.add(lower);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return Array.from(segments);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Determines if a candidate identifier matches any PII vocabulary term.
|
|
32
|
+
* A term matches only the full normalized identifier or one of its
|
|
33
|
+
* segments, by equality — never by containment.
|
|
34
|
+
*/
|
|
35
|
+
function matchVocabulary(candidate) {
|
|
36
|
+
const full = normalizeField(candidate);
|
|
37
|
+
const segments = splitIdentifierSegments(candidate);
|
|
20
38
|
for (const [category, terms] of Object.entries(patterns.vocabulary)) {
|
|
21
39
|
for (const term of terms) {
|
|
22
|
-
if (
|
|
40
|
+
if (term === full || segments.includes(term)) {
|
|
23
41
|
return category;
|
|
24
42
|
}
|
|
25
43
|
}
|
|
@@ -41,8 +59,49 @@ function isWhitelistedContext(modelContext) {
|
|
|
41
59
|
function hasFieldCue(contextWindow) {
|
|
42
60
|
return /\b(field|fields|column|columns|property|properties|attribute|schema|model|entity|table|class|interface)\b/i.test(contextWindow);
|
|
43
61
|
}
|
|
62
|
+
const COLLECTION_CUE_SUFFIXES = '(?:e|es|ed|ing|ion|ies|ied|ying|y|s)?';
|
|
63
|
+
function escapeRegexLiteral(value) {
|
|
64
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
65
|
+
}
|
|
66
|
+
function toCollectionCueBase(verb) {
|
|
67
|
+
return verb.endsWith('e') ? verb.slice(0, -1) : verb;
|
|
68
|
+
}
|
|
69
|
+
function buildCollectionCueRegex(verbs) {
|
|
70
|
+
const alternatives = verbs.flatMap((verb) => {
|
|
71
|
+
if (verb.includes(' ')) {
|
|
72
|
+
return [escapeRegexLiteral(verb)];
|
|
73
|
+
}
|
|
74
|
+
const base = toCollectionCueBase(verb);
|
|
75
|
+
const alts = [escapeRegexLiteral(base) + COLLECTION_CUE_SUFFIXES];
|
|
76
|
+
if (base === verb) {
|
|
77
|
+
const lastChar = base.slice(-1);
|
|
78
|
+
alts.push(escapeRegexLiteral(base + lastChar) + '(?:ed|ing)');
|
|
79
|
+
}
|
|
80
|
+
return alts;
|
|
81
|
+
});
|
|
82
|
+
return new RegExp(`\\b(?:${alternatives.join('|')})\\b`, 'i');
|
|
83
|
+
}
|
|
84
|
+
const collectionCueRegex = buildCollectionCueRegex(patterns.collectionCueVerbs);
|
|
44
85
|
function hasCollectionCue(contextWindow) {
|
|
45
|
-
return
|
|
86
|
+
return collectionCueRegex.test(contextWindow);
|
|
87
|
+
}
|
|
88
|
+
const HEADING_LINE_RE = /^#{1,6}\s+.+$/gm;
|
|
89
|
+
function isNonAffirmingSection(text, index) {
|
|
90
|
+
const keywords = patterns.provenance?.nonAffirmingSectionKeywords ?? [];
|
|
91
|
+
if (keywords.length === 0) {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
HEADING_LINE_RE.lastIndex = 0;
|
|
95
|
+
let lastHeading = '';
|
|
96
|
+
let match;
|
|
97
|
+
while ((match = HEADING_LINE_RE.exec(text)) !== null) {
|
|
98
|
+
if (match.index > index) {
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
lastHeading = match[0];
|
|
102
|
+
}
|
|
103
|
+
const lowerHeading = lastHeading.toLowerCase();
|
|
104
|
+
return keywords.some((keyword) => lowerHeading.includes(keyword.toLowerCase()));
|
|
46
105
|
}
|
|
47
106
|
/**
|
|
48
107
|
* Determines if a detected PII field is a GDPR Art. 9 special category.
|
|
@@ -97,15 +156,17 @@ function extractCandidates(text) {
|
|
|
97
156
|
if (word.length < 3) {
|
|
98
157
|
continue;
|
|
99
158
|
}
|
|
100
|
-
if (matchVocabulary(
|
|
159
|
+
if (matchVocabulary(word) === null) {
|
|
101
160
|
continue;
|
|
102
161
|
}
|
|
103
162
|
const wordIndex = lower.indexOf(word.toLowerCase());
|
|
104
163
|
const contextWindow = cleaned.substring(Math.max(0, wordIndex - 100), wordIndex + 100);
|
|
105
164
|
const context = inferModelContext(contextWindow);
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
(
|
|
165
|
+
const isBoundToCollection = hasCollectionCue(contextWindow) && !isNonAffirmingSection(cleaned, wordIndex);
|
|
166
|
+
if (isBoundToCollection &&
|
|
167
|
+
(isWhitelistedContext(context) ||
|
|
168
|
+
(hasFieldCue(contextWindow) && !isBlacklistedContext(contextWindow)) ||
|
|
169
|
+
(hasCollectionCue(contextWindow) && isWhitelistedContext(contextWindow)))) {
|
|
109
170
|
candidates.push({ field: word, context });
|
|
110
171
|
}
|
|
111
172
|
}
|
|
@@ -127,7 +188,7 @@ export function detectPIIInSpec(specTitle, specDescription, criteria) {
|
|
|
127
188
|
const seen = new Set();
|
|
128
189
|
for (const { field, context } of candidates) {
|
|
129
190
|
const normalized = normalizeField(field);
|
|
130
|
-
const category = matchVocabulary(
|
|
191
|
+
const category = matchVocabulary(field);
|
|
131
192
|
if (!category) {
|
|
132
193
|
continue;
|
|
133
194
|
}
|
|
@@ -86,12 +86,24 @@ export function extractCriteriaLines(huContent) {
|
|
|
86
86
|
const lines = huContent.split('\n');
|
|
87
87
|
const criteria = [];
|
|
88
88
|
let inCriteriaSection = false;
|
|
89
|
+
let criteriaHeadingLevel = 0;
|
|
89
90
|
for (const raw of lines) {
|
|
90
91
|
const line = raw.trim();
|
|
91
|
-
// Detect entering / leaving an Acceptance Criteria section
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
92
|
+
// Detect entering / leaving an Acceptance Criteria section. A heading
|
|
93
|
+
// deeper than the criteria heading (e.g. `### Scenario` nested under
|
|
94
|
+
// `## Acceptance Criteria`) stays inside the section — only a heading
|
|
95
|
+
// at the same or shallower level closes it.
|
|
96
|
+
const headingMatch = /^(#{1,6})\s+/.exec(line);
|
|
97
|
+
if (headingMatch?.[1] !== undefined) {
|
|
98
|
+
const level = headingMatch[1].length;
|
|
99
|
+
const isCriteriaHeading = /^#{1,6}\s+(acceptance\s+criteria|criteria|criterios\s+de\s+aceptaci[oó]n|criterios)\b/i.test(line);
|
|
100
|
+
if (isCriteriaHeading) {
|
|
101
|
+
inCriteriaSection = true;
|
|
102
|
+
criteriaHeadingLevel = level;
|
|
103
|
+
}
|
|
104
|
+
else if (level <= criteriaHeadingLevel) {
|
|
105
|
+
inCriteriaSection = false;
|
|
106
|
+
}
|
|
95
107
|
continue;
|
|
96
108
|
}
|
|
97
109
|
// Form 1 — checkbox (always counts, regardless of section)
|
|
@@ -349,10 +361,12 @@ function checkSpecificityGate(spec, criteriaLines, fichaContent, anticipatedTest
|
|
|
349
361
|
if (!hasFunctionName && !hasTypeName) {
|
|
350
362
|
blockers.push(`Difficulty ${String(spec.difficulty)} spec requires at least 1 criterion with an exact function name (e.g. createFoo()) or type name (e.g. FooBar).`);
|
|
351
363
|
}
|
|
352
|
-
|
|
364
|
+
const anticipatedTestBreaksHasTestPathWithNumber = /\d/.test(anticipatedTestBreaksContent) &&
|
|
365
|
+
[...extractFilePaths(anticipatedTestBreaksContent)].some((path) => /\.test\.[a-z0-9]+$/i.test(path));
|
|
353
366
|
const hasTestBreak = /\btests?\/[a-zA-Z0-9/_.-]+\.test\.[a-z]+.*\bline\s+~?\d+/i.test(allText) ||
|
|
354
|
-
/\
|
|
355
|
-
/\btests\/[a-zA-Z0-9/_.-]+\.test\.[a-z0-9]+(?![a-zA-Z0-9_.\\/-])[^\n]*\bline\s+~?\d+/i.test(anticipatedTestBreaksContent)
|
|
367
|
+
/\bchanges?\b.*\bfrom\b.*\bto\b/i.test(allText) ||
|
|
368
|
+
/\btests\/[a-zA-Z0-9/_.-]+\.test\.[a-z0-9]+(?![a-zA-Z0-9_.\\/-])[^\n]*\bline\s+~?\d+/i.test(anticipatedTestBreaksContent) ||
|
|
369
|
+
anticipatedTestBreaksHasTestPathWithNumber;
|
|
356
370
|
if (!hasTestBreak) {
|
|
357
371
|
blockers.push(`Difficulty ${String(spec.difficulty)} spec requires at least 1 anticipated test break ` +
|
|
358
372
|
`(e.g. 'tests/api/foo.test.ts line 47: change count from 477 to 478').`);
|
|
@@ -100,7 +100,7 @@ function splitClauses(text) {
|
|
|
100
100
|
.map((clause) => clause.trim())
|
|
101
101
|
.filter(Boolean);
|
|
102
102
|
}
|
|
103
|
-
const AFFIRMATIVE_ACTION_VERBS = /\b(?:build(?:s|ing)?|chang(?:e|es|ing)|modif(?:y|ies|ying)|alter(?:s|ing)?|add(?:s|ing)?|introduc(?:e|es|ing)|implement(?:s|ing)?|enabl(?:e|es|ing)|expos(?:e|es|ing|ed)|mutat(?:e|es|ing)|rewrit(?:e|es|ing|ten)|remov(?:e|es|ing|ed)|relax(?:es|ing|ed)?|forc(?:e|es|ing))\b/;
|
|
103
|
+
const AFFIRMATIVE_ACTION_VERBS = /\b(?:build(?:s|ing)?|chang(?:e|es|ing)|modif(?:y|ies|ying)|alter(?:s|ing)?|add(?:s|ing)?|introduc(?:e|es|ing)|implement(?:s|ing)?|enabl(?:e|es|ing)|expos(?:e|es|ing|ed)|mutat(?:e|es|ing)|rewrit(?:e|es|ing|ten)|remov(?:e|es|ing|ed)|relax(?:es|ing|ed)?|forc(?:e|es|ing)|suppress(?:es|ing|ed)?)\b/;
|
|
104
104
|
function assertsForbiddenAction(text) {
|
|
105
105
|
return AFFIRMATIVE_ACTION_VERBS.test(normalize(text));
|
|
106
106
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// engine/spec-quality-scorer.ts — Spec quality scoring logic (SPEC-314)
|
|
2
2
|
import { readFile } from 'node:fs/promises';
|
|
3
|
-
import { readSpecTechnicalSection } from './spec-format/read-technical-section.js';
|
|
3
|
+
import { extractSectionBody, readSpecTechnicalSection, } from './spec-format/read-technical-section.js';
|
|
4
4
|
import { extractAcceptanceCriteriaTexts } from './spec-format/acceptance-criteria.js';
|
|
5
5
|
import { stripFrontmatter } from './frontmatter-parser.js';
|
|
6
6
|
// ── Constants ────────────────────────────────────────────────────────────────
|
|
@@ -327,7 +327,7 @@ function scoreAmbiguity(content, criteriaLines) {
|
|
|
327
327
|
recommendations,
|
|
328
328
|
};
|
|
329
329
|
}
|
|
330
|
-
function scoreRisk(spec, technicalContent) {
|
|
330
|
+
function scoreRisk(spec, technicalContent, specBody) {
|
|
331
331
|
const issues = [];
|
|
332
332
|
const recommendations = [];
|
|
333
333
|
let score = MAX_DIMENSION_SCORE;
|
|
@@ -364,8 +364,11 @@ function scoreRisk(spec, technicalContent) {
|
|
|
364
364
|
'throughput',
|
|
365
365
|
'sla',
|
|
366
366
|
'nfr',
|
|
367
|
+
'non-functional',
|
|
367
368
|
];
|
|
368
|
-
const
|
|
369
|
+
const hasNfrKeyword = nfrKeywords.some((k) => lowerTech.includes(k));
|
|
370
|
+
const hasNfrSubsection = extractSectionBody(specBody, 'Non-Functional Requirements').trim().length > 0;
|
|
371
|
+
const hasNfrs = hasNfrKeyword || hasNfrSubsection;
|
|
369
372
|
if (!hasNfrs) {
|
|
370
373
|
score -= 5;
|
|
371
374
|
issues.push('No non-functional requirements found in technical spec');
|
|
@@ -401,7 +404,7 @@ export async function scoreSpecQuality(spec) {
|
|
|
401
404
|
const completenessDetail = scoreCompleteness(spec, specContent);
|
|
402
405
|
const testabilityDetail = scoreTestability(criteriaLines);
|
|
403
406
|
const ambiguityDetail = scoreAmbiguity(specContent, criteriaLines);
|
|
404
|
-
const riskDetail = scoreRisk(spec, technicalContent);
|
|
407
|
+
const riskDetail = scoreRisk(spec, technicalContent, specContent);
|
|
405
408
|
const total = completenessDetail.score + testabilityDetail.score + ambiguityDetail.score + riskDetail.score;
|
|
406
409
|
const qualityScore = {
|
|
407
410
|
total,
|
|
@@ -300,6 +300,66 @@ export function classifyDriftSeverity(criterion) {
|
|
|
300
300
|
}
|
|
301
301
|
return 'low';
|
|
302
302
|
}
|
|
303
|
+
function commentSyntaxFor(ext) {
|
|
304
|
+
if (ext === '.py' || ext === '.rb') {
|
|
305
|
+
return { lineTokens: ['#'], block: false };
|
|
306
|
+
}
|
|
307
|
+
if (ext === '.php') {
|
|
308
|
+
return { lineTokens: ['//', '#'], block: true };
|
|
309
|
+
}
|
|
310
|
+
return { lineTokens: ['//'], block: true };
|
|
311
|
+
}
|
|
312
|
+
function extractCommentSegments(lines, syntax) {
|
|
313
|
+
const segments = [];
|
|
314
|
+
let inBlock = false;
|
|
315
|
+
for (const line of lines) {
|
|
316
|
+
let comment = '';
|
|
317
|
+
let inString = null;
|
|
318
|
+
let j = 0;
|
|
319
|
+
while (j < line.length) {
|
|
320
|
+
const ch = line.charAt(j);
|
|
321
|
+
if (inBlock) {
|
|
322
|
+
if (ch === '*' && line.charAt(j + 1) === '/') {
|
|
323
|
+
inBlock = false;
|
|
324
|
+
j += 2;
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
comment += ch;
|
|
328
|
+
j++;
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
if (inString !== null) {
|
|
332
|
+
if (ch === '\\') {
|
|
333
|
+
j += 2;
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
if (ch === inString) {
|
|
337
|
+
inString = null;
|
|
338
|
+
}
|
|
339
|
+
j++;
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
const lineTok = syntax.lineTokens.find((t) => line.startsWith(t, j));
|
|
343
|
+
if (lineTok !== undefined) {
|
|
344
|
+
comment += line.slice(j + lineTok.length);
|
|
345
|
+
break;
|
|
346
|
+
}
|
|
347
|
+
if (syntax.block && ch === '/' && line.charAt(j + 1) === '*') {
|
|
348
|
+
inBlock = true;
|
|
349
|
+
j += 2;
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
if (ch === '"' || ch === "'" || ch === '`') {
|
|
353
|
+
inString = ch;
|
|
354
|
+
j++;
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
j++;
|
|
358
|
+
}
|
|
359
|
+
segments.push(comment);
|
|
360
|
+
}
|
|
361
|
+
return segments;
|
|
362
|
+
}
|
|
303
363
|
/**
|
|
304
364
|
* Run lightweight quality checks on a single file and return any findings.
|
|
305
365
|
*/
|
|
@@ -337,9 +397,10 @@ export async function quickQualityCheck(file, projectPath) {
|
|
|
337
397
|
});
|
|
338
398
|
}
|
|
339
399
|
// Check for TODO/FIXME/HACK
|
|
400
|
+
const commentSegments = extractCommentSegments(lines, commentSyntaxFor(ext));
|
|
340
401
|
for (let i = 0; i < lines.length; i++) {
|
|
341
|
-
const
|
|
342
|
-
if (
|
|
402
|
+
const segment = commentSegments[i];
|
|
403
|
+
if (segment !== undefined && /\b(TODO|FIXME|HACK|XXX)\b/.exec(segment)) {
|
|
343
404
|
findings.push({
|
|
344
405
|
file,
|
|
345
406
|
line: i + 1,
|
|
@@ -1,8 +1,36 @@
|
|
|
1
1
|
// tools/check-readiness.ts — Completeness checkpoint tool (SPEC-039, SPEC-314, SPEC-716)
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
2
3
|
import { specStore } from '../storage/index.js';
|
|
3
4
|
import { buildCheckReadinessSummary } from '../engine/human-summary.js';
|
|
4
5
|
import { validateSpecFormat } from '../core/spec-validator.js';
|
|
6
|
+
import { parseFrontmatter } from '../engine/frontmatter-parser.js';
|
|
5
7
|
import { resolveProjectId } from './resolve-project-id.js';
|
|
8
|
+
const VALID_DIFFICULTIES = [1, 2, 3, 4, 5];
|
|
9
|
+
const VALID_SCOPES = ['trivial', 'feature', 'cross-module', 'architectural'];
|
|
10
|
+
function asDifficulty(value) {
|
|
11
|
+
return VALID_DIFFICULTIES.find((candidate) => candidate === value);
|
|
12
|
+
}
|
|
13
|
+
function asSpecScope(value) {
|
|
14
|
+
return VALID_SCOPES.find((candidate) => candidate === value);
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Re-derive difficulty/scope from the freshly-read spec.md frontmatter so the
|
|
18
|
+
* readiness gate honors on-disk edits instead of the specs.json snapshot that
|
|
19
|
+
* `specStore` caches in memory.
|
|
20
|
+
*/
|
|
21
|
+
async function readFreshDifficultyAndScope(specPath) {
|
|
22
|
+
try {
|
|
23
|
+
const raw = await readFile(specPath, 'utf-8');
|
|
24
|
+
const { metadata } = parseFrontmatter(raw);
|
|
25
|
+
return {
|
|
26
|
+
difficulty: asDifficulty(metadata.difficulty),
|
|
27
|
+
scope: asSpecScope(metadata.scope),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return {};
|
|
32
|
+
}
|
|
33
|
+
}
|
|
6
34
|
// ── Formatting helpers ───────────────────────────────────────────────────────
|
|
7
35
|
const MAX_VISIBLE_BLOCKERS = 8;
|
|
8
36
|
const MAX_VISIBLE_WARNINGS = 8;
|
|
@@ -120,7 +148,7 @@ export async function handleCheckReadiness(args) {
|
|
|
120
148
|
};
|
|
121
149
|
}
|
|
122
150
|
const { specId, mode = 'strict' } = args;
|
|
123
|
-
const spec = await specStore.
|
|
151
|
+
const spec = await specStore.getSpecFresh(projectId, specId);
|
|
124
152
|
if (!spec) {
|
|
125
153
|
return {
|
|
126
154
|
content: [
|
|
@@ -132,9 +160,15 @@ export async function handleCheckReadiness(args) {
|
|
|
132
160
|
isError: true,
|
|
133
161
|
};
|
|
134
162
|
}
|
|
163
|
+
const fresh = spec.specPath ? await readFreshDifficultyAndScope(spec.specPath) : {};
|
|
164
|
+
const effectiveSpec = {
|
|
165
|
+
...spec,
|
|
166
|
+
difficulty: fresh.difficulty ?? spec.difficulty,
|
|
167
|
+
scope: fresh.scope ?? spec.scope,
|
|
168
|
+
};
|
|
135
169
|
// validateSpecFormat owns the readiness and quality evaluation so the response
|
|
136
170
|
// cannot combine separately computed evidence from different reads of spec.md.
|
|
137
|
-
const validationResult = await validateSpecFormat(
|
|
171
|
+
const validationResult = await validateSpecFormat(effectiveSpec, { readinessMode: mode });
|
|
138
172
|
// SPEC-716: Ground ready flag in unified SpecValidationResult.
|
|
139
173
|
// Only non-file-access errors from validateSpecFormat are surfaced in the
|
|
140
174
|
// report — MISSING_FRONTMATTER due to file-not-found is best-effort since
|
|
@@ -2,12 +2,31 @@
|
|
|
2
2
|
import { t, ti } from '../i18n/index.js';
|
|
3
3
|
import { compactJson } from './output-formatter.js';
|
|
4
4
|
import { getDirectiveText, getHumanSummaryPrefix } from '../engine/host-rules-templates/index.js';
|
|
5
|
+
function stripSuccessDecoration(title) {
|
|
6
|
+
let current = title.trim();
|
|
7
|
+
let changed = true;
|
|
8
|
+
while (changed) {
|
|
9
|
+
changed = false;
|
|
10
|
+
if (current.startsWith('✅')) {
|
|
11
|
+
current = current.slice(1).trimStart();
|
|
12
|
+
changed = true;
|
|
13
|
+
}
|
|
14
|
+
if (current.startsWith('**') && current.endsWith('**') && current.length >= 4) {
|
|
15
|
+
const inner = current.slice(2, -2);
|
|
16
|
+
if (!inner.includes('**')) {
|
|
17
|
+
current = inner.trim();
|
|
18
|
+
changed = true;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return current;
|
|
23
|
+
}
|
|
5
24
|
/**
|
|
6
25
|
* Format a success response with optional next steps.
|
|
7
26
|
* Output: ✅ **{title}**\n\n{body}\n\n🚀 **Next Steps**\n1. ...\n2. ...
|
|
8
27
|
*/
|
|
9
28
|
export function formatSuccess(title, body, nextSteps) {
|
|
10
|
-
const header = `✅ **${title}**`;
|
|
29
|
+
const header = `✅ **${stripSuccessDecoration(title)}**`;
|
|
11
30
|
const parts = [header, '', body];
|
|
12
31
|
if (nextSteps && nextSteps.length > 0) {
|
|
13
32
|
parts.push('', t('tools.response.nextSteps'));
|
|
@@ -19,6 +19,10 @@ export interface MinimalImplementationRule {
|
|
|
19
19
|
replacementGuidance: string;
|
|
20
20
|
blockWhenRiskAtLeast?: 'low' | 'medium' | 'high' | 'max';
|
|
21
21
|
}
|
|
22
|
+
export interface MinimalImplementationProvenance {
|
|
23
|
+
nonProposingSectionKeywords: string[];
|
|
24
|
+
negationCues: string[];
|
|
25
|
+
}
|
|
22
26
|
export interface MinimalImplementationPolicy {
|
|
23
27
|
version: 1;
|
|
24
28
|
enabled: boolean;
|
|
@@ -36,6 +40,7 @@ export interface MinimalImplementationPolicy {
|
|
|
36
40
|
debtEvidence: {
|
|
37
41
|
requiredFields: string[];
|
|
38
42
|
};
|
|
43
|
+
provenance?: MinimalImplementationProvenance;
|
|
39
44
|
}
|
|
40
45
|
export interface LoadedMinimalImplementationPolicy {
|
|
41
46
|
policy: MinimalImplementationPolicy;
|
package/dist/types/privacy.d.ts
CHANGED
|
@@ -119,6 +119,13 @@ export interface PiiFrameworkConfig {
|
|
|
119
119
|
dataPortability: boolean;
|
|
120
120
|
regions: string[];
|
|
121
121
|
}
|
|
122
|
+
/**
|
|
123
|
+
* Provenance guards that keep PII detection anchored to affirmative,
|
|
124
|
+
* data-collecting spec content rather than example enumerations.
|
|
125
|
+
*/
|
|
126
|
+
export interface PiiProvenanceConfig {
|
|
127
|
+
nonAffirmingSectionKeywords: string[];
|
|
128
|
+
}
|
|
122
129
|
/**
|
|
123
130
|
* Full structure of pii-patterns.json.
|
|
124
131
|
*/
|
|
@@ -126,8 +133,10 @@ export interface PiiPatterns {
|
|
|
126
133
|
vocabulary: Record<string, string[]>;
|
|
127
134
|
modelContextBlacklist: string[];
|
|
128
135
|
modelContextWhitelist: string[];
|
|
136
|
+
collectionCueVerbs: string[];
|
|
129
137
|
frameworks: Record<string, PiiFrameworkConfig>;
|
|
130
138
|
thirdPartySignatures: Record<string, string[]>;
|
|
139
|
+
provenance?: PiiProvenanceConfig;
|
|
131
140
|
}
|
|
132
141
|
/**
|
|
133
142
|
* Input for the data_governance MCP tool.
|
package/package.json
CHANGED
package/planu-plugin.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "dev.planu.cli",
|
|
3
3
|
"displayName": "Planu — Spec Driven Development",
|
|
4
4
|
"description": "Manage software specs, estimations, and autonomous SDD workflows. Language-agnostic MCP server for Claude Code.",
|
|
5
|
-
"version": "5.3.
|
|
5
|
+
"version": "5.3.44",
|
|
6
6
|
"icon": "assets/plugin/icon.svg",
|
|
7
7
|
"command": ["npx", "@planu/cli@latest"],
|
|
8
8
|
"packageName": "@planu/cli",
|