@jenga-ai/agent 3.5.0 → 4.0.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 +85 -78
- package/agents/developer.md +1 -1
- package/agents/scrum-master.md +20 -2
- package/agents/tester.md +3 -3
- package/hooks/on_session_end.sh +5 -5
- package/lib/generate-agent-context.js +2 -2
- package/lib/generate-copilot-hooks.js +1 -1
- package/lib/generate-skill-allow-list.js +79 -8
- package/lib/mirror.js +1 -1
- package/lib/postinstall-manifest.js +1 -1
- package/lib/skill-allow-list.json +2 -2
- package/mcp/help/index.js +8 -17
- package/mcp/help/scan.js +73 -0
- package/package.json +5 -1
- package/project/app/api/lib/resolve-project-root.js +1 -1
- package/project/app/api/parsers/knowledge-graph.js +100 -9
- package/project/app/api/routes/health.js +36 -0
- package/project/app/api/scripts/capture-snapshot.js +9 -6
- package/project/app/package.json +4 -0
- package/project/app/ui/dist/assets/index-BADc5mmH.css +1 -0
- package/project/app/ui/dist/assets/index-C3oiuli_.js +104 -0
- package/project/app/ui/dist/index.html +2 -2
- package/project/app/ui/package.json +4 -0
- package/project/app/ui/scripts/build-snapshot-html.cjs +63 -2
- package/scripts/acquire-concurrency-slot.sh +35 -5
- package/scripts/apply-j-prefix.sh +46 -5
- package/scripts/build-pages-site.sh +1 -1
- package/scripts/check-public-playbook-steps.sh +158 -52
- package/scripts/check-publicignore-match.sh +2 -2
- package/scripts/compute-deploy-reconcile.sh +5 -5
- package/scripts/delete-bare-skill-dirs.sh +329 -0
- package/scripts/generate-legacy-shipped-paths.js +2 -2
- package/scripts/idea_manager.sh +273 -3
- package/scripts/mark-deployed.sh +2 -2
- package/scripts/populate-knowledge-graph.entity-resolution.test.js +254 -0
- package/scripts/populate-knowledge-graph.js +213 -5
- package/scripts/populate-knowledge-graph.staleness.test.js +130 -0
- package/scripts/postinstall.js +1 -1
- package/scripts/release-concurrency-slot.sh +34 -4
- package/scripts/render-ranked-list.sh +270 -0
- package/scripts/repoint-dead-bare-path-prose.py +81 -0
- package/scripts/repoint-skill-refs.sh +539 -0
- package/scripts/rewrite-stale-skill-preambles.py +188 -0
- package/scripts/strip-polyfill-frontmatter.py +166 -0
- package/scripts/todo_manager.sh +16 -1
- package/scripts/validate-typed-object.sh +750 -0
- package/scripts/verify-legacy-seed-reconcile.sh +10 -10
- package/scripts/verify-postinstall-reconcile.sh +7 -7
- package/scripts/write-context-digest.sh +1 -1
- package/skills/j-brainstorm/SKILL.md +3 -4
- package/skills/j-btw/SKILL.md +3 -4
- package/skills/j-clearify/SKILL.md +5 -6
- package/skills/j-close-story/SKILL.md +11 -12
- package/skills/j-close-story/scripts/check-privatized.sh +4 -4
- package/skills/j-close-story/scripts/check-story-closeable.sh +11 -4
- package/skills/j-commit/SKILL.md +3 -4
- package/skills/j-continue/SKILL.md +5 -6
- package/skills/j-deep-dive/SKILL.md +3 -4
- package/skills/j-distribute/CONFIG_SCHEMA.md +82 -5
- package/skills/j-distribute/SKILL.md +3 -4
- package/skills/j-do/SKILL.md +100 -18
- package/skills/j-doc/SKILL.md +3 -4
- package/skills/j-doc-sync/SKILL.md +4 -4
- package/skills/j-dooo/SKILL.md +6 -15
- package/skills/j-error/SKILL.md +3 -4
- package/skills/j-evaluate/SKILL.md +3 -4
- package/skills/j-examplify/SKILL.md +3 -4
- package/skills/j-gitignore/SKILL.md +157 -0
- package/skills/j-gitignore/assets/jenga-paths.txt +50 -0
- package/skills/j-gitignore/scripts/_catalog.sh +105 -0
- package/skills/j-gitignore/scripts/audit-gitignore.sh +194 -0
- package/skills/j-gitignore/scripts/repair-gitignore.sh +226 -0
- package/skills/j-gitignore/scripts/untrack-jenga-files.sh +210 -0
- package/skills/j-help/SKILL.md +3 -4
- package/skills/j-idea/SKILL.md +80 -9
- package/skills/j-idea/assets/idea_template.md +1 -1
- package/skills/j-improve/SKILL.md +4 -5
- package/skills/j-init/SKILL.md +23 -14
- package/skills/j-init/assets/scope-thresholds_template.json +5 -2
- package/skills/j-init/scripts/apply-scaffold-visibility.sh +9 -7
- package/skills/j-init/scripts/init.sh +4 -4
- package/skills/j-jbp/SKILL.md +3 -4
- package/skills/j-lgtm/SKILL.md +3 -4
- package/skills/j-pi-plan/SKILL.md +3 -4
- package/skills/j-playbook/SKILL.md +1 -1
- package/skills/j-proceed/SKILL.md +3 -4
- package/skills/j-publish/SKILL.md +4 -5
- package/skills/j-publish/adapters/npm-ci.md +6 -1
- package/skills/j-publish/adapters/npm.md +1 -1
- package/skills/j-publish/scripts/generate_release_notes.sh +1 -1
- package/skills/j-publish/scripts/npm_stage_inspect.sh +61 -0
- package/skills/j-publish/scripts/run_gates.sh +1 -1
- package/skills/j-reconcile/SKILL.md +40 -7
- package/skills/j-reconcile/assets/report_format.md +11 -0
- package/skills/j-reconcile/scripts/detect-unlinked-code.sh +13 -13
- package/skills/j-reconcile-origin/SKILL.md +3 -4
- package/skills/j-redo/SKILL.md +4 -5
- package/skills/j-skillify/SKILL.md +3 -4
- package/skills/j-spinoff/SKILL.md +4 -5
- package/skills/j-status/SKILL.md +18 -4
- package/skills/j-todo/SKILL.md +44 -5
- package/skills/j-todo/scripts/argument-is-not-ranked-list.sh +92 -0
- package/skills/j-todo/scripts/argument-is-ranked-list.sh +78 -0
- package/skills/j-uncharted/SKILL.md +252 -13
- package/skills/j-uncharted/scripts/detect-dependencies.sh +80 -22
- package/skills/j-uncharted/scripts/detect-tests.sh +1 -1
- package/skills/j-uncharted/scripts/diff-since-baseline.sh +600 -0
- package/skills/j-uncharted/scripts/elicitation-state.sh +1 -1
- package/skills/j-uncharted/scripts/find-scan-baseline.sh +545 -0
- package/skills/j-uncharted/scripts/run-engine.sh +36 -2
- package/skills/j-uncharted/scripts/validate-proposed-items.sh +1 -1
- package/skills/j-uncharted/scripts/write-scan-record.sh +361 -0
- package/skills/j-wtf/SKILL.md +4 -5
- package/skills/jenga/SKILL.md +106 -11
- package/skills/jenga/playbooks/board-hygiene.json +32 -0
- package/skills/jenga/playbooks/schema.json +73 -6
- package/skills/jenga/playbooks/understand-then-commit.json +19 -0
- package/skills/jenga/scripts/load-nl-catalog.js +5 -2
- package/skills/jenga/scripts/load-nl-catalog.sh +1 -1
- package/skills/jenga/scripts/load-playbooks.sh +290 -5
- package/skills/jenga/scripts/match-playbook.sh +4 -4
- package/skills/jenga/scripts/run-playbook-step.sh +267 -1
- package/templates/SCRUM_BOARD_SCHEMA.md +14 -1
- package/templates/SKILL_TEMPLATE.md +12 -0
- package/templates/permission-levels/level-1-locked.json +1 -1
- package/templates/permission-levels/level-2-guarded.json +1 -1
- package/templates/permission-levels/level-3-standard.json +1 -1
- package/templates/permission-levels/level-4-elevated.json +2 -2
- package/templates/permission-levels/level-5-unrestricted.json +2 -2
- package/templates/playbook-types.json +40 -6
- package/project/app/ui/dist/assets/index-7fj-vllY.js +0 -104
- package/project/app/ui/dist/assets/index-CdK3Qrep.css +0 -1
- package/scripts/audit-twin-divergence.sh +0 -625
- package/scripts/generate-j-alias.sh +0 -333
- package/skills/j-dev-done/SKILL.md +0 -53
- package/skills/j-dev-done/scripts/classify-commit-outcome.sh +0 -114
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* @file scripts/populate-knowledge-graph.entity-resolution.test.js
|
|
4
|
+
* Plain Node `assert`-based unit tests for E20_S10_T04's canonical-key entity-resolution
|
|
5
|
+
* merge-on-write logic in scripts/populate-knowledge-graph.js (canonicalKey, resolveNodeIdentity,
|
|
6
|
+
* mergeGraph's key-based merge branch). No test-framework dependency — same convention as
|
|
7
|
+
* project/app/api/parsers/knowledge-graph.test.js.
|
|
8
|
+
*
|
|
9
|
+
* Why a unit-level test file rather than only CLI-level bats coverage (tests/populate-knowledge-graph.bats):
|
|
10
|
+
* this populator currently only ever computes `source: 'board'` nodes from board frontmatter, whose
|
|
11
|
+
* `id` IS already the canonical key input (an Epic/Story's own stable board id) — so there is no
|
|
12
|
+
* live producer of a "different id, same key" scenario to exercise end-to-end through the CLI yet.
|
|
13
|
+
* The merge machinery is general-purpose infrastructure ahead of its real caller (a future
|
|
14
|
+
* `human`/`ast` writer) — exercising it directly against synthetic node fixtures is the correct
|
|
15
|
+
* level for that, per E20_S10_T04-plan.md.
|
|
16
|
+
*
|
|
17
|
+
* Run directly:
|
|
18
|
+
* node scripts/populate-knowledge-graph.entity-resolution.test.js
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import assert from 'node:assert';
|
|
22
|
+
import {
|
|
23
|
+
canonicalKey,
|
|
24
|
+
resolveNodeIdentity,
|
|
25
|
+
mergeGraph,
|
|
26
|
+
} from './populate-knowledge-graph.js';
|
|
27
|
+
|
|
28
|
+
async function main() {
|
|
29
|
+
|
|
30
|
+
const tests = [];
|
|
31
|
+
function test(name, fn) {
|
|
32
|
+
tests.push({ name, fn });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// -----------------------------------------------------------------------------------------
|
|
36
|
+
// canonicalKey()
|
|
37
|
+
// -----------------------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
test('canonicalKey: epic/story key on their own normalized id', () => {
|
|
40
|
+
assert.strictEqual(canonicalKey({ id: 'E90', type: 'epic' }), 'epic:e90');
|
|
41
|
+
assert.strictEqual(canonicalKey({ id: 'E90_S01', type: 'story' }), 'story:e90_s01');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('canonicalKey: epic/story ids are case- and whitespace-normalized', () => {
|
|
45
|
+
assert.strictEqual(canonicalKey({ id: ' E90 ', type: 'epic' }), canonicalKey({ id: 'e90', type: 'epic' }));
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('canonicalKey: other node types key on normalized label', () => {
|
|
49
|
+
assert.strictEqual(
|
|
50
|
+
canonicalKey({ id: 'svc-1', type: 'service', label: 'Billing Worker' }),
|
|
51
|
+
canonicalKey({ id: 'svc-2', type: 'service', label: ' billing worker ' })
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('canonicalKey: distinct labels never collide (conservative, exact-normalized match only)', () => {
|
|
56
|
+
assert.notStrictEqual(
|
|
57
|
+
canonicalKey({ id: 'svc-1', type: 'service', label: 'Billing Worker' }),
|
|
58
|
+
canonicalKey({ id: 'svc-2', type: 'service', label: 'Billing Workers' })
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('canonicalKey: a node with no usable key returns null (never over-merges)', () => {
|
|
63
|
+
assert.strictEqual(canonicalKey({ id: 'x', type: 'service' }), null); // no label
|
|
64
|
+
assert.strictEqual(canonicalKey(null), null);
|
|
65
|
+
assert.strictEqual(canonicalKey({ id: 'x' }), null); // no type
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('canonicalKey: different types with the same label never collide', () => {
|
|
69
|
+
assert.notStrictEqual(
|
|
70
|
+
canonicalKey({ id: 'a', type: 'service', label: 'Auth' }),
|
|
71
|
+
canonicalKey({ id: 'b', type: 'module', label: 'Auth' })
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// -----------------------------------------------------------------------------------------
|
|
76
|
+
// resolveNodeIdentity() — genuine merge case
|
|
77
|
+
// -----------------------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
test('resolveNodeIdentity: a computed node matching an existing key under a DIFFERENT id merges into the existing id', () => {
|
|
80
|
+
const nodeMap = new Map([
|
|
81
|
+
['svc-existing', { id: 'svc-existing', type: 'service', label: 'Billing Worker', description: 'old desc', source: 'ast' }],
|
|
82
|
+
]);
|
|
83
|
+
const keyIndex = new Map([[canonicalKey({ type: 'service', label: 'Billing Worker' }), 'svc-existing']]);
|
|
84
|
+
const mergeLog = [];
|
|
85
|
+
|
|
86
|
+
resolveNodeIdentity(
|
|
87
|
+
nodeMap,
|
|
88
|
+
'svc-new',
|
|
89
|
+
{ id: 'svc-new', type: 'service', label: 'Billing Worker', description: 'new desc', source: 'ast' },
|
|
90
|
+
keyIndex,
|
|
91
|
+
mergeLog
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
assert.strictEqual(nodeMap.size, 1, 'no second node should be created for the same entity');
|
|
95
|
+
assert.ok(!nodeMap.has('svc-new'), 'the incoming id must not become a new map entry');
|
|
96
|
+
const merged = nodeMap.get('svc-existing');
|
|
97
|
+
assert.strictEqual(merged.id, 'svc-existing', 'the existing (target) id must remain stable');
|
|
98
|
+
assert.strictEqual(merged.description, 'new desc', 'fields update from the incoming node');
|
|
99
|
+
assert.strictEqual(mergeLog.length, 1);
|
|
100
|
+
assert.strictEqual(mergeLog[0].targetId, 'svc-existing');
|
|
101
|
+
assert.strictEqual(mergeLog[0].mergedFromId, 'svc-new');
|
|
102
|
+
assert.ok(mergeLog[0].changedFields.includes('description'));
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('resolveNodeIdentity: merging never downgrades an existing human/ast source via a lower-precedence board write', () => {
|
|
106
|
+
const nodeMap = new Map([
|
|
107
|
+
['svc-existing', { id: 'svc-existing', type: 'service', label: 'Billing Worker', source: 'human' }],
|
|
108
|
+
]);
|
|
109
|
+
const keyIndex = new Map([[canonicalKey({ type: 'service', label: 'Billing Worker' }), 'svc-existing']]);
|
|
110
|
+
const mergeLog = [];
|
|
111
|
+
|
|
112
|
+
resolveNodeIdentity(
|
|
113
|
+
nodeMap,
|
|
114
|
+
'svc-new',
|
|
115
|
+
{ id: 'svc-new', type: 'service', label: 'Billing Worker', source: 'board' },
|
|
116
|
+
keyIndex,
|
|
117
|
+
mergeLog
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
assert.strictEqual(nodeMap.get('svc-existing').source, 'human', 'source must not be downgraded by a board write');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('resolveNodeIdentity: merging upgrades a board source to human/ast when the incoming write is verified', () => {
|
|
124
|
+
const nodeMap = new Map([
|
|
125
|
+
['svc-existing', { id: 'svc-existing', type: 'service', label: 'Billing Worker', source: 'board' }],
|
|
126
|
+
]);
|
|
127
|
+
const keyIndex = new Map([[canonicalKey({ type: 'service', label: 'Billing Worker' }), 'svc-existing']]);
|
|
128
|
+
const mergeLog = [];
|
|
129
|
+
|
|
130
|
+
resolveNodeIdentity(
|
|
131
|
+
nodeMap,
|
|
132
|
+
'svc-new',
|
|
133
|
+
{ id: 'svc-new', type: 'service', label: 'Billing Worker', source: 'ast' },
|
|
134
|
+
keyIndex,
|
|
135
|
+
mergeLog
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
assert.strictEqual(nodeMap.get('svc-existing').source, 'ast');
|
|
139
|
+
assert.ok(mergeLog[0].changedFields.includes('source'));
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// -----------------------------------------------------------------------------------------
|
|
143
|
+
// resolveNodeIdentity() — legitimate near-miss (must NOT merge)
|
|
144
|
+
// -----------------------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
test('resolveNodeIdentity: a near-miss (distinct canonical key) creates a separate node, no merge', () => {
|
|
147
|
+
const nodeMap = new Map([
|
|
148
|
+
['svc-existing', { id: 'svc-existing', type: 'service', label: 'Billing Worker', source: 'ast' }],
|
|
149
|
+
]);
|
|
150
|
+
// keyIndex reflects only the existing node's own key — "Billing Workers" (plural) has a
|
|
151
|
+
// distinct canonical key and is absent from the index, so no match is possible.
|
|
152
|
+
const keyIndex = new Map([[canonicalKey({ type: 'service', label: 'Billing Worker' }), 'svc-existing']]);
|
|
153
|
+
const mergeLog = [];
|
|
154
|
+
|
|
155
|
+
resolveNodeIdentity(
|
|
156
|
+
nodeMap,
|
|
157
|
+
'svc-other',
|
|
158
|
+
{ id: 'svc-other', type: 'service', label: 'Billing Workers', source: 'ast' },
|
|
159
|
+
keyIndex,
|
|
160
|
+
mergeLog
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
assert.strictEqual(nodeMap.size, 2, 'a legitimately distinct entity must get its own node');
|
|
164
|
+
assert.ok(nodeMap.has('svc-other'));
|
|
165
|
+
assert.strictEqual(mergeLog.length, 0, 'no merge should be logged for a near-miss');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('resolveNodeIdentity: a computed node whose key matches an existing node under the SAME id is a no-op merge (not logged as a key-based merge)', () => {
|
|
169
|
+
const nodeMap = new Map([
|
|
170
|
+
['E90', { id: 'E90', type: 'epic', label: 'Test Epic', description: 'old', source: 'board' }],
|
|
171
|
+
]);
|
|
172
|
+
const keyIndex = new Map([[canonicalKey({ id: 'E90', type: 'epic' }), 'E90']]);
|
|
173
|
+
const mergeLog = [];
|
|
174
|
+
|
|
175
|
+
resolveNodeIdentity(
|
|
176
|
+
nodeMap,
|
|
177
|
+
'E90',
|
|
178
|
+
{ id: 'E90', type: 'epic', label: 'Test Epic', description: 'new', source: 'board' },
|
|
179
|
+
keyIndex,
|
|
180
|
+
mergeLog
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
assert.strictEqual(nodeMap.size, 1);
|
|
184
|
+
assert.strictEqual(nodeMap.get('E90').description, 'new', 'plain id-keyed update still applies');
|
|
185
|
+
assert.strictEqual(mergeLog.length, 0, 'matching under the same id is the pre-existing idempotent case, not a key-based merge');
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// -----------------------------------------------------------------------------------------
|
|
189
|
+
// mergeGraph() — end-to-end through the public merge entrypoint
|
|
190
|
+
// -----------------------------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
test('mergeGraph: existing idempotent-on-id behavior (E20_S09) is not regressed', () => {
|
|
193
|
+
const existing = {
|
|
194
|
+
nodes: [{ id: 'E90', type: 'epic', label: 'Test Epic', description: 'd', source: 'board' }],
|
|
195
|
+
edges: [],
|
|
196
|
+
};
|
|
197
|
+
const computed = {
|
|
198
|
+
nodes: new Map([['E90', { id: 'E90', type: 'epic', label: 'Test Epic', description: 'd', source: 'board' }]]),
|
|
199
|
+
edges: new Map(),
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const first = mergeGraph(existing, computed);
|
|
203
|
+
const second = mergeGraph({ nodes: first.nodes, edges: first.edges }, computed);
|
|
204
|
+
|
|
205
|
+
assert.deepStrictEqual(first.nodes, second.nodes);
|
|
206
|
+
assert.strictEqual(second.nodes.length, 1);
|
|
207
|
+
assert.strictEqual(second.mergeLog.length, 0);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test('mergeGraph: a genuine key-based merge reduces two entries to one and logs the merge', () => {
|
|
211
|
+
const existing = {
|
|
212
|
+
nodes: [{ id: 'svc-existing', type: 'service', label: 'Billing Worker', description: 'old', source: 'ast' }],
|
|
213
|
+
edges: [],
|
|
214
|
+
};
|
|
215
|
+
const computed = {
|
|
216
|
+
nodes: new Map([
|
|
217
|
+
['svc-new', { id: 'svc-new', type: 'service', label: 'Billing Worker', description: 'updated', source: 'ast' }],
|
|
218
|
+
]),
|
|
219
|
+
edges: new Map(),
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const result = mergeGraph(existing, computed);
|
|
223
|
+
|
|
224
|
+
assert.strictEqual(result.nodes.length, 1);
|
|
225
|
+
assert.strictEqual(result.nodes[0].id, 'svc-existing');
|
|
226
|
+
assert.strictEqual(result.nodes[0].description, 'updated');
|
|
227
|
+
assert.strictEqual(result.mergeLog.length, 1);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test('mergeGraph: return value is JSON-serializable as exactly {nodes, edges} for graph.json (mergeLog is reporting-only)', () => {
|
|
231
|
+
const existing = { nodes: [], edges: [] };
|
|
232
|
+
const computed = { nodes: new Map(), edges: new Map() };
|
|
233
|
+
const result = mergeGraph(existing, computed);
|
|
234
|
+
const { nodes, edges } = result;
|
|
235
|
+
assert.deepStrictEqual(JSON.parse(JSON.stringify({ nodes, edges })), { nodes: [], edges: [] });
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
let failures = 0;
|
|
239
|
+
for (const { name, fn } of tests) {
|
|
240
|
+
try {
|
|
241
|
+
await fn();
|
|
242
|
+
console.log(`PASS - ${name}`);
|
|
243
|
+
} catch (err) {
|
|
244
|
+
failures += 1;
|
|
245
|
+
console.error(`FAIL - ${name}`);
|
|
246
|
+
console.error(err && err.stack ? err.stack : err);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
console.log(`\n${tests.length - failures}/${tests.length} passed`);
|
|
251
|
+
if (failures > 0) process.exitCode = 1;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
main();
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
import fs from 'node:fs';
|
|
43
43
|
import path from 'node:path';
|
|
44
44
|
import { fileURLToPath } from 'node:url';
|
|
45
|
+
import { createHash } from 'node:crypto';
|
|
45
46
|
|
|
46
47
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
47
48
|
const REPO_ROOT = path.join(__dirname, '..');
|
|
@@ -49,7 +50,21 @@ const REPO_ROOT = path.join(__dirname, '..');
|
|
|
49
50
|
const EPIC_ID_RE = /^E\d+$/;
|
|
50
51
|
const STORY_ID_RE = /^E\d+_S\d+$/;
|
|
51
52
|
|
|
52
|
-
|
|
53
|
+
// E20_S10_T05: extracted_at/content_hash/needs_revalidation are appended after the pre-existing
|
|
54
|
+
// fields — never inserted in the middle — so a graph.json written before E20_S10_T05 diffs
|
|
55
|
+
// cleanly against one written after it for every node that doesn't carry the new fields.
|
|
56
|
+
const NODE_KEY_ORDER = [
|
|
57
|
+
'id',
|
|
58
|
+
'type',
|
|
59
|
+
'label',
|
|
60
|
+
'description',
|
|
61
|
+
'source',
|
|
62
|
+
'status',
|
|
63
|
+
'superseded_by',
|
|
64
|
+
'extracted_at',
|
|
65
|
+
'content_hash',
|
|
66
|
+
'needs_revalidation',
|
|
67
|
+
];
|
|
53
68
|
const EDGE_KEY_ORDER = ['id', 'from', 'to', 'type', 'description'];
|
|
54
69
|
|
|
55
70
|
// ── CLI args ────────────────────────────────────────────────────────────────
|
|
@@ -326,6 +341,168 @@ function isBoardOwnedEdgeId(id) {
|
|
|
326
341
|
return BOARD_EDGE_ID_PREFIXES.some((prefix) => id.startsWith(prefix));
|
|
327
342
|
}
|
|
328
343
|
|
|
344
|
+
// ── Entity resolution (E20_S10_T04) ────────────────────────────────────────
|
|
345
|
+
//
|
|
346
|
+
// This governs *whether two nodes are the same node at all* — distinct from, and does not
|
|
347
|
+
// replace, STUB_SCHEMA.md's Evidence-Wins Conflict Rule (which governs *which description wins*
|
|
348
|
+
// once two nodes are already known to be the same entity). A node's literal `id` is already this
|
|
349
|
+
// populator's own stable identity for its own `board`-sourced writes (an Epic/Story's own board
|
|
350
|
+
// id never changes shape between runs), so key-based merging is infrastructure ahead of its real
|
|
351
|
+
// caller: a future `human`/`ast` writer describing the same real-world entity under a different
|
|
352
|
+
// literal `id` than an existing node. Deliberately conservative (under-merging on purpose, per the
|
|
353
|
+
// task's own instruction to start narrow and expand only on observed false negatives) — a node
|
|
354
|
+
// with no usable key returns `null` and is matched by `id` only, never over-merged.
|
|
355
|
+
|
|
356
|
+
const SOURCE_PRECEDENCE = { human: 2, ast: 2, board: 1 };
|
|
357
|
+
|
|
358
|
+
function sourcePrecedence(source) {
|
|
359
|
+
return SOURCE_PRECEDENCE[source] ?? 0;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function normalizeKeyPart(value) {
|
|
363
|
+
return String(value ?? '').trim().toLowerCase().replace(/\s+/g, ' ');
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Compute a node's canonical identity key, or `null` if this node type has no reliable key yet.
|
|
368
|
+
* `epic`/`story` key on their own (already-stable) board id, matching this populator's existing
|
|
369
|
+
* identity scheme. Any other type (e.g. a future `service`/`module`/`function` from an `ast`/
|
|
370
|
+
* `human` writer) keys on its normalized `label` — exact-normalized, not fuzzy, per the
|
|
371
|
+
* conservative-by-design instruction above.
|
|
372
|
+
* @param {{id: string, type: string, label?: string}} node
|
|
373
|
+
* @returns {string|null}
|
|
374
|
+
*/
|
|
375
|
+
function canonicalKey(node) {
|
|
376
|
+
if (!node || !node.type) return null;
|
|
377
|
+
if (node.type === 'epic' || node.type === 'story') {
|
|
378
|
+
return node.id ? `${node.type}:${normalizeKeyPart(node.id)}` : null;
|
|
379
|
+
}
|
|
380
|
+
return node.label ? `${node.type}:${normalizeKeyPart(node.label)}` : null;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Resolve a computed node against an existing node map using canonical-key identity resolution,
|
|
385
|
+
* merging in place (and logging the merge) when a genuine match is found under a *different*
|
|
386
|
+
* literal id. Falls back to plain `id`-keyed merge/create — completely unchanged from the
|
|
387
|
+
* pre-E20_S10_T04 behavior — whenever no key-based match applies.
|
|
388
|
+
* @param {Map<string, object>} nodeMap mutated in place
|
|
389
|
+
* @param {string} computedId
|
|
390
|
+
* @param {object} computedNode
|
|
391
|
+
* @param {Map<string, string>} keyIndex canonical key -> existing node id, built before this run's
|
|
392
|
+
* computed nodes are applied (so a computed node is only ever matched against pre-existing data,
|
|
393
|
+
* never against another computed node from the same run)
|
|
394
|
+
* @param {Array<object>} mergeLog appended to in place with a record of every key-based merge
|
|
395
|
+
*/
|
|
396
|
+
function resolveNodeIdentity(nodeMap, computedId, computedNode, keyIndex, mergeLog) {
|
|
397
|
+
const key = canonicalKey(computedNode);
|
|
398
|
+
const matchedExistingId = key ? keyIndex.get(key) : undefined;
|
|
399
|
+
|
|
400
|
+
if (matchedExistingId && matchedExistingId !== computedId) {
|
|
401
|
+
// Genuine key-based merge: same canonical identity, different literal id. Merge into the
|
|
402
|
+
// EXISTING node's id (stable target) rather than creating a second node for the same entity.
|
|
403
|
+
const existingNode = nodeMap.get(matchedExistingId) ?? {};
|
|
404
|
+
const resolvedSource =
|
|
405
|
+
sourcePrecedence(computedNode.source) >= sourcePrecedence(existingNode.source)
|
|
406
|
+
? computedNode.source
|
|
407
|
+
: existingNode.source; // never downgrade an existing node's provenance via a lower-precedence write
|
|
408
|
+
|
|
409
|
+
const changedFields = [];
|
|
410
|
+
for (const field of ['label', 'description', 'type']) {
|
|
411
|
+
if (computedNode[field] !== undefined && computedNode[field] !== existingNode[field]) {
|
|
412
|
+
changedFields.push(field);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
if (existingNode.source !== resolvedSource) changedFields.push('source');
|
|
416
|
+
|
|
417
|
+
const merged = {
|
|
418
|
+
...existingNode,
|
|
419
|
+
...computedNode,
|
|
420
|
+
id: existingNode.id, // keep the existing (target) id stable — never renamed by a merge
|
|
421
|
+
source: resolvedSource,
|
|
422
|
+
};
|
|
423
|
+
nodeMap.set(matchedExistingId, canonicalizeKeys(ensureProvenanceMetadata(merged), NODE_KEY_ORDER));
|
|
424
|
+
|
|
425
|
+
mergeLog.push({
|
|
426
|
+
key,
|
|
427
|
+
targetId: matchedExistingId,
|
|
428
|
+
mergedFromId: computedId,
|
|
429
|
+
changedFields,
|
|
430
|
+
existingSource: existingNode.source,
|
|
431
|
+
incomingSource: computedNode.source,
|
|
432
|
+
});
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// No key-based match (or the match IS this same id, i.e. the pre-existing idempotent-on-id
|
|
437
|
+
// case) — fall back to plain id-keyed merge/create, unchanged from pre-E20_S10_T04 behavior.
|
|
438
|
+
const merged = { ...(nodeMap.get(computedId) ?? {}), ...computedNode };
|
|
439
|
+
nodeMap.set(computedId, canonicalizeKeys(ensureProvenanceMetadata(merged), NODE_KEY_ORDER));
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// ── Staleness / revalidation (E20_S10_T05) ─────────────────────────────────
|
|
443
|
+
//
|
|
444
|
+
// Gating on `source` alone (E20_S10_T01) relocates, rather than solves, the original "board goes
|
|
445
|
+
// stale" defect: a non-`board` node can itself drift from the source it was extracted from with no
|
|
446
|
+
// signal to the user. `hashSource`/`checkStaleness` are the general-purpose, directly-testable
|
|
447
|
+
// utilities this closes with — deliberately NOT new scheduling infrastructure, per the task's own
|
|
448
|
+
// instruction to hook into an existing lifecycle trigger point instead. This populator has no live
|
|
449
|
+
// `ast`/`human` writer yet (same caveat as E20_S10_T04's entity resolution), so it applies these
|
|
450
|
+
// utilities to its own passthrough of any non-`board` node it merges, using that node's own
|
|
451
|
+
// descriptive text as a content proxy — a future dedicated writer with real source-file text
|
|
452
|
+
// should call `hashSource()` directly with its own normalized content instead of relying on this
|
|
453
|
+
// proxy.
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Hash arbitrary content (already normalized by the caller, if normalization is available) into a
|
|
457
|
+
* short, stable fingerprint. Prefer passing normalized/parsed structure rather than raw bytes when
|
|
458
|
+
* the caller has one available — e.g. a future AST writer hashing a normalized AST dump rather than
|
|
459
|
+
* raw file text — so purely cosmetic/formatting-only source changes don't flip staleness.
|
|
460
|
+
* @param {string} content
|
|
461
|
+
* @returns {string} hex-encoded sha256 digest
|
|
462
|
+
*/
|
|
463
|
+
function hashSource(content) {
|
|
464
|
+
return createHash('sha256').update(String(content ?? '')).digest('hex');
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Best-effort "source content" proxy for a node with no separately-tracked raw source file: its
|
|
469
|
+
* own label + description. Used only by this populator's own write path below, which has nothing
|
|
470
|
+
* else to hash against; a dedicated ast/human writer with real source-file text should hash that
|
|
471
|
+
* directly via hashSource() instead of going through this proxy.
|
|
472
|
+
*/
|
|
473
|
+
function contentFingerprint(node) {
|
|
474
|
+
return `${node.label ?? ''}${node.description ?? ''}`;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Compare a node's stored `content_hash` against a freshly computed hash of its current source
|
|
479
|
+
* content. A node with no stored `content_hash` yet has nothing to compare against — reported as
|
|
480
|
+
* not stale (never a false "needs re-verification" for a node that was never hash-stamped at all).
|
|
481
|
+
* @param {{content_hash?: string}} node
|
|
482
|
+
* @param {string} currentContent
|
|
483
|
+
* @returns {{stale: boolean}}
|
|
484
|
+
*/
|
|
485
|
+
function checkStaleness(node, currentContent) {
|
|
486
|
+
if (!node || !node.content_hash) return { stale: false };
|
|
487
|
+
return { stale: hashSource(currentContent) !== node.content_hash };
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Stamp `extracted_at`/`content_hash` on any non-`board` node being written, refreshing
|
|
492
|
+
* `extracted_at` only when the computed content hash actually changes (so re-running against
|
|
493
|
+
* unchanged content never bumps the timestamp). `board` nodes are left untouched — this metadata
|
|
494
|
+
* is scoped to non-`board` provenance per the task's own Acceptance Criteria.
|
|
495
|
+
* @param {object} node
|
|
496
|
+
* @param {string} [now] ISO 8601 UTC timestamp; overridable for tests
|
|
497
|
+
* @returns {object} a new node object (or the same reference, if source is 'board')
|
|
498
|
+
*/
|
|
499
|
+
function ensureProvenanceMetadata(node, now = new Date().toISOString()) {
|
|
500
|
+
if (!node || node.source === 'board') return node;
|
|
501
|
+
const hash = hashSource(contentFingerprint(node));
|
|
502
|
+
if (node.content_hash === hash) return node; // unchanged — keep the existing extracted_at as-is
|
|
503
|
+
return { ...node, content_hash: hash, extracted_at: now };
|
|
504
|
+
}
|
|
505
|
+
|
|
329
506
|
function mergeGraph(existing, computed) {
|
|
330
507
|
const nodeMap = new Map(existing.nodes.map((node) => [node.id, node]));
|
|
331
508
|
// Prune stale populator-owned nodes no longer produced this run (e.g. an Epic or Story board
|
|
@@ -339,9 +516,18 @@ function mergeGraph(existing, computed) {
|
|
|
339
516
|
nodeMap.delete(id);
|
|
340
517
|
}
|
|
341
518
|
}
|
|
519
|
+
|
|
520
|
+
// Built BEFORE this run's computed nodes are applied, so key-based resolution only ever matches
|
|
521
|
+
// against pre-existing graph state — never against a sibling computed node from the same pass.
|
|
522
|
+
const keyIndex = new Map();
|
|
523
|
+
for (const node of nodeMap.values()) {
|
|
524
|
+
const key = canonicalKey(node);
|
|
525
|
+
if (key && !keyIndex.has(key)) keyIndex.set(key, node.id);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const mergeLog = [];
|
|
342
529
|
for (const [id, computedNode] of computed.nodes) {
|
|
343
|
-
|
|
344
|
-
nodeMap.set(id, canonicalizeKeys(merged, NODE_KEY_ORDER));
|
|
530
|
+
resolveNodeIdentity(nodeMap, id, computedNode, keyIndex, mergeLog);
|
|
345
531
|
}
|
|
346
532
|
|
|
347
533
|
const edgeMap = new Map(existing.edges.map((edge) => [edge.id, edge]));
|
|
@@ -360,7 +546,7 @@ function mergeGraph(existing, computed) {
|
|
|
360
546
|
|
|
361
547
|
const nodes = [...nodeMap.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
362
548
|
const edges = [...edgeMap.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
363
|
-
return { nodes, edges };
|
|
549
|
+
return { nodes, edges, mergeLog };
|
|
364
550
|
}
|
|
365
551
|
|
|
366
552
|
// ── Main ─────────────────────────────────────────────────────────────────
|
|
@@ -375,9 +561,26 @@ function run(argv) {
|
|
|
375
561
|
const computed = deriveNodesAndEdges(epics, stories);
|
|
376
562
|
|
|
377
563
|
const existing = loadExistingGraph(graphPath);
|
|
378
|
-
const
|
|
564
|
+
const { nodes, edges, mergeLog } = mergeGraph(existing, computed);
|
|
565
|
+
// graph.json's on-disk shape is strictly {nodes, edges} per STUB_SCHEMA.md — mergeLog is
|
|
566
|
+
// reporting-only and must never be serialized into it.
|
|
567
|
+
const merged = { nodes, edges };
|
|
379
568
|
const nextContent = `${JSON.stringify(merged, null, 2)}\n`;
|
|
380
569
|
|
|
570
|
+
// E20_S10_T04: every key-based merge is logged (what changed, which sources were involved) so a
|
|
571
|
+
// silent merge never hides a legitimate content difference between sources — printed regardless
|
|
572
|
+
// of --dry-run, since a merge decision is worth surfacing even on a preview run.
|
|
573
|
+
for (const entry of mergeLog) {
|
|
574
|
+
process.stdout.write(
|
|
575
|
+
`[merge] ${entry.mergedFromId} -> ${entry.targetId} (key: ${entry.key}) — ` +
|
|
576
|
+
`source ${entry.existingSource ?? 'none'} + ${entry.incomingSource ?? 'none'} -> ${
|
|
577
|
+
entry.changedFields.includes('source')
|
|
578
|
+
? 'upgraded'
|
|
579
|
+
: entry.existingSource ?? entry.incomingSource
|
|
580
|
+
}; changed fields: ${entry.changedFields.length ? entry.changedFields.join(', ') : 'none'}\n`,
|
|
581
|
+
);
|
|
582
|
+
}
|
|
583
|
+
|
|
381
584
|
let currentContent = null;
|
|
382
585
|
try {
|
|
383
586
|
currentContent = fs.readFileSync(graphPath, 'utf8');
|
|
@@ -425,5 +628,10 @@ export {
|
|
|
425
628
|
deriveNodesAndEdges,
|
|
426
629
|
loadExistingGraph,
|
|
427
630
|
mergeGraph,
|
|
631
|
+
canonicalKey,
|
|
632
|
+
resolveNodeIdentity,
|
|
633
|
+
hashSource,
|
|
634
|
+
checkStaleness,
|
|
635
|
+
ensureProvenanceMetadata,
|
|
428
636
|
run,
|
|
429
637
|
};
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* @file scripts/populate-knowledge-graph.staleness.test.js
|
|
4
|
+
* Plain Node `assert`-based unit tests for E20_S10_T05's staleness/revalidation logic in
|
|
5
|
+
* scripts/populate-knowledge-graph.js (hashSource, checkStaleness, ensureProvenanceMetadata). Same
|
|
6
|
+
* no-framework convention as knowledge-graph.test.js and
|
|
7
|
+
* populate-knowledge-graph.entity-resolution.test.js.
|
|
8
|
+
*
|
|
9
|
+
* Run directly:
|
|
10
|
+
* node scripts/populate-knowledge-graph.staleness.test.js
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import assert from 'node:assert';
|
|
14
|
+
import {
|
|
15
|
+
hashSource,
|
|
16
|
+
checkStaleness,
|
|
17
|
+
ensureProvenanceMetadata,
|
|
18
|
+
} from './populate-knowledge-graph.js';
|
|
19
|
+
|
|
20
|
+
async function main() {
|
|
21
|
+
const tests = [];
|
|
22
|
+
function test(name, fn) {
|
|
23
|
+
tests.push({ name, fn });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// -----------------------------------------------------------------------------------------
|
|
27
|
+
// hashSource()
|
|
28
|
+
// -----------------------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
test('hashSource: identical content produces identical hashes', () => {
|
|
31
|
+
assert.strictEqual(hashSource('hello world'), hashSource('hello world'));
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('hashSource: different content produces different hashes', () => {
|
|
35
|
+
assert.notStrictEqual(hashSource('hello world'), hashSource('hello mars'));
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('hashSource: is defined for empty/undefined content (never throws)', () => {
|
|
39
|
+
assert.doesNotThrow(() => hashSource(''));
|
|
40
|
+
assert.doesNotThrow(() => hashSource(undefined));
|
|
41
|
+
assert.strictEqual(hashSource(''), hashSource(undefined));
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// -----------------------------------------------------------------------------------------
|
|
45
|
+
// checkStaleness()
|
|
46
|
+
// -----------------------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
test('checkStaleness: reports "unchanged, no badge" (stale: false) when the source hash still matches', () => {
|
|
49
|
+
const content = 'function billingWorker() { reconcileLedger(); }';
|
|
50
|
+
const node = { content_hash: hashSource(content) };
|
|
51
|
+
assert.deepStrictEqual(checkStaleness(node, content), { stale: false });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('checkStaleness: reports "needs re-verification" (stale: true) when the source has changed', () => {
|
|
55
|
+
const originalContent = 'function billingWorker() { reconcileLedger(); }';
|
|
56
|
+
const changedContent = 'function billingWorker() { retryCharge(); }'; // materially different
|
|
57
|
+
const node = { content_hash: hashSource(originalContent) };
|
|
58
|
+
assert.deepStrictEqual(checkStaleness(node, changedContent), { stale: true });
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('checkStaleness: a node with no stored content_hash is never reported stale (nothing to compare against)', () => {
|
|
62
|
+
assert.deepStrictEqual(checkStaleness({}, 'anything'), { stale: false });
|
|
63
|
+
assert.deepStrictEqual(checkStaleness(null, 'anything'), { stale: false });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// -----------------------------------------------------------------------------------------
|
|
67
|
+
// ensureProvenanceMetadata()
|
|
68
|
+
// -----------------------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
test('ensureProvenanceMetadata: a board node is left untouched (no extracted_at/content_hash)', () => {
|
|
71
|
+
const node = { id: 'E90', type: 'epic', label: 'Epic', description: 'd', source: 'board' };
|
|
72
|
+
const result = ensureProvenanceMetadata(node, '2026-01-01T00:00:00Z');
|
|
73
|
+
assert.strictEqual(result, node);
|
|
74
|
+
assert.strictEqual(result.content_hash, undefined);
|
|
75
|
+
assert.strictEqual(result.extracted_at, undefined);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('ensureProvenanceMetadata: a new non-board node is stamped with content_hash + extracted_at', () => {
|
|
79
|
+
const node = { id: 'svc-1', type: 'service', label: 'Billing Worker', description: 'd', source: 'ast' };
|
|
80
|
+
const result = ensureProvenanceMetadata(node, '2026-01-01T00:00:00Z');
|
|
81
|
+
assert.ok(result.content_hash);
|
|
82
|
+
assert.strictEqual(result.extracted_at, '2026-01-01T00:00:00Z');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('ensureProvenanceMetadata: re-stamping unchanged content does NOT bump extracted_at (no noisy re-timestamping)', () => {
|
|
86
|
+
const node = { id: 'svc-1', type: 'service', label: 'Billing Worker', description: 'd', source: 'ast' };
|
|
87
|
+
const first = ensureProvenanceMetadata(node, '2026-01-01T00:00:00Z');
|
|
88
|
+
const second = ensureProvenanceMetadata(first, '2026-06-01T00:00:00Z'); // later "now", same content
|
|
89
|
+
assert.strictEqual(second.extracted_at, '2026-01-01T00:00:00Z', 'extracted_at must not change when content is unchanged');
|
|
90
|
+
assert.strictEqual(second.content_hash, first.content_hash);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('ensureProvenanceMetadata: content that actually changes DOES bump both content_hash and extracted_at', () => {
|
|
94
|
+
const node = { id: 'svc-1', type: 'service', label: 'Billing Worker', description: 'd', source: 'ast' };
|
|
95
|
+
const first = ensureProvenanceMetadata(node, '2026-01-01T00:00:00Z');
|
|
96
|
+
const changed = { ...first, description: 'materially different description' };
|
|
97
|
+
const second = ensureProvenanceMetadata(changed, '2026-06-01T00:00:00Z');
|
|
98
|
+
assert.notStrictEqual(second.content_hash, first.content_hash);
|
|
99
|
+
assert.strictEqual(second.extracted_at, '2026-06-01T00:00:00Z');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('a purely cosmetic/formatting-only change to normalized input does not flip staleness when the caller passes normalized content', () => {
|
|
103
|
+
// Simulates a future caller that normalizes whitespace before hashing (e.g. a normalized AST
|
|
104
|
+
// dump rather than raw file bytes) — per the task's "prefer hashing normalized/parsed
|
|
105
|
+
// structure over raw bytes where feasible" guidance.
|
|
106
|
+
const normalize = (raw) => raw.trim().replace(/\s+/g, ' ');
|
|
107
|
+
const original = 'function billingWorker() {\n reconcileLedger();\n}\n';
|
|
108
|
+
const cosmeticallyReformatted = ' function billingWorker() { reconcileLedger(); } ';
|
|
109
|
+
|
|
110
|
+
const node = { content_hash: hashSource(normalize(original)) };
|
|
111
|
+
assert.deepStrictEqual(checkStaleness(node, normalize(cosmeticallyReformatted)), { stale: false });
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
let failures = 0;
|
|
115
|
+
for (const { name, fn } of tests) {
|
|
116
|
+
try {
|
|
117
|
+
await fn();
|
|
118
|
+
console.log(`PASS - ${name}`);
|
|
119
|
+
} catch (err) {
|
|
120
|
+
failures += 1;
|
|
121
|
+
console.error(`FAIL - ${name}`);
|
|
122
|
+
console.error(err && err.stack ? err.stack : err);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
console.log(`\n${tests.length - failures}/${tests.length} passed`);
|
|
127
|
+
if (failures > 0) process.exitCode = 1;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
main();
|
package/scripts/postinstall.js
CHANGED
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
* "package_version": "3.0.1",
|
|
50
50
|
* "generated_at": "<ISO 8601>",
|
|
51
51
|
* "dest_root": ".agents",
|
|
52
|
-
* "paths": ["agents/developer.md", "skills/do/SKILL.md"]
|
|
52
|
+
* "paths": ["agents/developer.md", "skills/j-do/SKILL.md"]
|
|
53
53
|
* }
|
|
54
54
|
* `paths` are relative to the destination root, POSIX-separated,
|
|
55
55
|
* deduped and sorted, and record REGULAR FILES ONLY — never
|