@sabaiway/agent-workflow-kit 1.9.1 → 1.11.0
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 +61 -0
- package/README.md +10 -7
- package/SKILL.md +46 -18
- package/bin/install.mjs +117 -14
- package/bin/install.test.mjs +128 -5
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/contracts.md +21 -1
- package/tools/engine-source.mjs +115 -0
- package/tools/engine-source.test.mjs +182 -0
- package/tools/fs-safe.mjs +4 -1
- package/tools/fs-safe.test.mjs +6 -0
- package/tools/hide-footprint.integration.test.mjs +168 -0
- package/tools/hide-footprint.mjs +570 -0
- package/tools/hide-footprint.test.mjs +463 -0
- package/tools/inject-methodology.mjs +51 -7
- package/tools/inject-methodology.test.mjs +157 -12
- package/tools/known-footprint.mjs +161 -0
- package/tools/known-footprint.test.mjs +271 -0
- package/references/planning.md +0 -105
- package/tools/methodology-slot.md +0 -1
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// hide-footprint.mjs — the kit's single hide-writer. Makes a HIDDEN-mode repo "look normal" by keeping
|
|
3
|
+
// the full AI/agent footprint (the kit's own artifacts + every known foreign tool's files) out of
|
|
4
|
+
// commits — in ONE managed, fenced block in the PROJECT-LOCAL `.git/info/exclude` (AD-014, amends
|
|
5
|
+
// AD-006). Never the machine-global `core.excludesFile`: visibility is a project setting, so its ignore
|
|
6
|
+
// must be project-scoped (the AD-013 `.claude/skills/` one-off, generalized).
|
|
7
|
+
//
|
|
8
|
+
// Registry → classify → splice, all with cwd = the resolved project dir and on repo-relative probe
|
|
9
|
+
// paths (the anchored gitignore form is NOT a git pathspec — a leading "/" reads as "outside
|
|
10
|
+
// repository"). Tracked-ness is read from `git ls-files` STDOUT (the index authority); an UNKNOWN git
|
|
11
|
+
// state fails CLOSED (typed STOP), never open. A tracked path is never silently un-tracked — only
|
|
12
|
+
// `git rm --cached` truly un-tracks it, which the tool PRINTS as guidance and never runs.
|
|
13
|
+
//
|
|
14
|
+
// Decisions (plan D1–D16): one managed fence, re-derived wholesale → re-run is byte-identical (zero
|
|
15
|
+
// diff); tracked → ASK; present-but-untracked high-risk (generic names) → ASK; everything else → HIDE;
|
|
16
|
+
// asks are excluded from the block unless opted in via `--include` (an asks-only, traversal/glob-free
|
|
17
|
+
// path). Add-local-first, then DETECT + REPORT the residual legacy global block — removal is the
|
|
18
|
+
// explicit, ASK-gated `--remove-global` (prints a restorable backup), never a default (an arbitrary
|
|
19
|
+
// host's OTHER hidden repo may rely on the same root-anchored global lines). Windows is supported (text
|
|
20
|
+
// edit; forward-slash patterns; CRLF preserved via EOL detection). Dependency-free, Node >= 18.
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
readFileSync, writeFileSync, statSync, readdirSync,
|
|
24
|
+
} from 'node:fs';
|
|
25
|
+
import { join, resolve, relative, isAbsolute, basename } from 'node:path';
|
|
26
|
+
import { execFileSync } from 'node:child_process';
|
|
27
|
+
import { pathToFileURL } from 'node:url';
|
|
28
|
+
import os from 'node:os';
|
|
29
|
+
import {
|
|
30
|
+
KIT_OWN_PATHS, KNOWN_FOOTPRINT, FOOTPRINT_STOP, stop,
|
|
31
|
+
normalizeSlashes, isDirPattern, patternToProbe, expandGlob, matchesKnownGlob,
|
|
32
|
+
} from './known-footprint.mjs';
|
|
33
|
+
|
|
34
|
+
// ── managed-fence + legacy markers ──────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
export const START_MARKER = '# >>> agent-workflow-kit hidden mode (managed; do not edit between markers) >>>';
|
|
37
|
+
export const END_MARKER = '# <<< agent-workflow-kit hidden mode <<<';
|
|
38
|
+
|
|
39
|
+
// Recognized legacy forms we ABSORB (local) or MIGRATE (global). Matched by prefix so a minor wording
|
|
40
|
+
// drift can't strand a line (D7/D8).
|
|
41
|
+
const isLegacyGlobalHeader = (t) => t.startsWith('# agent-workflow-kit hidden mode (machine-local');
|
|
42
|
+
const isAd013Comment = (t) => t.startsWith('# standalone local-dev agent skills');
|
|
43
|
+
|
|
44
|
+
// The HISTORICAL global path-sets a legacy deployment wrote to `core.excludesFile` — kit-old (the
|
|
45
|
+
// verified 11 lines), memory-old (memory's set, incl. the now-subsumed `.memory-version` /
|
|
46
|
+
// `.workflow-version` stamps), and the AD-013 standalone line. NOT the new (larger) KIT_OWN_PATHS, and
|
|
47
|
+
// NOT the new fence — only what an OLD deployment actually emitted (D7).
|
|
48
|
+
const HISTORICAL_GLOBAL_PATHS = new Set([
|
|
49
|
+
'/AGENTS.md', '/CLAUDE.md', '/docs/ai/', '/docs/plans/',
|
|
50
|
+
'/docs/ai/.memory-version', '/docs/ai/.workflow-version',
|
|
51
|
+
'/scripts/_expect-shim.mjs',
|
|
52
|
+
'/scripts/archive-changelog.mjs', '/scripts/archive-changelog.test.mjs',
|
|
53
|
+
'/scripts/archive-issues.mjs', '/scripts/archive-issues.test.mjs',
|
|
54
|
+
'/scripts/check-docs-size.mjs', '/scripts/check-docs-size.test.mjs',
|
|
55
|
+
'/scripts/install-git-hooks.mjs',
|
|
56
|
+
'/.claude/skills/',
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
// Registry lookups. The external set excludes the glob (its concrete files are expanded on demand).
|
|
60
|
+
const REGISTRY_SET = new Set([...KIT_OWN_PATHS, ...KNOWN_FOOTPRINT.filter((e) => !e.glob).map((e) => e.pattern)]);
|
|
61
|
+
// LOW-RISK recognized patterns: the only ones absorb/preserve folds present-irrelevantly (D8). KIT_OWN
|
|
62
|
+
// are all low-risk; a high-risk foreign file is only ever folded when actually present (a candidate).
|
|
63
|
+
const LOWRISK_SET = new Set([
|
|
64
|
+
...KIT_OWN_PATHS,
|
|
65
|
+
...KNOWN_FOOTPRINT.filter((e) => !e.glob && !e.falsePositiveRisk).map((e) => e.pattern),
|
|
66
|
+
]);
|
|
67
|
+
const registryMetaFor = (pattern) => {
|
|
68
|
+
if (KIT_OWN_PATHS.includes(pattern)) return { type: isDirPattern(pattern) ? 'dir' : 'file', falsePositiveRisk: false, owner: 'agent-workflow-kit' };
|
|
69
|
+
const e = KNOWN_FOOTPRINT.find((x) => x.pattern === pattern);
|
|
70
|
+
return e ? { type: e.type, falsePositiveRisk: e.falsePositiveRisk, owner: e.owner } : { type: isDirPattern(pattern) ? 'dir' : 'file', falsePositiveRisk: false, owner: 'unknown' };
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// Map a possibly slash-variant pattern to its registry canonical form, or null if unknown.
|
|
74
|
+
const canonicalize = (pattern) => {
|
|
75
|
+
const p = normalizeSlashes(pattern);
|
|
76
|
+
if (REGISTRY_SET.has(p)) return p;
|
|
77
|
+
if (REGISTRY_SET.has(`${p}/`)) return `${p}/`;
|
|
78
|
+
if (p.endsWith('/') && REGISTRY_SET.has(p.slice(0, -1))) return p.slice(0, -1);
|
|
79
|
+
return null;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
// Recognize a pre-existing HIDE RULE — a registry pattern OR a concrete child of a `glob:true` entry
|
|
83
|
+
// (e.g. `/.github/copilot-instructions.md`, which is NOT itself a registry pattern). Returns the
|
|
84
|
+
// canonical form, or null. This makes prior consent survive a re-run for a glob-backed entry:
|
|
85
|
+
// canonicalize alone misses glob children, so the consented line would be dropped → a silent un-hide.
|
|
86
|
+
const recognizeHideRule = (pattern) => canonicalize(pattern) ?? (matchesKnownGlob(pattern) ? normalizeSlashes(pattern) : null);
|
|
87
|
+
|
|
88
|
+
// Slash-tolerant membership in the HISTORICAL global set.
|
|
89
|
+
const historicalMatch = (pattern) => {
|
|
90
|
+
const p = normalizeSlashes(pattern);
|
|
91
|
+
return HISTORICAL_GLOBAL_PATHS.has(p) || HISTORICAL_GLOBAL_PATHS.has(`${p}/`) || (p.endsWith('/') && HISTORICAL_GLOBAL_PATHS.has(p.slice(0, -1)));
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
// ── EOL + line model ────────────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
// EOL detected from the file's first line; empty/missing → LF (D2, Review fold agy#6).
|
|
97
|
+
const detectEol = (content) => {
|
|
98
|
+
const i = content.indexOf('\n');
|
|
99
|
+
if (i <= 0) return '\n';
|
|
100
|
+
return content[i - 1] === '\r' ? '\r\n' : '\n';
|
|
101
|
+
};
|
|
102
|
+
// Split into logical lines; `lines.join(eol)` reproduces the structure (CRLF normalized to `eol`).
|
|
103
|
+
const splitLines = (content) => (content === '' ? [] : content.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n'));
|
|
104
|
+
// Re-join, dropping trailing blank lines and ending with exactly one EOL (canonical, idempotent).
|
|
105
|
+
const joinLines = (lines, eol) => {
|
|
106
|
+
const out = [...lines];
|
|
107
|
+
while (out.length && out[out.length - 1] === '') out.pop();
|
|
108
|
+
return out.length ? `${out.join(eol)}${eol}` : '';
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
// A raw exclude line → an anchored pattern (leading "/"), or null when blank/comment.
|
|
112
|
+
const lineToPattern = (raw) => {
|
|
113
|
+
const t = normalizeSlashes(raw).trim();
|
|
114
|
+
if (t === '' || t.startsWith('#')) return null;
|
|
115
|
+
return t.startsWith('/') ? t : `/${t}`;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
// ── git runner (injectable) ──────────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
// One git invocation → { status, stdout, stderr }. execFileSync THROWS on a non-zero exit, so a "no
|
|
121
|
+
// match" (check-ignore exit 1) arrives via the catch — captured, not raised. A missing git binary
|
|
122
|
+
// (ENOENT) has status null → a STOP (the agent host can't run git; reported with the concrete reason).
|
|
123
|
+
const defaultGit = (args, { cwd, env }) => {
|
|
124
|
+
try {
|
|
125
|
+
const stdout = execFileSync('git', args, { cwd, env, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
126
|
+
return { status: 0, stdout, stderr: '' };
|
|
127
|
+
} catch (err) {
|
|
128
|
+
if (err && err.code === 'ENOENT') throw stop(`cannot run git (${err.code}) — the agent host has no usable git on PATH`);
|
|
129
|
+
return { status: err.status ?? null, stdout: (err.stdout ?? '').toString(), stderr: (err.stderr ?? '').toString() };
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
const runGit = (deps, dir, args) => (deps.git ?? defaultGit)(args, { cwd: dir, env: deps.env ?? process.env });
|
|
133
|
+
|
|
134
|
+
const fsOf = (deps = {}) => ({
|
|
135
|
+
readFile: deps.readFile ?? ((p) => readFileSync(p, 'utf8')),
|
|
136
|
+
writeFile: deps.writeFile ?? ((p, c) => writeFileSync(p, c, 'utf8')),
|
|
137
|
+
stat: deps.stat ?? statSync,
|
|
138
|
+
readdir: deps.readdir ?? readdirSync,
|
|
139
|
+
});
|
|
140
|
+
const expandTilde = (p, home) => (p === '~' ? home : p.startsWith('~/') ? join(home, p.slice(2)) : p);
|
|
141
|
+
|
|
142
|
+
// ── git probes ────────────────────────────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
// The resolved project-local exclude file — worktree/submodule-safe (NEVER a hardcoded path).
|
|
145
|
+
export const excludePath = (deps, dir) => {
|
|
146
|
+
const r = runGit(deps, dir, ['rev-parse', '--git-path', 'info/exclude']);
|
|
147
|
+
if (r.status !== 0) throw stop(`cannot resolve info/exclude (git rev-parse exit ${r.status}): ${r.stderr.trim()}`);
|
|
148
|
+
const rel = r.stdout.trim();
|
|
149
|
+
return isAbsolute(rel) ? rel : join(dir, rel);
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
// Tracked-ness from the INDEX: `git ls-files` STDOUT non-empty ⇒ tracked. Empty + exit 0 ⇒ untracked.
|
|
153
|
+
// Any nonzero / stderr ⇒ UNKNOWN ⇒ typed STOP (fail-closed — UNKNOWN never counts as safe-to-hide).
|
|
154
|
+
export const isTracked = (probe, deps, dir) => {
|
|
155
|
+
const r = runGit(deps, dir, ['ls-files', '-z', '--', probe]);
|
|
156
|
+
if (r.status !== 0 || (r.stderr && r.stderr.trim() !== '')) {
|
|
157
|
+
throw stop(`git ls-files UNKNOWN state (exit ${r.status}) for ${probe}: ${r.stderr.trim()}`);
|
|
158
|
+
}
|
|
159
|
+
return r.stdout.replace(/\0/g, '').length > 0;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
// `git check-ignore -v` → { ignored, source }. Exit 0 = ignored (verbose line carries the winning
|
|
163
|
+
// source); exit 1 = NOT ignored (normal); exit 128 = STOP. The index is respected: a tracked path
|
|
164
|
+
// reports exit 1 here (so tracked-ness is read from ls-files, never inferred from this).
|
|
165
|
+
export const checkIgnore = (probe, deps, dir) => {
|
|
166
|
+
const r = runGit(deps, dir, ['check-ignore', '-v', '--', probe]);
|
|
167
|
+
if (r.status === 1) return { ignored: false, source: null };
|
|
168
|
+
if (r.status !== 0) throw stop(`git check-ignore failed (exit ${r.status}) for ${probe}: ${r.stderr.trim()}`);
|
|
169
|
+
const out = r.stdout.split('\n').find((l) => l.trim() !== '') ?? '';
|
|
170
|
+
const tab = out.lastIndexOf('\t');
|
|
171
|
+
const left = tab >= 0 ? out.slice(0, tab) : out;
|
|
172
|
+
const m = left.match(/^(.*):(\d+):(.*)$/);
|
|
173
|
+
return { ignored: true, source: m ? m[1] : null, pattern: m ? m[3] : null };
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
// Is `source` a `.gitignore` that lives INSIDE the repo AND is tracked? Guards against a machine-global
|
|
177
|
+
// `core.excludesFile` that happens to be NAMED `.gitignore` (basename collision): such a source resolves
|
|
178
|
+
// OUTSIDE the repo, so feeding its relative path to `git ls-files` would error ("outside repository")
|
|
179
|
+
// and STOP the run — it is not a project `.gitignore` and must not be treated as one.
|
|
180
|
+
const isTrackedRepoGitignore = (source, deps, dir) => {
|
|
181
|
+
if (!source || basename(source) !== '.gitignore') return false;
|
|
182
|
+
const rel = relative(dir, resolve(dir, source));
|
|
183
|
+
if (rel.startsWith('..') || isAbsolute(rel)) return false; // outside the repo → not a project .gitignore
|
|
184
|
+
return isTracked(rel || '.gitignore', deps, dir);
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
// Does a TRACKED `.gitignore` already cover this path? (Only then is a candidate dropped as redundant.)
|
|
188
|
+
const coveredByTrackedGitignore = (probe, deps, dir) => {
|
|
189
|
+
const ci = checkIgnore(probe, deps, dir);
|
|
190
|
+
return ci.ignored ? isTrackedRepoGitignore(ci.source, deps, dir) : false;
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const presentOnDisk = (pattern, fsx, dir) => {
|
|
194
|
+
const probe = patternToProbe(pattern).replace(/\/$/, '');
|
|
195
|
+
try {
|
|
196
|
+
fsx.stat(join(dir, probe));
|
|
197
|
+
return true;
|
|
198
|
+
} catch (err) {
|
|
199
|
+
if (err && err.code === 'ENOENT') return false;
|
|
200
|
+
throw stop(`cannot stat ${join(dir, probe)} (${err.code ?? 'fs error'})`);
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
// ── visibility inference (D16) ──────────────────────────────────────────────────
|
|
205
|
+
|
|
206
|
+
// A deployment does not record its chosen visibility. Infer it from the kit's anchor artifact:
|
|
207
|
+
// tracked/committed → VISIBLE (the hide tool must write zero bytes — D10)
|
|
208
|
+
// untracked AND ignored → HIDDEN (run the reconcile)
|
|
209
|
+
// untracked AND not ignored → AMBIGUOUS (fresh-uncommitted vs broken-hidden → the agent ASKs)
|
|
210
|
+
export const inferVisibility = (deps, dir, fsx = fsOf(deps)) => {
|
|
211
|
+
const anchors = ['/AGENTS.md', '/docs/ai/'];
|
|
212
|
+
// VISIBLE keys on tracked-ness (git state), not disk presence — a committed-but-deleted AGENTS.md is
|
|
213
|
+
// still visible. Check BOTH anchors before falling through to the ignored/ambiguous test.
|
|
214
|
+
for (const a of anchors) {
|
|
215
|
+
if (isTracked(patternToProbe(a), deps, dir)) return { visibility: 'visible', anchor: a, tracked: true, ignored: false };
|
|
216
|
+
}
|
|
217
|
+
const anchor = anchors.find((a) => presentOnDisk(a, fsx, dir)) ?? anchors[0];
|
|
218
|
+
const ci = checkIgnore(patternToProbe(anchor), deps, dir);
|
|
219
|
+
if (ci.ignored) return { visibility: 'hidden', anchor, tracked: false, ignored: true };
|
|
220
|
+
return { visibility: 'ambiguous', anchor, tracked: false, ignored: false };
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
// ── classify (D4 — one algorithm, exact order) ───────────────────────────────────
|
|
224
|
+
|
|
225
|
+
// Per candidate, in order: (1) tracked → ASK; (2) untracked & a tracked .gitignore already covers it →
|
|
226
|
+
// DROP (redundant); (3) present high-risk (generic name) → ASK; (4) HIDE.
|
|
227
|
+
const classifyOne = (cand, deps, dir, fsx) => {
|
|
228
|
+
const probe = patternToProbe(cand.pattern);
|
|
229
|
+
if (isTracked(probe, deps, dir)) {
|
|
230
|
+
return { ...cand, verdict: 'ask-tracked', reason: 'tracked in git — an exclude does nothing; un-track with `git rm --cached` to hide (never done silently)' };
|
|
231
|
+
}
|
|
232
|
+
if (coveredByTrackedGitignore(probe, deps, dir)) return { ...cand, verdict: 'drop', reason: 'already covered by a tracked .gitignore (redundant)' };
|
|
233
|
+
if (cand.falsePositiveRisk && presentOnDisk(cand.pattern, fsx, dir)) {
|
|
234
|
+
return { ...cand, verdict: 'ask-risk', reason: `present but its name is generic (${cand.owner}) — confirm before hiding` };
|
|
235
|
+
}
|
|
236
|
+
return { ...cand, verdict: 'hide', reason: 'untracked footprint — hide' };
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
// ── candidate assembly ───────────────────────────────────────────────────────────
|
|
240
|
+
|
|
241
|
+
const assembleCandidates = (deps, dir, fsx, forcedPreserve) => {
|
|
242
|
+
const byPattern = new Map();
|
|
243
|
+
const add = (pattern, meta) => { if (!byPattern.has(pattern)) byPattern.set(pattern, { pattern, ...meta }); };
|
|
244
|
+
for (const p of KIT_OWN_PATHS) add(p, { type: isDirPattern(p) ? 'dir' : 'file', falsePositiveRisk: false, owner: 'agent-workflow-kit', origin: 'kit-own' });
|
|
245
|
+
for (const e of KNOWN_FOOTPRINT) {
|
|
246
|
+
if (e.glob) {
|
|
247
|
+
for (const f of expandGlob(e.pattern, { dir, readdir: fsx.readdir, stat: fsx.stat })) {
|
|
248
|
+
add(f, { type: 'file', falsePositiveRisk: e.falsePositiveRisk, owner: e.owner, origin: 'external' });
|
|
249
|
+
}
|
|
250
|
+
} else if (presentOnDisk(e.pattern, fsx, dir)) {
|
|
251
|
+
add(e.pattern, { type: e.type, falsePositiveRisk: e.falsePositiveRisk, owner: e.owner, origin: 'external' });
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
// Pre-existing recognized LOW-RISK rules (absorbed bare lines + current fence body) are a deliberate
|
|
255
|
+
// prior local decision — kept regardless of on-disk presence (D8).
|
|
256
|
+
for (const p of forcedPreserve) add(p, { ...registryMetaFor(p), origin: 'preserved' });
|
|
257
|
+
return [...byPattern.values()];
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
// ── block build + splice (D2) ────────────────────────────────────────────────────
|
|
261
|
+
|
|
262
|
+
export const buildBlock = (patterns) => [...new Set(patterns)].sort();
|
|
263
|
+
|
|
264
|
+
// Find the managed fence in a line array. { state:'none' } | { state:'ok', startIdx, endIdx } |
|
|
265
|
+
// { state:'malformed', reason }. Malformed → caller STOPs with the file byte-for-byte unchanged.
|
|
266
|
+
const findFence = (lines) => {
|
|
267
|
+
const starts = lines.map((l, i) => (l === START_MARKER ? i : -1)).filter((i) => i >= 0);
|
|
268
|
+
const ends = lines.map((l, i) => (l === END_MARKER ? i : -1)).filter((i) => i >= 0);
|
|
269
|
+
if (starts.length === 0 && ends.length === 0) return { state: 'none' };
|
|
270
|
+
if (starts.length !== 1 || ends.length !== 1) return { state: 'malformed', reason: `expected exactly one managed marker pair, found ${starts.length} start / ${ends.length} end` };
|
|
271
|
+
if (ends[0] < starts[0]) return { state: 'malformed', reason: 'end marker precedes start marker' };
|
|
272
|
+
return { state: 'ok', startIdx: starts[0], endIdx: ends[0] };
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
// ── absorb (local pre-existing recognized lines → folded into the fence) ──────────
|
|
276
|
+
|
|
277
|
+
// Remove recognized pre-existing bare hide rules + their orphan comment headers (AD-013 / a stray
|
|
278
|
+
// legacy header) from OUTSIDE the fence; return the cleaned lines + the canonical patterns recognized
|
|
279
|
+
// (D8). A recognized line is a prior local decision to hide → consent, folded into the single managed
|
|
280
|
+
// block. Low-risk rules fold regardless of presence (D8 preserve). A HIGH-RISK rule folds as consent
|
|
281
|
+
// only while its file is still PRESENT; if its file is absent it is left exactly as-is (a deliberate
|
|
282
|
+
// pre-emptive user hide is never silently removed, and there is no present-file report mismatch).
|
|
283
|
+
const absorbOutside = (outsideLines, isPresent) => {
|
|
284
|
+
const kept = [];
|
|
285
|
+
const absorbed = [];
|
|
286
|
+
for (const raw of outsideLines) {
|
|
287
|
+
const t = raw.trim();
|
|
288
|
+
if (isAd013Comment(t) || isLegacyGlobalHeader(t)) continue; // orphan header for an absorbed rule
|
|
289
|
+
const pat = lineToPattern(raw);
|
|
290
|
+
const canon = pat ? recognizeHideRule(pat) : null;
|
|
291
|
+
if (canon && (LOWRISK_SET.has(canon) || isPresent(canon))) {
|
|
292
|
+
absorbed.push(canon);
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
kept.push(raw); // unrecognized, or a high-risk recognized rule whose file is absent → leave it
|
|
296
|
+
}
|
|
297
|
+
return { kept, absorbed };
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
// ── verify (D5 — over the WROTE set; "hidden" ≡ untracked ∧ ignored-by-OUR-source) ─
|
|
301
|
+
|
|
302
|
+
const verifyPath = (pattern, deps, dir, excludeFile) => {
|
|
303
|
+
const probe = patternToProbe(pattern);
|
|
304
|
+
const tracked = isTracked(probe, deps, dir);
|
|
305
|
+
const ci = checkIgnore(probe, deps, dir);
|
|
306
|
+
let hidden = false;
|
|
307
|
+
if (!tracked && ci.ignored && ci.source) {
|
|
308
|
+
if (resolve(dir, ci.source) === resolve(dir, excludeFile)) hidden = true;
|
|
309
|
+
else if (isTrackedRepoGitignore(ci.source, deps, dir)) hidden = true;
|
|
310
|
+
}
|
|
311
|
+
return { path: pattern, tracked, ignored: ci.ignored, source: ci.source, hidden };
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
// ── global migration (D6/D7/D12) ─────────────────────────────────────────────────
|
|
315
|
+
|
|
316
|
+
const globalExcludesPath = (deps, dir, home) => {
|
|
317
|
+
const r = runGit(deps, dir, ['config', '--get', 'core.excludesFile']);
|
|
318
|
+
if (r.status === 1) return null; // unset = NORMAL "nothing to migrate" (not a STOP)
|
|
319
|
+
if (r.status !== 0) throw stop(`git config --get core.excludesFile failed (exit ${r.status}): ${r.stderr.trim()}`);
|
|
320
|
+
const p = r.stdout.trim();
|
|
321
|
+
return p ? expandTilde(p, home) : null;
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
// Detect + REPORT a residual legacy global block; remove ONLY contiguous recognized runs (header +
|
|
325
|
+
// historical paths) and ONLY when removeGlobal — printing the removed lines as a restorable backup.
|
|
326
|
+
export const migrateFromGlobal = (deps, dir, { home, removeGlobal, dryRun }) => {
|
|
327
|
+
const fsx = fsOf(deps);
|
|
328
|
+
const gp = globalExcludesPath(deps, dir, home);
|
|
329
|
+
if (!gp) return { found: false, source: null, removedLines: [], action: 'none' };
|
|
330
|
+
let content;
|
|
331
|
+
try {
|
|
332
|
+
content = fsx.readFile(gp);
|
|
333
|
+
} catch (err) {
|
|
334
|
+
if (err && err.code === 'ENOENT') return { found: false, source: gp, removedLines: [], action: 'none' };
|
|
335
|
+
throw stop(`cannot read global excludes ${gp} (${err.code ?? 'fs error'})`);
|
|
336
|
+
}
|
|
337
|
+
const eol = detectEol(content);
|
|
338
|
+
const lines = splitLines(content);
|
|
339
|
+
const recognized = (raw) => {
|
|
340
|
+
const t = raw.trim();
|
|
341
|
+
if (isLegacyGlobalHeader(t) || isAd013Comment(t)) return true;
|
|
342
|
+
const pat = lineToPattern(raw);
|
|
343
|
+
return pat ? historicalMatch(pat) : false;
|
|
344
|
+
};
|
|
345
|
+
const kept = [];
|
|
346
|
+
const removed = [];
|
|
347
|
+
let i = 0;
|
|
348
|
+
while (i < lines.length) {
|
|
349
|
+
if (recognized(lines[i])) {
|
|
350
|
+
while (i < lines.length && recognized(lines[i])) { removed.push(lines[i]); i += 1; }
|
|
351
|
+
} else {
|
|
352
|
+
kept.push(lines[i]);
|
|
353
|
+
i += 1;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
if (removed.length === 0) return { found: false, source: gp, removedLines: [], action: 'none' };
|
|
357
|
+
if (!removeGlobal) return { found: true, source: gp, removedLines: removed, action: 'kept' };
|
|
358
|
+
if (!dryRun) fsx.writeFile(gp, joinLines(kept, eol));
|
|
359
|
+
return { found: true, source: gp, removedLines: removed, action: 'removed', backup: removed.join('\n') };
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
// ── --include validation (D4 — only an asks[] path; no traversal/glob) ───────────
|
|
363
|
+
|
|
364
|
+
const canonicalizeIncludeArg = (arg) => {
|
|
365
|
+
const t = normalizeSlashes(arg).trim();
|
|
366
|
+
if (t.includes('*')) throw stop(`--include rejects a glob: ${arg}`);
|
|
367
|
+
if (t.split('/').includes('..')) throw stop(`--include rejects traversal: ${arg}`);
|
|
368
|
+
return t.startsWith('/') ? t : `/${t}`;
|
|
369
|
+
};
|
|
370
|
+
// An include matches an asks[] entry by its anchored pattern, slash-insensitively (file vs dir form).
|
|
371
|
+
const matchAsk = (canon, asks) => asks.find((a) => a.pattern === canon || a.pattern === `${canon}/` || (canon.endsWith('/') && a.pattern === canon.slice(0, -1)));
|
|
372
|
+
|
|
373
|
+
// ── core: compute the plan, then (optionally) apply it ───────────────────────────
|
|
374
|
+
|
|
375
|
+
export const hideFootprint = (opts = {}, deps = {}) => {
|
|
376
|
+
const dir = resolve(opts.dir ?? process.cwd());
|
|
377
|
+
const home = deps.home ?? os.homedir();
|
|
378
|
+
const fsx = fsOf(deps);
|
|
379
|
+
const dryRun = !!opts.dryRun;
|
|
380
|
+
const excludeFile = excludePath(deps, dir);
|
|
381
|
+
|
|
382
|
+
// Upgrade reconcile self-detects visibility first; bootstrap/delegated callers assert hidden.
|
|
383
|
+
if (opts.reconcile) {
|
|
384
|
+
const vis = inferVisibility(deps, dir, fsx);
|
|
385
|
+
if (vis.visibility !== 'hidden') {
|
|
386
|
+
return { excludeFile, action: 'noop', visibility: vis.visibility, anchor: vis.anchor, ambiguous: vis.visibility === 'ambiguous', wrote: [], asks: [], needsUntrack: [], dropped: [], verify: [], global: { action: 'skipped' } };
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const content = (() => {
|
|
391
|
+
try {
|
|
392
|
+
return fsx.readFile(excludeFile);
|
|
393
|
+
} catch (err) {
|
|
394
|
+
if (err && err.code === 'ENOENT') return '';
|
|
395
|
+
throw stop(`cannot read ${excludeFile} (${err.code ?? 'fs error'})`);
|
|
396
|
+
}
|
|
397
|
+
})();
|
|
398
|
+
const eol = detectEol(content);
|
|
399
|
+
const lines = splitLines(content);
|
|
400
|
+
const fence = findFence(lines);
|
|
401
|
+
if (fence.state === 'malformed') throw stop(`refusing to edit a malformed managed block in ${excludeFile}: ${fence.reason} (file left unchanged)`);
|
|
402
|
+
|
|
403
|
+
const before = fence.state === 'ok' ? lines.slice(0, fence.startIdx) : lines;
|
|
404
|
+
const fenceBodyLines = fence.state === 'ok' ? lines.slice(fence.startIdx + 1, fence.endIdx) : [];
|
|
405
|
+
const after = fence.state === 'ok' ? lines.slice(fence.endIdx + 1) : [];
|
|
406
|
+
|
|
407
|
+
// ── unhide (D12): drop our fence; report/remove the residual global block ──────
|
|
408
|
+
if (opts.unhide) {
|
|
409
|
+
const global = migrateFromGlobal(deps, dir, { home, removeGlobal: opts.removeGlobal, dryRun });
|
|
410
|
+
const newLines = [...before, ...after];
|
|
411
|
+
const newContent = joinLines(newLines, eol);
|
|
412
|
+
const changed = newContent !== content;
|
|
413
|
+
if (changed && !dryRun) fsx.writeFile(excludeFile, newContent);
|
|
414
|
+
return { excludeFile, action: fence.state === 'ok' ? (changed ? 'unhidden' : 'noop') : 'noop', visibility: 'hidden', wrote: [], asks: [], needsUntrack: [], dropped: [], verify: [], global };
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// ── absorb pre-existing recognized lines, assemble + classify candidates ───────
|
|
418
|
+
const isPresentCanon = (canon) => presentOnDisk(canon, fsx, dir);
|
|
419
|
+
const { kept: cleanBefore, absorbed: absorbedBefore } = absorbOutside(before, isPresentCanon);
|
|
420
|
+
const { kept: cleanAfter, absorbed: absorbedAfter } = absorbOutside(after, isPresentCanon);
|
|
421
|
+
const fenceBodyPatterns = fenceBodyLines.map((l) => recognizeHideRule(lineToPattern(l) ?? '')).filter(Boolean);
|
|
422
|
+
// Every pre-existing recognized hide rule (current fence body + absorbed loose lines, glob children
|
|
423
|
+
// included) is prior consent. LOW-RISK rules are force-preserved as candidates regardless of presence
|
|
424
|
+
// (D8); HIGH-RISK ones survive only while present (handled via the present-candidate path + consent).
|
|
425
|
+
const recognizedExisting = [...new Set([...absorbedBefore, ...absorbedAfter, ...fenceBodyPatterns])];
|
|
426
|
+
const forcedPreserve = recognizedExisting.filter((p) => LOWRISK_SET.has(p));
|
|
427
|
+
|
|
428
|
+
const classified = assembleCandidates(deps, dir, fsx, forcedPreserve).map((c) => classifyOne(c, deps, dir, fsx));
|
|
429
|
+
const hideSet = classified.filter((c) => c.verdict === 'hide');
|
|
430
|
+
const asks = classified.filter((c) => c.verdict === 'ask-tracked' || c.verdict === 'ask-risk');
|
|
431
|
+
const dropped = classified.filter((c) => c.verdict === 'drop');
|
|
432
|
+
|
|
433
|
+
// Effective include = HIDE ∪ prior in-fence consent (still-ASK paths) ∪ this-run --include.
|
|
434
|
+
const includeArgs = (opts.include ?? []).map(canonicalizeIncludeArg);
|
|
435
|
+
const includedFromFlags = includeArgs.map((canon) => {
|
|
436
|
+
const a = matchAsk(canon, asks);
|
|
437
|
+
if (!a) throw stop(`--include ${canon} is not one of this run's asks — it can only opt in a path the tool surfaced`);
|
|
438
|
+
return a;
|
|
439
|
+
});
|
|
440
|
+
const priorConsent = asks.filter((a) => recognizedExisting.includes(a.pattern));
|
|
441
|
+
const includedAsks = [...new Map([...includedFromFlags, ...priorConsent].map((a) => [a.pattern, a])).values()];
|
|
442
|
+
|
|
443
|
+
const writtenList = [...hideSet, ...includedAsks];
|
|
444
|
+
const writtenPatterns = buildBlock(writtenList.map((c) => c.pattern));
|
|
445
|
+
const needsUntrack = includedAsks.filter((a) => a.verdict === 'ask-tracked');
|
|
446
|
+
|
|
447
|
+
// ── build the new file (splice the fence; preserve outside lines) ──────────────
|
|
448
|
+
const fenceLines = writtenPatterns.length ? [START_MARKER, ...writtenPatterns, END_MARKER] : [];
|
|
449
|
+
const newLines = writtenPatterns.length
|
|
450
|
+
? [...cleanBefore, ...fenceLines, ...cleanAfter]
|
|
451
|
+
: [...cleanBefore, ...cleanAfter];
|
|
452
|
+
const newContent = joinLines(newLines, eol);
|
|
453
|
+
const changed = newContent !== content;
|
|
454
|
+
if (changed && !dryRun) fsx.writeFile(excludeFile, newContent);
|
|
455
|
+
|
|
456
|
+
// ── verify (post-write) every WROTE path; the hidden invariant GATES every written UNTRACKED
|
|
457
|
+
// path (the auto-HIDE set AND consented present-high-risk hides) — only TRACKED needsUntrack
|
|
458
|
+
// paths are exempt (an exclude cannot hide a tracked file; they carry `git rm --cached`) (D5) ──
|
|
459
|
+
const verify = dryRun ? [] : writtenList.map((c) => verifyPath(c.pattern, deps, dir, excludeFile));
|
|
460
|
+
const notHidden = verify.filter((v) => !v.hidden && !v.tracked);
|
|
461
|
+
if (notHidden.length) {
|
|
462
|
+
throw stop(`hide verification failed — these were written but are not hidden by the project-local exclude: ${notHidden.map((v) => v.path).join(', ')}`);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// ── then detect/report (or remove) the residual legacy global block (D6) ───────
|
|
466
|
+
const global = migrateFromGlobal(deps, dir, { home, removeGlobal: opts.removeGlobal, dryRun });
|
|
467
|
+
|
|
468
|
+
const action = !changed ? 'noop' : fence.state === 'ok' ? 'updated' : 'created';
|
|
469
|
+
return {
|
|
470
|
+
excludeFile,
|
|
471
|
+
action,
|
|
472
|
+
visibility: 'hidden',
|
|
473
|
+
wrote: writtenPatterns,
|
|
474
|
+
asks: asks.filter((a) => !includedAsks.some((i) => i.pattern === a.pattern)).map((a) => ({ path: a.pattern, reason: a.reason, owner: a.owner })),
|
|
475
|
+
needsUntrack: needsUntrack.map((a) => {
|
|
476
|
+
const target = patternToProbe(a.pattern).replace(/\/$/, '');
|
|
477
|
+
return { path: a.pattern, command: isDirPattern(a.pattern) ? `git rm --cached -r -- ${target}` : `git rm --cached -- ${target}` };
|
|
478
|
+
}),
|
|
479
|
+
dropped: dropped.map((d) => d.pattern),
|
|
480
|
+
verify,
|
|
481
|
+
global,
|
|
482
|
+
};
|
|
483
|
+
};
|
|
484
|
+
|
|
485
|
+
// ── CLI ───────────────────────────────────────────────────────────────────────────
|
|
486
|
+
|
|
487
|
+
const USAGE = `usage: hide-footprint [--dir <project>] [--reconcile] [--include=<path>]... [--keep-global | --remove-global] [--unhide] [--dry-run] [--help]
|
|
488
|
+
|
|
489
|
+
--dir <project> the target project (default: cwd)
|
|
490
|
+
--reconcile upgrade mode — infer visibility first; write zero bytes when VISIBLE, ASK when AMBIGUOUS
|
|
491
|
+
--include=<path> opt a surfaced ASK path into the hidden set (only an asks[] path; no glob/traversal)
|
|
492
|
+
--keep-global keep + report the residual legacy machine-global block (DEFAULT)
|
|
493
|
+
--remove-global remove the recognized legacy machine-global block (prints a restorable backup)
|
|
494
|
+
--unhide remove the project-local managed block (residual global only with --remove-global)
|
|
495
|
+
--dry-run print the plan; change nothing
|
|
496
|
+
--help, -h this help
|
|
497
|
+
|
|
498
|
+
Writes ONE managed block in the PROJECT-LOCAL .git/info/exclude (never the machine-global excludes).
|
|
499
|
+
Tracked files are never silently un-tracked — the tool prints the \`git rm --cached\` it will not run.`;
|
|
500
|
+
|
|
501
|
+
const parseArgs = (argv) => {
|
|
502
|
+
const out = { dir: undefined, reconcile: false, include: [], removeGlobal: false, keepGlobal: false, unhide: false, dryRun: false, help: false, bad: null };
|
|
503
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
504
|
+
const a = argv[i];
|
|
505
|
+
if (a === '--help' || a === '-h') out.help = true;
|
|
506
|
+
else if (a === '--dry-run') out.dryRun = true;
|
|
507
|
+
else if (a === '--reconcile') out.reconcile = true;
|
|
508
|
+
else if (a === '--unhide') out.unhide = true;
|
|
509
|
+
else if (a === '--keep-global') out.keepGlobal = true;
|
|
510
|
+
else if (a === '--remove-global') out.removeGlobal = true;
|
|
511
|
+
else if (a === '--dir') {
|
|
512
|
+
const next = argv[i + 1];
|
|
513
|
+
if (next === undefined || next.startsWith('-')) out.bad = '--dir needs a path argument';
|
|
514
|
+
else { out.dir = next; i += 1; }
|
|
515
|
+
} else if (a.startsWith('--include=')) out.include.push(a.slice('--include='.length));
|
|
516
|
+
else if (a === '--include') {
|
|
517
|
+
const next = argv[i + 1];
|
|
518
|
+
if (next === undefined || next.startsWith('-')) out.bad = '--include needs a path argument';
|
|
519
|
+
else { out.include.push(next); i += 1; }
|
|
520
|
+
} else out.bad = `unknown argument: ${a}`;
|
|
521
|
+
}
|
|
522
|
+
if (out.keepGlobal && out.removeGlobal) out.bad = '--keep-global and --remove-global are mutually exclusive';
|
|
523
|
+
return out;
|
|
524
|
+
};
|
|
525
|
+
|
|
526
|
+
const fmtGlobal = (g) => {
|
|
527
|
+
if (!g || g.action === 'none' || g.action === 'skipped') return [];
|
|
528
|
+
if (g.action === 'kept') return [` • residual legacy machine-global block in ${g.source} (${g.removedLines.filter((l) => l.trim() && !l.trim().startsWith('#')).length} path line(s)) — KEPT + reported; pass --remove-global to remove (with a printed backup)`];
|
|
529
|
+
if (g.action === 'removed') return [` • removed the legacy machine-global block from ${g.source} (backup of ${g.removedLines.length} line(s) printed below)`, ...g.removedLines.map((l) => ` | ${l}`)];
|
|
530
|
+
return [];
|
|
531
|
+
};
|
|
532
|
+
|
|
533
|
+
const formatReport = (r, dryRun) => {
|
|
534
|
+
const lines = [dryRun ? 'hide-footprint — DRY RUN (no changes)' : 'hide-footprint'];
|
|
535
|
+
if (r.visibility === 'visible') return [...lines, ` • deployment is VISIBLE (anchor ${r.anchor} is tracked) — nothing to hide; wrote zero bytes`].join('\n');
|
|
536
|
+
if (r.ambiguous) return [...lines, ` • AMBIGUOUS visibility (anchor ${r.anchor} is untracked AND not ignored) — cannot tell fresh-uncommitted from broken-hidden; ASK the user before writing`].join('\n');
|
|
537
|
+
lines.push(` • ${r.action} ${r.excludeFile}`);
|
|
538
|
+
// The block contains every written pattern, but a TRACKED --include path is NOT hidden by it (it is
|
|
539
|
+
// reported separately, below) — so the "hidden" line lists only the genuinely-hidden untracked paths.
|
|
540
|
+
const untrackedOnly = new Set(r.needsUntrack.map((n) => n.path));
|
|
541
|
+
const hiddenNow = r.wrote.filter((p) => !untrackedOnly.has(p));
|
|
542
|
+
if (hiddenNow.length) lines.push(` • hidden (${hiddenNow.length}): ${hiddenNow.join(', ')}`);
|
|
543
|
+
for (const a of r.asks) lines.push(` • ASK ${a.path} — ${a.reason}`);
|
|
544
|
+
for (const n of r.needsUntrack) lines.push(` • tracked, NOT hidden: ${n.path} — run \`${n.command}\` to un-track (kept on disk)`);
|
|
545
|
+
if (r.dropped.length) lines.push(` • skipped ${r.dropped.length} already-ignored (tracked .gitignore)`);
|
|
546
|
+
lines.push(...fmtGlobal(r.global));
|
|
547
|
+
return lines.join('\n');
|
|
548
|
+
};
|
|
549
|
+
|
|
550
|
+
export const main = (argv = process.argv.slice(2), deps = {}) => {
|
|
551
|
+
const log = deps.log ?? console.log;
|
|
552
|
+
const errlog = deps.errlog ?? console.error;
|
|
553
|
+
const args = parseArgs(argv);
|
|
554
|
+
if (args.help) { log(USAGE); return 0; }
|
|
555
|
+
if (args.bad) { errlog(args.bad); errlog(USAGE); return 2; }
|
|
556
|
+
try {
|
|
557
|
+
const result = hideFootprint(
|
|
558
|
+
{ dir: args.dir, dryRun: args.dryRun, reconcile: args.reconcile, include: args.include, removeGlobal: args.removeGlobal, unhide: args.unhide },
|
|
559
|
+
deps,
|
|
560
|
+
);
|
|
561
|
+
log(formatReport(result, args.dryRun));
|
|
562
|
+
return 0;
|
|
563
|
+
} catch (err) {
|
|
564
|
+
if (err && err.code === FOOTPRINT_STOP) { errlog(err.message); return 1; }
|
|
565
|
+
throw err;
|
|
566
|
+
}
|
|
567
|
+
};
|
|
568
|
+
|
|
569
|
+
const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
570
|
+
if (isDirectRun) process.exit(main(process.argv.slice(2)));
|