agentic-workflow-manager 3.3.1 → 3.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/commands/ledger/index.js +1 -1
- package/dist/src/core/export/transform.js +51 -1
- package/dist/src/core/ledger/cluster.js +138 -0
- package/dist/src/core/ledger/store.js +20 -11
- package/dist/tests/core/export/engine.test.js +7 -2
- package/dist/tests/core/export/transform.test.js +77 -0
- package/dist/tests/core/ledger/cluster.test.js +240 -0
- package/dist/tests/core/ledger/store.test.js +42 -7
- package/package.json +1 -1
|
@@ -47,7 +47,7 @@ function registerLedgerCommand(program) {
|
|
|
47
47
|
});
|
|
48
48
|
ledger
|
|
49
49
|
.command('recurring')
|
|
50
|
-
.description('print
|
|
50
|
+
.description('print recurrence clusters with count >= min (exact signature repeats and cross-reviewer convergence)')
|
|
51
51
|
.option('--min <n>', 'minimum occurrences', '2')
|
|
52
52
|
.option('--branch <branch>', 'override branch (default: git current branch)')
|
|
53
53
|
.action((opts) => {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.DEFERENCE_LINE = void 0;
|
|
4
|
+
exports.stripIntraRegistryPaths = stripIntraRegistryPaths;
|
|
4
5
|
exports.claudeAiTransform = claudeAiTransform;
|
|
5
6
|
// cli/src/core/export/transform.ts
|
|
6
7
|
//
|
|
@@ -9,6 +10,53 @@ exports.claudeAiTransform = claudeAiTransform;
|
|
|
9
10
|
// línea) — sin parser YAML a propósito (YAGNI, cero deps).
|
|
10
11
|
const DEFERENCE_LINE = (skillName) => `In environments with AWM installed (Claude Code), defer to the registry's ${skillName} skill — this port is for environments without filesystem access.`;
|
|
11
12
|
exports.DEFERENCE_LINE = DEFERENCE_LINE;
|
|
13
|
+
// Paths intra-registry: resuelven en Claude Code (donde el registry está en
|
|
14
|
+
// disco) y nunca en claude.ai, donde solo se sube la skill portable. Se limpian
|
|
15
|
+
// en el artefacto exportado en vez de editar el SKILL.md canónico, que en Claude
|
|
16
|
+
// Code sí los necesita.
|
|
17
|
+
const SKILL_NAME = '[a-z0-9][a-z0-9-]*';
|
|
18
|
+
const REF_FILE = '[A-Za-z0-9._-]+';
|
|
19
|
+
const PATH_SRC = '(?:skills\\/' + SKILL_NAME + '\\/references\\/' + REF_FILE + '\\.md'
|
|
20
|
+
+ '|skills\\/' + SKILL_NAME + '\\/SKILL\\.md)';
|
|
21
|
+
/** Reconoce, en una sola pasada sobre el body ORIGINAL, tanto el caso
|
|
22
|
+
* "paréntesis cuyo único contenido es un path" (grupo 1) como el path suelto
|
|
23
|
+
* en cualquier otra posición (grupo 2). Una sola pasada evita un bug real de
|
|
24
|
+
* splicing encontrado en code review: si se borrara el paréntesis en una
|
|
25
|
+
* pasada separada, un path inmediatamente siguiente podría quedar pegado a
|
|
26
|
+
* texto que antes terminaba en `/` (el cierre de una URL, p. ej.), y el guard
|
|
27
|
+
* de "embebido en URL" (que mira el carácter previo) confundiría eso con un
|
|
28
|
+
* path genuinamente embebido. Matcheando todo en una sola pasada contra el
|
|
29
|
+
* string original, cada offset que llega a `isEmbeddedInUrl` es siempre real,
|
|
30
|
+
* nunca un artefacto de un borrado previo. */
|
|
31
|
+
const PATH_OR_DROPPED_PAREN = new RegExp('([ \\t]*\\((?:see[ \\t]+)?`?' + PATH_SRC + '`?\\))' + '|' + '(`?' + PATH_SRC + '`?)', 'g');
|
|
32
|
+
/** Un path precedido por `/` es el final de una URL o de un path más largo (un
|
|
33
|
+
* enlace a GitHub, por ejemplo). Esas referencias SÍ resuelven para quien lee la
|
|
34
|
+
* skill en claude.ai, así que no se tocan. */
|
|
35
|
+
function isEmbeddedInUrl(haystack, matchStart, matched) {
|
|
36
|
+
const pathStart = matchStart + matched.indexOf('skills/');
|
|
37
|
+
return pathStart > 0 && haystack[pathStart - 1] === '/';
|
|
38
|
+
}
|
|
39
|
+
const PATH_MATCHER = new RegExp('^skills\\/(' + SKILL_NAME + ')\\/references\\/(' + REF_FILE + ')\\.md$'
|
|
40
|
+
+ '|^skills\\/(' + SKILL_NAME + ')\\/SKILL\\.md$');
|
|
41
|
+
function pathlessForm(p) {
|
|
42
|
+
const m = PATH_MATCHER.exec(p);
|
|
43
|
+
if (!m)
|
|
44
|
+
throw new Error(`unreachable: "${p}" matched PATH_SRC but not PATH_MATCHER — the two must stay in sync`);
|
|
45
|
+
const [, refSkill, refFile, skillOnlyName] = m;
|
|
46
|
+
if (skillOnlyName !== undefined)
|
|
47
|
+
return `the \`${skillOnlyName}\` skill`;
|
|
48
|
+
return `the \`${refSkill}\` skill's ${refFile.replace(/-/g, ' ')} reference`;
|
|
49
|
+
}
|
|
50
|
+
function stripIntraRegistryPaths(body) {
|
|
51
|
+
return body.replace(PATH_OR_DROPPED_PAREN, (match, parenForm, bareForm, offset) => {
|
|
52
|
+
if (isEmbeddedInUrl(body, offset, match))
|
|
53
|
+
return match;
|
|
54
|
+
if (parenForm !== undefined)
|
|
55
|
+
return '';
|
|
56
|
+
const path = bareForm.replace(/^`|`$/g, '');
|
|
57
|
+
return pathlessForm(path);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
12
60
|
function claudeAiTransform(skillMd, skillName) {
|
|
13
61
|
// \r?\n-tolerant, same rationale as readArtifactDescription in discovery.ts:
|
|
14
62
|
// SKILL.md files may be CRLF-terminated and that's still valid frontmatter.
|
|
@@ -53,5 +101,7 @@ function claudeAiTransform(skillMd, skillName) {
|
|
|
53
101
|
? `${value.slice(0, -1)} ${deference.replace(/'/g, "''")}'`
|
|
54
102
|
: `${value} ${deference}`;
|
|
55
103
|
fmLines[descIdx] = `description: ${newValue}`;
|
|
56
|
-
|
|
104
|
+
// Solo el body: el frontmatter ya se editó arriba y sus campos no son prosa
|
|
105
|
+
// navegable (R2.4).
|
|
106
|
+
return `---\n${fmLines.join('\n')}\n---\n${stripIntraRegistryPaths(body)}`;
|
|
57
107
|
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.LEXICAL_AFFINITY_MIN = void 0;
|
|
4
|
+
exports.normalizeTokens = normalizeTokens;
|
|
5
|
+
exports.affinity = affinity;
|
|
6
|
+
exports.normalizeRef = normalizeRef;
|
|
7
|
+
exports.clusterEntries = clusterEntries;
|
|
8
|
+
/** Palabras sin valor discriminante de identidad de defecto: se descartan antes
|
|
9
|
+
* de medir afinidad para que no inflen el score de dos hallazgos distintos. */
|
|
10
|
+
const STOPWORDS = new Set([
|
|
11
|
+
'the', 'and', 'for', 'with', 'that', 'this', 'from', 'into', 'not', 'but',
|
|
12
|
+
'its', 'has', 'have', 'was', 'are', 'were', 'when', 'then', 'than', 'only',
|
|
13
|
+
'all', 'any', 'via', 'per', 'out', 'off', 'over', 'under',
|
|
14
|
+
]);
|
|
15
|
+
function normalizeTokens(...texts) {
|
|
16
|
+
const out = new Set();
|
|
17
|
+
for (const text of texts) {
|
|
18
|
+
for (const token of text.toLowerCase().split(/[^a-z0-9]+/)) {
|
|
19
|
+
if (token.length < 2)
|
|
20
|
+
continue;
|
|
21
|
+
if (STOPWORDS.has(token))
|
|
22
|
+
continue;
|
|
23
|
+
out.add(token);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
/** Coeficiente de solapamiento — |A∩B| / min(|A|,|B|). Elegido sobre Jaccard
|
|
29
|
+
* porque el caso normal acá es un slug corto contra una descripción larga, y
|
|
30
|
+
* Jaccard castiga esa diferencia de longitud incluso cuando el set corto está
|
|
31
|
+
* completamente contenido en el largo. */
|
|
32
|
+
function affinity(a, b) {
|
|
33
|
+
if (a.size === 0 || b.size === 0)
|
|
34
|
+
return 0;
|
|
35
|
+
let shared = 0;
|
|
36
|
+
for (const token of a)
|
|
37
|
+
if (b.has(token))
|
|
38
|
+
shared++;
|
|
39
|
+
return shared / Math.min(a.size, b.size);
|
|
40
|
+
}
|
|
41
|
+
/** El locus de archivo de un `ref`, o null cuando el ref no apunta a un archivo.
|
|
42
|
+
* `PR #16` devuelve null a propósito: un PR entero no es un locus de defecto, y
|
|
43
|
+
* agrupar por él fundiría todo lo hallado en una misma review. */
|
|
44
|
+
function normalizeRef(ref) {
|
|
45
|
+
if (!ref)
|
|
46
|
+
return null;
|
|
47
|
+
const locus = ref.split(':')[0].trim();
|
|
48
|
+
if (!locus)
|
|
49
|
+
return null;
|
|
50
|
+
if (!locus.includes('/') && !/\.[a-z0-9]+$/i.test(locus))
|
|
51
|
+
return null;
|
|
52
|
+
return locus;
|
|
53
|
+
}
|
|
54
|
+
/** Umbral sin `ref` compartido: alto, porque la afinidad léxica es la única
|
|
55
|
+
* evidencia disponible y un falso positivo acá funde hallazgos de archivos
|
|
56
|
+
* distintos.
|
|
57
|
+
*
|
|
58
|
+
* Con `ref` compartido no hay umbral de ratio: alcanza **un token en común**
|
|
59
|
+
* (`score > 0`). Es deliberado y no es lo mismo que "un umbral muy bajo":
|
|
60
|
+
* compartir archivo ya es evidencia fuerte y barata de mismo locus, así que lo
|
|
61
|
+
* único que falta descartar es el par sin ninguna palabra en común — dos
|
|
62
|
+
* defectos genuinamente distintos que caen en el mismo archivo. Un ratio bajo
|
|
63
|
+
* (probamos 0.2) hacía que el caso real del issue —tres lentes, siete a nueve
|
|
64
|
+
* tokens cada una, un solo token compartido por par— cayera exactamente sobre
|
|
65
|
+
* el borde: agrupaba por casualidad aritmética, y cualquier palabra más en una
|
|
66
|
+
* descripción lo habría vuelto a romper. */
|
|
67
|
+
exports.LEXICAL_AFFINITY_MIN = 0.6;
|
|
68
|
+
/** Devuelve, por índice de entrada, el índice raíz de su cluster. */
|
|
69
|
+
function unify(entries) {
|
|
70
|
+
const parent = entries.map((_, i) => i);
|
|
71
|
+
const find = (i) => {
|
|
72
|
+
let node = i;
|
|
73
|
+
while (parent[node] !== node) {
|
|
74
|
+
parent[node] = parent[parent[node]];
|
|
75
|
+
node = parent[node];
|
|
76
|
+
}
|
|
77
|
+
return node;
|
|
78
|
+
};
|
|
79
|
+
const union = (a, b) => {
|
|
80
|
+
const rootA = find(a);
|
|
81
|
+
const rootB = find(b);
|
|
82
|
+
// La raíz más baja gana: hace el resultado independiente del orden de
|
|
83
|
+
// comparación, y por lo tanto determinístico.
|
|
84
|
+
if (rootA !== rootB)
|
|
85
|
+
parent[Math.max(rootA, rootB)] = Math.min(rootA, rootB);
|
|
86
|
+
};
|
|
87
|
+
const tokens = entries.map((e) => normalizeTokens(e.signature, e.desc));
|
|
88
|
+
const refs = entries.map((e) => normalizeRef(e.ref));
|
|
89
|
+
for (let i = 0; i < entries.length; i++) {
|
|
90
|
+
for (let j = i + 1; j < entries.length; j++) {
|
|
91
|
+
if (entries[i].signature === entries[j].signature) {
|
|
92
|
+
union(i, j); // R1.1 — piso preservado, sin más condiciones
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (entries[i].polarity !== entries[j].polarity)
|
|
96
|
+
continue; // R1.4
|
|
97
|
+
const score = affinity(tokens[i], tokens[j]);
|
|
98
|
+
const sameFile = refs[i] !== null && refs[i] === refs[j];
|
|
99
|
+
// Mismo archivo: cualquier palabra en común alcanza (R1.2).
|
|
100
|
+
// Archivos distintos: la afinidad tiene que sostener sola (R1.3).
|
|
101
|
+
const unionable = sameFile ? score > 0 : score >= exports.LEXICAL_AFFINITY_MIN;
|
|
102
|
+
if (unionable)
|
|
103
|
+
union(i, j);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return entries.map((_, i) => find(i));
|
|
107
|
+
}
|
|
108
|
+
function clusterEntries(entries, min) {
|
|
109
|
+
const roots = unify(entries);
|
|
110
|
+
const byRoot = new Map();
|
|
111
|
+
for (let i = 0; i < entries.length; i++) {
|
|
112
|
+
const group = byRoot.get(roots[i]) ?? [];
|
|
113
|
+
group.push(entries[i]);
|
|
114
|
+
byRoot.set(roots[i], group);
|
|
115
|
+
}
|
|
116
|
+
const clusters = [];
|
|
117
|
+
for (const group of byRoot.values()) {
|
|
118
|
+
const freq = new Map();
|
|
119
|
+
for (const e of group)
|
|
120
|
+
freq.set(e.signature, (freq.get(e.signature) ?? 0) + 1);
|
|
121
|
+
const signatures = [...freq.keys()].sort();
|
|
122
|
+
// signatures viene ascendente y la comparación es estricta, así que un
|
|
123
|
+
// empate de frecuencia deja parada la primera lexicográfica (R1.8).
|
|
124
|
+
const representative = signatures.reduce((best, s) => (freq.get(s) > freq.get(best) ? s : best), signatures[0]);
|
|
125
|
+
clusters.push({
|
|
126
|
+
signature: representative,
|
|
127
|
+
count: group.length,
|
|
128
|
+
kind: signatures.length > 1 ? 'convergent' : 'exact',
|
|
129
|
+
signatures,
|
|
130
|
+
entries: group,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
return clusters
|
|
134
|
+
.filter((c) => c.count >= min)
|
|
135
|
+
.sort((a, b) => b.count - a.count
|
|
136
|
+
|| (a.kind === b.kind ? 0 : a.kind === 'convergent' ? -1 : 1)
|
|
137
|
+
|| a.signature.localeCompare(b.signature));
|
|
138
|
+
}
|
|
@@ -12,6 +12,7 @@ exports.archiveLedger = archiveLedger;
|
|
|
12
12
|
const fs_1 = __importDefault(require("fs"));
|
|
13
13
|
const path_1 = __importDefault(require("path"));
|
|
14
14
|
const child_process_1 = require("child_process");
|
|
15
|
+
const cluster_1 = require("./cluster");
|
|
15
16
|
const LEDGER_DIR = path_1.default.join('.awm', 'ledger');
|
|
16
17
|
function detectBranch(cwd) {
|
|
17
18
|
try {
|
|
@@ -33,6 +34,21 @@ function addEntry(cwd, entry) {
|
|
|
33
34
|
fs_1.default.mkdirSync(path_1.default.dirname(p), { recursive: true });
|
|
34
35
|
fs_1.default.appendFileSync(p, JSON.stringify(entry) + '\n', 'utf-8');
|
|
35
36
|
}
|
|
37
|
+
/** Required LedgerEntry fields that `cluster.ts` reads unconditionally
|
|
38
|
+
* (`signature`, `desc`, `ref` when present). A JSONL line can be syntactically
|
|
39
|
+
* valid JSON while still being shape-invalid (e.g. missing `desc`) — that's
|
|
40
|
+
* not a parse error, so it needs its own check, extending the same "skip
|
|
41
|
+
* malformed line" policy this function already applies to JSON syntax errors. */
|
|
42
|
+
function isWellFormedEntry(x) {
|
|
43
|
+
if (!x || typeof x !== 'object')
|
|
44
|
+
return false;
|
|
45
|
+
const e = x;
|
|
46
|
+
return typeof e.signature === 'string'
|
|
47
|
+
&& typeof e.desc === 'string'
|
|
48
|
+
&& typeof e.branch === 'string'
|
|
49
|
+
&& typeof e.polarity === 'string'
|
|
50
|
+
&& (e.ref === undefined || typeof e.ref === 'string');
|
|
51
|
+
}
|
|
36
52
|
function listEntries(cwd, branch) {
|
|
37
53
|
const p = ledgerPath(cwd, branch);
|
|
38
54
|
if (!fs_1.default.existsSync(p))
|
|
@@ -43,23 +59,16 @@ function listEntries(cwd, branch) {
|
|
|
43
59
|
if (!trimmed)
|
|
44
60
|
continue;
|
|
45
61
|
try {
|
|
46
|
-
|
|
62
|
+
const parsed = JSON.parse(trimmed);
|
|
63
|
+
if (isWellFormedEntry(parsed))
|
|
64
|
+
out.push(parsed);
|
|
47
65
|
}
|
|
48
66
|
catch { /* skip malformed line */ }
|
|
49
67
|
}
|
|
50
68
|
return out;
|
|
51
69
|
}
|
|
52
70
|
function recurring(cwd, branch, min) {
|
|
53
|
-
|
|
54
|
-
for (const e of listEntries(cwd, branch)) {
|
|
55
|
-
const arr = bySig.get(e.signature) ?? [];
|
|
56
|
-
arr.push(e);
|
|
57
|
-
bySig.set(e.signature, arr);
|
|
58
|
-
}
|
|
59
|
-
return [...bySig.entries()]
|
|
60
|
-
.map(([signature, entries]) => ({ signature, count: entries.length, entries }))
|
|
61
|
-
.filter(c => c.count >= min)
|
|
62
|
-
.sort((a, b) => b.count - a.count);
|
|
71
|
+
return (0, cluster_1.clusterEntries)(listEntries(cwd, branch), min);
|
|
63
72
|
}
|
|
64
73
|
function archiveLedger(cwd, branch, label) {
|
|
65
74
|
const src = ledgerPath(cwd, branch);
|
|
@@ -35,7 +35,7 @@ function makeRoot() {
|
|
|
35
35
|
fs_1.default.mkdirSync(path_1.default.join(mermaid, 'references'));
|
|
36
36
|
fs_1.default.writeFileSync(path_1.default.join(mermaid, 'references/flow.md'), 'flow reference bytes');
|
|
37
37
|
const ported = mk('ported', ['name: ported', 'portable: true', 'description: "Ported."']);
|
|
38
|
-
fs_1.default.writeFileSync(path_1.default.join(ported, 'port.claude-ai.md'), '---\nname: ported\ndescription: "Custom port."\n---\nOverride body, verbatim.\n');
|
|
38
|
+
fs_1.default.writeFileSync(path_1.default.join(ported, 'port.claude-ai.md'), '---\nname: ported\ndescription: "Custom port."\n---\nOverride body, verbatim, citing `skills/readiness-gate/SKILL.md` on purpose.\n');
|
|
39
39
|
return root;
|
|
40
40
|
}
|
|
41
41
|
describe('runExport (engine end-to-end)', () => {
|
|
@@ -60,7 +60,7 @@ describe('runExport (engine end-to-end)', () => {
|
|
|
60
60
|
expect(mermaidMd).toContain('defer to the registry');
|
|
61
61
|
expect(fs_1.default.readFileSync(path_1.default.join(out, 'claude-ai/mermaid/references/flow.md'), 'utf-8')).toBe('flow reference bytes');
|
|
62
62
|
const portedMd = fs_1.default.readFileSync(path_1.default.join(out, 'claude-ai/ported/SKILL.md'), 'utf-8');
|
|
63
|
-
expect(portedMd).toBe('---\nname: ported\ndescription: "Custom port."\n---\nOverride body, verbatim.\n'); // cero transforms
|
|
63
|
+
expect(portedMd).toBe('---\nname: ported\ndescription: "Custom port."\n---\nOverride body, verbatim, citing `skills/readiness-gate/SKILL.md` on purpose.\n'); // cero transforms
|
|
64
64
|
});
|
|
65
65
|
it('rejects an unknown target listing the valid ones', () => {
|
|
66
66
|
expect(() => (0, export_1.runExport)({ name: 'dev', target: 'hermes', out, roots: [root], zip: okZip }))
|
|
@@ -101,4 +101,9 @@ describe('runExport (engine end-to-end)', () => {
|
|
|
101
101
|
fs_1.default.rmSync(cwdTmp, { recursive: true, force: true });
|
|
102
102
|
}
|
|
103
103
|
});
|
|
104
|
+
it('does not rewrite paths inside a verbatim override', () => {
|
|
105
|
+
(0, export_1.runExport)({ name: 'dev', out, roots: [root], zip: okZip });
|
|
106
|
+
const portedMd = fs_1.default.readFileSync(path_1.default.join(out, 'claude-ai/ported/SKILL.md'), 'utf-8');
|
|
107
|
+
expect(portedMd).toContain('citing `skills/readiness-gate/SKILL.md` on purpose');
|
|
108
|
+
});
|
|
104
109
|
});
|
|
@@ -71,4 +71,81 @@ describe('claudeAiTransform', () => {
|
|
|
71
71
|
const input = FM(['name: x', 'portable: true', 'description: "Does things." # a comment']);
|
|
72
72
|
expect(() => (0, transform_1.claudeAiTransform)(input, 'x')).toThrow(/trailing content|comment/i);
|
|
73
73
|
});
|
|
74
|
+
it('cleans intra-registry paths in the body', () => {
|
|
75
|
+
const md = [
|
|
76
|
+
'---',
|
|
77
|
+
'name: product-discovery',
|
|
78
|
+
'version: "1.0.0"',
|
|
79
|
+
'portable: true',
|
|
80
|
+
'description: "Explores problem space."',
|
|
81
|
+
'---',
|
|
82
|
+
'Hand off to `product-brief` (see `skills/product-brief/SKILL.md`) at the end.',
|
|
83
|
+
'',
|
|
84
|
+
].join('\n');
|
|
85
|
+
const out = (0, transform_1.claudeAiTransform)(md, 'product-discovery');
|
|
86
|
+
expect(out).toContain('Hand off to `product-brief` at the end.');
|
|
87
|
+
expect(out).not.toContain('skills/product-brief/SKILL.md');
|
|
88
|
+
});
|
|
89
|
+
it('leaves the frontmatter block free of body rewriting', () => {
|
|
90
|
+
const md = [
|
|
91
|
+
'---',
|
|
92
|
+
'name: weird',
|
|
93
|
+
'description: "Mentions skills/readiness-gate/SKILL.md inside the description."',
|
|
94
|
+
'---',
|
|
95
|
+
'Body with no paths.',
|
|
96
|
+
'',
|
|
97
|
+
].join('\n');
|
|
98
|
+
const out = (0, transform_1.claudeAiTransform)(md, 'weird');
|
|
99
|
+
expect(out).toContain('Mentions skills/readiness-gate/SKILL.md inside the description.');
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
describe('stripIntraRegistryPaths', () => {
|
|
103
|
+
it('drops a parenthetical whose only content is a see-path', () => {
|
|
104
|
+
expect((0, transform_1.stripIntraRegistryPaths)('crystallize into a `product-brief` (see `skills/product-brief/SKILL.md`) — the handoff.')).toBe('crystallize into a `product-brief` — the handoff.');
|
|
105
|
+
});
|
|
106
|
+
it('drops a bare-path parenthetical without leaving a space before the comma', () => {
|
|
107
|
+
expect((0, transform_1.stripIntraRegistryPaths)('Same discipline as `brainstorming` (see `skills/brainstorming/SKILL.md`), applied at the business level.')).toBe('Same discipline as `brainstorming`, applied at the business level.');
|
|
108
|
+
});
|
|
109
|
+
it('drops a parenthetical holding only a references path', () => {
|
|
110
|
+
expect((0, transform_1.stripIntraRegistryPaths)("conforming to the brief contract's frontmatter (`skills/readiness-gate/references/brief-contract.md`), using:")).toBe("conforming to the brief contract's frontmatter, using:");
|
|
111
|
+
});
|
|
112
|
+
it('rewrites a path in place when the parenthetical carries more text', () => {
|
|
113
|
+
expect((0, transform_1.stripIntraRegistryPaths)('the literal YAML block below (see `skills/readiness-gate/references/brief-contract.md` for the full normative rules).')).toBe("the literal YAML block below (see the `readiness-gate` skill's brief contract reference for the full normative rules).");
|
|
114
|
+
});
|
|
115
|
+
it('rewrites a bare unquoted path in prose', () => {
|
|
116
|
+
expect((0, transform_1.stripIntraRegistryPaths)('shape are normative — see skills/readiness-gate/references/brief-contract.md.')).toBe("shape are normative — see the `readiness-gate` skill's brief contract reference.");
|
|
117
|
+
});
|
|
118
|
+
it('renders a SKILL.md path as a nameless skill reference', () => {
|
|
119
|
+
expect((0, transform_1.stripIntraRegistryPaths)('invoke `skills/readiness-gate/SKILL.md` to certify it.'))
|
|
120
|
+
.toBe('invoke the `readiness-gate` skill to certify it.');
|
|
121
|
+
});
|
|
122
|
+
it('leaves a GitHub URL containing the same path untouched', () => {
|
|
123
|
+
const url = 'see https://github.com/Kodria/awm-baseline-registry/blob/main/skills/readiness-gate/SKILL.md for the source.';
|
|
124
|
+
expect((0, transform_1.stripIntraRegistryPaths)(url)).toBe(url);
|
|
125
|
+
});
|
|
126
|
+
it('leaves a markdown link whose target is a URL untouched', () => {
|
|
127
|
+
const link = '[the gate](https://github.com/Kodria/awm-baseline-registry/blob/main/skills/readiness-gate/references/brief-contract.md)';
|
|
128
|
+
expect((0, transform_1.stripIntraRegistryPaths)(link)).toBe(link);
|
|
129
|
+
});
|
|
130
|
+
it('leaves prose with no intra-registry path byte-identical', () => {
|
|
131
|
+
const body = '# Heading\n\nA body that cites `docs/plans/x.md` and nothing else.\n';
|
|
132
|
+
expect((0, transform_1.stripIntraRegistryPaths)(body)).toBe(body);
|
|
133
|
+
});
|
|
134
|
+
it('handles several paths in one body', () => {
|
|
135
|
+
expect((0, transform_1.stripIntraRegistryPaths)('hand off to `product-brief` (`skills/product-brief/SKILL.md`) then invoke `skills/readiness-gate/SKILL.md`.')).toBe('hand off to `product-brief` then invoke the `readiness-gate` skill.');
|
|
136
|
+
});
|
|
137
|
+
it('rewrites a path immediately following a dropped parenthetical, even with no separator', () => {
|
|
138
|
+
// Regression guard: a naive two-pass implementation (drop parentheticals,
|
|
139
|
+
// THEN rewrite bare paths on the already-mutated string) can splice a
|
|
140
|
+
// URL's trailing "/" directly against this path with zero separator,
|
|
141
|
+
// making the URL-embedding guard misfire and silently skip the rewrite.
|
|
142
|
+
expect((0, transform_1.stripIntraRegistryPaths)('See http://x.com/y/ (see `skills/a/SKILL.md`)skills/b/SKILL.md now.')).toBe('See http://x.com/y/the `b` skill now.');
|
|
143
|
+
});
|
|
144
|
+
it('rewrites a path that is the very first characters of the body', () => {
|
|
145
|
+
// Exercises the pathStart === 0 boundary in isEmbeddedInUrl (pathStart > 0
|
|
146
|
+
// must be false, not true, when the path opens the string) — every other
|
|
147
|
+
// test in this file has text preceding the path, so this was untested.
|
|
148
|
+
expect((0, transform_1.stripIntraRegistryPaths)('skills/readiness-gate/SKILL.md is required.'))
|
|
149
|
+
.toBe('the `readiness-gate` skill is required.');
|
|
150
|
+
});
|
|
74
151
|
});
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const cluster_1 = require("../../../src/core/ledger/cluster");
|
|
4
|
+
describe('normalizeTokens', () => {
|
|
5
|
+
test('lowercases and splits slugs on non-alphanumeric boundaries', () => {
|
|
6
|
+
expect([...(0, cluster_1.normalizeTokens)('Validator-Skips_Agents.References')].sort())
|
|
7
|
+
.toEqual(['agents', 'references', 'skips', 'validator']);
|
|
8
|
+
});
|
|
9
|
+
test('drops tokens shorter than two characters', () => {
|
|
10
|
+
expect([...(0, cluster_1.normalizeTokens)('a b ok x')].sort()).toEqual(['ok']);
|
|
11
|
+
});
|
|
12
|
+
test('drops stopwords so they cannot inflate affinity', () => {
|
|
13
|
+
expect([...(0, cluster_1.normalizeTokens)('the gate walks only the skills')].sort())
|
|
14
|
+
.toEqual(['gate', 'skills', 'walks']);
|
|
15
|
+
});
|
|
16
|
+
test('merges tokens across every text it is given', () => {
|
|
17
|
+
expect([...(0, cluster_1.normalizeTokens)('gate-walks', 'skills dir')].sort())
|
|
18
|
+
.toEqual(['dir', 'gate', 'skills', 'walks']);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
describe('affinity', () => {
|
|
22
|
+
test('is the overlap coefficient, so a contained short set scores 1', () => {
|
|
23
|
+
const short = (0, cluster_1.normalizeTokens)('validator gate');
|
|
24
|
+
const long = (0, cluster_1.normalizeTokens)('validator gate walks the skills directory only');
|
|
25
|
+
expect((0, cluster_1.affinity)(short, long)).toBe(1);
|
|
26
|
+
});
|
|
27
|
+
test('is 0 for disjoint sets', () => {
|
|
28
|
+
expect((0, cluster_1.affinity)((0, cluster_1.normalizeTokens)('alpha slug'), (0, cluster_1.normalizeTokens)('beta timeout'))).toBe(0);
|
|
29
|
+
});
|
|
30
|
+
test('is 0 when either side is empty', () => {
|
|
31
|
+
expect((0, cluster_1.affinity)(new Set(), (0, cluster_1.normalizeTokens)('alpha'))).toBe(0);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
describe('normalizeRef', () => {
|
|
35
|
+
test('strips the line number and keeps the file locus', () => {
|
|
36
|
+
expect((0, cluster_1.normalizeRef)('scripts/validate-portability.mjs:41')).toBe('scripts/validate-portability.mjs');
|
|
37
|
+
});
|
|
38
|
+
test('accepts a bare filename with an extension', () => {
|
|
39
|
+
expect((0, cluster_1.normalizeRef)('split.ts:12')).toBe('split.ts');
|
|
40
|
+
});
|
|
41
|
+
test('rejects a non-file ref: a whole PR is not a defect locus', () => {
|
|
42
|
+
expect((0, cluster_1.normalizeRef)('PR #16')).toBeNull();
|
|
43
|
+
});
|
|
44
|
+
test('rejects a URL, whose pre-colon portion carries no locus', () => {
|
|
45
|
+
expect((0, cluster_1.normalizeRef)('https://github.com/Kodria/agentic-workflow/pull/15')).toBeNull();
|
|
46
|
+
});
|
|
47
|
+
test('returns null for a missing ref', () => {
|
|
48
|
+
expect((0, cluster_1.normalizeRef)(undefined)).toBeNull();
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
function entry(over = {}) {
|
|
52
|
+
return {
|
|
53
|
+
ts: '2026-07-25T00:00:00.000Z',
|
|
54
|
+
branch: 'feat-x',
|
|
55
|
+
phase: 'post-qa',
|
|
56
|
+
source_skill: 'post-implementation-qa',
|
|
57
|
+
polarity: 'finding',
|
|
58
|
+
class: 'logica',
|
|
59
|
+
signature: 'some-finding',
|
|
60
|
+
severity: 'important',
|
|
61
|
+
desc: 'something is wrong',
|
|
62
|
+
ref: 'src/some.ts:1',
|
|
63
|
+
...over,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
describe('clusterEntries — exact signature floor', () => {
|
|
67
|
+
test('groups identical signatures and honours min', () => {
|
|
68
|
+
const clusters = (0, cluster_1.clusterEntries)([
|
|
69
|
+
entry({ signature: 'dup', desc: 'alpha slug mismatch', ref: 'src/a.ts:1' }),
|
|
70
|
+
entry({ signature: 'dup', desc: 'alpha slug mismatch', ref: 'src/a.ts:1' }),
|
|
71
|
+
entry({ signature: 'solo', desc: 'beta timeout on retry', ref: 'src/b.ts:9' }),
|
|
72
|
+
], 2);
|
|
73
|
+
expect(clusters).toHaveLength(1);
|
|
74
|
+
expect(clusters[0]).toMatchObject({ signature: 'dup', count: 2, kind: 'exact' });
|
|
75
|
+
});
|
|
76
|
+
test('unions identical signatures even across unrelated files and descriptions', () => {
|
|
77
|
+
const clusters = (0, cluster_1.clusterEntries)([
|
|
78
|
+
entry({ signature: 'same-slug', desc: 'alpha slug mismatch', ref: 'src/a.ts:1' }),
|
|
79
|
+
entry({ signature: 'same-slug', desc: 'beta timeout on retry', ref: 'src/b.ts:9' }),
|
|
80
|
+
], 2);
|
|
81
|
+
expect(clusters).toHaveLength(1);
|
|
82
|
+
expect(clusters[0].count).toBe(2);
|
|
83
|
+
});
|
|
84
|
+
test('unions identical signatures regardless of polarity, as before', () => {
|
|
85
|
+
const clusters = (0, cluster_1.clusterEntries)([
|
|
86
|
+
entry({ signature: 'same-slug', polarity: 'finding' }),
|
|
87
|
+
entry({ signature: 'same-slug', polarity: 'win' }),
|
|
88
|
+
], 2);
|
|
89
|
+
expect(clusters).toHaveLength(1);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
describe('clusterEntries — convergence on a shared file', () => {
|
|
93
|
+
// El caso real del 2026-07-25: tres lentes aisladas, tres slugs distintos,
|
|
94
|
+
// un solo defecto (el gate de portabilidad recorría solo skills/).
|
|
95
|
+
const threeLenses = () => [
|
|
96
|
+
entry({
|
|
97
|
+
signature: 'validator-skips-agents-references',
|
|
98
|
+
desc: 'the portability validator never walks agents/ references',
|
|
99
|
+
ref: 'scripts/validate-portability.mjs:41',
|
|
100
|
+
source_skill: 'fidelity-lens',
|
|
101
|
+
}),
|
|
102
|
+
entry({
|
|
103
|
+
signature: 'validator-scope-skills-only',
|
|
104
|
+
desc: 'validator scope covers skills only, missing sibling trees',
|
|
105
|
+
ref: 'scripts/validate-portability.mjs:41',
|
|
106
|
+
source_skill: 'logic-lens',
|
|
107
|
+
}),
|
|
108
|
+
entry({
|
|
109
|
+
signature: 'gate-walks-skills-only',
|
|
110
|
+
desc: 'the gate walks skills and nothing else',
|
|
111
|
+
ref: 'scripts/validate-portability.mjs:58',
|
|
112
|
+
source_skill: 'robustness-lens',
|
|
113
|
+
}),
|
|
114
|
+
];
|
|
115
|
+
test('clusters three independent lenses on one defect', () => {
|
|
116
|
+
const clusters = (0, cluster_1.clusterEntries)(threeLenses(), 2);
|
|
117
|
+
expect(clusters).toHaveLength(1);
|
|
118
|
+
expect(clusters[0].count).toBe(3);
|
|
119
|
+
});
|
|
120
|
+
test('labels the cluster convergent and lists every distinct signature', () => {
|
|
121
|
+
const clusters = (0, cluster_1.clusterEntries)(threeLenses(), 2);
|
|
122
|
+
expect(clusters[0].kind).toBe('convergent');
|
|
123
|
+
expect(clusters[0].signatures).toEqual([
|
|
124
|
+
'gate-walks-skills-only',
|
|
125
|
+
'validator-scope-skills-only',
|
|
126
|
+
'validator-skips-agents-references',
|
|
127
|
+
]);
|
|
128
|
+
});
|
|
129
|
+
test('does NOT merge two unrelated defects that happen to share a file', () => {
|
|
130
|
+
const clusters = (0, cluster_1.clusterEntries)([
|
|
131
|
+
entry({
|
|
132
|
+
signature: 'gate-walks-skills-only',
|
|
133
|
+
desc: 'the gate walks skills and nothing else',
|
|
134
|
+
ref: 'scripts/validate-portability.mjs:58',
|
|
135
|
+
}),
|
|
136
|
+
entry({
|
|
137
|
+
signature: 'exit-code-swallowed',
|
|
138
|
+
desc: 'process exits zero after a failed assertion',
|
|
139
|
+
ref: 'scripts/validate-portability.mjs:58',
|
|
140
|
+
}),
|
|
141
|
+
], 2);
|
|
142
|
+
expect(clusters).toEqual([]);
|
|
143
|
+
});
|
|
144
|
+
test('does NOT merge a win with a finding on the strength of a shared file', () => {
|
|
145
|
+
const clusters = (0, cluster_1.clusterEntries)([
|
|
146
|
+
entry({ signature: 'gate-walks-skills-only', desc: 'the gate walks skills only', ref: 'a.mjs:1', polarity: 'finding' }),
|
|
147
|
+
entry({ signature: 'gate-walks-skills-fix', desc: 'the gate walks skills only, now fixed', ref: 'a.mjs:1', polarity: 'win' }),
|
|
148
|
+
], 2);
|
|
149
|
+
expect(clusters).toEqual([]);
|
|
150
|
+
});
|
|
151
|
+
test('a non-file ref contributes no clustering signal', () => {
|
|
152
|
+
const clusters = (0, cluster_1.clusterEntries)([
|
|
153
|
+
entry({ signature: 'alpha-defect', desc: 'alpha slug mismatch', ref: 'PR #16' }),
|
|
154
|
+
entry({ signature: 'beta-defect', desc: 'beta timeout on retry', ref: 'PR #16' }),
|
|
155
|
+
], 2);
|
|
156
|
+
expect(clusters).toEqual([]);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
describe('clusterEntries — lexical convergence without a shared file', () => {
|
|
160
|
+
test('clusters near-identical wording across different files', () => {
|
|
161
|
+
const clusters = (0, cluster_1.clusterEntries)([
|
|
162
|
+
entry({ signature: 'vacuous-test-asserts-nothing', desc: 'test asserts nothing meaningful', ref: 'tests/a.test.ts:3' }),
|
|
163
|
+
entry({ signature: 'vacuous-test-asserts-nothing-either', desc: 'test asserts nothing meaningful', ref: 'tests/b.test.ts:7' }),
|
|
164
|
+
], 2);
|
|
165
|
+
expect(clusters).toHaveLength(1);
|
|
166
|
+
expect(clusters[0].kind).toBe('convergent');
|
|
167
|
+
});
|
|
168
|
+
test('leaves weakly-related findings on different files apart', () => {
|
|
169
|
+
const clusters = (0, cluster_1.clusterEntries)([
|
|
170
|
+
entry({ signature: 'validator-scope-skills-only', desc: 'validator scope covers skills', ref: 'src/a.ts:1' }),
|
|
171
|
+
entry({ signature: 'gate-walks-skills-only', desc: 'the gate walks skills', ref: 'src/b.ts:1' }),
|
|
172
|
+
], 2);
|
|
173
|
+
expect(clusters).toEqual([]);
|
|
174
|
+
});
|
|
175
|
+
test('merges A and C transitively through B, though A and C alone would not cluster', () => {
|
|
176
|
+
const chain = [
|
|
177
|
+
entry({ signature: 'aaa-marker', desc: 'alpha beta gamma delta', ref: 'src/a.ts:1' }),
|
|
178
|
+
entry({ signature: 'bbb-marker', desc: 'beta gamma delta epsilon', ref: 'src/b.ts:1' }),
|
|
179
|
+
entry({ signature: 'ccc-marker', desc: 'gamma delta epsilon zeta', ref: 'src/c.ts:1' }),
|
|
180
|
+
];
|
|
181
|
+
const clusters = (0, cluster_1.clusterEntries)(chain, 2);
|
|
182
|
+
expect(clusters).toHaveLength(1);
|
|
183
|
+
expect(clusters[0]).toMatchObject({ count: 3, kind: 'convergent' });
|
|
184
|
+
expect(clusters[0].signatures).toEqual(['aaa-marker', 'bbb-marker', 'ccc-marker']);
|
|
185
|
+
// Control: without the bridging entry, A and C do not satisfy the
|
|
186
|
+
// threshold on their own (affinity 0.5 < LEXICAL_AFFINITY_MIN 0.6) —
|
|
187
|
+
// proving the merge above genuinely relies on transitive closure
|
|
188
|
+
// through B, not a coincidence of the threshold being lenient.
|
|
189
|
+
const withoutBridge = (0, cluster_1.clusterEntries)([chain[0], chain[2]], 2);
|
|
190
|
+
expect(withoutBridge).toEqual([]);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
describe('clusterEntries — representative and ordering', () => {
|
|
194
|
+
test('representative signature is the most frequent one', () => {
|
|
195
|
+
const clusters = (0, cluster_1.clusterEntries)([
|
|
196
|
+
entry({ signature: 'zeta-frequent', desc: 'gate walks skills only', ref: 'a.mjs:1' }),
|
|
197
|
+
entry({ signature: 'zeta-frequent', desc: 'gate walks skills only', ref: 'a.mjs:1' }),
|
|
198
|
+
entry({ signature: 'alpha-rare', desc: 'gate walks skills only, second lens', ref: 'a.mjs:1' }),
|
|
199
|
+
], 2);
|
|
200
|
+
expect(clusters).toHaveLength(1);
|
|
201
|
+
expect(clusters[0].signature).toBe('zeta-frequent');
|
|
202
|
+
expect(clusters[0].count).toBe(3);
|
|
203
|
+
});
|
|
204
|
+
test('ties on frequency resolve to the lexicographically first signature', () => {
|
|
205
|
+
const clusters = (0, cluster_1.clusterEntries)([
|
|
206
|
+
entry({ signature: 'zeta-lens', desc: 'gate walks skills only', ref: 'a.mjs:1' }),
|
|
207
|
+
entry({ signature: 'alpha-lens', desc: 'gate walks skills only', ref: 'a.mjs:1' }),
|
|
208
|
+
], 2);
|
|
209
|
+
expect(clusters[0].signature).toBe('alpha-lens');
|
|
210
|
+
});
|
|
211
|
+
test('sorts by count desc, then convergent before exact, then signature asc', () => {
|
|
212
|
+
const clusters = (0, cluster_1.clusterEntries)([
|
|
213
|
+
// convergent cluster of 2 on one file
|
|
214
|
+
entry({ signature: 'mid-one', desc: 'gate walks skills only', ref: 'mid.mjs:1' }),
|
|
215
|
+
entry({ signature: 'mid-two', desc: 'gate walks skills only, other lens', ref: 'mid.mjs:1' }),
|
|
216
|
+
// exact cluster of 2, unrelated
|
|
217
|
+
entry({ signature: 'exact-dup', desc: 'alpha slug mismatch', ref: 'exact.ts:1' }),
|
|
218
|
+
entry({ signature: 'exact-dup', desc: 'alpha slug mismatch', ref: 'exact.ts:1' }),
|
|
219
|
+
// exact cluster of 3, unrelated — highest count wins regardless of kind
|
|
220
|
+
entry({ signature: 'top-dup', desc: 'beta timeout on retry', ref: 'top.ts:1' }),
|
|
221
|
+
entry({ signature: 'top-dup', desc: 'beta timeout on retry', ref: 'top.ts:1' }),
|
|
222
|
+
entry({ signature: 'top-dup', desc: 'beta timeout on retry', ref: 'top.ts:1' }),
|
|
223
|
+
], 2);
|
|
224
|
+
expect(clusters.map((c) => [c.signature, c.count, c.kind])).toEqual([
|
|
225
|
+
['top-dup', 3, 'exact'],
|
|
226
|
+
['mid-one', 2, 'convergent'],
|
|
227
|
+
['exact-dup', 2, 'exact'],
|
|
228
|
+
]);
|
|
229
|
+
});
|
|
230
|
+
test('an empty ledger yields no clusters', () => {
|
|
231
|
+
expect((0, cluster_1.clusterEntries)([], 2)).toEqual([]);
|
|
232
|
+
});
|
|
233
|
+
test('min <= 0 still returns every group, including size-1 groups', () => {
|
|
234
|
+
const solo = entry({ signature: 'lonely', desc: 'nobody else mentions this', ref: 'z.ts:1' });
|
|
235
|
+
expect((0, cluster_1.clusterEntries)([solo], 0)).toEqual([
|
|
236
|
+
{ signature: 'lonely', count: 1, kind: 'exact', signatures: ['lonely'], entries: [solo] },
|
|
237
|
+
]);
|
|
238
|
+
expect((0, cluster_1.clusterEntries)([solo], -5)).toEqual((0, cluster_1.clusterEntries)([solo], 0));
|
|
239
|
+
});
|
|
240
|
+
});
|
|
@@ -54,6 +54,17 @@ describe('ledger store — add/list', () => {
|
|
|
54
54
|
expect(got).toHaveLength(2);
|
|
55
55
|
expect(got.map(e => e.signature)).toEqual(['public-fn-returns-infinity', 's2']);
|
|
56
56
|
});
|
|
57
|
+
test('listEntries skips a shape-invalid (but syntactically valid) entry without throwing', () => {
|
|
58
|
+
const p = (0, store_1.ledgerPath)(cwd, 'feat-x');
|
|
59
|
+
fs_1.default.mkdirSync(path_1.default.dirname(p), { recursive: true });
|
|
60
|
+
const missingDesc = JSON.stringify({ ts: 't', branch: 'feat-x', phase: 'p', source_skill: 's', polarity: 'finding', class: 'logica', signature: 'missing-desc', severity: 'minor', ref: 'a.ts:1' });
|
|
61
|
+
const nullDesc = JSON.stringify({ ...entry(), desc: null });
|
|
62
|
+
const numericSignature = JSON.stringify({ ...entry(), signature: 42 });
|
|
63
|
+
fs_1.default.writeFileSync(p, [missingDesc, nullDesc, numericSignature, JSON.stringify(entry({ signature: 's2' }))].join('\n') + '\n');
|
|
64
|
+
const got = (0, store_1.listEntries)(cwd, 'feat-x');
|
|
65
|
+
expect(got).toHaveLength(1);
|
|
66
|
+
expect(got[0].signature).toBe('s2');
|
|
67
|
+
});
|
|
57
68
|
});
|
|
58
69
|
describe('ledger store — detectBranch', () => {
|
|
59
70
|
test('falls back to _no-branch outside a git repo', () => {
|
|
@@ -84,10 +95,10 @@ describe('ledger store — recurring', () => {
|
|
|
84
95
|
test('groups by signature and reports clusters with count >= min', () => {
|
|
85
96
|
(0, store_1.addEntry)(cwd, entry({ signature: 'dup' }));
|
|
86
97
|
(0, store_1.addEntry)(cwd, entry({ signature: 'dup' }));
|
|
87
|
-
(0, store_1.addEntry)(cwd, entry({ signature: 'solo' }));
|
|
98
|
+
(0, store_1.addEntry)(cwd, entry({ signature: 'solo', ref: 'src/other.ts:3', desc: 'pagination cursor skips a page' }));
|
|
88
99
|
const clusters = (0, store_1.recurring)(cwd, 'feat-x', 2);
|
|
89
100
|
expect(clusters).toHaveLength(1);
|
|
90
|
-
expect(clusters[0]).toMatchObject({ signature: 'dup', count: 2 });
|
|
101
|
+
expect(clusters[0]).toMatchObject({ signature: 'dup', count: 2, kind: 'exact' });
|
|
91
102
|
expect(clusters[0].entries).toHaveLength(2);
|
|
92
103
|
});
|
|
93
104
|
test('respects --min: count 2 is excluded when min is 3', () => {
|
|
@@ -96,14 +107,38 @@ describe('ledger store — recurring', () => {
|
|
|
96
107
|
expect((0, store_1.recurring)(cwd, 'feat-x', 3)).toEqual([]);
|
|
97
108
|
});
|
|
98
109
|
test('sorts clusters by count descending', () => {
|
|
99
|
-
(0, store_1.addEntry)(cwd, entry({ signature: 'a' }));
|
|
100
|
-
(0, store_1.addEntry)(cwd, entry({ signature: 'a' }));
|
|
101
|
-
(0, store_1.addEntry)(cwd, entry({ signature: 'b' }));
|
|
102
|
-
(0, store_1.addEntry)(cwd, entry({ signature: 'b' }));
|
|
103
|
-
(0, store_1.addEntry)(cwd, entry({ signature: 'b' }));
|
|
110
|
+
(0, store_1.addEntry)(cwd, entry({ signature: 'a', ref: 'src/a.ts:1', desc: 'alpha slug mismatch' }));
|
|
111
|
+
(0, store_1.addEntry)(cwd, entry({ signature: 'a', ref: 'src/a.ts:1', desc: 'alpha slug mismatch' }));
|
|
112
|
+
(0, store_1.addEntry)(cwd, entry({ signature: 'b', ref: 'src/b.ts:1', desc: 'beta timeout on retry' }));
|
|
113
|
+
(0, store_1.addEntry)(cwd, entry({ signature: 'b', ref: 'src/b.ts:1', desc: 'beta timeout on retry' }));
|
|
114
|
+
(0, store_1.addEntry)(cwd, entry({ signature: 'b', ref: 'src/b.ts:1', desc: 'beta timeout on retry' }));
|
|
104
115
|
const clusters = (0, store_1.recurring)(cwd, 'feat-x', 2);
|
|
105
116
|
expect(clusters.map(c => c.signature)).toEqual(['b', 'a']);
|
|
106
117
|
});
|
|
118
|
+
test('reports independent lenses on one file as a single convergent cluster', () => {
|
|
119
|
+
(0, store_1.addEntry)(cwd, entry({
|
|
120
|
+
signature: 'validator-scope-skills-only',
|
|
121
|
+
desc: 'validator scope covers skills only',
|
|
122
|
+
ref: 'scripts/validate-portability.mjs:41',
|
|
123
|
+
}));
|
|
124
|
+
(0, store_1.addEntry)(cwd, entry({
|
|
125
|
+
signature: 'gate-walks-skills-only',
|
|
126
|
+
desc: 'the gate walks skills and nothing else',
|
|
127
|
+
ref: 'scripts/validate-portability.mjs:58',
|
|
128
|
+
}));
|
|
129
|
+
const clusters = (0, store_1.recurring)(cwd, 'feat-x', 2);
|
|
130
|
+
expect(clusters).toHaveLength(1);
|
|
131
|
+
expect(clusters[0]).toMatchObject({ count: 2, kind: 'convergent' });
|
|
132
|
+
expect(clusters[0].signatures).toEqual(['gate-walks-skills-only', 'validator-scope-skills-only']);
|
|
133
|
+
});
|
|
134
|
+
test('recurring does not crash on a shape-invalid entry mixed into an otherwise valid ledger', () => {
|
|
135
|
+
(0, store_1.addEntry)(cwd, entry({ signature: 'dup', desc: 'alpha slug mismatch', ref: 'src/a.ts:1' }));
|
|
136
|
+
(0, store_1.addEntry)(cwd, entry({ signature: 'dup', desc: 'alpha slug mismatch', ref: 'src/a.ts:1' }));
|
|
137
|
+
const p = (0, store_1.ledgerPath)(cwd, 'feat-x');
|
|
138
|
+
fs_1.default.appendFileSync(p, JSON.stringify({ ts: 't', branch: 'feat-x', phase: 'p', source_skill: 's', polarity: 'finding', class: 'logica', signature: 'no-desc', severity: 'minor', ref: 'b.ts:1' }) + '\n');
|
|
139
|
+
expect(() => (0, store_1.recurring)(cwd, 'feat-x', 2)).not.toThrow();
|
|
140
|
+
expect((0, store_1.recurring)(cwd, 'feat-x', 2)).toEqual([expect.objectContaining({ signature: 'dup', count: 2 })]);
|
|
141
|
+
});
|
|
107
142
|
});
|
|
108
143
|
describe('ledger store — archive', () => {
|
|
109
144
|
let cwd;
|