karajan-code 2.34.0 → 3.1.0
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/README.md +120 -38
- package/bin/kj-rag-mcp.js +75 -0
- package/docs/README.es.md +5 -5
- package/package.json +22 -17
- package/packages/hu-board/package.json +5 -2
- package/packages/hu-board/public/app.js +81 -4025
- package/packages/hu-board/public/index.html +20 -0
- package/packages/hu-board/public/utils/api.js +139 -0
- package/packages/hu-board/public/utils/board-view.js +453 -0
- package/packages/hu-board/public/utils/command-launcher.js +213 -0
- package/packages/hu-board/public/utils/config-editor.js +181 -0
- package/packages/hu-board/public/utils/dashboard-view.js +103 -0
- package/packages/hu-board/public/utils/formatters.js +232 -0
- package/packages/hu-board/public/utils/graph-view.js +216 -0
- package/packages/hu-board/public/utils/hu-actions.js +224 -0
- package/packages/hu-board/public/utils/init-listeners.js +180 -0
- package/packages/hu-board/public/utils/log-panel.js +222 -0
- package/packages/hu-board/public/utils/modals.js +290 -0
- package/packages/hu-board/public/utils/plan-rollup-view.js +53 -0
- package/packages/hu-board/public/utils/preflight-view.js +89 -0
- package/packages/hu-board/public/utils/project-actions.js +143 -0
- package/packages/hu-board/public/utils/project-picker-view.js +139 -0
- package/packages/hu-board/public/utils/run-launcher.js +169 -0
- package/packages/hu-board/public/utils/server-push.js +252 -0
- package/packages/hu-board/public/utils/sessions-view.js +199 -0
- package/packages/hu-board/public/utils/story-detail-view.js +324 -0
- package/packages/hu-board/public/utils/story-edit-form.js +271 -0
- package/packages/hu-board/src/config-yaml.js +14 -0
- package/packages/hu-board/src/db.js +15 -2
- package/packages/hu-board/src/routes/api.js +27 -9
- package/scripts/audit-test-diet.mjs +151 -0
- package/src/audit/deterministic-summary.js +20 -0
- package/src/audit/harness-scorecard.js +52 -3
- package/src/audit/harness-section.js +6 -2
- package/src/audit/injection-findings.js +105 -0
- package/src/checks/hardware.js +150 -0
- package/src/checks/node.js +9 -10
- package/src/checks/runner.js +10 -0
- package/src/cli/register-meta.js +48 -2
- package/src/cli/register-plan.js +10 -1
- package/src/commands/clean.js +62 -0
- package/src/commands/doctor.js +2 -0
- package/src/commands/init.js +41 -1
- package/src/commands/plan/generate.js +9 -3
- package/src/commands/rag-mcp.js +18 -0
- package/src/commands/sync.js +30 -2
- package/src/commands/telemetry.js +70 -0
- package/src/config/defaults.js +9 -1
- package/src/config/schema.js +2 -0
- package/src/orchestrator/config-init.js +1 -0
- package/src/orchestrator/drivers/iteration-phases/quality-gates.js +23 -0
- package/src/orchestrator/stages/coder-stage.js +11 -2
- package/src/orchestrator/stages/reviewer-stage.js +11 -0
- package/src/orchestrator/stages/tdd-discipline-stage.js +56 -0
- package/src/orchestrator/stages/tool-judge-stage.js +53 -0
- package/src/prompts/tool-correctness-judge.js +31 -0
- package/src/review/git-stash-helper.js +104 -0
- package/src/review/tdd-discipline.js +90 -0
- package/src/roles/audit-role.js +9 -1
- package/src/roles/tool-correctness-judge-role.js +78 -0
- package/src/session/mutators.js +4 -0
- package/src/sonar/scanner.js +51 -16
- package/src/sync/apply-canvas-patch.js +55 -0
- package/src/utils/paths.js +12 -0
- package/src/utils/quota-simulator.js +56 -0
- package/src/utils/repo-cleaner.js +79 -0
- package/src/utils/telemetry.js +34 -12
- package/src/utils/tool-call-extractor.js +56 -0
- package/src/utils/vector-store-orphans.js +89 -0
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
// KJC-TSK-0501 step 8c/8 — Story edit form (inline editor for the HU modal).
|
|
2
|
+
//
|
|
3
|
+
// Classic script (no exports). Loaded by index.html before app.js so the
|
|
4
|
+
// top-level `function` declarations are hoisted into the shared realm and
|
|
5
|
+
// `showStoryDetail` (story-detail-view.js) can call `renderStoryEditForm`
|
|
6
|
+
// when the user hits ✎ on a story.
|
|
7
|
+
//
|
|
8
|
+
// Globals consumed from earlier scripts / app.js:
|
|
9
|
+
// - esc() (app.js)
|
|
10
|
+
// - shortStoryId() (utils/formatters.js)
|
|
11
|
+
// - projectInitialsCache (mutable in app.js — assignment crosses
|
|
12
|
+
// scripts transparently)
|
|
13
|
+
// - showStoryDetail() (utils/story-detail-view.js)
|
|
14
|
+
// - showError() (utils/modals.js)
|
|
15
|
+
// - closeModal() (app.js → window.closeModal, used by
|
|
16
|
+
// inline onclick on the modal close button)
|
|
17
|
+
// - renderBoard() (app.js)
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Swap the story modal into an inline edit form. Accepts the full story
|
|
21
|
+
* record that `showStoryDetail` just rendered so we don't re-fetch —
|
|
22
|
+
* the user's intent is "edit what I'm looking at".
|
|
23
|
+
*
|
|
24
|
+
* Cancel restores the read-only view via `showStoryDetail`; Save posts a
|
|
25
|
+
* PATCH and, on success, re-opens in read-only with the refreshed row.
|
|
26
|
+
*
|
|
27
|
+
* @param {object} story
|
|
28
|
+
*/
|
|
29
|
+
function renderStoryEditForm(story) {
|
|
30
|
+
const content = document.getElementById('modal-content');
|
|
31
|
+
const initials = projectInitialsCache[story.project_id] || 'kj';
|
|
32
|
+
const shortId = shortStoryId(story, initials);
|
|
33
|
+
|
|
34
|
+
const rawAc = story.acceptance_criteria ? JSON.parse(story.acceptance_criteria) : [];
|
|
35
|
+
// Represent AC as text: plain strings go verbatim, Gherkin objects
|
|
36
|
+
// collapse to "Given … | When … | Then …". On save we parse back —
|
|
37
|
+
// if the line matches the pattern we treat it as Gherkin, otherwise
|
|
38
|
+
// a free-form string.
|
|
39
|
+
const acInitial = rawAc
|
|
40
|
+
.map((c) => typeof c === 'string' ? c : (c.given ? `Given ${c.given} | When ${c.when} | Then ${c.then}` : JSON.stringify(c)))
|
|
41
|
+
.join('\n');
|
|
42
|
+
|
|
43
|
+
// Tests-first editor: one block per test with a type selector
|
|
44
|
+
// (shell | gherkin), the content textarea, optional file path, and
|
|
45
|
+
// a remove button. Save collects them into the structured v2.7.5
|
|
46
|
+
// array shape. Plain strings in the existing data are treated as
|
|
47
|
+
// legacy shell tests and auto-upgraded to the structured form.
|
|
48
|
+
const rawTests = story.acceptance_tests ? JSON.parse(story.acceptance_tests) : [];
|
|
49
|
+
const testsInitial = rawTests.map((t) => {
|
|
50
|
+
if (typeof t === 'string') return { type: 'shell', content: t, file: '' };
|
|
51
|
+
if (t && typeof t === 'object') {
|
|
52
|
+
return {
|
|
53
|
+
type: t.type === 'gherkin' ? 'gherkin' : 'shell',
|
|
54
|
+
content: typeof t.content === 'string' ? t.content : JSON.stringify(t),
|
|
55
|
+
file: typeof t.file === 'string' ? t.file : '',
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
return { type: 'shell', content: String(t ?? ''), file: '' };
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const scopeInitial = story.original_text || story.certified_want || '';
|
|
62
|
+
const TASK_TYPES = ['sw', 'infra', 'doc', 'add-tests', 'refactor'];
|
|
63
|
+
|
|
64
|
+
content.innerHTML = `
|
|
65
|
+
<div class="modal__header">
|
|
66
|
+
<div>
|
|
67
|
+
<div class="modal__title" title="${esc(story.id)}">${esc(shortId)} <span style="font-size:0.75rem;color:var(--text-muted);font-weight:normal">(editing)</span></div>
|
|
68
|
+
<div class="modal__subtitle" style="font-size:0.75rem;color:var(--text-muted);font-family:monospace;margin-top:2px">${esc(story.id)}</div>
|
|
69
|
+
</div>
|
|
70
|
+
<button class="modal__close" onclick="closeModal()">×</button>
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
<form id="hu-edit-form" onsubmit="return false" style="display:flex;flex-direction:column;gap:14px;padding:8px 0">
|
|
74
|
+
<label style="display:flex;flex-direction:column;gap:4px;font-size:0.85rem">
|
|
75
|
+
<span style="color:var(--text-muted)">Title</span>
|
|
76
|
+
<input type="text" id="edit-title" value="${esc(story.title || '')}"
|
|
77
|
+
style="padding:8px;border:1px solid var(--border);background:var(--bg-primary);color:var(--text);border-radius:var(--radius-sm);font-size:0.95rem"
|
|
78
|
+
maxlength="200" required>
|
|
79
|
+
</label>
|
|
80
|
+
|
|
81
|
+
<label style="display:flex;flex-direction:column;gap:4px;font-size:0.85rem">
|
|
82
|
+
<span style="color:var(--text-muted)">Scope</span>
|
|
83
|
+
<textarea id="edit-scope" rows="4"
|
|
84
|
+
style="padding:8px;border:1px solid var(--border);background:var(--bg-primary);color:var(--text);border-radius:var(--radius-sm);font-family:inherit;font-size:0.9rem;line-height:1.5;resize:vertical">${esc(scopeInitial)}</textarea>
|
|
85
|
+
</label>
|
|
86
|
+
|
|
87
|
+
<label style="display:flex;flex-direction:column;gap:4px;font-size:0.85rem">
|
|
88
|
+
<span style="color:var(--text-muted)">Task type</span>
|
|
89
|
+
<select id="edit-task-type"
|
|
90
|
+
style="padding:8px;border:1px solid var(--border);background:var(--bg-primary);color:var(--text);border-radius:var(--radius-sm);font-size:0.9rem">
|
|
91
|
+
${TASK_TYPES.map((t) => `<option value="${t}">${t}</option>`).join('')}
|
|
92
|
+
</select>
|
|
93
|
+
</label>
|
|
94
|
+
|
|
95
|
+
<label style="display:flex;flex-direction:column;gap:4px;font-size:0.85rem">
|
|
96
|
+
<span style="color:var(--text-muted)">
|
|
97
|
+
Acceptance criteria — one per line.
|
|
98
|
+
Gherkin: <code>Given X | When Y | Then Z</code>
|
|
99
|
+
</span>
|
|
100
|
+
<textarea id="edit-ac" rows="6"
|
|
101
|
+
style="padding:8px;border:1px solid var(--border);background:var(--bg-primary);color:var(--text);border-radius:var(--radius-sm);font-family:var(--font-mono, monospace);font-size:0.85rem;line-height:1.5;resize:vertical">${esc(acInitial)}</textarea>
|
|
102
|
+
</label>
|
|
103
|
+
|
|
104
|
+
<fieldset style="border:1px solid var(--border);border-radius:var(--radius-sm);padding:10px 12px;margin:0">
|
|
105
|
+
<legend style="color:var(--text-muted);font-size:0.85rem;padding:0 6px">
|
|
106
|
+
Acceptance tests — the contract the coder must satisfy
|
|
107
|
+
</legend>
|
|
108
|
+
<div id="edit-tests-list" style="display:flex;flex-direction:column;gap:8px"></div>
|
|
109
|
+
<button type="button" id="edit-tests-add"
|
|
110
|
+
style="margin-top:8px;padding:4px 10px;font-size:0.8rem;background:var(--bg-primary);color:var(--text);border:1px solid var(--border);border-radius:var(--radius-sm);cursor:pointer">
|
|
111
|
+
+ Add test
|
|
112
|
+
</button>
|
|
113
|
+
</fieldset>
|
|
114
|
+
|
|
115
|
+
<div style="display:flex;justify-content:flex-end;gap:8px;margin-top:8px;padding-top:12px;border-top:1px solid var(--border)">
|
|
116
|
+
<button type="button" id="edit-cancel" class="control-btn"
|
|
117
|
+
style="padding:6px 14px;border:1px solid var(--border);background:var(--bg-primary);color:var(--text);border-radius:var(--radius-sm);cursor:pointer">
|
|
118
|
+
Cancel
|
|
119
|
+
</button>
|
|
120
|
+
<button type="button" id="edit-save" class="control-btn"
|
|
121
|
+
style="padding:6px 14px;border:none;background:var(--color-green);color:#fff;border-radius:var(--radius-sm);cursor:pointer;font-weight:600">
|
|
122
|
+
Save
|
|
123
|
+
</button>
|
|
124
|
+
</div>
|
|
125
|
+
</form>
|
|
126
|
+
`;
|
|
127
|
+
|
|
128
|
+
// Tests editor state + renderers. Kept on the function scope (not
|
|
129
|
+
// on window) so reopening the form gives a fresh copy.
|
|
130
|
+
let testRows = [...testsInitial];
|
|
131
|
+
const listEl = document.getElementById('edit-tests-list');
|
|
132
|
+
|
|
133
|
+
function renderTestsEditor() {
|
|
134
|
+
listEl.innerHTML = testRows.map((t, i) => `
|
|
135
|
+
<div class="edit-test-row" data-idx="${i}"
|
|
136
|
+
style="display:grid;grid-template-columns:auto 1fr auto;gap:6px;align-items:start;padding:8px;border:1px solid var(--border);border-radius:var(--radius-sm);background:var(--bg-primary)">
|
|
137
|
+
<select data-field="type"
|
|
138
|
+
style="padding:4px 6px;border:1px solid var(--border);background:var(--bg-secondary);color:var(--text);border-radius:var(--radius-sm);font-size:0.8rem">
|
|
139
|
+
<option value="shell"${t.type === 'shell' ? ' selected' : ''}>shell</option>
|
|
140
|
+
<option value="gherkin"${t.type === 'gherkin' ? ' selected' : ''}>gherkin</option>
|
|
141
|
+
</select>
|
|
142
|
+
<div style="display:flex;flex-direction:column;gap:4px">
|
|
143
|
+
<textarea data-field="content" rows="${t.type === 'gherkin' ? 3 : 2}"
|
|
144
|
+
placeholder="${t.type === 'gherkin' ? 'Given …\\nWhen …\\nThen …' : 'npx vitest run test/foo.test.js'}"
|
|
145
|
+
style="padding:6px 8px;border:1px solid var(--border);background:var(--bg-secondary);color:var(--text);border-radius:var(--radius-sm);font-family:var(--font-mono, monospace);font-size:0.82rem;line-height:1.4;resize:vertical">${esc(t.content)}</textarea>
|
|
146
|
+
<input type="text" data-field="file" placeholder="Optional: file path (e.g. tests/login.test.ts)" value="${esc(t.file || '')}"
|
|
147
|
+
style="padding:4px 8px;border:1px solid var(--border);background:var(--bg-secondary);color:var(--text);border-radius:var(--radius-sm);font-family:var(--font-mono, monospace);font-size:0.75rem">
|
|
148
|
+
</div>
|
|
149
|
+
<button type="button" data-field="remove" title="Remove this test"
|
|
150
|
+
style="align-self:start;padding:4px 8px;background:transparent;border:1px solid var(--border);color:var(--text-muted);border-radius:var(--radius-sm);cursor:pointer;font-size:0.8rem">✕</button>
|
|
151
|
+
</div>
|
|
152
|
+
`).join('');
|
|
153
|
+
|
|
154
|
+
// Bind the edit events. We re-bind on every render — cheap, and
|
|
155
|
+
// keeps the state → DOM mapping simple.
|
|
156
|
+
listEl.querySelectorAll('.edit-test-row').forEach((row) => {
|
|
157
|
+
const idx = Number(row.dataset.idx);
|
|
158
|
+
row.querySelector('[data-field="type"]').addEventListener('change', (e) => {
|
|
159
|
+
testRows[idx].type = e.target.value;
|
|
160
|
+
renderTestsEditor(); // re-render so placeholder updates
|
|
161
|
+
});
|
|
162
|
+
row.querySelector('[data-field="content"]').addEventListener('input', (e) => {
|
|
163
|
+
testRows[idx].content = e.target.value;
|
|
164
|
+
});
|
|
165
|
+
row.querySelector('[data-field="file"]').addEventListener('input', (e) => {
|
|
166
|
+
testRows[idx].file = e.target.value;
|
|
167
|
+
});
|
|
168
|
+
row.querySelector('[data-field="remove"]').addEventListener('click', () => {
|
|
169
|
+
testRows.splice(idx, 1);
|
|
170
|
+
renderTestsEditor();
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
renderTestsEditor();
|
|
176
|
+
|
|
177
|
+
document.getElementById('edit-tests-add').addEventListener('click', () => {
|
|
178
|
+
testRows.push({ type: 'shell', content: '', file: '' });
|
|
179
|
+
renderTestsEditor();
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
document.getElementById('edit-cancel').addEventListener('click', () => showStoryDetail(story.id));
|
|
183
|
+
document.getElementById('edit-save').addEventListener('click', () => saveStoryEdits(story, () => testRows));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Collect the form values, compute the diff against the story as it
|
|
188
|
+
* was on modal open, and PATCH only the changed fields. Empty diff is
|
|
189
|
+
* a no-op (close out of edit mode without an API call).
|
|
190
|
+
* @param {object} story
|
|
191
|
+
*/
|
|
192
|
+
async function saveStoryEdits(story, getTestRows) {
|
|
193
|
+
const title = document.getElementById('edit-title').value.trim();
|
|
194
|
+
const scope = document.getElementById('edit-scope').value;
|
|
195
|
+
const taskType = document.getElementById('edit-task-type').value;
|
|
196
|
+
const acRaw = document.getElementById('edit-ac').value;
|
|
197
|
+
|
|
198
|
+
if (!title) {
|
|
199
|
+
await showError('Title cannot be empty.', { title: 'Invalid input' });
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Parse AC: one per line; a line with "Given X | When Y | Then Z"
|
|
204
|
+
// (case-insensitive) becomes Gherkin; everything else stays a string.
|
|
205
|
+
// Blank lines are dropped.
|
|
206
|
+
const acceptance_criteria = acRaw.split('\n')
|
|
207
|
+
.map((line) => line.trim())
|
|
208
|
+
.filter(Boolean)
|
|
209
|
+
.map((line) => {
|
|
210
|
+
const m = /^Given\s+(.*?)\s*\|\s*When\s+(.*?)\s*\|\s*Then\s+(.*)$/i.exec(line);
|
|
211
|
+
if (m) return { given: m[1].trim(), when: m[2].trim(), then: m[3].trim() };
|
|
212
|
+
return line;
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// Collect the structured acceptance_tests from the editor state,
|
|
216
|
+
// dropping rows the user left empty.
|
|
217
|
+
const testRows = typeof getTestRows === 'function' ? getTestRows() : [];
|
|
218
|
+
const acceptance_tests = testRows
|
|
219
|
+
.map((t) => ({
|
|
220
|
+
type: t.type === 'gherkin' ? 'gherkin' : 'shell',
|
|
221
|
+
content: (t.content || '').trim(),
|
|
222
|
+
file: (t.file || '').trim(),
|
|
223
|
+
}))
|
|
224
|
+
.filter((t) => t.content.length > 0)
|
|
225
|
+
.map((t) => (t.file ? t : { type: t.type, content: t.content }));
|
|
226
|
+
|
|
227
|
+
// Diff against the original so we don't send untouched fields and
|
|
228
|
+
// let the server's COALESCE keep existing values.
|
|
229
|
+
const patch = {};
|
|
230
|
+
const originalScope = story.original_text || story.certified_want || '';
|
|
231
|
+
if (title !== (story.title || '')) patch.title = title;
|
|
232
|
+
if (scope !== originalScope) patch.scope = scope;
|
|
233
|
+
if (taskType && taskType !== 'sw') patch.task_type = taskType;
|
|
234
|
+
const prevAcStr = story.acceptance_criteria || '[]';
|
|
235
|
+
const nextAcStr = JSON.stringify(acceptance_criteria);
|
|
236
|
+
if (nextAcStr !== prevAcStr) patch.acceptance_criteria = acceptance_criteria;
|
|
237
|
+
const prevTestsStr = story.acceptance_tests || '[]';
|
|
238
|
+
const nextTestsStr = JSON.stringify(acceptance_tests);
|
|
239
|
+
if (nextTestsStr !== prevTestsStr) patch.acceptance_tests = acceptance_tests;
|
|
240
|
+
|
|
241
|
+
if (Object.keys(patch).length === 0) {
|
|
242
|
+
showStoryDetail(story.id);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
try {
|
|
247
|
+
const res = await fetch(`/api/stories/${encodeURIComponent(story.id)}`, {
|
|
248
|
+
method: 'PATCH',
|
|
249
|
+
headers: { 'Content-Type': 'application/json' },
|
|
250
|
+
body: JSON.stringify(patch),
|
|
251
|
+
});
|
|
252
|
+
if (!res.ok) {
|
|
253
|
+
if (res.status === 404) {
|
|
254
|
+
await showError(
|
|
255
|
+
'The board server does not recognise the extended PATCH endpoint.\n\n'
|
|
256
|
+
+ 'Restart it to pick up v2.7.5+:\n\n'
|
|
257
|
+
+ ' kj board stop && kj board start',
|
|
258
|
+
{ title: 'Board out of date' }
|
|
259
|
+
);
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
const msg = (await res.json().catch(() => ({}))).error || `HTTP ${res.status}`;
|
|
263
|
+
await showError(msg, { title: 'Could not save HU' });
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
await renderBoard(); // card shows new title/counts
|
|
267
|
+
await showStoryDetail(story.id); // reopen modal with fresh data
|
|
268
|
+
} catch (err) {
|
|
269
|
+
await showError(err.message, { title: 'Could not save HU' });
|
|
270
|
+
}
|
|
271
|
+
}
|
|
@@ -349,11 +349,19 @@ function getDeep(obj, path) {
|
|
|
349
349
|
return cur;
|
|
350
350
|
}
|
|
351
351
|
|
|
352
|
+
// KJC-BUG-0076: never walk into Object.prototype via crafted YAML keys
|
|
353
|
+
// (`__proto__.x`, `constructor.prototype.y`, `prototype.z`). Silent skip
|
|
354
|
+
// over a poisoned segment — the rest of the path is untouched.
|
|
355
|
+
const UNSAFE_KEY_RE = /^(?:__proto__|constructor|prototype)$/;
|
|
356
|
+
|
|
352
357
|
function setDeep(obj, path, value) {
|
|
358
|
+
if (typeof path !== 'string') return;
|
|
353
359
|
const parts = path.split('.');
|
|
354
360
|
const last = parts.pop();
|
|
361
|
+
if (UNSAFE_KEY_RE.test(last)) return;
|
|
355
362
|
let cur = obj;
|
|
356
363
|
for (const p of parts) {
|
|
364
|
+
if (UNSAFE_KEY_RE.test(p)) return;
|
|
357
365
|
if (cur[p] == null || typeof cur[p] !== 'object') cur[p] = {};
|
|
358
366
|
cur = cur[p];
|
|
359
367
|
}
|
|
@@ -361,10 +369,13 @@ function setDeep(obj, path, value) {
|
|
|
361
369
|
}
|
|
362
370
|
|
|
363
371
|
function deleteDeep(obj, path) {
|
|
372
|
+
if (typeof path !== 'string') return;
|
|
364
373
|
const parts = path.split('.');
|
|
365
374
|
const last = parts.pop();
|
|
375
|
+
if (UNSAFE_KEY_RE.test(last)) return;
|
|
366
376
|
let cur = obj;
|
|
367
377
|
for (const p of parts) {
|
|
378
|
+
if (UNSAFE_KEY_RE.test(p)) return;
|
|
368
379
|
if (cur == null || typeof cur !== 'object') return;
|
|
369
380
|
cur = cur[p];
|
|
370
381
|
}
|
|
@@ -516,3 +527,6 @@ export function writeConfigPatch(patch, { scope = 'global' } = {}) {
|
|
|
516
527
|
renameSync(tmpFile, p);
|
|
517
528
|
return { path: p, scope, written: true, applied, errors: [] };
|
|
518
529
|
}
|
|
530
|
+
|
|
531
|
+
// Exposed for prototype-pollution regression tests (KJC-BUG-0076).
|
|
532
|
+
export const __pollutionInternals = { setDeep, deleteDeep };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import Database from 'better-sqlite3';
|
|
2
|
-
import { join } from 'node:path';
|
|
3
|
-
import { mkdirSync, renameSync } from 'node:fs';
|
|
2
|
+
import { join, dirname } from 'node:path';
|
|
3
|
+
import { mkdirSync, renameSync, rmSync } from 'node:fs';
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
|
|
6
6
|
/** @type {import('better-sqlite3').Database | null} */
|
|
@@ -23,6 +23,19 @@ function vitestTmpKjHome() {
|
|
|
23
23
|
`karajan-vitest-${process.pid}-${Math.random().toString(36).slice(2, 10)}`,
|
|
24
24
|
'.karajan'
|
|
25
25
|
);
|
|
26
|
+
// KJC-BUG-0075: clean up on clean exit so /tmp doesn't accumulate one
|
|
27
|
+
// dir per fork × run. We rm the *parent* (the `karajan-vitest-<pid>-<rand>`
|
|
28
|
+
// wrapper), not the `.karajan` segment, so the whole tmp tree goes.
|
|
29
|
+
// Only fires on graceful exit; SIGKILL leaks are caught by the
|
|
30
|
+
// global-setup mtime purge at the start of the next run.
|
|
31
|
+
const parent = dirname(_vitestKjHome);
|
|
32
|
+
process.on('exit', () => {
|
|
33
|
+
try {
|
|
34
|
+
rmSync(parent, { recursive: true, force: true });
|
|
35
|
+
} catch {
|
|
36
|
+
// best-effort: never crash a test process on cleanup failure
|
|
37
|
+
}
|
|
38
|
+
});
|
|
26
39
|
return _vitestKjHome;
|
|
27
40
|
}
|
|
28
41
|
|
|
@@ -545,15 +545,27 @@ router.post('/tombstones/:type/:id/restore', (req, res) => {
|
|
|
545
545
|
* rewritten before we ack, then re-synced into SQLite, so a reload
|
|
546
546
|
* renders the committed state.
|
|
547
547
|
*/
|
|
548
|
-
//
|
|
549
|
-
//
|
|
550
|
-
//
|
|
551
|
-
//
|
|
552
|
-
//
|
|
553
|
-
//
|
|
554
|
-
//
|
|
555
|
-
//
|
|
556
|
-
const ALLOWED_STORY_STATUSES = new Set(['pending', '
|
|
548
|
+
// KJC-TSK-0394 PR6 (AC 6): el PATCH endpoint sólo acepta el modelo
|
|
549
|
+
// canónico {pending, running, done}. Status legacy (`certified`,
|
|
550
|
+
// `coding`, `reviewing`, `failed`, `blocked`, `needs_context`) siguen
|
|
551
|
+
// vivos en plans existentes y los traduce la UI con canonicalStatus(),
|
|
552
|
+
// pero NO pueden ser seteados por escrituras nuevas — devolvemos 400
|
|
553
|
+
// con `suggestion` apuntando al mapping correcto. `running` queda fuera
|
|
554
|
+
// del dropdown del modal (lifecycle del orquestador), pero el endpoint
|
|
555
|
+
// lo acepta por si un programa externo lo necesita.
|
|
556
|
+
const ALLOWED_STORY_STATUSES = new Set(['pending', 'running', 'done']);
|
|
557
|
+
// Mapping canónico para la sugerencia del 400 cuando el caller manda
|
|
558
|
+
// un valor legacy. Mantenemos la regla viva en src/plan/plan-schema.js
|
|
559
|
+
// (mapLegacyStatusToResult) — esta tabla es sólo el subset accionable
|
|
560
|
+
// desde el endpoint (status + result).
|
|
561
|
+
const LEGACY_STATUS_SUGGESTION = {
|
|
562
|
+
certified: { status: 'done', result: 'pass' },
|
|
563
|
+
failed: { status: 'pending', result: 'fail' },
|
|
564
|
+
coding: { status: 'running', result: null },
|
|
565
|
+
reviewing: { status: 'running', result: null },
|
|
566
|
+
blocked: { status: 'pending', result: null },
|
|
567
|
+
needs_context: { status: 'pending', result: null },
|
|
568
|
+
};
|
|
557
569
|
// KJC-TSK-0406: coder_model y reviewer_model son editables desde el
|
|
558
570
|
// modal del board. Independientes — el usuario puede subir el reviewer
|
|
559
571
|
// y bajar el coder sin afectar al resto del plan.
|
|
@@ -579,8 +591,14 @@ router.patch('/stories/:id', (req, res) => {
|
|
|
579
591
|
});
|
|
580
592
|
}
|
|
581
593
|
if (hasStatus && !ALLOWED_STORY_STATUSES.has(body.status)) {
|
|
594
|
+
// KJC-TSK-0394 PR6 (AC 6): si el caller manda un status legacy,
|
|
595
|
+
// devolvemos un 400 con `suggestion` apuntando al mapping canónico.
|
|
596
|
+
// Una herramienta externa puede leer el body, aplicar el mapeo y
|
|
597
|
+
// re-intentar — sin tener que descubrir la tabla por ensayo y error.
|
|
598
|
+
const suggestion = LEGACY_STATUS_SUGGESTION[body.status] || null;
|
|
582
599
|
return res.status(400).json({
|
|
583
600
|
error: `status must be one of: ${[...ALLOWED_STORY_STATUSES].join(', ')}`,
|
|
601
|
+
suggestion,
|
|
584
602
|
});
|
|
585
603
|
}
|
|
586
604
|
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* scripts/audit-test-diet.mjs — semantic loss-of-meaning auditor (KJC-TSK-0345).
|
|
4
|
+
* Categories: empty-no-expect, skipped-pending, imports-orphan, deprecated-export,
|
|
5
|
+
* subsumed-candidate. Each finding carries confidence (high|low). Complements
|
|
6
|
+
* tests/_diet/audit.mjs (which classifies by LOC bucket only).
|
|
7
|
+
*/
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
|
|
12
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
const DEFAULT_ROOT = resolve(HERE, "..");
|
|
14
|
+
const SKIP_DIRS = new Set(["_diet", "fixtures", "node_modules"]);
|
|
15
|
+
|
|
16
|
+
export function walkTests(rootDir, testsSubdir = "tests") {
|
|
17
|
+
const out = [];
|
|
18
|
+
const base = join(rootDir, testsSubdir);
|
|
19
|
+
if (!existsSync(base)) return out;
|
|
20
|
+
const stack = [base];
|
|
21
|
+
while (stack.length) {
|
|
22
|
+
for (const e of readdirSync(stack.pop(), { withFileTypes: true })) {
|
|
23
|
+
const p = join(e.parentPath ?? base, e.name);
|
|
24
|
+
if (e.isDirectory() && !SKIP_DIRS.has(e.name)) stack.push(p);
|
|
25
|
+
else if (e.isFile() && /\.test\.(m?js|ts)$/.test(e.name)) out.push(p);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function parseImports(source) {
|
|
32
|
+
const imports = [];
|
|
33
|
+
const re = /^[ \t]*import\s+(?:([\w*\s{},]+?)\s+from\s+)?['"]([^'"]+)['"]/gm;
|
|
34
|
+
let m;
|
|
35
|
+
while ((m = re.exec(source)) !== null) {
|
|
36
|
+
const named = [];
|
|
37
|
+
const braced = (m[1] || "").match(/\{([^}]+)\}/);
|
|
38
|
+
if (braced) for (const part of braced[1].split(",")) {
|
|
39
|
+
const name = part.trim().split(/\s+as\s+/)[0].trim();
|
|
40
|
+
if (name) named.push(name);
|
|
41
|
+
}
|
|
42
|
+
imports.push({ specifier: m[2], named });
|
|
43
|
+
}
|
|
44
|
+
return imports;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function resolveSrcImport(testFile, specifier, rootDir) {
|
|
48
|
+
if (!specifier.startsWith(".") && !specifier.startsWith("#")) return null;
|
|
49
|
+
const base = specifier.startsWith("#") ? resolve(rootDir, specifier.replace(/^#/, "src/")) : resolve(dirname(testFile), specifier);
|
|
50
|
+
for (const c of [base, `${base}.js`, `${base}.mjs`, join(base, "index.js")]) {
|
|
51
|
+
if (existsSync(c) && statSync(c).isFile()) return c;
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function extractItNames(source) {
|
|
57
|
+
const names = new Set();
|
|
58
|
+
for (const m of source.matchAll(/\b(?:it|test)(?:\.(?:only|skip|todo))?\s*\(\s*['"`]([^'"`]+)['"`]/g)) names.add(m[1]);
|
|
59
|
+
return names;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function extractExports(source) {
|
|
63
|
+
const names = new Set();
|
|
64
|
+
for (const m of source.matchAll(/export\s+(?:async\s+)?(?:function|class|const|let|var)\s+([A-Za-z_$][\w$]*)/g)) names.add(m[1]);
|
|
65
|
+
for (const m of source.matchAll(/export\s*\{([^}]+)\}/g)) for (const part of m[1].split(",")) {
|
|
66
|
+
const name = part.trim().split(/\s+as\s+/).pop().trim();
|
|
67
|
+
if (name) names.add(name);
|
|
68
|
+
}
|
|
69
|
+
if (/export\s+default\b/.test(source)) names.add("default");
|
|
70
|
+
return names;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function auditFile(testFile, rootDir) {
|
|
74
|
+
const source = readFileSync(testFile, "utf-8");
|
|
75
|
+
const rel = relative(rootDir, testFile);
|
|
76
|
+
const findings = [];
|
|
77
|
+
const hasIt = /\b(?:it|test)\s*\(/.test(source);
|
|
78
|
+
if (hasIt && !/\bexpect\s*\(/.test(source)) findings.push({ category: "empty-no-expect", confidence: "high", detail: "it()/test() blocks with no expect()" });
|
|
79
|
+
if (/\b(?:it|test|describe)\.skip\s*\(|\bxit\s*\(|\bxtest\s*\(|\b(?:it|test)\.todo\s*\(/.test(source)) {
|
|
80
|
+
findings.push({ category: "skipped-pending", confidence: "high", detail: "uses .skip / xit / xtest / .todo" });
|
|
81
|
+
}
|
|
82
|
+
const srcImports = [];
|
|
83
|
+
for (const imp of parseImports(source)) {
|
|
84
|
+
if (!imp.specifier.startsWith(".") && !imp.specifier.startsWith("#")) continue;
|
|
85
|
+
const resolved = resolveSrcImport(testFile, imp.specifier, rootDir);
|
|
86
|
+
if (!resolved) { findings.push({ category: "imports-orphan", confidence: "high", detail: `cannot resolve '${imp.specifier}'` }); continue; }
|
|
87
|
+
if (imp.named.length) {
|
|
88
|
+
const missing = imp.named.filter((n) => !extractExports(readFileSync(resolved, "utf-8")).has(n));
|
|
89
|
+
if (missing.length) findings.push({ category: "deprecated-export", confidence: "low", detail: `${imp.specifier} no longer exports: ${missing.join(", ")}` });
|
|
90
|
+
}
|
|
91
|
+
srcImports.push({ resolved: relative(rootDir, resolved), named: imp.named });
|
|
92
|
+
}
|
|
93
|
+
return { path: rel, srcImports, findings, itNames: extractItNames(source) };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function findSubsumed(perFile) {
|
|
97
|
+
const bySrc = new Map();
|
|
98
|
+
for (const f of perFile) for (const imp of f.srcImports) {
|
|
99
|
+
if (!bySrc.has(imp.resolved)) bySrc.set(imp.resolved, []);
|
|
100
|
+
bySrc.get(imp.resolved).push({ path: f.path, named: new Set(imp.named), itNames: f.itNames });
|
|
101
|
+
}
|
|
102
|
+
const out = [];
|
|
103
|
+
for (const [src, consumers] of bySrc) {
|
|
104
|
+
if (consumers.length < 2) continue;
|
|
105
|
+
const sorted = [...consumers].sort((a, b) => a.named.size - b.named.size);
|
|
106
|
+
const narrow = sorted[0]; const broad = sorted[sorted.length - 1];
|
|
107
|
+
if (narrow.path === broad.path || narrow.named.size === 0 || broad.named.size === 0) continue;
|
|
108
|
+
if (!(narrow.named.size < broad.named.size && [...narrow.named].every((n) => broad.named.has(n)))) continue;
|
|
109
|
+
// Require ≥50% of narrow's it() names to also appear in broad's. Imports
|
|
110
|
+
// alone are a false-positive trap: two tests can share helpers and assert
|
|
111
|
+
// on disjoint behavior (e.g. tests/budget.test.js vs tests/utils/budget.test.js).
|
|
112
|
+
if (narrow.itNames.size === 0) continue;
|
|
113
|
+
const overlap = [...narrow.itNames].filter((n) => broad.itNames.has(n)).length;
|
|
114
|
+
if (overlap / narrow.itNames.size < 0.5) continue;
|
|
115
|
+
out.push({ category: "subsumed-candidate", confidence: "low", path: narrow.path, detail: `${overlap}/${narrow.itNames.size} it() names also in ${broad.path}; subset of imports from ${src}` });
|
|
116
|
+
}
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function auditTestDiet(rootDir = DEFAULT_ROOT) {
|
|
121
|
+
const files = walkTests(rootDir);
|
|
122
|
+
const perFile = files.map((f) => auditFile(f, rootDir));
|
|
123
|
+
const findings = [];
|
|
124
|
+
for (const f of perFile) for (const x of f.findings) findings.push({ path: f.path, ...x });
|
|
125
|
+
for (const s of findSubsumed(perFile)) findings.push(s);
|
|
126
|
+
return { generatedAt: new Date().toISOString(), rootDir, totals: { filesAudited: files.length, findings: findings.length }, findings };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function renderMarkdown(report) {
|
|
130
|
+
const byCat = new Map();
|
|
131
|
+
for (const f of report.findings) {
|
|
132
|
+
if (!byCat.has(f.category)) byCat.set(f.category, []);
|
|
133
|
+
byCat.get(f.category).push(f);
|
|
134
|
+
}
|
|
135
|
+
const lines = [`# Semantic test-diet audit`, ``, `Generated: ${report.generatedAt}`, `Files audited: ${report.totals.filesAudited}`, `Findings: ${report.totals.findings}`, ``];
|
|
136
|
+
for (const [cat, items] of byCat) {
|
|
137
|
+
lines.push(`## ${cat} (${items.length})`, ``);
|
|
138
|
+
for (const it of items) lines.push(`- \`${it.path}\` — ${it.detail} (confidence: ${it.confidence})`);
|
|
139
|
+
lines.push(``);
|
|
140
|
+
}
|
|
141
|
+
return lines.join("\n");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
145
|
+
const report = auditTestDiet(DEFAULT_ROOT);
|
|
146
|
+
const outDir = join(DEFAULT_ROOT, "audit");
|
|
147
|
+
if (!existsSync(outDir)) mkdirSync(outDir, { recursive: true });
|
|
148
|
+
writeFileSync(join(outDir, "test-diet-semantic.json"), JSON.stringify(report, null, 2));
|
|
149
|
+
writeFileSync(join(outDir, "test-diet-semantic.md"), renderMarkdown(report));
|
|
150
|
+
console.log(`audit: ${report.totals.filesAudited} files, ${report.totals.findings} findings -> audit/test-diet-semantic.{json,md}`);
|
|
151
|
+
}
|
|
@@ -14,6 +14,7 @@ import { groupVulnerabilitiesBySeverity } from "./osv-findings.js";
|
|
|
14
14
|
import { groupFindingsBySeverity as groupSemgrepBySeverity } from "./semgrep-findings.js";
|
|
15
15
|
import { groupCyclesBySeverity } from "./circular-deps.js";
|
|
16
16
|
import { groupDeadExportsBySeverity } from "./dead-exports.js";
|
|
17
|
+
import { groupInjectionBySeverity } from "./injection-findings.js";
|
|
17
18
|
|
|
18
19
|
const MAX_SAMPLE_DEAD_EXPORTS = 10;
|
|
19
20
|
const MAX_SAMPLE_SONAR_PER_SEVERITY = 5;
|
|
@@ -21,6 +22,7 @@ const MAX_SAMPLE_OSV_PER_SEVERITY = 5;
|
|
|
21
22
|
const MAX_SAMPLE_SEMGREP_PER_SEVERITY = 5;
|
|
22
23
|
const MAX_SAMPLE_CYCLES_PER_SEVERITY = 5;
|
|
23
24
|
const MAX_SAMPLE_KNIP_PER_SEVERITY = 5;
|
|
25
|
+
const MAX_SAMPLE_INJECTION_PER_SEVERITY = 5;
|
|
24
26
|
|
|
25
27
|
/**
|
|
26
28
|
* @param {{basalCost?: object, growthDelta?: object, stack?: object, sonarFindings?: object, webperf?: object, osvFindings?: object}} ctx
|
|
@@ -38,11 +40,29 @@ export function formatDeterministicSummary(ctx) {
|
|
|
38
40
|
if (ctx.semgrepFindings) lines.push(...formatSemgrepBlock(ctx.semgrepFindings));
|
|
39
41
|
if (ctx.circularDeps) lines.push(...formatCircularDepsBlock(ctx.circularDeps));
|
|
40
42
|
if (ctx.deadExports) lines.push(...formatDeadExportsBlock(ctx.deadExports));
|
|
43
|
+
if (ctx.injectionFindings) lines.push(...formatInjectionBlock(ctx.injectionFindings));
|
|
41
44
|
if (ctx.webperf) lines.push(...formatWebperfBlock(ctx.webperf));
|
|
42
45
|
|
|
43
46
|
return lines.join("\n");
|
|
44
47
|
}
|
|
45
48
|
|
|
49
|
+
function formatInjectionBlock(inj) {
|
|
50
|
+
if (!inj.available) return ["### Prompt-injection scan", `- Status: not available — ${inj.reason || "scan failed"}`, ""];
|
|
51
|
+
const lines = ["### Prompt-injection scan (specs / domain / onboarding)", `- Files scanned: ${inj.scanned ?? 0}`, `- Findings: ${inj.total ?? 0}`];
|
|
52
|
+
if ((inj.total ?? 0) > 0) {
|
|
53
|
+
for (const [severity, items] of Object.entries(groupInjectionBySeverity(inj.findings || []))) {
|
|
54
|
+
if (items.length === 0) continue;
|
|
55
|
+
lines.push(` - ${severity} (${items.length}):`);
|
|
56
|
+
for (const f of items.slice(0, MAX_SAMPLE_INJECTION_PER_SEVERITY)) {
|
|
57
|
+
lines.push(` - ${f.file || "(unknown)"}${f.line ? `:${f.line}` : ""} [${f.type}] ${f.pattern}`);
|
|
58
|
+
}
|
|
59
|
+
if (items.length > MAX_SAMPLE_INJECTION_PER_SEVERITY) lines.push(` - ... and ${items.length - MAX_SAMPLE_INJECTION_PER_SEVERITY} more in ${severity}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
lines.push("");
|
|
63
|
+
return lines;
|
|
64
|
+
}
|
|
65
|
+
|
|
46
66
|
function formatCircularDepsBlock(circularDeps) {
|
|
47
67
|
if (!circularDeps.available) {
|
|
48
68
|
return ["### Circular Dependencies (architecture)", `- Status: not available — ${circularDeps.reason || "madge not available"}`, ""];
|