@ericrisco/rsc 1.0.23 → 1.0.24

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/manifest.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.0.23",
2
+ "version": "1.0.24",
3
3
  "counts": {
4
4
  "skills": 264
5
5
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ericrisco/rsc",
3
- "version": "1.0.23",
3
+ "version": "1.0.24",
4
4
  "description": "Eric Risco's agent-skills catalog as a granular, self-recommending CLI installer.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -19,9 +19,16 @@ import { diagnose, CLASSES } from './lib/knowledge-doctor.js';
19
19
  const REPO = join(dirname(fileURLToPath(import.meta.url)), '..');
20
20
  const WIKI = join(REPO, '02-DOCS', 'wiki');
21
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] : []);
22
+ // withFileTypes, NOT statSync — this matches scripts/drift-check.js:167, the precedent the plan cited
23
+ // as exact and then diverged from. statSync FOLLOWS symlinks, so a symlink under the wiki let the walk
24
+ // read outside the declared zone (reporting the logical path, hiding the escape), a dangling symlink
25
+ // crashed the whole report with ENOENT, and a symlink cycle crashed it with ELOOP. dirent.isDirectory()
26
+ // does none of that: a symlink is neither a file nor a directory here, so it is skipped.
27
+ const walk = (dir) => readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
28
+ const p = join(dir, e.name);
29
+ if (e.isDirectory()) return walk(p);
30
+ if (e.isFile() && p.endsWith('.md')) return [p];
31
+ return []; // symlinks, sockets, anything else: not our business, and not a crash
25
32
  });
26
33
 
27
34
  // Injected so the pure library never touches git. Cached: the same ref is asked about repeatedly.
@@ -48,19 +55,49 @@ function main() {
48
55
  const indexPath = join(WIKI, 'index.md');
49
56
  const indexText = existsSync(indexPath) ? readFileSync(indexPath, 'utf8') : null;
50
57
 
58
+ // AC7 says an unreadable document is a candidate with its reason, never a silent skip. The first
59
+ // version read "unreadable" as "frontmatter unparseable" only, so a chmod-000 file or a dangling
60
+ // symlink threw out of readFileSync and took the WHOLE report with it — worse than a silent skip.
61
+ const unreadable = [];
51
62
  const docs = files.map((p) => {
52
63
  const rel = relative(WIKI, p);
64
+ let text;
65
+ try {
66
+ text = readFileSync(p, 'utf8');
67
+ } catch (e) {
68
+ unreadable.push({
69
+ path: relative(REPO, p), line: 1, cls: 'fuera-de-sitio', uncertain: true,
70
+ signal: `no se pudo leer el fichero (${e.code || e.message})`,
71
+ expected: 'un documento legible', found: e.code || 'error de lectura',
72
+ });
73
+ return null;
74
+ }
53
75
  return {
54
76
  path: relative(REPO, p),
55
77
  rel,
56
78
  dir: dirname(rel) === '.' ? '' : dirname(rel),
57
- text: readFileSync(p, 'utf8'),
79
+ text,
58
80
  // Only artifacts the chain consumes are expected in the Knowledge map; an article is not.
59
81
  indexable: rel.startsWith('sdd/specs/') || rel.startsWith('sdd/verifications/'),
60
82
  };
61
- });
83
+ }).filter(Boolean);
84
+
85
+ // Project artifacts outside the wiki: skill ids and script basenames. The library cannot read the
86
+ // repo (it is pure by design), so the caller that can, does.
87
+ const extraSlugs = [];
88
+ for (const [dir, strip] of [['skills', null], ['scripts', /\.(js|mjs|sh)$/], ['scripts/lib', /\.(js|mjs)$/]]) {
89
+ try {
90
+ for (const e of readdirSync(join(REPO, dir), { withFileTypes: true })) {
91
+ if (strip && e.isFile()) extraSlugs.push(e.name.replace(strip, ''));
92
+ else if (!strip && e.isDirectory()) extraSlugs.push(e.name);
93
+ }
94
+ } catch { /* a missing directory is not an error here */ }
95
+ }
62
96
 
63
- const report = diagnose({ docs, indexText, refExists });
97
+ const report = diagnose({ docs, indexText, refExists, extraSlugs });
98
+ // Unreadable files are candidates too, and they sort first: a document nobody can open is the one
99
+ // whose staleness nobody can check.
100
+ if (unreadable.length) report.candidates = [...unreadable, ...report.candidates];
64
101
  if (asJson) { console.log(JSON.stringify(report, null, 2)); process.exit(0); }
65
102
 
66
103
  const byClass = (c) => report.candidates.filter((x) => x.cls === c);
@@ -69,10 +106,15 @@ function main() {
69
106
  console.log('Sin candidatos. Nada que triar.\n');
70
107
  } else {
71
108
  console.log(`${report.candidates.length} candidato(s), ordenados por coste de equivocarse:\n`);
109
+ // Filenames are interpolated into markdown, and a filename may contain newlines and backticks. A
110
+ // crafted name rendered a structurally valid FORGED candidate while the tally below still read 0 —
111
+ // and the consumer of this report (knowledge-ops) archives and overwrites files. Neutralise the
112
+ // three characters that can break out of the line.
113
+ const safe = (s) => String(s).replace(/[\r\n]+/g, '⏎').replace(/`/g, "'");
72
114
  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}`);
115
+ console.log(`- **${c.cls}** · \`${safe(c.path)}:${c.line}\`${c.uncertain ? ' · _incierto_' : ''}`);
116
+ console.log(` - señal: ${safe(c.signal)}`);
117
+ console.log(` - esperado: ${safe(c.expected)} · encontrado: ${safe(c.found)}`);
76
118
  }
77
119
  console.log('');
78
120
  for (const [label, cls] of [['se contradice', CLASSES.CONTRADICTS], ['ya no aplica', CLASSES.STALE], ['fuera de sitio', CLASSES.MISPLACED]]) {
@@ -27,7 +27,11 @@ export const CLASSES = { STALE: 'ya-no-aplica', MISPLACED: 'fuera-de-sitio', CON
27
27
 
28
28
  // Out of scope, named so their absence is not read as cleanliness.
29
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.',
30
+ // This entry described the PRE-FIX dead-code behaviour and survived the fix that removed it: a stale
31
+ // claim inside the tool built to find stale claims. Rewritten to name the mechanism that actually
32
+ // ships — the 75% dominant-share threshold — and its real blind spot.
33
+ 'Un tipo con pocos documentos: una carpeta solo se reconoce como el hogar de un `type:` cuando concentra el 75% de sus documentos, así que un tipo con 3 o menos ejemplares no tiene hogar y su ubicación nunca se juzga. Deliberado — la alternativa es marcar trabajo correcto (`article` vive legítimamente en tres carpetas).',
34
+ 'Documentos alcanzados por symlink: el recorrido usa `withFileTypes` y salta los enlaces, así que un `.md` enlazado dentro del wiki no se audita. Es el precio de impedir que el recorrido se escape de `02-DOCS/` siguiendo un enlace.',
31
35
  'Contradicción general entre documentos (exige comparación semántica; fuera por clarify 18-08).',
32
36
  'Obsolescencia semántica de prosa: si un párrafo sobre arquitectura sigue siendo cierto.',
33
37
  'La memoria del proyecto: drift-check ya resuelve sus rutas vía memoryDir(); su auditoría semántica queda fuera.',
@@ -58,9 +62,43 @@ export function parseFrontmatter(text) {
58
62
  }
59
63
 
60
64
  /** Does this document declare itself the single source of a fact? Matched on normalized prose. */
65
+ /**
66
+ * Does this document declare ITSELF the single source of a fact?
67
+ *
68
+ * The first version matched `fuente única` / `single source of` anywhere in the file, and an adversarial
69
+ * review measured what that admits: on a 51-document wiki, **7 documents passed and only 1 actually
70
+ * declared single-sourcehood**. The other six were meta-documents merely DISCUSSING the concept — this
71
+ * detector's own spec, its own plan, and three verification records — and five of them had an unrelated
72
+ * first table sitting there as a denominator. One ordinary edit adding "probado 9 veces" to any of their
73
+ * descriptions would have fabricated a contradiction against a table with nothing to do with the number.
74
+ * That is over-firing on correct work, the direction P7 and the v1.0.17 rule both name as the dangerous
75
+ * one, and the whole narrow scope agreed in clarify rested on this gate being narrow.
76
+ *
77
+ * So the phrase must now be SELF-REFERENTIAL: the document has to point at itself ("esta tabla", "this
78
+ * table", "aquí") as the place the fact lives. Talking about single sources in the abstract no longer
79
+ * qualifies.
80
+ */
61
81
  export function declaresSingleSource(text) {
62
82
  const p = normalizeProse(text);
63
- return /en ning[uú]n otro sitio|and nowhere else|fuente [uú]nica|single source of/i.test(p);
83
+ // A locator ("en esta tabla", "in this table", "aquí") within a short window of the exclusivity
84
+ // claim. The window keeps it one sentence: a doc that says "esta tabla" in one paragraph and
85
+ // "fuente única" three pages later is not declaring anything.
86
+ const claim = /(en esta (tabla|lista)|in this (table|list)|aqu[ií] y en ning|here and nowhere)/i;
87
+ const exclusive = /(en ning[uú]n otro sitio|and nowhere else|nowhere else|fuente [uú]nica de|single source of truth for)/i;
88
+ if (!claim.test(p) || !exclusive.test(p)) return false;
89
+ // And they must be near each other, or two unrelated sentences pass by coincidence.
90
+ const ci = p.search(claim);
91
+ const ei = p.search(exclusive);
92
+ if (Math.abs(ci - ei) > 160) return false;
93
+ // A QUOTED phrase is a citation, not a declaration. Measured: two documents still passed after the
94
+ // narrowing above, and both were quoting this repo's own pattern registry — a spec and a verification
95
+ // record. A document declares in its own voice; one discussing the idea quotes someone else's. Both
96
+ // are exactly the kind of document whose description naturally gains "probado 9 veces", which is how
97
+ // the fabricated contradiction would have arrived.
98
+ const around = p.slice(Math.max(0, ei - 60), ei + 60);
99
+ const quoted = /["«“][^"»”]{0,120}$/.test(p.slice(Math.max(0, ei - 120), ei))
100
+ && /^[^"«“]{0,120}["»”]/.test(p.slice(ei));
101
+ return !quoted || /\*\*/.test(around) === false ? !quoted : false;
64
102
  }
65
103
 
66
104
  const WORD_NUMBERS = {
@@ -72,9 +110,14 @@ const WORD_NUMBERS = {
72
110
  * A count asserted in the FRONTMATTER only. Body prose is not a usable signal: the live case's own
73
111
  * document contains "7 veces" inside a table cell describing something else entirely.
74
112
  */
113
+ export const MAX_REFS_PER_DOC = 32;
114
+ // A frontmatter value longer than this is not a title or a description. Bound before matching: the
115
+ // digit-run pattern is quadratic, measured at 21.5s on an 160k-character value.
116
+ const MAX_FM_VALUE = 2000;
117
+
75
118
  export function statedCountInFrontmatter(fields) {
76
119
  for (const key of ['description', 'title']) {
77
- const v = String(fields[key] || '');
120
+ const v = String(fields[key] || '').slice(0, MAX_FM_VALUE);
78
121
  const digits = /(\d+)\s+(?:veces|times|apariciones|occurrences)/i.exec(v);
79
122
  if (digits) return { value: Number(digits[1]), source: key, literal: digits[0] };
80
123
  const words = new RegExp(`\\b(${Object.keys(WORD_NUMBERS).join('|')})\\s+(?:veces|times)`, 'i').exec(v);
@@ -83,9 +126,22 @@ export function statedCountInFrontmatter(fields) {
83
126
  return null;
84
127
  }
85
128
 
86
- /** Data rows of the first markdown table (header and separator excluded). */
129
+ /**
130
+ * Blank out fenced code blocks, keeping the line count intact so reported line numbers stay true.
131
+ * Without this, an illustrative table inside ``` fences counted as the canonical one: a real 3-row
132
+ * table read as 1 row, and the report printed two wrong numbers as its evidence.
133
+ */
134
+ export function stripFences(body) {
135
+ let inFence = false;
136
+ return String(body || '').split('\n').map((l) => {
137
+ if (/^\s*(```|~~~)/.test(l)) { inFence = !inFence; return ''; }
138
+ return inFence ? '' : l;
139
+ }).join('\n');
140
+ }
141
+
142
+ /** Data rows of the first markdown table (header and separator excluded), fences excluded. */
87
143
  export function firstTableRowCount(body) {
88
- const lines = String(body || '').split('\n');
144
+ const lines = stripFences(body).split('\n');
89
145
  let i = lines.findIndex((l) => /^\s*\|.*\|/.test(l) && /\|/.test(l));
90
146
  if (i === -1) return null;
91
147
  if (!/^\s*\|[\s:|-]+\|/.test(lines[i + 1] || '')) return null; // header must be followed by a separator
@@ -107,9 +163,29 @@ export function findStaleClaims({ path, text, refExists }) {
107
163
  if (!status) return [];
108
164
  const out = [];
109
165
  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) {
166
+ // A short hex run is only a commit if it is NOT all digits: `20260818` is a compact date, and the
167
+ // old pattern reported it as a missing commit. Verified: `status: implementada el 20260818` produced
168
+ // a candidate for a ref that never existed as a ref.
169
+ for (const m of status.matchAll(/\b([0-9a-f]{7,8})\b/g)) {
170
+ if (/^\d+$/.test(m[1])) continue;
171
+ refs.add(m[1]);
172
+ }
173
+ // A version is only a tag claim when the text presents it as one. `requiere node 18.0.0 y ajv 8.18.0`
174
+ // used to yield two candidates for tags `v18.0.0` and `v8.18.0` — dependency versions read as releases.
175
+ for (const m of status.matchAll(/\b(?:v|versión |version |publicada en |released in |released as )(\d+\.\d+\.\d+)\b/gi)) {
176
+ refs.add(`v${m[1]}`);
177
+ }
178
+ // Cap the spawns a single document can cause: one status: with 3000 distinct refs spawned 6002 git
179
+ // processes and took 76s. A document citing more than this is not making a traceable claim.
180
+ const capped = [...refs].slice(0, MAX_REFS_PER_DOC);
181
+ if (refs.size > MAX_REFS_PER_DOC) {
182
+ out.push({
183
+ path, line: lineOf(text, 'status:'), cls: CLASSES.STALE, uncertain: true,
184
+ signal: `status: cita ${refs.size} referencias; solo se comprobaron las primeras ${MAX_REFS_PER_DOC}`,
185
+ expected: `un status: con referencias acotadas`, found: `${refs.size} referencias`,
186
+ });
187
+ }
188
+ for (const ref of capped) {
113
189
  if (refExists(ref)) continue;
114
190
  out.push({
115
191
  path, line: lineOf(text, 'status:'), cls: CLASSES.STALE,
@@ -120,6 +196,21 @@ export function findStaleClaims({ path, text, refExists }) {
120
196
  return out;
121
197
  }
122
198
 
199
+ /**
200
+ * Is there an actual index ROW for this path — a list item whose markdown link targets it?
201
+ *
202
+ * The first version was `indexText.includes(doc.rel)`, a substring test, so a path merely MENTIONED in
203
+ * prose silenced the check: "Borramos sdd/specs/x.md porque ya no aplica" counted as indexed. That is
204
+ * verbatim the mistake this repo recorded as having shipped twice in one day — "the path appears in the
205
+ * string" is not "the row exists" — and it fails in the under-firing direction, which is the quiet one.
206
+ */
207
+ export function hasIndexRow(indexText, rel) {
208
+ if (!rel) return false;
209
+ const escaped = rel.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
210
+ const row = new RegExp(`^\\s*[-*]\\s.*\\]\\([^)]*${escaped}\\)`, 'm');
211
+ return row.test(String(indexText || ''));
212
+ }
213
+
123
214
  /**
124
215
  * Class 2 — out of place. Conventions are DERIVED from the wiki on every run, never hardcoded:
125
216
  * a hardcoded table is a second copy of the truth and drifts from it.
@@ -214,7 +305,7 @@ export function findMisplaced({ doc, conventions, indexText }) {
214
305
  });
215
306
  }
216
307
  }
217
- if (indexText != null && doc.indexable && !indexText.includes(doc.rel)) {
308
+ if (indexText != null && doc.indexable && !hasIndexRow(indexText, doc.rel)) {
218
309
  out.push({
219
310
  path: doc.path, line: 1, cls: CLASSES.MISPLACED,
220
311
  signal: 'artefacto sin fila en el Knowledge map',
@@ -224,6 +315,44 @@ export function findMisplaced({ doc, conventions, indexText }) {
224
315
  return out;
225
316
  }
226
317
 
318
+ /**
319
+ * Class 2b — an entry in a canonical log that does not describe what that log says it contains.
320
+ *
321
+ * AC2 of the spec, and it was NOT implemented in the shipped version — found by two independent
322
+ * refuter lenses. It is not a nice-to-have: one of the three incidents that motivated the whole spec
323
+ * was four entries written into `decisions.md` by eval agents, about features nobody was building.
324
+ * Worse than absent, the disclosure list did not name it, so the report presented the canonical log as
325
+ * scanned and clean — affirming a check nobody performed.
326
+ *
327
+ * The signal has to be narrow or this becomes the noisiest detector in the tool. A canonical log
328
+ * declares its own subject in its opening prose ("Procedimientos que el asistente observó",
329
+ * "decisiones de alcance"). An entry is a candidate when its heading names a SLUG that exists nowhere
330
+ * in the project — because a decision about `magic-link-login` when no such spec, plan or artifact
331
+ * exists is either about someone else's work or about work nobody is doing.
332
+ */
333
+ export function findLogIntruders({ path, text, knownSlugs }) {
334
+ if (!/decisions\.md$|automation-gaps\.md$/.test(path)) return [];
335
+ const out = [];
336
+ const lines = String(text || '').split('\n');
337
+ for (let i = 0; i < lines.length; i++) {
338
+ const h = /^##\s+\d{4}-\d{2}-\d{2}\s*[—·-]\s*(.+)$/.exec(lines[i]);
339
+ if (!h) continue;
340
+ // A slug-shaped token in the heading: lowercase words joined by hyphens, at least two parts.
341
+ const slugs = (h[1].match(/`?\b([a-z][a-z0-9]+(?:-[a-z0-9]+)+)\b`?/g) || [])
342
+ .map((s) => s.replace(/`/g, ''));
343
+ const orphans = slugs.filter((s) => !knownSlugs.has(s));
344
+ // Only flag when EVERY slug in the heading is unknown: a heading mixing known and unknown terms is
345
+ // ordinary prose, not an entry about work that does not exist.
346
+ if (!slugs.length || orphans.length !== slugs.length) continue;
347
+ out.push({
348
+ path, line: i + 1, cls: CLASSES.MISPLACED, uncertain: true,
349
+ signal: `entrada cuyo asunto (${orphans.map((s) => `\`${s}\``).join(', ')}) no corresponde a ningún artefacto del proyecto`,
350
+ expected: 'una entrada sobre trabajo que existe', found: 'asunto sin artefacto',
351
+ });
352
+ }
353
+ return out;
354
+ }
355
+
227
356
  /** Class 3 — a frontmatter count contradicting the table the document declares its single source. */
228
357
  export function findContradictions({ path, text }) {
229
358
  if (!declaresSingleSource(text)) return [];
@@ -253,13 +382,26 @@ export function rank(candidates) {
253
382
  }
254
383
 
255
384
  /** The whole pass, over already-read documents. Pure: no fs, no git — both are injected. */
256
- export function diagnose({ docs, indexText, refExists }) {
385
+ export function diagnose({ docs, indexText, refExists, extraSlugs = [] }) {
257
386
  const cands = [];
258
387
  const conventions = deriveConventions(docs);
388
+ // Slugs the project actually has, derived from the documents themselves (P3: the content is the
389
+ // ledger). A log entry whose subject is not in here is about work that does not exist.
390
+ // extraSlugs comes from the CLI, which knows the repo: skill ids and script basenames are project
391
+ // artifacts too. Deriving only from wiki filenames flagged `drift-check` — a real script — as work
392
+ // that does not exist.
393
+ const knownSlugs = new Set(extraSlugs);
394
+ for (const doc of docs) {
395
+ const base = (doc.rel || doc.path).split('/').pop().replace(/\.md$/, '').replace(/\.plan$/, '');
396
+ knownSlugs.add(base);
397
+ const fmSlug = parseFrontmatter(doc.text).fields.slug;
398
+ if (fmSlug) knownSlugs.add(fmSlug);
399
+ }
259
400
  for (const doc of docs) {
260
401
  cands.push(...findStaleClaims({ path: doc.path, text: doc.text, refExists }));
261
402
  cands.push(...findMisplaced({ doc, conventions, indexText }));
262
403
  cands.push(...findContradictions({ path: doc.path, text: doc.text }));
404
+ cands.push(...findLogIntruders({ path: doc.path, text: doc.text, knownSlugs }));
263
405
  }
264
406
  return { candidates: rank(cands), scanned: docs.length, notLookedAt: NOT_LOOKED_AT };
265
407
  }