gcf-aif-ext 0.2.1 → 0.2.4
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/commands/gcf.js +4 -3
- package/extension.json +1 -1
- package/hooks/pre-commit.js +3 -1
- package/lib/audit-core.js +6 -1
- package/lib/gcf-paths.js +12 -1
- package/lib/git.js +35 -12
- package/lib/id.js +0 -11
- package/lib/safe-path.js +12 -6
- package/lib/verdict-fs.js +9 -5
- package/package.json +1 -1
package/commands/gcf.js
CHANGED
|
@@ -141,7 +141,7 @@ export function register(program) {
|
|
|
141
141
|
gcf.command('hooks-install')
|
|
142
142
|
.description('Install the gcf pre-commit gate into the repository hooks dir (idempotent)')
|
|
143
143
|
.action(async () => {
|
|
144
|
-
const { writeFile
|
|
144
|
+
const { writeFile } = await import('node:fs/promises');
|
|
145
145
|
// --git-path resolves the hooks dir for a plain repo, a worktree and a submodule alike,
|
|
146
146
|
// where a bare existsSync('.git') passes on a worktree and then fails with ENOTDIR.
|
|
147
147
|
const res = spawnSync('git', ['rev-parse', '--git-path', 'hooks'], { cwd: process.cwd(), encoding: 'utf8' });
|
|
@@ -156,8 +156,9 @@ export function register(program) {
|
|
|
156
156
|
+ `node "${hookScript.replaceAll('\\', '/')}" || exit 1`);
|
|
157
157
|
}
|
|
158
158
|
await mkdir(path.dirname(target), { recursive: true });
|
|
159
|
-
|
|
160
|
-
|
|
159
|
+
// Mode on creation, not a chmod afterwards that can fail with the hook already in place.
|
|
160
|
+
// A file being replaced is our own earlier shim, already executable.
|
|
161
|
+
await writeFile(target, shim, { encoding: 'utf8', mode: 0o755 });
|
|
161
162
|
console.log(`pre-commit hook installed → ${target}`);
|
|
162
163
|
});
|
|
163
164
|
|
package/extension.json
CHANGED
package/hooks/pre-commit.js
CHANGED
|
@@ -87,5 +87,7 @@ for (const s of systemDocs) {
|
|
|
87
87
|
|
|
88
88
|
// 3: regenerate INDEX and stage it (deterministic artifact, not a human error).
|
|
89
89
|
await writeIndex(root);
|
|
90
|
-
|
|
90
|
+
// An unstaged INDEX (gitignored, locked index) would leave the commit without the row it validated.
|
|
91
|
+
const added = spawnSync('git', ['add', '--', path.join(root, 'INDEX.md')], { cwd: projectDir, stdio: 'ignore' });
|
|
92
|
+
if (added.status !== 0) fail('could not stage gcf/INDEX.md — is it gitignored?');
|
|
91
93
|
process.exit(0);
|
package/lib/audit-core.js
CHANGED
|
@@ -22,7 +22,8 @@ export async function audit(projectDir) {
|
|
|
22
22
|
const root = path.join(path.resolve(projectDir), 'gcf');
|
|
23
23
|
const config = await readConfig(root);
|
|
24
24
|
const cards = await listCards(root);
|
|
25
|
-
const report = { first_line: '', stale_built: [], tune_limit: [], wip: null, pillars: null, until: [], linter: {}, pragmas: [], index_drift: false, illegal_status: [] };
|
|
25
|
+
const report = { first_line: '', stale_built: [], tune_limit: [], open_depends: [], wip: null, pillars: null, until: [], linter: {}, pragmas: [], index_drift: false, illegal_status: [] };
|
|
26
|
+
const liveIds = new Set(cards.map((c) => c.data.id));
|
|
26
27
|
|
|
27
28
|
// Staleness is measured in commits, so outside a repository there is no answer to give.
|
|
28
29
|
// Without this probe every built card would read as stale and stop the harness.
|
|
@@ -51,6 +52,10 @@ export async function audit(projectDir) {
|
|
|
51
52
|
if ((c.data.tune_count ?? 0) >= config.tune_limit) {
|
|
52
53
|
report.tune_limit.push({ id: c.data.id, tune_count: c.data.tune_count, limit: config.tune_limit });
|
|
53
54
|
}
|
|
55
|
+
// A depends id that still has a card file is unplayed (GCF-PRD §7.2). Data for the
|
|
56
|
+
// harness gate, not an alert: a proposed card waiting on its dependency is normal.
|
|
57
|
+
const open = (c.data.depends ?? []).filter((d) => liveIds.has(d));
|
|
58
|
+
if (open.length) report.open_depends.push({ id: c.data.id, depends: open });
|
|
54
59
|
}
|
|
55
60
|
|
|
56
61
|
const wipCount = cards.filter((c) => c.data.status === 'built').length;
|
package/lib/gcf-paths.js
CHANGED
|
@@ -25,7 +25,18 @@ export function gcfRoot(projectDir = process.cwd()) {
|
|
|
25
25
|
export async function readConfig(root) {
|
|
26
26
|
const file = path.join(root, 'config.yml');
|
|
27
27
|
if (!existsSync(file)) return { ...DEFAULT_CONFIG };
|
|
28
|
-
|
|
28
|
+
const parsed = YAML.parse(await readFile(file, 'utf8')) ?? {};
|
|
29
|
+
if (typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('gcf/config.yml must be a key: value mapping');
|
|
30
|
+
// A blank `key:` parses as null and means "not set", like an absent key.
|
|
31
|
+
const config = { ...DEFAULT_CONFIG, ...Object.fromEntries(Object.entries(parsed).filter(([, v]) => v !== null)) };
|
|
32
|
+
// A limit that is not a number compares false against everything, which silently
|
|
33
|
+
// switches its gate off — the one failure a gate must not have.
|
|
34
|
+
for (const key of Object.keys(DEFAULT_CONFIG)) {
|
|
35
|
+
if (!Number.isFinite(config[key]) || config[key] < 0) {
|
|
36
|
+
throw new Error(`gcf/config.yml: ${key} must be a non-negative number, got ${JSON.stringify(config[key])}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return config;
|
|
29
40
|
}
|
|
30
41
|
|
|
31
42
|
/** Token estimate: ceil(chars/4). Stability over accuracy (GCF-PRD §9.1). */
|
package/lib/git.js
CHANGED
|
@@ -2,18 +2,41 @@ import { spawnSync } from 'node:child_process';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* The stage-0 index entry (mode + blob) of each file, or `entry: null` when the index
|
|
6
|
+
* holds none. Paths are taken relative to `cwd` (an existing directory inside the work
|
|
7
|
+
* tree), so a missing parent directory of a file never misreads as "untracked". A
|
|
8
|
+
* conflicted path is left out: writing a single entry back would mark it resolved.
|
|
9
|
+
* Returns null outside a work tree — there is no index to put back.
|
|
9
10
|
*/
|
|
10
|
-
export function
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
11
|
+
export function snapshotIndex(cwd, files) {
|
|
12
|
+
const rels = files.map((f) => path.relative(cwd, f).replaceAll('\\', '/'));
|
|
13
|
+
// -z: no C-quoting of non-ASCII paths, so the output is the path itself.
|
|
14
|
+
const res = spawnSync('git', ['ls-files', '-s', '-z', '--', ...rels], { cwd, encoding: 'utf8' });
|
|
15
|
+
if (res.status !== 0) return null;
|
|
16
|
+
const prefix = spawnSync('git', ['rev-parse', '--show-prefix'], { cwd, encoding: 'utf8' }).stdout.trim();
|
|
17
|
+
const entries = new Map();
|
|
18
|
+
const conflicted = new Set();
|
|
19
|
+
for (const rec of res.stdout.split('\0').filter(Boolean)) {
|
|
20
|
+
const m = rec.match(/^(\d+) ([0-9a-f]+) (\d)\t(.+)$/s);
|
|
21
|
+
if (!m) continue;
|
|
22
|
+
if (m[3] === '0') entries.set(m[4], { mode: m[1], blob: m[2] });
|
|
23
|
+
else conflicted.add(m[4]);
|
|
17
24
|
}
|
|
18
|
-
return
|
|
25
|
+
return { cwd, prefix, files: rels.filter((r) => !conflicted.has(r)).map((rel) => ({ rel, entry: entries.get(rel) ?? null })) };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Put back the entries `snapshotIndex` took. Returns one Error per path it could not restore. */
|
|
29
|
+
export function restoreIndex(snapshot) {
|
|
30
|
+
if (!snapshot) return [];
|
|
31
|
+
const errors = [];
|
|
32
|
+
for (const { rel, entry } of snapshot.files) {
|
|
33
|
+
// --cacheinfo takes a path from the repository root and ignores the cwd; --force-remove
|
|
34
|
+
// takes one relative to the cwd.
|
|
35
|
+
const args = entry
|
|
36
|
+
? ['update-index', '--add', '--cacheinfo', `${entry.mode},${entry.blob},${snapshot.prefix}${rel}`]
|
|
37
|
+
: ['update-index', '--force-remove', '--', rel];
|
|
38
|
+
const res = spawnSync('git', args, { cwd: snapshot.cwd, encoding: 'utf8' });
|
|
39
|
+
if (res.status !== 0) errors.push(new Error(`could not restore the index entry of ${rel}: ${(res.stderr || '').trim() || res.error?.message}`));
|
|
40
|
+
}
|
|
41
|
+
return errors;
|
|
19
42
|
}
|
package/lib/id.js
CHANGED
|
@@ -18,17 +18,6 @@ export function isValidId(id) {
|
|
|
18
18
|
return ID_RE.test(String(id ?? ''));
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
/** Next free number across BOTH kinds: a card and a candidate never share a number. */
|
|
22
|
-
export function nextId(kind, existingIds) {
|
|
23
|
-
if (kind !== 'card' && kind !== 'cand') throw new Error(`Unknown id kind: ${kind}`);
|
|
24
|
-
let max = 0;
|
|
25
|
-
for (const id of existingIds) {
|
|
26
|
-
const m = String(id).match(/^(?:card|cand)-(\d{3})$/);
|
|
27
|
-
if (m) max = Math.max(max, Number(m[1]));
|
|
28
|
-
}
|
|
29
|
-
return `${kind}-${String(max + 1).padStart(3, '0')}`;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
21
|
/** `cand-012` → `card-012`: materialization keeps the number (GCF-PRD §5.2). */
|
|
33
22
|
export function candToCard(candId) {
|
|
34
23
|
const m = String(candId).match(/^cand-(\d{3})$/);
|
package/lib/safe-path.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readFile, writeFile, rename, mkdir, unlink } from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import {
|
|
3
|
+
import { snapshotIndex, restoreIndex } from './git.js';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Resolve `target` and assert it stays inside `projectDir` (reject traversal / outside-project).
|
|
@@ -39,10 +39,15 @@ export async function atomicWrite(file, data) {
|
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
/**
|
|
43
|
-
|
|
42
|
+
/**
|
|
43
|
+
* Restore every listed file to its original bytes when `action` fails. With `gitCwd` (an
|
|
44
|
+
* existing directory in the work tree) the files' git index entries are restored as well.
|
|
45
|
+
*/
|
|
46
|
+
export async function withFileRollback(files, action, { gitCwd = null } = {}) {
|
|
47
|
+
const unique = [...new Set(files)];
|
|
44
48
|
const snapshots = [];
|
|
45
|
-
|
|
49
|
+
const index = gitCwd ? snapshotIndex(gitCwd, unique) : null;
|
|
50
|
+
for (const file of unique) {
|
|
46
51
|
try {
|
|
47
52
|
snapshots.push([file, await readFile(file, 'utf8')]);
|
|
48
53
|
} catch (err) {
|
|
@@ -63,8 +68,9 @@ export async function withFileRollback(files, action) {
|
|
|
63
68
|
rollbackErrors.push(rollbackError);
|
|
64
69
|
}
|
|
65
70
|
}
|
|
66
|
-
// An inner step may already have staged its
|
|
67
|
-
|
|
71
|
+
// An inner step may already have staged its writes. Re-adding the restored files would
|
|
72
|
+
// fold the user's unstaged edits into the index; putting back the old entries keeps them apart.
|
|
73
|
+
rollbackErrors.push(...restoreIndex(index));
|
|
68
74
|
if (rollbackErrors.length) throw new AggregateError([err, ...rollbackErrors], `Operation failed and rollback was incomplete: ${err.message}`);
|
|
69
75
|
throw err;
|
|
70
76
|
}
|
package/lib/verdict-fs.js
CHANGED
|
@@ -4,7 +4,7 @@ import path from 'node:path';
|
|
|
4
4
|
import { spawnSync } from 'node:child_process';
|
|
5
5
|
import { parse, serialize } from './frontmatter.js';
|
|
6
6
|
import { listCards } from './gcf-paths.js';
|
|
7
|
-
import { atomicWrite, withFileRollback } from './safe-path.js';
|
|
7
|
+
import { atomicWrite, resolveInside, withFileRollback } from './safe-path.js';
|
|
8
8
|
import { writeIndex } from './index-gen.js';
|
|
9
9
|
import { isLegal } from './transitions.js';
|
|
10
10
|
|
|
@@ -44,8 +44,6 @@ function operationInProgress(projectDir) {
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
function gitCommit(projectDir, message, addPaths) {
|
|
47
|
-
const busy = operationInProgress(projectDir);
|
|
48
|
-
if (busy) throw new Error(`cannot record a verdict while ${busy} is in progress — finish or abort it first`);
|
|
49
47
|
// Stage and commit exactly the files this verdict touched: `git add -- gcf/` would stage a
|
|
50
48
|
// user's unrelated edits under gcf/, and rollback only restores what it snapshotted.
|
|
51
49
|
for (const p of addPaths) spawnSync('git', ['add', '--', p], { cwd: projectDir, stdio: 'ignore' });
|
|
@@ -72,6 +70,10 @@ export async function applyVerdict(projectDir, cardId, verdict, { note = '', tag
|
|
|
72
70
|
if (!isLegal(card.data.status, target)) {
|
|
73
71
|
throw new Error(`Illegal transition ${card.data.status} -> ${target} for ${cardId} (verdict needs status=built)`);
|
|
74
72
|
}
|
|
73
|
+
// Before any write: refusing after the files changed would send a failed commit through the
|
|
74
|
+
// rollback, and restoring a conflicted index entry marks the conflict resolved.
|
|
75
|
+
const busy = commit ? operationInProgress(projectDir) : null;
|
|
76
|
+
if (busy) throw new Error(`cannot record a verdict while ${busy} is in progress — finish or abort it first`);
|
|
75
77
|
|
|
76
78
|
const touched = [card.file, path.join(root, 'INDEX.md')];
|
|
77
79
|
const systems = card.data.system ?? [];
|
|
@@ -95,7 +97,9 @@ export async function applyVerdict(projectDir, cardId, verdict, { note = '', tag
|
|
|
95
97
|
await unlink(card.file);
|
|
96
98
|
};
|
|
97
99
|
} else {
|
|
98
|
-
|
|
100
|
+
// `system:` names files, and the pre-commit gate validates it only after they are written:
|
|
101
|
+
// `../../x` would already have overwritten a file outside gcf/.
|
|
102
|
+
const sysFiles = systems.map((s) => resolveInside(path.join(root, 'systems'), `${s}.md`));
|
|
99
103
|
touched.push(...sysFiles);
|
|
100
104
|
mutate = async () => {
|
|
101
105
|
for (const [i, sysFile] of sysFiles.entries()) {
|
|
@@ -121,6 +125,6 @@ export async function applyVerdict(projectDir, cardId, verdict, { note = '', tag
|
|
|
121
125
|
await mutate();
|
|
122
126
|
await writeIndex(root);
|
|
123
127
|
if (commit) hash = gitCommit(projectDir, message, touched);
|
|
124
|
-
});
|
|
128
|
+
}, { gitCwd: commit ? path.resolve(projectDir) : null });
|
|
125
129
|
return { verdict, card: cardId, systems, hash };
|
|
126
130
|
}
|