@cspeach/cli 1.1.28 → 1.1.30
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/dist/agent/loop.js +6 -3
- package/dist/agent/turn-error-ux.js +40 -0
- package/dist/auth/auth-file.js +2 -2
- package/dist/cli.js +4 -0
- package/dist/commands/login.js +1 -1
- package/dist/commands/logout.js +1 -1
- package/dist/commands/team.js +298 -26
- package/dist/commands/whoami.js +1 -1
- package/dist/config/paths.js +15 -1
- package/dist/one-shot.js +1 -0
- package/dist/projects/clean-core-confirm.js +36 -0
- package/dist/projects/extract-clean-core.js +79 -0
- package/dist/projects/save-command.js +38 -9
- package/dist/projects/validate.js +2 -1
- package/dist/projects/workspace.js +2 -1
- package/dist/register/atc-file.js +9 -1
- package/dist/register/findings.js +79 -0
- package/dist/register/measure.js +60 -1
- package/dist/register/offer.js +33 -0
- package/dist/register/picture.js +21 -0
- package/dist/register/records.js +32 -0
- package/dist/register/status-file/register-map.js +213 -0
- package/dist/register/status-file/register-model.js +307 -0
- package/dist/register/status-file/register-parts.js +509 -0
- package/dist/register/status-file/register-status-file.js +171 -0
- package/dist/register/status-file/team-css.js +5 -0
- package/dist/register/status-file-sync.js +19 -0
- package/dist/register/status-html.js +17 -0
- package/dist/register/workbooks.js +104 -0
- package/dist/register/write-whole.js +22 -0
- package/dist/register/xlsx.js +117 -0
- package/dist/renderer/tool-labels.js +2 -1
- package/dist/repl.js +1 -0
- package/dist/skills/bundled-skills.js +83 -83
- package/dist/tools/_filesystem-shared.js +16 -9
- package/dist/tools/project/team_findings.js +72 -0
- package/dist/tools/sap-write.js +8 -0
- package/dist/tools/tool-loading.js +1 -1
- package/dist/ui/app.js +4 -0
- package/dist/ui/widgets/hold-raw-mode.js +26 -0
- package/package.json +2 -2
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
const MANIFEST_RE = /<!--\s*(?:csforge|cspeach):clean-core-manifest\s*\n([\s\S]*?)\n?\s*-->/;
|
|
2
|
+
const DISPOSITIONS = ['keep', 'exception', 'fix', 'rebuild', 'retire'];
|
|
3
|
+
const MODES = ['triage', 'judge', 'adjudicate', 'advise'];
|
|
4
|
+
const MAX_REASON = 300;
|
|
5
|
+
const letter = (raw, id) => {
|
|
6
|
+
const v = raw.trim().toUpperCase();
|
|
7
|
+
if (v === '' || v === '-')
|
|
8
|
+
return null;
|
|
9
|
+
if (v === 'A' || v === 'B' || v === 'C' || v === 'D')
|
|
10
|
+
return v;
|
|
11
|
+
throw new Error(`${id}: a level is one of A, B, C, D or - (got "${raw.trim()}")`);
|
|
12
|
+
};
|
|
13
|
+
export function extractCleanCoreDecision(markdown) {
|
|
14
|
+
const m = MANIFEST_RE.exec(markdown);
|
|
15
|
+
if (!m)
|
|
16
|
+
throw new Error('No <!-- cspeach:clean-core-manifest --> block found in skill output');
|
|
17
|
+
const kv = {};
|
|
18
|
+
const rows = [];
|
|
19
|
+
const seen = new Set();
|
|
20
|
+
let inTable = false;
|
|
21
|
+
let blankInTable = false;
|
|
22
|
+
for (const raw of (m[1] ?? '').split('\n')) {
|
|
23
|
+
const line = raw.replace(/\r$/, '');
|
|
24
|
+
// Inside the table a blank line does not end it: a row after one is refused
|
|
25
|
+
// below, so no row can slip out of the table into the settings.
|
|
26
|
+
if (!line.trim()) {
|
|
27
|
+
if (inTable)
|
|
28
|
+
blankInTable = true;
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (inTable) {
|
|
32
|
+
// `adjudications:` is the last setting; an unindented line here is a broken row.
|
|
33
|
+
if (!/^\s+/.test(line))
|
|
34
|
+
throw new Error(`"${line.trim()}": a row must be indented under adjudications:`);
|
|
35
|
+
const cells = line.trim().split('|').map((c) => c.trim());
|
|
36
|
+
const [id = '', type = '', name = '', level = '', word = '', target = ''] = cells;
|
|
37
|
+
if (blankInTable)
|
|
38
|
+
throw new Error(`${id}: a row comes after a blank line inside adjudications:`);
|
|
39
|
+
// Rows are matched to their cards by id; two rows with one id would share one answer.
|
|
40
|
+
if (seen.has(id))
|
|
41
|
+
throw new Error(`${id}: two rows have this id`);
|
|
42
|
+
seen.add(id);
|
|
43
|
+
const reason = cells.slice(6).join(' | ').trim().slice(0, MAX_REASON);
|
|
44
|
+
const disposition = word.toLowerCase();
|
|
45
|
+
if (!DISPOSITIONS.includes(disposition))
|
|
46
|
+
throw new Error(`${id}: "${word}" is not one of ${DISPOSITIONS.join(', ')}`);
|
|
47
|
+
if (!type || !name)
|
|
48
|
+
throw new Error(`${id}: a row needs an object type and a name`);
|
|
49
|
+
if (disposition === 'exception' && !reason)
|
|
50
|
+
throw new Error(`${id}: an accepted exception needs a reason`);
|
|
51
|
+
const moves = disposition === 'fix' || disposition === 'rebuild';
|
|
52
|
+
rows.push({
|
|
53
|
+
id, objectType: type.toUpperCase(), objectName: name.toUpperCase(), level: letter(level, id), disposition,
|
|
54
|
+
targetLevel: moves ? letter(target, id) : null, reason, confirmed: false,
|
|
55
|
+
});
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
const colon = line.indexOf(':');
|
|
59
|
+
if (colon === -1)
|
|
60
|
+
continue;
|
|
61
|
+
const key = line.slice(0, colon).trim();
|
|
62
|
+
const value = line.slice(colon + 1).trim();
|
|
63
|
+
if (key === 'adjudications' && !value) {
|
|
64
|
+
inTable = true;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
kv[key] = value;
|
|
68
|
+
}
|
|
69
|
+
if (kv.artefact !== 'clean-core-decision')
|
|
70
|
+
throw new Error(`Manifest artefact mismatch: expected 'clean-core-decision', got '${kv.artefact}'`);
|
|
71
|
+
if (!rows.length)
|
|
72
|
+
throw new Error('clean-core-decision manifest has no rows');
|
|
73
|
+
const mode = MODES.includes(kv.input_mode ?? '') ? kv.input_mode : 'triage';
|
|
74
|
+
return {
|
|
75
|
+
title: kv.title || 'Clean-core decisions',
|
|
76
|
+
content: { inputMode: mode, scope: kv.scope ?? '', adjudications: rows },
|
|
77
|
+
items: rows.map((r) => ({ id: r.id, status: 'proposed', answer: null, answeredBy: null, answeredAt: null, comments: [] })),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
@@ -7,6 +7,7 @@ import { extractUpgradeScan, extractUpgradeProgress, extractUpgradeReport } from
|
|
|
7
7
|
import { extractCcaAssessment } from './extract-cca.js';
|
|
8
8
|
import { extractModernizeResult } from './extract-modernize.js';
|
|
9
9
|
import { extractTestCoverage } from './extract-test-coverage.js';
|
|
10
|
+
import { extractCleanCoreDecision } from './extract-clean-core.js';
|
|
10
11
|
import { extractPlan, laneConflictsWithPrior, LANE_NEW_FAMILY } from './extract-plan.js';
|
|
11
12
|
import { buildEnvelope } from './build.js';
|
|
12
13
|
import { buildPlanRevision } from './plan-run.js';
|
|
@@ -103,6 +104,14 @@ const SKILL_REGISTRY = {
|
|
|
103
104
|
itemNoun: 'phases',
|
|
104
105
|
manifestMarker: 'plan-manifest',
|
|
105
106
|
},
|
|
107
|
+
'abap-clean-core': {
|
|
108
|
+
// 2026-10-06 (clean-core mission control): a triage table. The rows become
|
|
109
|
+
// team project decisions only after one Yes per row (clean-core-confirm.ts).
|
|
110
|
+
artefactType: 'clean-core-decision',
|
|
111
|
+
extract: (md) => extractCleanCoreDecision(md),
|
|
112
|
+
itemNoun: 'decisions',
|
|
113
|
+
manifestMarker: 'clean-core-manifest',
|
|
114
|
+
},
|
|
106
115
|
};
|
|
107
116
|
/**
|
|
108
117
|
* C1 (2026-06-11, defect D22) — map an inline artifact-manifest block in the
|
|
@@ -128,6 +137,8 @@ export function detectArtifactSkill(markdown) {
|
|
|
128
137
|
return 'abap-modernize';
|
|
129
138
|
if (/<!--\s*(?:csforge|cspeach):test-coverage-manifest\b/.test(markdown))
|
|
130
139
|
return 'abap-test';
|
|
140
|
+
if (/<!--\s*(?:csforge|cspeach):clean-core-manifest\b/.test(markdown))
|
|
141
|
+
return 'abap-clean-core';
|
|
131
142
|
const up = /<!--\s*(?:csforge|cspeach):upgrade-manifest\s*\n([\s\S]*?)\n\s*-->/.exec(markdown);
|
|
132
143
|
if (up) {
|
|
133
144
|
const art = /(?:^|\n)\s*artefact:\s*(\S+)/.exec(up[1] ?? '');
|
|
@@ -360,22 +371,33 @@ export async function runSaveCommand(args) {
|
|
|
360
371
|
}
|
|
361
372
|
// C1 review fix — name the revision target BEFORE the confirm so the user
|
|
362
373
|
// knows what a 'y' will do. Log line only, deliberately NOT a second
|
|
363
|
-
// question: the B5 headless contract (below) allows
|
|
374
|
+
// question: the B5 headless contract (below) allows one `prompt` question.
|
|
364
375
|
if (planExtract && prior) {
|
|
365
376
|
const priorFilename = prior.path.split(/[\\\/]/).pop();
|
|
366
377
|
args.log(`[save] this will save as v${prior.envelope.version + 1} of existing plan '${prior.envelope.title}' (${priorFilename})`);
|
|
367
378
|
}
|
|
368
|
-
// CONTRACT (B5): runSaveCommand
|
|
369
|
-
//
|
|
370
|
-
//
|
|
371
|
-
//
|
|
372
|
-
//
|
|
373
|
-
//
|
|
379
|
+
// CONTRACT (B5): runSaveCommand asks 1+N cards — this save confirm, then
|
|
380
|
+
// (clean-core decisions only) one card per row. Only the save confirm may
|
|
381
|
+
// fall back to `prompt`: the headless path in loop.ts (maybeOfferSave)
|
|
382
|
+
// installs a prompt that blanket-answers 'y' to WHATEVER it is asked, so
|
|
383
|
+
// the row cards go through `confirm` only (never `prompt`), and a headless
|
|
384
|
+
// run confirms no row. Any further question routed through `prompt` would
|
|
385
|
+
// silently receive 'y' too: give maybeOfferSave's headless prompt a
|
|
386
|
+
// question-aware answer first.
|
|
374
387
|
const answer = await askYesNo({ question: 'Save as project file?', confirm: args.confirm, prompt: args.prompt });
|
|
375
388
|
if (!isYes(answer)) {
|
|
376
389
|
args.onNotSaved?.('declined');
|
|
377
390
|
return null;
|
|
378
391
|
}
|
|
392
|
+
// 2026-10-06 — a clean-core triage becomes team project decisions only row by
|
|
393
|
+
// row, each on its own Yes. Without a joined team project nothing is asked.
|
|
394
|
+
if (spec.artefactType === 'clean-core-decision') {
|
|
395
|
+
const { confirmCleanCoreRows } = await import('./clean-core-confirm.js');
|
|
396
|
+
const { activeProject } = await import('../register/store.js');
|
|
397
|
+
extract = await confirmCleanCoreRows(extract, {
|
|
398
|
+
...(args.confirm ? { confirm: args.confirm } : {}), log: args.log, joined: activeProject() !== null,
|
|
399
|
+
});
|
|
400
|
+
}
|
|
379
401
|
let env;
|
|
380
402
|
if (planExtract && prior) {
|
|
381
403
|
// C1 (D30) — the 49dd duplicate: the generic save used to mint a fresh
|
|
@@ -438,9 +460,16 @@ export async function runSaveCommand(args) {
|
|
|
438
460
|
const joined = activeProject();
|
|
439
461
|
if (joined) {
|
|
440
462
|
const { recordFromEnvelope } = await import('../register/records.js');
|
|
441
|
-
const res = recordFromEnvelope(env, path, joined, { name: args.author.name }, args.cwd);
|
|
463
|
+
const res = recordFromEnvelope(env, path, joined, { name: joined.name ?? args.author.name }, args.cwd);
|
|
442
464
|
if (res?.file)
|
|
443
|
-
args.log(`Recorded for team project ${joined.projectId}: ${res.written} objects → ${res.file}`);
|
|
465
|
+
args.log(`Recorded for team project ${joined.projectId}: ${res.written} objects → ${basename(res.file)}`);
|
|
466
|
+
if (res?.file) {
|
|
467
|
+
const { regenerateWorkingList } = await import('../register/workbooks.js');
|
|
468
|
+
// The name the team project knows this laptop by (init --lead), as the team verbs use.
|
|
469
|
+
const sheet = regenerateWorkingList(joined, joined.name ?? args.author.name);
|
|
470
|
+
if (sheet.line)
|
|
471
|
+
args.log(sheet.line.trimStart());
|
|
472
|
+
}
|
|
444
473
|
if (res?.refused.length) {
|
|
445
474
|
args.log(`Not recorded (${res.refused.length} not in the team project's object list): ${res.refused.slice(0, 5).join(', ')}${res.refused.length > 5 ? ', …' : ''}`);
|
|
446
475
|
}
|
|
@@ -5,7 +5,7 @@ const KNOWN_ARTEFACT_TYPES = [
|
|
|
5
5
|
'upgrade-scan', 'upgrade-progress', 'upgrade-report',
|
|
6
6
|
'cca-assessment',
|
|
7
7
|
'modernize-result', 'test-coverage',
|
|
8
|
-
'plan',
|
|
8
|
+
'plan', 'clean-core-decision',
|
|
9
9
|
];
|
|
10
10
|
const STATUS_BY_TYPE = {
|
|
11
11
|
'spec-gap': ['open', 'answered', 'deferred', 'n-a'],
|
|
@@ -20,6 +20,7 @@ const STATUS_BY_TYPE = {
|
|
|
20
20
|
'modernize-result': ['applied', 'skipped', 'failed', 'pending'],
|
|
21
21
|
'test-coverage': ['generated', 'skipped', 'failed', 'pending'],
|
|
22
22
|
'plan': ['todo', 'designing', 'building', 'verifying', 'validated', 'validated-with-waiver', 'blocked'],
|
|
23
|
+
'clean-core-decision': ['proposed', 'confirmed', 'declined'],
|
|
23
24
|
};
|
|
24
25
|
const REQUIRED_TOP_LEVEL = [
|
|
25
26
|
'schemaVersion', 'artefactType', 'id', 'title',
|
|
@@ -27,6 +27,7 @@ import { dirname, join, isAbsolute } from 'node:path';
|
|
|
27
27
|
import os from 'node:os';
|
|
28
28
|
import toml from '@iarna/toml';
|
|
29
29
|
import { loadConfig } from '../config/loader.js';
|
|
30
|
+
import { configFile } from '../config/paths.js';
|
|
30
31
|
/**
|
|
31
32
|
* Walk up the directory tree from `startDir` looking for a `.git` entry
|
|
32
33
|
* (file or directory — submodules use a file). Returns the directory
|
|
@@ -152,7 +153,7 @@ export async function ensureWorkspace() {
|
|
|
152
153
|
export function ensureWorkspaceSync() {
|
|
153
154
|
let target = defaultWorkspaceFor(process.cwd());
|
|
154
155
|
try {
|
|
155
|
-
const cfgPath =
|
|
156
|
+
const cfgPath = configFile();
|
|
156
157
|
if (existsSync(cfgPath)) {
|
|
157
158
|
const raw = readFileSync(cfgPath, 'utf-8');
|
|
158
159
|
const parsed = toml.parse(raw);
|
|
@@ -254,6 +254,14 @@ const quoteNeverClosed = (line) => `A quotation mark in line ${line} is never cl
|
|
|
254
254
|
* binary on one line — none of which may reach a person through CSPeach.
|
|
255
255
|
*/
|
|
256
256
|
export function shown(text) {
|
|
257
|
+
return shownUpTo(text, 60);
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* The same cleaning as `shown()`, with a longer cap: for text a person wrote on
|
|
261
|
+
* purpose (a decision's reason), which is read whole, not glanced at in a list.
|
|
262
|
+
* Kept to `max` characters, then "…".
|
|
263
|
+
*/
|
|
264
|
+
export function shownUpTo(text, max) {
|
|
257
265
|
const flat = String(text)
|
|
258
266
|
// An OSC sequence runs to BEL or ESC-backslash and can retitle the window.
|
|
259
267
|
.replace(/\x1b\][^\x07\x1b]*(\x07|\x1b\\)?/g, ' ')
|
|
@@ -268,7 +276,7 @@ export function shown(text) {
|
|
|
268
276
|
.replace(/[\x00-\x1f\x7f-\x9f]+/g, ' ')
|
|
269
277
|
.replace(/\s+/g, ' ')
|
|
270
278
|
.trim();
|
|
271
|
-
return flat.length >
|
|
279
|
+
return flat.length > max ? `${flat.slice(0, max)}…` : flat;
|
|
272
280
|
}
|
|
273
281
|
/** Five names and the count of the rest: a list nobody can read is a list nobody acts on. */
|
|
274
282
|
export const upToFive = (items) => (items.length > 5 ? `${items.slice(0, 5).join(', ')} and ${items.length - 5} more` : items.join(', '));
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rule by rule, what the runs found in each object — the detail the register
|
|
3
|
+
* itself keeps only as counts. Read from the same measurement records, with the
|
|
4
|
+
* register's own two rules: only the team project's check variant counts, and
|
|
5
|
+
* live evidence outranks file evidence, per object.
|
|
6
|
+
*
|
|
7
|
+
* Used by `cspeach team fixed` (which rules are open now) and by the read-only
|
|
8
|
+
* team_findings tool (what a triage proposes from). No source code is in here:
|
|
9
|
+
* a record carries a rule id and a message title, nothing more.
|
|
10
|
+
*/
|
|
11
|
+
import { normalizeKey, normalizeRuleId, UNNAMED_FINDING } from '@cspeach/register-core';
|
|
12
|
+
import { shown } from './atc-file.js';
|
|
13
|
+
import { collectRecords, readProjectFile } from './store.js';
|
|
14
|
+
/** Re-exported for the read-only team_findings tool: the one cleaning, a longer cap. */
|
|
15
|
+
export { shownUpTo } from './atc-file.js';
|
|
16
|
+
const variantKey = (v) => (typeof v === 'string' ? v.trim().toUpperCase() : '');
|
|
17
|
+
const basisOf = (v) => {
|
|
18
|
+
if (v === undefined || v === null)
|
|
19
|
+
return 'system';
|
|
20
|
+
const w = String(v).trim().toLowerCase();
|
|
21
|
+
return w === '' || w === 'system' ? 'system' : 'file';
|
|
22
|
+
};
|
|
23
|
+
export function findingsByObject(projectDir, outboxDir) {
|
|
24
|
+
const project = readProjectFile(projectDir);
|
|
25
|
+
const ours = variantKey(project.atcVariant);
|
|
26
|
+
const records = [
|
|
27
|
+
...collectRecords(projectDir).records,
|
|
28
|
+
...(outboxDir && outboxDir !== projectDir ? collectRecords(outboxDir).records : []),
|
|
29
|
+
];
|
|
30
|
+
const runs = records
|
|
31
|
+
.filter((r) => r?.kind === 'record' && r.recordType === 'measurement' && r.projectId === project.projectId
|
|
32
|
+
&& typeof r.recordId === 'string' && Number.isFinite(Date.parse(r.at)) && Array.isArray(r.rows))
|
|
33
|
+
.filter((r) => { const theirs = variantKey(r.atcVariant); return !(theirs && ours && theirs !== ours); })
|
|
34
|
+
.map((r) => ({ ...r, at: new Date(r.at).toISOString() }))
|
|
35
|
+
.sort((a, b) => (a.at < b.at ? -1 : a.at > b.at ? 1 : a.recordId < b.recordId ? -1 : a.recordId > b.recordId ? 1 : 0));
|
|
36
|
+
const seenIds = new Set();
|
|
37
|
+
const byKey = new Map();
|
|
38
|
+
for (const r of runs) {
|
|
39
|
+
if (seenIds.has(r.recordId))
|
|
40
|
+
continue; // a sync tool's "(1)" copy
|
|
41
|
+
seenIds.add(r.recordId);
|
|
42
|
+
for (const row of r.rows) {
|
|
43
|
+
if (typeof row?.key !== 'string' || !Array.isArray(row.findings))
|
|
44
|
+
continue;
|
|
45
|
+
const key = normalizeKey(row.key);
|
|
46
|
+
const list = byKey.get(key) ?? [];
|
|
47
|
+
let seen = list.find((s) => s.recordId === r.recordId);
|
|
48
|
+
if (!seen) {
|
|
49
|
+
seen = { at: r.at, recordId: r.recordId, basis: basisOf(r.basis), rules: new Map() };
|
|
50
|
+
list.push(seen);
|
|
51
|
+
}
|
|
52
|
+
for (const f of row.findings) {
|
|
53
|
+
const named = !!f && typeof f === 'object';
|
|
54
|
+
const rule = (named ? normalizeRuleId(f.rule) : typeof f === 'string' ? normalizeRuleId(f) : '') || UNNAMED_FINDING;
|
|
55
|
+
// `shown()`: the title was written on another laptop or came out of a
|
|
56
|
+
// handed-over file, and it goes to a terminal and to the model's tools.
|
|
57
|
+
const message = named && typeof f.message === 'string' ? shown(f.message) : '';
|
|
58
|
+
const had = seen.rules.get(rule);
|
|
59
|
+
if (had) {
|
|
60
|
+
had.count += 1;
|
|
61
|
+
if (!had.message && message)
|
|
62
|
+
had.message = message;
|
|
63
|
+
}
|
|
64
|
+
else
|
|
65
|
+
seen.rules.set(rule, { rule, message, count: 1 });
|
|
66
|
+
}
|
|
67
|
+
byKey.set(key, list);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const out = new Map();
|
|
71
|
+
for (const [key, list] of byKey) {
|
|
72
|
+
const driving = list.some((s) => s.basis === 'system') ? list.filter((s) => s.basis === 'system') : list;
|
|
73
|
+
const first = driving[0] ?? null;
|
|
74
|
+
const last = driving[driving.length - 1] ?? null;
|
|
75
|
+
const rules = (s) => (s ? [...s.rules.values()].sort((a, b) => (a.rule < b.rule ? -1 : a.rule > b.rule ? 1 : 0)) : []);
|
|
76
|
+
out.set(key, { key, baselineAt: first?.at ?? null, baseline: rules(first), latestAt: last?.at ?? null, latest: rules(last) });
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
package/dist/register/measure.js
CHANGED
|
@@ -39,6 +39,8 @@ import { writeRecord } from './records.js';
|
|
|
39
39
|
* row they get says so.
|
|
40
40
|
*/
|
|
41
41
|
export const ABSENT_MEANS_CLEAN = false;
|
|
42
|
+
/** R-1: how many silent objects one measurement checks again, one run each. */
|
|
43
|
+
export const MAX_SECOND_PASS = 200;
|
|
42
44
|
export function scopeLabel(scope) {
|
|
43
45
|
if (scope.kind === 'lane')
|
|
44
46
|
return `lane:${scope.id.trim()}`;
|
|
@@ -257,6 +259,7 @@ function readOutcome(answer) {
|
|
|
257
259
|
return o;
|
|
258
260
|
}
|
|
259
261
|
export async function measure(input) {
|
|
262
|
+
const started = Date.now();
|
|
260
263
|
const label = scopeLabel(input.scope);
|
|
261
264
|
// One object, one row, however often the baseline names it: a case-twin would
|
|
262
265
|
// otherwise be asked for twice and counted twice.
|
|
@@ -326,6 +329,60 @@ export async function measure(input) {
|
|
|
326
329
|
if (!(said === 'absent' && evidenceOf.get(key) === 'listed'))
|
|
327
330
|
evidenceOf.set(key, said);
|
|
328
331
|
}
|
|
332
|
+
// ── R-1: the objects the run did not list, each checked again on its own ──
|
|
333
|
+
let secondPass;
|
|
334
|
+
let notChecked = new Set();
|
|
335
|
+
let ownFindings = [];
|
|
336
|
+
if (input.confirmAbsent && !absentMeansClean && !outcome.stopped && !input.signal?.aborted) {
|
|
337
|
+
const silent = inScope.filter((o) => buckets.get(objectKey(o.type, o.name)) === 'absent');
|
|
338
|
+
if (silent.length) {
|
|
339
|
+
const toRun = silent.slice(0, Math.max(0, input.secondPassCap ?? MAX_SECOND_PASS));
|
|
340
|
+
const skipped = silent.slice(toRun.length).map((o) => objectKey(o.type, o.name));
|
|
341
|
+
const left = input.maxTotalMs ? input.maxTotalMs - (Date.now() - started) : undefined;
|
|
342
|
+
if (toRun.length && (left === undefined || left > 0)) {
|
|
343
|
+
const again = readOutcome(await input.atc.runSet(toRun.map((o) => ({ type: o.type, name: o.name })), variant, {
|
|
344
|
+
chunkSize: 1, maxSingleRuns: 0,
|
|
345
|
+
...(input.signal ? { signal: input.signal } : {}),
|
|
346
|
+
...(left !== undefined ? { maxTotalMs: left } : {}),
|
|
347
|
+
...(input.onProgress ? { onProgress: input.onProgress } : {}),
|
|
348
|
+
}));
|
|
349
|
+
const asked = new Set(toRun.map((o) => objectKey(o.type, o.name)));
|
|
350
|
+
// Only a run that LISTED the object counts. A refused or cut-short own run
|
|
351
|
+
// leaves it exactly where the first run left it: absent, no row.
|
|
352
|
+
const seen = new Set(again.measured.filter((m) => m?.evidence === 'listed').map(refKey).filter((k) => asked.has(k)));
|
|
353
|
+
for (const key of seen) {
|
|
354
|
+
claims.get(key)?.delete('absent');
|
|
355
|
+
claim(key, 'measured');
|
|
356
|
+
buckets.set(key, 'measured');
|
|
357
|
+
evidenceOf.set(key, 'listed');
|
|
358
|
+
}
|
|
359
|
+
ownFindings = again.findings.filter((f) => typeof f?.key === 'string' && seen.has(normalizeKey(f.key)));
|
|
360
|
+
// An own run that FINISHED and still did not list the object is the
|
|
361
|
+
// variant's silence, said twice. Anything else — refused, never reached —
|
|
362
|
+
// was not checked again, and nothing may be said about why.
|
|
363
|
+
const didNotFinish = new Set([
|
|
364
|
+
...again.failed.map(refKey),
|
|
365
|
+
...again.chunks.filter((c) => c?.status === 'refused' && !c.superseded).flatMap((c) => list(c.asked).map(refKey)),
|
|
366
|
+
]);
|
|
367
|
+
const silentAgain = new Set(again.measured.filter((m) => m?.evidence !== 'listed').map(refKey)
|
|
368
|
+
.filter((k) => asked.has(k) && !seen.has(k) && !didNotFinish.has(k)));
|
|
369
|
+
const unreached = [...asked].filter((k) => !seen.has(k) && !silentAgain.has(k));
|
|
370
|
+
const stopped = STOPPED_BY.has(again.stopped) ? again.stopped
|
|
371
|
+
: input.signal?.aborted ? 'aborted' : undefined;
|
|
372
|
+
notChecked = new Set([...unreached, ...skipped]);
|
|
373
|
+
secondPass = {
|
|
374
|
+
asked: toRun.length, listed: seen.size, skipped: skipped.length,
|
|
375
|
+
...(skipped.length ? { skippedBy: 'cap' } : {}),
|
|
376
|
+
...(unreached.length ? { notChecked: unreached.length } : {}),
|
|
377
|
+
...(stopped ? { stopped } : {}),
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
else {
|
|
381
|
+
notChecked = new Set(silent.map((o) => objectKey(o.type, o.name)));
|
|
382
|
+
secondPass = { asked: 0, listed: 0, skipped: silent.length, skippedBy: toRun.length ? 'time' : 'cap' };
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
329
386
|
// Row order follows the scope, so two runs of the same lane read the same way.
|
|
330
387
|
const rowRefs = inScope.filter((o) => buckets.get(objectKey(o.type, o.name)) === 'measured')
|
|
331
388
|
.map((o) => ({ type: o.type, name: o.name, ...(evidenceOf.get(objectKey(o.type, o.name)) ? { evidence: evidenceOf.get(objectKey(o.type, o.name)) } : {}) }));
|
|
@@ -347,10 +404,11 @@ export async function measure(input) {
|
|
|
347
404
|
...coverage, unsupportedNames, absentNames, scope: label, refusedChunks,
|
|
348
405
|
...(duplicates ? { duplicateFindingsMerged: duplicates } : {}),
|
|
349
406
|
...(stoppedBy ? { stoppedBy } : {}),
|
|
407
|
+
...(secondPass ? { secondPass, notCheckedNames: keysInScope.filter((k) => notChecked.has(k)).slice(0, MAX_ABSENT_NAMES) } : {}),
|
|
350
408
|
};
|
|
351
409
|
if (!rowRefs.length)
|
|
352
410
|
return { ...counts, file: null, withFindings: 0, refusedRows };
|
|
353
|
-
const rows = findingsToRows(rowRefs, outcome.findings, input.project.type);
|
|
411
|
+
const rows = findingsToRows(rowRefs, [...outcome.findings, ...ownFindings], input.project.type);
|
|
354
412
|
const res = writeRecord(input.joined, 'measurement', rows, input.by, {
|
|
355
413
|
...(input.now ? { now: input.now } : {}), ...(input.dir ? { dir: input.dir } : {}),
|
|
356
414
|
...(input.fileName ? { fileName: input.fileName } : {}), ...(input.rand ? { rand: input.rand } : {}),
|
|
@@ -361,6 +419,7 @@ export async function measure(input) {
|
|
|
361
419
|
// below are recomputed after the refusal and are the ones a caller prints.
|
|
362
420
|
extra: {
|
|
363
421
|
atcVariant: variant, scope: label, basis: 'system', coverage,
|
|
422
|
+
...(secondPass ? { secondPass } : {}),
|
|
364
423
|
...(duplicates ? { duplicateFindingsMerged: duplicates } : {}),
|
|
365
424
|
},
|
|
366
425
|
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one line CSPeach prints after it activated, and verified, an object of
|
|
3
|
+
* the team project this laptop has joined (spec §4.3). It records nothing:
|
|
4
|
+
* "CSPeach activated it" and "the developer says it is fixed" are two different
|
|
5
|
+
* sentences, and only the second is a claim.
|
|
6
|
+
*/
|
|
7
|
+
import { objectKey } from '@cspeach/register-core';
|
|
8
|
+
import { activeProject, readBaseline } from './store.js';
|
|
9
|
+
export function teamFixOffers(objects) {
|
|
10
|
+
try {
|
|
11
|
+
const joined = activeProject();
|
|
12
|
+
if (!joined)
|
|
13
|
+
return [];
|
|
14
|
+
const baseline = readBaseline(joined.projectDir);
|
|
15
|
+
const byKey = new Map(baseline.map((o) => [objectKey(o.type, o.name), o]));
|
|
16
|
+
const byName = new Map();
|
|
17
|
+
for (const o of baseline)
|
|
18
|
+
byName.set(o.name.toUpperCase(), [...(byName.get(o.name.toUpperCase()) ?? []), o]);
|
|
19
|
+
const lines = [];
|
|
20
|
+
for (const o of objects) {
|
|
21
|
+
const type = String(o?.type ?? '').split('/')[0].trim().toUpperCase();
|
|
22
|
+
const name = String(o?.name ?? '').trim().toUpperCase();
|
|
23
|
+
const same = byName.get(name) ?? [];
|
|
24
|
+
const hit = byKey.get(objectKey(type, name)) ?? (same.length === 1 ? same[0] : undefined);
|
|
25
|
+
if (hit)
|
|
26
|
+
lines.push(`This object is in your team project. Mark it fixed? cspeach team fixed ${hit.type.toUpperCase()} ${hit.name.toUpperCase()}`);
|
|
27
|
+
}
|
|
28
|
+
return [...new Set(lines)];
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return []; // a folder halfway through a sync must never touch an activation
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { buildRegister, narrowedLanes } from '@cspeach/register-core';
|
|
3
|
+
import { collectRecords, readBaseline, readLanes, readProjectFile } from './store.js';
|
|
4
|
+
export function registerFor(projectDir, outboxDir) {
|
|
5
|
+
const project = readProjectFile(projectDir);
|
|
6
|
+
const collected = collectRecords(projectDir);
|
|
7
|
+
// This laptop's own outbox counts too, so a developer sees their work before
|
|
8
|
+
// handing it in. It is also the one way this picture can legitimately differ
|
|
9
|
+
// from the team project page's, so `status` says out loud when it counted them.
|
|
10
|
+
const own = outboxDir && path.resolve(outboxDir) !== path.resolve(projectDir) ? collectRecords(outboxDir) : { records: [], unreadable: 0, files: 0 };
|
|
11
|
+
const baseline = readBaseline(projectDir);
|
|
12
|
+
const lanes = readLanes(projectDir);
|
|
13
|
+
const register = buildRegister({ project, baseline, lanes, records: [...collected.records, ...own.records] });
|
|
14
|
+
return {
|
|
15
|
+
register, files: collected.files + own.files,
|
|
16
|
+
unreadable: collected.unreadable + own.unreadable, ownFiles: own.files,
|
|
17
|
+
// F2: a lanes.json written before names began to narrow packages holds
|
|
18
|
+
// fewer objects now. The lead is told, not left to work it out from a count.
|
|
19
|
+
narrowed: narrowedLanes(lanes, baseline),
|
|
20
|
+
};
|
|
21
|
+
}
|
package/dist/register/records.js
CHANGED
|
@@ -137,6 +137,34 @@ export function fixRowsFromUpgradeProgress(content, baseline) {
|
|
|
137
137
|
}
|
|
138
138
|
return { rows, ambiguous };
|
|
139
139
|
}
|
|
140
|
+
/**
|
|
141
|
+
* Decision rows from a saved clean-core triage — CONFIRMED rows only (one Yes
|
|
142
|
+
* per row, clean-core-confirm.ts). The five words map onto the four
|
|
143
|
+
* classifications (spec §4.2); a fix or a rebuild with no target reaches A.
|
|
144
|
+
*/
|
|
145
|
+
export function decisionRowsFromCleanCore(content) {
|
|
146
|
+
const rows = [];
|
|
147
|
+
for (const r of content.adjudications ?? []) {
|
|
148
|
+
if (r?.confirmed !== true)
|
|
149
|
+
continue;
|
|
150
|
+
const moves = r.disposition === 'fix' || r.disposition === 'rebuild';
|
|
151
|
+
const base = r.disposition === 'keep' ? { classification: 'keep' }
|
|
152
|
+
: r.disposition === 'exception' ? { classification: 'keep', exception: true }
|
|
153
|
+
: r.disposition === 'fix' ? { classification: 'fix' }
|
|
154
|
+
: r.disposition === 'rebuild' ? { classification: 'redesign' }
|
|
155
|
+
: r.disposition === 'retire' ? { classification: 'retire' } : null;
|
|
156
|
+
if (!base)
|
|
157
|
+
continue;
|
|
158
|
+
const reason = typeof r.reason === 'string' ? r.reason.trim().slice(0, 300) : '';
|
|
159
|
+
rows.push({
|
|
160
|
+
key: objectKey(r.objectType, r.objectName), ...base,
|
|
161
|
+
...(r.level ? { level: r.level } : {}),
|
|
162
|
+
...(moves ? { targetLevel: r.targetLevel ?? 'A' } : {}),
|
|
163
|
+
...(reason ? { reason } : {}),
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
return rows;
|
|
167
|
+
}
|
|
140
168
|
/**
|
|
141
169
|
* Called after every successful envelope save. Does nothing unless this laptop
|
|
142
170
|
* has joined a project; never throws (a record is a by-product, a save is not).
|
|
@@ -160,5 +188,9 @@ opts = {}) {
|
|
|
160
188
|
const res = writeRecord(joined, 'fix', rows, by, { source, ...opts });
|
|
161
189
|
return { ...res, refused: [...res.refused, ...ambiguous.map((n) => `${n} (name matches more than one object type)`)] };
|
|
162
190
|
}
|
|
191
|
+
if (env.artefactType === 'clean-core-decision') {
|
|
192
|
+
const rows = decisionRowsFromCleanCore(env.content);
|
|
193
|
+
return writeRecord(joined, 'decision', rows, by, { source, ...opts });
|
|
194
|
+
}
|
|
163
195
|
return null;
|
|
164
196
|
}
|