@ikon85/agent-workflow-kit 0.13.0 → 0.14.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/README.md +9 -0
- package/agent-workflow-kit.package.json +43 -1
- package/package.json +1 -1
- package/scripts/census/census.test.mjs +252 -0
- package/scripts/census/delta.mjs +14 -0
- package/scripts/census/fingerprint.mjs +20 -0
- package/scripts/census/index.mjs +9 -0
- package/scripts/census/scan.mjs +174 -0
- package/scripts/census/state.mjs +23 -0
- package/scripts/census/state.test.mjs +15 -0
- package/scripts/census/transaction.mjs +50 -0
- package/scripts/census/transaction.test.mjs +103 -0
- package/src/lib/bundle.mjs +8 -0
package/README.md
CHANGED
|
@@ -332,6 +332,15 @@ still reference. Flags: `--force` (overwrite pre-existing files on `init`),
|
|
|
332
332
|
|
|
333
333
|
## Release notes
|
|
334
334
|
|
|
335
|
+
### 0.14.0
|
|
336
|
+
|
|
337
|
+
- added: `scripts/census/delta.mjs`
|
|
338
|
+
- added: `scripts/census/fingerprint.mjs`
|
|
339
|
+
- added: `scripts/census/index.mjs`
|
|
340
|
+
- added: `scripts/census/scan.mjs`
|
|
341
|
+
- added: `scripts/census/state.mjs`
|
|
342
|
+
- added: `scripts/census/transaction.mjs`
|
|
343
|
+
|
|
335
344
|
### 0.13.0
|
|
336
345
|
|
|
337
346
|
- added: `.agents/skills/setup-workflow/assets/agent-workflow-kit-update.yml`
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"kitVersion": "0.
|
|
2
|
+
"kitVersion": "0.14.0",
|
|
3
3
|
"files": [
|
|
4
4
|
{
|
|
5
5
|
"path": ".agents/skills/ask-matt/SKILL.md",
|
|
@@ -1880,6 +1880,48 @@
|
|
|
1880
1880
|
"mode": 493,
|
|
1881
1881
|
"origin": "kit"
|
|
1882
1882
|
},
|
|
1883
|
+
{
|
|
1884
|
+
"path": "scripts/census/delta.mjs",
|
|
1885
|
+
"kind": "script",
|
|
1886
|
+
"sha256": "b4f92ecb4bf1920bd62707c83f308269385bc5e35c2205abc97d8d8b849ade84",
|
|
1887
|
+
"mode": 420,
|
|
1888
|
+
"origin": "kit"
|
|
1889
|
+
},
|
|
1890
|
+
{
|
|
1891
|
+
"path": "scripts/census/fingerprint.mjs",
|
|
1892
|
+
"kind": "script",
|
|
1893
|
+
"sha256": "fbb25d9f53ab74a9bdffc5db816a56f0d976204b74c2126b8ec7c11e8c8d54fc",
|
|
1894
|
+
"mode": 420,
|
|
1895
|
+
"origin": "kit"
|
|
1896
|
+
},
|
|
1897
|
+
{
|
|
1898
|
+
"path": "scripts/census/index.mjs",
|
|
1899
|
+
"kind": "script",
|
|
1900
|
+
"sha256": "ef87d073842dec13f27c929e573e064314fdd704aeeec85e80d526c044f23a9a",
|
|
1901
|
+
"mode": 420,
|
|
1902
|
+
"origin": "kit"
|
|
1903
|
+
},
|
|
1904
|
+
{
|
|
1905
|
+
"path": "scripts/census/scan.mjs",
|
|
1906
|
+
"kind": "script",
|
|
1907
|
+
"sha256": "0988282c8aa6f1e1644eab924fab6e5e306c5a8bb3c26175ff74080d8de673d9",
|
|
1908
|
+
"mode": 420,
|
|
1909
|
+
"origin": "kit"
|
|
1910
|
+
},
|
|
1911
|
+
{
|
|
1912
|
+
"path": "scripts/census/state.mjs",
|
|
1913
|
+
"kind": "script",
|
|
1914
|
+
"sha256": "588fb5bd3a3855fd7acdf9c056c63e0a04f94308b91f48b9be18807ca849fab7",
|
|
1915
|
+
"mode": 420,
|
|
1916
|
+
"origin": "kit"
|
|
1917
|
+
},
|
|
1918
|
+
{
|
|
1919
|
+
"path": "scripts/census/transaction.mjs",
|
|
1920
|
+
"kind": "script",
|
|
1921
|
+
"sha256": "63a27f4b28583178bfc08fd6ac553528d9d955e1ec06bc37f779824d5fcaa92f",
|
|
1922
|
+
"mode": 420,
|
|
1923
|
+
"origin": "kit"
|
|
1924
|
+
},
|
|
1883
1925
|
{
|
|
1884
1926
|
"path": "scripts/execute-ready-check.py",
|
|
1885
1927
|
"kind": "script",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ikon85/agent-workflow-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"description": "Portable AI-agent workflow skills (plan → execute → land → learn) for Claude Code & Codex — grilling, TDD, diagnosis, two-axis code review, cross-model Codex review, design & domain-modeling, plus a skill router (ask-matt). npx init/update/diff/uninstall.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { cp, mkdtemp, mkdir, rm, symlink, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { execFile } from 'node:child_process';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import {
|
|
9
|
+
CENSUS_VERDICTS,
|
|
10
|
+
diffCensus,
|
|
11
|
+
scanCensus,
|
|
12
|
+
serializeCensus,
|
|
13
|
+
} from './index.mjs';
|
|
14
|
+
|
|
15
|
+
const exec = promisify(execFile);
|
|
16
|
+
const FIXTURE = new URL('../../test/fixtures/census-recipes/known/', import.meta.url);
|
|
17
|
+
|
|
18
|
+
async function makeRepository() {
|
|
19
|
+
const root = await mkdtemp(join(tmpdir(), 'awk-census-'));
|
|
20
|
+
await cp(FIXTURE, root, { recursive: true });
|
|
21
|
+
await exec('git', ['init', '--quiet'], { cwd: root });
|
|
22
|
+
await exec('git', ['add', '.'], { cwd: root });
|
|
23
|
+
await exec('git', ['add', '-f', 'ignored/src/ignored.mjs'], { cwd: root });
|
|
24
|
+
return root;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
test('known recipe scans are byte-identical and separate denominator from evidence', async () => {
|
|
28
|
+
const root = await makeRepository();
|
|
29
|
+
try {
|
|
30
|
+
const first = await scanCensus({ repoRoot: root });
|
|
31
|
+
const second = await scanCensus({ repoRoot: root });
|
|
32
|
+
|
|
33
|
+
assert.equal(serializeCensus(first), serializeCensus(second));
|
|
34
|
+
assert.deepEqual(first.denominator.map(({ path }) => path), [
|
|
35
|
+
'package.json',
|
|
36
|
+
'src/index.mjs',
|
|
37
|
+
]);
|
|
38
|
+
assert.deepEqual(first.evidence.map(({ path }) => path), [
|
|
39
|
+
'docs/architecture.md',
|
|
40
|
+
'test/index.test.mjs',
|
|
41
|
+
]);
|
|
42
|
+
assert.equal(first.fingerprints.builder.length, 64);
|
|
43
|
+
assert.equal(first.fingerprints.topology.length, 64);
|
|
44
|
+
} finally {
|
|
45
|
+
await rm(root, { recursive: true, force: true });
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('scan state reflects explicit activation and active-census facts', async () => {
|
|
50
|
+
const root = await makeRepository();
|
|
51
|
+
try {
|
|
52
|
+
assert.equal((await scanCensus({ repoRoot: root })).state, 'disabled');
|
|
53
|
+
assert.equal((await scanCensus({ repoRoot: root, enabled: true })).state, 'bootstrap');
|
|
54
|
+
assert.equal((await scanCensus({ repoRoot: root, enabled: true, hasActive: true })).state, 'current');
|
|
55
|
+
|
|
56
|
+
await mkdir(join(root, 'new-service', 'src'), { recursive: true });
|
|
57
|
+
await writeFile(join(root, 'new-service', 'src', 'index.mjs'), 'export const newService = true;\n');
|
|
58
|
+
assert.equal(
|
|
59
|
+
(await scanCensus({ repoRoot: root, enabled: true, hasActive: true })).state,
|
|
60
|
+
'refresh_required',
|
|
61
|
+
);
|
|
62
|
+
} finally {
|
|
63
|
+
await rm(root, { recursive: true, force: true });
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('secret, ignored, generated, and vendor paths are never read or counted', async () => {
|
|
68
|
+
const root = await makeRepository();
|
|
69
|
+
try {
|
|
70
|
+
const reads = [];
|
|
71
|
+
const result = await scanCensus({
|
|
72
|
+
repoRoot: root,
|
|
73
|
+
readText: async (path) => {
|
|
74
|
+
reads.push(path);
|
|
75
|
+
return (await import('node:fs/promises')).readFile(path, 'utf8');
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
const output = serializeCensus(result);
|
|
79
|
+
|
|
80
|
+
assert.ok(!reads.some((path) => path.includes('secrets')));
|
|
81
|
+
assert.ok(!reads.some((path) => path.includes('credentials')));
|
|
82
|
+
assert.ok(!output.includes('CENSUS_SECRET_CANARY'));
|
|
83
|
+
assert.ok(!output.includes('secrets/'));
|
|
84
|
+
assert.ok(!output.includes('credentials.txt'));
|
|
85
|
+
assert.ok(!output.includes('vendor/src'));
|
|
86
|
+
assert.ok(!output.includes('dist/src'));
|
|
87
|
+
assert.ok(!output.includes('ignored/src'));
|
|
88
|
+
assert.equal(result.denominator.length, 2);
|
|
89
|
+
} finally {
|
|
90
|
+
await rm(root, { recursive: true, force: true });
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('tracked product symlinks stay unread and force an open refresh', async () => {
|
|
95
|
+
const root = await makeRepository();
|
|
96
|
+
const external = await mkdtemp(join(tmpdir(), 'awk-census-external-'));
|
|
97
|
+
const externalPath = join(external, 'canary.mjs');
|
|
98
|
+
await writeFile(externalPath, 'CENSUS_EXTERNAL_SECRET_CANARY\n');
|
|
99
|
+
await symlink(externalPath, join(root, 'src', 'external.mjs'));
|
|
100
|
+
await exec('git', ['add', 'src/external.mjs'], { cwd: root });
|
|
101
|
+
try {
|
|
102
|
+
const reads = [];
|
|
103
|
+
const result = await scanCensus({
|
|
104
|
+
repoRoot: root,
|
|
105
|
+
enabled: true,
|
|
106
|
+
hasActive: true,
|
|
107
|
+
readText: async (path) => {
|
|
108
|
+
reads.push(path);
|
|
109
|
+
return (await import('node:fs/promises')).readFile(path, 'utf8');
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
assert.ok(!reads.includes(join(root, 'src', 'external.mjs')));
|
|
114
|
+
assert.ok(!reads.includes(externalPath));
|
|
115
|
+
assert.ok(!serializeCensus(result).includes('CENSUS_EXTERNAL_SECRET_CANARY'));
|
|
116
|
+
assert.ok(!result.denominator.some(({ path }) => path === 'src/external.mjs'));
|
|
117
|
+
assert.deepEqual(result.families.surfaces, [
|
|
118
|
+
{ name: 'production-config', status: 'abgedeckt', type: 'surface' },
|
|
119
|
+
{ name: 'src', status: 'offen', type: 'surface' },
|
|
120
|
+
]);
|
|
121
|
+
assert.equal(result.state, 'refresh_required');
|
|
122
|
+
} finally {
|
|
123
|
+
await rm(root, { recursive: true, force: true });
|
|
124
|
+
await rm(external, { recursive: true, force: true });
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('a tracked product file missing from the working tree forces an open refresh', async () => {
|
|
129
|
+
const root = await makeRepository();
|
|
130
|
+
try {
|
|
131
|
+
await rm(join(root, 'src', 'index.mjs'));
|
|
132
|
+
|
|
133
|
+
const result = await scanCensus({ repoRoot: root, enabled: true, hasActive: true });
|
|
134
|
+
|
|
135
|
+
assert.deepEqual(result.denominator.map(({ path }) => path), ['package.json']);
|
|
136
|
+
assert.deepEqual(result.families.surfaces, [
|
|
137
|
+
{ name: 'production-config', status: 'abgedeckt', type: 'surface' },
|
|
138
|
+
{ name: 'src', status: 'offen', type: 'surface' },
|
|
139
|
+
]);
|
|
140
|
+
assert.equal(result.state, 'refresh_required');
|
|
141
|
+
} finally {
|
|
142
|
+
await rm(root, { recursive: true, force: true });
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('an unreadable tracked product file makes its family open without a duplicate covered verdict', async () => {
|
|
147
|
+
const root = await makeRepository();
|
|
148
|
+
try {
|
|
149
|
+
const result = await scanCensus({
|
|
150
|
+
repoRoot: root,
|
|
151
|
+
enabled: true,
|
|
152
|
+
hasActive: true,
|
|
153
|
+
readText: async (path) => {
|
|
154
|
+
if (path === join(root, 'src', 'index.mjs')) throw new Error('simulated unreadable product file');
|
|
155
|
+
return (await import('node:fs/promises')).readFile(path, 'utf8');
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
assert.deepEqual(result.families.surfaces, [
|
|
160
|
+
{ name: 'production-config', status: 'abgedeckt', type: 'surface' },
|
|
161
|
+
{ name: 'src', status: 'offen', type: 'surface' },
|
|
162
|
+
]);
|
|
163
|
+
assert.equal(result.state, 'refresh_required');
|
|
164
|
+
} finally {
|
|
165
|
+
await rm(root, { recursive: true, force: true });
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('an untracked source root is open and prevents current', async () => {
|
|
170
|
+
const root = await makeRepository();
|
|
171
|
+
try {
|
|
172
|
+
await mkdir(join(root, 'new-service', 'src'), { recursive: true });
|
|
173
|
+
await writeFile(join(root, 'new-service', 'src', 'index.mjs'), 'export const newService = true;\n');
|
|
174
|
+
|
|
175
|
+
const result = await scanCensus({ repoRoot: root, enabled: true, hasActive: true });
|
|
176
|
+
|
|
177
|
+
assert.equal(result.state, 'refresh_required');
|
|
178
|
+
assert.deepEqual(result.families.surfaces.filter(({ status }) => status === 'offen'), [
|
|
179
|
+
{ name: 'new-service/src', status: 'offen', type: 'surface' },
|
|
180
|
+
]);
|
|
181
|
+
} finally {
|
|
182
|
+
await rm(root, { recursive: true, force: true });
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test('delta reports compact path changes and open families', async () => {
|
|
187
|
+
const root = await makeRepository();
|
|
188
|
+
try {
|
|
189
|
+
const previous = await scanCensus({ repoRoot: root });
|
|
190
|
+
await writeFile(join(root, 'src', 'index.mjs'), 'export const answer = 43;\n');
|
|
191
|
+
await mkdir(join(root, 'new-service', 'src'), { recursive: true });
|
|
192
|
+
await writeFile(join(root, 'new-service', 'src', 'index.mjs'), 'export const newService = true;\n');
|
|
193
|
+
const next = await scanCensus({ repoRoot: root });
|
|
194
|
+
|
|
195
|
+
assert.deepEqual(diffCensus(previous, next), {
|
|
196
|
+
added: [],
|
|
197
|
+
changed: ['src/index.mjs'],
|
|
198
|
+
open: ['new-service/src'],
|
|
199
|
+
removed: [],
|
|
200
|
+
});
|
|
201
|
+
} finally {
|
|
202
|
+
await rm(root, { recursive: true, force: true });
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test('content-only edits do not change the topology fingerprint', async () => {
|
|
207
|
+
const root = await makeRepository();
|
|
208
|
+
try {
|
|
209
|
+
const previous = await scanCensus({ repoRoot: root });
|
|
210
|
+
await writeFile(join(root, 'src', 'index.mjs'), 'export const answer = 99;\n');
|
|
211
|
+
const next = await scanCensus({ repoRoot: root });
|
|
212
|
+
|
|
213
|
+
assert.equal(previous.fingerprints.topology, next.fingerprints.topology);
|
|
214
|
+
assert.deepEqual(diffCensus(previous, next).changed, ['src/index.mjs']);
|
|
215
|
+
} finally {
|
|
216
|
+
await rm(root, { recursive: true, force: true });
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test('family verdicts expose every machine-readable contract value', () => {
|
|
221
|
+
assert.deepEqual(CENSUS_VERDICTS, {
|
|
222
|
+
covered: 'abgedeckt',
|
|
223
|
+
notRelevant: 'nicht relevant',
|
|
224
|
+
open: 'offen',
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test('behavior families represent covered, not-relevant, and open verdicts separately', async () => {
|
|
229
|
+
const root = await makeRepository();
|
|
230
|
+
try {
|
|
231
|
+
const result = await scanCensus({
|
|
232
|
+
repoRoot: root,
|
|
233
|
+
enabled: true,
|
|
234
|
+
hasActive: true,
|
|
235
|
+
behaviorFamilies: [
|
|
236
|
+
{ name: 'auth-session', status: CENSUS_VERDICTS.covered },
|
|
237
|
+
{ name: 'billing', status: CENSUS_VERDICTS.notRelevant },
|
|
238
|
+
{ name: 'email-delivery', status: CENSUS_VERDICTS.open },
|
|
239
|
+
],
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
assert.deepEqual(result.families.behaviors, [
|
|
243
|
+
{ name: 'auth-session', status: 'abgedeckt', type: 'behavior' },
|
|
244
|
+
{ name: 'billing', status: 'nicht relevant', type: 'behavior' },
|
|
245
|
+
{ name: 'email-delivery', status: 'offen', type: 'behavior' },
|
|
246
|
+
]);
|
|
247
|
+
assert.ok(result.families.surfaces.every(({ type }) => type === 'surface'));
|
|
248
|
+
assert.equal(result.state, 'refresh_required');
|
|
249
|
+
} finally {
|
|
250
|
+
await rm(root, { recursive: true, force: true });
|
|
251
|
+
}
|
|
252
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
function byPath(census) {
|
|
2
|
+
return new Map(census.denominator.map((entry) => [entry.path, entry.hash]));
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function diffCensus(previous, next) {
|
|
6
|
+
const before = byPath(previous);
|
|
7
|
+
const after = byPath(next);
|
|
8
|
+
const added = [...after.keys()].filter((path) => !before.has(path)).sort();
|
|
9
|
+
const removed = [...before.keys()].filter((path) => !after.has(path)).sort();
|
|
10
|
+
const changed = [...after.keys()].filter((path) => before.has(path) && before.get(path) !== after.get(path)).sort();
|
|
11
|
+
const open = [...next.families.surfaces, ...next.families.behaviors]
|
|
12
|
+
.filter(({ status }) => status === 'offen').map(({ name }) => name).sort();
|
|
13
|
+
return { added, changed, open, removed };
|
|
14
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
export const CENSUS_BUILDER_VERSION = '1';
|
|
4
|
+
|
|
5
|
+
export function sha256(value) {
|
|
6
|
+
return createHash('sha256').update(value).digest('hex');
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function fingerprintCensus({ denominator, evidence, families }) {
|
|
10
|
+
const structural = (entries) => entries.map(({ family, kind, path }) => ({ family, kind, path }));
|
|
11
|
+
const topology = {
|
|
12
|
+
denominator: structural(denominator),
|
|
13
|
+
evidence: structural(evidence),
|
|
14
|
+
families,
|
|
15
|
+
};
|
|
16
|
+
return {
|
|
17
|
+
builder: sha256(`agent-workflow-kit-census:${CENSUS_BUILDER_VERSION}`),
|
|
18
|
+
topology: sha256(JSON.stringify(topology)),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { scanCensus } from './scan.mjs';
|
|
2
|
+
export { CENSUS_BUILDER_VERSION, fingerprintCensus } from './fingerprint.mjs';
|
|
3
|
+
export { CENSUS_STATES, CENSUS_VERDICTS, resolveCensusState } from './state.mjs';
|
|
4
|
+
export { diffCensus } from './delta.mjs';
|
|
5
|
+
export { activateCensus, CensusTransactionError } from './transaction.mjs';
|
|
6
|
+
|
|
7
|
+
export function serializeCensus(census) {
|
|
8
|
+
return `${JSON.stringify(census)}\n`;
|
|
9
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { execFile, spawn } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
import { lstat, readFile, realpath } from 'node:fs/promises';
|
|
4
|
+
import { isAbsolute, relative, resolve, sep } from 'node:path';
|
|
5
|
+
import { fingerprintCensus, sha256 } from './fingerprint.mjs';
|
|
6
|
+
import { CENSUS_VERDICTS, resolveCensusState } from './state.mjs';
|
|
7
|
+
|
|
8
|
+
const exec = promisify(execFile);
|
|
9
|
+
const EVIDENCE_PARTS = new Set(['docs', 'test', 'tests', '__tests__']);
|
|
10
|
+
const EXCLUDED_PARTS = new Set([
|
|
11
|
+
'.git', '.next', 'build', 'coverage', 'dist', 'generated', 'node_modules', 'vendor',
|
|
12
|
+
]);
|
|
13
|
+
const CONFIG_NAMES = new Set([
|
|
14
|
+
'package.json', 'tsconfig.json', 'vite.config.js', 'vite.config.mjs',
|
|
15
|
+
'vite.config.ts', 'next.config.js', 'next.config.mjs', 'Dockerfile',
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
function pathParts(path) {
|
|
19
|
+
return path.split('/');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function isEvidence(path) {
|
|
23
|
+
const parts = pathParts(path);
|
|
24
|
+
return parts.some((part) => EVIDENCE_PARTS.has(part))
|
|
25
|
+
|| /(?:^|\.)test\.[^.]+$/.test(path)
|
|
26
|
+
|| /(?:^|\.)spec\.[^.]+$/.test(path)
|
|
27
|
+
|| /(?:^|\/)README(?:\.[^/]*)?$/.test(path);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isSecret(path) {
|
|
31
|
+
return pathParts(path).some((part) => {
|
|
32
|
+
const name = part.toLowerCase();
|
|
33
|
+
return name === '.env' || name.startsWith('.env.')
|
|
34
|
+
|| /^(?:secrets?|credentials?)(?:[._-]|$)/.test(name)
|
|
35
|
+
|| /private[-_.]?key/.test(name);
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isExcluded(path) {
|
|
40
|
+
return pathParts(path).some((part) => EXCLUDED_PARTS.has(part));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isProduct(path) {
|
|
44
|
+
const parts = pathParts(path);
|
|
45
|
+
if (CONFIG_NAMES.has(parts.at(-1))) return true;
|
|
46
|
+
return parts.some((part) => ['src', 'app', 'apps', 'lib', 'packages'].includes(part));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function familyFor(path, kind) {
|
|
50
|
+
if (kind === 'config') return 'production-config';
|
|
51
|
+
const parts = pathParts(path);
|
|
52
|
+
const rootIndex = parts.findIndex((part) => ['src', 'app', 'apps', 'lib', 'packages'].includes(part));
|
|
53
|
+
const packageRoot = ['apps', 'packages'].includes(parts[rootIndex]);
|
|
54
|
+
return parts.slice(0, Math.min(rootIndex + (packageRoot ? 2 : 1), parts.length)).join('/');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function gitPaths(repoRoot, args) {
|
|
58
|
+
const { stdout } = await exec('git', args, { cwd: repoRoot, encoding: 'utf8' });
|
|
59
|
+
return stdout.split('\0').filter(Boolean).sort();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function gitIgnoredPaths(repoRoot, paths) {
|
|
63
|
+
if (!paths.length) return new Set();
|
|
64
|
+
return new Promise((resolve, reject) => {
|
|
65
|
+
const child = spawn('git', ['check-ignore', '--no-index', '-z', '--stdin'], { cwd: repoRoot });
|
|
66
|
+
const chunks = [];
|
|
67
|
+
child.stdout.on('data', (chunk) => chunks.push(chunk));
|
|
68
|
+
child.on('error', reject);
|
|
69
|
+
child.on('close', (code) => {
|
|
70
|
+
if (code !== 0 && code !== 1) return reject(new Error(`git check-ignore exited ${code}`));
|
|
71
|
+
resolve(new Set(Buffer.concat(chunks).toString('utf8').split('\0').filter(Boolean)));
|
|
72
|
+
});
|
|
73
|
+
child.stdin.end(`${paths.join('\0')}\0`);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function regularContainedPath(repoRoot, realRepoRoot, path) {
|
|
78
|
+
const candidate = resolve(repoRoot, path);
|
|
79
|
+
const lexicalRelative = relative(resolve(repoRoot), candidate);
|
|
80
|
+
if (lexicalRelative === '..' || lexicalRelative.startsWith(`..${sep}`) || isAbsolute(lexicalRelative)) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
let stats;
|
|
84
|
+
let canonical;
|
|
85
|
+
try {
|
|
86
|
+
stats = await lstat(candidate);
|
|
87
|
+
// Symlinks, including links that resolve inside the repository, are excluded.
|
|
88
|
+
// Only a regular file whose canonical path remains inside the repository may be read.
|
|
89
|
+
if (!stats.isFile()) return null;
|
|
90
|
+
canonical = await realpath(candidate);
|
|
91
|
+
} catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
const canonicalRelative = relative(realRepoRoot, canonical);
|
|
95
|
+
if (canonicalRelative === '..' || canonicalRelative.startsWith(`..${sep}`) || isAbsolute(canonicalRelative)) {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
return candidate;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function behaviorSummary(entries) {
|
|
102
|
+
const verdicts = new Set(Object.values(CENSUS_VERDICTS));
|
|
103
|
+
return entries.map(({ name, status }) => {
|
|
104
|
+
if (typeof name !== 'string' || !name) throw new TypeError('behavior family requires a name');
|
|
105
|
+
if (!verdicts.has(status)) throw new TypeError(`invalid behavior family verdict: ${status}`);
|
|
106
|
+
return { name, status, type: 'behavior' };
|
|
107
|
+
}).sort((left, right) => {
|
|
108
|
+
const leftKey = `${left.name}\0${left.status}`;
|
|
109
|
+
const rightKey = `${right.name}\0${right.status}`;
|
|
110
|
+
return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0;
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function surfaceSummary(entries, openPaths) {
|
|
115
|
+
const statuses = new Map(entries.map(({ family }) => [family, CENSUS_VERDICTS.covered]));
|
|
116
|
+
for (const path of openPaths) {
|
|
117
|
+
if (isProduct(path) && !isExcluded(path) && !isSecret(path)) {
|
|
118
|
+
const kind = CONFIG_NAMES.has(pathParts(path).at(-1)) ? 'config' : 'source';
|
|
119
|
+
statuses.set(familyFor(path, kind), CENSUS_VERDICTS.open);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return [...statuses].sort(([left], [right]) => (
|
|
123
|
+
left < right ? -1 : left > right ? 1 : 0
|
|
124
|
+
)).map(([name, status]) => ({
|
|
125
|
+
name, status, type: 'surface',
|
|
126
|
+
}));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function scanCensus({
|
|
130
|
+
repoRoot,
|
|
131
|
+
behaviorFamilies = [],
|
|
132
|
+
enabled = false,
|
|
133
|
+
hasActive = false,
|
|
134
|
+
readText = (path) => readFile(path, 'utf8'),
|
|
135
|
+
}) {
|
|
136
|
+
const realRepoRoot = await realpath(repoRoot);
|
|
137
|
+
const tracked = await gitPaths(repoRoot, ['ls-files', '-z']);
|
|
138
|
+
const untracked = await gitPaths(repoRoot, ['ls-files', '--others', '--exclude-standard', '-z']);
|
|
139
|
+
const ignored = await gitIgnoredPaths(repoRoot, tracked);
|
|
140
|
+
const denominator = [];
|
|
141
|
+
const evidence = [];
|
|
142
|
+
const openPaths = [...untracked];
|
|
143
|
+
for (const path of tracked) {
|
|
144
|
+
if (ignored.has(path) || isSecret(path) || isExcluded(path)) continue;
|
|
145
|
+
const target = isEvidence(path) ? evidence : isProduct(path) ? denominator : null;
|
|
146
|
+
if (!target) continue;
|
|
147
|
+
const readablePath = await regularContainedPath(repoRoot, realRepoRoot, path);
|
|
148
|
+
if (!readablePath) {
|
|
149
|
+
if (target === denominator) openPaths.push(path);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
let content;
|
|
153
|
+
try {
|
|
154
|
+
content = await readText(readablePath);
|
|
155
|
+
} catch {
|
|
156
|
+
if (target === denominator) openPaths.push(path);
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
const kind = CONFIG_NAMES.has(pathParts(path).at(-1)) ? 'config' : 'source';
|
|
160
|
+
target.push({ family: familyFor(path, kind), hash: sha256(content), kind, path });
|
|
161
|
+
}
|
|
162
|
+
const families = {
|
|
163
|
+
surfaces: surfaceSummary(denominator, openPaths),
|
|
164
|
+
behaviors: behaviorSummary(behaviorFamilies),
|
|
165
|
+
};
|
|
166
|
+
const result = { denominator, evidence, families };
|
|
167
|
+
const hasOpen = [...families.surfaces, ...families.behaviors]
|
|
168
|
+
.some(({ status }) => status === CENSUS_VERDICTS.open);
|
|
169
|
+
return {
|
|
170
|
+
...result,
|
|
171
|
+
fingerprints: fingerprintCensus(result),
|
|
172
|
+
state: resolveCensusState({ enabled, hasActive, hasOpen }),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export const CENSUS_STATES = Object.freeze([
|
|
2
|
+
'disabled',
|
|
3
|
+
'bootstrap',
|
|
4
|
+
'current',
|
|
5
|
+
'refresh_required',
|
|
6
|
+
'updating',
|
|
7
|
+
'failed',
|
|
8
|
+
]);
|
|
9
|
+
|
|
10
|
+
export const CENSUS_VERDICTS = Object.freeze({
|
|
11
|
+
covered: 'abgedeckt',
|
|
12
|
+
notRelevant: 'nicht relevant',
|
|
13
|
+
open: 'offen',
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
export function resolveCensusState({ enabled, hasActive, hasOpen = false, updating = false, failed = false }) {
|
|
17
|
+
if (!enabled) return 'disabled';
|
|
18
|
+
if (updating) return 'updating';
|
|
19
|
+
if (failed) return 'failed';
|
|
20
|
+
if (!hasActive) return 'bootstrap';
|
|
21
|
+
if (hasOpen) return 'refresh_required';
|
|
22
|
+
return 'current';
|
|
23
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { CENSUS_STATES, resolveCensusState } from './index.mjs';
|
|
4
|
+
|
|
5
|
+
test('state resolver implements the complete census state contract', () => {
|
|
6
|
+
assert.deepEqual(CENSUS_STATES, [
|
|
7
|
+
'disabled', 'bootstrap', 'current', 'refresh_required', 'updating', 'failed',
|
|
8
|
+
]);
|
|
9
|
+
assert.equal(resolveCensusState({ enabled: false }), 'disabled');
|
|
10
|
+
assert.equal(resolveCensusState({ enabled: true, hasActive: false }), 'bootstrap');
|
|
11
|
+
assert.equal(resolveCensusState({ enabled: true, hasActive: true }), 'current');
|
|
12
|
+
assert.equal(resolveCensusState({ enabled: true, hasActive: true, hasOpen: true }), 'refresh_required');
|
|
13
|
+
assert.equal(resolveCensusState({ enabled: true, hasActive: true, updating: true }), 'updating');
|
|
14
|
+
assert.equal(resolveCensusState({ enabled: true, hasActive: true, failed: true }), 'failed');
|
|
15
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { mkdir, open, rename, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
|
|
4
|
+
export class CensusTransactionError extends Error {
|
|
5
|
+
constructor(message, state, cause) {
|
|
6
|
+
super(message, { cause });
|
|
7
|
+
this.name = 'CensusTransactionError';
|
|
8
|
+
this.state = state;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
async function acquireLock(lockPath) {
|
|
12
|
+
await mkdir(dirname(lockPath), { recursive: true });
|
|
13
|
+
try {
|
|
14
|
+
const handle = await open(lockPath, 'wx');
|
|
15
|
+
await handle.close();
|
|
16
|
+
} catch (error) {
|
|
17
|
+
if (error.code === 'EEXIST') {
|
|
18
|
+
throw new CensusTransactionError(`census update already locked: ${lockPath}`, 'updating', error);
|
|
19
|
+
}
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Stage, verify, then atomically replace the active census while holding a local lock. */
|
|
25
|
+
export async function activateCensus({
|
|
26
|
+
activePath,
|
|
27
|
+
candidate,
|
|
28
|
+
verify,
|
|
29
|
+
lockPath = `${activePath}.lock`,
|
|
30
|
+
stagePath = `${activePath}.candidate`,
|
|
31
|
+
renameCandidate = rename,
|
|
32
|
+
}) {
|
|
33
|
+
if (typeof verify !== 'function') {
|
|
34
|
+
throw new TypeError('census activation requires a callable verifier');
|
|
35
|
+
}
|
|
36
|
+
await acquireLock(lockPath);
|
|
37
|
+
try {
|
|
38
|
+
const serialized = `${JSON.stringify(candidate)}\n`;
|
|
39
|
+
await writeFile(stagePath, serialized, 'utf8');
|
|
40
|
+
const verified = await verify(candidate, { activePath, stagePath });
|
|
41
|
+
if (verified === false) throw new Error('census candidate verification returned false');
|
|
42
|
+
await renameCandidate(stagePath, activePath);
|
|
43
|
+
return { activePath, state: 'current' };
|
|
44
|
+
} catch (error) {
|
|
45
|
+
await rm(stagePath, { force: true });
|
|
46
|
+
throw new CensusTransactionError(`census activation failed: ${error.message}`, 'failed', error);
|
|
47
|
+
} finally {
|
|
48
|
+
await rm(lockPath, { force: true });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { activateCensus } from './index.mjs';
|
|
7
|
+
|
|
8
|
+
test('activation requires a callable verifier before staging or swapping', async () => {
|
|
9
|
+
for (const verify of [undefined, 'not-callable']) {
|
|
10
|
+
const root = await mkdtemp(join(tmpdir(), 'awk-census-transaction-'));
|
|
11
|
+
const activePath = join(root, 'active.json');
|
|
12
|
+
const previous = '{"generation":"previous"}\n';
|
|
13
|
+
await writeFile(activePath, previous);
|
|
14
|
+
let swapped = false;
|
|
15
|
+
try {
|
|
16
|
+
await assert.rejects(
|
|
17
|
+
activateCensus({
|
|
18
|
+
activePath,
|
|
19
|
+
candidate: { generation: 'next' },
|
|
20
|
+
renameCandidate: async () => { swapped = true; },
|
|
21
|
+
verify,
|
|
22
|
+
}),
|
|
23
|
+
/callable verifier/,
|
|
24
|
+
);
|
|
25
|
+
assert.equal(await readFile(activePath, 'utf8'), previous);
|
|
26
|
+
await assert.rejects(readFile(`${activePath}.candidate`), { code: 'ENOENT' });
|
|
27
|
+
await assert.rejects(readFile(`${activePath}.lock`), { code: 'ENOENT' });
|
|
28
|
+
assert.equal(swapped, false);
|
|
29
|
+
} finally {
|
|
30
|
+
await rm(root, { recursive: true, force: true });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('verify failure preserves active bytes and a second run can succeed', async () => {
|
|
36
|
+
const root = await mkdtemp(join(tmpdir(), 'awk-census-transaction-'));
|
|
37
|
+
const activePath = join(root, 'active.json');
|
|
38
|
+
const previous = '{"generation":"previous"}\n';
|
|
39
|
+
await writeFile(activePath, previous);
|
|
40
|
+
try {
|
|
41
|
+
await assert.rejects(
|
|
42
|
+
activateCensus({
|
|
43
|
+
activePath,
|
|
44
|
+
candidate: { generation: 'next' },
|
|
45
|
+
verify: async () => { throw new Error('fixture verify failure'); },
|
|
46
|
+
}),
|
|
47
|
+
(error) => error.state === 'failed',
|
|
48
|
+
);
|
|
49
|
+
assert.equal(await readFile(activePath, 'utf8'), previous);
|
|
50
|
+
|
|
51
|
+
const result = await activateCensus({
|
|
52
|
+
activePath,
|
|
53
|
+
candidate: { generation: 'next' },
|
|
54
|
+
verify: async ({ generation }) => generation === 'next',
|
|
55
|
+
});
|
|
56
|
+
assert.equal(result.state, 'current');
|
|
57
|
+
assert.equal(await readFile(activePath, 'utf8'), '{"generation":"next"}\n');
|
|
58
|
+
} finally {
|
|
59
|
+
await rm(root, { recursive: true, force: true });
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('swap failure preserves active bytes', async () => {
|
|
64
|
+
const root = await mkdtemp(join(tmpdir(), 'awk-census-transaction-'));
|
|
65
|
+
const activePath = join(root, 'active.json');
|
|
66
|
+
const previous = '{"generation":"previous"}\n';
|
|
67
|
+
await writeFile(activePath, previous);
|
|
68
|
+
try {
|
|
69
|
+
await assert.rejects(
|
|
70
|
+
activateCensus({
|
|
71
|
+
activePath,
|
|
72
|
+
candidate: { generation: 'next' },
|
|
73
|
+
renameCandidate: async () => { throw new Error('fixture swap failure'); },
|
|
74
|
+
verify: async () => true,
|
|
75
|
+
}),
|
|
76
|
+
(error) => error.state === 'failed',
|
|
77
|
+
);
|
|
78
|
+
assert.equal(await readFile(activePath, 'utf8'), previous);
|
|
79
|
+
} finally {
|
|
80
|
+
await rm(root, { recursive: true, force: true });
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('an existing local lock reports updating without touching active bytes', async () => {
|
|
85
|
+
const root = await mkdtemp(join(tmpdir(), 'awk-census-transaction-'));
|
|
86
|
+
const activePath = join(root, 'active.json');
|
|
87
|
+
const previous = '{"generation":"previous"}\n';
|
|
88
|
+
await writeFile(activePath, previous);
|
|
89
|
+
await writeFile(`${activePath}.lock`, 'held\n');
|
|
90
|
+
try {
|
|
91
|
+
await assert.rejects(
|
|
92
|
+
activateCensus({
|
|
93
|
+
activePath,
|
|
94
|
+
candidate: { generation: 'next' },
|
|
95
|
+
verify: async () => true,
|
|
96
|
+
}),
|
|
97
|
+
(error) => error.state === 'updating',
|
|
98
|
+
);
|
|
99
|
+
assert.equal(await readFile(activePath, 'utf8'), previous);
|
|
100
|
+
} finally {
|
|
101
|
+
await rm(root, { recursive: true, force: true });
|
|
102
|
+
}
|
|
103
|
+
});
|
package/src/lib/bundle.mjs
CHANGED
|
@@ -50,6 +50,14 @@ export const HELPER_FILES = [
|
|
|
50
50
|
// GitHub-consumer automation: invokes the existing update command, then owns
|
|
51
51
|
// only the stable tested branch/pull-request upsert.
|
|
52
52
|
{ path: 'scripts/kit-update-pr.mjs', kind: 'script', mode: 0o755 },
|
|
53
|
+
// Stdlib-only project census foundation. index.mjs is the stable consumer
|
|
54
|
+
// entrypoint; its five local modules must ship with it as one helper unit.
|
|
55
|
+
{ path: 'scripts/census/index.mjs', kind: 'script', mode: 0o644 },
|
|
56
|
+
{ path: 'scripts/census/scan.mjs', kind: 'script', mode: 0o644 },
|
|
57
|
+
{ path: 'scripts/census/fingerprint.mjs', kind: 'script', mode: 0o644 },
|
|
58
|
+
{ path: 'scripts/census/delta.mjs', kind: 'script', mode: 0o644 },
|
|
59
|
+
{ path: 'scripts/census/state.mjs', kind: 'script', mode: 0o644 },
|
|
60
|
+
{ path: 'scripts/census/transaction.mjs', kind: 'script', mode: 0o644 },
|
|
53
61
|
// Shared hook utility imported by the shipped hooks (drift-guard,
|
|
54
62
|
// sync-board-status). Library (imported, not run) → 0o644. MUST ship or those
|
|
55
63
|
// hooks ImportError on arrival.
|