Merge pull request #2902 from affaan-m/maint/pr-stewardship-portability-2026-08-29

fix: forward-port reviewed ECC 2.2 fixes (13 PRs)
This commit is contained in:
haelyra
2026-08-29 16:30:55 -04:00
committed by GitHub
44 changed files with 2776 additions and 306 deletions
+22
View File
@@ -0,0 +1,22 @@
# ECC for AdaL CLI
This directory contains the ECC (Everything Claude Code) configuration for the AdaL CLI harness.
## What is installed
- `rules/` — shared coding rules and guidelines
- `skills/` — reusable skills
- `commands/` — slash commands
- `AGENTS.md` — agent instructions
## Manual install
```bash
bash ./install.sh --target adal --profile minimal
```
## Notes
- The `adal` target installs into the project-level `./.adal/` directory.
- AdaL's own config (`~/.adal/settings.json`, MCP servers, plugins) is **not** touched by ECC install.
- Use `npx ecc doctor --target adal` to check install health.
+1 -1
View File
@@ -251,7 +251,7 @@ jobs:
persist-credentials: false
- name: Setup Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.11'
+3 -43
View File
@@ -35,46 +35,6 @@
*/
// Export the main plugin
export { ECCHooksPlugin, default } from "./plugins/index.js"
// Export individual components for selective use
export * from "./plugins/index.js"
// Version export
export const VERSION = "1.6.0"
// Plugin metadata
export const metadata = {
name: "ecc-universal",
version: VERSION,
description: "ECC plugin for OpenCode",
author: "affaan-m",
features: {
agents: 13,
commands: 31,
skills: 37,
configAssets: true,
hookEvents: [
"file.edited",
"tool.execute.before",
"tool.execute.after",
"session.created",
"session.idle",
"session.deleted",
"file.watcher.updated",
"permission.ask",
"todo.updated",
"shell.env",
"experimental.session.compacting",
],
customTools: [
"run-tests",
"check-coverage",
"security-audit",
"format-code",
"lint-check",
"git-summary",
"changed-files",
],
},
}
// opencode's legacy plugin loader iterates every module export and throws if
// any is not a plugin function, so only the plugin function may be exported.
export { default } from "./plugins/index.js"
+1 -1
View File
@@ -3,7 +3,7 @@
"hooks": {
"SessionStart": [
{
"matcher": "*",
"matcher": ".*",
"hooks": [
{
"type": "command",
+16 -16
View File
@@ -36,7 +36,7 @@
"id": "pre:edit-write:suggest-compact"
},
{
"matcher": "*",
"matcher": ".*",
"hooks": [
{
"type": "command",
@@ -73,7 +73,7 @@
"id": "pre:config-protection"
},
{
"matcher": "*",
"matcher": ".*",
"hooks": [
{
"type": "command",
@@ -98,7 +98,7 @@
],
"PreCompact": [
{
"matcher": "*",
"matcher": ".*",
"hooks": [
{
"type": "command",
@@ -111,7 +111,7 @@
],
"SessionStart": [
{
"matcher": "*",
"matcher": ".*",
"hooks": [
{
"type": "command",
@@ -122,7 +122,7 @@
"id": "session:start"
},
{
"matcher": "*",
"matcher": ".*",
"hooks": [
{
"type": "command",
@@ -135,7 +135,7 @@
],
"PostToolUse": [
{
"matcher": "*",
"matcher": ".*",
"hooks": [
{
"type": "command",
@@ -147,7 +147,7 @@
"id": "post:dispatcher:sync"
},
{
"matcher": "*",
"matcher": ".*",
"hooks": [
{
"type": "command",
@@ -162,7 +162,7 @@
],
"PostToolUseFailure": [
{
"matcher": "*",
"matcher": ".*",
"hooks": [
{
"type": "command",
@@ -186,7 +186,7 @@
],
"Stop": [
{
"matcher": "*",
"matcher": ".*",
"hooks": [
{
"type": "command",
@@ -197,7 +197,7 @@
"id": "stop:plan-canvas-pending"
},
{
"matcher": "*",
"matcher": ".*",
"hooks": [
{
"type": "command",
@@ -209,7 +209,7 @@
"id": "stop:format-typecheck"
},
{
"matcher": "*",
"matcher": ".*",
"hooks": [
{
"type": "command",
@@ -220,7 +220,7 @@
"id": "stop:check-console-log"
},
{
"matcher": "*",
"matcher": ".*",
"hooks": [
{
"type": "command",
@@ -233,7 +233,7 @@
"id": "stop:session-end"
},
{
"matcher": "*",
"matcher": ".*",
"hooks": [
{
"type": "command",
@@ -246,7 +246,7 @@
"id": "stop:evaluate-session"
},
{
"matcher": "*",
"matcher": ".*",
"hooks": [
{
"type": "command",
@@ -259,7 +259,7 @@
"id": "stop:cost-tracker"
},
{
"matcher": "*",
"matcher": ".*",
"hooks": [
{
"type": "command",
@@ -274,7 +274,7 @@
],
"SessionEnd": [
{
"matcher": "*",
"matcher": ".*",
"hooks": [
{
"type": "command",
+16 -8
View File
@@ -19,7 +19,8 @@
"zed",
"hermes",
"openclaw",
"kimi"
"kimi",
"adal"
],
"dependencies": [],
"defaultInstall": true,
@@ -47,7 +48,8 @@
"zed",
"hermes",
"openclaw",
"kimi"
"kimi",
"adal"
],
"dependencies": [],
"defaultInstall": true,
@@ -75,7 +77,8 @@
"zed",
"hermes",
"openclaw",
"kimi"
"kimi",
"adal"
],
"dependencies": [],
"defaultInstall": true,
@@ -121,7 +124,8 @@
"scripts/setup-package-manager.js",
".hermes",
".openclaw",
".kimi"
".kimi",
".adal"
],
"targets": [
"claude",
@@ -137,7 +141,8 @@
"zed",
"hermes",
"openclaw",
"kimi"
"kimi",
"adal"
],
"dependencies": [],
"defaultInstall": true,
@@ -294,7 +299,8 @@
"zed",
"hermes",
"openclaw",
"kimi"
"kimi",
"adal"
],
"dependencies": [
"platform-configs"
@@ -369,7 +375,8 @@
"zed",
"hermes",
"openclaw",
"kimi"
"kimi",
"adal"
],
"dependencies": [
"skill-unified-memory"
@@ -627,7 +634,8 @@
"zed",
"hermes",
"openclaw",
"kimi"
"kimi",
"adal"
],
"dependencies": [
"platform-configs"
+1
View File
@@ -40,6 +40,7 @@
"url": "https://github.com/affaan-m/ECC/issues"
},
"files": [
".adal/",
".agents/",
".claude-plugin/",
".codex/",
+2 -1
View File
@@ -31,7 +31,8 @@
"zed",
"hermes",
"openclaw",
"kimi"
"kimi",
"adal"
]
},
"profile": {
+2 -1
View File
@@ -61,7 +61,8 @@
"zed",
"hermes",
"openclaw",
"kimi"
"kimi",
"adal"
]
}
},
+214 -37
View File
@@ -1,11 +1,13 @@
#!/usr/bin/env node
/**
* Validate curated skill directories (skills/ in repo).
* Validate curated skill directories (skills/ in repo) and their
* translated mirrors (docs/{locale}/skills/ in repo).
*
* Checks:
* 1. Each sub-directory of skills/ contains a SKILL.md file.
* 2. SKILL.md is non-empty.
* 3. SKILL.md frontmatter (if present) declares a `name:` field.
* 3. SKILL.md frontmatter is present and declares both `name:` and
* `description:` fields.
* 4. SKILL.md frontmatter `description:` uses an inline scalar — not a
* literal block scalar (`|` / `|-` / `|+`), which preserves internal
* newlines and breaks flat-table renderers keyed off `description`.
@@ -17,14 +19,17 @@
*
* Structural findings (missing/empty SKILL.md) are always errors.
*
* Scope: curated only. Learned/imported/evolved roots are out of scope.
* If skills/ does not exist, exit 0 (no curated skills to validate).
* Scope: curated skills/ plus translated docs/{locale}/skills/ mirrors.
* Learned/imported/evolved roots are out of scope. If neither root
* exists, exit 0 (nothing to validate).
*/
const fs = require('fs');
const path = require('path');
const yaml = require('js-yaml');
const SKILLS_DIR = path.join(__dirname, '../../skills');
const DOCS_DIR = path.join(__dirname, '../../docs');
const STRICT = process.argv.includes('--strict') || process.env.CI_STRICT_SKILLS === '1';
@@ -64,8 +69,41 @@ function extractFrontmatter(content) {
* @param {string[]} lines
* @returns {{values: Record<string,string>, descriptionIndicator: string|null}}
*/
function stripUnquotedYamlComment(rawValue) {
let inSingleQuote = false;
let inDoubleQuote = false;
for (let index = 0; index < rawValue.length; index++) {
const character = rawValue[index];
if (inDoubleQuote && character === '\\') {
index += 1;
continue;
}
if (!inDoubleQuote && character === "'") {
if (inSingleQuote && rawValue[index + 1] === "'") {
index += 1;
} else {
inSingleQuote = !inSingleQuote;
}
continue;
}
if (!inSingleQuote && character === '"') {
inDoubleQuote = !inDoubleQuote;
continue;
}
if (!inSingleQuote && !inDoubleQuote && character === '#'
&& (index === 0 || /\s/.test(rawValue[index - 1]))) {
return rawValue.slice(0, index).trim();
}
}
return rawValue.trim();
}
function inspectFrontmatter(lines) {
const values = Object.create(null);
let values = Object.create(null);
let syntaxErrors = [];
let descriptionIndicator = null;
let inBlockScalar = false;
let blockScalarIndent = -1;
@@ -87,14 +125,33 @@ function inspectFrontmatter(lines) {
const key = match[1];
const rawValue = match[2];
// Strip unquoted comments for value/indicator inspection. Handles both
// trailing comments (`foo: bar # note`) and comment-only values
// (`foo: # todo`) so the latter is treated as empty.
const valueNoComment = rawValue
.replace(/^\s*#.*$/, '')
.replace(/\s+#.*$/, '')
.trim();
values[key] = valueNoComment;
// Strip YAML comments only when # appears outside a quoted scalar.
const valueNoComment = stripUnquotedYamlComment(rawValue);
values = Object.assign(Object.create(null), values, { [key]: valueNoComment });
const isQuoted = /^"(?:[^"\\]|\\.)*"$/.test(valueNoComment) || /^'(?:[^']|'')*'$/.test(valueNoComment);
if (!isQuoted && valueNoComment !== '') {
// A plain (unquoted) YAML scalar can never contain ": " — that
// sequence starts a new mapping key. When the translation pass
// drops a value's quoting, or glues the next frontmatter key onto
// the end of a value, this is exactly what shows up (see #2630).
if (valueNoComment.includes(': ')) {
syntaxErrors = [...syntaxErrors,
`${key}: unquoted value contains ': ' — invalid YAML; ` + `quote the value or the next key was likely glued onto this line`
];
}
// '@' and '`' are reserved YAML indicators and cannot start a
// plain scalar (see #2630 — a reordering during translation moved
// '@' into the first column of an unquoted description).
if (/^[@`]/.test(valueNoComment)) {
syntaxErrors = [
...syntaxErrors,
`${key}: unquoted value starts with reserved character '${valueNoComment[0]}' — quote the value`
];
}
}
// Detect literal / folded block-scalar indicators. Accept chomp
// modifiers (`-` / `+`) and optional indent-indicator digits in
@@ -108,7 +165,25 @@ function inspectFrontmatter(lines) {
}
}
return { values, descriptionIndicator };
try {
const parsed = yaml.load(lines.join('\n'));
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
syntaxErrors = [...syntaxErrors, 'must be a top-level YAML mapping'];
} else {
for (const key of ['name', 'description']) {
if (!Object.prototype.hasOwnProperty.call(parsed, key)) continue;
if (typeof parsed[key] !== 'string') {
syntaxErrors = [...syntaxErrors, `${key}: value must be a string`];
continue;
}
values = Object.assign(Object.create(null), values, { [key]: parsed[key] });
}
}
} catch (error) {
syntaxErrors = [...syntaxErrors, `invalid YAML: ${error.reason || error.message}`];
}
return { values, descriptionIndicator, syntaxErrors };
}
/**
@@ -120,6 +195,10 @@ function inspectFrontmatter(lines) {
* `reportFrontmatterFinding`, which owns the WARN/ERROR decision based
* on strict mode.
*
* Curated skills/ tolerates a SKILL.md with no frontmatter block at all
* (frontmatter checks only apply when a block is present) — this mirrors
* pre-existing behavior and is covered by an explicit regression test.
*
* @param {string} dir
* @param {string} skillsDir
* @param {(msg: string) => void} reportFrontmatterFinding
@@ -127,8 +206,35 @@ function inspectFrontmatter(lines) {
*/
function validateSkillDir(dir, skillsDir, reportFrontmatterFinding) {
const skillMd = path.join(skillsDir, dir, 'SKILL.md');
return validateSkillFile(skillMd, `${dir}/SKILL.md`, reportFrontmatterFinding, { requireFrontmatter: false });
}
/**
* Validate a single SKILL.md file at an arbitrary path.
*
* Shared by the curated skills/ scan and the translated
* docs/{locale}/skills/ scan — same checks apply to both, since a
* translated mirror's frontmatter must be just as parseable as the
* English original (see #2630).
*
* `requireFrontmatter: true` (used for docs/{locale}/skills/ mirrors)
* flags a completely missing frontmatter block as a finding — the
* translated mirror must carry the same `name`/`description` as its
* English original. Curated skills/ (requireFrontmatter: false) keeps
* the pre-existing tolerant behavior of skipping checks entirely when no
* block is present.
*
* @param {string} skillMd
* @param {string} label
* @param {(msg: string) => void} reportFrontmatterFinding
* @param {{requireFrontmatter?: boolean}} [opts]
* @returns {{fatal: boolean}}
*/
function validateSkillFile(skillMd, label, reportFrontmatterFinding, opts = {}) {
const { requireFrontmatter = false } = opts;
if (!fs.existsSync(skillMd)) {
console.error(`ERROR: ${dir}/ - Missing SKILL.md`);
console.error(`ERROR: ${label} - Missing SKILL.md`);
return { fatal: true };
}
@@ -136,43 +242,95 @@ function validateSkillDir(dir, skillsDir, reportFrontmatterFinding) {
try {
content = fs.readFileSync(skillMd, 'utf-8');
} catch (err) {
console.error(`ERROR: ${dir}/SKILL.md - ${err.message}`);
console.error(`ERROR: ${label} - ${err.message}`);
return { fatal: true };
}
if (content.trim().length === 0) {
console.error(`ERROR: ${dir}/SKILL.md - Empty file`);
console.error(`ERROR: ${label} - Empty file`);
return { fatal: true };
}
const fm = extractFrontmatter(content);
if (fm.present) {
const { values, descriptionIndicator } = inspectFrontmatter(fm.lines);
if (!Object.prototype.hasOwnProperty.call(values, 'name')) {
reportFrontmatterFinding(`${dir}/SKILL.md - frontmatter missing required field: name`);
} else if (values.name === '') {
reportFrontmatterFinding(`${dir}/SKILL.md - frontmatter 'name' is empty`);
if (!fm.present) {
if (requireFrontmatter) {
reportFrontmatterFinding(`${label} - no frontmatter block found (missing name/description)`);
}
return { fatal: false };
}
if (descriptionIndicator && descriptionIndicator.startsWith('|')) {
reportFrontmatterFinding(
`${dir}/SKILL.md - frontmatter description uses literal block scalar ` + `'${descriptionIndicator}' which preserves internal newlines; ` + `use an inline string or folded '>' scalar instead`
);
}
const { values, descriptionIndicator, syntaxErrors } = inspectFrontmatter(fm.lines);
if (!Object.prototype.hasOwnProperty.call(values, 'name')) {
reportFrontmatterFinding(`${label} - frontmatter missing required field: name`);
} else if (values.name === '') {
reportFrontmatterFinding(`${label} - frontmatter 'name' is empty`);
}
if (!Object.prototype.hasOwnProperty.call(values, 'description')) {
reportFrontmatterFinding(`${label} - frontmatter missing required field: description`);
} else if (values.description === '') {
reportFrontmatterFinding(`${label} - frontmatter 'description' is empty`);
}
if (descriptionIndicator && descriptionIndicator.startsWith('|')) {
reportFrontmatterFinding(
`${label} - frontmatter description uses literal block scalar ` + `'${descriptionIndicator}' which preserves internal newlines; ` + `use an inline string or folded '>' scalar instead`
);
}
for (const syntaxError of syntaxErrors) {
reportFrontmatterFinding(`${label} - frontmatter ${syntaxError}`);
}
return { fatal: false };
}
/**
* Find every SKILL.md under docs/{locale}/skills/*, mirroring the
* curated skills/ layout one locale directory deeper.
*
* @param {string} docsDir
* @returns {Array<{skillMd: string, label: string}>}
*/
function findDocsSkillFiles(docsDir) {
if (!fs.existsSync(docsDir)) return [];
const readDirectories = (directory, label) => {
try {
return fs.readdirSync(directory, { withFileTypes: true });
} catch {
throw new Error(`unable to read ${label}`);
}
};
const locales = readDirectories(docsDir, 'docs directory')
.filter(e => e.isDirectory() && !e.name.startsWith('.'))
.map(e => e.name);
return locales.flatMap(locale => {
const localeSkillsDir = path.join(docsDir, locale, 'skills');
if (!fs.existsSync(localeSkillsDir)) return [];
const skillDirs = readDirectories(localeSkillsDir, `docs/${locale}/skills directory`)
.filter(e => e.isDirectory() && !e.name.startsWith('.'))
.map(e => e.name);
return skillDirs.map(skillDir => ({
skillMd: path.join(localeSkillsDir, skillDir, 'SKILL.md'),
label: `docs/${locale}/skills/${skillDir}/SKILL.md`
}));
});
}
function validateSkills() {
if (!fs.existsSync(SKILLS_DIR)) {
console.log('No curated skills directory (skills/), skipping');
const curatedExists = fs.existsSync(SKILLS_DIR);
const docsSkillFiles = findDocsSkillFiles(DOCS_DIR);
if (!curatedExists && docsSkillFiles.length === 0) {
console.log('No skills directory (skills/ or docs/*/skills/), skipping');
process.exit(0);
}
const entries = fs.readdirSync(SKILLS_DIR, { withFileTypes: true });
const dirs = entries.filter(e => e.isDirectory() && !e.name.startsWith('.')).map(e => e.name);
let hasErrors = false;
let warnCount = 0;
let validCount = 0;
@@ -187,8 +345,22 @@ function validateSkills() {
}
};
for (const dir of dirs) {
const { fatal } = validateSkillDir(dir, SKILLS_DIR, reportFrontmatterFinding);
if (curatedExists) {
const entries = fs.readdirSync(SKILLS_DIR, { withFileTypes: true });
const dirs = entries.filter(e => e.isDirectory() && !e.name.startsWith('.')).map(e => e.name);
for (const dir of dirs) {
const { fatal } = validateSkillDir(dir, SKILLS_DIR, reportFrontmatterFinding);
if (fatal) {
hasErrors = true;
continue;
}
validCount++;
}
}
for (const { skillMd, label } of docsSkillFiles) {
const { fatal } = validateSkillFile(skillMd, label, reportFrontmatterFinding, { requireFrontmatter: true });
if (fatal) {
hasErrors = true;
continue;
@@ -207,4 +379,9 @@ function validateSkills() {
console.log(msg);
}
validateSkills();
try {
validateSkills();
} catch (error) {
console.error(`ERROR: ${error.message}`);
process.exit(1);
}
Regular → Executable
+14 -2
View File
@@ -60,11 +60,23 @@ has_node_script() {
node -e 'const fs=require("fs"); const p=JSON.parse(fs.readFileSync("package.json","utf8")); process.exit(p.scripts && p.scripts[process.argv[1]] ? 0 : 1)' "$script_name" >/dev/null 2>&1
}
run_pnpm() {
if command -v corepack >/dev/null 2>&1; then
# Corepack may download the pinned pnpm version on a cache miss. Set
# COREPACK_ENABLE_NETWORK=0 to make an offline cache miss fail immediately.
corepack pnpm "$@"
elif command -v pnpm >/dev/null 2>&1; then
pnpm "$@"
else
fail "pnpm could not be resolved from PATH or Corepack"
fi
}
run_node_script() {
local pm="$1"
local script_name="$2"
case "$pm" in
pnpm) pnpm run "$script_name" ;;
pnpm) run_pnpm run "$script_name" ;;
bun) bun run "$script_name" ;;
yarn) yarn "$script_name" ;;
npm) npm run "$script_name" ;;
@@ -90,7 +102,7 @@ if [[ -f "package.json" ]]; then
ran_any_check=1
log "Running dependency audit (ECC_PREPUSH_AUDIT=1)"
case "$pm" in
pnpm) pnpm audit --prod || fail "pnpm audit failed" ;;
pnpm) run_pnpm audit --prod || fail "pnpm audit failed" ;;
bun) bun audit || fail "bun audit failed" ;;
yarn) yarn npm audit --recursive || fail "yarn audit failed" ;;
npm) npm audit --omit=dev || fail "npm audit failed" ;;
+32 -6
View File
@@ -61,11 +61,33 @@ phase() { echo -e "\n${PURPLE}════════════════
extract_score() {
# Extract the TOTAL weighted score from a feedback file
local file="$1"
# Look for **TOTAL** or **X.X/10** pattern
grep -oP '(?<=\*\*TOTAL\*\*.*\*\*)[0-9]+\.[0-9]+' "$file" 2>/dev/null \
|| grep -oP '(?<=TOTAL.*\|.*\| \*\*)[0-9]+\.[0-9]+' "$file" 2>/dev/null \
|| grep -oP 'Verdict:.*([0-9]+\.[0-9]+)' "$file" 2>/dev/null | grep -oP '[0-9]+\.[0-9]+' \
|| echo "0.0"
awk '
/\*\*TOTAL\*\*/ {
total_line = $0
total = ""
while (match(total_line, /[0-9]+[.][0-9]+/)) {
total = substr(total_line, RSTART, RLENGTH)
total_line = substr(total_line, RSTART + RLENGTH)
}
if (total != "") {
print total
found = 1
exit
}
}
/Verdict:/ && /[Ss]core[[:space:]]*[:=]?[[:space:]]*[0-9]+[.][0-9]+/ {
verdict = $0
sub(/^.*[Ss]core[[:space:]]*[:=]?[[:space:]]*/, "", verdict)
if (match(verdict, /^[0-9]+[.][0-9]+/)) {
verdict = substr(verdict, RSTART, RLENGTH)
} else {
verdict = ""
}
}
END {
if (!found) print (verdict != "" ? verdict : "0.0")
}
' "$file" 2>/dev/null
}
score_passes() {
@@ -241,8 +263,12 @@ done
phase "PHASE 3: Build Report"
FINAL_SCORE="${SCORES[-1]:-0.0}"
NUM_ITERATIONS=${#SCORES[@]}
if [ "$NUM_ITERATIONS" -gt 0 ]; then
FINAL_SCORE="${SCORES[$((NUM_ITERATIONS - 1))]}"
else
FINAL_SCORE="0.0"
fi
ELAPSED=$(elapsed)
# Build score progression table
+32 -10
View File
@@ -26,6 +26,7 @@ const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const { extractCommandSubstitutions, extractSubshellGroups, extractBraceGroups } = require('../lib/shell-substitution');
const { stripHeredocBodies } = require('./gateguard-heredoc');
// Session state — scoped per session to avoid cross-session races.
const STATE_DIR = process.env.GATEGUARD_STATE_DIR || path.join(process.env.HOME || process.env.USERPROFILE || '/tmp', '.gateguard');
@@ -41,6 +42,10 @@ const MAX_SESSION_KEYS = 50;
const ROUTINE_BASH_SESSION_KEY = '__bash_session__';
const EDIT_WRITE_HOOK_ID = 'pre:edit-write:gateguard-fact-force';
const BASH_HOOK_ID = 'pre:bash:gateguard-fact-force';
const EDIT_WRITE_NARROW_RECOVERY_HINT =
'Narrow recovery: add a matching path glob to `GATEGUARD_EXEMPT_GLOBS` to skip first-touch Edit/Write checks without disabling destructive Bash checks.';
const ROUTINE_BASH_NARROW_RECOVERY_HINT =
'Narrow recovery: set `GATEGUARD_BASH_ROUTINE_DISABLED=1`; destructive Bash checks remain active.';
const ECC_DISABLE_VALUES = new Set(['0', 'false', 'off', 'disabled', 'disable']);
const ECC_ENABLE_VALUES = new Set(['1', 'true', 'on', 'enabled', 'enable', 'yes']);
@@ -672,7 +677,8 @@ function isDestructiveBash(command) {
// after quoting AND subshell delimiters are normalized so phrases
// inside `$(...)` or backticks are also caught.
const raw = String(command || '');
const flattened = explodeSubshells(stripQuotedStrings(raw));
const executable = stripHeredocBodies(raw);
const flattened = explodeSubshells(stripQuotedStrings(executable));
if (DESTRUCTIVE_SQL_DD.test(flattened)) return true;
// Operator-supplied additional destructive patterns. Same scope as the
@@ -687,7 +693,7 @@ function isDestructiveBash(command) {
// isDestructiveFindExec would turn `find . -exec 'rm' {} \;` into `find . -exec {} \;`
// — the binary name disappears and the check returns false. Using raw body text avoids
// that false-negative while also catching `&&`, `;`, `|`, and `||` compound forms.
const bodies = collectExecutableBodies(raw);
const bodies = collectExecutableBodies(executable);
for (const body of bodies) {
for (const rawSeg of body
.split(/[;|&]+/)
@@ -709,7 +715,7 @@ function isDestructiveBash(command) {
// Quote-aware pass: closes the quoted-command-word, newline-separator,
// quoted-find-exec, and sh/bash -c bypasses (GHSA-4v57-ph3x-gf55).
if (isDestructiveQuoteAware(raw)) return true;
if (isDestructiveQuoteAware(executable)) return true;
return false;
}
@@ -1095,7 +1101,7 @@ function condensedGateMsg(action, filePath, ordinal) {
return (
`[Fact-Forcing Gate] (denial #${ordinal} this session) First ${action} of ${safe}: ` +
"briefly state importers/callers, affected API, data schemas if any, and the user's verbatim instruction, then retry. " +
'(ECC_GATEGUARD=off disables this gate.)'
'(Use GATEGUARD_EXEMPT_GLOBS for path-scoped exemptions; ECC_GATEGUARD=off disables this gate.)'
);
}
@@ -1126,9 +1132,15 @@ function routineBashMsg() {
].join('\n');
}
function withRecoveryHint(message, hookIds = [EDIT_WRITE_HOOK_ID]) {
function withRecoveryHint(message, hookIds = [EDIT_WRITE_HOOK_ID], narrowRecoveryHint = '') {
const disableTargets = hookIds.map(hookId => `\`${hookId}\``).join(' or ');
return [message, '', `Recovery: if GateGuard is blocking setup or repair work, run this session with \`ECC_GATEGUARD=off\` or add ${disableTargets} to \`ECC_DISABLED_HOOKS\`.`].join('\n');
const recoveryLines = narrowRecoveryHint ? [narrowRecoveryHint, ''] : [];
return [
message,
'',
...recoveryLines,
`Recovery: if GateGuard is blocking setup or repair work, run this session with \`ECC_GATEGUARD=off\` or add ${disableTargets} to \`ECC_DISABLED_HOOKS\`.`
].join('\n');
}
function isSubagentInvocation(data) {
@@ -1146,12 +1158,15 @@ function isSubagentInvocation(data) {
function denyResult(reason, options = {}) {
const includeRecoveryHint = options.includeRecoveryHint !== false;
const hookIds = Array.isArray(options.hookIds) && options.hookIds.length > 0 ? options.hookIds : [EDIT_WRITE_HOOK_ID];
const narrowRecoveryHint = typeof options.narrowRecoveryHint === 'string' ? options.narrowRecoveryHint : '';
return {
stdout: JSON.stringify({
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'deny',
permissionDecisionReason: includeRecoveryHint ? withRecoveryHint(reason, hookIds) : reason
permissionDecisionReason: includeRecoveryHint
? withRecoveryHint(reason, hookIds, narrowRecoveryHint)
: reason
}
}),
exitCode: 0
@@ -1208,7 +1223,9 @@ function run(rawInput) {
const action = toolName === 'Edit' ? 'edit' : 'creation';
return denyResult(condensedGateMsg(action, filePath, denials), { includeRecoveryHint: false });
}
return denyResult(toolName === 'Edit' ? editGateMsg(filePath) : writeGateMsg(filePath));
return denyResult(toolName === 'Edit' ? editGateMsg(filePath) : writeGateMsg(filePath), {
narrowRecoveryHint: EDIT_WRITE_NARROW_RECOVERY_HINT
});
}
return rawInput; // allow
@@ -1230,7 +1247,9 @@ function run(rawInput) {
if (denials > getFullDenialBudget()) {
return denyResult(condensedGateMsg('edit', filePath, denials), { includeRecoveryHint: false });
}
return denyResult(editGateMsg(filePath));
return denyResult(editGateMsg(filePath), {
narrowRecoveryHint: EDIT_WRITE_NARROW_RECOVERY_HINT
});
}
}
return rawInput; // allow
@@ -1266,7 +1285,10 @@ function run(rawInput) {
if (!markChecked(ROUTINE_BASH_SESSION_KEY)) {
return allowWithStateWarning();
}
return denyResult(routineBashMsg(), { hookIds: [BASH_HOOK_ID] });
return denyResult(routineBashMsg(), {
hookIds: [BASH_HOOK_ID],
narrowRecoveryHint: ROUTINE_BASH_NARROW_RECOVERY_HINT
});
}
return rawInput; // allow
+258
View File
@@ -0,0 +1,258 @@
'use strict';
const { extractCommandSubstitutions } = require('../lib/shell-substitution');
/**
* Recognize the deliberately narrow passive sink supported by this parser.
* Shell operators and substitutions make the payload's destination ambiguous,
* so every other form retains the original input for fail-closed checks.
*
* @param {string} line
* @returns {boolean}
*/
function isProvenPassiveHeredocLine(line) {
const trimmed = line.trim();
return /^cat(?=\s|[<>])/.test(trimmed) && !/[;&|()`]/.test(trimmed);
}
/**
* Parse a heredoc delimiter after a verified `<<` operator.
*
* @param {string} line
* @param {number} operatorIndex
* @returns {{ heredoc: { delimiter: string, quoted: boolean, stripTabs: boolean }, endIndex: number } | null}
*/
function parseHeredocDelimiter(line, operatorIndex) {
let endIndex = operatorIndex + 2;
const stripTabs = line[endIndex] === '-';
if (stripTabs) endIndex += 1;
while (endIndex < line.length && /[ \t]/.test(line[endIndex])) endIndex += 1;
let delimiter = '';
let quoted = false;
const delimiterQuote = line[endIndex] === '"' || line[endIndex] === "'" ? line[endIndex] : null;
if (delimiterQuote) {
quoted = true;
const closingQuote = line.indexOf(delimiterQuote, endIndex + 1);
if (closingQuote < 0) return null;
delimiter = line.slice(endIndex + 1, closingQuote);
endIndex = closingQuote;
} else {
const match = line.slice(endIndex).match(/^[A-Za-z_][A-Za-z0-9_]*/);
if (!match) return null;
delimiter = match[0];
endIndex += delimiter.length - 1;
}
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(delimiter)) return null;
const next = line[endIndex + 1];
if (next && !/[\s;&|<>()]/.test(next)) return null;
return { heredoc: { delimiter, quoted, stripTabs }, endIndex };
}
/**
* Iterate over simple heredoc redirections on one complete shell command line.
* A null item marks ambiguous syntax so the caller can fail closed.
*
* @param {string} line
* @returns {Generator<{ delimiter: string, quoted: boolean, stripTabs: boolean } | null>}
*/
function* iterateHeredocs(line) {
let quote = null;
let escaped = false;
for (let i = 0; i < line.length; i += 1) {
const ch = line[i];
if (quote === "'") {
if (ch === "'") quote = null;
continue;
}
if (escaped) {
escaped = false;
continue;
}
if (ch === '\\') {
escaped = true;
continue;
}
if (quote === '"') {
if (ch === quote) quote = null;
continue;
}
if (ch === '"' || ch === "'") {
quote = ch;
continue;
}
if ((ch === '$' && line[i + 1] === '(' && line[i + 2] === '(') || (ch === '(' && line[i + 1] === '(')) {
yield null;
return;
}
if (ch === '$' && line[i + 1] === '[') {
yield null;
return;
}
if (ch === '#' && (i === 0 || /[\s;&|()]/.test(line[i - 1]))) break;
if (ch !== '<' || line[i + 1] !== '<') continue;
if (line[i + 2] === '<') {
yield null;
return;
}
const prefix = line.slice(0, i);
if (prefix.includes('((') || prefix.includes('[[')) {
yield null;
return;
}
const parsed = parseHeredocDelimiter(line, i);
if (!parsed) {
yield null;
return;
}
yield parsed.heredoc;
i = parsed.endIndex;
}
if (quote || escaped) yield null;
}
/**
* Find simple heredoc redirections on one complete shell command line.
* Anything ambiguous returns null so the caller can fail closed.
*
* @param {string} line
* @returns {{ delimiter: string, quoted: boolean, stripTabs: boolean }[] | null}
*/
function findHeredocs(line) {
const heredocs = [...iterateHeredocs(line)];
return heredocs.includes(null) ? null : heredocs;
}
/** @returns {boolean} */
function hasLineContinuation(line) {
const trailing = line.match(/\\+$/);
return Boolean(trailing && trailing[0].length % 2 === 1);
}
/** @returns {string} */
function normalizeUnquotedHeredocLines(lines, stripTabs = false) {
const logical = lines
.map((line, index) => {
const normalized = stripTabs ? line.replace(/^\t+/, '') : line;
if (index === lines.length - 1) return normalized;
return hasLineContinuation(normalized) ? normalized.slice(0, -1) : `${normalized}\n`;
})
.join('');
return logical;
}
/** @returns {{ text: string, nextIndex: number }} */
function readHeredocLine(lines, startIndex, quoted, stripTabs) {
if (quoted) {
const text = stripTabs ? lines[startIndex].replace(/^\t+/, '') : lines[startIndex];
return { text, nextIndex: startIndex + 1 };
}
let endIndex = startIndex;
while (endIndex < lines.length - 1 && hasLineContinuation(lines[endIndex])) endIndex += 1;
const text = normalizeUnquotedHeredocLines(lines.slice(startIndex, endIndex + 1), stripTabs);
return { text, nextIndex: endIndex + 1 };
}
/**
* Extract executable substitutions from an unquoted heredoc. Quote characters
* in its payload are literal and do not suppress expansion.
*
* @param {string[]} body
* @returns {string[]}
*/
function extractHeredocCommandSubstitutions(body, stripTabs) {
const text = normalizeUnquotedHeredocLines(body, stripTabs);
return [...new Set(extractCommandSubstitutions(text, { literalOuterQuotes: true }))];
}
/**
* Consume one heredoc body and return its immutable parser result.
*
* @param {string[]} lines
* @param {number} startIndex
* @param {{ delimiter: string, quoted: boolean, stripTabs: boolean }} heredoc
* @returns {{ nextIndex: number, substitutions: string[] } | null}
*/
function consumeHeredocBody(lines, startIndex, heredoc) {
let lineIndex = startIndex;
while (lineIndex < lines.length) {
const logical = readHeredocLine(lines, lineIndex, heredoc.quoted, heredoc.stripTabs);
if (logical.text !== heredoc.delimiter) {
lineIndex = logical.nextIndex;
continue;
}
const body = lines.slice(startIndex, lineIndex);
const substitutions = heredoc.quoted ? [] : extractHeredocCommandSubstitutions(body, heredoc.stripTabs);
return { nextIndex: logical.nextIndex, substitutions };
}
return null;
}
/**
* @param {string[]} lines
* @param {number} startIndex
* @param {{ delimiter: string, quoted: boolean, stripTabs: boolean }[]} heredocs
* @returns {{ nextIndex: number, chunks: object | null } | null}
*/
function consumeHeredocBodies(lines, startIndex, heredocs) {
let state = { nextIndex: startIndex, chunks: null };
for (const heredoc of heredocs) {
const consumed = consumeHeredocBody(lines, state.nextIndex, heredoc);
if (!consumed) return null;
state = {
nextIndex: consumed.nextIndex,
chunks: consumed.substitutions.length === 0 ? state.chunks : { substitutions: consumed.substitutions, previous: state.chunks }
};
}
return state;
}
/** @returns {Generator<string>} */
function* iterateSubstitutionChunks(chunks) {
let ordered = null;
for (let chunk = chunks; chunk; chunk = chunk.previous) {
ordered = { substitutions: chunk.substitutions, next: ordered };
}
for (let chunk = ordered; chunk; chunk = chunk.next) {
yield* chunk.substitutions;
}
}
/**
* Remove heredoc payload text before classifying the surrounding shell
* command. Prose in a heredoc is data, so matching it as a command produces
* false positives. Unquoted heredocs can still execute `$()` and backtick
* substitutions; retain only those substitution bodies for classification and
* drop the remaining payload text. Quoted heredoc payloads are fully inert.
* Ambiguous shell syntax returns the original input unchanged (fail closed).
*
* @param {string} input
* @returns {string}
*/
function stripHeredocBodies(input) {
const raw = String(input || '');
const lines = raw.split(/\r?\n/);
let headerIndex = -1;
let pending = [];
for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
const line = lines[lineIndex];
const heredocs = findHeredocs(line);
if (heredocs === null) return raw;
if (heredocs.length > 0 && !isProvenPassiveHeredocLine(line)) return raw;
if (heredocs.length > 0) {
pending = heredocs;
headerIndex = lineIndex;
break;
}
}
if (headerIndex < 0) return lines.join('\n');
const consumed = consumeHeredocBodies(lines, headerIndex + 1, pending);
if (!consumed) return raw;
const trailing = lines.slice(consumed.nextIndex);
if (trailing.some(line => line.trim())) return raw;
const substitutions = iterateSubstitutionChunks(consumed.chunks);
return [...lines.slice(0, headerIndex + 1), ...substitutions, ...trailing].join('\n');
}
module.exports = { stripHeredocBodies };
+9 -4
View File
@@ -34,7 +34,7 @@ const {
} = require('../lib/utils');
const {
readLatestContextTokens,
resolveContextWindowTokens,
resolveContextWindow,
resolveContextThreshold,
resolveContextInterval,
computeContextBucket,
@@ -171,7 +171,7 @@ function buildContextSuggestion(transcriptPath, bucketFile, env) {
const usage = readLatestContextTokens(transcriptPath);
if (!usage) return null;
const windowTokens = resolveContextWindowTokens(usage.tokens, usage.model);
const { windowTokens, inferred } = resolveContextWindow(usage.tokens, usage.model);
const threshold = resolveContextThreshold(env, windowTokens);
if (threshold <= 0) return null; // COMPACT_CONTEXT_THRESHOLD=0 disables
@@ -185,8 +185,13 @@ function buildContextSuggestion(transcriptPath, bucketFile, env) {
writeFile(bucketFile, String(bucket));
const approxTokens = `${Math.round(usage.tokens / 1000)}k`;
const percent = Math.round((usage.tokens / windowTokens) * 100);
return `[StrategicCompact] Context ~${approxTokens} tokens (${percent}% of ${formatWindowLabel(windowTokens)} window) - consider /compact at the next logical boundary`;
// Only quote a percentage when the window size was actually detected.
// Against an assumed 200k default the denominator is a guess, and a
// "97% of 200k window" line on a 1M session triggers needless compaction.
const scale = inferred
? ''
: ` (${Math.round((usage.tokens / windowTokens) * 100)}% of ${formatWindowLabel(windowTokens)} window)`;
return `[StrategicCompact] Context ~${approxTokens} tokens${scale} - consider /compact at the next logical boundary`;
} catch (err) {
log(`[StrategicCompact] Context signal skipped: ${err.message}`);
return null;
+1
View File
@@ -47,6 +47,7 @@ Targets:
hermes - Install shared rules/skills/commands into ~/.hermes/
kimi - Install Kimi Code project instructions, skills, and MCP config into ./.kimi-code/ (ECC hooks not configured)
openclaw - Install shared rules/skills/commands into ~/.openclaw/
adal - Install shared rules/skills/commands into ./.adal/
Options:
--profile <name> Resolve and install a manifest profile
+13
View File
@@ -201,6 +201,19 @@ const HARNESS_CAPABILITIES = deepFreeze([
hooks: hooks('not-configured', false, 'ECC hooks are not configured by this adapter.'),
aliases: [],
},
{
id: 'adal',
label: 'AdaL CLI',
targetIds: ['adal'],
channel: 'managed-project',
installMode: 'managed-project',
guidedReady: false,
availability: 'advanced',
destination: './.adal',
scopes: [scope('project', 'adal', './.adal')],
hooks: hooks('not-configured', false, 'ECC hooks are not configured by this adapter.'),
aliases: ['adal-cli'],
},
{
id: 'hermes',
label: 'Hermes',
+8 -1
View File
@@ -5,7 +5,7 @@ const { getInstallTargetAdapter, planInstallTargetScaffold } = require('./instal
const { resolveInvocationEnvironment } = require('./invocation-environment');
const DEFAULT_REPO_ROOT = path.join(__dirname, '../..');
const SUPPORTED_INSTALL_TARGETS = ['claude', 'claude-project', 'cursor', 'antigravity', 'codex', 'gemini', 'opencode', 'codebuddy', 'joycode', 'qwen', 'zed', 'hermes', 'openclaw', 'kimi'];
const SUPPORTED_INSTALL_TARGETS = ['claude', 'claude-project', 'cursor', 'antigravity', 'codex', 'gemini', 'opencode', 'codebuddy', 'joycode', 'qwen', 'zed', 'hermes', 'openclaw', 'kimi', 'adal'];
const COMPONENT_FAMILY_PREFIXES = {
baseline: 'baseline:',
language: 'lang:',
@@ -99,6 +99,13 @@ const LEGACY_COMPAT_BASE_MODULE_IDS_BY_TARGET = Object.freeze({
'platform-configs',
'workflow-quality',
],
adal: [
'rules-core',
'agents-core',
'commands-core',
'platform-configs',
'workflow-quality',
],
});
const LEGACY_LANGUAGE_ALIAS_TO_CANONICAL = Object.freeze({
c: 'c',
@@ -0,0 +1,10 @@
const { createInstallTargetAdapter } = require('./helpers');
module.exports = createInstallTargetAdapter({
id: 'adal-project',
target: 'adal',
kind: 'project',
rootSegments: ['.adal'],
installStatePathSegments: ['ecc-install-state.json'],
nativeRootRelativePath: '.adal',
});
+1
View File
@@ -16,6 +16,7 @@ const PLATFORM_SOURCE_PATH_OWNERS = Object.freeze({
'.codebuddy': 'codebuddy',
'.qwen': 'qwen',
'.zed': 'zed',
'.adal': 'adal',
});
function normalizeRelativePath(relativePath) {
+2
View File
@@ -1,3 +1,4 @@
const adalProject = require('./adal-project');
const antigravityProject = require('./antigravity-project');
const claudeHome = require('./claude-home');
const claudeProject = require('./claude-project');
@@ -29,6 +30,7 @@ const ADAPTERS = Object.freeze([
kimiProject,
qwenHome,
zedProject,
adalProject,
]);
function listInstallTargetAdapters() {
+76 -105
View File
@@ -1,126 +1,97 @@
'use strict';
/**
* Extract executable command-substitution bodies from a shell line.
*
* Single quotes are literal, so substitutions inside them are ignored;
* double quotes still permit substitutions, so those bodies are scanned
* before quoted text is stripped. Returns each substitution body plus
* any nested substitutions discovered recursively.
*
* Originally introduced in scripts/hooks/gateguard-fact-force.js
* (PR #1853 round 2). Extracted to a shared lib so other PreToolUse
* hooks that need the same "scan inside `$(...)` and backticks"
* behavior can reuse it without duplicating the parser.
*
* @param {string} input
* @returns {string[]}
*/
function extractCommandSubstitutions(input) {
const source = String(input || '');
const substitutions = [];
/** @returns {{ body: string, endIndex: number }} */
function readBacktickSubstitution(source, startIndex) {
let body = '';
let endIndex = startIndex + 1;
while (endIndex < source.length) {
const inner = source[endIndex];
if (inner === '\\') {
const escaped = source[endIndex + 1];
body = escaped === undefined ? `${body}\\` : `${body}\\${escaped}`;
endIndex += escaped === undefined ? 1 : 2;
continue;
}
if (inner === '`') break;
body = `${body}${inner}`;
endIndex += 1;
}
return { body, endIndex };
}
/** @returns {{ body: string, endIndex: number }} */
function readDollarSubstitution(source, startIndex) {
let body = '';
let depth = 1;
let inSingle = false;
let inDouble = false;
let endIndex = startIndex + 2;
while (endIndex < source.length && depth > 0) {
const inner = source[endIndex];
if (inner === '\\' && !inSingle) {
const escaped = source[endIndex + 1];
body = escaped === undefined ? `${body}\\` : `${body}\\${escaped}`;
endIndex += escaped === undefined ? 1 : 2;
continue;
}
if (inner === "'" && !inDouble) inSingle = !inSingle;
else if (inner === '"' && !inSingle) inDouble = !inDouble;
else if (!inSingle && !inDouble && inner === '(') depth += 1;
else if (!inSingle && !inDouble && inner === ')') depth -= 1;
if (depth > 0) body = `${body}${inner}`;
endIndex += depth > 0 ? 1 : 0;
}
return { body, endIndex };
}
for (let i = 0; i < source.length; i++) {
/**
* Iterate over command-substitution bodies, followed by nested bodies.
* Quote characters in an unquoted heredoc are literal only at the outer level;
* substitutions still use normal shell quote semantics internally.
*
* @param {string} input
* @param {{ literalOuterQuotes?: boolean }} [options]
* @returns {Generator<string>}
*/
function* iterateCommandSubstitutions(input, options = {}) {
const source = String(input || '');
const literalOuterQuotes = options.literalOuterQuotes === true;
let inSingle = false;
let inDouble = false;
for (let i = 0; i < source.length; i += 1) {
const ch = source[i];
const prev = source[i - 1];
if (ch === '\\' && !inSingle) {
i += 1;
continue;
}
if (ch === "'" && !inDouble && prev !== '\\') {
if (!literalOuterQuotes && ch === "'" && !inDouble) {
inSingle = !inSingle;
continue;
}
if (ch === '"' && !inSingle && prev !== '\\') {
if (!literalOuterQuotes && ch === '"' && !inSingle) {
inDouble = !inDouble;
continue;
}
if (inSingle) {
continue;
}
if (ch === '`') {
let body = '';
i += 1;
while (i < source.length) {
const inner = source[i];
if (inner === '\\') {
body += inner;
if (i + 1 < source.length) {
body += source[i + 1];
i += 2;
} else {
// Trailing backslash at end of an unterminated span: advance past
// it so it is not appended a second time by the fallthrough below.
i += 1;
}
continue;
}
if (inner === '`') {
break;
}
body += inner;
i += 1;
}
if (body.trim()) {
substitutions.push(body);
substitutions.push(...extractCommandSubstitutions(body));
}
continue;
}
if (ch === '$' && source[i + 1] === '(') {
let depth = 1;
let body = '';
let bodyInSingle = false;
let bodyInDouble = false;
i += 2;
while (i < source.length && depth > 0) {
const inner = source[i];
const innerPrev = source[i - 1];
if (inner === '\\' && !bodyInSingle) {
body += inner;
if (i + 1 < source.length) {
body += source[i + 1];
i += 2;
} else {
// Trailing backslash at end of an unterminated span: advance past
// it so it is not appended a second time by the fallthrough below.
i += 1;
}
continue;
}
if (inner === "'" && !bodyInDouble && innerPrev !== '\\') {
bodyInSingle = !bodyInSingle;
} else if (inner === '"' && !bodyInSingle && innerPrev !== '\\') {
bodyInDouble = !bodyInDouble;
} else if (!bodyInSingle && !bodyInDouble) {
if (inner === '(') {
depth += 1;
} else if (inner === ')') {
depth -= 1;
if (depth === 0) {
break;
}
}
}
body += inner;
i += 1;
}
if (body.trim()) {
substitutions.push(body);
substitutions.push(...extractCommandSubstitutions(body));
}
}
if (inSingle) continue;
const span = ch === '`' ? readBacktickSubstitution(source, i) : null;
const substitution = ch === '$' && source[i + 1] === '(' ? readDollarSubstitution(source, i) : span;
if (!substitution) continue;
i = substitution.endIndex;
if (!substitution.body.trim()) continue;
yield substitution.body;
yield* iterateCommandSubstitutions(substitution.body);
}
}
return substitutions;
/**
* Extract executable command-substitution bodies from a shell line.
*
* @param {string} input
* @param {{ literalOuterQuotes?: boolean }} [options]
* @returns {string[]}
*/
function extractCommandSubstitutions(input, options = {}) {
return [...iterateCommandSubstitutions(input, options)];
}
/**
+41 -11
View File
@@ -158,24 +158,30 @@ function readLatestContextTokens(transcriptPath, options = {}) {
}
/**
* Detect the context window size for a turn.
* 1M when the model id carries the `[1m]` marker, matches a known large-window
* model family, or when the observed token count already exceeds the standard
* 200k window (covers logs that drop the suffix); otherwise the standard 200k
* window.
* Detect the context window size for a turn, and report whether that size was
* positively detected or merely assumed.
*
* `inferred: false` means the size came from evidence an explicit env
* override, the `[1m]` marker, or a known large-window family. An observed
* token count above the standard window selects the safer large-window
* thresholds, but remains inferred because the true denominator could be an
* unmarked intermediate size such as 400k. Callers must not present inferred
* windows as fact.
*
* @returns {{ windowTokens: number, inferred: boolean }}
*/
function resolveContextWindowTokens(tokens, model) {
function resolveContextWindow(tokens, model) {
// Explicit window override wins: 400k models (e.g. Opus 4.x) match neither the
// 200k default nor the 1M marker and would otherwise report ~double usage (#2290).
// Honor ECC's own knob and Claude Code's native CLAUDE_CODE_AUTO_COMPACT_WINDOW.
const env = (typeof process !== 'undefined' && process.env) || {};
const envWindow = Number.parseInt(env.ECC_CONTEXT_WINDOW_TOKENS || env.CLAUDE_CODE_AUTO_COMPACT_WINDOW || '', 10);
if (Number.isInteger(envWindow) && envWindow > 0) {
return envWindow;
return { windowTokens: envWindow, inferred: false };
}
if (typeof model === 'string' && model.includes(LARGE_WINDOW_MODEL_MARKER)) {
return LARGE_CONTEXT_WINDOW_TOKENS;
return { windowTokens: LARGE_CONTEXT_WINDOW_TOKENS, inferred: false };
}
// Large-window model families without a [1m] marker fall through the checks
@@ -183,15 +189,37 @@ function resolveContextWindowTokens(tokens, model) {
if (typeof model === 'string') {
const known = KNOWN_MODEL_WINDOW_TOKENS.find(([familyId]) => isKnownModelFamilyMatch(model, familyId));
if (known) {
return known[1];
return { windowTokens: known[1], inferred: false };
}
}
if (Number.isFinite(tokens) && tokens > STANDARD_CONTEXT_WINDOW_TOKENS) {
return LARGE_CONTEXT_WINDOW_TOKENS;
return { windowTokens: LARGE_CONTEXT_WINDOW_TOKENS, inferred: true };
}
return STANDARD_CONTEXT_WINDOW_TOKENS;
return { windowTokens: STANDARD_CONTEXT_WINDOW_TOKENS, inferred: true };
}
/**
* Detect the context window size for a turn.
* 1M when the model id carries the `[1m]` marker, matches a known large-window
* model family, or when the observed token count already exceeds the standard
* 200k window (covers logs that drop the suffix); otherwise the standard 200k
* window.
*/
function resolveContextWindowTokens(tokens, model) {
return resolveContextWindow(tokens, model).windowTokens;
}
/**
* True when the resolved window is the assumed 200k default rather than a
* detected size. Opt-in large-window models that ship no `[1m]` marker in the
* transcript (e.g. a 1M-context Opus tier, where the base tier is 200k and the
* two are indistinguishable by model id) land here, so a percentage computed
* against 200k can be wildly wrong while usage sits below that mark.
*/
function isContextWindowInferred(tokens, model) {
return resolveContextWindow(tokens, model).inferred;
}
/**
@@ -254,7 +282,9 @@ module.exports = {
DEFAULT_CONTEXT_INTERVAL_TOKENS,
DEFAULT_TRANSCRIPT_TAIL_BYTES,
readLatestContextTokens,
resolveContextWindow,
resolveContextWindowTokens,
isContextWindowInferred,
resolveContextThreshold,
resolveContextInterval,
computeContextBucket,
+49
View File
@@ -106,6 +106,55 @@ near-identical blocks cannot accumulate in the context window and
amplify model repetition loops (#2142). Retrying the same file or
command after presenting facts never re-triggers the gate.
#### Graduated controls
`ECC_GATEGUARD=off` (or `GATEGUARD_DISABLED=1`) turns the gate off entirely.
The variables in this table do **not** — each narrows one behaviour while the
load-bearing destructive-Bash checks keep running:
| Variable | Default | Effect |
|---|---|---|
| `GATEGUARD_BASH_ROUTINE_DISABLED` | unset (gate on) | Disables the **routine-Bash** gate only. The destructive-Bash gate (`rm -rf`, `git reset --hard`, `drop table`, `dd if=`, …) is unaffected. |
| `GATEGUARD_EXEMPT_GLOBS` | unset (no exemptions) | Comma-separated globs; a matching Edit/Write/MultiEdit target skips first-touch fact-forcing. Intended for low-import-value trees (tests, generated artifacts, scratch dirs) where "who imports this / what schema" carries no signal. |
| `GATEGUARD_FACT_FORCE_FULL_DENIALS` | `3` | How many denials emit the full four-fact block before later ones condense to a single line. `0` condenses from the very first denial. |
| `GATEGUARD_BASH_EXTRA_DESTRUCTIVE` | unset | Extra destructive-command patterns, as regex source, added to the built-in set. A malformed regex is treated as unset (built-ins still apply) and logged once to stderr. |
| `GATEGUARD_STATE_DIR` | `~/.gateguard` | Where per-session gate state is kept. If state cannot be persisted the gate allows the operation rather than looping, and names this variable in the warning. |
`GATEGUARD_BASH_ROUTINE_DISABLED` accepts `1`, `true`, `on`, `enabled`,
`enable`, or `yes` (case- and whitespace-insensitive); any other value
leaves the gate on.
#### Turning the gate off completely
| Variable | Effect |
|---|---|
| `ECC_GATEGUARD=off` | Disables GateGuard for the session. Accepts `0`, `false`, `off`, `disabled`, or `disable`. |
| `GATEGUARD_DISABLED=1` | Same effect. Recognises `1` only — the spellings above do **not** apply here. |
For hook-level control, keep using `ECC_DISABLED_HOOKS` with the GateGuard hook ID.
#### Glob semantics for `GATEGUARD_EXEMPT_GLOBS`
Patterns are matched, unanchored, against the target path with backslashes
normalized to `/` and the whole string lowercased — the path exactly as the
hook receives it, which for Claude Code tool payloads is absolute. `*` matches
within a path segment, `**` across segments, `?` a single character. Matching
is fail-open: a malformed pattern is dropped rather than raising.
Note that a leading `**/` compiles to `.*/`, so it requires at least one
preceding separator: `**/tests/**` exempts `/repo/tests/foo.js` but would not
match a bare relative `tests/foo.js`. Add the separator-free form too if you
pass relative paths:
```json
{
"env": {
"GATEGUARD_BASH_ROUTINE_DISABLED": "1",
"GATEGUARD_EXEMPT_GLOBS": "**/tests/**,tests/**,**/*.test.*,**/docs/**,**/dist/**"
}
}
```
### Option B: Full package with config
```bash
+1
View File
@@ -8,6 +8,7 @@ dependencies = ["pyyaml>=6.0"]
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["."]
markers = ["unit: isolated tests without external services"]
[dependency-groups]
dev = [
+63 -12
View File
@@ -24,6 +24,7 @@ ALLOWED_SETUP_EXECUTABLES = frozenset({
# controlled by the cwd= keyword. Scenarios that include these in
# setup_commands (a common shell-style convention) must be tolerated.
SHELL_BUILTINS = frozenset({"cd", "pushd", "popd"})
REPORT_VALUE_LIMIT = 5000
@dataclass(frozen=True)
@@ -122,6 +123,66 @@ def _setup_sandbox(sandbox_dir: Path, scenario: Scenario) -> None:
continue
def _redact_home_path(text: str) -> str:
"""Replace the operator's home directory with a portable placeholder.
Observations flow into grade() and then into a written report
(results/<skill>.md) that's meant to be read, diffed, and shared —
an absolute path bakes the operator's username into every tool call
that happened to touch anything under $HOME (including the sandbox
itself, which lives under a tempdir but scenario setup_commands or
an agent's own tool calls can still reference $HOME directly).
"""
home = str(Path.home()).rstrip("/\\")
if not home or home == "/" or re.fullmatch(r"[A-Za-z]:", home):
return text
parts = re.split(r"[\\/]+", home)
home_pattern = r"[\\/]".join(re.escape(part) for part in parts)
right_boundary = r"(?=$|[\\/]|[\s\"'`,;:)}\]])"
flags = re.IGNORECASE if re.match(r"^[A-Za-z]:[\\/]", home) else 0
pattern = re.compile(
rf"(?<![\w.~+-]){home_pattern}{right_boundary}",
flags,
)
return pattern.sub("~", text)
def _redact_home_paths(value: object) -> object:
"""Return a copy with home paths redacted from string keys and leaves.
Redacted mapping keys receive a stable numeric suffix when two original
keys collapse to the same portable value. This preserves every observation
without leaking the original home path or silently dropping data.
"""
if isinstance(value, str):
return _redact_home_path(value)
if isinstance(value, dict):
redacted: dict[object, object] = {}
for key, item in value.items():
redacted_key = _redact_home_path(key) if isinstance(key, str) else key
candidate = redacted_key
suffix = 2
while candidate in redacted:
candidate = f"{redacted_key}#{suffix}"
suffix += 1
redacted[candidate] = _redact_home_paths(item)
return redacted
if isinstance(value, list):
return [_redact_home_paths(item) for item in value]
return value
def _serialize_report_value(value: object) -> str:
"""Redact structured report data before encoding and truncating it."""
redacted = _redact_home_paths(value)
if isinstance(redacted, (dict, list)):
serialized = json.dumps(redacted)
else:
serialized = str(redacted)
return serialized[:REPORT_VALUE_LIMIT]
def _parse_stream_json(stdout: str) -> list[ObservationEvent]:
"""Parse claude -p stream-json output into ObservationEvents.
@@ -147,14 +208,9 @@ def _parse_stream_json(stdout: str) -> list[ObservationEvent]:
if block.get("type") == "tool_use":
tool_use_id = block.get("id", "")
tool_input = block.get("input", {})
input_str = (
json.dumps(tool_input)[:5000]
if isinstance(tool_input, dict)
else str(tool_input)[:5000]
)
pending[tool_use_id] = {
"tool": block.get("name", "unknown"),
"input": input_str,
"input": _serialize_report_value(tool_input),
"order": event_counter,
}
event_counter += 1
@@ -167,18 +223,13 @@ def _parse_stream_json(stdout: str) -> list[ObservationEvent]:
if tool_use_id in pending:
info = pending.pop(tool_use_id)
output_content = block.get("content", "")
if isinstance(output_content, list):
output_str = json.dumps(output_content)[:5000]
else:
output_str = str(output_content)[:5000]
events.append(ObservationEvent(
timestamp=f"T{info['order']:04d}",
event="tool_complete",
tool=info["tool"],
session=msg.get("session_id", "unknown"),
input=info["input"],
output=output_str,
output=_serialize_report_value(output_content),
))
for _tool_use_id, info in pending.items():
+147 -3
View File
@@ -2,13 +2,14 @@
from __future__ import annotations
import json
import subprocess
from dataclasses import dataclass
from unittest.mock import MagicMock, patch
from pathlib import Path
from unittest.mock import patch
import pytest
from scripts.runner import _setup_sandbox, run_scenario
from scripts.runner import _parse_stream_json, _setup_sandbox, run_scenario
@dataclass(frozen=True)
@@ -143,6 +144,149 @@ class TestRunScenarioMaxTurnsTermination:
run_scenario(scenario, model="haiku")
@pytest.mark.unit
class TestParseStreamJsonRedactsHomePath:
"""Observations feed grade() and then a written report (results/<skill>.md) —
a raw absolute path bakes the operator's username into every tool call
that touched anything under $HOME. --add-dir restricts the sandbox, but
scenario setup_commands or the model's own tool calls can still reference
$HOME directly (e.g. a Bash command using ~ expansion, or a scenario that
legitimately needs to read a dotfile). Redact to a portable placeholder
rather than persisting the raw path.
"""
def _stream_json_for(self, tool_input: dict, output_content: object) -> str:
return (
'{"type":"assistant","message":{"content":[{"type":"tool_use",'
'"id":"tu1","name":"Read","input":' + json.dumps(tool_input) + "}]}}\n"
'{"type":"user","session_id":"s1","message":{"content":[{"type":'
'"tool_result","tool_use_id":"tu1","content":' + json.dumps(output_content) + "}]}}\n"
)
@staticmethod
def _set_home(monkeypatch: pytest.MonkeyPatch, home: str) -> None:
monkeypatch.setattr(Path, "home", classmethod(lambda cls: Path(home)))
def test_posix_input_string_leaves_and_embedded_paths_redacted(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
home = "/home/alice"
self._set_home(monkeypatch, home)
stdout = self._stream_json_for(
{
"command": f"cat '{home}/notes/secrets.env' && echo home={home}, done",
"nested": {"paths": [f"{home}/one", f"{home}/two"]},
},
"irrelevant output",
)
events = _parse_stream_json(stdout)
assert len(events) == 1
assert home not in events[0].input
parsed_input = json.loads(events[0].input)
assert parsed_input["command"] == "cat '~/notes/secrets.env' && echo home=~, done"
assert parsed_input["nested"]["paths"] == ["~/one", "~/two"]
def test_windows_home_with_unicode_and_backslashes_redacted(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
home = r"C:\Users\Zoë"
self._set_home(monkeypatch, home)
stdout = self._stream_json_for(
{
"paths": [
home + r"\Documents\résumé.txt",
"C:/Users/Zoë/資料.txt",
]
},
"irrelevant output",
)
events = _parse_stream_json(stdout)
assert len(events) == 1
parsed_input = json.loads(events[0].input)
assert parsed_input["paths"] == [
r"~\Documents\résumé.txt",
"~/資料.txt",
]
def test_mapping_keys_are_redacted_without_silent_collision(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
home = r"C:\Users\Zoë"
self._set_home(monkeypatch, home)
stdout = self._stream_json_for(
{
home + r"\private.txt": "first",
r"c:\users\zoë\private.txt": "second",
},
"irrelevant output",
)
events = _parse_stream_json(stdout)
parsed_input = json.loads(events[0].input)
assert home not in events[0].input
assert parsed_input == {
r"~\private.txt": "first",
r"~\private.txt#2": "second",
}
def test_sibling_and_embedded_prefix_paths_untouched(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
home = "/home/alice"
self._set_home(monkeypatch, home)
outside_paths = [
"/home/alice-old/report.txt",
"/home/alice2/report.txt",
"/tmp/home/alice/report.txt",
]
stdout = self._stream_json_for(
{"paths": outside_paths},
[{"type": "text", "text": path} for path in outside_paths],
)
events = _parse_stream_json(stdout)
assert json.loads(events[0].input)["paths"] == outside_paths
assert [item["text"] for item in json.loads(events[0].output)] == outside_paths
def test_list_output_redacts_nested_string_leaves(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
home = "/Users/reviewer"
self._set_home(monkeypatch, home)
output_content = [
{"type": "text", "text": f"created {home}/résumé.txt"},
{"type": "metadata", "paths": [home, f"{home}/資料.json"]},
]
stdout = self._stream_json_for({"file_path": "irrelevant"}, output_content)
events = _parse_stream_json(stdout)
assert json.loads(events[0].output) == [
{"type": "text", "text": "created ~/résumé.txt"},
{"type": "metadata", "paths": ["~", "~/資料.json"]},
]
def test_redacts_before_json_serialization_and_5000_character_truncation(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
home = "/home/alice"
self._set_home(monkeypatch, home)
boundary_value = "x" * 4977 + f" {home}/secret.txt" + "tail" * 20
stdout = self._stream_json_for(
{"command": boundary_value},
boundary_value,
)
events = _parse_stream_json(stdout)
assert len(events[0].input) == 5000
assert "~/secret" in events[0].input
assert "/home/" not in events[0].input
assert len(events[0].output) == 5000
assert "~/secret.txt" in events[0].output
assert "/home/" not in events[0].output
class TestRunScenarioErrorIncludesStdoutTail:
"""Error messages must include stdout tail, not only stderr.
+39 -8
View File
@@ -13,6 +13,27 @@
set -euo pipefail
sort_nul_file() {
local input_file="$1"
local sorted_file="${input_file}.sorted"
node -e '
const fs = require("fs");
const input = fs.readFileSync(0);
const records = [];
let start = 0;
for (let index = 0; index < input.length; index += 1) {
if (input[index] === 0) {
records.push(input.subarray(start, index + 1));
start = index + 1;
}
}
if (start < input.length) records.push(input.subarray(start));
records.sort(Buffer.compare);
process.stdout.write(Buffer.concat(records));
' <"$input_file" >"$sorted_file"
mv "$sorted_file" "$input_file"
}
RESULTS_JSON="${1:-}"
CWD_SKILLS_DIR="${SKILL_STOCKTAKE_PROJECT_DIR:-${2:-$PWD/.claude/skills}}"
GLOBAL_DIR="${SKILL_STOCKTAKE_GLOBAL_DIR:-$HOME/.claude/skills}"
@@ -37,9 +58,6 @@ if [[ ! "$evaluated_at" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2
exit 1
fi
# Pre-extract known paths from results.json once (O(1) lookup per file instead of O(n*m))
known_paths=$(jq -r '.skills[].path' "$RESULTS_JSON" 2>/dev/null)
tmpdir=$(mktemp -d)
# Use a function to avoid embedding $tmpdir in a quoted string (prevents injection
# if TMPDIR were crafted to contain shell metacharacters).
@@ -51,14 +69,27 @@ i=0
process_dir() {
local dir="$1"
while IFS= read -r file; do
local find_out="$tmpdir/.find-stdout"
local find_err="$tmpdir/.find-stderr"
# Capture find's exit status and stderr instead of discarding them: with -L,
# a broken symlink or unreadable directory makes find skip that entry AND
# exit non-zero, which would otherwise silently under-count skills.
# NUL-delimited (-print0 / sort_nul_file / read -d '') so a path containing a
# literal newline can't desync record boundaries — paths here are untrusted.
if ! find -L "$dir" -name "SKILL.md" -type f -print0 >"$find_out" 2>"$find_err"; then
echo "Warning: find encountered errors while scanning $dir (broken symlinks or permission issues may cause skills to be missed):" >&2
cat "$find_err" >&2
fi
sort_nul_file "$find_out"
while IFS= read -r -d '' file; do
local mtime dp is_new
mtime=$(date -u -r "$file" +%Y-%m-%dT%H:%M:%SZ)
dp="${file/#$HOME/~}"
# Check if this file is known to results.json (exact whole-line match to
# avoid substring false-positives, e.g. "python-patterns" matching "python-patterns-v2").
if echo "$known_paths" | grep -qxF "$dp"; then
# Keep path comparison structured so literal newlines remain part of one
# JSON string instead of becoming ambiguous line-delimited records.
if jq -e --arg path "$dp" '.skills | any(.path == $path)' "$RESULTS_JSON" >/dev/null 2>&1; then
is_new="false"
# Known file: only emit if mtime changed (ISO 8601 string comparison is safe)
[[ "$mtime" > "$evaluated_at" ]] || continue
@@ -74,7 +105,7 @@ process_dir() {
'{path:$path,mtime:$mtime,is_new:$is_new}' \
> "$tmpdir/$i.json"
i=$((i+1))
done < <(find "$dir" -name "*.md" -type f 2>/dev/null | sort)
done < "$find_out"
}
[[ -d "$GLOBAL_DIR" ]] && process_dir "$GLOBAL_DIR"
+49 -8
View File
@@ -13,6 +13,27 @@
set -euo pipefail
sort_nul_file() {
local input_file="$1"
local sorted_file="${input_file}.sorted"
node -e '
const fs = require("fs");
const input = fs.readFileSync(0);
const records = [];
let start = 0;
for (let index = 0; index < input.length; index += 1) {
if (input[index] === 0) {
records.push(input.subarray(start, index + 1));
start = index + 1;
}
}
if (start < input.length) records.push(input.subarray(start));
records.sort(Buffer.compare);
process.stdout.write(Buffer.concat(records));
' <"$input_file" >"$sorted_file"
mv "$sorted_file" "$input_file"
}
GLOBAL_DIR="${SKILL_STOCKTAKE_GLOBAL_DIR:-$HOME/.claude/skills}"
CWD_SKILLS_DIR="${SKILL_STOCKTAKE_PROJECT_DIR:-${1:-$PWD/.claude/skills}}"
# Path to JSONL file containing tool-use observations (optional; used for usage frequency counts).
@@ -95,17 +116,37 @@ scan_dir_to_json() {
fi
local i=0
while IFS= read -r file; do
local find_out="$tmpdir/.find-stdout"
local find_err="$tmpdir/.find-stderr"
# Capture find's exit status and stderr instead of discarding them: with -L,
# a broken symlink or unreadable directory makes find skip that entry AND
# exit non-zero, which would otherwise silently under-count skills.
# NUL-delimited (-print0 / sort_nul_file / read -d '') so a path containing a
# literal newline can't desync record boundaries — paths here are untrusted.
if ! find -L "$dir" -name "SKILL.md" -type f -print0 >"$find_out" 2>"$find_err"; then
echo "Warning: find encountered errors while scanning $dir (broken symlinks or permission issues may cause skills to be missed):" >&2
cat "$find_err" >&2
fi
sort_nul_file "$find_out"
while IFS= read -r -d '' file; do
local name desc mtime u7 u30 dp
name=$(extract_field "$file" "name")
desc=$(extract_field "$file" "description")
mtime=$(date -u -r "$file" +%Y-%m-%dT%H:%M:%SZ)
# Use awk exact field match to avoid substring false-positives from grep -F.
# uniq -c output format: " N /path/to/file" — path is always field 2.
u7=$(echo "$obs_7d_counts" | awk -v f="$file" '$2 == f {print $1}' | head -1)
u7="${u7:-0}"
u30=$(echo "$obs_30d_counts" | awk -v f="$file" '$2 == f {print $1}' | head -1)
u30="${u30:-0}"
if [[ "$file" == *[[:space:]]* ]]; then
# The aggregated fast path is line-delimited. Preserve unusual paths by
# falling back to the structured JSON matcher for this record.
u7=$(count_obs "$file" "$c7")
u30=$(count_obs "$file" "$c30")
else
# Use awk exact field match to avoid substring false-positives from grep -F.
# uniq -c output format: " N /path/to/file" — path is always field 2.
u7=$(echo "$obs_7d_counts" | awk -v f="$file" '$2 == f {print $1}' | head -1)
u7="${u7:-0}"
u30=$(echo "$obs_30d_counts" | awk -v f="$file" '$2 == f {print $1}' | head -1)
u30="${u30:-0}"
fi
dp="${file/#$HOME/~}"
jq -n \
@@ -118,7 +159,7 @@ scan_dir_to_json() {
'{path:$path,name:$name,description:$description,use_7d:$use_7d,use_30d:$use_30d,mtime:$mtime}' \
> "$tmpdir/$i.json"
i=$((i+1))
done < <(find "$dir" -name "*.md" -type f 2>/dev/null | sort)
done < "$find_out"
if [[ $i -eq 0 ]]; then
echo "[]"
+341
View File
@@ -0,0 +1,341 @@
/**
* Surface test for #2573: every GATEGUARD_* environment variable the hook
* reads must be documented in the GateGuard skill doc.
*
* `GATEGUARD_BASH_ROUTINE_DISABLED` shipped with no documentation at all and
* `GATEGUARD_EXEMPT_GLOBS` was mentioned only in a release note, so operators
* had no discoverable way to narrow the gate short of disabling it outright.
* This pins the surface: adding a knob to the hook without documenting it
* fails here.
*
* The env reads are extracted from *code only* comments, string literals,
* template-literal text and regex literals are blanked out first, so a knob
* named in a comment or an error message is never mistaken for a read. And
* because a regex scanner cannot see every possible access form, the supported
* forms are enforced as a convention rather than assumed: any other way of
* reaching `process.env` fails the guard below with instructions, instead of
* silently letting an undocumented knob through.
*
* Supported (and enforced) read forms:
* process.env.GATEGUARD_X
* process.env['GATEGUARD_X'] // or "GATEGUARD_X"
*
* Run with: node tests/ci/gateguard-env-documented.test.js
*/
'use strict';
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const repoRoot = path.join(__dirname, '..', '..');
const hookPath = path.join(repoRoot, 'scripts', 'hooks', 'gateguard-fact-force.js');
const skillPath = path.join(repoRoot, 'skills', 'gateguard', 'SKILL.md');
let passed = 0;
let failed = 0;
function test(name, fn) {
try {
fn();
console.log(` \u2713 ${name}`);
return true;
} catch (err) {
console.log(` \u2717 ${name}`);
console.log(` Error: ${err.message}`);
return false;
}
}
/** A `/` here starts a regex literal, not a division. */
const REGEX_CAN_FOLLOW = new Set([
'', '(', ',', '=', ':', '[', '!', '&', '|', '?', '{', '}', ';', '+', '-', '*', '%', '~', '^', '<', '>',
]);
const REGEX_CAN_FOLLOW_KEYWORD = new Set([
'await', 'case', 'delete', 'do', 'else', 'in', 'instanceof', 'new', 'of',
'return', 'throw', 'typeof', 'void', 'yield',
]);
function regexFollowsKeyword(source, slashIndex) {
const match = source.slice(0, slashIndex).match(/([A-Za-z_$][\w$]*)\s*$/);
return Boolean(match && REGEX_CAN_FOLLOW_KEYWORD.has(match[1]));
}
/**
* Blank out comments and literal text, preserving length and line breaks so
* offsets stay comparable with the raw source.
*
* Code inside a template literal's `${...}` is preserved it is real code and
* may contain an env read while the surrounding literal text is blanked.
*/
function blankCommentsAndLiterals(source) {
const out = [];
const emit = (ch) => out.push(ch === '\n' ? '\n' : ' ');
const keep = (ch) => out.push(ch);
let i = 0;
let prev = '';
// Stack of open template literals. 0 = in literal text, >=1 = inside `${...}`
// (the number tracks brace nesting within the expression).
const templates = [];
const inTemplateText = () => templates.length > 0 && templates[templates.length - 1] === 0;
while (i < source.length) {
const ch = source[i];
const next = source[i + 1];
// Template-literal TEXT is handled first: inside it, `//`, quotes and `/`
// are literal characters, not comments, strings or regexes.
if (inTemplateText()) {
if (ch === '\\') { emit(ch); if (i + 1 < source.length) { emit(source[i + 1]); } i += 2; continue; }
if (ch === '`') { templates.pop(); emit(ch); prev = '`'; i += 1; continue; }
if (ch === '$' && next === '{') {
templates[templates.length - 1] = 1;
keep(ch); keep(next); prev = '{'; i += 2;
continue;
}
emit(ch); i += 1;
continue;
}
if (ch === '/' && next === '/') {
while (i < source.length && source[i] !== '\n') { emit(source[i]); i += 1; }
continue;
}
if (ch === '/' && next === '*') {
emit(ch); emit(next); i += 2;
while (i < source.length && !(source[i] === '*' && source[i + 1] === '/')) { emit(source[i]); i += 1; }
if (i < source.length) { emit('*'); emit('/'); i += 2; }
continue;
}
if (ch === '/' && (REGEX_CAN_FOLLOW.has(prev) || regexFollowsKeyword(source, i))) {
emit(ch); i += 1;
let inClass = false;
while (i < source.length) {
const r = source[i];
if (r === '\\') { emit(r); if (i + 1 < source.length) { emit(source[i + 1]); } i += 2; continue; }
if (r === '[') { inClass = true; }
else if (r === ']') { inClass = false; }
else if (r === '/' && !inClass) { emit(r); i += 1; break; }
else if (r === '\n') { break; }
emit(r); i += 1;
}
prev = '/';
continue;
}
if (ch === '"' || ch === "'") {
const quote = ch;
emit(ch); i += 1;
while (i < source.length) {
const s = source[i];
if (s === '\\') { emit(s); if (i + 1 < source.length) { emit(source[i + 1]); } i += 2; continue; }
if (s === quote) { emit(s); i += 1; break; }
if (s === '\n') { break; }
emit(s); i += 1;
}
prev = quote;
continue;
}
if (ch === '`') {
templates.push(0);
emit(ch); i += 1;
continue;
}
if (templates.length > 0 && ch === '}') {
const depth = templates[templates.length - 1];
if (depth === 1) { templates[templates.length - 1] = 0; keep(ch); i += 1; prev = '}'; continue; }
if (depth > 1) { templates[templates.length - 1] = depth - 1; }
}
if (templates.length > 0 && ch === '{' && templates[templates.length - 1] >= 1) {
templates[templates.length - 1] += 1;
}
keep(ch);
if (!/\s/.test(ch)) { prev = ch; }
i += 1;
}
return out.join('');
}
const DOTTED_READ = /process\.env\.(GATEGUARD_[A-Z0-9_]+)/g;
const QUOTED_KEY = /^(['"])(GATEGUARD_[A-Z0-9_]+)\1$/;
const PLAIN_QUOTED_KEY = /^(['"])[A-Za-z0-9_]+\1$/;
function matchAll(source, pattern) {
return [...source.matchAll(pattern)].map(m => m[1]);
}
/**
* Keys used in `process.env[...]`, located in code but read from the raw source.
*
* Blanking replaces literal *text* with spaces, which would erase the key
* itself so the bracket positions are found in the blanked code (proving the
* access is real code, not a comment or a doc string) and the key is then read
* back out of the raw source at the same offset. `blankCommentsAndLiterals`
* preserves length, which is what makes the offsets interchangeable.
*/
function bracketedEnvKeys(source) {
const code = blankCommentsAndLiterals(source);
return [...code.matchAll(/process\.env\s*\[/g)]
.map((m) => {
const at = source.slice(m.index).match(/^process\.env\s*\[\s*([^\]]*?)\s*\]/);
return at ? at[1] : null;
})
.filter(key => key !== null);
}
/** GATEGUARD_* env reads present in real code (comments and literals excluded). */
function readGateguardEnvNames(source) {
const code = blankCommentsAndLiterals(source);
const bracketed = bracketedEnvKeys(source)
.map(key => (key.match(QUOTED_KEY) || [])[2])
.filter(Boolean);
return new Set([...matchAll(code, DOTTED_READ), ...bracketed]);
}
/**
* Access forms this parser cannot follow. Each would let a GATEGUARD_* read
* escape the documentation check, so they are rejected outright.
*/
const UNSUPPORTED_ACCESS = [
{ label: 'destructuring from process.env', pattern: /\}\s*=\s*process\.env\b/ },
{ label: 'process.env aliased to a binding', pattern: /(?:const|let|var)\s+[A-Za-z_$][\w$]*\s*=\s*process\.env\s*(?:[;,)\]]|$)/m },
{ label: 'spread of process.env', pattern: /\.\.\.\s*process\.env\b/ },
{ label: 'enumeration of process.env', pattern: /Object\.(?:keys|values|entries|assign|fromEntries)\(\s*process\.env\b/ },
{ label: 'Reflect access on process.env', pattern: /Reflect\.(?:get|has|set|deleteProperty|defineProperty|getOwnPropertyDescriptor|ownKeys)\(\s*process\.env\b/ },
];
/** `process.env[...]` whose key is not a plain quoted string. */
function findComputedEnvAccess(source) {
return bracketedEnvKeys(source).filter(key => !PLAIN_QUOTED_KEY.test(key));
}
function findUnsupportedAccess(source) {
const code = blankCommentsAndLiterals(source);
const structural = UNSUPPORTED_ACCESS.filter(rule => rule.pattern.test(code)).map(rule => rule.label);
const computed = findComputedEnvAccess(source).map(key => `computed process.env[${key}]`);
return [...structural, ...computed];
}
console.log('\nGateGuard env-var documentation surface\n');
if (test('hook and skill doc both exist', () => {
assert.ok(fs.existsSync(hookPath), `missing ${hookPath}`);
assert.ok(fs.existsSync(skillPath), `missing ${skillPath}`);
})) passed++; else failed++;
const hookSource = fs.existsSync(hookPath) ? fs.readFileSync(hookPath, 'utf8') : '';
const skillDoc = fs.existsSync(skillPath) ? fs.readFileSync(skillPath, 'utf8') : '';
const envNames = readGateguardEnvNames(hookSource);
if (test('hook reads at least one GATEGUARD_* variable', () => {
assert.ok(envNames.size > 0, 'no GATEGUARD_* env reads found - has the hook moved?');
})) passed++; else failed++;
if (test('every GATEGUARD_* variable the hook reads is documented', () => {
const undocumented = [...envNames].filter(name => !skillDoc.includes(name)).sort();
assert.deepStrictEqual(
undocumented,
[],
`undocumented in skills/gateguard/SKILL.md: ${undocumented.join(', ')}`
);
})) passed++; else failed++;
if (test('the documented knobs are the ones the hook actually reads', () => {
// Guards the reverse drift: a doc naming a knob the hook no longer reads.
// Compared against the parsed env reads, not raw source — a name surviving
// only in a comment or error string must not satisfy this.
const documented = [...new Set(skillDoc.match(/GATEGUARD_[A-Z0-9_]+/g) || [])];
const stale = documented.filter(name => !envNames.has(name)).sort();
assert.deepStrictEqual(stale, [], `documented but unread by the hook: ${stale.join(', ')}`);
})) passed++; else failed++;
if (test('the hook reaches process.env only through the supported literal forms', () => {
const unsupported = findUnsupportedAccess(hookSource).sort();
assert.deepStrictEqual(
unsupported,
[],
'the hook uses an env access form this test cannot follow, so an undocumented '
+ 'GATEGUARD_* knob could bypass the check. Either keep to '
+ "`process.env.GATEGUARD_X` / `process.env['GATEGUARD_X']`, or teach "
+ `readGateguardEnvNames the new form. Found: ${unsupported.join(', ')}`
);
})) passed++; else failed++;
// --- parser self-checks: the convention above is only worth as much as these ---
if (test('blanking preserves offsets and line count', () => {
const blanked = blankCommentsAndLiterals(hookSource);
assert.strictEqual(blanked.length, hookSource.length, 'blanking changed the source length');
assert.strictEqual(
blanked.split('\n').length,
hookSource.split('\n').length,
'blanking changed the line count'
);
})) passed++; else failed++;
if (test('env reads are read from code, not from comments, strings or regexes', () => {
const fixture = [
"const a = process.env.GATEGUARD_REAL_ONE;",
"const b = process.env['GATEGUARD_REAL_TWO'];",
'// process.env.GATEGUARD_IN_LINE_COMMENT is only mentioned here',
'/* process.env.GATEGUARD_IN_BLOCK_COMMENT */',
"const msg = 'process.env.GATEGUARD_IN_STRING';",
'const tpl = `process.env.GATEGUARD_IN_TEMPLATE ${process.env.GATEGUARD_REAL_THREE}`;',
'const re = /process\\.env\\.GATEGUARD_IN_REGEX\\/\\//;',
].join('\n');
const found = [...readGateguardEnvNames(fixture)].sort();
assert.deepStrictEqual(found, ['GATEGUARD_REAL_ONE', 'GATEGUARD_REAL_THREE', 'GATEGUARD_REAL_TWO']);
})) passed++; else failed++;
if (test('a regex literal containing a slash does not swallow the code after it', () => {
const fixture = 'const re = /a\\/\\/b/;\nconst x = process.env.GATEGUARD_AFTER_REGEX;';
assert.deepStrictEqual([...readGateguardEnvNames(fixture)], ['GATEGUARD_AFTER_REGEX']);
})) passed++; else failed++;
if (test('a regex literal after a statement keyword is ignored', () => {
const fixture = 'function matches() { return /process\\.env\\.GATEGUARD_IN_RETURN_REGEX/; }';
assert.deepStrictEqual([...readGateguardEnvNames(fixture)], []);
})) passed++; else failed++;
if (test('the access guard rejects every form the parser cannot follow', () => {
const cases = [
['destructuring', 'const { GATEGUARD_HIDDEN } = process.env;'],
['alias', 'const env = process.env;\nconst v = env.GATEGUARD_HIDDEN;'],
['computed template', 'const v = process.env[`GATEGUARD_${suffix}`];'],
['computed variable', 'const v = process.env[name];'],
['spread', 'const all = { ...process.env };'],
['enumeration', 'const ks = Object.keys(process.env);'],
['Reflect.get', "const v = Reflect.get(process.env, 'GATEGUARD_HIDDEN');"],
['Reflect.has', "const v = Reflect.has(process.env, 'GATEGUARD_HIDDEN');"],
['Reflect.ownKeys', 'const ks = Reflect.ownKeys(process.env);'],
];
const missed = cases.filter(([, code]) => findUnsupportedAccess(code).length === 0).map(([label]) => label);
assert.deepStrictEqual(missed, [], `access guard missed: ${missed.join(', ')}`);
})) passed++; else failed++;
if (test('the access guard accepts the supported forms and ignores commented ones', () => {
const ok = [
'const v = process.env.GATEGUARD_STATE_DIR;',
"const v = process.env['GATEGUARD_STATE_DIR'];",
'const v = process.env["GATEGUARD_STATE_DIR"];',
'// const { GATEGUARD_HIDDEN } = process.env;',
"const doc = 'const { GATEGUARD_HIDDEN } = process.env;';",
];
const wrong = ok.filter(code => findUnsupportedAccess(code).length > 0);
assert.deepStrictEqual(wrong, [], `false positives from the access guard: ${wrong.join(' | ')}`);
})) passed++; else failed++;
console.log(`\nPassed: ${passed}`);
console.log(`Failed: ${failed}\n`);
if (failed > 0) {
process.exit(1);
}
+149 -1
View File
@@ -213,7 +213,7 @@ function runCatalogValidator(overrides = {}) {
// Captures stderr on both success and failure (the shared
// runSourceViaTempFile helper only surfaces stderr when the child
// exits non-zero, which hides WARN lines in the default mode).
function runSkillsValidator(testDir, argv = [], envOverrides = {}) {
function runSkillsValidator(testDir, argv = [], envOverrides = {}, docsDir) {
const validatorPath = path.join(validatorsDir, 'validate-skills.js');
let source = fs.readFileSync(validatorPath, 'utf8');
source = stripShebang(source);
@@ -221,6 +221,12 @@ function runSkillsValidator(testDir, argv = [], envOverrides = {}) {
/const SKILLS_DIR = .*?;/,
`const SKILLS_DIR = ${JSON.stringify(testDir)};`,
);
// Default to a nonexistent docs root so tests exercising only
// SKILLS_DIR aren't polluted by this repo's real docs/*/skills/ tree.
source = source.replace(
/const DOCS_DIR = .*?;/,
`const DOCS_DIR = ${JSON.stringify(docsDir || '/nonexistent-docs-dir-for-tests')};`,
);
if (argv.length > 0) {
const argvPreamble = argv
.map(arg => `process.argv.push(${JSON.stringify(arg)});`)
@@ -2801,6 +2807,148 @@ function runTests() {
cleanupTestDir(testDir);
})) passed++; else failed++;
// ── Round 84: validate-skills docs/{locale}/skills/ mirror scan (#2630) ──
console.log('\nRound 84: validate-skills.js (docs/{locale}/skills/ frontmatter, #2630):');
if (test('flags a glued key onto description as invalid YAML', () => {
const testDir = createTestDir();
const docsDir = path.join(testDir, 'docs-root');
const skillDir = path.join(docsDir, 'ja-JP', 'skills', 'example');
fs.mkdirSync(skillDir, { recursive: true });
fs.writeFileSync(path.join(skillDir, 'SKILL.md'),
'---\nname: example\ndescription: some text.license: Apache-2.0\nversion: 1.0.0\n---\n# Example');
const result = runSkillsValidator('/nonexistent/skills-dir', ['--strict'], {}, docsDir);
assert.strictEqual(result.code, 1, 'Should fail on glued key');
assert.ok(result.stderr.includes("unquoted value contains ': '"),
`Should report the glued-key defect, got: ${result.stderr}`);
cleanupTestDir(testDir);
})) passed++; else failed++;
if (test('flags a dropped-quote description containing a colon as invalid YAML', () => {
const testDir = createTestDir();
const docsDir = path.join(testDir, 'docs-root');
const skillDir = path.join(docsDir, 'ja-JP', 'skills', 'example');
fs.mkdirSync(skillDir, { recursive: true });
fs.writeFileSync(path.join(skillDir, 'SKILL.md'),
'---\nname: example\ndescription: Verification loop: migrations, linting\n---\n# Example');
const result = runSkillsValidator('/nonexistent/skills-dir', ['--strict'], {}, docsDir);
assert.strictEqual(result.code, 1, 'Should fail on unquoted colon in description');
assert.ok(result.stderr.includes("unquoted value contains ': '"),
`Should report the dropped-quote defect, got: ${result.stderr}`);
cleanupTestDir(testDir);
})) passed++; else failed++;
if (test('flags a description starting with the reserved @ indicator', () => {
const testDir = createTestDir();
const docsDir = path.join(testDir, 'docs-root');
const skillDir = path.join(docsDir, 'ja-JP', 'skills', 'example');
fs.mkdirSync(skillDir, { recursive: true });
fs.writeFileSync(path.join(skillDir, 'SKILL.md'),
'---\nname: example\ndescription: @Observable state management\n---\n# Example');
const result = runSkillsValidator('/nonexistent/skills-dir', ['--strict'], {}, docsDir);
assert.strictEqual(result.code, 1, 'Should fail on leading @');
assert.ok(result.stderr.includes("reserved character '@'"),
`Should report the reserved-indicator defect, got: ${result.stderr}`);
cleanupTestDir(testDir);
})) passed++; else failed++;
if (test('preserves # inside a quoted frontmatter value', () => {
const testDir = createTestDir();
const docsDir = path.join(testDir, 'docs-root');
const skillDir = path.join(docsDir, 'ja-JP', 'skills', 'example');
fs.mkdirSync(skillDir, { recursive: true });
fs.writeFileSync(path.join(skillDir, 'SKILL.md'),
'---\nname: example\ndescription: "Fix: details #tag" # translation note\n---\n# Example');
const result = runSkillsValidator('/nonexistent/skills-dir', ['--strict'], {}, docsDir);
assert.strictEqual(result.code, 0,
`Quoted # content must remain valid, got stderr: ${result.stderr}`);
cleanupTestDir(testDir);
})) passed++; else failed++;
if (test('rejects malformed quoted skill frontmatter', () => {
const testDir = createTestDir();
const skillDir = path.join(testDir, 'malformed-quote');
fs.mkdirSync(skillDir);
fs.writeFileSync(path.join(skillDir, 'SKILL.md'),
'---\nname: malformed-quote\ndescription: "unterminated\n---\n# Example');
const result = runSkillsValidator(testDir, ['--strict']);
assert.strictEqual(result.code, 1, 'Strict validation must reject malformed YAML');
assert.ok(result.stderr.includes('invalid YAML'),
`Should report the YAML parse failure, got: ${result.stderr}`);
cleanupTestDir(testDir);
})) passed++; else failed++;
if (test('rejects an empty folded skill description', () => {
const testDir = createTestDir();
const skillDir = path.join(testDir, 'empty-folded-description');
fs.mkdirSync(skillDir);
fs.writeFileSync(path.join(skillDir, 'SKILL.md'),
'---\nname: empty-folded-description\ndescription: >\n---\n# Example');
const result = runSkillsValidator(testDir, ['--strict']);
assert.strictEqual(result.code, 1, 'Strict validation must reject an empty folded scalar');
assert.ok(result.stderr.includes("'description' is empty"),
`Should report the empty parsed description, got: ${result.stderr}`);
cleanupTestDir(testDir);
})) passed++; else failed++;
if (test('reports an unreadable docs root deterministically', () => {
const testDir = createTestDir();
const docsPath = path.join(testDir, 'docs-file');
fs.writeFileSync(docsPath, 'not a directory');
const result = runSkillsValidator('/nonexistent/skills-dir', ['--strict'], {}, docsPath);
assert.strictEqual(result.code, 1, 'Should fail when the docs root cannot be read');
assert.strictEqual(result.stderr.trim(), 'ERROR: unable to read docs directory');
cleanupTestDir(testDir);
})) passed++; else failed++;
if (test('flags a docs mirror SKILL.md with no frontmatter block at all', () => {
const testDir = createTestDir();
const docsDir = path.join(testDir, 'docs-root');
const skillDir = path.join(docsDir, 'ja-JP', 'skills', 'example');
fs.mkdirSync(skillDir, { recursive: true });
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), '# Example\n\nNo frontmatter here.');
const result = runSkillsValidator('/nonexistent/skills-dir', ['--strict'], {}, docsDir);
assert.strictEqual(result.code, 1, 'Should fail when docs mirror has no frontmatter');
assert.ok(result.stderr.includes('no frontmatter block found'),
`Should report the missing-frontmatter defect, got: ${result.stderr}`);
cleanupTestDir(testDir);
})) passed++; else failed++;
if (test('curated skills/ still tolerates a SKILL.md with no frontmatter (unchanged)', () => {
const testDir = createTestDir();
const skillDir = path.join(testDir, 'no-frontmatter-skill');
fs.mkdirSync(skillDir, { recursive: true });
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), '# Example\n\nNo frontmatter here.');
const result = runSkillsValidator(testDir, ['--strict']);
assert.strictEqual(result.code, 0,
`Curated skills/ must not require frontmatter, got stderr: ${result.stderr}`);
cleanupTestDir(testDir);
})) passed++; else failed++;
if (test('passes on a valid docs/{locale}/skills/ mirror', () => {
const testDir = createTestDir();
const docsDir = path.join(testDir, 'docs-root');
const skillDir = path.join(docsDir, 'zh-CN', 'skills', 'example');
fs.mkdirSync(skillDir, { recursive: true });
fs.writeFileSync(path.join(skillDir, 'SKILL.md'),
'---\nname: example\ndescription: "Well-formed: quoted value"\n---\n# Example');
const result = runSkillsValidator('/nonexistent/skills-dir', ['--strict'], {}, docsDir);
assert.strictEqual(result.code, 0, `Should pass on well-formed mirror, got: ${result.stderr}`);
assert.ok(result.stdout.includes('Validated 1'), 'Should count the one docs skill file');
cleanupTestDir(testDir);
})) passed++; else failed++;
// ==========================================
// validate-install-manifests.js
// ==========================================
+138
View File
@@ -0,0 +1,138 @@
/**
* Regression tests for the standalone GAN harness helpers.
*/
'use strict';
const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawnSync } = require('child_process');
const repoRoot = path.resolve(__dirname, '..');
const harnessPath = path.join(repoRoot, 'scripts', 'gan-harness.sh');
const harnessSource = fs.readFileSync(harnessPath, 'utf8');
if (process.platform === 'win32') {
console.log('\n=== GAN harness helpers ===\n');
console.log(' - skipped on Windows; GAN harness shell helpers are Unix-only');
console.log('\nPassed: 0');
console.log('Failed: 0');
process.exit(0);
}
function test(name, fn) {
try {
fn();
console.log(`${name}`);
return true;
} catch (error) {
console.log(`${name}`);
console.log(` Error: ${error.message}`);
return false;
}
}
function runHarnessScript(script, args = []) {
const bashExecutable = process.platform === 'win32' ? 'bash' : '/bin/bash';
const result = spawnSync(bashExecutable, ['-c', script, 'gan-harness-test', ...args], {
encoding: 'utf8',
});
assert.strictEqual(result.status, 0, result.stderr || 'GAN harness script failed');
return result.stdout.trim();
}
function extractScore(feedback) {
const functionMatch = harnessSource.match(/extract_score\(\) \{[\s\S]*?\n\}/);
assert.ok(functionMatch, 'expected scripts/gan-harness.sh to define extract_score');
const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-gan-harness-'));
const feedbackPath = path.join(temporaryDirectory, 'feedback.md');
fs.writeFileSync(feedbackPath, feedback, 'utf8');
try {
return runHarnessScript(`${functionMatch[0]}\nextract_score "$1"`, [feedbackPath]);
} finally {
fs.rmSync(temporaryDirectory, { recursive: true, force: true });
}
}
console.log('\n=== GAN harness helpers ===\n');
const results = Object.freeze([
test('extract_score reads the documented TOTAL table format', () => {
const feedback = '| **TOTAL** | | | **7.5** |\n';
const result = extractScore(feedback);
assert.strictEqual(result, '7.5');
}),
test('extract_score reads the compact TOTAL format', () => {
const feedback = '**TOTAL** | **8.3**\n';
const result = extractScore(feedback);
assert.strictEqual(result, '8.3');
}),
test('extract_score reads a Verdict score', () => {
const feedback = 'Verdict: PASS with score 9.1\n';
const result = extractScore(feedback);
assert.strictEqual(result, '9.1');
}),
test('extract_score does not treat a Verdict threshold as a score', () => {
const feedback = '## Verdict: PASS / FAIL (threshold: 7.0)\n';
const result = extractScore(feedback);
assert.strictEqual(result, '0.0');
}),
test('extract_score prefers a TOTAL score after a Verdict threshold', () => {
const feedback = [
'## Verdict: PASS / FAIL (threshold: 7.0)',
'| **TOTAL** | **1.0** | **9.0** |',
].join('\n');
const result = extractScore(feedback);
assert.strictEqual(result, '9.0');
}),
test('extract_score returns the fallback when no supported score exists', () => {
const feedback = 'Other score: 9.9\n';
const result = extractScore(feedback);
assert.strictEqual(result, '0.0');
}),
test('final score lookup is compatible with the macOS Bash 3.2 runtime', () => {
const finalScoreBlock = harnessSource.match(
/NUM_ITERATIONS=\$\{#SCORES\[@\]\}\nif \[ "\$NUM_ITERATIONS"[\s\S]*?\nfi/
);
const scoreOutput = harnessSource.match(/echo -e "\s{2}Score:[^\n]+/);
assert.ok(finalScoreBlock, 'expected scripts/gan-harness.sh to select a final score');
assert.ok(scoreOutput, 'expected scripts/gan-harness.sh to print the final score');
assert.doesNotMatch(
harnessSource,
/\bSCORES\[\s*-\s*\d+\s*\]/,
'negative array subscripts require Bash 4.3+'
);
const output = runHarnessScript(
[`SCORES=("$@")`, 'CYAN=""', 'NC=""', finalScoreBlock[0], scoreOutput[0]].join('\n'),
['6.2', '8.7']
);
assert.match(output, /Score:\s+8\.7\s+\/\s+10\.0/);
}),
]);
const passed = results.filter(Boolean).length;
const failed = results.length - passed;
console.log(`\nPassed: ${passed}`);
console.log(`Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);
+404
View File
@@ -145,6 +145,8 @@ function runTests() {
assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('Fact-Forcing Gate'));
assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('import/require'));
assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('/src/app.js'));
assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('GATEGUARD_EXEMPT_GLOBS'), 'Edit denial should show the path-scoped exemption control');
assert.ok(!output.hookSpecificOutput.permissionDecisionReason.includes('GATEGUARD_BASH_ROUTINE_DISABLED'), 'Edit denial should not suggest the routine Bash control');
})
)
passed++;
@@ -538,6 +540,8 @@ function runTests() {
assert.strictEqual(output.hookSpecificOutput.permissionDecision, 'deny');
assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('ECC_GATEGUARD=off'), 'denial reason should show the direct recovery env toggle');
assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('ECC_DISABLED_HOOKS'), 'denial reason should mention the existing hook-id disable control');
assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('GATEGUARD_EXEMPT_GLOBS'), 'Edit/Write denial should show the path-scoped exemption control');
assert.ok(!output.hookSpecificOutput.permissionDecisionReason.includes('GATEGUARD_BASH_ROUTINE_DISABLED'), 'Edit/Write denial should not suggest the routine Bash control');
})
)
passed++;
@@ -558,6 +562,9 @@ function runTests() {
assert.strictEqual(output.hookSpecificOutput.permissionDecision, 'deny');
assert.ok(reason.includes('pre:bash:gateguard-fact-force'), 'routine Bash denial should show the Bash hook ID');
assert.ok(!reason.includes('pre:edit-write:gateguard-fact-force'), 'routine Bash denial should not show the Edit/Write hook ID as the targeted disable');
assert.ok(reason.includes('GATEGUARD_BASH_ROUTINE_DISABLED=1'), 'routine Bash denial should show the narrow routine-gate control');
assert.ok(reason.includes('destructive Bash checks remain active'), 'routine Bash denial should preserve the destructive-check safety boundary');
assert.ok(!reason.includes('GATEGUARD_EXEMPT_GLOBS'), 'routine Bash denial should not suggest the Edit/Write path control');
})
)
passed++;
@@ -577,6 +584,9 @@ function runTests() {
assert.strictEqual(output.hookSpecificOutput.permissionDecision, 'deny');
assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('Destructive command detected'));
assert.ok(!output.hookSpecificOutput.permissionDecisionReason.includes('ECC_GATEGUARD=off'), 'destructive gate should not advertise disabling GateGuard');
assert.ok(!output.hookSpecificOutput.permissionDecisionReason.includes('ECC_DISABLED_HOOKS'), 'destructive gate should not advertise disabling its hook');
assert.ok(!output.hookSpecificOutput.permissionDecisionReason.includes('GATEGUARD_BASH_ROUTINE_DISABLED'), 'destructive gate should not advertise the routine-only bypass');
assert.ok(!output.hookSpecificOutput.permissionDecisionReason.includes('GATEGUARD_EXEMPT_GLOBS'), 'destructive gate should not advertise the Edit/Write path exemption');
})
)
passed++;
@@ -602,6 +612,7 @@ function runTests() {
assert.strictEqual(output.hookSpecificOutput.permissionDecision, 'deny');
assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('Fact-Forcing Gate'));
assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('/src/multi-a.js'));
assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('GATEGUARD_EXEMPT_GLOBS'), 'MultiEdit denial should show the path-scoped exemption control');
})
)
passed++;
@@ -1477,6 +1488,395 @@ function runTests() {
passed++;
else failed++;
if (
test('allows destructive SQL prose inside a quoted heredoc', () => {
expectAllow(
[
"cat > migration-notes.md <<'EOF'",
'This migration will DROP TABLE old_sessions after verification.',
'EOF'
].join('\n'),
'quoted heredoc SQL prose'
);
})
)
passed++;
else failed++;
if (
test('allows destructive prose and separators inside an unquoted heredoc', () => {
expectAllow(
[
'cat > migration-notes.md <<EOF',
'Document only: DELETE FROM sessions; rm -rf old-cache',
'EOF'
].join('\n'),
'unquoted heredoc prose'
);
})
)
passed++;
else failed++;
if (
test('allows destructive prose inside a tab-stripping heredoc', () => {
expectAllow(
[
'cat > migration-notes.md <<-EOF',
'\tTRUNCATE old_sessions; rm -rf old-cache',
'\tEOF'
].join('\n'),
'tab-stripping heredoc prose'
);
})
)
passed++;
else failed++;
if (
test('handles multiple heredoc redirections in declaration order', () => {
expectAllow(
[
"cat <<ONE <<'TWO'",
'DELETE FROM sessions is documentation here.',
'ONE',
'$(rm -rf /tmp/example-only)',
'TWO'
].join('\n'),
'multiple heredoc redirections'
);
})
)
passed++;
else failed++;
if (
test('fails closed when a shell consumes the heredoc payload', () => {
for (const command of [
['bash <<EOF', 'rm -rf /tmp/shell-input-target', 'EOF'].join('\n'),
["sh <<'EOF'", 'git reset --hard', 'EOF'].join('\n'),
['cat <<EOF | sh', 'rm -rf /tmp/piped-shell-target', 'EOF'].join('\n'),
["cat > /tmp/review-script <<'EOF'", 'rm -rf /tmp/persisted-target', 'EOF', 'bash /tmp/review-script'].join('\n')
]) {
expectDestructiveDeny(command, 'shell-executed heredoc payload');
}
})
)
passed++;
else failed++;
if (
test('does not rescan a here-string as a heredoc', () => {
expectDestructiveDeny(
['cat <<<EOF', 'rm -rf /tmp/here-string-followup'].join('\n'),
'command after here-string'
);
})
)
passed++;
else failed++;
if (
test('uses shell-correct single-quote escaping while finding heredocs', () => {
expectDestructiveDeny(
["echo 'a\\'X'<<EOF 'Y'b\\'", 'rm -rf /tmp/quoted-followup'].join('\n'),
'command after quoted non-heredoc text'
);
})
)
passed++;
else failed++;
if (
test('fails closed on an unclosed heredoc body', () => {
expectDestructiveDeny(
['cat <<EOF', 'rm -rf /tmp/unclosed-heredoc'].join('\n'),
'unclosed heredoc body'
);
})
)
passed++;
else failed++;
if (
test('still denies destructive commands after a heredoc terminator', () => {
expectDestructiveDeny(
[
"cat > migration-notes.md <<'EOF'",
'DROP TABLE is documentation here.',
'EOF',
'rm -rf /tmp/real-target'
].join('\n'),
'command after heredoc terminator'
);
})
)
passed++;
else failed++;
if (
test('still denies command substitutions inside an unquoted heredoc', () => {
expectDestructiveDeny(
[
'cat > output.txt <<EOF',
'$(rm -rf /tmp/expanded-target)',
'EOF'
].join('\n'),
'unquoted heredoc command substitution'
);
})
)
passed++;
else failed++;
if (
test('allows literal command substitutions inside a quoted heredoc', () => {
expectAllow(
[
"cat > example.md <<'EOF'",
'$(rm -rf /tmp/example-only)',
'EOF'
].join('\n'),
'quoted heredoc command-substitution prose'
);
})
)
passed++;
else failed++;
if (
test('does not mistake an arithmetic shift for a heredoc', () => {
expectDestructiveDeny(
['echo $((1 << 2))', 'rm -rf /tmp/real-target'].join('\n'),
'command after arithmetic shift'
);
})
)
passed++;
else failed++;
if (
test('does not mistake a named arithmetic shift operand for a heredoc', () => {
expectDestructiveDeny(
['echo $((flags << WIDTH))', 'rm -rf /tmp/real-target'].join('\n'),
'command after named arithmetic shift'
);
})
)
passed++;
else failed++;
if (
test('fails closed on multiline arithmetic shift contexts', () => {
for (const arithmetic of [
['((', 'flags << WIDTH', '))'],
['$((', 'flags << WIDTH', '))'],
['$[', 'flags << WIDTH', ']']
]) {
expectDestructiveDeny(
[...arithmetic, 'rm -rf /tmp/real-target'].join('\n'),
'command after multiline arithmetic shift'
);
}
})
)
passed++;
else failed++;
if (
test('does not mistake a conditional string operator for a heredoc', () => {
expectDestructiveDeny(
['[[ alpha << omega ]]', 'rm -rf /tmp/real-target'].join('\n'),
'command after conditional shift-like operator'
);
})
)
passed++;
else failed++;
if (
test('does not parse heredocs inside operator-adjacent comments', () => {
expectDestructiveDeny(
['true;# <<EOF', 'rm -rf /tmp/real-target'].join('\n'),
'command after commented heredoc marker'
);
})
)
passed++;
else failed++;
if (
test('fails closed on heredoc markers inside multiline quotes', () => {
expectDestructiveDeny(
['printf \'%s\' "literal', '<<EOF', 'still literal"', 'rm -rf /tmp/real-target'].join('\n'),
'command after multiline quoted heredoc marker'
);
})
)
passed++;
else failed++;
if (
test('fails closed on ANSI-C quoted heredoc delimiters', () => {
expectDestructiveDeny(
["cat <<$'EOF'", 'documentation', 'EOF', 'rm -rf /tmp/real-target'].join('\n'),
'command after ANSI-C heredoc'
);
})
)
passed++;
else failed++;
if (
test('fails closed on escaped heredoc delimiter words', () => {
expectDestructiveDeny(
['cat <<E\\', 'OF', 'documentation', 'EOF', 'rm -rf /tmp/real-target'].join('\n'),
'command after escaped heredoc delimiter'
);
})
)
passed++;
else failed++;
if (
test('denies multiline command substitutions inside an unquoted heredoc', () => {
expectDestructiveDeny(
['cat <<EOF', '$(', 'rm -rf /tmp/expanded-target', ')', 'EOF'].join('\n'),
'multiline unquoted heredoc command substitution'
);
})
)
passed++;
else failed++;
if (
test('denies multiline backtick substitutions inside an unquoted heredoc', () => {
expectDestructiveDeny(
['cat <<EOF', '`', 'rm -rf /tmp/expanded-target', '`', 'EOF'].join('\n'),
'multiline unquoted heredoc backtick substitution'
);
})
)
passed++;
else failed++;
if (
test('denies line-continued command substitutions inside an unquoted heredoc', () => {
expectDestructiveDeny(
['cat <<EOF', '$\\', '(', 'rm -rf /tmp/expanded-target', ')', 'EOF'].join('\n'),
'line-continued unquoted heredoc command substitution'
);
})
)
passed++;
else failed++;
if (
test('denies split command names after heredoc line continuation', () => {
expectDestructiveDeny(
['cat <<EOF', '$(r\\', 'm -rf /tmp/expanded-target', ')', 'EOF'].join('\n'),
'split command name in unquoted heredoc substitution'
);
})
)
passed++;
else failed++;
if (
test('allows a joined command when tab stripping removes the option separator', () => {
expectAllow(
['cat <<-EOF', '\t$(rm\\', '\t-rf /tmp/expanded-target)', 'EOF'].join('\n'),
'tab stripping joins rm and -rf into a harmless command name'
);
})
)
passed++;
else failed++;
if (
test('denies split command names after tab-stripped heredoc continuations', () => {
expectDestructiveDeny(
['cat <<-EOF', '\t$(r\\', '\tm -rf /tmp/expanded-target)', 'EOF'].join('\n'),
'split command name in tab-stripped unquoted heredoc substitution'
);
})
)
passed++;
else failed++;
if (
test('fails closed on line-continued unquoted heredoc terminators', () => {
expectDestructiveDeny(
['cat <<EOF', 'payload', 'EO\\', 'F', 'rm -rf /tmp/real-target'].join('\n'),
'command after line-continued heredoc terminator'
);
})
)
passed++;
else failed++;
if (
test('allows escaped command-substitution prose in an unquoted heredoc', () => {
expectAllow(
['cat <<EOF', '\\$(echo example)', 'DROP TABLE is documentation here.', 'EOF'].join('\n'),
'escaped unquoted heredoc command-substitution prose'
);
})
)
passed++;
else failed++;
if (
test('denies substitutions inside literal quote characters in an unquoted heredoc', () => {
for (const payload of [
"'$(rm -rf /tmp/expanded-target)'",
'"$(rm -rf /tmp/expanded-target)"',
"'`rm -rf /tmp/expanded-target`'"
]) {
expectDestructiveDeny(
['cat <<EOF', payload, 'EOF'].join('\n'),
'quoted-looking unquoted heredoc substitution'
);
}
})
)
passed++;
else failed++;
if (
test('allows quoted destructive prose inside a harmless heredoc substitution', () => {
expectAllow(
['cat <<EOF', "$(printf '%s' 'rm -rf /tmp/example-only')", 'EOF'].join('\n'),
'quoted prose inside heredoc substitution'
);
})
)
passed++;
else failed++;
if (
test('still denies destructive commands after arithmetic shifts', () => {
expectDestructiveDeny(
['echo $((1 << 2))', 'rm -rf /tmp/shift-target'].join('\n'),
'command after $((...)) arithmetic shift'
);
expectDestructiveDeny(
['echo $((x << 2))', 'rm -rf /tmp/shift-target'].join('\n'),
'command after $((...)) identifier shift'
);
expectDestructiveDeny(
['(( 1 << 2 ))', 'rm -rf /tmp/shift-target'].join('\n'),
'command after ((...)) arithmetic shift'
);
expectDestructiveDeny(
['echo $[x << 1]', 'rm -rf /tmp/shift-target'].join('\n'),
'command after legacy $[...] arithmetic shift'
);
})
)
passed++;
else failed++;
if (
test('allows git push --force-if-includes as a safety-checked variant', () => {
expectAllow('git push --force-with-lease --force-if-includes origin main', 'git push --force-if-includes');
@@ -2167,6 +2567,7 @@ function runTests() {
assert.ok(!reason.includes('present these facts'), 'no repeated four-fact block');
assert.ok(!reason.includes('\n'), 'condensed message is a single line');
assert.ok(reason.includes('ECC_GATEGUARD=off'), 'condensed message keeps a recovery hint');
assert.ok(reason.includes('GATEGUARD_EXEMPT_GLOBS'), 'condensed Edit denial keeps the path-scoped recovery hint');
})
)
passed++;
@@ -2182,6 +2583,8 @@ function runTests() {
const secondReason = second.hookSpecificOutput.permissionDecisionReason;
assert.ok(firstReason.includes('denial #6'), `expected ordinal 6, got: ${firstReason}`);
assert.ok(secondReason.includes('denial #7'), `expected ordinal 7, got: ${secondReason}`);
assert.ok(firstReason.includes('GATEGUARD_EXEMPT_GLOBS'), 'condensed Write denial keeps the path-scoped recovery hint');
assert.ok(!firstReason.includes('GATEGUARD_BASH_ROUTINE_DISABLED'), 'condensed Write denial should not suggest the routine Bash control');
assert.notStrictEqual(firstReason, secondReason, 'successive denials must differ so they cannot compound verbatim');
})
)
@@ -2246,6 +2649,7 @@ function runTests() {
assert.strictEqual(output.hookSpecificOutput.permissionDecision, 'deny');
assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('denial #5'));
assert.ok(!output.hookSpecificOutput.permissionDecisionReason.includes('present these facts'));
assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('GATEGUARD_EXEMPT_GLOBS'), 'condensed MultiEdit denial keeps the path-scoped recovery hint');
})
)
passed++;
+17 -1
View File
@@ -2585,7 +2585,7 @@ async function runTests() {
['post:dispatcher:sync', 'post:dispatcher:async'],
'PostToolUse should have one sync and one async dispatcher'
);
assert.ok(postEntries.every(entry => entry.matcher === '*'));
assert.ok(postEntries.every(entry => entry.matcher === '.*'));
const preCommand = Array.isArray(preBash[0].hooks[0].command) ? preBash[0].hooks[0].command.join(' ') : preBash[0].hooks[0].command;
@@ -2599,6 +2599,22 @@ async function runTests() {
passed++;
else failed++;
if (
test('all string hook matchers are valid regular expressions', () => {
const hooksPath = path.join(__dirname, '..', '..', 'hooks', 'hooks.json');
const hooks = JSON.parse(fs.readFileSync(hooksPath, 'utf8'));
for (const [eventName, hookArray] of Object.entries(hooks.hooks)) {
for (const entry of hookArray) {
if (typeof entry.matcher !== 'string') continue;
assert.doesNotThrow(() => new RegExp(entry.matcher), `${eventName}/${entry.id || 'hook'} should use a valid regex matcher`);
}
}
})
)
passed++;
else failed++;
if (
test('SessionEnd marker hook is async and cleanup-safe', () => {
const hooksPath = path.join(__dirname, '..', '..', 'hooks', 'hooks.json');
+1 -1
View File
@@ -82,7 +82,7 @@ function runTests() {
entries.map(entry => entry.id),
['post:dispatcher:sync', 'post:dispatcher:async']
);
assert.ok(entries.every(entry => entry.matcher === '*'));
assert.ok(entries.every(entry => entry.matcher === '.*'));
assert.strictEqual(entries[0].hooks[0].async, undefined);
assert.strictEqual(entries[1].hooks[0].async, true);
assert.ok(entries[0].hooks[0].command.includes('posttooluse-dispatcher.js'));
+6 -3
View File
@@ -694,17 +694,20 @@ function runTests() {
};
}
if (test('suggests compact when context exceeds the 200k-window threshold', () => {
if (test('omits the percentage when the context window is assumed', () => {
const ctx = createContextContext();
const transcript = writeTranscriptFixture(170000);
try {
const result = runCompactWithInput({ session_id: ctx.sessionId, transcript_path: transcript });
const result = runCompactWithInput(
{ session_id: ctx.sessionId, transcript_path: transcript },
{ ECC_CONTEXT_WINDOW_TOKENS: '', CLAUDE_CODE_AUTO_COMPACT_WINDOW: '' },
);
assert.strictEqual(result.code, 0, 'Should exit 0');
assert.ok(result.stdout.trim().length > 0, `Expected stdout payload. Got: "${result.stdout}"`);
const parsed = JSON.parse(result.stdout);
const context = parsed.hookSpecificOutput.additionalContext;
assert.ok(context.includes('Context ~170k tokens'), `Expected token estimate. Got: ${context}`);
assert.ok(context.includes('85% of 200k window'), `Expected window percentage. Got: ${context}`);
assert.ok(!context.includes('% of'), `Expected no percentage for an assumed window. Got: ${context}`);
} finally {
try { fs.unlinkSync(transcript); } catch (_err) { /* ignore */ }
ctx.cleanup();
+5 -4
View File
@@ -33,12 +33,12 @@ function runTests() {
let passed = 0;
let failed = 0;
if (test('represents all 14 registered targets exactly once across 13 harnesses', () => {
if (test('represents all 15 registered targets exactly once across 14 harnesses', () => {
const catalogTargetIds = HARNESS_CAPABILITIES.flatMap(harness => harness.targetIds);
const adapterTargetIds = listInstallTargetAdapters().map(adapter => adapter.target);
assert.strictEqual(HARNESS_CAPABILITIES.length, 13);
assert.strictEqual(new Set(catalogTargetIds).size, 14);
assert.strictEqual(HARNESS_CAPABILITIES.length, 14);
assert.strictEqual(new Set(catalogTargetIds).size, 15);
assert.deepStrictEqual([...catalogTargetIds].sort(), [...SUPPORTED_INSTALL_TARGETS].sort());
assert.deepStrictEqual([...catalogTargetIds].sort(), [...adapterTargetIds].sort());
})) passed++; else failed++;
@@ -100,6 +100,7 @@ function runTests() {
joycode: ['project', './.joycode'],
qwen: ['home', '~/.qwen'],
zed: ['project', './.zed'],
adal: ['project', './.adal'],
hermes: ['home', '~/.hermes'],
openclaw: ['home', '~/.openclaw'],
};
@@ -170,7 +171,7 @@ function runTests() {
const first = listHarnessCapabilities();
first.pop();
assert.strictEqual(listHarnessCapabilities().length, 13);
assert.strictEqual(listHarnessCapabilities().length, 14);
const guided = listGuidedHarnesses();
guided.reverse();
+113
View File
@@ -975,6 +975,119 @@ function runTests() {
);
})) passed++; else failed++;
if (test('resolves adal adapter root and install-state path from project root', () => {
const adapter = getInstallTargetAdapter('adal');
const projectRoot = '/workspace/app';
const root = adapter.resolveRoot({ projectRoot });
const statePath = adapter.getInstallStatePath({ projectRoot });
assert.strictEqual(adapter.id, 'adal-project');
assert.strictEqual(adapter.target, 'adal');
assert.strictEqual(adapter.kind, 'project');
assert.strictEqual(root, path.join(projectRoot, '.adal'));
assert.strictEqual(statePath, path.join(projectRoot, '.adal', 'ecc-install-state.json'));
})) passed++; else failed++;
if (test('adal adapter supports lookup by target and adapter id', () => {
const byTarget = getInstallTargetAdapter('adal');
const byId = getInstallTargetAdapter('adal-project');
assert.strictEqual(byTarget.id, 'adal-project');
assert.strictEqual(byId.id, 'adal-project');
assert.ok(byTarget.supports('adal'));
assert.ok(byTarget.supports('adal-project'));
})) passed++; else failed++;
if (test('plans adal project rules, skills, and native root sync', () => {
const repoRoot = path.join(__dirname, '..', '..');
const projectRoot = '/workspace/app';
const plan = planInstallTargetScaffold({
target: 'adal',
repoRoot,
projectRoot,
modules: [
{
id: 'rules-core',
paths: ['rules'],
},
{
id: 'workflow-quality',
paths: ['skills/tdd-workflow'],
},
{
id: 'platform-configs',
paths: ['.adal', '.cursor', '.zed'],
},
],
});
assert.strictEqual(plan.adapter.id, 'adal-project');
assert.strictEqual(plan.targetRoot, path.join(projectRoot, '.adal'));
assert.strictEqual(plan.installStatePath, path.join(projectRoot, '.adal', 'ecc-install-state.json'));
assert.ok(
plan.operations.some(operation => (
normalizedRelativePath(operation.sourceRelativePath) === 'rules'
&& operation.destinationPath === path.join(projectRoot, '.adal', 'rules')
)),
'Should preserve rules under .adal/rules'
);
assert.ok(
plan.operations.some(operation => (
normalizedRelativePath(operation.sourceRelativePath) === 'skills/tdd-workflow'
&& operation.destinationPath === path.join(projectRoot, '.adal', 'skills', 'tdd-workflow')
)),
'Should install skills under .adal/skills'
);
assert.ok(
plan.operations.some(operation => (
normalizedRelativePath(operation.sourceRelativePath) === '.adal'
&& operation.destinationPath === path.join(projectRoot, '.adal')
&& operation.strategy === 'sync-root-children'
)),
'Should sync native .adal root children in place'
);
})) passed++; else failed++;
if (test('adal adapter skips foreign platform source paths', () => {
const repoRoot = path.join(__dirname, '..', '..');
const projectRoot = '/workspace/app';
const plan = planInstallTargetScaffold({
target: 'adal',
repoRoot,
projectRoot,
modules: [
{
id: 'platform-configs',
paths: ['.cursor', '.zed', 'rules'],
},
],
});
assert.ok(
plan.operations.some(operation => (
normalizedRelativePath(operation.sourceRelativePath) === 'rules'
&& operation.destinationPath === path.join(projectRoot, '.adal', 'rules')
)),
'Should still include non-foreign rules path (guards against empty-plan regression)'
);
assert.ok(
!plan.operations.some(operation => (
normalizedRelativePath(operation.sourceRelativePath) === '.cursor'
|| normalizedRelativePath(operation.sourceRelativePath).startsWith('.cursor/')
)),
'Should skip foreign Cursor platform paths'
);
assert.ok(
!plan.operations.some(operation => (
normalizedRelativePath(operation.sourceRelativePath) === '.zed'
|| normalizedRelativePath(operation.sourceRelativePath).startsWith('.zed/')
)),
'Should skip foreign Zed platform paths'
);
})) passed++; else failed++;
if (test('exposes validate and planOperations on codebuddy adapter', () => {
const codebuddyAdapter = getInstallTargetAdapter('codebuddy');
+7 -5
View File
@@ -1,10 +1,6 @@
'use strict';
const assert = require('assert');
const {
extractCommandSubstitutions,
extractSubshellGroups,
extractBraceGroups,
} = require('../../scripts/lib/shell-substitution');
const { extractCommandSubstitutions, extractSubshellGroups, extractBraceGroups } = require('../../scripts/lib/shell-substitution');
console.log('=== Testing shell-substitution.js ===\n');
@@ -66,6 +62,12 @@ test('double-quoted body extracted, single-quoted body ignored', () => {
test('single quotes inside a $() body are preserved', () => {
assert.deepStrictEqual(extractCommandSubstitutions("x=$(echo 'a b')"), ["echo 'a b'"]);
});
test('literal outer quotes do not suppress substitutions', () => {
assert.deepStrictEqual(extractCommandSubstitutions("'$(whoami)'", { literalOuterQuotes: true }), ['whoami']);
});
test('literal outer quotes preserve shell quoting inside a substitution', () => {
assert.deepStrictEqual(extractCommandSubstitutions("'$(echo '$(ignored)')'", { literalOuterQuotes: true }), ["echo '$(ignored)'"]);
});
console.log('\nextractCommandSubstitutions - escaped substitutions:');
test('escaped \\$() is NOT extracted (literal dollar)', () => {
+39 -1
View File
@@ -23,7 +23,8 @@ const {
resolveContextThreshold,
resolveContextInterval,
computeContextBucket,
formatWindowLabel
formatWindowLabel,
isContextWindowInferred
} = require('../../scripts/lib/transcript-context');
console.log('=== Testing transcript-context.js ===\n');
@@ -138,6 +139,10 @@ console.log('\nresolveContextWindowTokens:');
// Isolation: an env-set window override (either knob) otherwise leaks into the
// default-window assertions below and fails them (#2290).
const originalContextWindowEnv = {
ECC_CONTEXT_WINDOW_TOKENS: process.env.ECC_CONTEXT_WINDOW_TOKENS,
CLAUDE_CODE_AUTO_COMPACT_WINDOW: process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW,
};
delete process.env.ECC_CONTEXT_WINDOW_TOKENS;
delete process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW;
@@ -218,6 +223,39 @@ test('treats an empty model id as standard window', () => {
assert.strictEqual(resolveContextWindowTokens(100000, ''), STANDARD_CONTEXT_WINDOW_TOKENS);
});
// ── isContextWindowInferred ──
console.log('\nisContextWindowInferred:');
test('flags the assumed 200k default as inferred', () => {
assert.strictEqual(isContextWindowInferred(187000, 'claude-opus-9'), true);
});
test('an env override is a detected window, not inferred', () => {
process.env.ECC_CONTEXT_WINDOW_TOKENS = '1000000';
try {
assert.strictEqual(isContextWindowInferred(187000, 'claude-opus-9'), false);
} finally {
delete process.env.ECC_CONTEXT_WINDOW_TOKENS;
}
});
test('a [1m] marker is a detected window, not inferred', () => {
assert.strictEqual(isContextWindowInferred(187000, 'claude-opus-4-5[1m]'), false);
});
test('a known large-window family is a detected window, not inferred', () => {
assert.strictEqual(isContextWindowInferred(187000, 'claude-fable-5'), false);
});
test('tokens above the standard window still leave the exact size inferred', () => {
assert.strictEqual(isContextWindowInferred(220000, 'claude-opus-9'), true);
});
for (const [name, value] of Object.entries(originalContextWindowEnv)) {
if (value === undefined) delete process.env[name];
else process.env[name] = value;
}
// ── resolveContextThreshold ──
console.log('\nresolveContextThreshold:');
+70
View File
@@ -46,6 +46,76 @@ function main() {
assert.strictEqual(result.status, 0, result.stderr)
assert.ok(fs.existsSync(distEntry), ".opencode/dist/index.js should exist after build")
}],
["built OpenCode entry exports only the plugin function", () => {
const check = `
const assert = require("assert")
const { pathToFileURL } = require("url")
async function main() {
let mod
try {
mod = await import(pathToFileURL(process.argv[1]).href)
} catch (error) {
console.error(error)
process.exit(1)
}
assert.deepStrictEqual(Object.keys(mod).sort(), ["default"])
assert.strictEqual(typeof mod.default, "function")
let shellCalls = 0
const plugin = await mod.default({
client: { app: { log: () => {} } },
$: async () => {
shellCalls += 1
throw new Error("$ must not be called during plugin init")
},
directory: process.cwd(),
worktree: process.cwd(),
})
assert.strictEqual(shellCalls, 0, "$ must not be called during plugin init")
assert.ok(plugin && typeof plugin === "object", "default export must return a plugin record")
const expectedHooks = [
"file.edited",
"tool.execute.after",
"tool.execute.before",
"session.created",
"session.idle",
"session.deleted",
"file.watcher.updated",
"todo.updated",
"shell.env",
"experimental.session.compacting",
"permission.ask",
]
for (const hook of expectedHooks) {
assert.strictEqual(typeof plugin[hook], "function", "missing hook: " + hook)
}
assert.ok(plugin.tool && typeof plugin.tool === "object", "plugin record must expose a tool object")
assert.deepStrictEqual(
Object.keys(plugin.tool).sort(),
["changed-files", "dependency-analyzer"],
"plugin.tool must expose exactly the custom tools"
)
for (const toolName of ["changed-files", "dependency-analyzer"]) {
const toolDefinition = plugin.tool[toolName]
assert.ok(toolDefinition && typeof toolDefinition === "object", "missing tool: " + toolName)
assert.strictEqual(typeof toolDefinition.description, "string", toolName + " must declare a description")
assert.ok(toolDefinition.args && typeof toolDefinition.args === "object", toolName + " must declare args")
assert.strictEqual(typeof toolDefinition.execute, "function", toolName + " must declare an execute function")
}
}
main().catch((error) => {
console.error(error)
process.exit(1)
})
`
const result = spawnSync(process.execPath, ["-e", check, distEntry], {
cwd: repoRoot,
encoding: "utf8",
})
assert.strictEqual(result.status, 0, result.stderr)
}],
["npm pack includes the compiled OpenCode dist payload", () => {
const result = spawnSync("npm", ["pack", "--dry-run", "--json"], {
cwd: repoRoot,
+208 -11
View File
@@ -11,6 +11,7 @@ const TOML = require('@iarna/toml');
const repoRoot = path.join(__dirname, '..', '..');
const installScript = path.join(repoRoot, 'scripts', 'codex', 'install-global-git-hooks.sh');
const prePushHook = path.join(repoRoot, 'scripts', 'codex-git-hooks', 'pre-push');
const pluginCacheCheckScript = path.join(repoRoot, 'scripts', 'codex', 'check-plugin-cache.js');
const mergeCodexConfigScript = path.join(repoRoot, 'scripts', 'codex', 'merge-codex-config.js');
const mergeMcpConfigScript = path.join(repoRoot, 'scripts', 'codex', 'merge-mcp-config.js');
@@ -42,18 +43,39 @@ function cleanup(dirPath) {
fs.rmSync(dirPath, { recursive: true, force: true });
}
function runBash(scriptPath, args = [], env = {}, cwd = repoRoot) {
return spawnSync('bash', [scriptPath, ...args], {
function resolveBashExecutable(env = process.env) {
return env.BASH_PATH
|| (process.platform === 'win32' && fs.existsSync('C:\\Program Files\\Git\\bin\\bash.exe')
? 'C:\\Program Files\\Git\\bin\\bash.exe'
: fs.existsSync('/bin/bash')
? '/bin/bash'
: 'bash');
}
function runBash(
scriptPath,
{ args = [], env = {}, cwd = repoRoot, input = undefined, preservePath = true } = {},
) {
const effectiveEnv = {
...(preservePath ? process.env : {}),
...env,
};
const bash = resolveBashExecutable(effectiveEnv);
return spawnSync(bash, [scriptPath, ...args], {
cwd,
env: {
...process.env,
...env,
},
env: effectiveEnv,
encoding: 'utf8',
input,
stdio: ['pipe', 'pipe', 'pipe'],
});
}
function toBashPath(filePath) {
return process.platform === 'win32'
? `/${filePath[0].toLowerCase()}${filePath.slice(2).replaceAll('\\', '/')}`
: filePath;
}
function runNode(scriptPath, args = [], env = {}, cwd = repoRoot) {
return spawnSync('node', [scriptPath, ...args], {
cwd,
@@ -116,6 +138,174 @@ const cacheManifestWithLocalRefs = {
let passed = 0;
let failed = 0;
if (
test('shell test runner honors an explicit BASH_PATH override', () => {
assert.strictEqual(
resolveBashExecutable({ BASH_PATH: '/custom/git/bin/bash' }),
'/custom/git/bin/bash',
);
})
)
passed++;
else failed++;
if (
test('shell test runner honors a per-invocation BASH_PATH override', () => {
const tempDir = createTempDir('ecc-missing-bash-');
try {
const missingBash = path.join(tempDir, 'bash');
const result = runBash(prePushHook, { env: { BASH_PATH: missingBash } });
assert.strictEqual(result.error?.code, 'ENOENT');
} finally {
cleanup(tempDir);
}
})
)
passed++;
else failed++;
function runHermeticPrePush({
failScript = null,
includeCorepack = true,
includePnpm = false,
audit = false,
} = {}) {
const tempDir = createTempDir('codex-pre-push-');
const binDir = path.join(tempDir, 'bin');
const projectDir = path.join(tempDir, 'project');
const callsPath = path.join(tempDir, 'calls.txt');
const bashEnv = path.join(tempDir, 'bash-env');
fs.mkdirSync(binDir);
fs.mkdirSync(projectDir);
const functionStub = (name, corepack) => `${name}() {
${corepack ? 'node -e \'const p=require("./package.json"); process.exit(p.packageManager === "pnpm@11.9.0" ? 0 : 1)\' || return 97' : ':'}
printf '%s\\n' "${corepack ? '' : 'pnpm '}$*" >> "${toBashPath(callsPath)}"
${corepack ? 'shift' : ':'}
shift
test "$1" != "${failScript || '__never__'}"
}`;
fs.writeFileSync(
bashEnv,
`git() { return 0; }
node() { "${toBashPath(process.execPath)}" "$@"; }
${includeCorepack ? functionStub('corepack', true) : ''}
${includePnpm ? functionStub('pnpm', false) : ''}
`,
);
fs.writeFileSync(path.join(projectDir, 'pnpm-lock.yaml'), 'lockfileVersion: 9\n');
const initialized = spawnSync('git', ['init', '--quiet'], { cwd: projectDir });
assert.strictEqual(initialized.status, 0, initialized.stderr?.toString());
writeJson(path.join(projectDir, 'package.json'), {
packageManager: 'pnpm@11.9.0',
scripts: { lint: 'x', typecheck: 'x', test: 'x', build: 'x' },
});
const result = runBash(prePushHook, {
env: {
PATH: toBashPath(binDir),
BASH_ENV: toBashPath(bashEnv),
ECC_PREPUSH_AUDIT: audit ? '1' : '0',
ECC_SKIP_GIT_HOOKS: '0',
ECC_SKIP_PREPUSH: '0',
MSYS_NO_PATHCONV: '1',
},
cwd: projectDir,
input: Buffer.from('refs/heads/main 1111111111111111111111111111111111111111 refs/heads/main 0000000000000000000000000000000000000000\n'),
preservePath: false,
});
const calls = fs.existsSync(callsPath)
? fs.readFileSync(callsPath, 'utf8').trim().split(/\r?\n/)
: [];
cleanup(tempDir);
return { result, calls };
}
if (
test('pre-push uses Corepack pinned pnpm and runs every required verification script', () => {
const { result, calls } = runHermeticPrePush();
assert.strictEqual(result.status, 0, JSON.stringify(result, null, 2));
assert.deepStrictEqual(calls, [
'pnpm run lint',
'pnpm run typecheck',
'pnpm run test',
'pnpm run build',
], JSON.stringify(result, null, 2));
})
)
passed++;
else failed++;
if (
test('pre-push falls back to direct pnpm when Corepack is absent', () => {
const { result, calls } = runHermeticPrePush({
includeCorepack: false,
includePnpm: true,
});
assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`);
assert.deepStrictEqual(calls, [
'pnpm run lint',
'pnpm run typecheck',
'pnpm run test',
'pnpm run build',
]);
})
)
passed++;
else failed++;
if (
test('pre-push fails closed when pnpm and Corepack cannot resolve', () => {
const { result } = runHermeticPrePush({ includeCorepack: false });
assert.notStrictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`);
assert.match(result.stderr, /pnpm.*(?:resolve|found)/i);
})
)
passed++;
else failed++;
if (
test('pre-push stops immediately when a required verification script fails', () => {
const { result, calls } = runHermeticPrePush({ failScript: 'typecheck' });
assert.notStrictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`);
assert.deepStrictEqual(calls, ['pnpm run lint', 'pnpm run typecheck']);
assert.match(result.stderr, /typecheck failed/);
})
)
passed++;
else failed++;
if (
test('pre-push runs the production audit through Corepack pnpm', () => {
const { result, calls } = runHermeticPrePush({ audit: true });
assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`);
assert.deepStrictEqual(calls, [
'pnpm run lint',
'pnpm run typecheck',
'pnpm run test',
'pnpm run build',
'pnpm audit --prod',
]);
})
)
passed++;
else failed++;
if (
test('pre-push fails closed when the production audit fails', () => {
const { result, calls } = runHermeticPrePush({ audit: true, failScript: '--prod' });
assert.notStrictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`);
assert.deepStrictEqual(calls, [
'pnpm run lint',
'pnpm run typecheck',
'pnpm run test',
'pnpm run build',
'pnpm audit --prod',
]);
assert.match(result.stderr, /pnpm audit failed/);
})
)
passed++;
else failed++;
if (
test('check-plugin-cache fails when the installed cache is missing manifest-referenced files', () => {
const homeDir = createTempDir('codex-plugin-cache-home-');
@@ -266,9 +456,11 @@ if (os.platform() === 'win32') {
const weirdHooksDir = path.join(homeDir, 'git-hooks "quoted"');
try {
const result = runBash(installScript, [], {
HOME: homeDir,
ECC_GLOBAL_HOOKS_DIR: weirdHooksDir,
const result = runBash(installScript, {
env: {
HOME: homeDir,
ECC_GLOBAL_HOOKS_DIR: weirdHooksDir,
},
});
assert.strictEqual(result.status, 0, result.stderr || result.stdout);
@@ -663,7 +855,10 @@ if (
fs.mkdirSync(codexDir, { recursive: true });
fs.writeFileSync(configPath, config);
const syncResult = runBash(syncScript, ['--update-mcp'], makeHermeticCodexEnv(homeDir, codexDir));
const syncResult = runBash(syncScript, {
args: ['--update-mcp'],
env: makeHermeticCodexEnv(homeDir, codexDir),
});
assert.strictEqual(syncResult.status, 0, `${syncResult.stdout}\n${syncResult.stderr}`);
const syncedAgents = fs.readFileSync(agentsPath, 'utf8');
@@ -724,7 +919,9 @@ if (
fs.mkdirSync(codexDir, { recursive: true });
fs.writeFileSync(configPath, config);
const syncResult = runBash(syncScript, [], makeHermeticCodexEnv(homeDir, codexDir));
const syncResult = runBash(syncScript, {
env: makeHermeticCodexEnv(homeDir, codexDir),
});
assert.strictEqual(syncResult.status, 0, `${syncResult.stdout}\n${syncResult.stderr}`);
const parsedConfig = TOML.parse(fs.readFileSync(configPath, 'utf8'));
+3 -1
View File
@@ -32,6 +32,7 @@ const windowsPackageCommands = new Set([
]);
const unsafeWindowsShellChars = /[\r\n"&|<>^%!()]/;
const commandTimeoutMs = 90_000;
const archiveExtractionTimeoutMs = 180_000;
let passed = 0;
let failed = 0;
@@ -96,7 +97,7 @@ function run(command, args, options = {}) {
env: options.env || process.env,
maxBuffer: 10 * 1024 * 1024,
shell: invocation.shell || false,
timeout: commandTimeoutMs,
timeout: options.timeout ?? commandTimeoutMs,
windowsHide: true,
});
@@ -171,6 +172,7 @@ function prepareLocalPackedProject(packageManager) {
fs.mkdirSync(modulesDirectory, { recursive: true });
run('tar', ['-xzf', fixture.archivePath, '-C', modulesDirectory], {
cwd: projectDirectory,
timeout: archiveExtractionTimeoutMs,
});
fs.renameSync(extractedDirectory, packageDirectory);
fs.mkdirSync(binDirectory, { recursive: true });
@@ -0,0 +1,152 @@
#!/usr/bin/env node
'use strict';
const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawnSync } = require('child_process');
const repoRoot = path.resolve(__dirname, '..', '..');
const scanScript = path.join(repoRoot, 'skills', 'skill-stocktake', 'scripts', 'scan.sh');
const quickDiffScript = path.join(repoRoot, 'skills', 'skill-stocktake', 'scripts', 'quick-diff.sh');
let passed = 0;
let failed = 0;
function test(description, fn) {
try {
fn();
console.log(`${description}`);
passed++;
} catch (error) {
console.log(`${description}: ${error.message}`);
failed++;
}
}
function writeSkill(skillDir, name) {
fs.mkdirSync(skillDir, { recursive: true });
fs.writeFileSync(
path.join(skillDir, 'SKILL.md'),
`---\nname: ${name}\ndescription: test fixture\n---\n# ${name}\n`,
);
}
function runBash(scriptPath, args, env) {
return spawnSync('bash', [scriptPath, ...args], {
encoding: 'utf8',
env: { ...process.env, ...env },
});
}
console.log('\nSkill stocktake discovery tests:');
test('both scanners use canonical, error-visible, NUL-delimited discovery', () => {
for (const scriptPath of [scanScript, quickDiffScript]) {
const source = fs.readFileSync(scriptPath, 'utf8');
assert.match(source, /find -L "\$dir" -name "SKILL\.md" -type f -print0/);
assert.match(source, /sort_nul_file "\$find_out"/);
assert.match(source, /records\.sort\(Buffer\.compare\)/);
assert.doesNotMatch(source, /sort -z/, `${path.basename(scriptPath)} still requires GNU sort`);
assert.match(source, /read -r -d '' file/);
assert.doesNotMatch(source, /find [^\n]*2>\/dev\/null/, `${path.basename(scriptPath)} still hides find errors`);
}
});
if (process.platform === 'win32') {
console.log(' ↷ POSIX symlink and newline-path integration cases skipped on Windows');
} else {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-skill-stocktake-'));
try {
const projectSkills = path.join(tempRoot, 'project', '.claude', 'skills');
const directSkill = path.join(projectSkills, 'direct skill');
const linkedTarget = path.join(tempRoot, 'shared', 'linked-skill');
const newlineSkill = path.join(projectSkills, 'newline\nskill');
const resultsPath = path.join(tempRoot, 'results.json');
const observationsPath = path.join(tempRoot, 'observations.jsonl');
writeSkill(directSkill, 'direct-skill');
writeSkill(linkedTarget, 'linked-skill');
writeSkill(newlineSkill, 'newline-skill');
fs.symlinkSync(linkedTarget, path.join(projectSkills, 'linked-skill'), 'dir');
fs.mkdirSync(path.join(directSkill, 'references'), { recursive: true });
fs.writeFileSync(path.join(directSkill, 'references', 'notes.md'), '# supporting notes\n');
fs.writeFileSync(
resultsPath,
JSON.stringify({ evaluated_at: '2099-01-01T00:00:00Z', skills: [] }),
);
fs.writeFileSync(
observationsPath,
`${JSON.stringify({
tool: 'Read',
path: path.join(newlineSkill, 'SKILL.md'),
timestamp: new Date().toISOString(),
})}\n${JSON.stringify({
tool: 'Read',
path: path.join(directSkill, 'SKILL.md'),
timestamp: new Date().toISOString(),
})}\n`,
);
const env = {
SKILL_STOCKTAKE_GLOBAL_DIR: path.join(tempRoot, 'missing-global'),
SKILL_STOCKTAKE_PROJECT_DIR: projectSkills,
SKILL_STOCKTAKE_OBSERVATIONS: observationsPath,
};
test('scan follows symlinked skills and ignores nested Markdown assets', () => {
const result = runBash(scanScript, [], env);
assert.strictEqual(result.status, 0, result.stderr);
const output = JSON.parse(result.stdout);
assert.strictEqual(output.scan_summary.project.count, 3);
assert.deepStrictEqual(
output.skills.map(skill => skill.name).sort(),
['direct-skill', 'linked-skill', 'newline-skill'],
);
const newlineEntry = output.skills.find(skill => skill.name === 'newline-skill');
assert.strictEqual(newlineEntry.use_7d, 1);
assert.strictEqual(newlineEntry.use_30d, 1);
const spaceEntry = output.skills.find(skill => skill.name === 'direct-skill');
assert.strictEqual(spaceEntry.use_7d, 1);
assert.strictEqual(spaceEntry.use_30d, 1);
});
test('quick diff keeps newline-containing skill paths as one record', () => {
const result = runBash(quickDiffScript, [resultsPath], env);
assert.strictEqual(result.status, 0, result.stderr);
const output = JSON.parse(result.stdout);
assert.strictEqual(output.length, 3);
assert.strictEqual(
output.filter(entry => entry.path.includes('newline\nskill/SKILL.md')).length,
1,
);
assert.ok(output.every(entry => entry.is_new === true));
});
test('quick diff recognizes a cached newline-containing path', () => {
fs.writeFileSync(
resultsPath,
JSON.stringify({
evaluated_at: '2099-01-01T00:00:00Z',
skills: [{ path: path.join(newlineSkill, 'SKILL.md') }],
}),
);
const result = runBash(quickDiffScript, [resultsPath], env);
assert.strictEqual(result.status, 0, result.stderr);
const output = JSON.parse(result.stdout);
assert.strictEqual(output.length, 2);
assert.ok(output.every(entry => !entry.path.includes('newline\nskill/SKILL.md')));
});
} catch (error) {
console.log(` ✗ fixture setup: ${error.message}`);
failed++;
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
}
console.log(`\nPassed: ${passed}`);
console.log(`Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);