@dzhechkov/harness-core 0.8.6 → 0.8.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dz-manifest.json +361 -97
- package/README.md +48 -1
- package/dist/amendment-trace.d.ts.map +1 -1
- package/dist/amendment-trace.js +12 -1
- package/dist/amendment-trace.js.map +1 -1
- package/dist/codex-invoke.d.ts +73 -0
- package/dist/codex-invoke.d.ts.map +1 -0
- package/dist/codex-invoke.js +80 -0
- package/dist/codex-invoke.js.map +1 -0
- package/dist/compounding.d.ts +54 -0
- package/dist/compounding.d.ts.map +1 -1
- package/dist/compounding.js +221 -1
- package/dist/compounding.js.map +1 -1
- package/dist/discrimination-gate.d.ts +63 -3
- package/dist/discrimination-gate.d.ts.map +1 -1
- package/dist/discrimination-gate.js +113 -16
- package/dist/discrimination-gate.js.map +1 -1
- package/dist/eta.d.ts +92 -0
- package/dist/eta.d.ts.map +1 -0
- package/dist/eta.js +488 -0
- package/dist/eta.js.map +1 -0
- package/dist/event-chain.d.ts +30 -0
- package/dist/event-chain.d.ts.map +1 -1
- package/dist/event-chain.js +24 -0
- package/dist/event-chain.js.map +1 -1
- package/dist/feature-adr-checkpoints.js +1 -1
- package/dist/feature-adr-decision-recall.d.ts +167 -0
- package/dist/feature-adr-decision-recall.d.ts.map +1 -0
- package/dist/feature-adr-decision-recall.js +519 -0
- package/dist/feature-adr-decision-recall.js.map +1 -0
- package/dist/feature-adr-landing.d.ts +37 -0
- package/dist/feature-adr-landing.d.ts.map +1 -0
- package/dist/feature-adr-landing.js +59 -0
- package/dist/feature-adr-landing.js.map +1 -0
- package/dist/feature-adr-routing.d.ts +2 -2
- package/dist/feature-adr-routing.d.ts.map +1 -1
- package/dist/feature-adr-routing.js +7 -11
- package/dist/feature-adr-routing.js.map +1 -1
- package/dist/guard-promotion.d.ts +41 -0
- package/dist/guard-promotion.d.ts.map +1 -1
- package/dist/guard-promotion.js +218 -4
- package/dist/guard-promotion.js.map +1 -1
- package/dist/guard-volume.d.ts +108 -0
- package/dist/guard-volume.d.ts.map +1 -0
- package/dist/guard-volume.js +536 -0
- package/dist/guard-volume.js.map +1 -0
- package/dist/guard.d.ts +17 -0
- package/dist/guard.d.ts.map +1 -1
- package/dist/guard.js +92 -4
- package/dist/guard.js.map +1 -1
- package/dist/index.d.ts +24 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +19 -5
- package/dist/index.js.map +1 -1
- package/dist/integration-apply.d.ts +25 -0
- package/dist/integration-apply.d.ts.map +1 -0
- package/dist/integration-apply.js +299 -0
- package/dist/integration-apply.js.map +1 -0
- package/dist/integration-evidence.d.ts +46 -0
- package/dist/integration-evidence.d.ts.map +1 -0
- package/dist/integration-evidence.js +44 -0
- package/dist/integration-evidence.js.map +1 -0
- package/dist/integration-probe-worker.d.ts +22 -0
- package/dist/integration-probe-worker.d.ts.map +1 -0
- package/dist/integration-probe-worker.js +334 -0
- package/dist/integration-probe-worker.js.map +1 -0
- package/dist/integrations-verify.d.ts +60 -0
- package/dist/integrations-verify.d.ts.map +1 -0
- package/dist/integrations-verify.js +194 -0
- package/dist/integrations-verify.js.map +1 -0
- package/dist/lesson-generalization.d.ts +29 -0
- package/dist/lesson-generalization.d.ts.map +1 -0
- package/dist/lesson-generalization.js +84 -0
- package/dist/lesson-generalization.js.map +1 -0
- package/dist/mutation-gate.d.ts +39 -36
- package/dist/mutation-gate.d.ts.map +1 -1
- package/dist/mutation-gate.js +111 -5
- package/dist/mutation-gate.js.map +1 -1
- package/dist/operations.d.ts +27 -0
- package/dist/operations.d.ts.map +1 -1
- package/dist/operations.js +186 -7
- package/dist/operations.js.map +1 -1
- package/dist/patterns.d.ts +27 -1
- package/dist/patterns.d.ts.map +1 -1
- package/dist/patterns.js +211 -45
- package/dist/patterns.js.map +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +27 -5
- package/dist/plugin.js.map +1 -1
- package/dist/recommend.d.ts +4 -5
- package/dist/recommend.d.ts.map +1 -1
- package/dist/recommend.js +110 -45
- package/dist/recommend.js.map +1 -1
- package/dist/registry.d.ts +32 -1
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +165 -9
- package/dist/registry.js.map +1 -1
- package/dist/run-records.d.ts +3 -0
- package/dist/run-records.d.ts.map +1 -1
- package/dist/run-records.js +18 -0
- package/dist/run-records.js.map +1 -1
- package/dist/score.d.ts +95 -0
- package/dist/score.d.ts.map +1 -1
- package/dist/score.js +274 -2
- package/dist/score.js.map +1 -1
- package/dist/setup.d.ts.map +1 -1
- package/dist/setup.js +20 -17
- package/dist/setup.js.map +1 -1
- package/dist/skill-selection.d.ts +72 -0
- package/dist/skill-selection.d.ts.map +1 -0
- package/dist/skill-selection.js +76 -0
- package/dist/skill-selection.js.map +1 -0
- package/dist/stem.d.ts +12 -0
- package/dist/stem.d.ts.map +1 -0
- package/dist/stem.js +89 -0
- package/dist/stem.js.map +1 -0
- package/dist/target-integrations.d.ts +65 -0
- package/dist/target-integrations.d.ts.map +1 -0
- package/dist/target-integrations.js +152 -0
- package/dist/target-integrations.js.map +1 -0
- package/dist/telemetry-vocabulary.d.ts +7 -0
- package/dist/telemetry-vocabulary.d.ts.map +1 -1
- package/dist/telemetry-vocabulary.js +29 -0
- package/dist/telemetry-vocabulary.js.map +1 -1
- package/dist/vector-tier.d.ts +6 -1
- package/dist/vector-tier.d.ts.map +1 -1
- package/dist/vector-tier.js +32 -7
- package/dist/vector-tier.js.map +1 -1
- package/package.json +7 -6
- package/sbom.json +772 -112
- package/src/amendment-trace.ts +12 -1
- package/src/codex-invoke.ts +138 -0
- package/src/compounding.ts +300 -1
- package/src/discrimination-gate.ts +183 -19
- package/src/eta.ts +590 -0
- package/src/event-chain.ts +41 -0
- package/src/feature-adr-checkpoints.ts +1 -1
- package/src/feature-adr-decision-recall.ts +652 -0
- package/src/feature-adr-landing.ts +109 -0
- package/src/feature-adr-routing.ts +7 -11
- package/src/guard-promotion.ts +245 -4
- package/src/guard-volume.ts +752 -0
- package/src/guard.ts +110 -4
- package/src/index.ts +73 -6
- package/src/integration-apply.ts +332 -0
- package/src/integration-evidence.ts +89 -0
- package/src/integration-probe-worker.ts +310 -0
- package/src/integration-receipts/claude-code/mcp/2.1.235.json +35 -0
- package/src/integrations-verify.ts +258 -0
- package/src/lesson-generalization.ts +115 -0
- package/src/mutation-gate.ts +165 -5
- package/src/operations.ts +207 -7
- package/src/patterns.ts +252 -43
- package/src/plugin.ts +27 -5
- package/src/recommend.ts +116 -46
- package/src/registry.ts +144 -11
- package/src/run-records.ts +23 -0
- package/src/score.ts +361 -3
- package/src/setup.ts +20 -17
- package/src/skill-selection.ts +111 -0
- package/src/stem.ts +87 -0
- package/src/target-integrations.ts +225 -0
- package/src/telemetry-vocabulary.ts +36 -0
- package/src/vector-tier.ts +44 -14
package/src/score.ts
CHANGED
|
@@ -35,6 +35,14 @@ export interface RunScorecard {
|
|
|
35
35
|
readonly disciplines: readonly DisciplineScore[];
|
|
36
36
|
/** Extracted cross-model grade, when one exists (e.g. "A−", "C"). */
|
|
37
37
|
readonly qeGrade: string | null;
|
|
38
|
+
/**
|
|
39
|
+
* F30а-3, ADDITIVE: present when the run's artifacts carry a structured mutation table. Optional
|
|
40
|
+
* on purpose — every scorecard written before this field existed stays a valid RunScorecard, and
|
|
41
|
+
* `scoreAggregateRowFrom` keeps accepting rows without it. A run with no table reports `absent`
|
|
42
|
+
* rather than omitting the field, so "the gate never ran" and "an older scorer wrote this row"
|
|
43
|
+
* stay distinguishable.
|
|
44
|
+
*/
|
|
45
|
+
readonly mutationEvidence?: MutationEvidence;
|
|
38
46
|
readonly passed: number;
|
|
39
47
|
readonly total: number;
|
|
40
48
|
readonly summary: string;
|
|
@@ -161,6 +169,119 @@ function evidenceLinePositive(text: string, re: RegExp, negationRe: RegExp = NEG
|
|
|
161
169
|
return null;
|
|
162
170
|
}
|
|
163
171
|
|
|
172
|
+
/**
|
|
173
|
+
* F30а-3 — structured mutation evidence.
|
|
174
|
+
*
|
|
175
|
+
* `discrimination` has always scored PROSE: a sentence the author writes about their own work.
|
|
176
|
+
* "None of the mutants survived" scores identically whether three mutations ran or zero did, which
|
|
177
|
+
* makes the strongest discipline in the pipeline rest on the weakest kind of evidence. The mutation
|
|
178
|
+
* gate already emits a five-valued verdict per registry entry, and only `PROVEN` is proof — so a
|
|
179
|
+
* table carrying those verdicts can be COUNTED instead of believed.
|
|
180
|
+
*
|
|
181
|
+
* The design is defined by what it REFUSES. A table that looks like evidence and proves nothing is
|
|
182
|
+
* worse than no table, because it buys the appearance of rigour: the empty one, the header-only one,
|
|
183
|
+
* the one whose every row is INCONCLUSIVE. Each of those returns a non-proving status and is NAMED
|
|
184
|
+
* in the scorecard evidence, never silently treated as corroboration.
|
|
185
|
+
*/
|
|
186
|
+
export type MutationEvidenceStatus =
|
|
187
|
+
| 'proven' // ≥1 row carries the gate's PROVEN verdict — the only status that is evidence
|
|
188
|
+
| 'present-unproven' // a real table, but zero PROVEN rows (includes the header-only table)
|
|
189
|
+
| 'malformed' // a table whose verdict column holds no recognised token — unreadable, not proof
|
|
190
|
+
| 'absent'; // no mutation table in the text at all
|
|
191
|
+
|
|
192
|
+
export interface MutationEvidence {
|
|
193
|
+
readonly status: MutationEvidenceStatus;
|
|
194
|
+
/** Rows whose verdict is exactly PROVEN. */
|
|
195
|
+
readonly proven: number;
|
|
196
|
+
/** Data rows found under the header (0 for the header-only table). */
|
|
197
|
+
readonly rows: number;
|
|
198
|
+
/** The line the verdict rests on — evidence must show its work. */
|
|
199
|
+
readonly evidence: string;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* The gate's own verdict vocabulary. Anything outside it is UNRECOGNISED, which is why a table of
|
|
204
|
+
* free-text opinions ("looked fine", "ok") is malformed rather than quietly scored as unproven:
|
|
205
|
+
* the difference between "the gate ran and found nothing" and "nobody ran the gate" is the whole
|
|
206
|
+
* point of the field.
|
|
207
|
+
*/
|
|
208
|
+
const MUTATION_VERDICTS = new Set([
|
|
209
|
+
'PROVEN',
|
|
210
|
+
'MUTATION_UNPARSEABLE',
|
|
211
|
+
'MUTATION_LOAD_FATAL',
|
|
212
|
+
'OVER_FAILING',
|
|
213
|
+
'INCONCLUSIVE',
|
|
214
|
+
'SURVIVED',
|
|
215
|
+
'NOT_PROVEN',
|
|
216
|
+
]);
|
|
217
|
+
|
|
218
|
+
const splitRow = (line: string): string[] =>
|
|
219
|
+
line
|
|
220
|
+
.trim()
|
|
221
|
+
.replace(/^\|/, '')
|
|
222
|
+
.replace(/\|$/, '')
|
|
223
|
+
.split('|')
|
|
224
|
+
.map((cell) => cell.trim());
|
|
225
|
+
|
|
226
|
+
const isSeparatorRow = (line: string): boolean => /^\|[\s:|-]+\|?$/.test(line.trim());
|
|
227
|
+
|
|
228
|
+
export function readMutationEvidence(text: string): MutationEvidence {
|
|
229
|
+
const lines = String(text).split('\n');
|
|
230
|
+
for (let i = 0; i < lines.length; i++) {
|
|
231
|
+
const line = lines[i];
|
|
232
|
+
if (line === undefined || !line.trim().startsWith('|')) continue;
|
|
233
|
+
const header = splitRow(line);
|
|
234
|
+
// A mutation table is identified by its COLUMNS, not by a heading above it: headings drift
|
|
235
|
+
// across reports and translations, column names are what the parser actually reads.
|
|
236
|
+
const verdictCol = header.findIndex((c) => /verdict/i.test(c));
|
|
237
|
+
if (verdictCol < 0 || !header.some((c) => /mutation|mutant/i.test(c))) continue;
|
|
238
|
+
|
|
239
|
+
let rows = 0;
|
|
240
|
+
let proven = 0;
|
|
241
|
+
let recognised = 0;
|
|
242
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
243
|
+
const row = lines[j];
|
|
244
|
+
if (row === undefined || !row.trim().startsWith('|')) break;
|
|
245
|
+
if (isSeparatorRow(row)) continue;
|
|
246
|
+
const cells = splitRow(row);
|
|
247
|
+
rows++;
|
|
248
|
+
const cell = (cells[verdictCol] ?? '').toUpperCase();
|
|
249
|
+
// Word-bounded: NOT_PROVEN contains PROVEN as a substring and must never count as one.
|
|
250
|
+
const token = (cell.match(/\b[A-Z_]{4,}\b/) ?? [])[0] ?? '';
|
|
251
|
+
if (!MUTATION_VERDICTS.has(token)) continue;
|
|
252
|
+
recognised++;
|
|
253
|
+
if (token === 'PROVEN') proven++;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (proven > 0) {
|
|
257
|
+
return {
|
|
258
|
+
status: 'proven',
|
|
259
|
+
proven,
|
|
260
|
+
rows,
|
|
261
|
+
evidence: `mutation table: ${proven} of ${rows} row(s) PROVEN`,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
if (rows > 0 && recognised === 0) {
|
|
265
|
+
return {
|
|
266
|
+
status: 'malformed',
|
|
267
|
+
proven: 0,
|
|
268
|
+
rows,
|
|
269
|
+
evidence: `mutation table: ${rows} row(s), no recognised gate verdict in the Verdict column — unreadable, not proof`,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
return {
|
|
273
|
+
status: 'present-unproven',
|
|
274
|
+
proven: 0,
|
|
275
|
+
rows,
|
|
276
|
+
evidence:
|
|
277
|
+
rows === 0
|
|
278
|
+
? 'mutation table present with no data rows — proved nothing'
|
|
279
|
+
: `mutation table: 0 of ${rows} row(s) PROVEN — proved nothing`,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
return { status: 'absent', proven: 0, rows: 0, evidence: 'no mutation table' };
|
|
283
|
+
}
|
|
284
|
+
|
|
164
285
|
// Word-bounded on BOTH sides: "upgrade B-tree" fabricated a B- (Codex QE #1). The lookahead also
|
|
165
286
|
// rejects "Grade B-tree" (letter after the dash) while keeping the real "Grade: A−" formats.
|
|
166
287
|
//
|
|
@@ -305,14 +426,26 @@ export function scoreRun(slug: string, artifacts: RunArtifacts): RunScorecard {
|
|
|
305
426
|
// MUTANTS — "None of the mutants survived", "neither mutant escaped" — which is the proof, not
|
|
306
427
|
// its denial. The quantifiers would discard exactly the strongest lines this discipline exists
|
|
307
428
|
// to find. "No discrimination proof was performed" is still caught by the narrow list.
|
|
429
|
+
// F30а-3 makes this ADDITIVE: a structured mutation table is counted FIRST (it can be
|
|
430
|
+
// verified, prose can only be believed), and the prose path below is left exactly as it was —
|
|
431
|
+
// no run that scored `pass` yesterday loses it today. What the table changes is the OTHER
|
|
432
|
+
// direction: a table that proves nothing is named in the evidence instead of sitting silently
|
|
433
|
+
// beside a winning sentence and reading as corroboration.
|
|
434
|
+
const mutationEvidence = readMutationEvidence(allText);
|
|
308
435
|
const discr =
|
|
309
436
|
evidenceLinePositive(allText, /discrimination|§42/i) ??
|
|
310
437
|
evidenceLinePositive(allText, /mutation[s]?\s.*(prov|kill)|mutant[s]?\s.*(kill|red)|RED on the old|goes? RED|failed as expected/i);
|
|
438
|
+
const discrPasses = mutationEvidence.status === 'proven' || discr !== null;
|
|
311
439
|
add(
|
|
312
440
|
'discrimination',
|
|
313
441
|
'the property test is proven able to fail',
|
|
314
|
-
|
|
315
|
-
|
|
442
|
+
discrPasses ? 'pass' : 'absent',
|
|
443
|
+
mutationEvidence.status === 'proven'
|
|
444
|
+
? mutationEvidence.evidence
|
|
445
|
+
: (discr ??
|
|
446
|
+
(mutationEvidence.status === 'absent'
|
|
447
|
+
? 'no discrimination/§42/mutation-proof evidence in any artifact'
|
|
448
|
+
: mutationEvidence.evidence)),
|
|
316
449
|
);
|
|
317
450
|
|
|
318
451
|
// 3. Cross-model QE — an independent family reviewed it, and a grade exists.
|
|
@@ -414,7 +547,7 @@ export function scoreRun(slug: string, artifacts: RunArtifacts): RunScorecard {
|
|
|
414
547
|
(grade !== null ? ` · QE grade ${grade}` : ' · no QE grade') +
|
|
415
548
|
(worst.length > 0 ? ` · absent: ${worst.join(', ')}` : '');
|
|
416
549
|
|
|
417
|
-
return { slug, disciplines, qeGrade: grade, passed, total, summary };
|
|
550
|
+
return { slug, disciplines, qeGrade: grade, mutationEvidence, passed, total, summary };
|
|
418
551
|
}
|
|
419
552
|
|
|
420
553
|
const MARK: Record<DisciplineVerdict, string> = { pass: '✓', partial: '◐', absent: '✗' };
|
|
@@ -427,7 +560,232 @@ export function renderScorecard(card: RunScorecard): string {
|
|
|
427
560
|
out.push(` ${MARK[d.verdict]} ${d.title}`);
|
|
428
561
|
out.push(` ${d.evidence}`);
|
|
429
562
|
}
|
|
563
|
+
// F30а-3: the table's own verdict is reported INDEPENDENTLY of which path scored `discrimination`.
|
|
564
|
+
// When prose carries the pass, the discipline's evidence line shows the prose — and a table that
|
|
565
|
+
// proved nothing would sit in the report unmentioned, beside a sentence claiming the mutants died.
|
|
566
|
+
// Silence about a hollow table is the failure this feature exists to prevent, so it is stated here
|
|
567
|
+
// even when it changes no verdict. No table at all stays silent: the common case earns no noise.
|
|
568
|
+
if (card.mutationEvidence !== undefined && card.mutationEvidence.status !== 'absent') {
|
|
569
|
+
out.push('');
|
|
570
|
+
out.push(` ${card.mutationEvidence.evidence}`);
|
|
571
|
+
}
|
|
430
572
|
out.push('');
|
|
431
573
|
out.push(` ${card.summary}`);
|
|
432
574
|
return out.join('\n');
|
|
433
575
|
}
|
|
576
|
+
|
|
577
|
+
/** The append-only projection of one immutable `score-<qeHash>.json` receipt. */
|
|
578
|
+
export interface ScoreAggregateRow {
|
|
579
|
+
readonly ts: string;
|
|
580
|
+
readonly slug: string;
|
|
581
|
+
readonly qeHash: string;
|
|
582
|
+
readonly passed: number;
|
|
583
|
+
readonly total: number;
|
|
584
|
+
readonly qeGrade: string | null;
|
|
585
|
+
readonly disciplines: readonly {
|
|
586
|
+
readonly id: string;
|
|
587
|
+
readonly verdict: DisciplineVerdict;
|
|
588
|
+
}[];
|
|
589
|
+
/**
|
|
590
|
+
* F30а-3, ADDITIVE: rows PROVEN by the mutation gate, when the scorecard carried a table.
|
|
591
|
+
* `undefined` means the scorecard had no mutation field at all — which is NOT the same as `0`
|
|
592
|
+
* (a table that ran and proved nothing). Every row written before this field existed keeps
|
|
593
|
+
* parsing: the aggregate must never lose its history to a schema change.
|
|
594
|
+
*/
|
|
595
|
+
readonly mutationProven?: number;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
export interface ScoreReceiptInput {
|
|
599
|
+
readonly content: string;
|
|
600
|
+
readonly qeHash: string;
|
|
601
|
+
/** Supplied by the impure caller. Receipt projection never reads a clock. */
|
|
602
|
+
readonly ts: string;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function isDisciplineVerdict(value: unknown): value is DisciplineVerdict {
|
|
606
|
+
return value === 'pass' || value === 'partial' || value === 'absent';
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function scoreAggregateRowFrom(value: unknown): ScoreAggregateRow | null {
|
|
610
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return null;
|
|
611
|
+
const record = value as Record<string, unknown>;
|
|
612
|
+
const disciplinesValue = record['disciplines'];
|
|
613
|
+
if (!Array.isArray(disciplinesValue)) return null;
|
|
614
|
+
const disciplines: { id: string; verdict: DisciplineVerdict }[] = [];
|
|
615
|
+
const disciplineIds = new Set<string>();
|
|
616
|
+
for (const value of disciplinesValue) {
|
|
617
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return null;
|
|
618
|
+
const discipline = value as Record<string, unknown>;
|
|
619
|
+
if (typeof discipline['id'] !== 'string' || discipline['id'].trim() === '') return null;
|
|
620
|
+
if (!isDisciplineVerdict(discipline['verdict'])) return null;
|
|
621
|
+
if (disciplineIds.has(discipline['id'])) return null;
|
|
622
|
+
disciplineIds.add(discipline['id']);
|
|
623
|
+
disciplines.push({ id: discipline['id'], verdict: discipline['verdict'] });
|
|
624
|
+
}
|
|
625
|
+
const passed = record['passed'];
|
|
626
|
+
const total = record['total'];
|
|
627
|
+
const qeGrade = record['qeGrade'];
|
|
628
|
+
if (typeof record['ts'] !== 'string' || record['ts'].trim() === '') return null;
|
|
629
|
+
if (typeof record['slug'] !== 'string' || record['slug'].trim() === '') return null;
|
|
630
|
+
if (typeof record['qeHash'] !== 'string' || record['qeHash'].trim() === '') return null;
|
|
631
|
+
if (typeof passed !== 'number' || !Number.isSafeInteger(passed) || passed < 0) return null;
|
|
632
|
+
if (typeof total !== 'number' || !Number.isSafeInteger(total) || total < 0) return null;
|
|
633
|
+
if (qeGrade !== null && (typeof qeGrade !== 'string' || qeGrade.trim() === '')) return null;
|
|
634
|
+
if (total !== disciplines.length) return null;
|
|
635
|
+
if (passed !== disciplines.filter((discipline) => discipline.verdict === 'pass').length) return null;
|
|
636
|
+
return {
|
|
637
|
+
ts: record['ts'],
|
|
638
|
+
slug: record['slug'],
|
|
639
|
+
qeHash: record['qeHash'],
|
|
640
|
+
passed,
|
|
641
|
+
total,
|
|
642
|
+
qeGrade,
|
|
643
|
+
disciplines,
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* Parse one score receipt into the deliberately small aggregate schema.
|
|
649
|
+
*
|
|
650
|
+
* The caller owns I/O and supplies `ts`; this function is deterministic for the same input. A
|
|
651
|
+
* syntactically valid but internally inconsistent scorecard is unreadable evidence, not a row the
|
|
652
|
+
* aggregate should silently bless.
|
|
653
|
+
*/
|
|
654
|
+
export function scoreReceiptToAggregateRow(input: ScoreReceiptInput): ScoreAggregateRow {
|
|
655
|
+
let parsed: unknown;
|
|
656
|
+
try {
|
|
657
|
+
parsed = JSON.parse(input.content) as unknown;
|
|
658
|
+
} catch {
|
|
659
|
+
throw new Error('score receipt is not valid JSON');
|
|
660
|
+
}
|
|
661
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
662
|
+
throw new Error('score receipt is not a RunScorecard object');
|
|
663
|
+
}
|
|
664
|
+
const receipt = parsed as Record<string, unknown>;
|
|
665
|
+
const row = scoreAggregateRowFrom({ ...receipt, ts: input.ts, qeHash: input.qeHash });
|
|
666
|
+
if (row === null) throw new Error('score receipt has an invalid RunScorecard shape');
|
|
667
|
+
// Projected here rather than inside scoreAggregateRowFrom so that a MALFORMED mutation field can
|
|
668
|
+
// never invalidate an otherwise-good row: the field is additive, so its absence — or its
|
|
669
|
+
// unreadability — costs the row nothing but the field itself.
|
|
670
|
+
const ev = receipt['mutationEvidence'];
|
|
671
|
+
if (typeof ev === 'object' && ev !== null && !Array.isArray(ev)) {
|
|
672
|
+
const proven = (ev as Record<string, unknown>)['proven'];
|
|
673
|
+
if (typeof proven === 'number' && Number.isFinite(proven) && proven >= 0) {
|
|
674
|
+
return { ...row, mutationProven: proven };
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
return row;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/** Read only valid aggregate rows; event-chain verification separately names malformed lines. */
|
|
681
|
+
export function readScoreAggregateRows(text: string): ScoreAggregateRow[] {
|
|
682
|
+
const rows: ScoreAggregateRow[] = [];
|
|
683
|
+
for (const line of String(text).split('\n')) {
|
|
684
|
+
if (line.trim() === '') continue;
|
|
685
|
+
try {
|
|
686
|
+
const row = scoreAggregateRowFrom(JSON.parse(line) as unknown);
|
|
687
|
+
if (row !== null) rows.push(row);
|
|
688
|
+
} catch {
|
|
689
|
+
/* A torn line is chain evidence, not a row. verifyEventChainText names it. */
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
return rows;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/** Keep the first occurrence of each `(slug, qeHash)` pair not already present in the aggregate. */
|
|
696
|
+
export function dedupeScoreAggregateRows(
|
|
697
|
+
candidates: readonly ScoreAggregateRow[],
|
|
698
|
+
existing: readonly ScoreAggregateRow[],
|
|
699
|
+
): ScoreAggregateRow[] {
|
|
700
|
+
const seen = new Set(existing.map((row) => `${row.slug}\u0000${row.qeHash}`));
|
|
701
|
+
const fresh: ScoreAggregateRow[] = [];
|
|
702
|
+
for (const row of candidates) {
|
|
703
|
+
const key = `${row.slug}\u0000${row.qeHash}`;
|
|
704
|
+
if (seen.has(key)) continue;
|
|
705
|
+
seen.add(key);
|
|
706
|
+
fresh.push(row);
|
|
707
|
+
}
|
|
708
|
+
return fresh;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
export type ScoreAggregateVerdict = 'REPORTED' | 'INSUFFICIENT_DATA';
|
|
712
|
+
|
|
713
|
+
export interface ScoreDisciplineAggregate {
|
|
714
|
+
readonly id: string;
|
|
715
|
+
readonly pass: number;
|
|
716
|
+
readonly partial: number;
|
|
717
|
+
readonly absent: number;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
export interface ScoreGradeAggregate {
|
|
721
|
+
readonly grade: string | null;
|
|
722
|
+
readonly count: number;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
export interface ScoreAggregateReport {
|
|
726
|
+
readonly verdict: ScoreAggregateVerdict;
|
|
727
|
+
readonly receipts: number;
|
|
728
|
+
readonly appended: number;
|
|
729
|
+
readonly unreadable: number;
|
|
730
|
+
/** Repo-relative receipt paths: unreadable evidence is always named, never only counted. */
|
|
731
|
+
readonly unreadableReceipts: readonly string[];
|
|
732
|
+
readonly disciplines: readonly ScoreDisciplineAggregate[];
|
|
733
|
+
readonly grades: readonly ScoreGradeAggregate[];
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/** Fold the aggregate into an advisory report. No readable rows is a third state, never success. */
|
|
737
|
+
export function buildScoreAggregateReport(
|
|
738
|
+
rows: readonly ScoreAggregateRow[],
|
|
739
|
+
unreadableReceipts: readonly string[],
|
|
740
|
+
appended: number,
|
|
741
|
+
): ScoreAggregateReport {
|
|
742
|
+
const disciplineCounts = new Map<string, { pass: number; partial: number; absent: number }>();
|
|
743
|
+
const gradeCounts = new Map<string | null, number>();
|
|
744
|
+
for (const row of rows) {
|
|
745
|
+
gradeCounts.set(row.qeGrade, (gradeCounts.get(row.qeGrade) ?? 0) + 1);
|
|
746
|
+
for (const discipline of row.disciplines) {
|
|
747
|
+
const counts = disciplineCounts.get(discipline.id) ?? { pass: 0, partial: 0, absent: 0 };
|
|
748
|
+
counts[discipline.verdict] += 1;
|
|
749
|
+
disciplineCounts.set(discipline.id, counts);
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
const disciplines = [...disciplineCounts.entries()]
|
|
753
|
+
.sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)
|
|
754
|
+
.map(([id, counts]) => ({ id, ...counts }));
|
|
755
|
+
const grades = [...gradeCounts.entries()]
|
|
756
|
+
.sort(([a], [b]) => (a === null ? 1 : b === null ? -1 : a < b ? -1 : a > b ? 1 : 0))
|
|
757
|
+
.map(([grade, count]) => ({ grade, count }));
|
|
758
|
+
return {
|
|
759
|
+
verdict: rows.length === 0 ? 'INSUFFICIENT_DATA' : 'REPORTED',
|
|
760
|
+
receipts: rows.length + unreadableReceipts.length,
|
|
761
|
+
appended,
|
|
762
|
+
unreadable: unreadableReceipts.length,
|
|
763
|
+
unreadableReceipts: [...unreadableReceipts],
|
|
764
|
+
disciplines,
|
|
765
|
+
grades,
|
|
766
|
+
};
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
export function renderScoreAggregateReport(report: ScoreAggregateReport): string {
|
|
770
|
+
const out = [
|
|
771
|
+
'dz score --all — chained score-receipt aggregate (descriptive-only, never a gate)',
|
|
772
|
+
`VERDICT: ${report.verdict}`,
|
|
773
|
+
`receipts: ${report.receipts}`,
|
|
774
|
+
`appended: ${report.appended}`,
|
|
775
|
+
`unreadable: ${report.unreadable}`,
|
|
776
|
+
];
|
|
777
|
+
if (report.unreadableReceipts.length > 0) {
|
|
778
|
+
out.push(`unreadable receipts: ${report.unreadableReceipts.join(', ')}`);
|
|
779
|
+
}
|
|
780
|
+
if (report.verdict === 'INSUFFICIENT_DATA') {
|
|
781
|
+
out.push('No readable score receipts; no discipline ratio or percentage is reported.');
|
|
782
|
+
return out.join('\n');
|
|
783
|
+
}
|
|
784
|
+
out.push('disciplines:');
|
|
785
|
+
for (const discipline of report.disciplines) {
|
|
786
|
+
out.push(` ${discipline.id}: pass ${discipline.pass} · partial ${discipline.partial} · absent ${discipline.absent}`);
|
|
787
|
+
}
|
|
788
|
+
out.push('QE grades:');
|
|
789
|
+
for (const grade of report.grades) out.push(` ${grade.grade ?? '(no grade)'}: ${grade.count}`);
|
|
790
|
+
return out.join('\n');
|
|
791
|
+
}
|
package/src/setup.ts
CHANGED
|
@@ -21,6 +21,7 @@ import { join } from 'node:path';
|
|
|
21
21
|
import { execSync } from 'node:child_process';
|
|
22
22
|
|
|
23
23
|
import { mergeManagedHookEntries } from './managed-hooks.js';
|
|
24
|
+
import { applyIntegrationFragments, IntegrationApplyError } from './integration-apply.js';
|
|
24
25
|
|
|
25
26
|
/** Memory backend type. */
|
|
26
27
|
export type MemoryBackend = 'jsonl' | 'agentdb';
|
|
@@ -659,11 +660,9 @@ export function runSetup(opts: SetupOptions): SetupResult {
|
|
|
659
660
|
steps.push({ name: 'Configure hooks', status: 'skipped', detail: '--no-hooks' });
|
|
660
661
|
}
|
|
661
662
|
|
|
662
|
-
// Step 5.5: Register agentdb MCP
|
|
663
|
-
//
|
|
664
|
-
//
|
|
665
|
-
// shared-store invariant silently failed). READ-MERGE-WRITE: only the `agentdb` entry is
|
|
666
|
-
// managed; every other registered server is preserved (audit code#5 — never clobber).
|
|
663
|
+
// Step 5.5: Register agentdb MCP through the SAME ownership-aware transaction used by `dz init`.
|
|
664
|
+
// `.mcp.json` is the project-scope carrier Claude Code actually loads. A known historical dz
|
|
665
|
+
// agentdb shape is adopted; an ambiguous hand-authored entry is preserved and named as an error.
|
|
667
666
|
if (backend === 'agentdb') {
|
|
668
667
|
const agentdbEntry = {
|
|
669
668
|
command: 'npx',
|
|
@@ -675,17 +674,19 @@ export function runSetup(opts: SetupOptions): SetupResult {
|
|
|
675
674
|
// other: measured 2026-07-09, 5 of 20 samples zero bytes and 4 torn (ADR-001, 2026-08-26).
|
|
676
675
|
env: { AGENTDB_PATH: agentdbMcpStorePath(opts.projectRoot) },
|
|
677
676
|
};
|
|
678
|
-
const mcpConfigPath = join(opts.projectRoot, '.mcp.json');
|
|
679
677
|
try {
|
|
680
|
-
const
|
|
681
|
-
|
|
682
|
-
: {
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
678
|
+
const applied = applyIntegrationFragments({
|
|
679
|
+
projectRoot: opts.projectRoot,
|
|
680
|
+
fragments: [{
|
|
681
|
+
component: 'mcp',
|
|
682
|
+
carrierPath: '.mcp.json',
|
|
683
|
+
scope: 'project',
|
|
684
|
+
format: 'json',
|
|
685
|
+
rootKey: 'mcpServers',
|
|
686
|
+
entries: { agentdb: agentdbEntry },
|
|
687
|
+
}],
|
|
688
|
+
});
|
|
689
|
+
if (applied.written.includes('.mcp.json')) {
|
|
689
690
|
steps.push({
|
|
690
691
|
name: 'Register agentdb MCP',
|
|
691
692
|
status: 'done',
|
|
@@ -696,8 +697,10 @@ export function runSetup(opts: SetupOptions): SetupResult {
|
|
|
696
697
|
} else {
|
|
697
698
|
steps.push({ name: 'Register agentdb MCP', status: 'skipped', detail: 'already registered and current' });
|
|
698
699
|
}
|
|
699
|
-
} catch {
|
|
700
|
-
|
|
700
|
+
} catch (error) {
|
|
701
|
+
const reason = error instanceof IntegrationApplyError ? error.reasonCode : 'APPLY_FAILED';
|
|
702
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
703
|
+
steps.push({ name: 'Register agentdb MCP', status: 'error', detail: `${reason}: ${detail}` });
|
|
701
704
|
}
|
|
702
705
|
// Migrate off the legacy location: `.claude/mcp.json` is not loaded by Claude Code. If it
|
|
703
706
|
// holds ONLY our old agentdb registration, remove the file; otherwise leave it and warn.
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `--select` resolution: one provider per requested skill, decided ONCE (backlog 9d15b9b6, PR-A).
|
|
3
|
+
*
|
|
4
|
+
* THE DEFECT THIS REPLACES. `installSkills` looped the installer once PER ROOT, handing each root
|
|
5
|
+
* the same select list. Asking each root independently produces two failures that look like
|
|
6
|
+
* opposites but share one cause — the REQUEST was never resolved, only re-asked:
|
|
7
|
+
*
|
|
8
|
+
* - a skill present in two roots was installed TWICE and counted twice. The field report's
|
|
9
|
+
* observed `2 skill(s)` was ONE skill installed twice, read by everyone as an orchestrator plus
|
|
10
|
+
* its stage skill (features/digitize-book-codex-init-research/00_research/00-root-cause.md);
|
|
11
|
+
* - a skill present in NO root produced a warning and exit 0. `0 skill(s)` read as success, which
|
|
12
|
+
* is the same class as a health check reporting clean because it could not look.
|
|
13
|
+
*
|
|
14
|
+
* WHY A PURE MODULE. The decision must happen BEFORE anything is written — an exit 1 that arrives
|
|
15
|
+
* after hooks and memory are installed leaves a half-configured project, which is worse than either
|
|
16
|
+
* outcome. A pure resolver can be called at the top of the seam and interrogated without side
|
|
17
|
+
* effects; a resolver tangled with the filesystem cannot.
|
|
18
|
+
*
|
|
19
|
+
* DELIBERATELY NOT HERE: dependency closure (an orchestrator pulling its stage skills). That is
|
|
20
|
+
* PR-B. Mixing the two would put a correctness fix and a semantic change in one unreviewable
|
|
21
|
+
* change, and the exit-contract fix is what unblocks the rest.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** One skill root and the ids it offers, in the order discovery returned them. */
|
|
25
|
+
export interface SkillRoot {
|
|
26
|
+
readonly dir: string;
|
|
27
|
+
readonly ids: readonly string[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The single root chosen to provide one requested id. */
|
|
31
|
+
export interface ChosenProvider {
|
|
32
|
+
readonly id: string;
|
|
33
|
+
readonly dir: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A requested id offered by more than one root. NOT an error: precedence resolves it. But it is
|
|
38
|
+
* NAMED, because an operator who sees one install where two roots offer the skill must be able to
|
|
39
|
+
* learn which root lost — otherwise the precedence is folklore, and the next person re-derives it.
|
|
40
|
+
*/
|
|
41
|
+
export interface ShadowedProvider {
|
|
42
|
+
readonly id: string;
|
|
43
|
+
readonly chosen: string;
|
|
44
|
+
readonly alsoIn: readonly string[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface SelectResolution {
|
|
48
|
+
/** One entry per DISTINCT requested id that resolved, in request order. */
|
|
49
|
+
readonly chosen: readonly ChosenProvider[];
|
|
50
|
+
/** Requested ids no root offers. Non-empty means the caller must refuse and write nothing. */
|
|
51
|
+
readonly missing: readonly string[];
|
|
52
|
+
/** Requested ids offered by several roots, with the losers named. */
|
|
53
|
+
readonly shadowed: readonly ShadowedProvider[];
|
|
54
|
+
/** True only when EVERY requested id resolved. Shadowing does not make it false. */
|
|
55
|
+
readonly ok: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* PRECEDENCE IS ROOT ORDER, and that is a promise, not an implementation detail: the first root in
|
|
60
|
+
* `roots` that offers an id provides it. Callers pass roots in discovery order, so the rule the
|
|
61
|
+
* operator can state ("the earlier root wins") is the rule that runs. The previous behaviour
|
|
62
|
+
* depended on `readdir` order inside each root and on the loop visiting roots — an order nobody
|
|
63
|
+
* documented and nobody could rely on.
|
|
64
|
+
*
|
|
65
|
+
* Asking for the same id twice is asking once: a request is a SET of skills, and duplicating a name
|
|
66
|
+
* in the argument does not duplicate an installation.
|
|
67
|
+
*/
|
|
68
|
+
export function resolveSelection(
|
|
69
|
+
select: readonly string[],
|
|
70
|
+
roots: readonly SkillRoot[],
|
|
71
|
+
): SelectResolution {
|
|
72
|
+
const chosen: ChosenProvider[] = [];
|
|
73
|
+
const missing: string[] = [];
|
|
74
|
+
const shadowed: ShadowedProvider[] = [];
|
|
75
|
+
const seen = new Set<string>();
|
|
76
|
+
|
|
77
|
+
for (const id of select) {
|
|
78
|
+
if (seen.has(id)) continue;
|
|
79
|
+
seen.add(id);
|
|
80
|
+
const providers = roots.filter((root) => root.ids.includes(id)).map((root) => root.dir);
|
|
81
|
+
const first = providers[0];
|
|
82
|
+
if (first === undefined) {
|
|
83
|
+
missing.push(id);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
chosen.push({ id, dir: first });
|
|
87
|
+
// EVERY losing root is listed, not just the second: with three providers, naming one of the two
|
|
88
|
+
// losers would send the reader to fix the wrong copy.
|
|
89
|
+
if (providers.length > 1) shadowed.push({ id, chosen: first, alsoIn: providers.slice(1) });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return { chosen, missing, shadowed, ok: missing.length === 0 };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The refusal an explicit `--select` owes its caller. Returns null when there is nothing to refuse.
|
|
97
|
+
*
|
|
98
|
+
* Shaped as text rather than an exception so the seam can print it and return a code without a
|
|
99
|
+
* try/catch that a later edit might swallow — the failure mode this whole change exists to remove
|
|
100
|
+
* is a problem that produced a warning and kept going.
|
|
101
|
+
*/
|
|
102
|
+
export function formatSelectRefusal(resolution: SelectResolution, roots: readonly SkillRoot[]): string | null {
|
|
103
|
+
if (resolution.ok) return null;
|
|
104
|
+
const lines = [
|
|
105
|
+
`dz: --select refused — ${resolution.missing.length} requested skill(s) exist in none of the ${roots.length} skill root(s):`,
|
|
106
|
+
];
|
|
107
|
+
for (const id of resolution.missing) lines.push(` missing: ${id}`);
|
|
108
|
+
for (const root of roots) lines.push(` searched: ${root.dir} (${root.ids.length} skill(s))`);
|
|
109
|
+
lines.push('Nothing was written. Fix the id, or install the pack that provides it, then re-run.');
|
|
110
|
+
return lines.join('\n');
|
|
111
|
+
}
|
package/src/stem.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dependency-free lexical normalization shared by registry search and recommend.
|
|
3
|
+
* It deliberately handles only the measured RU/EN inflection cases; semantic
|
|
4
|
+
* similarity and paraphrases belong to the separate recall/advisor tier.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const CYRILLIC_TOKEN = /^\p{Script=Cyrillic}+$/u;
|
|
8
|
+
const LATIN_TOKEN = /^\p{Script=Latin}+$/u;
|
|
9
|
+
|
|
10
|
+
const CYRILLIC_SUFFIXES = [
|
|
11
|
+
'иями',
|
|
12
|
+
'иях', 'иям', 'ией', 'ями', 'ами', 'ого', 'его', 'ому', 'ему', 'ыми', 'ими',
|
|
13
|
+
'ях', 'ах', 'ой', 'ей', 'ий', 'ый', 'ая', 'яя', 'ое', 'ее', 'ие', 'ые',
|
|
14
|
+
'ом', 'ем', 'ам', 'ям', 'ов', 'ев', 'ью', 'ья', 'ье', 'ия', 'ии',
|
|
15
|
+
'и', 'ы', 'а', 'я', 'о', 'е', 'у', 'ю', 'ь',
|
|
16
|
+
] as const;
|
|
17
|
+
|
|
18
|
+
function canonicalize(text: string): string {
|
|
19
|
+
return text.normalize('NFC').toLowerCase().replaceAll('ё', 'е');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Unicode-aware word split without JavaScript's ASCII-only word-boundary escape. */
|
|
23
|
+
export function tokenize(text: string): string[] {
|
|
24
|
+
return canonicalize(text).split(/[^\p{L}\p{N}]+/u).filter(Boolean);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function stemCyrillic(token: string): string {
|
|
28
|
+
for (const suffix of CYRILLIC_SUFFIXES) {
|
|
29
|
+
if (!token.endsWith(suffix)) continue;
|
|
30
|
+
const candidate = token.slice(0, -suffix.length);
|
|
31
|
+
if (candidate.length < 3) continue;
|
|
32
|
+
|
|
33
|
+
// Short nouns ending in a base vowel are the precision trap behind тест/тесто
|
|
34
|
+
// and права/правка. Plural и/ы still normalize тесты→тест, while a one-letter
|
|
35
|
+
// case/gender tail is stripped only from a longer lexical base.
|
|
36
|
+
if (suffix.length === 1 && suffix !== 'и' && suffix !== 'ы' && suffix !== 'ь'
|
|
37
|
+
&& candidate.length < 5) continue;
|
|
38
|
+
return candidate;
|
|
39
|
+
}
|
|
40
|
+
return token;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function replaceSuffix(token: string, suffix: string, replacement: string): string | undefined {
|
|
44
|
+
if (!token.endsWith(suffix)) return undefined;
|
|
45
|
+
const candidate = token.slice(0, -suffix.length) + replacement;
|
|
46
|
+
return candidate.length >= 3 ? candidate : undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function stemLatin(token: string): string {
|
|
50
|
+
const ies = replaceSuffix(token, 'ies', 'y');
|
|
51
|
+
if (ies !== undefined) return ies;
|
|
52
|
+
const sses = replaceSuffix(token, 'sses', 'ss');
|
|
53
|
+
if (sses !== undefined) return sses;
|
|
54
|
+
const sis = replaceSuffix(token, 'sis', 's');
|
|
55
|
+
if (sis !== undefined) return sis;
|
|
56
|
+
const ses = replaceSuffix(token, 'ses', 's');
|
|
57
|
+
if (ses !== undefined) return ses;
|
|
58
|
+
|
|
59
|
+
if (token.endsWith('es')) {
|
|
60
|
+
const preceding = token.at(-3);
|
|
61
|
+
if (preceding !== 'i' && preceding !== 's') {
|
|
62
|
+
const es = replaceSuffix(token, 'es', '');
|
|
63
|
+
if (es !== undefined) return es;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (token.endsWith('s')) {
|
|
67
|
+
const preceding = token.at(-2);
|
|
68
|
+
if (preceding !== 's' && preceding !== 'u' && preceding !== 'i' && preceding !== 'y') {
|
|
69
|
+
const s = replaceSuffix(token, 's', '');
|
|
70
|
+
if (s !== undefined) return s;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return token;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Lowercase one token and remove at most one conservative inflectional suffix. */
|
|
77
|
+
export function stemToken(token: string): string {
|
|
78
|
+
const normalized = canonicalize(token);
|
|
79
|
+
if (CYRILLIC_TOKEN.test(normalized)) return stemCyrillic(normalized);
|
|
80
|
+
if (LATIN_TOKEN.test(normalized)) return stemLatin(normalized);
|
|
81
|
+
return normalized;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Tokenize and stem a text with the shared RU/EN rules. */
|
|
85
|
+
export function stems(text: string): string[] {
|
|
86
|
+
return tokenize(text).map(stemToken);
|
|
87
|
+
}
|