etymd 0.17.0 → 0.19.1
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/CHANGELOG.md +68 -0
- package/README.md +10 -5
- package/dist/{approve-VN4EDBR5.js → approve-J6JU7MBX.js} +3 -3
- package/dist/audit-5N2TU2X7.js +11 -0
- package/dist/{brief-7MWP35UI.js → brief-NHHILXIH.js} +3 -3
- package/dist/{chunk-YQZDYDAK.js → chunk-2DRA7S67.js} +1 -1
- package/dist/{chunk-446EZNAQ.js → chunk-6DJMKZGK.js} +83 -10
- package/dist/chunk-7N5JDXQG.js +109 -0
- package/dist/{chunk-ANFNXFRI.js → chunk-GFUDKTC5.js} +2 -2
- package/dist/{chunk-BUMOGHQA.js → chunk-KQFEKOON.js} +1 -1
- package/dist/{chunk-LVR2DPR7.js → chunk-M7XDP6FT.js} +30 -3
- package/dist/{chunk-PIJZUDDQ.js → chunk-PQISPOB2.js} +191 -130
- package/dist/{chunk-WKP7M2B3.js → chunk-UMKC3OWQ.js} +11 -6
- package/dist/{chunk-FNA4R5KT.js → chunk-WKP72Z3J.js} +59 -11
- package/dist/cli.js +16 -16
- package/dist/{doctor-R4QDCAN5.js → doctor-264Q5PLY.js} +6 -6
- package/dist/{fleet-WPYEVHVL.js → fleet-VT625Q2J.js} +22 -6
- package/dist/{gates-3HRGYCYY.js → gates-YUMSTKUG.js} +5 -4
- package/dist/{generate-FSJQFQPV.js → generate-62GQSCKH.js} +3 -2
- package/dist/index.d.ts +21 -3
- package/dist/index.js +379 -58
- package/dist/{init-6RULX5JZ.js → init-G4XXFXVD.js} +5 -4
- package/dist/{premise-GFWHSR3K.js → premise-RNMAKMVA.js} +9 -5
- package/dist/{propose-IYNOIWAO.js → propose-35JXMEPH.js} +6 -6
- package/dist/scan-F6OW4ANK.js +5 -0
- package/dist/{scan-LLWW4YOU.js → scan-PJH32H7C.js} +3 -3
- package/package.json +2 -2
- package/dist/audit-PHAK5HEW.js +0 -11
- package/dist/scan-5SC47FSM.js +0 -5
package/dist/index.js
CHANGED
|
@@ -20,7 +20,7 @@ var __export = (target, all) => {
|
|
|
20
20
|
var PACK_VERSION;
|
|
21
21
|
var init_version = __esm({
|
|
22
22
|
"src/pack/version.ts"() {
|
|
23
|
-
PACK_VERSION = "
|
|
23
|
+
PACK_VERSION = "15";
|
|
24
24
|
}
|
|
25
25
|
});
|
|
26
26
|
|
|
@@ -30,7 +30,7 @@ var init_package = __esm({
|
|
|
30
30
|
"package.json"() {
|
|
31
31
|
package_default = {
|
|
32
32
|
name: "etymd",
|
|
33
|
-
version: "0.
|
|
33
|
+
version: "0.19.1",
|
|
34
34
|
description: "Keep your agent instructions true \u2014 verify AGENTS.md, CLAUDE.md, rules, skills, and the task you hand an agent against the actual repo, with drift caught over time and a regression ledger.",
|
|
35
35
|
keywords: [
|
|
36
36
|
"cli",
|
|
@@ -104,7 +104,7 @@ var init_package = __esm({
|
|
|
104
104
|
prettier: "3.4.2",
|
|
105
105
|
tsup: "8.5.1",
|
|
106
106
|
typescript: "5.7.2",
|
|
107
|
-
vitest: "4.1.
|
|
107
|
+
vitest: "4.1.11"
|
|
108
108
|
},
|
|
109
109
|
publishConfig: {
|
|
110
110
|
access: "public"
|
|
@@ -388,10 +388,17 @@ async function hasLintStagedConfig(root, pkg) {
|
|
|
388
388
|
}
|
|
389
389
|
return false;
|
|
390
390
|
}
|
|
391
|
+
function normalizeHooksPath(root, raw) {
|
|
392
|
+
const abs = path2.resolve(root, raw);
|
|
393
|
+
const rel = path2.relative(root, abs);
|
|
394
|
+
const outside = path2.isAbsolute(rel) || rel === ".." || rel.startsWith(`..${path2.sep}`);
|
|
395
|
+
return outside ? abs : normalizeRelPath(rel) || ".";
|
|
396
|
+
}
|
|
391
397
|
async function detectHooks(root, hooksPath, pkg) {
|
|
392
398
|
const lintStaged = await hasLintStagedConfig(root, pkg);
|
|
393
399
|
if (hooksPath) {
|
|
394
|
-
|
|
400
|
+
const dir = normalizeHooksPath(root, hooksPath);
|
|
401
|
+
if (dir === ".husky/_") {
|
|
395
402
|
const base2 = path2.join(root, ".husky");
|
|
396
403
|
return {
|
|
397
404
|
source: "husky",
|
|
@@ -402,10 +409,10 @@ async function detectHooks(root, hooksPath, pkg) {
|
|
|
402
409
|
lintStaged
|
|
403
410
|
};
|
|
404
411
|
}
|
|
405
|
-
const base = path2.
|
|
412
|
+
const base = path2.resolve(root, dir);
|
|
406
413
|
return {
|
|
407
|
-
source:
|
|
408
|
-
dir
|
|
414
|
+
source: dir === ".githooks" ? "githooks" : "custom",
|
|
415
|
+
dir,
|
|
409
416
|
preCommit: await pathExists(path2.join(base, "pre-commit")),
|
|
410
417
|
prePush: await pathExists(path2.join(base, "pre-push")),
|
|
411
418
|
commitMsg: await pathExists(path2.join(base, "commit-msg")),
|
|
@@ -494,6 +501,64 @@ async function detectArtifacts(root) {
|
|
|
494
501
|
});
|
|
495
502
|
return artifacts;
|
|
496
503
|
}
|
|
504
|
+
function detectClaudeCodeVersion() {
|
|
505
|
+
const override = process.env.ETYMD_CLAUDE_VERSION;
|
|
506
|
+
if (override !== void 0) {
|
|
507
|
+
return Promise.resolve(override === "none" || override === "" ? null : override);
|
|
508
|
+
}
|
|
509
|
+
claudeVersionMemo ??= pExecFile2("claude", ["--version"], { timeout: 5e3 }).then(({ stdout }) => /(\d+\.\d+\.\d+)/.exec(stdout)?.[1] ?? null).catch(() => null);
|
|
510
|
+
return claudeVersionMemo;
|
|
511
|
+
}
|
|
512
|
+
function versionBefore(a, b) {
|
|
513
|
+
const pa = a.split(".").map(Number);
|
|
514
|
+
const pb = b.split(".").map(Number);
|
|
515
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
516
|
+
const x = pa[i] ?? 0;
|
|
517
|
+
const y = pb[i] ?? 0;
|
|
518
|
+
if (x !== y) return x < y;
|
|
519
|
+
}
|
|
520
|
+
return false;
|
|
521
|
+
}
|
|
522
|
+
async function checkClaudePointer(root, claudeVersion) {
|
|
523
|
+
const agentsAbs = path2.join(root, "AGENTS.md");
|
|
524
|
+
const claudeAbs = path2.join(root, "CLAUDE.md");
|
|
525
|
+
if (!await pathExists(agentsAbs)) return { ok: true, via: "no-agents" };
|
|
526
|
+
const statOrNone = async (p) => {
|
|
527
|
+
try {
|
|
528
|
+
return await promises.stat(p);
|
|
529
|
+
} catch {
|
|
530
|
+
return null;
|
|
531
|
+
}
|
|
532
|
+
};
|
|
533
|
+
const [agentsSt, claudeSt] = await Promise.all([statOrNone(agentsAbs), statOrNone(claudeAbs)]);
|
|
534
|
+
if (agentsSt && claudeSt && agentsSt.ino !== 0 && agentsSt.ino === claudeSt.ino && agentsSt.dev === claudeSt.dev) {
|
|
535
|
+
return { ok: true, via: "same-file" };
|
|
536
|
+
}
|
|
537
|
+
const rootClaude = await readText(claudeAbs);
|
|
538
|
+
if (rootClaude !== null && ROOT_POINTER_IMPORT_RE.test(rootClaude)) {
|
|
539
|
+
return { ok: true, via: "root-import" };
|
|
540
|
+
}
|
|
541
|
+
const dotClaude = await readText(path2.join(root, ".claude", "CLAUDE.md"));
|
|
542
|
+
if (dotClaude !== null && DOT_CLAUDE_IMPORT_RE.test(dotClaude)) {
|
|
543
|
+
return { ok: true, via: "dotclaude-import" };
|
|
544
|
+
}
|
|
545
|
+
if (rootClaude !== null || dotClaude !== null) {
|
|
546
|
+
return {
|
|
547
|
+
ok: false,
|
|
548
|
+
kind: "no-import",
|
|
549
|
+
detail: rootClaude !== null ? "AGENTS.md and CLAUDE.md both present, but CLAUDE.md does not import it" : "AGENTS.md and .claude/CLAUDE.md both present, but .claude/CLAUDE.md does not import it"
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
const version = await detectClaudeCodeVersion() ;
|
|
553
|
+
if (version !== null && versionBefore(version, CLAUDE_AGENTS_FALLBACK_VERSION)) {
|
|
554
|
+
return {
|
|
555
|
+
ok: false,
|
|
556
|
+
kind: "old-reader",
|
|
557
|
+
detail: `AGENTS.md present, no CLAUDE.md, and Claude Code ${version} predates the AGENTS.md fallback (${CLAUDE_AGENTS_FALLBACK_VERSION})`
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
return { ok: true, via: "native-fallback" };
|
|
561
|
+
}
|
|
497
562
|
async function walkTree(root) {
|
|
498
563
|
const state = { budget: 2e4, truncated: false };
|
|
499
564
|
const countFiles = async (dir) => {
|
|
@@ -539,7 +604,7 @@ async function walkTree(root) {
|
|
|
539
604
|
dirs.sort((a, b) => b.files - a.files);
|
|
540
605
|
return { dirs, truncated: state.truncated };
|
|
541
606
|
}
|
|
542
|
-
var IGNORED_DIRS, MEANINGFUL_DOT_DIRS, isMeta, ROLE_LADDERS, FRAMEWORK_MARKERS, ARTIFACT_SPECS, SHELL_SHEBANG, SHELL_EXT;
|
|
607
|
+
var IGNORED_DIRS, MEANINGFUL_DOT_DIRS, isMeta, ROLE_LADDERS, FRAMEWORK_MARKERS, ARTIFACT_SPECS, SHELL_SHEBANG, SHELL_EXT, ROOT_POINTER_IMPORT_RE, DOT_CLAUDE_IMPORT_RE, CLAUDE_AGENTS_FALLBACK_VERSION, pExecFile2, claudeVersionMemo;
|
|
543
608
|
var init_detect = __esm({
|
|
544
609
|
"src/core/detect.ts"() {
|
|
545
610
|
init_util();
|
|
@@ -714,6 +779,10 @@ var init_detect = __esm({
|
|
|
714
779
|
];
|
|
715
780
|
SHELL_SHEBANG = /^#!\s*\/\S*\b(?:ba|da|z)?sh\b|^#!\s*\/usr\/bin\/env\s+(?:ba|da|z)?sh\b/;
|
|
716
781
|
SHELL_EXT = /\.(?:sh|bash|zsh)$/;
|
|
782
|
+
ROOT_POINTER_IMPORT_RE = /^\s*@(\.\/)?AGENTS\.md\s*$/m;
|
|
783
|
+
DOT_CLAUDE_IMPORT_RE = /^\s*@\.\.\/AGENTS\.md\s*$/m;
|
|
784
|
+
CLAUDE_AGENTS_FALLBACK_VERSION = "2.1.277";
|
|
785
|
+
pExecFile2 = promisify(execFile);
|
|
717
786
|
}
|
|
718
787
|
});
|
|
719
788
|
|
|
@@ -795,11 +864,12 @@ async function scanProject(root, opts = {}) {
|
|
|
795
864
|
const [branch, head, hooksPathRaw, authorsRaw, subjectsRaw] = isRepo ? await Promise.all([
|
|
796
865
|
git(abs, ["rev-parse", "--abbrev-ref", "HEAD"]),
|
|
797
866
|
git(abs, ["rev-parse", "--short", "HEAD"]),
|
|
798
|
-
git
|
|
867
|
+
// `--type=path` expands a leading `~` the way git itself does when it runs the hooks.
|
|
868
|
+
git(abs, ["config", "--type=path", "--get", "core.hooksPath"]),
|
|
799
869
|
git(abs, ["log", "-200", "--format=%ae"]),
|
|
800
870
|
git(abs, ["log", "-50", "--format=%s"])
|
|
801
871
|
]) : [null, null, null, null, null];
|
|
802
|
-
const hooksPath = hooksPathRaw
|
|
872
|
+
const hooksPath = hooksPathRaw ? normalizeHooksPath(abs, hooksPathRaw) : void 0;
|
|
803
873
|
const hooks = await detectHooks(abs, hooksPath, rootPkg);
|
|
804
874
|
const shell = await detectShellSurface(abs, isRepo);
|
|
805
875
|
const freshness = await collectFreshness(abs, isRepo, artifacts, opts.upstreamRemote);
|
|
@@ -1132,6 +1202,19 @@ ${[
|
|
|
1132
1202
|
].filter(Boolean).join("\n") || "# add the project's key commands"}
|
|
1133
1203
|
\`\`\`
|
|
1134
1204
|
|
|
1205
|
+
`,
|
|
1206
|
+
"md"
|
|
1207
|
+
);
|
|
1208
|
+
}
|
|
1209
|
+
function generateClaudePointerMd() {
|
|
1210
|
+
return stampGenerated(
|
|
1211
|
+
`# CLAUDE.md
|
|
1212
|
+
|
|
1213
|
+
Single source of truth for agent instructions lives in \`AGENTS.md\`. This pointer keeps Claude
|
|
1214
|
+
Code aligned with every other agent \u2014 edit \`AGENTS.md\`, not this file.
|
|
1215
|
+
|
|
1216
|
+
@AGENTS.md
|
|
1217
|
+
|
|
1135
1218
|
`,
|
|
1136
1219
|
"md"
|
|
1137
1220
|
);
|
|
@@ -1154,15 +1237,21 @@ function contentScreenCall(opts) {
|
|
|
1154
1237
|
`${indent}fi`
|
|
1155
1238
|
].join("\n");
|
|
1156
1239
|
}
|
|
1157
|
-
function localHookCall(hook) {
|
|
1240
|
+
function localHookCall(hook, feedRefs = false) {
|
|
1241
|
+
const refsCapture = feedRefs ? `# git hands the pushed refs to pre-push ONCE, on stdin \u2014 one line per ref:
|
|
1242
|
+
# "<local ref> <local sha> <remote ref> <remote sha>". The shell gate below reads them too, so
|
|
1243
|
+
# they are captured here and fed to each.
|
|
1244
|
+
refs=$(cat)
|
|
1245
|
+
` : "";
|
|
1246
|
+
const call = feedRefs ? `printf '%s\\n' "$refs" | "$LOCAL" "$@" || exit 1` : `"$LOCAL" "$@" || exit 1`;
|
|
1158
1247
|
return `# Repo-owned checks. This file is generated and will be overwritten; \`.githooks/${hook}.local\`
|
|
1159
1248
|
# is yours \u2014 etymd never reads, writes, or regenerates it. Put project-specific guards there.
|
|
1160
1249
|
# A guard running tests that build fixture repositories should scrub git's exported GIT_* names
|
|
1161
1250
|
# first \u2014 a child git inherits them and ignores its cwd, so the suite would hit the real repo:
|
|
1162
1251
|
# env $(env | grep -o '^GIT_[A-Za-z0-9_]*' | sed 's/^/-u /') <your command>
|
|
1163
1252
|
LOCAL="$(dirname "$0")/${hook}.local"
|
|
1164
|
-
if [ -x "$LOCAL" ]; then
|
|
1165
|
-
|
|
1253
|
+
${refsCapture}if [ -x "$LOCAL" ]; then
|
|
1254
|
+
${call}
|
|
1166
1255
|
fi`;
|
|
1167
1256
|
}
|
|
1168
1257
|
function generatePreCommitHook(selfBuild = false) {
|
|
@@ -1171,10 +1260,10 @@ function generatePreCommitHook(selfBuild = false) {
|
|
|
1171
1260
|
|
|
1172
1261
|
${localHookCall("pre-commit")}
|
|
1173
1262
|
|
|
1174
|
-
# Content screen \u2014 staged file bytes. Refuses to commit environment,
|
|
1175
|
-
#
|
|
1176
|
-
# are machine-local by design, so this is a NO-OP wherever no checker is installed:
|
|
1177
|
-
# commit anywhere, active only where you opted in.
|
|
1263
|
+
# Content screen \u2014 staged file bytes. Refuses to commit detail about your environment, work
|
|
1264
|
+
# or identity into a repo whose history is (or could become) public. The checker and its
|
|
1265
|
+
# patterns are machine-local by design, so this is a NO-OP wherever no checker is installed:
|
|
1266
|
+
# safe to commit anywhere, active only where you opted in.
|
|
1178
1267
|
#
|
|
1179
1268
|
# Bypass, with a reason: git commit --no-verify
|
|
1180
1269
|
${contentGateResolution(selfBuild)}
|
|
@@ -1228,42 +1317,181 @@ ${localHookCall("commit-msg")}
|
|
|
1228
1317
|
exit 0
|
|
1229
1318
|
`);
|
|
1230
1319
|
}
|
|
1320
|
+
function generateShellDiscoveryScript() {
|
|
1321
|
+
return stampGenerated(`#!/usr/bin/env sh
|
|
1322
|
+
# etymd: shell script discovery for the pre-push shellcheck step. Arguments: the scratch
|
|
1323
|
+
# directory the hook created, then the tracked paths to classify (NUL-delimited on the hook's
|
|
1324
|
+
# side, positional here). Verdicts land in the scratch: scripts (NUL-delimited matches) and one
|
|
1325
|
+
# dot per decision into count / zsh-count / skip-count, tallied by the hook after the pipeline.
|
|
1326
|
+
#
|
|
1327
|
+
# The hook runs it from inside a commit materialised from git's object store, so paths resolve
|
|
1328
|
+
# against that tree, never the working tree. A path with nothing readable behind it \u2014 a
|
|
1329
|
+
# submodule entry, a dangling symlink \u2014 cannot lie about its contents, so it is a disclosed
|
|
1330
|
+
# skip, never a block. A regular file
|
|
1331
|
+
# that EXISTS but cannot be read is the other branch \u2014 coverage would silently shrink, so it
|
|
1332
|
+
# fails, naming the path.
|
|
1333
|
+
#
|
|
1334
|
+
# The two \`[ "$?" -eq 1 ]\` guards are the match/error protocol: grep reports "no match" as 1
|
|
1335
|
+
# and a failure as 2 or more, and only the first is a verdict. Dropping the guard would let a
|
|
1336
|
+
# failing matcher pass as "not a shell script" \u2014 the exact silent coverage-shrink the
|
|
1337
|
+
# fail-closed rules exist to prevent. The checker does not associate \`$?\` with the enclosing
|
|
1338
|
+
# if-condition, which is why this shape survives the pass it serves; keep it that way.
|
|
1339
|
+
work=$1
|
|
1340
|
+
shift
|
|
1341
|
+
for file do
|
|
1342
|
+
if [ ! -f "./$file" ]; then
|
|
1343
|
+
printf . >> "$work/skip-count" || exit 1
|
|
1344
|
+
continue
|
|
1345
|
+
fi
|
|
1346
|
+
# 4096 bytes bound the read \u2014 a binary with no newline would otherwise be copied whole
|
|
1347
|
+
# into the scratch on every push. The second head restores line-1-only semantics, so a
|
|
1348
|
+
# shebang embedded on a LATER line of a document cannot match the patterns below.
|
|
1349
|
+
head -c 4096 "./$file" > "$work/head-bytes" || {
|
|
1350
|
+
echo "etymd: cannot read tracked file for shellcheck: $file" >&2
|
|
1351
|
+
exit 1
|
|
1352
|
+
}
|
|
1353
|
+
head -n 1 "$work/head-bytes" > "$work/first-line" || exit 1
|
|
1354
|
+
if grep -qE "^#!.*[/ ](ba|da)?sh( |$)" "$work/first-line"; then
|
|
1355
|
+
printf "./%s\\0" "$file" >> "$work/scripts" || exit 1
|
|
1356
|
+
printf . >> "$work/count" || exit 1
|
|
1357
|
+
else
|
|
1358
|
+
[ "$?" -eq 1 ] || exit 1
|
|
1359
|
+
if grep -qE "^#!.*[/ ]zsh( |$)" "$work/first-line"; then
|
|
1360
|
+
printf . >> "$work/zsh-count" || exit 1
|
|
1361
|
+
else
|
|
1362
|
+
[ "$?" -eq 1 ] || exit 1
|
|
1363
|
+
fi
|
|
1364
|
+
fi
|
|
1365
|
+
done
|
|
1366
|
+
`);
|
|
1367
|
+
}
|
|
1231
1368
|
function shellcheckStep() {
|
|
1232
1369
|
return `
|
|
1233
1370
|
# Shell correctness. Scripts are discovered by shebang over TRACKED files at push time, so a
|
|
1234
|
-
# script added later is covered without regenerating this hook.
|
|
1371
|
+
# script added later is covered without regenerating this hook. The commits BEING PUSHED are
|
|
1372
|
+
# the bytes that ship, so each one is materialised from git's object store and checked there \u2014
|
|
1373
|
+
# never the working tree (wrong in both directions: a fixed tree let an unfixed commit ship, and
|
|
1374
|
+
# a dirty tree shared by several sessions blocked an unrelated push) and never the tip alone (a
|
|
1375
|
+
# bad commit under a clean tip shipped while the gate read only the tip's fix). The classifier
|
|
1376
|
+
# is discover-shell-scripts.sh beside this hook \u2014 tracked and shebanged like what it classifies,
|
|
1377
|
+
# so the scan it implements finds and checks it too. zsh is NOT in the checked set:
|
|
1235
1378
|
# the checker cannot parse it (SC1071 is a parser-level error no inline directive can silence),
|
|
1236
1379
|
# so checking it would fail every push on the parser, not on the script. Excluded \u2014 and said so
|
|
1237
1380
|
# at run time below, because a coverage hole that is silent is indistinguishable from coverage.
|
|
1381
|
+
# The same honesty splits the unreadable: a regular file that exists but cannot be read blocks
|
|
1382
|
+
# the push (coverage would otherwise silently shrink), while a tracked path with nothing readable
|
|
1383
|
+
# behind it \u2014 a submodule entry, a dangling symlink \u2014 is counted and said so below as skipped,
|
|
1384
|
+
# never a block.
|
|
1238
1385
|
#
|
|
1239
1386
|
# "the checker", not its name, on purpose: a comment whose first word is that name is read as
|
|
1240
1387
|
# a DIRECTIVE, and an unparseable directive is itself an error (SC1072/SC1073). A hook that
|
|
1241
1388
|
# explains why it skips a shell dialect must not break the checker while doing it.
|
|
1242
1389
|
if command -v shellcheck >/dev/null 2>&1; then
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1390
|
+
(
|
|
1391
|
+
# POSIX pipelines report only the LAST command's status, so discovery tallies progress in
|
|
1392
|
+
# files \u2014 one dot per decision, counted with wc -c after the pipeline \u2014 rather than
|
|
1393
|
+
# streaming through it: a pipeline that dies halfway cannot then pass as complete coverage,
|
|
1394
|
+
# and the same tallies carry the counts to the reporting below. The classifier writes the
|
|
1395
|
+
# tallies; this half only reads them.
|
|
1396
|
+
# NUL delimiters preserve filenames; positional arguments avoid xargs -I size limits and
|
|
1397
|
+
# interpreting filenames as shell code (never -I{}). The subshell confines cleanup to
|
|
1398
|
+
# this step. The classifier itself is the tracked, shebanged helper beside this hook, so
|
|
1399
|
+
# the discovery it performs finds and checks it too \u2014 the gate covers its own classifier.
|
|
1400
|
+
shellcheck_tmp=$(mktemp -d) || exit 1
|
|
1401
|
+
trap 'rm -rf "$shellcheck_tmp"' 0
|
|
1402
|
+
trap 'exit 1' 1 2 3 15
|
|
1403
|
+
# Every commit in each pushed range, never the tip alone: pushing two commits \u2014 a bad
|
|
1404
|
+
# script, then its fix \u2014 passed a tip-only read while the bad commit landed on the remote.
|
|
1405
|
+
# An all-zero local sha is a delete (nothing to check). An all-zero REMOTE sha is a new
|
|
1406
|
+
# branch: everything no remote already has is being pushed, so the range is the local sha
|
|
1407
|
+
# minus every remote-tracking ref \u2014 commits a remote already received were gated when they
|
|
1408
|
+
# landed there, and the residue is exactly this push's new commits. A remote sha this clone
|
|
1409
|
+
# has never seen (the remote moved on since the last fetch) cannot bound a range, so it takes
|
|
1410
|
+
# the same new-branch rule instead of refusing a push the gate could have read. Enumeration
|
|
1411
|
+
# failure refuses the push: a range the gate could not list is a range it did not read.
|
|
1412
|
+
# (pattern) with both parens: bash 3.2 (macOS /bin/sh) cannot parse an unbalanced )
|
|
1413
|
+
# in a case pattern.
|
|
1414
|
+
: > "$shellcheck_tmp/shas" || exit 1
|
|
1415
|
+
printf '%s\\n' "$refs" | while read -r _lref lsha _rref rsha; do
|
|
1416
|
+
case "$lsha" in
|
|
1417
|
+
(*[!0]*) ;;
|
|
1418
|
+
(*) continue ;;
|
|
1419
|
+
esac
|
|
1420
|
+
case "$rsha" in
|
|
1421
|
+
(*[!0]*)
|
|
1422
|
+
if git cat-file -e "\${rsha}^{commit}" 2>/dev/null; then
|
|
1423
|
+
git rev-list "$rsha..$lsha"
|
|
1424
|
+
else
|
|
1425
|
+
git rev-list "$lsha" --not --remotes
|
|
1426
|
+
fi ;;
|
|
1427
|
+
(*) git rev-list "$lsha" --not --remotes ;;
|
|
1428
|
+
esac >> "$shellcheck_tmp/shas" || exit 1
|
|
1429
|
+
done || {
|
|
1430
|
+
echo "etymd: could not enumerate the commits being pushed for shellcheck" >&2
|
|
1256
1431
|
exit 1
|
|
1257
1432
|
}
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1433
|
+
shas=$(sort -u "$shellcheck_tmp/shas") || exit 1
|
|
1434
|
+
[ -n "$shas" ] || echo "\u203A shellcheck: no commit in the pushed refs (deletes only, or nothing on stdin) \u2014 nothing to check"
|
|
1435
|
+
# Resolved once, before any cd: the classifier runs inside each materialised tree, and a
|
|
1436
|
+
# relative hook path would no longer point at it from there.
|
|
1437
|
+
discover="$(cd "$(dirname "$0")" && pwd)/discover-shell-scripts.sh" || exit 1
|
|
1438
|
+
for sha in $shas; do
|
|
1439
|
+
# A fresh directory per commit, removed once that commit is checked so a long push does
|
|
1440
|
+
# not pile up one full tree per commit; the subshell trap above still cleans the root on
|
|
1441
|
+
# every early exit.
|
|
1442
|
+
tree=$(mktemp -d "$shellcheck_tmp/commit.XXXXXX") || exit 1
|
|
1443
|
+
# Checked out through a scratch index, never \`git archive\`: archive honours the
|
|
1444
|
+
# export-ignore and export-subst attributes, so a script marked export-ignore would leave
|
|
1445
|
+
# the checked set without a word. read-tree + checkout-index writes every tracked blob.
|
|
1446
|
+
# Large-file pointers stay pointers \u2014 they are never shell, and a push must not need the
|
|
1447
|
+
# network to be checked.
|
|
1448
|
+
if ! git cat-file -e "\${sha}^{commit}" 2>/dev/null \\
|
|
1449
|
+
|| ! GIT_INDEX_FILE="$shellcheck_tmp/index" git read-tree "$sha" 2>/dev/null \\
|
|
1450
|
+
|| ! GIT_INDEX_FILE="$shellcheck_tmp/index" GIT_LFS_SKIP_SMUDGE=1 \\
|
|
1451
|
+
git checkout-index -a -f --prefix="$tree/" 2>/dev/null; then
|
|
1452
|
+
echo "\u2717 shellcheck: could not materialise $(git rev-parse --short "$sha" 2>/dev/null || echo "$sha") \u2014 refusing the push rather than certifying bytes this gate did not read" >&2
|
|
1453
|
+
exit 1
|
|
1454
|
+
fi
|
|
1455
|
+
if [ -n "$(git ls-tree -r --name-only "$sha")" ] && [ -z "$(ls -A "$tree")" ]; then
|
|
1456
|
+
echo "\u2717 shellcheck: $(git rev-parse --short "$sha" 2>/dev/null || echo "$sha") extracted to an empty tree \u2014 refusing the push" >&2
|
|
1457
|
+
exit 1
|
|
1458
|
+
fi
|
|
1459
|
+
: > "$shellcheck_tmp/scripts" && : > "$shellcheck_tmp/count" && : > "$shellcheck_tmp/zsh-count" && : > "$shellcheck_tmp/skip-count" || exit 1
|
|
1460
|
+
git ls-tree -r -z --name-only "$sha" > "$shellcheck_tmp/tracked" || {
|
|
1461
|
+
echo "etymd: cannot enumerate the tree of $(git rev-parse --short "$sha" 2>/dev/null || echo "$sha") for shellcheck" >&2
|
|
1462
|
+
exit 1
|
|
1463
|
+
}
|
|
1464
|
+
( cd "$tree" && xargs -0 "$discover" "$shellcheck_tmp" ) < "$shellcheck_tmp/tracked" || {
|
|
1465
|
+
echo "etymd: shell script discovery failed; shellcheck coverage is incomplete" >&2
|
|
1466
|
+
exit 1
|
|
1467
|
+
}
|
|
1468
|
+
count=$(wc -c < "$shellcheck_tmp/count") || exit 1
|
|
1469
|
+
zsh_count=$(wc -c < "$shellcheck_tmp/zsh-count") || exit 1
|
|
1470
|
+
skip_count=$(wc -c < "$shellcheck_tmp/skip-count") || exit 1
|
|
1471
|
+
if [ "$skip_count" -gt 0 ]; then
|
|
1472
|
+
echo "\u203A shellcheck: $((skip_count)) tracked path(s) with nothing readable behind them (submodule or dangling symlink) \u2014 not checked, not failed"
|
|
1473
|
+
fi
|
|
1474
|
+
if [ "$zsh_count" -gt 0 ]; then
|
|
1475
|
+
echo "\u203A shellcheck: $((zsh_count)) zsh script(s) excluded \u2014 shellcheck cannot parse zsh (SC1071); not checked, not failed"
|
|
1476
|
+
fi
|
|
1477
|
+
if [ "$count" -gt 0 ]; then
|
|
1478
|
+
echo "\u203A shellcheck ($((count)) scripts in $(git rev-parse --short "$sha" 2>/dev/null || echo "$sha"), blocking at severity=warning)"
|
|
1479
|
+
( cd "$tree" && xargs -0 shellcheck -S warning -- < "$shellcheck_tmp/scripts" ) || {
|
|
1480
|
+
echo " fix, or justify inline with '# shellcheck disable=SCxxxx # why'"
|
|
1481
|
+
exit 1
|
|
1482
|
+
}
|
|
1483
|
+
# Everything below the blocking bar, shown once the push is already cleared. Never affects
|
|
1484
|
+
# the exit code \u2014 advice that can fail a push is not advice.
|
|
1485
|
+
advice=$( ( cd "$tree" && xargs -0 shellcheck -S style -f gcc -- < "$shellcheck_tmp/scripts" 2>/dev/null ) \\
|
|
1486
|
+
| grep -v ': warning:\\|: error:' || true)
|
|
1487
|
+
if [ -n "$advice" ]; then
|
|
1488
|
+
echo " \xB7 style/info (not blocking):"
|
|
1489
|
+
printf '%s\\n' "$advice" | sed 's/^/ /'
|
|
1490
|
+
fi
|
|
1491
|
+
fi
|
|
1492
|
+
rm -rf "$tree" || exit 1
|
|
1493
|
+
done
|
|
1494
|
+
) || exit 1
|
|
1267
1495
|
else
|
|
1268
1496
|
echo "\u203A shellcheck skipped (not on PATH) \u2014 install it to gate this repo's shell scripts"
|
|
1269
1497
|
fi`;
|
|
@@ -1296,7 +1524,7 @@ fi`;
|
|
|
1296
1524
|
return stampGenerated(`#!/usr/bin/env sh
|
|
1297
1525
|
# etymd: correctness gate. Mirrors CI cheapest-first; blocks the push on any failure.
|
|
1298
1526
|
|
|
1299
|
-
${localHookCall("pre-push")}${runner}
|
|
1527
|
+
${localHookCall("pre-push", true)}${runner}
|
|
1300
1528
|
${body}${shellStep}
|
|
1301
1529
|
${auditStep}
|
|
1302
1530
|
|
|
@@ -1426,6 +1654,7 @@ async function planWorkflow(root, facts, opts) {
|
|
|
1426
1654
|
};
|
|
1427
1655
|
if (opts.agents) {
|
|
1428
1656
|
await add("AGENTS.md", generateAgentsMd(facts), "Minimal operating contract (scaffold)");
|
|
1657
|
+
await add("CLAUDE.md", generateClaudePointerMd(), "Claude Code pointer to AGENTS.md");
|
|
1429
1658
|
}
|
|
1430
1659
|
if (opts.gates) {
|
|
1431
1660
|
const existingPrePush = await readText(path2.join(root, ".githooks", "pre-push"));
|
|
@@ -1459,6 +1688,14 @@ async function planWorkflow(root, facts, opts) {
|
|
|
1459
1688
|
"Correctness gate (pre-push)",
|
|
1460
1689
|
true
|
|
1461
1690
|
);
|
|
1691
|
+
if (facts.shell?.scripts) {
|
|
1692
|
+
await add(
|
|
1693
|
+
".githooks/discover-shell-scripts.sh",
|
|
1694
|
+
generateShellDiscoveryScript(),
|
|
1695
|
+
"Shell discovery (pre-push shellcheck helper)",
|
|
1696
|
+
true
|
|
1697
|
+
);
|
|
1698
|
+
}
|
|
1462
1699
|
if (opts.gateConfig?.publishGate ?? opts.publishGate ?? facts.publishable) {
|
|
1463
1700
|
await add(
|
|
1464
1701
|
"scripts/artifact-check.sh",
|
|
@@ -1893,7 +2130,7 @@ async function localHookTools(root, facts, scripts) {
|
|
|
1893
2130
|
const inertCompanions = [];
|
|
1894
2131
|
const readHook = async (name) => {
|
|
1895
2132
|
if (!hooks.dir) return empty;
|
|
1896
|
-
const text = await readText(path2.
|
|
2133
|
+
const text = await readText(path2.resolve(root, hooks.dir, name));
|
|
1897
2134
|
if (!text) return empty;
|
|
1898
2135
|
const tools = new Set(matchTools(text, scripts));
|
|
1899
2136
|
const companion = await companionOf(root, hooks.dir, name, text, scripts);
|
|
@@ -2042,7 +2279,7 @@ async function buildGateInventory(root, facts) {
|
|
|
2042
2279
|
|
|
2043
2280
|
// src/lenses/gate-integrity/screener.ts
|
|
2044
2281
|
init_util();
|
|
2045
|
-
var
|
|
2282
|
+
var pExecFile3 = promisify(execFile);
|
|
2046
2283
|
var HOOK_FILES = ["pre-commit", "pre-push", "commit-msg"];
|
|
2047
2284
|
var SCREEN_CALL_RE = /"\$GATE"\s+screen\b/;
|
|
2048
2285
|
var DEV_BUILD_ARM = "[ -x ./dist/cli.js ]";
|
|
@@ -2059,7 +2296,7 @@ async function probeScreener(root, facts) {
|
|
|
2059
2296
|
const doors = [];
|
|
2060
2297
|
let devBuildArm = false;
|
|
2061
2298
|
for (const name of HOOK_FILES) {
|
|
2062
|
-
const text = await readText(path2.
|
|
2299
|
+
const text = await readText(path2.resolve(root, dir, name));
|
|
2063
2300
|
if (!text || !SCREEN_CALL_RE.test(text)) continue;
|
|
2064
2301
|
doors.push(`${dir}/${name}`);
|
|
2065
2302
|
if (text.includes(DEV_BUILD_ARM)) devBuildArm = true;
|
|
@@ -2079,7 +2316,7 @@ async function probeScreener(root, facts) {
|
|
|
2079
2316
|
source = "path";
|
|
2080
2317
|
}
|
|
2081
2318
|
try {
|
|
2082
|
-
await
|
|
2319
|
+
await pExecFile3(runner, ["screen", "--help"], { cwd: root, timeout: 1e4 });
|
|
2083
2320
|
return { present: true, doors, runner, source, answersScreen: true };
|
|
2084
2321
|
} catch (err) {
|
|
2085
2322
|
const e = err;
|
|
@@ -2757,10 +2994,13 @@ function extractCommandClaims(text) {
|
|
|
2757
2994
|
return { scripts, filteredSkipped };
|
|
2758
2995
|
}
|
|
2759
2996
|
var PATH_TOKEN_RE = /^[A-Za-z0-9_.-]+(\/[A-Za-z0-9_.$-]+)+\/?$/;
|
|
2997
|
+
var HOSTNAME_RE = /^[a-z0-9-]+(?:\.[a-z0-9-]+)*\.([a-z]{2,})$/i;
|
|
2760
2998
|
var KNOWN_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
2761
2999
|
..."ts tsx cts mts js jsx cjs mjs json jsonc json5 md mdx mdc yml yaml toml ini cfg conf env sh bash zsh fish ps1 bat cmd css scss sass less html htm xml svg sql prisma graphql gql proto py rb rs go java kt kts swift c h cc cpp hpp cs php vue svelte astro txt log lock csv tsv png jpg jpeg gif webp ico avif woff woff2 ttf otf wasm map pem key crt tf tfvars example sample local snap ejs hbs pug".split(" ")
|
|
2762
3000
|
]);
|
|
2763
3001
|
var CREATION_CONTEXT_RE = /\b(?:creat(?:e|es|ed|ing)|generat(?:e|es|ed|ing)|scaffold(?:s|ed|ing)?|quarantin(?:e|es|ed|ing)|(?:writ(?:e|es|ten|ing)|output(?:s|ted)?|emit(?:s|ted|ting)?|sav(?:e|es|ed|ing)|mov(?:e|es|ed|ing)|copy|copi(?:es|ed))\s+(?:it\s+|them\s+)?(?:to|into)|new\s+(?:file|directory|folder)|will\s+(?:be\s+)?(?:created|generated|written)|add(?:s|ed|ing)?\s+(?:a|the)\s+new)\b/i;
|
|
3002
|
+
var FETCH_CONTEXT_RE = /\b(?:fetch(?:es|ed|ing)?|pull(?:s|ed|ing)?|clone(?:s|d)?|download(?:s|ed|ing)?)\b/i;
|
|
3003
|
+
var EXTERNAL_URL_RE = /https?:\/\/|\bwww\./i;
|
|
2764
3004
|
var PLACEHOLDER_SEGMENTS = /* @__PURE__ */ new Set(["placeholder", "foo", "bar", "baz", "qux"]);
|
|
2765
3005
|
var PLACEHOLDER_PREFIX_RE = /^(?:my|your)-/i;
|
|
2766
3006
|
function isPlaceholderClaim(token) {
|
|
@@ -2794,14 +3034,21 @@ ${line}`;
|
|
|
2794
3034
|
}
|
|
2795
3035
|
function extractPathClaims(text, opts = {}) {
|
|
2796
3036
|
const prospectiveOnly = /* @__PURE__ */ new Map();
|
|
3037
|
+
const fetchedOnly = /* @__PURE__ */ new Map();
|
|
2797
3038
|
const namespacedOnly = /* @__PURE__ */ new Map();
|
|
2798
3039
|
const placeholder = /* @__PURE__ */ new Set();
|
|
3040
|
+
const dirs = /* @__PURE__ */ new Set();
|
|
2799
3041
|
for (const m of text.matchAll(/`([^`\n]+)`/g)) {
|
|
2800
3042
|
const token = m[1].trim();
|
|
2801
3043
|
if (token.includes(" ") || token.length > 120) continue;
|
|
2802
3044
|
if (token.startsWith("/") || token.startsWith("~") || token.startsWith("@") || token.startsWith("$"))
|
|
2803
3045
|
continue;
|
|
2804
3046
|
if (token.includes("://") || token.startsWith("www.")) continue;
|
|
3047
|
+
if (token.includes("/")) {
|
|
3048
|
+
const firstSegment = token.split("/")[0] ?? token;
|
|
3049
|
+
const hostSuffix = HOSTNAME_RE.exec(firstSegment)?.[1]?.toLowerCase();
|
|
3050
|
+
if (hostSuffix && !KNOWN_EXTENSIONS.has(hostSuffix)) continue;
|
|
3051
|
+
}
|
|
2805
3052
|
if (/[*?{}<>|]/.test(token)) continue;
|
|
2806
3053
|
if (token.includes("@")) continue;
|
|
2807
3054
|
if (!PATH_TOKEN_RE.test(token)) continue;
|
|
@@ -2814,8 +3061,12 @@ function extractPathClaims(text, opts = {}) {
|
|
|
2814
3061
|
placeholder.add(claim);
|
|
2815
3062
|
continue;
|
|
2816
3063
|
}
|
|
2817
|
-
|
|
3064
|
+
if (isDirClaim) dirs.add(claim);
|
|
3065
|
+
const context = claimContext(text, m.index ?? 0);
|
|
3066
|
+
const prospective2 = CREATION_CONTEXT_RE.test(context);
|
|
2818
3067
|
prospectiveOnly.set(claim, (prospectiveOnly.get(claim) ?? true) && prospective2);
|
|
3068
|
+
const fetched2 = FETCH_CONTEXT_RE.test(context) && EXTERNAL_URL_RE.test(context);
|
|
3069
|
+
fetchedOnly.set(claim, (fetchedOnly.get(claim) ?? true) && fetched2);
|
|
2819
3070
|
if (opts.namespaces) {
|
|
2820
3071
|
const nsLead = new RegExp(`(${NAMESPACE_IDENT}):[ \\t]*$`).exec(text.slice(0, m.index ?? 0));
|
|
2821
3072
|
const prefixed = isNamespace(nsLead?.[1]);
|
|
@@ -2824,13 +3075,15 @@ function extractPathClaims(text, opts = {}) {
|
|
|
2824
3075
|
}
|
|
2825
3076
|
const paths = [];
|
|
2826
3077
|
const prospective = [];
|
|
3078
|
+
const fetched = [];
|
|
2827
3079
|
const namespaced = [];
|
|
2828
3080
|
for (const [claim, only] of prospectiveOnly) {
|
|
2829
|
-
if (
|
|
3081
|
+
if (fetchedOnly.get(claim)) fetched.push(claim);
|
|
3082
|
+
else if (only) prospective.push(claim);
|
|
2830
3083
|
else if (namespacedOnly.get(claim)) namespaced.push(claim);
|
|
2831
3084
|
else paths.push(claim);
|
|
2832
3085
|
}
|
|
2833
|
-
return { paths, prospective, namespaced, placeholder: [...placeholder] };
|
|
3086
|
+
return { paths, dirs: [...dirs], prospective, fetched, namespaced, placeholder: [...placeholder] };
|
|
2834
3087
|
}
|
|
2835
3088
|
var LOCAL_REF_LEADINS = new Set(
|
|
2836
3089
|
"decision decisions entry entries ruling rulings record records ledger id ids item items see per in of on at by to as is was are were the a an and or but not with under over from via vs than after before since between through against latest newest earliest only also still now supersedes superseded superseding amends amended extends extended cites cited citing adds added adding wrote written writes locked locks closed closes opened opens resolves resolved reopened recorded number numbers".split(" ")
|
|
@@ -2902,12 +3155,14 @@ async function buildTruthEnv(root, facts) {
|
|
|
2902
3155
|
for (const key of Object.keys(pkgJson?.scripts ?? {})) knownScripts.add(key);
|
|
2903
3156
|
}
|
|
2904
3157
|
const bases = [root, ...facts.packages.map((p) => path2.join(root, p.dir))];
|
|
2905
|
-
const pathResolves = async (claim) => {
|
|
3158
|
+
const pathResolves = async (claim, fromDir) => {
|
|
2906
3159
|
for (const base of bases) {
|
|
2907
3160
|
if (await pathExists(path2.join(base, claim))) return true;
|
|
2908
3161
|
if (await pathExists(path2.join(base, "src", claim))) return true;
|
|
2909
3162
|
if (await pathExists(path2.join(base, "scripts", claim))) return true;
|
|
2910
3163
|
}
|
|
3164
|
+
if (fromDir && fromDir !== "." && await pathExists(path2.join(root, fromDir, claim)))
|
|
3165
|
+
return true;
|
|
2911
3166
|
return false;
|
|
2912
3167
|
};
|
|
2913
3168
|
const binResolves = async (name) => {
|
|
@@ -2937,6 +3192,7 @@ function emptyCounters() {
|
|
|
2937
3192
|
unverifiableCommands: 0,
|
|
2938
3193
|
gitignoredSkipped: 0,
|
|
2939
3194
|
prospectiveSkipped: 0,
|
|
3195
|
+
fetchedSkipped: 0,
|
|
2940
3196
|
placeholderSkipped: 0,
|
|
2941
3197
|
qualifiedRefsSkipped: 0,
|
|
2942
3198
|
unresolvableRefs: 0,
|
|
@@ -2979,19 +3235,48 @@ async function checkTextClaims(env, file, opts, counters) {
|
|
|
2979
3235
|
confidence: "high"
|
|
2980
3236
|
});
|
|
2981
3237
|
}
|
|
2982
|
-
const { paths, prospective, placeholder, namespaced } = extractPathClaims(
|
|
2983
|
-
|
|
2984
|
-
|
|
3238
|
+
const { paths, dirs, prospective, fetched, placeholder, namespaced } = extractPathClaims(
|
|
3239
|
+
file.text,
|
|
3240
|
+
{ namespaces: opts.treatNamespacedPrefixes }
|
|
3241
|
+
);
|
|
2985
3242
|
counters.prospectiveSkipped += prospective.length;
|
|
3243
|
+
counters.fetchedSkipped += fetched.length;
|
|
2986
3244
|
counters.placeholderSkipped += placeholder.length;
|
|
2987
3245
|
counters.namespacedSkipped += namespaced.length;
|
|
2988
|
-
const
|
|
3246
|
+
const fromDir = path2.dirname(file.path);
|
|
3247
|
+
const unresolved = [];
|
|
2989
3248
|
for (const claim of paths) {
|
|
2990
|
-
if (await env.pathResolves(claim
|
|
3249
|
+
if (await env.pathResolves(claim, fromDir))
|
|
3250
|
+
examined.push({ kind: "path", value: claim, exists: true });
|
|
3251
|
+
else unresolved.push(claim);
|
|
3252
|
+
}
|
|
3253
|
+
const proseBases = /* @__PURE__ */ new Set();
|
|
3254
|
+
for (const dirClaim of dirs) {
|
|
3255
|
+
if (await env.pathResolves(dirClaim, fromDir)) {
|
|
3256
|
+
proseBases.add(dirClaim);
|
|
3257
|
+
proseBases.add(path2.dirname(dirClaim));
|
|
3258
|
+
}
|
|
3259
|
+
}
|
|
3260
|
+
const missing = [];
|
|
3261
|
+
for (const claim of unresolved) {
|
|
3262
|
+
let anchored = false;
|
|
3263
|
+
for (const base of proseBases) {
|
|
3264
|
+
if (await pathExists(path2.join(env.root, base, claim))) anchored = true;
|
|
3265
|
+
else if (fromDir !== "." && await pathExists(path2.join(env.root, fromDir, base, claim)))
|
|
3266
|
+
anchored = true;
|
|
3267
|
+
if (anchored) break;
|
|
3268
|
+
}
|
|
3269
|
+
if (anchored) examined.push({ kind: "path", value: claim, exists: true });
|
|
2991
3270
|
else missing.push(claim);
|
|
2992
3271
|
}
|
|
2993
3272
|
const ignoredOut = missing.length ? await git(env.root, ["check-ignore", ...missing]) : null;
|
|
2994
3273
|
const gitignored = new Set((ignoredOut ?? "").split("\n").filter(Boolean));
|
|
3274
|
+
const unproven = missing.filter((claim) => !gitignored.has(claim));
|
|
3275
|
+
if (unproven.length) {
|
|
3276
|
+
const dirOut = await git(env.root, ["check-ignore", ...unproven.map((claim) => `${claim}/`)]);
|
|
3277
|
+
for (const line of (dirOut ?? "").split("\n").filter(Boolean))
|
|
3278
|
+
gitignored.add(line.replace(/\/$/, ""));
|
|
3279
|
+
}
|
|
2995
3280
|
let pathFindings = 0;
|
|
2996
3281
|
for (const claim of missing) {
|
|
2997
3282
|
if (opts.rootedFirstSegments && !opts.rootedFirstSegments.has(claim.split("/")[0] ?? claim)) {
|
|
@@ -3279,6 +3564,11 @@ var instructionTruthLens = {
|
|
|
3279
3564
|
`${counters.prospectiveSkipped} path claim(s) sit in create-this prose (the file instructs generating them) \u2014 forward-looking, not stale; skipped, not flagged.`
|
|
3280
3565
|
);
|
|
3281
3566
|
}
|
|
3567
|
+
if (counters.fetchedSkipped) {
|
|
3568
|
+
disclosures.push(
|
|
3569
|
+
`${counters.fetchedSkipped} path claim(s) sit beside the URL they are fetched from \u2014 another tree's files, unverifiable here; skipped, not flagged.`
|
|
3570
|
+
);
|
|
3571
|
+
}
|
|
3282
3572
|
if (counters.placeholderSkipped) {
|
|
3283
3573
|
disclosures.push(
|
|
3284
3574
|
`${counters.placeholderSkipped} path claim(s) are naming stand-ins (e.g. \`my-custom-skill\`) rather than real references; skipped, not flagged.`
|
|
@@ -3316,7 +3606,7 @@ var instructionTruthLens = {
|
|
|
3316
3606
|
);
|
|
3317
3607
|
}
|
|
3318
3608
|
disclosures.push(
|
|
3319
|
-
`Checked ${files.length} instruction file(s); commands resolved against root + ${facts.packages.length} workspace manifest(s) plus installed binaries; paths matched against root and
|
|
3609
|
+
`Checked ${files.length} instruction file(s); commands resolved against root + ${facts.packages.length} workspace manifest(s) plus installed binaries; paths matched against root, package roots, the claiming file's directory, and directories the same file names; existence judged on the working tree (gitignored-but-present is true, gitignored-and-absent unverifiable). Heuristics: workspace-filtered commands skipped (${counters.filteredSkipped}); tokens without a recognized extension treated as prose (a dir claim needs a trailing slash); schemeless hosts read as URLs; gitignored claims unverifiable; create-this, fetched-from-URL, and stand-in path claims skipped; absolute/globbed/placeholder tokens skipped; doc mentions inside \`~/\` home paths skipped; framework-pattern staleness not checked.`
|
|
3320
3610
|
);
|
|
3321
3611
|
return {
|
|
3322
3612
|
lens: LENS_ID4,
|
|
@@ -3747,6 +4037,7 @@ async function loadFleetManifest(manifestPath) {
|
|
|
3747
4037
|
|
|
3748
4038
|
// src/engine/fleet.ts
|
|
3749
4039
|
init_config();
|
|
4040
|
+
init_detect();
|
|
3750
4041
|
init_facts();
|
|
3751
4042
|
init_util();
|
|
3752
4043
|
|
|
@@ -3875,7 +4166,7 @@ function parseMilestones(text) {
|
|
|
3875
4166
|
}
|
|
3876
4167
|
|
|
3877
4168
|
// src/engine/fleet.ts
|
|
3878
|
-
var
|
|
4169
|
+
var pExecFile4 = promisify(execFile);
|
|
3879
4170
|
var FLEET_LENS = "fleet-manifest";
|
|
3880
4171
|
var FLEET_JSON_SCHEMA = "fleet-experimental-0.2";
|
|
3881
4172
|
var GUARDED_WALL_ARTIFACTS = ["PROJECT_CONTEXT.md", "DECISIONS.md"];
|
|
@@ -3930,7 +4221,7 @@ function stateBudgetsFor(entry) {
|
|
|
3930
4221
|
async function gitGrepFiles(root, needle, mode = "fixed") {
|
|
3931
4222
|
try {
|
|
3932
4223
|
const matchFlag = mode === "regex" ? "-E" : "-F";
|
|
3933
|
-
const { stdout } = await
|
|
4224
|
+
const { stdout } = await pExecFile4("git", ["grep", "-I", "-l", matchFlag, "-e", needle], {
|
|
3934
4225
|
cwd: root,
|
|
3935
4226
|
timeout: 8e3
|
|
3936
4227
|
});
|
|
@@ -4353,6 +4644,31 @@ async function checkGateDrift(manifest, findings, disclosures) {
|
|
|
4353
4644
|
}
|
|
4354
4645
|
}
|
|
4355
4646
|
}
|
|
4647
|
+
async function checkClaudePointers(manifest, findings) {
|
|
4648
|
+
for (const entry of manifest.entries) {
|
|
4649
|
+
const root = entry.resolvedRoot;
|
|
4650
|
+
if (!root || !await isDirectory(root)) continue;
|
|
4651
|
+
const check = await checkClaudePointer(root);
|
|
4652
|
+
if (check.ok) continue;
|
|
4653
|
+
findings.push(
|
|
4654
|
+
check.kind === "no-import" ? finding3(
|
|
4655
|
+
`${FLEET_LENS}/claude-pointer-missing:${entry.name}`,
|
|
4656
|
+
"risk",
|
|
4657
|
+
`\`${entry.name}\` has a CLAUDE.md that never imports its AGENTS.md`,
|
|
4658
|
+
[`${entry.name}: ${check.detail}`],
|
|
4659
|
+
"Claude Code reads CLAUDE.md when one exists and falls back to AGENTS.md only when none does. A CLAUDE.md without the import shadows AGENTS.md, so the contract looks universal from inside the repo while Claude Code never receives it.",
|
|
4660
|
+
"Add a full-line `@AGENTS.md` import to that CLAUDE.md, symlink either file to the other, or delete the CLAUDE.md if AGENTS.md is the whole contract."
|
|
4661
|
+
) : finding3(
|
|
4662
|
+
`${FLEET_LENS}/claude-pointer-missing:${entry.name}`,
|
|
4663
|
+
"gap",
|
|
4664
|
+
`\`${entry.name}\` relies on the AGENTS.md fallback, which the installed Claude Code predates`,
|
|
4665
|
+
[`${entry.name}: ${check.detail}`],
|
|
4666
|
+
`Claude Code reads AGENTS.md on its own only from ${CLAUDE_AGENTS_FALLBACK_VERSION}; older releases load CLAUDE.md alone, so this repo's contract is invisible to them.`,
|
|
4667
|
+
"Update Claude Code, or create a CLAUDE.md beside AGENTS.md whose only import line is `@AGENTS.md`."
|
|
4668
|
+
)
|
|
4669
|
+
);
|
|
4670
|
+
}
|
|
4671
|
+
}
|
|
4356
4672
|
async function collectWallFindings(manifest) {
|
|
4357
4673
|
const findings = [];
|
|
4358
4674
|
const disclosures = [];
|
|
@@ -4362,6 +4678,7 @@ async function collectWallFindings(manifest) {
|
|
|
4362
4678
|
await checkHygieneNeedles(manifest, findings, disclosures);
|
|
4363
4679
|
await checkGuardedEmails(manifest, findings, disclosures);
|
|
4364
4680
|
await checkGateDrift(manifest, findings, disclosures);
|
|
4681
|
+
await checkClaudePointers(manifest, findings);
|
|
4365
4682
|
return { findings, disclosures };
|
|
4366
4683
|
}
|
|
4367
4684
|
function stateAgeDays(facts) {
|
|
@@ -4708,7 +5025,6 @@ var STOP_WORDS = new Set(
|
|
|
4708
5025
|
);
|
|
4709
5026
|
var WRAP_RE = /^([([{"']*)(.*?)([.,;:!?)\]}"']*)$/;
|
|
4710
5027
|
var TRAIL_RE = /[.,;:!?)\]}"']*$/;
|
|
4711
|
-
var HOSTNAME_RE = /^[a-z0-9-]+(?:\.[a-z0-9-]+)*\.([a-z]{2,})$/i;
|
|
4712
5028
|
function promoteBareTokens(text, ctx) {
|
|
4713
5029
|
const skips = {
|
|
4714
5030
|
bareInvocations: 0,
|
|
@@ -4896,6 +5212,11 @@ async function runPremise(opts) {
|
|
|
4896
5212
|
`${counters.prospectiveSkipped} path(s) sit in create-this prose (the task says to create them) \u2014 forward-looking, not missing; skipped.`
|
|
4897
5213
|
);
|
|
4898
5214
|
}
|
|
5215
|
+
if (counters.fetchedSkipped) {
|
|
5216
|
+
disclosures.push(
|
|
5217
|
+
`${counters.fetchedSkipped} path(s) are named beside the URL they are fetched from \u2014 another tree, not this repo; skipped, not flagged.`
|
|
5218
|
+
);
|
|
5219
|
+
}
|
|
4899
5220
|
if (counters.placeholderSkipped) {
|
|
4900
5221
|
disclosures.push(
|
|
4901
5222
|
`${counters.placeholderSkipped} path(s) are naming stand-ins (e.g. \`my-feature\`) rather than real references; skipped.`
|