@ericrisco/rsc 1.1.3 → 1.1.4

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.1.3",
2
+ "version": "1.1.4",
3
3
  "counts": {
4
4
  "skills": 281
5
5
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ericrisco/rsc",
3
- "version": "1.1.3",
3
+ "version": "1.1.4",
4
4
  "description": "Eric Risco's agent-skills catalog as a granular, self-recommending CLI installer.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -34,6 +34,10 @@ const UNCHECKED = [
34
34
  'que ninguna sección contenga una suposición sin marcar (no es detectable por máquina; es el juicio que la fase existe para ejercer)',
35
35
  'que el contenido de cada sección sea correcto, y no sólo presente',
36
36
  'que los criterios de aceptación sean de verdad binarios',
37
+ // Added with the status check below, for the same reason the three above exist: a green that is
38
+ // narrower than the rule it appears to enforce is the decorative gate all over again.
39
+ 'si una spec dice "implementada" sin nombrar commit, PR ni versión (no hay nada que ir a mirar)',
40
+ 'si el trabajo de una spec que se declara sin aterrizar llegó por otra vía (git no lo distingue)',
37
41
  ];
38
42
 
39
43
  function fold(s) {
@@ -120,3 +124,97 @@ export function specCompleteness(markdown) {
120
124
  unchecked: UNCHECKED,
121
125
  };
122
126
  }
127
+
128
+ // ─────────────────────────────────────────────────────────────────────────────
129
+ // Does the status still match the repository?
130
+ //
131
+ // The section check above asks whether a spec is COMPLETE. This asks whether it is still TRUE — and
132
+ // nothing asked that before, which is how seven of twenty-five statuses came to contradict the repo:
133
+ // one claiming "sin push ni PR" over work that had been in main for weeks, four sitting in
134
+ // `awaiting-approval` over work already published. Spec: 02-DOCS/wiki/sdd/specs/spec-status-drift.md
135
+ //
136
+ // The vocabulary below was extracted from the 25 real statuses, not imagined. That matters: an
137
+ // invented recognizer reaches for the bare word `implementada`, and the bare word is not checkable —
138
+ // it names nothing to go and look for. Only a commit, a PR or a released version do.
139
+
140
+ /** The claim shapes this recognizer knows. Anything else is silence, not a finding. */
141
+ export const CLAIM_KINDS = ['sha', 'pr', 'version', 'not-landed'];
142
+
143
+ // Deliberately absent: the bare words `implementada`, `desplegada`, `descartada`, and the phrase
144
+ // `no aprobada`. The first two name nothing to check. The last two look like landing claims and are
145
+ // not — `descartada` is a spec closed on purpose after being measured, `no aprobada` describes HOW
146
+ // approval happened (in autopilot, not item by item), not whether code shipped. Treating either as a
147
+ // landing claim would invent drift in the two best-closed specs of the set.
148
+ const SHA = /\b[0-9a-f]{7,40}\b/g;
149
+ const PR = /\bPR\s*#(\d+)/gi;
150
+ const VERSION = /\bpublicada en (\d+\.\d+\.\d+)/gi;
151
+ const NOT_LANDED = /\bsin push\b|\bsin PR\b|\bawaiting-approval\b/i;
152
+
153
+ /**
154
+ * Pull the checkable claims out of a status string. Pure: no git, no I/O.
155
+ * @param {string} statusText
156
+ * @returns {Array<{kind: string, value: string, raw: string}>}
157
+ */
158
+ export function statusClaims(statusText) {
159
+ const text = typeof statusText === 'string' ? statusText : '';
160
+ const claims = [];
161
+ for (const m of text.matchAll(PR)) claims.push({ kind: 'pr', value: m[1], raw: m[0] });
162
+ for (const m of text.matchAll(VERSION)) claims.push({ kind: 'version', value: m[1], raw: m[0] });
163
+ for (const m of text.matchAll(SHA)) claims.push({ kind: 'sha', value: m[0], raw: m[0] });
164
+ const neg = NOT_LANDED.exec(text);
165
+ if (neg) claims.push({ kind: 'not-landed', value: neg[0], raw: neg[0] });
166
+ return claims;
167
+ }
168
+
169
+ /**
170
+ * Judge each claim against the repository.
171
+ *
172
+ * Four verdicts and none of them collapse into another. `unverifiable` is the one that keeps this
173
+ * honest — a claim we could not check is not a claim that passed, and it is not drift either.
174
+ *
175
+ * `not-landed` is always handed to a human, and that is a deliberate retreat from what the spec
176
+ * first promised. No query distinguishes "never landed" from "landed by another route": the case
177
+ * that started this work names a commit that genuinely is not in main, while its deliverable has
178
+ * been there for weeks. Checking the named commit would bless the misleading status; inferring the
179
+ * other from anything else would be a heuristic inside a gate. So the deterministic part narrows,
180
+ * and only what it narrowed goes to judgement — principle 1, and the shape `knowledge-drift` already
181
+ * uses.
182
+ *
183
+ * @param {Array<object>} claims from `statusClaims`
184
+ * @param {?{hasCommit: Function, isAncestor: Function, hasPr: Function, hasTag: Function}} probe
185
+ * null when there is no repository to ask — every claim then reports unverifiable.
186
+ * @returns {Array<{kind: string, value: string, verdict: string, reason: string}>}
187
+ */
188
+ export function checkClaims(claims, probe) {
189
+ return (claims || []).map((c) => {
190
+ if (c.kind === 'not-landed') {
191
+ return {
192
+ ...c,
193
+ verdict: 'needs-human',
194
+ reason: 'git cannot tell "never landed" from "landed by another route" — look at whether the deliverable is in main, not at the branch',
195
+ };
196
+ }
197
+ if (!probe) return { ...c, verdict: 'unverifiable', reason: 'no repository to ask' };
198
+ try {
199
+ if (c.kind === 'sha') {
200
+ if (!probe.hasCommit(c.value)) {
201
+ return { ...c, verdict: 'unverifiable', reason: `commit ${c.value} is not in this repository` };
202
+ }
203
+ return probe.isAncestor(c.value)
204
+ ? { ...c, verdict: 'holds', reason: `${c.value} is in main` }
205
+ : { ...c, verdict: 'stale', reason: `${c.value} is NOT in main` };
206
+ }
207
+ if (c.kind === 'pr') {
208
+ return probe.hasPr(c.value)
209
+ ? { ...c, verdict: 'holds', reason: `PR #${c.value} is merged into main` }
210
+ : { ...c, verdict: 'stale', reason: `no merge of PR #${c.value} in main` };
211
+ }
212
+ return probe.hasTag(c.value)
213
+ ? { ...c, verdict: 'holds', reason: `v${c.value} is tagged` }
214
+ : { ...c, verdict: 'stale', reason: `no v${c.value} tag in this repository` };
215
+ } catch (e) {
216
+ // Could not look. Saying `stale` here would invent a finding out of our own failure.
217
+ return { ...c, verdict: 'unverifiable', reason: e.message };
218
+ }
219
+ });
220
+ }
@@ -7,7 +7,8 @@
7
7
  import { readFileSync, readdirSync } from 'node:fs';
8
8
  import { join, dirname, basename } from 'node:path';
9
9
  import { fileURLToPath } from 'node:url';
10
- import { specCompleteness } from './lib/spec-gate.js';
10
+ import { execFileSync } from 'node:child_process';
11
+ import { specCompleteness, statusClaims, checkClaims } from './lib/spec-gate.js';
11
12
 
12
13
  const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
13
14
  const DEFAULT_DIR = join(ROOT, '02-DOCS', 'wiki', 'sdd', 'specs');
@@ -22,6 +23,35 @@ function defaultTargets() {
22
23
  }
23
24
  }
24
25
 
26
+ // The repository, asked instead of assumed. Returns null when there is nothing to ask — a
27
+ // report-only instrument that crashes where it cannot look is worse than one that says so.
28
+ function gitProbe(root) {
29
+ const git = (args) => execFileSync('git', args, { cwd: root, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
30
+ let main;
31
+ try {
32
+ git(['rev-parse', '--git-dir']);
33
+ // Prefer the remote tip — a stale local `main` reports merged work as missing. But CI checks out
34
+ // detached with no `origin/main` and no local `main`, and falling through to "no repository to
35
+ // ask" there means the check silently stops checking exactly where it runs unattended. HEAD is
36
+ // the honest last resort: on CI it IS the branch under test.
37
+ main = ['origin/main', 'main', 'HEAD'].find((ref) => {
38
+ try { git(['rev-parse', '--verify', ref]); return true; } catch { return false; }
39
+ });
40
+ if (!main) throw new Error('no ref to compare against');
41
+ } catch {
42
+ return null;
43
+ }
44
+ return {
45
+ hasCommit: (sha) => { try { git(['cat-file', '-e', `${sha}^{commit}`]); return true; } catch { return false; } },
46
+ isAncestor: (sha) => { try { git(['merge-base', '--is-ancestor', sha, main]); return true; } catch { return false; } },
47
+ // The squash-merge subject carries `(#N)`; --fixed-strings so the parens are not a regex.
48
+ hasPr: (n) => { try { return git(['log', main, '--grep', `(#${n})`, '--fixed-strings', '-1', '--format=%H']) !== ''; } catch { return false; } },
49
+ hasTag: (v) => { try { return git(['tag', '-l', `v${v}`]) !== ''; } catch { return false; } },
50
+ };
51
+ }
52
+
53
+ const statusOf = (text) => (/^status:[ \t]*(.*)$/m.exec(text) || [, ''])[1];
54
+
25
55
  function main() {
26
56
  const args = process.argv.slice(2);
27
57
  const targets = args.length ? args : defaultTargets();
@@ -31,8 +61,21 @@ function main() {
31
61
  }
32
62
 
33
63
  let failed = 0;
64
+ let drifted = 0;
65
+ // One probe per repository, not one for the tool's own. A spec's claims are about the history of
66
+ // the project the spec belongs to — checking them against wherever this script happens to live
67
+ // gives the right answer only by coincidence, and the wrong one the moment the gate is pointed at
68
+ // another checkout.
69
+ const probes = new Map();
70
+ const probeFor = (path) => {
71
+ const dir = dirname(path);
72
+ if (!probes.has(dir)) probes.set(dir, gitProbe(dir));
73
+ return probes.get(dir);
74
+ };
75
+ let anyProbe = false;
34
76
  for (const path of targets) {
35
- const r = specCompleteness(readFileSync(path, 'utf8'));
77
+ const text = readFileSync(path, 'utf8');
78
+ const r = specCompleteness(text);
36
79
  const name = basename(path);
37
80
  if (r.ok) {
38
81
  const typed = r.openPoints.length - r.untyped.length;
@@ -40,17 +83,27 @@ function main() {
40
83
  if (r.untyped.length) {
41
84
  console.log(` untyped (treated as open questions): ${r.untyped.length}`);
42
85
  }
43
- } else {
86
+ }
87
+ if (!r.ok) {
44
88
  failed += 1;
45
89
  console.log(`FAIL ${name}`);
46
90
  if (r.missing.length) console.log(` missing section(s): ${r.missing.join(', ')}`);
47
91
  if (r.empty.length) console.log(` section(s) with no content: ${r.empty.join(', ')}`);
48
92
  }
93
+ // A status that still matches the repository prints nothing: the report grows only where there
94
+ // is something to act on (P5).
95
+ const probe = probeFor(path);
96
+ anyProbe = anyProbe || Boolean(probe);
97
+ for (const v of checkClaims(statusClaims(statusOf(text)), probe)) {
98
+ if (v.verdict === 'holds') continue;
99
+ if (v.verdict === 'stale') drifted += 1;
100
+ console.log(` status ${v.verdict.toUpperCase()}: ${v.raw} — ${v.reason}`);
101
+ }
49
102
  }
50
103
 
51
104
  // A green here is narrower than the rule it enforces, so it says so every run rather than
52
105
  // letting the reader assume the gate covered more than it did.
53
- console.log(`\n${targets.length - failed}/${targets.length} pass. Not checked by this gate:`);
106
+ console.log(`\n${targets.length - failed}/${targets.length} pass, ${drifted} status claim(s) contradicted by the repo${anyProbe ? '' : ' (no repository to ask)'}. Not checked by this gate:`);
54
107
  for (const u of specCompleteness('').unchecked) console.log(` - ${u}`);
55
108
  if (failed) process.exit(1);
56
109
  }