@ericrisco/rsc 1.0.28 → 1.0.29

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.28",
2
+ "version": "1.0.29",
3
3
  "counts": {
4
4
  "skills": 276
5
5
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ericrisco/rsc",
3
- "version": "1.0.28",
3
+ "version": "1.0.29",
4
4
  "description": "Eric Risco's agent-skills catalog as a granular, self-recommending CLI installer.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -146,18 +146,50 @@ export function basesFor(docPath, { mode, root }) {
146
146
  return [dirname(docPath), root, join(root, '02-DOCS'), join(root, '02-DOCS', 'wiki'), join(root, 'skills')];
147
147
  }
148
148
 
149
- const resolves = (target, bases) => bases.some((b) => existsSync(resolve(b, target)));
149
+ // `existsSync` asks the filesystem, and on APFS/HFS+ (macOS) and NTFS (Windows) the filesystem
150
+ // ignores case. So a link to `AUDIT.md` resolves against a file named `audit.md` and this gate goes
151
+ // GREEN on the maintainer's machine while Linux CI — and every installed user — sees a broken link.
152
+ // That is P2 in its most expensive form: the gate fails open exactly where the work is done. It
153
+ // happened for real (the motion-craft port moved references and one link kept its old casing).
154
+ //
155
+ // So existence is checked one path segment at a time against the parent's actual directory listing,
156
+ // which is byte-exact on every filesystem. The walk starts at `root`, which is real by
157
+ // construction; the declared hole is a target that resolves OUTSIDE the repo, where there is no
158
+ // listing we can trust — the repo's own path may sit under a differently-cased volume or behind a
159
+ // symlink, and a false positive in a blocking gate costs more than a named gap.
160
+ // Spec: 02-DOCS/wiki/sdd/specs/drift-check-case.md
161
+ function existsExact(absTarget, root, cache) {
162
+ const rel = relative(root, absTarget);
163
+ // Outside the repo (or the root itself): keep the old behaviour, and say so rather than guess.
164
+ if (rel === '' || rel.startsWith('..') || rel.startsWith('/')) return existsSync(absTarget);
165
+
166
+ let dir = root;
167
+ for (const segment of rel.split('/')) {
168
+ let entries = cache.get(dir);
169
+ if (entries === undefined) {
170
+ // An unreadable directory is not evidence of drift; fall back rather than accuse.
171
+ try { entries = new Set(readdirSync(dir)); } catch { return existsSync(absTarget); }
172
+ cache.set(dir, entries);
173
+ }
174
+ if (!entries.has(segment)) return false;
175
+ dir = join(dir, segment);
176
+ }
177
+ return true;
178
+ }
179
+
180
+ const resolves = (target, bases, root, cache) =>
181
+ bases.some((b) => existsExact(resolve(b, target), root, cache));
150
182
 
151
183
  // Knowledge mode only. The authoring standards write per-skill paths generically — "every skill
152
184
  // carries `evals/cases.yaml`" — which is a claim about a shape shared by 258 directories, not a
153
185
  // location. Resolving it against any one skill is the honest reading; treating it as drift
154
186
  // produced five findings that were never wrong.
155
- function resolvesInAnySkill(target, root) {
187
+ function resolvesInAnySkill(target, root, cache) {
156
188
  if (target.startsWith('.') || target.startsWith('/')) return false;
157
189
  const skillsDir = join(root, 'skills');
158
190
  if (!existsSync(skillsDir)) return false;
159
191
  for (const e of readdirSync(skillsDir, { withFileTypes: true })) {
160
- if (e.isDirectory() && existsSync(join(skillsDir, e.name, target))) return true;
192
+ if (e.isDirectory() && existsExact(join(skillsDir, e.name, target), root, cache)) return true;
161
193
  }
162
194
  return false;
163
195
  }
@@ -186,6 +218,9 @@ export function scanTree(dir, { mode, root = REPO, label = null } = {}) {
186
218
  const findings = [];
187
219
  let claims = 0;
188
220
  let skipped = 0;
221
+ // Directory listings, memoized per scan rather than per module: 824 claims share a few hundred
222
+ // directories, and a cache that outlived the scan would answer from a stale listing.
223
+ const cache = new Map();
189
224
  for (const doc of markdownFiles(dir)) {
190
225
  const text = readFileSync(doc, 'utf8');
191
226
  const raw = mode === 'catalog'
@@ -199,8 +234,8 @@ export function scanTree(dir, { mode, root = REPO, label = null } = {}) {
199
234
  continue;
200
235
  }
201
236
  claims++;
202
- if (mode === 'knowledge' && resolvesInAnySkill(c.target, root)) continue;
203
- if (!resolves(c.target, bases)) {
237
+ if (mode === 'knowledge' && resolvesInAnySkill(c.target, root, cache)) continue;
238
+ if (!resolves(c.target, bases, root, cache)) {
204
239
  findings.push({
205
240
  doc: relative(root, doc),
206
241
  line: c.line,