@cspeach/cli 1.1.29 → 1.1.31
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/.cspeach/project.json +4 -0
- package/README.md +11 -1
- package/dist/agent/loop.js +127 -11
- package/dist/auth/auth-file.js +2 -2
- package/dist/cli.js +39 -13
- package/dist/commands/config-set.js +1 -1
- package/dist/commands/login.js +1 -1
- package/dist/commands/logout.js +1 -1
- package/dist/commands/plan-continue.js +1 -1
- package/dist/commands/plan-gate.js +8 -1
- package/dist/commands/plan-resume.js +24 -18
- package/dist/commands/plan-startup.js +49 -6
- package/dist/commands/team-discover.js +195 -0
- package/dist/commands/team-plan.js +585 -0
- package/dist/commands/team.js +652 -46
- package/dist/commands/whoami.js +1 -1
- package/dist/config/paths.js +15 -1
- package/dist/journey/chain.js +728 -0
- package/dist/journey/clean-core.js +213 -0
- package/dist/journey/flow.js +166 -0
- package/dist/journey/run.js +435 -0
- package/dist/journey/session-deps.js +77 -0
- package/dist/journey/trigger.js +153 -0
- package/dist/lane/app-chain.js +9 -7
- package/dist/lane/chain-token.js +9 -5
- package/dist/lane/roots.js +97 -41
- package/dist/one-shot.js +21 -1
- package/dist/projects/clean-core-confirm.js +192 -0
- package/dist/projects/detail-path.js +9 -4
- package/dist/projects/extract-clean-core.js +82 -0
- package/dist/projects/migration.js +4 -105
- package/dist/projects/plan-lane.js +67 -17
- package/dist/projects/plan-sources.js +13 -7
- package/dist/projects/project-folder.js +131 -0
- package/dist/projects/promote-command.js +39 -0
- package/dist/projects/save-command.js +115 -15
- package/dist/projects/saved-lines.js +17 -3
- package/dist/projects/sort-command.js +499 -0
- package/dist/projects/sort-core.js +248 -0
- package/dist/projects/validate.js +2 -1
- package/dist/projects/workspace-retire.js +48 -0
- package/dist/projects/workspace.js +182 -120
- package/dist/register/atc-file.js +9 -1
- package/dist/register/discover.js +901 -0
- package/dist/register/findings.js +82 -0
- package/dist/register/measure.js +60 -1
- package/dist/register/offer.js +125 -0
- package/dist/register/picture.js +25 -0
- package/dist/register/plan-import.js +325 -0
- package/dist/register/records.js +57 -0
- package/dist/register/status-file/estate-parts.js +168 -0
- package/dist/register/status-file/plan-parts.js +68 -0
- package/dist/register/status-file/progress-chart.js +110 -0
- package/dist/register/status-file/register-map.js +221 -0
- package/dist/register/status-file/register-model.js +341 -0
- package/dist/register/status-file/register-parts.js +555 -0
- package/dist/register/status-file/register-status-file.js +189 -0
- package/dist/register/status-file/team-css.js +5 -0
- package/dist/register/status-file-sync.js +22 -0
- package/dist/register/status-html.js +19 -0
- package/dist/register/store.js +40 -2
- package/dist/register/workbooks.js +124 -0
- package/dist/register/write-whole.js +22 -0
- package/dist/register/xlsx.js +127 -0
- package/dist/register/xref.js +117 -0
- package/dist/renderer/startup-lines.js +11 -0
- package/dist/renderer/tool-labels.js +3 -1
- package/dist/renderer/tool-widget.js +15 -1
- package/dist/repl/at-completer.js +6 -2
- package/dist/repl/at-picker.js +1 -1
- package/dist/repl/journey-entry.js +25 -0
- package/dist/repl.js +158 -39
- package/dist/session/backfill.js +342 -0
- package/dist/session/project-match.js +63 -0
- package/dist/session/resume.js +72 -24
- package/dist/session/schema.js +3 -1
- package/dist/session/store.js +96 -51
- package/dist/skill-catalog.js +2 -2
- package/dist/skills/bundled-skills.js +45 -45
- package/dist/tools/_filesystem-shared.js +190 -21
- package/dist/tools/_team-work-path.js +133 -0
- package/dist/tools/approval.js +7 -1
- package/dist/tools/filesystem/extract-xlsx.js +27 -3
- package/dist/tools/filesystem/file-edit.js +12 -4
- package/dist/tools/filesystem/file-read.js +8 -4
- package/dist/tools/filesystem/file-write.js +12 -4
- package/dist/tools/filesystem/glob.js +33 -8
- package/dist/tools/filesystem/grep.js +9 -4
- package/dist/tools/filesystem/read-document.js +6 -3
- package/dist/tools/project/cca_merge.js +8 -2
- package/dist/tools/project/team_estate.js +91 -0
- package/dist/tools/project/team_findings.js +145 -0
- package/dist/tools/project/upgrade_merge_progress.js +8 -2
- package/dist/tools/sap-read.js +21 -4
- package/dist/tools/sap-write.js +203 -5
- package/dist/tools/subagent/read_agent.js +3 -0
- package/dist/tools/tool-loading.js +2 -2
- package/dist/tools/transport-fit-check.js +117 -0
- package/dist/tools/transport-fit.js +15 -7
- package/dist/tools/transport.js +57 -2
- package/dist/ui/app.js +2 -2
- package/dist/ui/approval-modal.js +74 -6
- package/dist/ui/widgets/confirm-card.js +68 -5
- package/dist/util/open-in-browser.js +143 -0
- package/package.json +120 -120
|
@@ -0,0 +1,82 @@
|
|
|
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, opts = {}) {
|
|
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
|
+
const line = opts.lines && named && Number.isInteger(f.line) && f.line > 0 ? f.line : undefined;
|
|
60
|
+
if (had) {
|
|
61
|
+
had.count += 1;
|
|
62
|
+
if (!had.message && message)
|
|
63
|
+
had.message = message;
|
|
64
|
+
if (line !== undefined && (had.line === undefined || line < had.line))
|
|
65
|
+
had.line = line;
|
|
66
|
+
}
|
|
67
|
+
else
|
|
68
|
+
seen.rules.set(rule, { rule, message, count: 1, ...(line !== undefined ? { line } : {}) });
|
|
69
|
+
}
|
|
70
|
+
byKey.set(key, list);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const out = new Map();
|
|
74
|
+
for (const [key, list] of byKey) {
|
|
75
|
+
const driving = list.some((s) => s.basis === 'system') ? list.filter((s) => s.basis === 'system') : list;
|
|
76
|
+
const first = driving[0] ?? null;
|
|
77
|
+
const last = driving[driving.length - 1] ?? null;
|
|
78
|
+
const rules = (s) => (s ? [...s.rules.values()].sort((a, b) => (a.rule < b.rule ? -1 : a.rule > b.rule ? 1 : 0)) : []);
|
|
79
|
+
out.set(key, { key, baselineAt: first?.at ?? null, baseline: rules(first), latestAt: last?.at ?? null, latest: rules(last) });
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
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,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The line CSPeach prints after it activated, and verified, an object of the
|
|
3
|
+
* team project this laptop has joined (spec §4.3).
|
|
4
|
+
*
|
|
5
|
+
* 1.1.31 (owner, 2026-10-07: "why would developers claim it when CSPeach is
|
|
6
|
+
* working?"): when CSPeach itself wrote the object's source in this session,
|
|
7
|
+
* the fix is recorded for the developer — the claim covers every rule open at
|
|
8
|
+
* the object's last measurement, with the transport the write used. A claim is
|
|
9
|
+
* still not proof: only the next measurement moves it to verified.
|
|
10
|
+
* An object CSPeach activated but did not write (someone changed it in Eclipse
|
|
11
|
+
* or SE80) only gets the one-line offer; the developer claims that one.
|
|
12
|
+
*/
|
|
13
|
+
import { objectKey } from '@cspeach/register-core';
|
|
14
|
+
import { activeProject, readBaseline } from './store.js';
|
|
15
|
+
import { findingsByObject } from './findings.js';
|
|
16
|
+
import { writeRecord } from './records.js';
|
|
17
|
+
import { regenerateWorkingList } from './workbooks.js';
|
|
18
|
+
/** The source-writing tools, and the argument that names the object each one wrote. */
|
|
19
|
+
const SOURCE_WRITES = {
|
|
20
|
+
sap_set_source: 'name',
|
|
21
|
+
sap_update_method: 'class_name',
|
|
22
|
+
sap_set_class_include: 'className',
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Objects whose source CSPeach wrote successfully in this session (upper-case
|
|
26
|
+
* name → the transport the write used, '' when none is known).
|
|
27
|
+
*/
|
|
28
|
+
export function cspeachWritesThisSession(toolCalls) {
|
|
29
|
+
const out = new Map();
|
|
30
|
+
for (const c of toolCalls ?? []) {
|
|
31
|
+
if (!c.completed || c.is_error)
|
|
32
|
+
continue;
|
|
33
|
+
const field = SOURCE_WRITES[c.tool];
|
|
34
|
+
if (!field)
|
|
35
|
+
continue;
|
|
36
|
+
const args = (c.args ?? {});
|
|
37
|
+
const name = String(args[field] ?? '').trim().toUpperCase();
|
|
38
|
+
if (!name)
|
|
39
|
+
continue;
|
|
40
|
+
let transport = typeof args['transport'] === 'string' ? args['transport'] : '';
|
|
41
|
+
try {
|
|
42
|
+
// sap_set_source / sap_update_method report the request they used as transport_used.
|
|
43
|
+
const r = JSON.parse(c.result);
|
|
44
|
+
if (typeof r?.transport_used === 'string' && r.transport_used)
|
|
45
|
+
transport = r.transport_used;
|
|
46
|
+
else if (typeof r?.transport === 'string' && r.transport)
|
|
47
|
+
transport = r.transport;
|
|
48
|
+
}
|
|
49
|
+
catch { /* a plain-text result carries no transport */ }
|
|
50
|
+
out.set(name, transport.trim().toUpperCase());
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
/** Fixes this process already recorded (a write that activated, then an explicit activate, record once). */
|
|
55
|
+
const recordedThisRun = new Set();
|
|
56
|
+
/** Tests only. */
|
|
57
|
+
export function resetRecordedFixes() { recordedThisRun.clear(); }
|
|
58
|
+
export function teamFixOffers(objects, opts = {}) {
|
|
59
|
+
// A guided journey is on (journey/flow.ts): the same records, in solo words.
|
|
60
|
+
const solo = opts.wording === 'journey';
|
|
61
|
+
try {
|
|
62
|
+
const joined = activeProject();
|
|
63
|
+
if (!joined)
|
|
64
|
+
return [];
|
|
65
|
+
const baseline = readBaseline(joined.projectDir);
|
|
66
|
+
const byKey = new Map(baseline.map((o) => [objectKey(o.type, o.name), o]));
|
|
67
|
+
const byName = new Map();
|
|
68
|
+
for (const o of baseline)
|
|
69
|
+
byName.set(o.name.toUpperCase(), [...(byName.get(o.name.toUpperCase()) ?? []), o]);
|
|
70
|
+
const lines = [];
|
|
71
|
+
const done = new Set();
|
|
72
|
+
for (const o of objects) {
|
|
73
|
+
const type = String(o?.type ?? '').split('/')[0].trim().toUpperCase();
|
|
74
|
+
const name = String(o?.name ?? '').trim().toUpperCase();
|
|
75
|
+
const same = byName.get(name) ?? [];
|
|
76
|
+
const hit = byKey.get(objectKey(type, name)) ?? (same.length === 1 ? same[0] : undefined);
|
|
77
|
+
if (!hit)
|
|
78
|
+
continue;
|
|
79
|
+
const key = objectKey(hit.type, hit.name);
|
|
80
|
+
if (done.has(key))
|
|
81
|
+
continue;
|
|
82
|
+
done.add(key);
|
|
83
|
+
const label = `${hit.type.toUpperCase()} ${hit.name.toUpperCase()}`;
|
|
84
|
+
const transport = opts.written?.get(hit.name.toUpperCase());
|
|
85
|
+
if (transport === undefined) {
|
|
86
|
+
lines.push(solo
|
|
87
|
+
? `${label} is one of your objects, but CSPeach did not write it, so no fix was recorded.`
|
|
88
|
+
: `This object is in your team project. Mark it fixed? cspeach team fixed ${label}`);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
const runKey = `${joined.projectId}|${key}|${transport}`;
|
|
92
|
+
if (recordedThisRun.has(runKey))
|
|
93
|
+
continue;
|
|
94
|
+
const f = findingsByObject(joined.projectDir, joined.outboxDir).get(key);
|
|
95
|
+
if (!f?.latestAt) {
|
|
96
|
+
lines.push(solo
|
|
97
|
+
? `${label} has not been checked yet, so no fix was recorded. The next check looks at it.`
|
|
98
|
+
: `${label} has not been measured yet, so no fix was recorded. Run cspeach team refresh to measure it.`);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (!f.latest.length) {
|
|
102
|
+
lines.push(solo
|
|
103
|
+
? `${label} had nothing open at its last check, so no fix was recorded. The next check looks at it.`
|
|
104
|
+
: `${label} had nothing open at its last measurement, so no fix was recorded. The next cspeach team refresh checks it.`);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
// The name this person records under in this team project, as team verbs use it.
|
|
108
|
+
const who = opts.who ?? joined.name ?? process.env['CSPEACH_AUTHOR_NAME'] ?? process.env['USER'] ?? process.env['USERNAME'] ?? 'consultant';
|
|
109
|
+
const res = writeRecord(joined, 'fix', f.latest.map((r) => ({ key, rule: r.rule, status: 'fixed', ...(transport ? { transport } : {}) })), { name: who });
|
|
110
|
+
if (!res.file)
|
|
111
|
+
continue;
|
|
112
|
+
recordedThisRun.add(runKey);
|
|
113
|
+
const by = `✓ Recorded: ${label} fixed by CSPeach${transport ? ` in ${transport}` : ''}.`;
|
|
114
|
+
lines.push(solo ? `${by} Not yet checked — the next check proves it.` : `${by} A claim is not proof — the next cspeach team refresh checks it.`);
|
|
115
|
+
try {
|
|
116
|
+
regenerateWorkingList(joined, who);
|
|
117
|
+
}
|
|
118
|
+
catch { /* the record is kept; the list catches up on the next run */ }
|
|
119
|
+
}
|
|
120
|
+
return lines;
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return []; // a folder halfway through a sync must never touch an activation
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { buildRegister, narrowedLanes } from '@cspeach/register-core';
|
|
3
|
+
import { collectRecords, readBaseline, readEstate, 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
|
+
// The newest discovery, when the folder holds one: the estate around the team project.
|
|
14
|
+
const estate = readEstate(projectDir)?.estate;
|
|
15
|
+
const register = buildRegister({
|
|
16
|
+
project, baseline, lanes, records: [...collected.records, ...own.records], ...(estate !== undefined ? { estate } : {}),
|
|
17
|
+
});
|
|
18
|
+
return {
|
|
19
|
+
register, files: collected.files + own.files,
|
|
20
|
+
unreadable: collected.unreadable + own.unreadable, ownFiles: own.files,
|
|
21
|
+
// F2: a lanes.json written before names began to narrow packages holds
|
|
22
|
+
// fewer objects now. The lead is told, not left to work it out from a count.
|
|
23
|
+
narrowed: narrowedLanes(lanes, baseline),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Plan sheet, read back (spec 2026-10-06 §8). `team report` writes it; the
|
|
3
|
+
* lead edits the five columns before the dash in Excel; `team plan import`
|
|
4
|
+
* reads it here, compares it with what the export said and what the plan says
|
|
5
|
+
* now, and hands back the changes as the rows of ONE `excel` plan record.
|
|
6
|
+
*
|
|
7
|
+
* Read the strict way:
|
|
8
|
+
* - the header row is matched by column name (any case, trimmed), unknown columns ignored;
|
|
9
|
+
* - rows are matched by Type + Object, never by position; blank rows ignored;
|
|
10
|
+
* - a cell is a change only when it differs from what the export wrote AND from
|
|
11
|
+
* what the plan says now, so a cell the lead never touched never undoes
|
|
12
|
+
* someone else's newer change;
|
|
13
|
+
* - a change to a field a newer plan, decision or ruling record touched is a
|
|
14
|
+
* clash: shown with both values and skipped, never overwritten;
|
|
15
|
+
* - the fact columns after the dash are compared with the export and never read.
|
|
16
|
+
*
|
|
17
|
+
* Pure: buffer and register in, rows and counts out. Nothing is written here.
|
|
18
|
+
*/
|
|
19
|
+
import { normalizeKey, objectKey, planRows, PLAN_COLUMNS, } from '@cspeach/register-core';
|
|
20
|
+
import { readSheets } from '../tools/filesystem/extract-xlsx.js';
|
|
21
|
+
import { RegisterError } from './store.js';
|
|
22
|
+
import { EXPORT_STAMP } from './workbooks.js';
|
|
23
|
+
export const NOT_A_PLAN_SHEET = 'This is not a Plan sheet CSPeach wrote.';
|
|
24
|
+
const MAX_NOTE = 300;
|
|
25
|
+
const STAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?Z$/;
|
|
26
|
+
const DAY = /^\d{4}-\d{2}-\d{2}$/;
|
|
27
|
+
const SERIAL = /^\d{5}(\.\d+)?$/;
|
|
28
|
+
/** Excel's day zero for serial dates (the 1900 system, after its leap-year bug). */
|
|
29
|
+
const EXCEL_EPOCH = Date.UTC(1899, 11, 30);
|
|
30
|
+
const DASH = PLAN_COLUMNS.indexOf('—');
|
|
31
|
+
const FACT_COLUMNS = PLAN_COLUMNS.slice(DASH + 1);
|
|
32
|
+
const lower = (s) => s.trim().toLowerCase();
|
|
33
|
+
const CANONICAL = new Map(PLAN_COLUMNS.map((c) => [lower(c), c]));
|
|
34
|
+
/**
|
|
35
|
+
* The Plan sheet and the export stamp of a workbook `team report` wrote.
|
|
36
|
+
* Throws the one sentence for anything else: not an .xlsx, no Plan sheet, no
|
|
37
|
+
* stamp in the Read me, or a Plan sheet without Object and Type columns.
|
|
38
|
+
*/
|
|
39
|
+
export function readPlanSheet(buffer) {
|
|
40
|
+
let sheets;
|
|
41
|
+
try {
|
|
42
|
+
sheets = readSheets(buffer);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
throw new RegisterError(NOT_A_PLAN_SHEET);
|
|
46
|
+
}
|
|
47
|
+
const plan = sheets.find((s) => lower(s.name) === 'plan');
|
|
48
|
+
const readMe = sheets.find((s) => lower(s.name) === 'read me');
|
|
49
|
+
const stampCell = readMe?.rows.flat().find((c) => lower(c).startsWith(lower(EXPORT_STAMP)));
|
|
50
|
+
const exportStamp = stampCell ? stampCell.trim().slice(EXPORT_STAMP.length).trim() : '';
|
|
51
|
+
if (!plan || !STAMP.test(exportStamp) || Number.isNaN(Date.parse(exportStamp)))
|
|
52
|
+
throw new RegisterError(NOT_A_PLAN_SHEET);
|
|
53
|
+
const at = plan.rows.findIndex((r) => r.some((c) => c.trim() !== ''));
|
|
54
|
+
const header = at === -1 ? [] : plan.rows[at].map((c) => c.trim());
|
|
55
|
+
// Column name → index; the first column of a name wins, names it does not know are ignored.
|
|
56
|
+
const index = new Map();
|
|
57
|
+
header.forEach((name, i) => {
|
|
58
|
+
const canonical = CANONICAL.get(lower(name));
|
|
59
|
+
if (canonical && !index.has(canonical))
|
|
60
|
+
index.set(canonical, i);
|
|
61
|
+
});
|
|
62
|
+
if (!index.has('Object') || !index.has('Type'))
|
|
63
|
+
throw new RegisterError(NOT_A_PLAN_SHEET);
|
|
64
|
+
const rows = [];
|
|
65
|
+
for (const raw of plan.rows.slice(at + 1)) {
|
|
66
|
+
const row = {};
|
|
67
|
+
for (const [name, i] of index)
|
|
68
|
+
row[name] = (raw[i] ?? '').trim();
|
|
69
|
+
if (Object.values(row).every((v) => v === ''))
|
|
70
|
+
continue;
|
|
71
|
+
rows.push(row);
|
|
72
|
+
}
|
|
73
|
+
return { exportStamp, header, rows };
|
|
74
|
+
}
|
|
75
|
+
// ── cells ────────────────────────────────────────────────────────────────────
|
|
76
|
+
/** 1–5 (also `2.0`, ` 2 `), `defer` in any case, '' = blank, undefined = unreadable. */
|
|
77
|
+
function priorityCell(cell) {
|
|
78
|
+
const v = cell.trim();
|
|
79
|
+
if (!v)
|
|
80
|
+
return '';
|
|
81
|
+
if (lower(v) === 'defer')
|
|
82
|
+
return 'defer';
|
|
83
|
+
const m = /^([1-5])(?:\.0+)?$/.exec(v);
|
|
84
|
+
return m ? Number(m[1]) : undefined;
|
|
85
|
+
}
|
|
86
|
+
/** A real day as YYYY-MM-DD, or an Excel serial (46000 → 2025-12-09); '' = blank; undefined = unreadable. */
|
|
87
|
+
function dateCell(cell) {
|
|
88
|
+
const v = cell.trim();
|
|
89
|
+
if (!v)
|
|
90
|
+
return '';
|
|
91
|
+
if (DAY.test(v)) {
|
|
92
|
+
const d = new Date(`${v}T00:00:00Z`);
|
|
93
|
+
return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === v ? v : undefined;
|
|
94
|
+
}
|
|
95
|
+
if (SERIAL.test(v))
|
|
96
|
+
return new Date(EXCEL_EPOCH + Math.floor(Number(v)) * 86_400_000).toISOString().slice(0, 10);
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
function scopeCell(cell) {
|
|
100
|
+
const v = lower(cell);
|
|
101
|
+
if (!v)
|
|
102
|
+
return '';
|
|
103
|
+
// `1`/`0`: a cell Excel saved as a boolean reads back as a number.
|
|
104
|
+
if (v === 'yes' || v === 'y' || v === 'true' || v === '1')
|
|
105
|
+
return true;
|
|
106
|
+
if (v === 'no' || v === 'n' || v === 'false' || v === '0')
|
|
107
|
+
return false;
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
/** A lane id from `Owner (id)` (as exported), an owner name or a lane id, any case. '' = blank; undefined = unknown. */
|
|
111
|
+
function laneCell(cell, register, current) {
|
|
112
|
+
const v = cell.trim();
|
|
113
|
+
if (!v)
|
|
114
|
+
return '';
|
|
115
|
+
const byId = (id) => register.lanes.find((l) => lower(l.id) === lower(id))?.id;
|
|
116
|
+
const label = /^(.*)\(([^()]+)\)\s*$/.exec(v);
|
|
117
|
+
if (label) {
|
|
118
|
+
const id = byId(label[2]);
|
|
119
|
+
if (id && lower(register.lanes.find((l) => l.id === id).owner) === lower(label[1]))
|
|
120
|
+
return id;
|
|
121
|
+
}
|
|
122
|
+
const id = byId(v);
|
|
123
|
+
if (id)
|
|
124
|
+
return id;
|
|
125
|
+
const owned = register.lanes.filter((l) => lower(l.owner) === lower(v));
|
|
126
|
+
if (owned.length === 1)
|
|
127
|
+
return owned[0].id;
|
|
128
|
+
// An owner of several lanes: the lane the object is in now, if it is one of them.
|
|
129
|
+
if (owned.length > 1 && owned.some((l) => l.id === current))
|
|
130
|
+
return current;
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
// ── values the plan holds for an object ─────────────────────────────────────
|
|
134
|
+
const manualOf = (o) => {
|
|
135
|
+
const e = o.priority.effective;
|
|
136
|
+
return e.source === 'set by hand' || e.source === 'Excel plan' ? e.band : null;
|
|
137
|
+
};
|
|
138
|
+
const laneLabel = (register, id) => {
|
|
139
|
+
const l = id ? register.lanes.find((x) => x.id === id) : undefined;
|
|
140
|
+
return l ? `${l.owner} (${l.id})` : 'no lane';
|
|
141
|
+
};
|
|
142
|
+
const shownValue = (v) => (v === '' ? 'blank' : v);
|
|
143
|
+
/** The cell as the fact columns compare it: trimmed, and a number read as a number (3 = "3" = "3.0"). */
|
|
144
|
+
function sameFact(a, b) {
|
|
145
|
+
const x = a === null || a === undefined ? '' : String(a).trim();
|
|
146
|
+
const y = b === null || b === undefined ? '' : String(b).trim();
|
|
147
|
+
if (x === y)
|
|
148
|
+
return true;
|
|
149
|
+
return x !== '' && y !== '' && Number.isFinite(Number(x)) && Number(x) === Number(y);
|
|
150
|
+
}
|
|
151
|
+
/** field → newest (by, at) of a plan, decision or ruling record newer than the export, per object. */
|
|
152
|
+
function newerTouches(records, stamp) {
|
|
153
|
+
const out = new Map();
|
|
154
|
+
const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
155
|
+
for (const r of records) {
|
|
156
|
+
if (!isObj(r) || typeof r.at !== 'string' || !(r.at > stamp) || !Array.isArray(r.rows))
|
|
157
|
+
continue;
|
|
158
|
+
const by = isObj(r.by) && typeof r.by.name === 'string' ? r.by.name : 'someone';
|
|
159
|
+
for (const row of r.rows) {
|
|
160
|
+
if (!isObj(row) || typeof row.key !== 'string')
|
|
161
|
+
continue;
|
|
162
|
+
const key = normalizeKey(row.key);
|
|
163
|
+
if (!key)
|
|
164
|
+
continue;
|
|
165
|
+
const fields = [];
|
|
166
|
+
if (r.recordType === 'decision' || r.recordType === 'ruling')
|
|
167
|
+
fields.push('Priority');
|
|
168
|
+
else if (r.recordType === 'plan') {
|
|
169
|
+
const a = r.action;
|
|
170
|
+
if (a === 'priority' || (a === 'excel' && 'priority' in row))
|
|
171
|
+
fields.push('Priority');
|
|
172
|
+
if (a === 'assign' || (a === 'excel' && 'lane' in row))
|
|
173
|
+
fields.push('Lane');
|
|
174
|
+
if (a === 'scope' || (a === 'excel' && 'inScope' in row))
|
|
175
|
+
fields.push('In scope');
|
|
176
|
+
if ((a === 'note' || a === 'excel') && 'targetDate' in row)
|
|
177
|
+
fields.push('Target date');
|
|
178
|
+
if ((a === 'note' || a === 'excel') && 'note' in row)
|
|
179
|
+
fields.push('Note');
|
|
180
|
+
}
|
|
181
|
+
for (const f of fields) {
|
|
182
|
+
const byField = out.get(key) ?? new Map();
|
|
183
|
+
const had = byField.get(f);
|
|
184
|
+
if (!had || r.at > had.at)
|
|
185
|
+
byField.set(f, { by, at: r.at });
|
|
186
|
+
out.set(key, byField);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return out;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* The changes the sheet asks for. `register` is the plan now; `exported` the
|
|
194
|
+
* plan as the export saw it (built from the records up to the export stamp),
|
|
195
|
+
* the current one when not given; `records` every record the folder holds, for
|
|
196
|
+
* the clashes.
|
|
197
|
+
*/
|
|
198
|
+
export function diffPlan(sheet, register, records, opts = {}) {
|
|
199
|
+
const exported = opts.exported ?? register;
|
|
200
|
+
const now = new Map(register.objects.map((o) => [o.key, o]));
|
|
201
|
+
const then = new Map(exported.objects.map((o) => [o.key, o]));
|
|
202
|
+
const exportedFacts = new Map();
|
|
203
|
+
for (const row of planRows(exported)) {
|
|
204
|
+
exportedFacts.set(objectKey(String(row[1]), String(row[0])), Object.fromEntries(PLAN_COLUMNS.map((c, i) => [c, row[i]])));
|
|
205
|
+
}
|
|
206
|
+
const touched = newerTouches(records, sheet.exportStamp);
|
|
207
|
+
const counts = {
|
|
208
|
+
priorities: 0, moves: 0, scope: 0, notes: 0, unchanged: 0, notInTeamProject: 0, clashes: 0, factEditsIgnored: 0, unknownLanes: 0, unreadableCells: 0, blankCells: 0,
|
|
209
|
+
};
|
|
210
|
+
const out = { rows: [], counts, clashes: [], unknownLanes: [], exportStamp: sheet.exportStamp };
|
|
211
|
+
const seen = new Set();
|
|
212
|
+
for (const cells of sheet.rows) {
|
|
213
|
+
if (Object.values(cells).every((v) => !v || !v.trim()))
|
|
214
|
+
continue; // a blank row says nothing
|
|
215
|
+
const type = cells.Type ?? '';
|
|
216
|
+
const name = cells.Object ?? '';
|
|
217
|
+
const key = type && name ? objectKey(type, name) : '';
|
|
218
|
+
const o = key ? now.get(key) : undefined;
|
|
219
|
+
if (!o) {
|
|
220
|
+
counts.notInTeamProject += 1;
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
if (seen.has(key))
|
|
224
|
+
continue; // the first row of an object counts
|
|
225
|
+
seen.add(key);
|
|
226
|
+
const was = then.get(key) ?? o;
|
|
227
|
+
const row = { key };
|
|
228
|
+
let unreadable = 0;
|
|
229
|
+
let blank = 0;
|
|
230
|
+
let clashed = 0;
|
|
231
|
+
let unknownLane = false;
|
|
232
|
+
/** A change the lead made in the sheet: skipped when a newer record touched the same field. */
|
|
233
|
+
const change = (field, sheetText, nowText, apply) => {
|
|
234
|
+
const newer = touched.get(key)?.get(field);
|
|
235
|
+
if (newer) {
|
|
236
|
+
out.clashes.push({ key, field, sheet: shownValue(sheetText), now: shownValue(nowText), by: newer.by, at: newer.at });
|
|
237
|
+
clashed += 1;
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
apply();
|
|
241
|
+
};
|
|
242
|
+
if ('Priority' in cells) {
|
|
243
|
+
const v = priorityCell(cells.Priority);
|
|
244
|
+
if (v === undefined)
|
|
245
|
+
unreadable += 1;
|
|
246
|
+
else {
|
|
247
|
+
const desired = v === '' ? null : v;
|
|
248
|
+
if (desired !== manualOf(was) && desired !== manualOf(o)) {
|
|
249
|
+
change('Priority', cells.Priority, String(o.priority.effective.band), () => { row.priority = desired; counts.priorities += 1; });
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
if ('Lane' in cells) {
|
|
254
|
+
const v = laneCell(cells.Lane, register, o.lane);
|
|
255
|
+
const exportedCell = laneLabel(exported, was.lane);
|
|
256
|
+
if (v === undefined) {
|
|
257
|
+
// A label the export wrote for a lane removed since is not the lead's edit.
|
|
258
|
+
if (!sameFact(cells.Lane, exportedCell)) {
|
|
259
|
+
unknownLane = true;
|
|
260
|
+
out.unknownLanes.push({ key, lane: cells.Lane });
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
else if (v === '') {
|
|
264
|
+
// Blank gives an object the lead moved back to the lane patterns; a pattern lane has nothing to give back.
|
|
265
|
+
if (was.laneFrom === 'assigned' && o.laneFrom === 'assigned') {
|
|
266
|
+
change('Lane', '', laneLabel(register, o.lane), () => { row.lane = null; counts.moves += 1; });
|
|
267
|
+
}
|
|
268
|
+
else if (was.laneFrom !== 'assigned' && o.laneFrom !== 'assigned' && o.lane !== null) {
|
|
269
|
+
blank += 1; // a lane from the patterns: blank cannot take it away
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
else if (v !== was.lane && v !== o.lane) {
|
|
273
|
+
change('Lane', cells.Lane, laneLabel(register, o.lane), () => { row.lane = v; counts.moves += 1; });
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
if ('In scope' in cells) {
|
|
277
|
+
const v = scopeCell(cells['In scope']);
|
|
278
|
+
if (v === undefined)
|
|
279
|
+
unreadable += 1;
|
|
280
|
+
else if (v === '')
|
|
281
|
+
blank += 1; // in scope is yes or no, never nothing
|
|
282
|
+
else if (v !== was.inScope && v !== o.inScope) {
|
|
283
|
+
change('In scope', cells['In scope'], o.inScope ? 'yes' : 'no', () => { row.inScope = v; counts.scope += 1; });
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
let noted = false;
|
|
287
|
+
if ('Target date' in cells) {
|
|
288
|
+
const v = dateCell(cells['Target date']);
|
|
289
|
+
const before = was.plan.targetDate ?? '';
|
|
290
|
+
const current = o.plan.targetDate ?? '';
|
|
291
|
+
if (v === undefined)
|
|
292
|
+
unreadable += 1;
|
|
293
|
+
else if (v !== before && v !== current) {
|
|
294
|
+
change('Target date', cells['Target date'], current, () => { row.targetDate = v; noted = true; });
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
if ('Note' in cells) {
|
|
298
|
+
const v = cells.Note;
|
|
299
|
+
const before = was.plan.note ?? '';
|
|
300
|
+
const current = o.plan.note ?? '';
|
|
301
|
+
if (v.length > MAX_NOTE)
|
|
302
|
+
unreadable += 1;
|
|
303
|
+
else if (v !== before && v !== current) {
|
|
304
|
+
change('Note', v, current, () => { row.note = v; noted = true; });
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
if (noted)
|
|
308
|
+
counts.notes += 1;
|
|
309
|
+
const facts = exportedFacts.get(key);
|
|
310
|
+
for (const f of FACT_COLUMNS)
|
|
311
|
+
if (f in cells && facts && !sameFact(cells[f], facts[f]))
|
|
312
|
+
counts.factEditsIgnored += 1;
|
|
313
|
+
counts.unreadableCells += unreadable;
|
|
314
|
+
counts.blankCells += blank;
|
|
315
|
+
counts.clashes += clashed;
|
|
316
|
+
if (unknownLane)
|
|
317
|
+
counts.unknownLanes += 1;
|
|
318
|
+
const accepted = Object.keys(row).length > 1;
|
|
319
|
+
if (accepted)
|
|
320
|
+
out.rows.push(row);
|
|
321
|
+
else if (!unreadable && !blank && !clashed && !unknownLane)
|
|
322
|
+
counts.unchanged += 1;
|
|
323
|
+
}
|
|
324
|
+
return out;
|
|
325
|
+
}
|