etymd 0.2.2 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +70 -2
- package/README.md +145 -37
- package/dist/{approve-VC4V3OPW.js → approve-3B2UL7LG.js} +6 -4
- package/dist/audit-4YB3K62W.js +12 -0
- package/dist/{brief-TZ45MNHD.js → brief-6EEXGQUW.js} +6 -4
- package/dist/chunk-CRJHIXJH.js +5 -0
- package/dist/{chunk-LVLZLCND.js → chunk-DKC7MLWG.js} +1 -1
- package/dist/chunk-EF6BPBG6.js +22 -0
- package/dist/{chunk-BH37LELV.js → chunk-HOLFW5QY.js} +8 -5
- package/dist/chunk-LT67FU2Y.js +59 -0
- package/dist/{chunk-IW6K5HLV.js → chunk-MEJ4MBVL.js} +2 -2
- package/dist/{chunk-EUIHYD6F.js → chunk-MMPV67FW.js} +2 -2
- package/dist/{chunk-ANCCGYSH.js → chunk-NMZ3RHWW.js} +31 -59
- package/dist/{chunk-FZSIOCOZ.js → chunk-R74SJ7HS.js} +1 -1
- package/dist/{chunk-LBRNZZZF.js → chunk-TADKOW6P.js} +1 -99
- package/dist/{chunk-KMAJIQHC.js → chunk-V6NZEIHF.js} +7 -5
- package/dist/{chunk-MWIK6ML3.js → chunk-VFUYQLW7.js} +141 -25
- package/dist/{chunk-KBF3SW3N.js → chunk-WWBF4Y7K.js} +1 -1
- package/dist/chunk-YLNQQZ5F.js +102 -0
- package/dist/cli.js +50 -16
- package/dist/config-MX7OYI7F.js +4 -0
- package/dist/{context-GYCLR3TG.js → context-4DGXO7JR.js} +5 -3
- package/dist/doctor-T76ZNL4S.js +19 -0
- package/dist/{fleet-N3G5ZIGR.js → fleet-WOPKM2Z3.js} +248 -10
- package/dist/gates-WIGDS5VF.js +262 -0
- package/dist/generate-I75IEXXF.js +6 -0
- package/dist/index.d.ts +122 -33
- package/dist/index.js +737 -257
- package/dist/{init-4CQOLFQG.js → init-NSYVVMF2.js} +9 -5
- package/dist/ledger-TMIV45AA.js +5 -0
- package/dist/scan-BAD2UJAL.js +5 -0
- package/dist/{scan-CYOOGJFG.js → scan-COEPZJBO.js} +6 -4
- package/dist/screen-U7JMWZPS.js +134 -0
- package/package.json +2 -2
- package/dist/audit-LZJRJS5V.js +0 -9
- package/dist/doctor-6JDAHGDT.js +0 -16
- package/dist/gates-G5BCMRRV.js +0 -71
- package/dist/ledger-RHQJH5EA.js +0 -4
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { ETYMD_DIR } from './chunk-
|
|
3
|
-
import { pathExists, readText
|
|
4
|
-
import
|
|
5
|
-
import { promises } from 'node:fs';
|
|
2
|
+
import { ETYMD_DIR } from './chunk-R74SJ7HS.js';
|
|
3
|
+
import { pathExists, readText } from './chunk-YLNQQZ5F.js';
|
|
4
|
+
import path from 'node:path';
|
|
6
5
|
|
|
7
|
-
var CONFIG_FILE =
|
|
6
|
+
var CONFIG_FILE = path.join(ETYMD_DIR, "config.json");
|
|
8
7
|
var DEFAULT_CONFIG = {
|
|
9
8
|
instructions: { include: [], exclude: [] },
|
|
10
9
|
context: { perFileWords: 4e3, totalWords: 8e3 },
|
|
11
|
-
state: { staleAfterDays: 30, maxChars: 9500 }
|
|
10
|
+
state: { staleAfterDays: 30, maxChars: 9500 },
|
|
11
|
+
gates: { commands: [], failOn: "risk", allowWriting: [] }
|
|
12
12
|
};
|
|
13
13
|
function configPath(root) {
|
|
14
|
-
return
|
|
14
|
+
return path.join(root, CONFIG_FILE);
|
|
15
15
|
}
|
|
16
16
|
function readGlobList(value, key, problems) {
|
|
17
17
|
if (value === void 0) return void 0;
|
|
@@ -53,6 +53,23 @@ async function readConfig(root) {
|
|
|
53
53
|
const instructions = obj.instructions ?? {};
|
|
54
54
|
const context = obj.context ?? {};
|
|
55
55
|
const state = obj.state ?? {};
|
|
56
|
+
const gates = obj.gates ?? {};
|
|
57
|
+
const failOn = (() => {
|
|
58
|
+
if (gates.failOn === void 0) return void 0;
|
|
59
|
+
if (typeof gates.failOn !== "string" || !["risk", "gap", "polish"].includes(gates.failOn.toLowerCase())) {
|
|
60
|
+
problems.push(`${CONFIG_FILE}: \`gates.failOn\` must be risk | gap | polish \u2014 ignored.`);
|
|
61
|
+
return void 0;
|
|
62
|
+
}
|
|
63
|
+
return gates.failOn.toLowerCase();
|
|
64
|
+
})();
|
|
65
|
+
const publishGate = (() => {
|
|
66
|
+
if (gates.publishGate === void 0) return void 0;
|
|
67
|
+
if (typeof gates.publishGate !== "boolean") {
|
|
68
|
+
problems.push(`${CONFIG_FILE}: \`gates.publishGate\` must be true or false \u2014 ignored.`);
|
|
69
|
+
return void 0;
|
|
70
|
+
}
|
|
71
|
+
return gates.publishGate;
|
|
72
|
+
})();
|
|
56
73
|
return {
|
|
57
74
|
present: true,
|
|
58
75
|
problems,
|
|
@@ -68,60 +85,15 @@ async function readConfig(root) {
|
|
|
68
85
|
state: {
|
|
69
86
|
staleAfterDays: readBudget(state.staleAfterDays, "state.staleAfterDays", problems) ?? DEFAULT_CONFIG.state.staleAfterDays,
|
|
70
87
|
maxChars: readBudget(state.maxChars, "state.maxChars", problems) ?? DEFAULT_CONFIG.state.maxChars
|
|
88
|
+
},
|
|
89
|
+
gates: {
|
|
90
|
+
commands: readGlobList(gates.commands, "gates.commands", problems) ?? DEFAULT_CONFIG.gates.commands,
|
|
91
|
+
failOn: failOn ?? DEFAULT_CONFIG.gates.failOn,
|
|
92
|
+
publishGate,
|
|
93
|
+
allowWriting: readGlobList(gates.allowWriting, "gates.allowWriting", problems) ?? DEFAULT_CONFIG.gates.allowWriting
|
|
71
94
|
}
|
|
72
95
|
}
|
|
73
96
|
};
|
|
74
97
|
}
|
|
75
|
-
var ALWAYS_LOADED = [
|
|
76
|
-
{ path: "AGENTS.md", role: "operating contract" },
|
|
77
|
-
{ path: "PROJECT_CONTEXT.md", role: "ground-truth state" },
|
|
78
|
-
{ path: "CLAUDE.md", role: "Claude Code pointer" },
|
|
79
|
-
{ path: "GEMINI.md", role: "Gemini pointer" },
|
|
80
|
-
{ path: ".github/copilot-instructions.md", role: "Copilot instructions" },
|
|
81
|
-
{ path: ".cursorrules", role: "Cursor rules (legacy)" }
|
|
82
|
-
];
|
|
83
|
-
var EXTRACTION_THRESHOLD = DEFAULT_CONFIG.context.perFileWords;
|
|
84
|
-
function isAlwaysAppliedCursorRule(text) {
|
|
85
|
-
const fm = text.match(/^---\n([\s\S]*?)\n---/);
|
|
86
|
-
if (!fm) return true;
|
|
87
|
-
const frontmatter = fm[1] ?? "";
|
|
88
|
-
return /^\s*alwaysApply\s*:\s*true\s*$/m.test(frontmatter);
|
|
89
|
-
}
|
|
90
|
-
async function measureContext(root, perFileWords = EXTRACTION_THRESHOLD) {
|
|
91
|
-
const files = [];
|
|
92
|
-
for (const spec of ALWAYS_LOADED) {
|
|
93
|
-
const abs = path2.join(root, spec.path);
|
|
94
|
-
const text = await readText(abs);
|
|
95
|
-
if (text === null) continue;
|
|
96
|
-
const words = wordCount(text);
|
|
97
|
-
files.push({ path: spec.path, role: spec.role, words, approxTokens: approxTokens(words) });
|
|
98
|
-
}
|
|
99
|
-
const rulesDir = path2.join(root, ".cursor", "rules");
|
|
100
|
-
if (await pathExists(rulesDir)) {
|
|
101
|
-
try {
|
|
102
|
-
for (const entry of await promises.readdir(rulesDir)) {
|
|
103
|
-
if (!entry.endsWith(".mdc") && !entry.endsWith(".md")) continue;
|
|
104
|
-
const text = await readText(path2.join(rulesDir, entry));
|
|
105
|
-
if (text === null || !isAlwaysAppliedCursorRule(text)) continue;
|
|
106
|
-
const words = wordCount(text);
|
|
107
|
-
files.push({
|
|
108
|
-
path: `.cursor/rules/${entry}`,
|
|
109
|
-
role: "Cursor rule (always applied)",
|
|
110
|
-
words,
|
|
111
|
-
approxTokens: approxTokens(words)
|
|
112
|
-
});
|
|
113
|
-
}
|
|
114
|
-
} catch {
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
const totalWords = files.reduce((s, f) => s + f.words, 0);
|
|
118
|
-
return {
|
|
119
|
-
files: files.sort((a, b) => b.words - a.words),
|
|
120
|
-
totalWords,
|
|
121
|
-
totalApproxTokens: approxTokens(totalWords),
|
|
122
|
-
perFileWords,
|
|
123
|
-
extractionCandidates: files.filter((f) => f.words >= perFileWords)
|
|
124
|
-
};
|
|
125
|
-
}
|
|
126
98
|
|
|
127
|
-
export { CONFIG_FILE, DEFAULT_CONFIG,
|
|
99
|
+
export { CONFIG_FILE, DEFAULT_CONFIG, configPath, readConfig };
|
|
@@ -1,104 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { execFile } from 'node:child_process';
|
|
3
|
-
import { promises } from 'node:fs';
|
|
4
|
-
import { promisify } from 'node:util';
|
|
5
2
|
import pc from 'picocolors';
|
|
6
3
|
|
|
7
|
-
var pExecFile = promisify(execFile);
|
|
8
|
-
async function pathExists(p) {
|
|
9
|
-
try {
|
|
10
|
-
await promises.access(p);
|
|
11
|
-
return true;
|
|
12
|
-
} catch {
|
|
13
|
-
return false;
|
|
14
|
-
}
|
|
15
|
-
}
|
|
16
|
-
async function readJson(p) {
|
|
17
|
-
try {
|
|
18
|
-
const raw = await promises.readFile(p, "utf8");
|
|
19
|
-
return JSON.parse(raw);
|
|
20
|
-
} catch {
|
|
21
|
-
return null;
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
async function readText(p) {
|
|
25
|
-
try {
|
|
26
|
-
return await promises.readFile(p, "utf8");
|
|
27
|
-
} catch {
|
|
28
|
-
return null;
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
async function isDirectory(p) {
|
|
32
|
-
try {
|
|
33
|
-
return (await promises.stat(p)).isDirectory();
|
|
34
|
-
} catch {
|
|
35
|
-
return false;
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
async function git(root, args) {
|
|
39
|
-
try {
|
|
40
|
-
const { stdout } = await pExecFile("git", args, { cwd: root, timeout: 4e3 });
|
|
41
|
-
return stdout.trim();
|
|
42
|
-
} catch {
|
|
43
|
-
return null;
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
function wordCount(text) {
|
|
47
|
-
const m = text.trim().match(/\S+/g);
|
|
48
|
-
return m ? m.length : 0;
|
|
49
|
-
}
|
|
50
|
-
function approxTokens(words) {
|
|
51
|
-
return Math.round(words / 0.75);
|
|
52
|
-
}
|
|
53
|
-
var CI_ENV_VARS = [
|
|
54
|
-
"CI",
|
|
55
|
-
"GITHUB_ACTIONS",
|
|
56
|
-
"GITLAB_CI",
|
|
57
|
-
"BUILDKITE",
|
|
58
|
-
"CIRCLECI",
|
|
59
|
-
"JENKINS_URL",
|
|
60
|
-
"TF_BUILD"
|
|
61
|
-
];
|
|
62
|
-
function isCiEnvironment() {
|
|
63
|
-
return CI_ENV_VARS.some((v) => Boolean(process.env[v]));
|
|
64
|
-
}
|
|
65
|
-
function normalizeRelPath(p) {
|
|
66
|
-
return p.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, "");
|
|
67
|
-
}
|
|
68
|
-
function globToRegExp(glob) {
|
|
69
|
-
let re = "";
|
|
70
|
-
for (let i = 0; i < glob.length; i++) {
|
|
71
|
-
const c = glob[i];
|
|
72
|
-
if (c === "*") {
|
|
73
|
-
if (glob[i + 1] === "*") {
|
|
74
|
-
i += 1;
|
|
75
|
-
if (glob[i + 1] === "/") {
|
|
76
|
-
i += 1;
|
|
77
|
-
re += "(?:.*/)?";
|
|
78
|
-
} else {
|
|
79
|
-
re += ".*";
|
|
80
|
-
}
|
|
81
|
-
} else {
|
|
82
|
-
re += "[^/]*";
|
|
83
|
-
}
|
|
84
|
-
} else if (c === "?") {
|
|
85
|
-
re += "[^/]";
|
|
86
|
-
} else {
|
|
87
|
-
re += c.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
return new RegExp(`^${re}$`);
|
|
91
|
-
}
|
|
92
|
-
function matchesGlob(p, glob) {
|
|
93
|
-
const target = normalizeRelPath(p);
|
|
94
|
-
const pattern = normalizeRelPath(glob);
|
|
95
|
-
if (!pattern) return false;
|
|
96
|
-
if (!/[*?]/.test(pattern)) return target === pattern || target.startsWith(pattern + "/");
|
|
97
|
-
return globToRegExp(pattern).test(target);
|
|
98
|
-
}
|
|
99
|
-
function matchesAnyGlob(p, globs) {
|
|
100
|
-
return globs.some((g) => matchesGlob(p, g));
|
|
101
|
-
}
|
|
102
4
|
var theme = {
|
|
103
5
|
brand: (s) => pc.magenta(pc.bold(s)),
|
|
104
6
|
heading: (s) => pc.bold(s),
|
|
@@ -356,4 +258,4 @@ function renderLedger(ledger) {
|
|
|
356
258
|
}
|
|
357
259
|
}
|
|
358
260
|
|
|
359
|
-
export { TIER_BADGE,
|
|
261
|
+
export { TIER_BADGE, glyph, print, renderBaselineDrift, renderContext, renderFacts, renderFindings, renderFleetNotes, renderFleetRows, renderLedger, renderLedgerDiff, renderLensCoverage, renderPlan, section, theme };
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
2
|
+
import { measureContext } from './chunk-LT67FU2Y.js';
|
|
3
|
+
import { readLedger, reconcileLedger, writeLedger, visibleFindings } from './chunk-WWBF4Y7K.js';
|
|
4
|
+
import { DEFAULT_CONFIG, readConfig, CONFIG_FILE } from './chunk-NMZ3RHWW.js';
|
|
5
|
+
import { scanProject, expandFileGlobs } from './chunk-HOLFW5QY.js';
|
|
6
|
+
import { ETYMD_DIR, writeCachedFacts, readBaseline, deriveProfile, baselineCarriesMachinePath, BASELINE_FILE } from './chunk-R74SJ7HS.js';
|
|
7
|
+
import { PACK_VERSION } from './chunk-CRJHIXJH.js';
|
|
8
|
+
import { pathExists, readJson, git, readText, isDirectory, matchesAnyGlob, isCiEnvironment, normalizeRelPath } from './chunk-YLNQQZ5F.js';
|
|
7
9
|
import path from 'node:path';
|
|
8
10
|
import YAML from 'yaml';
|
|
9
11
|
import { promises } from 'node:fs';
|
|
@@ -1,26 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
2
|
+
import { DEFAULT_CONFIG } from './chunk-NMZ3RHWW.js';
|
|
3
|
+
import { PACK_VERSION } from './chunk-CRJHIXJH.js';
|
|
4
|
+
import { readText, pathExists } from './chunk-YLNQQZ5F.js';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
|
|
7
|
-
async function applyFiles(root, files, overwrite = /* @__PURE__ */ new Set()) {
|
|
8
|
-
const written = [];
|
|
9
|
-
const skipped = [];
|
|
10
|
-
for (const file of files) {
|
|
11
|
-
if (file.exists && !overwrite.has(file.path)) {
|
|
12
|
-
skipped.push(file.path);
|
|
13
|
-
continue;
|
|
14
|
-
}
|
|
15
|
-
const abs = path.join(root, file.path);
|
|
16
|
-
await promises.mkdir(path.dirname(abs), { recursive: true });
|
|
17
|
-
await promises.writeFile(abs, file.contents, "utf8");
|
|
18
|
-
if (file.executable) await promises.chmod(abs, 493);
|
|
19
|
-
written.push(file.path);
|
|
20
|
-
}
|
|
21
|
-
return { written, skipped };
|
|
22
|
-
}
|
|
23
|
-
|
|
24
7
|
// src/pack/templates.ts
|
|
25
8
|
function runPrefix(pm) {
|
|
26
9
|
switch (pm) {
|
|
@@ -128,26 +111,133 @@ ${[
|
|
|
128
111
|
<!-- etymd pack v${PACK_VERSION} -->
|
|
129
112
|
`;
|
|
130
113
|
}
|
|
114
|
+
var CONTENT_GATE_RESOLUTION = `GATE="\${CONTENT_GATE:-$(command -v etymd || true)}"`;
|
|
115
|
+
function localHookCall(hook) {
|
|
116
|
+
return `# Repo-owned checks. This file is generated and will be overwritten; \`.githooks/${hook}.local\`
|
|
117
|
+
# is yours \u2014 etymd never reads, writes, or regenerates it. Put project-specific guards there.
|
|
118
|
+
LOCAL="$(dirname "$0")/${hook}.local"
|
|
119
|
+
if [ -x "$LOCAL" ]; then
|
|
120
|
+
"$LOCAL" "$@" || exit 1
|
|
121
|
+
fi`;
|
|
122
|
+
}
|
|
131
123
|
function generatePreCommitHook() {
|
|
132
124
|
return `#!/usr/bin/env sh
|
|
133
125
|
# etymd: process gate. Cheap, locally-knowable checks belong here (fast, blocks the commit).
|
|
126
|
+
|
|
127
|
+
${localHookCall("pre-commit")}
|
|
128
|
+
|
|
129
|
+
# Content screen \u2014 staged file bytes. Refuses to commit environment, employer or identity
|
|
130
|
+
# detail into a repo whose history is (or could become) public. The checker and its patterns
|
|
131
|
+
# are machine-local by design, so this is a NO-OP wherever no checker is installed: safe to
|
|
132
|
+
# commit anywhere, active only where you opted in.
|
|
133
|
+
#
|
|
134
|
+
# Bypass, with a reason: git commit --no-verify
|
|
135
|
+
${CONTENT_GATE_RESOLUTION}
|
|
136
|
+
if [ -x "$GATE" ]; then
|
|
137
|
+
"$GATE" screen --staged || exit 1
|
|
138
|
+
fi
|
|
139
|
+
|
|
134
140
|
exit 0
|
|
135
141
|
`;
|
|
136
142
|
}
|
|
137
|
-
function
|
|
143
|
+
function generateCommitMsgHook() {
|
|
144
|
+
return `#!/usr/bin/env sh
|
|
145
|
+
# etymd: content screen \u2014 the commit message itself.
|
|
146
|
+
#
|
|
147
|
+
# The staged-content gate reads file bytes and never sees the message, yet a message is as
|
|
148
|
+
# permanently published as any file. No-op where no checker is installed.
|
|
149
|
+
#
|
|
150
|
+
# Bypass, with a reason: git commit --no-verify
|
|
151
|
+
GATE="\${COMMIT_MSG_GATE:-$(command -v etymd || true)}"
|
|
152
|
+
if [ -x "$GATE" ]; then
|
|
153
|
+
"$GATE" screen --message "$1" || exit 1
|
|
154
|
+
fi
|
|
155
|
+
|
|
156
|
+
${localHookCall("commit-msg")}
|
|
157
|
+
|
|
158
|
+
exit 0
|
|
159
|
+
`;
|
|
160
|
+
}
|
|
161
|
+
function generatePrePushHook(facts, gates) {
|
|
138
162
|
const run = runPrefix(facts.packageManager);
|
|
139
163
|
const c = facts.commands;
|
|
140
|
-
const candidates = [c.formatCheck, c.typecheck, c.lint];
|
|
141
|
-
const
|
|
164
|
+
const candidates = gates?.commands?.length ? gates.commands : [c.formatCheck, c.typecheck, c.lint];
|
|
165
|
+
const allowed = new Set(gates?.allowWriting ?? []);
|
|
166
|
+
const steps = candidates.filter(
|
|
167
|
+
(key) => Boolean(key) && (allowed.has(key) || isSafeGateCommand(c.raw[key]))
|
|
168
|
+
).map((key) => `${run} ${key}`);
|
|
142
169
|
const body = steps.length ? steps.map((s) => `echo "\u203A ${s}"
|
|
143
170
|
${s} || exit 1`).join("\n") : 'echo "etymd: no correctness commands detected \u2014 add format:check / typecheck / lint"';
|
|
171
|
+
const failOn = gates?.failOn ?? "risk";
|
|
172
|
+
const auditStep = `
|
|
173
|
+
if command -v etymd >/dev/null 2>&1; then
|
|
174
|
+
echo "\u203A etymd audit --fail-on ${failOn}"
|
|
175
|
+
etymd audit --no-ledger --fail-on ${failOn} || exit 1
|
|
176
|
+
else
|
|
177
|
+
echo "\u203A etymd audit skipped (not on PATH)"
|
|
178
|
+
fi`;
|
|
144
179
|
return `#!/usr/bin/env sh
|
|
145
180
|
# etymd: correctness gate. Mirrors CI cheapest-first; blocks the push on any failure.
|
|
181
|
+
|
|
182
|
+
${localHookCall("pre-push")}
|
|
146
183
|
${body}
|
|
184
|
+
${auditStep}
|
|
185
|
+
|
|
186
|
+
# Content screen, second pass \u2014 the WHOLE TREE rather than one diff. Catches anything committed
|
|
187
|
+
# with --no-verify and anything a rebase or merge brought in from elsewhere. Advisory here (it
|
|
188
|
+
# never blocks the push): the blocking decision belongs at commit time, where the fix is cheap.
|
|
189
|
+
${CONTENT_GATE_RESOLUTION}
|
|
190
|
+
if [ -x "$GATE" ] && [ "\${CONTENT_GATE_PREPUSH:-1}" = "1" ]; then
|
|
191
|
+
"$GATE" screen --tree --advisory || true
|
|
192
|
+
fi
|
|
193
|
+
|
|
194
|
+
exit 0
|
|
195
|
+
`;
|
|
196
|
+
}
|
|
197
|
+
function generateArtifactCheckScript() {
|
|
198
|
+
return `#!/usr/bin/env sh
|
|
199
|
+
# etymd: content screen \u2014 the published ARTIFACT, not the repository.
|
|
200
|
+
#
|
|
201
|
+
# Wire it into the irreversible moment:
|
|
202
|
+
# package.json \u2192 "prepublishOnly": "./scripts/artifact-check.sh"
|
|
203
|
+
#
|
|
204
|
+
# No-op where no checker is installed. Bypass is deliberate and loud: ARTIFACT_CHECK_SKIP=1.
|
|
205
|
+
set -eu
|
|
206
|
+
|
|
207
|
+
if [ "\${ARTIFACT_CHECK_SKIP:-0}" = "1" ]; then
|
|
208
|
+
echo "\u203A artifact-check: SKIPPED by ARTIFACT_CHECK_SKIP=1"
|
|
209
|
+
exit 0
|
|
210
|
+
fi
|
|
211
|
+
|
|
212
|
+
${CONTENT_GATE_RESOLUTION}
|
|
213
|
+
[ -x "$GATE" ] || { echo "\u203A artifact-check: no checker installed \u2014 skipping."; exit 0; }
|
|
214
|
+
|
|
215
|
+
WORK=$(mktemp -d)
|
|
216
|
+
trap 'rm -rf "$WORK"' EXIT INT TERM
|
|
217
|
+
|
|
218
|
+
# Pack exactly what would ship, then screen the unpacked bytes.
|
|
219
|
+
if [ -f package.json ]; then
|
|
220
|
+
npm pack --pack-destination "$WORK" >/dev/null 2>&1 || {
|
|
221
|
+
echo "\u203A artifact-check: npm pack failed \u2014 cannot verify what would ship" >&2; exit 1; }
|
|
222
|
+
tar -xzf "$WORK"/*.tgz -C "$WORK" 2>/dev/null || true
|
|
223
|
+
fi
|
|
224
|
+
|
|
225
|
+
"$GATE" screen --dir "$WORK" || exit 1
|
|
226
|
+
exit 0
|
|
147
227
|
`;
|
|
148
228
|
}
|
|
149
229
|
|
|
150
230
|
// src/core/generate.ts
|
|
231
|
+
function derivedCommands(facts, existingHook) {
|
|
232
|
+
const c = facts.commands;
|
|
233
|
+
const base = [c.formatCheck, c.typecheck, c.lint].filter(
|
|
234
|
+
(k) => Boolean(k) && isSafeGateCommand(c.raw[k])
|
|
235
|
+
);
|
|
236
|
+
if (c.test && existingHook && new RegExp(`\\b${c.test}\\b`).test(existingHook)) {
|
|
237
|
+
base.push(c.test);
|
|
238
|
+
}
|
|
239
|
+
return base;
|
|
240
|
+
}
|
|
151
241
|
async function planWorkflow(root, facts, opts) {
|
|
152
242
|
const out = [];
|
|
153
243
|
const add = async (rel, contents, label, executable = false) => {
|
|
@@ -167,10 +257,36 @@ async function planWorkflow(root, facts, opts) {
|
|
|
167
257
|
await add("AGENTS.md", generateAgentsMd(facts), "Minimal operating contract (scaffold)");
|
|
168
258
|
}
|
|
169
259
|
if (opts.gates) {
|
|
260
|
+
const existingPrePush = await readText(path.join(root, ".githooks", "pre-push"));
|
|
170
261
|
await add(".githooks/pre-commit", generatePreCommitHook(), "Process gate (pre-commit)", true);
|
|
171
|
-
await add(
|
|
262
|
+
await add(
|
|
263
|
+
".githooks/commit-msg",
|
|
264
|
+
generateCommitMsgHook(),
|
|
265
|
+
"Content screen (commit message)",
|
|
266
|
+
true
|
|
267
|
+
);
|
|
268
|
+
const gateConfig = opts.gateConfig?.commands?.length ? opts.gateConfig : {
|
|
269
|
+
commands: derivedCommands(facts, existingPrePush ?? void 0),
|
|
270
|
+
failOn: opts.gateConfig?.failOn ?? DEFAULT_CONFIG.gates.failOn,
|
|
271
|
+
publishGate: opts.gateConfig?.publishGate,
|
|
272
|
+
allowWriting: opts.gateConfig?.allowWriting ?? []
|
|
273
|
+
};
|
|
274
|
+
await add(
|
|
275
|
+
".githooks/pre-push",
|
|
276
|
+
generatePrePushHook(facts, gateConfig),
|
|
277
|
+
"Correctness gate (pre-push)",
|
|
278
|
+
true
|
|
279
|
+
);
|
|
280
|
+
if (opts.gateConfig?.publishGate ?? opts.publishGate ?? facts.publishable) {
|
|
281
|
+
await add(
|
|
282
|
+
"scripts/artifact-check.sh",
|
|
283
|
+
generateArtifactCheckScript(),
|
|
284
|
+
"Content screen (published artifact)",
|
|
285
|
+
true
|
|
286
|
+
);
|
|
287
|
+
}
|
|
172
288
|
}
|
|
173
289
|
return out;
|
|
174
290
|
}
|
|
175
291
|
|
|
176
|
-
export {
|
|
292
|
+
export { derivedCommands, isSafeGateCommand, planWorkflow, runPrefix };
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { execFile } from 'node:child_process';
|
|
3
|
+
import { promises } from 'node:fs';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
|
|
6
|
+
var pExecFile = promisify(execFile);
|
|
7
|
+
async function pathExists(p) {
|
|
8
|
+
try {
|
|
9
|
+
await promises.access(p);
|
|
10
|
+
return true;
|
|
11
|
+
} catch {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
async function readJson(p) {
|
|
16
|
+
try {
|
|
17
|
+
const raw = await promises.readFile(p, "utf8");
|
|
18
|
+
return JSON.parse(raw);
|
|
19
|
+
} catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
async function readText(p) {
|
|
24
|
+
try {
|
|
25
|
+
return await promises.readFile(p, "utf8");
|
|
26
|
+
} catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
async function isDirectory(p) {
|
|
31
|
+
try {
|
|
32
|
+
return (await promises.stat(p)).isDirectory();
|
|
33
|
+
} catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
async function git(root, args) {
|
|
38
|
+
try {
|
|
39
|
+
const { stdout } = await pExecFile("git", args, { cwd: root, timeout: 4e3 });
|
|
40
|
+
return stdout.trim();
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function wordCount(text) {
|
|
46
|
+
const m = text.trim().match(/\S+/g);
|
|
47
|
+
return m ? m.length : 0;
|
|
48
|
+
}
|
|
49
|
+
function approxTokens(words) {
|
|
50
|
+
return Math.round(words / 0.75);
|
|
51
|
+
}
|
|
52
|
+
var CI_ENV_VARS = [
|
|
53
|
+
"CI",
|
|
54
|
+
"GITHUB_ACTIONS",
|
|
55
|
+
"GITLAB_CI",
|
|
56
|
+
"BUILDKITE",
|
|
57
|
+
"CIRCLECI",
|
|
58
|
+
"JENKINS_URL",
|
|
59
|
+
"TF_BUILD"
|
|
60
|
+
];
|
|
61
|
+
function isCiEnvironment() {
|
|
62
|
+
return CI_ENV_VARS.some((v) => Boolean(process.env[v]));
|
|
63
|
+
}
|
|
64
|
+
function normalizeRelPath(p) {
|
|
65
|
+
return p.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, "");
|
|
66
|
+
}
|
|
67
|
+
function globToRegExp(glob) {
|
|
68
|
+
let re = "";
|
|
69
|
+
for (let i = 0; i < glob.length; i++) {
|
|
70
|
+
const c = glob[i];
|
|
71
|
+
if (c === "*") {
|
|
72
|
+
if (glob[i + 1] === "*") {
|
|
73
|
+
i += 1;
|
|
74
|
+
if (glob[i + 1] === "/") {
|
|
75
|
+
i += 1;
|
|
76
|
+
re += "(?:.*/)?";
|
|
77
|
+
} else {
|
|
78
|
+
re += ".*";
|
|
79
|
+
}
|
|
80
|
+
} else {
|
|
81
|
+
re += "[^/]*";
|
|
82
|
+
}
|
|
83
|
+
} else if (c === "?") {
|
|
84
|
+
re += "[^/]";
|
|
85
|
+
} else {
|
|
86
|
+
re += c.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return new RegExp(`^${re}$`);
|
|
90
|
+
}
|
|
91
|
+
function matchesGlob(p, glob) {
|
|
92
|
+
const target = normalizeRelPath(p);
|
|
93
|
+
const pattern = normalizeRelPath(glob);
|
|
94
|
+
if (!pattern) return false;
|
|
95
|
+
if (!/[*?]/.test(pattern)) return target === pattern || target.startsWith(pattern + "/");
|
|
96
|
+
return globToRegExp(pattern).test(target);
|
|
97
|
+
}
|
|
98
|
+
function matchesAnyGlob(p, globs) {
|
|
99
|
+
return globs.some((g) => matchesGlob(p, g));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export { approxTokens, git, isCiEnvironment, isDirectory, matchesAnyGlob, normalizeRelPath, pathExists, readJson, readText, wordCount };
|