@ericrisco/rsc 1.0.18 → 1.0.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/manifest.json +1 -1
- package/package.json +2 -1
- package/scripts/knowledge-doctor.js +89 -0
- package/scripts/lib/knowledge-doctor.js +265 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<div align="center">
|
|
2
2
|
|
|
3
|
-
<img src="https://raw.githubusercontent.com/ericrisco/rsc-harness/main/site/og.png" alt="rsc — 264
|
|
3
|
+
<img src="https://raw.githubusercontent.com/ericrisco/rsc-harness/main/site/og.png" alt="rsc — the plain-language wizard installing 5 of 264 skills into Claude Code and Codex CLI, leaving the other 259 out of your context window" width="960">
|
|
4
4
|
|
|
5
5
|
# `rsc` — 264 agent skills, one CLI, zero bloat
|
|
6
6
|
|
package/manifest.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ericrisco/rsc",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.20",
|
|
4
4
|
"description": "Eric Risco's agent-skills catalog as a granular, self-recommending CLI installer.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
"validate": "node scripts/build-manifest.js --validate",
|
|
17
17
|
"drift:check": "node scripts/drift-check.js",
|
|
18
18
|
"drift:knowledge": "node scripts/drift-check.js --knowledge",
|
|
19
|
+
"knowledge:doctor": "node scripts/knowledge-doctor.js",
|
|
19
20
|
"routing:audit": "node scripts/routing-audit.js",
|
|
20
21
|
"test": "node --test",
|
|
21
22
|
"coverage": "c8 --reporter=text node --test",
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// knowledge-doctor.js — report what the wiki claims that no longer holds.
|
|
3
|
+
//
|
|
4
|
+
// Usage: node scripts/knowledge-doctor.js [--json]
|
|
5
|
+
//
|
|
6
|
+
// Report-only, on demand. NOT in `rsc doctor`, NOT in any hook, NOT in the release gates — decided in
|
|
7
|
+
// clarify (2026-08-18): the precision a check needs scales with how intrusive it is, and an asked-for
|
|
8
|
+
// report tolerates noise an automatic one does not. Accepted cost, stated here so nobody has to
|
|
9
|
+
// rediscover it: drift is not detected on its own. Somebody has to run this.
|
|
10
|
+
//
|
|
11
|
+
// It NEVER writes. Not a file, not a mkdir. `02-DOCS/` is untracked (P9): there is no undo here.
|
|
12
|
+
|
|
13
|
+
import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
|
|
14
|
+
import { join, dirname, relative } from 'node:path';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
import { execFileSync } from 'node:child_process';
|
|
17
|
+
import { diagnose, CLASSES } from './lib/knowledge-doctor.js';
|
|
18
|
+
|
|
19
|
+
const REPO = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
20
|
+
const WIKI = join(REPO, '02-DOCS', 'wiki');
|
|
21
|
+
|
|
22
|
+
const walk = (dir) => readdirSync(dir).flatMap((e) => {
|
|
23
|
+
const p = join(dir, e);
|
|
24
|
+
return statSync(p).isDirectory() ? walk(p) : (p.endsWith('.md') ? [p] : []);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// Injected so the pure library never touches git. Cached: the same ref is asked about repeatedly.
|
|
28
|
+
const refCache = new Map();
|
|
29
|
+
const refExists = (ref) => {
|
|
30
|
+
if (refCache.has(ref)) return refCache.get(ref);
|
|
31
|
+
let ok = false;
|
|
32
|
+
for (const spec of [`${ref}^{commit}`, `refs/tags/${ref}`]) {
|
|
33
|
+
try { execFileSync('git', ['cat-file', '-e', spec], { cwd: REPO, stdio: 'ignore' }); ok = true; break; }
|
|
34
|
+
catch { /* not this kind of ref */ }
|
|
35
|
+
}
|
|
36
|
+
refCache.set(ref, ok);
|
|
37
|
+
return ok;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
function main() {
|
|
41
|
+
const asJson = process.argv.includes('--json');
|
|
42
|
+
if (!existsSync(WIKI)) {
|
|
43
|
+
// Not a failure: a repo without the harness has nothing to audit.
|
|
44
|
+
console.log('knowledge-doctor: no hay 02-DOCS/wiki/ — nada que auditar.');
|
|
45
|
+
process.exit(0);
|
|
46
|
+
}
|
|
47
|
+
const files = walk(WIKI);
|
|
48
|
+
const indexPath = join(WIKI, 'index.md');
|
|
49
|
+
const indexText = existsSync(indexPath) ? readFileSync(indexPath, 'utf8') : null;
|
|
50
|
+
|
|
51
|
+
const docs = files.map((p) => {
|
|
52
|
+
const rel = relative(WIKI, p);
|
|
53
|
+
return {
|
|
54
|
+
path: relative(REPO, p),
|
|
55
|
+
rel,
|
|
56
|
+
dir: dirname(rel) === '.' ? '' : dirname(rel),
|
|
57
|
+
text: readFileSync(p, 'utf8'),
|
|
58
|
+
// Only artifacts the chain consumes are expected in the Knowledge map; an article is not.
|
|
59
|
+
indexable: rel.startsWith('sdd/specs/') || rel.startsWith('sdd/verifications/'),
|
|
60
|
+
};
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const report = diagnose({ docs, indexText, refExists });
|
|
64
|
+
if (asJson) { console.log(JSON.stringify(report, null, 2)); process.exit(0); }
|
|
65
|
+
|
|
66
|
+
const byClass = (c) => report.candidates.filter((x) => x.cls === c);
|
|
67
|
+
console.log(`# knowledge-doctor — ${report.scanned} documentos\n`);
|
|
68
|
+
if (!report.candidates.length) {
|
|
69
|
+
console.log('Sin candidatos. Nada que triar.\n');
|
|
70
|
+
} else {
|
|
71
|
+
console.log(`${report.candidates.length} candidato(s), ordenados por coste de equivocarse:\n`);
|
|
72
|
+
for (const c of report.candidates) {
|
|
73
|
+
console.log(`- **${c.cls}** · \`${c.path}:${c.line}\`${c.uncertain ? ' · _incierto_' : ''}`);
|
|
74
|
+
console.log(` - señal: ${c.signal}`);
|
|
75
|
+
console.log(` - esperado: ${c.expected} · encontrado: ${c.found}`);
|
|
76
|
+
}
|
|
77
|
+
console.log('');
|
|
78
|
+
for (const [label, cls] of [['se contradice', CLASSES.CONTRADICTS], ['ya no aplica', CLASSES.STALE], ['fuera de sitio', CLASSES.MISPLACED]]) {
|
|
79
|
+
console.log(`- ${label}: ${byClass(cls).length}`);
|
|
80
|
+
}
|
|
81
|
+
console.log('');
|
|
82
|
+
}
|
|
83
|
+
console.log('## Lo que NO se miró\n');
|
|
84
|
+
for (const n of report.notLookedAt) console.log(`- ${n}`);
|
|
85
|
+
console.log('\nEl juicio es de `knowledge-ops` o tuyo: esto estrecha, no cura. No se ha escrito nada.');
|
|
86
|
+
process.exit(0);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
main();
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
// knowledge-doctor.js — does what the wiki CLAIMS still hold?
|
|
2
|
+
//
|
|
3
|
+
// drift-check answers one question: does the path a document names exist? It is a good mechanism and
|
|
4
|
+
// its scope ends there. It cannot see a claim that was true the day it was written and is not true
|
|
5
|
+
// now, and on 2026-08-18 three of those were found by hand in one session while drift-check passed
|
|
6
|
+
// green: a counter with three different values across three documents, four decisions-log entries
|
|
7
|
+
// written by eval agents about features nobody is building, and a spec asserting "the verify.sh of
|
|
8
|
+
// four stacks" when there are 189 and only 2 run coverage.
|
|
9
|
+
//
|
|
10
|
+
// A wiki with stale claims does not read badly. It reads with the same confidence and occasionally
|
|
11
|
+
// lies — and `02-DOCS/` is untracked (P9), so there is no git log to reconstruct when a sentence
|
|
12
|
+
// stopped being true.
|
|
13
|
+
//
|
|
14
|
+
// THIS DETECTS, IT DOES NOT CURE. `knowledge-ops` already owns the gardening (prune, archive never
|
|
15
|
+
// delete, merge duplicates, kill orphans) and knows HOW; what did not exist is anything telling it
|
|
16
|
+
// WHERE. Deterministic narrowing, judgment on the narrowed set (P1) — the split knowledge-drift used.
|
|
17
|
+
//
|
|
18
|
+
// Scope decided in clarify (2026-08-18): contradictions ONLY against a declared single source; the
|
|
19
|
+
// general cross-document case needs semantic comparison whose false-positive rate nobody measured,
|
|
20
|
+
// and a detector that cries wolf gets switched off (P7).
|
|
21
|
+
|
|
22
|
+
// Order = cost of being wrong. Artifacts another process consumes as INPUT come first; articles a
|
|
23
|
+
// human reads occasionally come last. A declared constant, not a heuristic.
|
|
24
|
+
export const SEVERITY_ORDER = ['sdd/specs', 'sdd/verifications', 'sdd', 'harness', 'brand', 'stack'];
|
|
25
|
+
|
|
26
|
+
export const CLASSES = { STALE: 'ya-no-aplica', MISPLACED: 'fuera-de-sitio', CONTRADICTS: 'se-contradice' };
|
|
27
|
+
|
|
28
|
+
// Out of scope, named so their absence is not read as cleanliness.
|
|
29
|
+
export const NOT_LOOKED_AT = [
|
|
30
|
+
'La PRIMERA instancia de una desviación de convención: las convenciones se derivan del wiki en cada corrida, así que un único fichero fuera de sitio define su propia ubicación como válida. Deliberado — hardcodear la tabla sería una segunda copia de la verdad que deriva de ella.',
|
|
31
|
+
'Contradicción general entre documentos (exige comparación semántica; fuera por clarify 18-08).',
|
|
32
|
+
'Obsolescencia semántica de prosa: si un párrafo sobre arquitectura sigue siendo cierto.',
|
|
33
|
+
'La memoria del proyecto: drift-check ya resuelve sus rutas vía memoryDir(); su auditoría semántica queda fuera.',
|
|
34
|
+
'Calidad de escritura, tono, o si un artículo merece existir.',
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Whitespace-collapsed text for prose matching. Not a nicety: the single-source declaration in this
|
|
39
|
+
* repo's own pattern registry is wrapped mid-phrase (`vive **en esta tabla y en\nningún otro
|
|
40
|
+
* sitio**`), so a line-based match loses it. Third time in one day that hard wrapping defeated a
|
|
41
|
+
* prose match — twice in tests before this.
|
|
42
|
+
*/
|
|
43
|
+
export const normalizeProse = (text) => String(text || '').replace(/\s+/g, ' ');
|
|
44
|
+
|
|
45
|
+
/** Split frontmatter from body. Returns {fields, body, ok, reason}. Unreadable => a candidate, never a skip. */
|
|
46
|
+
export function parseFrontmatter(text) {
|
|
47
|
+
const t = String(text || '');
|
|
48
|
+
if (!t.startsWith('---')) return { fields: {}, body: t, ok: false, reason: 'sin frontmatter YAML' };
|
|
49
|
+
const end = t.indexOf('\n---', 3);
|
|
50
|
+
if (end === -1) return { fields: {}, body: t, ok: false, reason: 'frontmatter sin cierre' };
|
|
51
|
+
const head = t.slice(4, end);
|
|
52
|
+
const fields = {};
|
|
53
|
+
for (const line of head.split('\n')) {
|
|
54
|
+
const m = /^([a-z_]+):\s*(.*)$/i.exec(line);
|
|
55
|
+
if (m) fields[m[1].toLowerCase()] = m[2].trim();
|
|
56
|
+
}
|
|
57
|
+
return { fields, body: t.slice(end + 4), ok: true, reason: null };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Does this document declare itself the single source of a fact? Matched on normalized prose. */
|
|
61
|
+
export function declaresSingleSource(text) {
|
|
62
|
+
const p = normalizeProse(text);
|
|
63
|
+
return /en ning[uú]n otro sitio|and nowhere else|fuente [uú]nica|single source of/i.test(p);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const WORD_NUMBERS = {
|
|
67
|
+
dos: 2, tres: 3, cuatro: 4, cinco: 5, seis: 6, siete: 7, ocho: 8, nueve: 9,
|
|
68
|
+
diez: 10, once: 11, doce: 12, trece: 13, catorce: 14, quince: 15,
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* A count asserted in the FRONTMATTER only. Body prose is not a usable signal: the live case's own
|
|
73
|
+
* document contains "7 veces" inside a table cell describing something else entirely.
|
|
74
|
+
*/
|
|
75
|
+
export function statedCountInFrontmatter(fields) {
|
|
76
|
+
for (const key of ['description', 'title']) {
|
|
77
|
+
const v = String(fields[key] || '');
|
|
78
|
+
const digits = /(\d+)\s+(?:veces|times|apariciones|occurrences)/i.exec(v);
|
|
79
|
+
if (digits) return { value: Number(digits[1]), source: key, literal: digits[0] };
|
|
80
|
+
const words = new RegExp(`\\b(${Object.keys(WORD_NUMBERS).join('|')})\\s+(?:veces|times)`, 'i').exec(v);
|
|
81
|
+
if (words) return { value: WORD_NUMBERS[words[1].toLowerCase()], source: key, literal: words[0] };
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Data rows of the first markdown table (header and separator excluded). */
|
|
87
|
+
export function firstTableRowCount(body) {
|
|
88
|
+
const lines = String(body || '').split('\n');
|
|
89
|
+
let i = lines.findIndex((l) => /^\s*\|.*\|/.test(l) && /\|/.test(l));
|
|
90
|
+
if (i === -1) return null;
|
|
91
|
+
if (!/^\s*\|[\s:|-]+\|/.test(lines[i + 1] || '')) return null; // header must be followed by a separator
|
|
92
|
+
let n = 0;
|
|
93
|
+
for (let j = i + 2; j < lines.length && /^\s*\|/.test(lines[j]); j++) n++;
|
|
94
|
+
return { rows: n, headerLine: i + 1 };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const lineOf = (text, needle) => {
|
|
98
|
+
const idx = String(text).indexOf(needle);
|
|
99
|
+
if (idx === -1) return 1;
|
|
100
|
+
return String(text).slice(0, idx).split('\n').length;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/** Class 1 — a status: citing a commit or tag that does not exist. refExists is injected. */
|
|
104
|
+
export function findStaleClaims({ path, text, refExists }) {
|
|
105
|
+
const { fields } = parseFrontmatter(text);
|
|
106
|
+
const status = String(fields.status || '');
|
|
107
|
+
if (!status) return [];
|
|
108
|
+
const out = [];
|
|
109
|
+
const refs = new Set();
|
|
110
|
+
for (const m of status.matchAll(/\b([0-9a-f]{7,8})\b/g)) refs.add(m[1]);
|
|
111
|
+
for (const m of status.matchAll(/\bv?(\d+\.\d+\.\d+)\b/g)) refs.add(`v${m[1]}`);
|
|
112
|
+
for (const ref of refs) {
|
|
113
|
+
if (refExists(ref)) continue;
|
|
114
|
+
out.push({
|
|
115
|
+
path, line: lineOf(text, 'status:'), cls: CLASSES.STALE,
|
|
116
|
+
signal: `status: cita \`${ref}\`, que no existe en el repo`,
|
|
117
|
+
expected: `un commit o tag \`${ref}\``, found: 'nada con ese nombre',
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Class 2 — out of place. Conventions are DERIVED from the wiki on every run, never hardcoded:
|
|
125
|
+
* a hardcoded table is a second copy of the truth and drifts from it.
|
|
126
|
+
*/
|
|
127
|
+
// A convention needs CORROBORATION, and this is the sharp edge of deriving one from the data.
|
|
128
|
+
//
|
|
129
|
+
// The first version of both rules below was dead code — a gate that could not fail. A misplaced file
|
|
130
|
+
// added its own directory to the set of valid homes for its type, and a file missing frontmatter
|
|
131
|
+
// counted inside its own denominator for "every sibling has one". In both cases the anomaly defined
|
|
132
|
+
// itself as normal, so neither check could ever fire. Caught by the tests, which is exactly the
|
|
133
|
+
// can-it-pass / can-it-fail symmetry from v1.0.17 doing its job on the way in rather than in
|
|
134
|
+
// production.
|
|
135
|
+
//
|
|
136
|
+
// The fix in both: a single instance never establishes a convention, and never excuses itself.
|
|
137
|
+
export const DOMINANT_HOME_SHARE = 0.75; // one directory holding this share of a type owns it
|
|
138
|
+
export const MIN_DOCS_FOR_TYPE_RULE = 3; // below this a directory is too small to have a convention
|
|
139
|
+
|
|
140
|
+
export function deriveConventions(docs) {
|
|
141
|
+
const typeCounts = new Map(); // type -> Map(dir -> n)
|
|
142
|
+
const dirsWithType = new Map();
|
|
143
|
+
for (const d of docs) {
|
|
144
|
+
const { fields, ok } = parseFrontmatter(d.text);
|
|
145
|
+
const has = ok && Boolean(fields.type);
|
|
146
|
+
const seen = dirsWithType.get(d.dir) || { withType: 0, total: 0 };
|
|
147
|
+
seen.total++; if (has) seen.withType++;
|
|
148
|
+
dirsWithType.set(d.dir, seen);
|
|
149
|
+
if (has) {
|
|
150
|
+
if (!typeCounts.has(fields.type)) typeCounts.set(fields.type, new Map());
|
|
151
|
+
const m = typeCounts.get(fields.type);
|
|
152
|
+
m.set(d.dir, (m.get(d.dir) || 0) + 1);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// A type is only "at home" somewhere if ONE directory holds a dominant share of it. `article` is
|
|
157
|
+
// spread across brand/harness/stack by design, so it has no dominant home and is never judged —
|
|
158
|
+
// which is the correct silence, not a gap.
|
|
159
|
+
const dominantHome = new Map();
|
|
160
|
+
for (const [type, dirs] of typeCounts) {
|
|
161
|
+
const total = [...dirs.values()].reduce((a, b) => a + b, 0);
|
|
162
|
+
const [bestDir, bestN] = [...dirs.entries()].sort((a, b) => b[1] - a[1])[0];
|
|
163
|
+
if (total >= 2 && bestN / total >= DOMINANT_HOME_SHARE) dominantHome.set(type, { dir: bestDir, share: bestN / total, total });
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Frontmatter is demanded where at most ONE document deviates — so the deviant does not veto the
|
|
167
|
+
// rule by existing, and a directory of mixed conventions still stays silent.
|
|
168
|
+
const typeRequiredDirs = new Set(
|
|
169
|
+
[...dirsWithType.entries()]
|
|
170
|
+
.filter(([, v]) => v.total >= MIN_DOCS_FOR_TYPE_RULE && v.withType >= v.total - 1)
|
|
171
|
+
.map(([k]) => k),
|
|
172
|
+
);
|
|
173
|
+
return { dominantHome, typeRequiredDirs };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function findMisplaced({ doc, conventions, indexText }) {
|
|
177
|
+
const out = [];
|
|
178
|
+
const { fields, ok, reason } = parseFrontmatter(doc.text);
|
|
179
|
+
if (!ok) {
|
|
180
|
+
// The SAME derived narrowing as the missing-`type:` branch below. The first real run flagged 8
|
|
181
|
+
// documents that legitimately have no frontmatter — the SDD phase standards, decisions.md,
|
|
182
|
+
// index.md — which is over-firing on correct work: 8 of 15 candidates were noise, and a report
|
|
183
|
+
// that cries wolf gets ignored (P7). A document without frontmatter is only a candidate in a
|
|
184
|
+
// directory where every other document has one. Applying the narrowing to one branch and not the
|
|
185
|
+
// other was the bug; the fix is symmetry, not an exclusion list.
|
|
186
|
+
if (conventions.typeRequiredDirs.has(doc.dir)) {
|
|
187
|
+
out.push({
|
|
188
|
+
path: doc.path, line: 1, cls: CLASSES.MISPLACED,
|
|
189
|
+
signal: `frontmatter ilegible (${reason}), y todos los demás documentos de \`${doc.dir}\` lo tienen`,
|
|
190
|
+
expected: 'frontmatter YAML con type:', found: reason,
|
|
191
|
+
uncertain: true,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
return out;
|
|
195
|
+
}
|
|
196
|
+
if (!fields.type) {
|
|
197
|
+
if (conventions.typeRequiredDirs.has(doc.dir)) {
|
|
198
|
+
out.push({
|
|
199
|
+
path: doc.path, line: 1, cls: CLASSES.MISPLACED,
|
|
200
|
+
signal: `sin \`type:\`, y todos los demás documentos de \`${doc.dir}\` lo tienen`,
|
|
201
|
+
expected: 'un type: no vacío', found: 'ausente',
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
} else {
|
|
205
|
+
const home = conventions.dominantHome.get(fields.type);
|
|
206
|
+
if (home && home.dir !== doc.dir) {
|
|
207
|
+
out.push({
|
|
208
|
+
path: doc.path, line: lineOf(doc.text, 'type:'), cls: CLASSES.MISPLACED,
|
|
209
|
+
// Deliberately phrased as an inconsistency: the detector cannot know which side should yield,
|
|
210
|
+
// and pretending otherwise would be inventing the answer.
|
|
211
|
+
signal: `\`type: ${fields.type}\` en \`${doc.dir}\`, pero ${Math.round(home.share * 100)}% de ese tipo (${home.total} docs) vive en \`${home.dir}\``,
|
|
212
|
+
expected: 'o el fichero en otra carpeta, o otro type:', found: 'inconsistencia — decide tú cuál cede',
|
|
213
|
+
uncertain: true,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
if (indexText != null && doc.indexable && !indexText.includes(doc.rel)) {
|
|
218
|
+
out.push({
|
|
219
|
+
path: doc.path, line: 1, cls: CLASSES.MISPLACED,
|
|
220
|
+
signal: 'artefacto sin fila en el Knowledge map',
|
|
221
|
+
expected: `una fila citando \`${doc.rel}\``, found: 'ninguna',
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
return out;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Class 3 — a frontmatter count contradicting the table the document declares its single source. */
|
|
228
|
+
export function findContradictions({ path, text }) {
|
|
229
|
+
if (!declaresSingleSource(text)) return [];
|
|
230
|
+
const { fields, body, ok } = parseFrontmatter(text);
|
|
231
|
+
if (!ok) return [];
|
|
232
|
+
const stated = statedCountInFrontmatter(fields);
|
|
233
|
+
if (!stated) return [];
|
|
234
|
+
const table = firstTableRowCount(body);
|
|
235
|
+
if (!table) return [];
|
|
236
|
+
if (stated.value === table.rows) return [];
|
|
237
|
+
return [{
|
|
238
|
+
path, line: lineOf(text, `${stated.source}:`), cls: CLASSES.CONTRADICTS,
|
|
239
|
+
signal: `el frontmatter dice "${stated.literal}" y el documento se declara fuente única, pero su primera tabla tiene ${table.rows} filas`,
|
|
240
|
+
expected: `${table.rows} (las filas de la tabla)`, found: `${stated.value} (en \`${stated.source}:\`)`,
|
|
241
|
+
}];
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Rank by cost of being wrong, then by class, then by path — stable and explainable. */
|
|
245
|
+
export function rank(candidates) {
|
|
246
|
+
const bucket = (p) => {
|
|
247
|
+
const i = SEVERITY_ORDER.findIndex((d) => p.includes(`/${d}/`) || p.endsWith(`/${d}`));
|
|
248
|
+
return i === -1 ? SEVERITY_ORDER.length : i;
|
|
249
|
+
};
|
|
250
|
+
const clsRank = (c) => [CLASSES.CONTRADICTS, CLASSES.STALE, CLASSES.MISPLACED].indexOf(c);
|
|
251
|
+
return [...candidates].sort((a, b) =>
|
|
252
|
+
bucket(a.path) - bucket(b.path) || clsRank(a.cls) - clsRank(b.cls) || a.path.localeCompare(b.path));
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** The whole pass, over already-read documents. Pure: no fs, no git — both are injected. */
|
|
256
|
+
export function diagnose({ docs, indexText, refExists }) {
|
|
257
|
+
const cands = [];
|
|
258
|
+
const conventions = deriveConventions(docs);
|
|
259
|
+
for (const doc of docs) {
|
|
260
|
+
cands.push(...findStaleClaims({ path: doc.path, text: doc.text, refExists }));
|
|
261
|
+
cands.push(...findMisplaced({ doc, conventions, indexText }));
|
|
262
|
+
cands.push(...findContradictions({ path: doc.path, text: doc.text }));
|
|
263
|
+
}
|
|
264
|
+
return { candidates: rank(cands), scanned: docs.length, notLookedAt: NOT_LOOKED_AT };
|
|
265
|
+
}
|