@ericrisco/rsc 1.0.28 → 1.0.30
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 +1 -1
- package/package.json +1 -1
- package/scripts/drift-check.js +40 -5
- package/targets/danger-guard.mjs +54 -3
package/manifest.json
CHANGED
package/package.json
CHANGED
package/scripts/drift-check.js
CHANGED
|
@@ -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
|
-
|
|
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() &&
|
|
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,
|
package/targets/danger-guard.mjs
CHANGED
|
@@ -54,11 +54,62 @@ if (typeof cmd !== 'string' || !cmd) allow();
|
|
|
54
54
|
// High-signal, low-false-positive. Each rule: what it catches and the plain why.
|
|
55
55
|
const noWhere = (verbRe) => verbRe.test(cmd) && !/\bwhere\b/i.test(cmd);
|
|
56
56
|
|
|
57
|
+
// A flag is a whitespace-separated token that BEGINS with a dash. That sounds obvious, and the
|
|
58
|
+
// first version of this rule got it wrong in a way that cost real trust: it tested
|
|
59
|
+
// /-[a-z]*r[a-z]*\b/ and /-[a-z]*f[a-z]*\b/ against the ENTIRE command string, so any hyphenated
|
|
60
|
+
// path segment carrying an "r" and an "f" read as the flags -r and -f. The segment `-fixture` alone
|
|
61
|
+
// satisfies both (f-i-x-t-u-r-e), so `rm skills/zz-case-fixture/SKILL.md` and `rm my-draft.md` were
|
|
62
|
+
// DENIED with the reason "rm with -r and -f" — a claim that was false about the command in front of
|
|
63
|
+
// it. It also denied commands that merely QUOTED a dangerous one, which is the same mistake the
|
|
64
|
+
// gitmoji guard had to fix: blocking the conversation about a command, not the command.
|
|
65
|
+
//
|
|
66
|
+
// That is worse than a nuisance. The deny message ends by telling the user how to switch this guard
|
|
67
|
+
// OFF, so crying wolf on a single-file delete is an active instruction to disable the thing that
|
|
68
|
+
// stops a real recursive wipe. Friction has to stay proportional to risk or the protection gets
|
|
69
|
+
// turned off (P7), and a denial's stated reason has to be true (P6).
|
|
70
|
+
//
|
|
71
|
+
// Flags are also scoped to the segment that actually runs rm: `rm notes.md && ls -lrtF` carries an
|
|
72
|
+
// r and an F, but they belong to ls.
|
|
73
|
+
function rmFlagsIn(segment) {
|
|
74
|
+
return segment.split(/\s+/).filter((t) => /^--?[A-Za-z]/.test(t));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Wrappers that stand in front of the real command without being it. A shell with -c is included
|
|
78
|
+
// deliberately: `bash -c "rm -rf /"` DOES run the delete, and dropping it would have been the hole
|
|
79
|
+
// this rewrite opened while fixing the over-fire.
|
|
80
|
+
const WRAPPERS = /^(sudo|doas|env|command|time|nohup|xargs)$/i;
|
|
81
|
+
const SHELLS = /^(bash|sh|zsh|dash|ksh)$/i;
|
|
82
|
+
|
|
83
|
+
// `rm` has to be the command being EXECUTED, not a word inside someone else's argument. Without
|
|
84
|
+
// this, `echo "never run rm -rf /"` was denied — the guard blocking the conversation about a
|
|
85
|
+
// command instead of the command, exactly the defect the gitmoji guard had to fix with its own
|
|
86
|
+
// RUNS_GIT check. A guard that argues with you about a sentence is a guard you turn off.
|
|
87
|
+
function runsRm(segment) {
|
|
88
|
+
const tokens = segment.trim().split(/\s+/).filter(Boolean);
|
|
89
|
+
let i = 0;
|
|
90
|
+
while (i < tokens.length) {
|
|
91
|
+
const bare = tokens[i].replace(/^['"]/, '');
|
|
92
|
+
if (WRAPPERS.test(bare)) { i += 1; continue; }
|
|
93
|
+
if (SHELLS.test(bare)) {
|
|
94
|
+
i += 1;
|
|
95
|
+
while (i < tokens.length && /^-/.test(tokens[i])) i += 1; // skip -c and friends
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
return bare === 'rm' || bare.endsWith('/rm');
|
|
99
|
+
}
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
|
|
57
103
|
function isRmRecursiveForce() {
|
|
58
104
|
if (!/\brm\b/.test(cmd)) return false;
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
105
|
+
for (const segment of cmd.split(/\|\||&&|[|;&]/)) {
|
|
106
|
+
if (!runsRm(segment)) continue;
|
|
107
|
+
const flags = rmFlagsIn(segment);
|
|
108
|
+
const hasR = flags.some((f) => /^--recursive$/i.test(f) || /^-[A-Za-z]*r/i.test(f));
|
|
109
|
+
const hasF = flags.some((f) => /^--force$/i.test(f) || /^-[A-Za-z]*f/i.test(f));
|
|
110
|
+
if (hasR && hasF) return true; // -rf / -fr / -Rf / -r -f / --recursive --force
|
|
111
|
+
}
|
|
112
|
+
return false;
|
|
62
113
|
}
|
|
63
114
|
|
|
64
115
|
const RULES = [
|