depa-codument 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +262 -0
  3. package/package.json +63 -0
  4. package/src/cli/commands/archive.ts +519 -0
  5. package/src/cli/commands/decisions.ts +123 -0
  6. package/src/cli/commands/engineering.ts +105 -0
  7. package/src/cli/commands/init.ts +54 -0
  8. package/src/cli/commands/list.ts +73 -0
  9. package/src/cli/commands/modeling.ts +105 -0
  10. package/src/cli/commands/show.ts +238 -0
  11. package/src/cli/commands/status.ts +140 -0
  12. package/src/cli/commands/upgrade-track.ts +385 -0
  13. package/src/cli/commands/upgrade-workspace.ts +138 -0
  14. package/src/cli/commands/validate.ts +330 -0
  15. package/src/cli/engineering/config.ts +68 -0
  16. package/src/cli/engineering/lint.ts +58 -0
  17. package/src/cli/engineering/merge.ts +172 -0
  18. package/src/cli/engineering/registry.ts +230 -0
  19. package/src/cli/engineering/schema.ts +126 -0
  20. package/src/cli/engineering/validate.ts +286 -0
  21. package/src/cli/index.ts +136 -0
  22. package/src/cli/modeling/config.ts +68 -0
  23. package/src/cli/modeling/lint.ts +58 -0
  24. package/src/cli/modeling/merge.ts +172 -0
  25. package/src/cli/modeling/registry.ts +229 -0
  26. package/src/cli/modeling/schema.ts +160 -0
  27. package/src/cli/modeling/validate.ts +282 -0
  28. package/src/cli/utils/index.ts +941 -0
  29. package/src/cli/utils/install.ts +291 -0
  30. package/src/cli/utils/spec-xml.ts +673 -0
  31. package/src/cli/utils/track-time.ts +75 -0
  32. package/src/cli/utils/vfs.ts +102 -0
  33. package/src/templates/codument/README.md +59 -0
  34. package/src/templates/codument/attractors/product.md +17 -0
  35. package/src/templates/codument/attractors/project.md +10 -0
  36. package/src/templates/codument/backlog/README.md +33 -0
  37. package/src/templates/codument/config/attractor-profiles.xml +31 -0
  38. package/src/templates/codument/config/engineering.xml +22 -0
  39. package/src/templates/codument/config/modeling.xml +22 -0
  40. package/src/templates/codument/config/operation-hooks.xml +55 -0
  41. package/src/templates/codument/memory/README.md +13 -0
  42. package/src/templates/codument/missions/README.md +125 -0
  43. package/src/templates/codument/sop/README.md +14 -0
  44. package/src/templates/codument/std/AGENTS.md +82 -0
  45. package/src/templates/codument/std/attractors/depa-attractor.md +572 -0
  46. package/src/templates/codument/std/attractors/knowledge-tiers.md +128 -0
  47. package/src/templates/codument/std/attractors/model-driven-docs.md +293 -0
  48. package/src/templates/codument/std/attractors/project-memory.md +48 -0
  49. package/src/templates/codument/std/docs-impl-fractal/index.md +110 -0
  50. package/src/templates/codument/std/docs-modeling-fractal/index.md +156 -0
  51. package/src/templates/codument/std/kernel-pointer.md +19 -0
  52. package/src/templates/codument/std/operations/README.md +30 -0
  53. package/src/templates/codument/std/operations/_operation-spec.md +41 -0
  54. package/src/templates/codument/std/operations/archive-mission.md +66 -0
  55. package/src/templates/codument/std/operations/archive-track.md +238 -0
  56. package/src/templates/codument/std/operations/artifact-sync.md +172 -0
  57. package/src/templates/codument/std/operations/discuss-phase.md +214 -0
  58. package/src/templates/codument/std/operations/discuss.md +87 -0
  59. package/src/templates/codument/std/operations/docs-bootstrap.md +148 -0
  60. package/src/templates/codument/std/operations/gap-loop.md +301 -0
  61. package/src/templates/codument/std/operations/impl-mission.md +167 -0
  62. package/src/templates/codument/std/operations/impl-quick.md +79 -0
  63. package/src/templates/codument/std/operations/impl-track.md +537 -0
  64. package/src/templates/codument/std/operations/migrate.md +337 -0
  65. package/src/templates/codument/std/operations/plan-mission.md +230 -0
  66. package/src/templates/codument/std/operations/plan-track-wave.md +231 -0
  67. package/src/templates/codument/std/operations/plan-track.md +579 -0
  68. package/src/templates/codument/std/operations/revise-track.md +136 -0
  69. package/src/templates/codument/std/operations/validate.md +339 -0
  70. package/src/templates/codument/std/operations/verify.md +184 -0
  71. package/src/templates/codument/std/root-agents.md +39 -0
  72. package/src/templates/codument/std/sop/questioning.md +98 -0
  73. package/src/templates/codument/std/sop/tdd.md +26 -0
  74. package/src/templates/codument/std/sop/validation.md +25 -0
  75. package/src/templates/codument/std/sop/wave-exec.md +42 -0
  76. package/src/templates/codument/std/sop/workflow.md +35 -0
  77. package/src/templates/codument/std/spec/behavior-delta.md +36 -0
  78. package/src/templates/codument/std/spec/behavior-registry.md +42 -0
  79. package/src/templates/codument/std/spec/engineering-delta.md +68 -0
  80. package/src/templates/codument/std/spec/engineering-node-schema.md +86 -0
  81. package/src/templates/codument/std/spec/engineering-registry.md +82 -0
  82. package/src/templates/codument/std/spec/flow-notation.md +93 -0
  83. package/src/templates/codument/std/spec/folder-manifest.md +99 -0
  84. package/src/templates/codument/std/spec/mission-xml-spec.md +249 -0
  85. package/src/templates/codument/std/spec/modeling-delta.md +85 -0
  86. package/src/templates/codument/std/spec/modeling-node-schema.md +183 -0
  87. package/src/templates/codument/std/spec/modeling-registry.md +49 -0
  88. package/src/templates/codument/std/spec/track-xml-spec.md +272 -0
  89. package/src/templates/codument/std/spec/xnl-format.md +301 -0
  90. package/src/templates/codument/workflows/README.md +15 -0
  91. package/src/templates/manifest.ts +177 -0
  92. package/src/templates/skills/README.md +38 -0
  93. package/src/templates/skills/codument-archive/SKILL.md +17 -0
  94. package/src/templates/skills/codument-archive-mission/SKILL.md +17 -0
  95. package/src/templates/skills/codument-archive-track/SKILL.md +17 -0
  96. package/src/templates/skills/codument-artifact-sync/SKILL.md +17 -0
  97. package/src/templates/skills/codument-code-quality-score/SKILL.md +67 -0
  98. package/src/templates/skills/codument-decision-tree/SKILL.md +40 -0
  99. package/src/templates/skills/codument-discuss/SKILL.md +17 -0
  100. package/src/templates/skills/codument-discuss-phase/SKILL.md +17 -0
  101. package/src/templates/skills/codument-docs-bootstrap/SKILL.md +17 -0
  102. package/src/templates/skills/codument-gap-loop/SKILL.md +17 -0
  103. package/src/templates/skills/codument-impl-mission/SKILL.md +17 -0
  104. package/src/templates/skills/codument-impl-quick/SKILL.md +17 -0
  105. package/src/templates/skills/codument-impl-track/SKILL.md +17 -0
  106. package/src/templates/skills/codument-implement/SKILL.md +14 -0
  107. package/src/templates/skills/codument-migrate/SKILL.md +17 -0
  108. package/src/templates/skills/codument-modeling-engineering-e2e/SKILL.md +74 -0
  109. package/src/templates/skills/codument-plan-mission/SKILL.md +17 -0
  110. package/src/templates/skills/codument-plan-track/SKILL.md +17 -0
  111. package/src/templates/skills/codument-plan-track-wave/SKILL.md +17 -0
  112. package/src/templates/skills/codument-revise-track/SKILL.md +17 -0
  113. package/src/templates/skills/codument-track/SKILL.md +14 -0
  114. package/src/templates/skills/codument-validate/SKILL.md +17 -0
  115. package/src/templates/skills/codument-verify/SKILL.md +17 -0
  116. package/src/types/text-assets.d.ts +9 -0
  117. package/src/version.ts +1 -0
@@ -0,0 +1,330 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import { parseOptions, codumentExists, TRACKS_DIR, CONFIG_DIR } from '../utils';
4
+ import { parseSpecXmlContent, type SpecXmlNode } from '../utils/spec-xml';
5
+
6
+ /**
7
+ * `codument validate [track-id]` — validates the new XML standard:
8
+ * - tracks/<id>/track.xml (root <Track>, per std/spec/track-xml-spec.md §9)
9
+ * - tracks/<id>/behavior_deltas/**.xml (root <behavior-patch>)
10
+ * No args (or `all`) validates every track under codument/tracks/.
11
+ */
12
+
13
+ interface ValidationError {
14
+ file: string;
15
+ message: string;
16
+ severity: 'error' | 'warning';
17
+ }
18
+
19
+ const METADATA_STATUS = new Set(['new', 'in_progress', 'completed', 'cancelled']);
20
+ const QUESTION_MODE = new Set(['decision-tree']);
21
+ const QUESTION_SEVERITY = new Set(['auto', 'light', 'normal', 'deep']);
22
+ const NODE_STATUS = new Set([
23
+ 'NOT_STARTED', 'ACTIVE', 'DELEGATED', 'FORWARDED', 'DONE', 'REFUSED', 'ABANDONED',
24
+ ]);
25
+ const CHILD_MODE = new Set(['sequential', 'dag']);
26
+ const HOOK_POINTS = new Set([
27
+ 'track:before', 'track:after', 'phase:before', 'phase:after', 'task:before', 'task:after',
28
+ ]);
29
+ const ON_EXHAUSTED = new Set(['block', 'continue', 'fail']);
30
+ const OP_TAGS = new Set(['upsert', 'delete', 'move']);
31
+
32
+ // --- tiny tree helpers over SpecXmlNode -------------------------------------
33
+
34
+ function childrenByTag(node: SpecXmlNode, tag: string): SpecXmlNode[] {
35
+ return node.children.filter((c) => c.tag === tag);
36
+ }
37
+ function firstChild(node: SpecXmlNode, tag: string): SpecXmlNode | undefined {
38
+ return node.children.find((c) => c.tag === tag);
39
+ }
40
+ function descendants(node: SpecXmlNode, pred: (n: SpecXmlNode) => boolean, acc: SpecXmlNode[] = []): SpecXmlNode[] {
41
+ for (const c of node.children) {
42
+ if (pred(c)) acc.push(c);
43
+ descendants(c, pred, acc);
44
+ }
45
+ return acc;
46
+ }
47
+
48
+ function parse(content: string, file: string, errors: ValidationError[]): SpecXmlNode | null {
49
+ try {
50
+ return parseSpecXmlContent(content);
51
+ } catch (e) {
52
+ errors.push({ file, severity: 'error', message: `格式错误 XML:${e instanceof Error ? e.message : String(e)}` });
53
+ return null;
54
+ }
55
+ }
56
+
57
+ // --- attractor profile names (config/attractor-profiles.xml) ----------------
58
+
59
+ function loadProfileNames(): Set<string> | null {
60
+ const file = path.join(CONFIG_DIR, 'attractor-profiles.xml');
61
+ if (!fs.existsSync(file)) return null;
62
+ try {
63
+ const root = parseSpecXmlContent(fs.readFileSync(file, 'utf-8'));
64
+ return new Set(
65
+ descendants(root, (n) => n.tag === 'Profile').map((p) => p.attrs['name']).filter(Boolean)
66
+ );
67
+ } catch {
68
+ return null;
69
+ }
70
+ }
71
+
72
+ // --- §9.4 per-layer DAG validity (Kahn) -------------------------------------
73
+
74
+ function validateSchedule(track: SpecXmlNode, file: string, errors: ValidationError[]): void {
75
+ const schedule = firstChild(track, 'Schedule');
76
+ if (!schedule) return;
77
+
78
+ const allNodes = descendants(track, (n) => n.tag === 'TaskGroup' || n.tag === 'Task' || n.tag === 'TaskSpace');
79
+ const byId = new Map<string, SpecXmlNode>();
80
+ for (const n of allNodes) {
81
+ const id = n.attrs['id'];
82
+ if (id) byId.set(id, n);
83
+ }
84
+ const directChildIds = (n: SpecXmlNode): Set<string> => {
85
+ const sub = firstChild(n, 'SubNodes') ?? n;
86
+ return new Set(
87
+ sub.children
88
+ .filter((c) => c.tag === 'TaskGroup' || c.tag === 'Task')
89
+ .map((c) => c.attrs['id'])
90
+ .filter(Boolean)
91
+ );
92
+ };
93
+
94
+ for (const dag of childrenByTag(schedule, 'Dag')) {
95
+ const forId = dag.attrs['for'];
96
+ const owner = forId ? byId.get(forId) : undefined;
97
+ if (!owner) {
98
+ errors.push({ file, severity: 'error', message: `<Schedule><Dag for="${forId}"> 引用了不存在的节点` });
99
+ continue;
100
+ }
101
+ if (owner.attrs['cdt:child-mode'] !== 'dag') {
102
+ errors.push({ file, severity: 'error', message: `<Dag for="${forId}"> 的目标节点未声明 cdt:child-mode="dag"` });
103
+ }
104
+ const layer = directChildIds(owner);
105
+ const nodes = childrenByTag(dag, 'Node');
106
+ const ids = nodes.map((n) => n.attrs['id']).filter(Boolean);
107
+ const preds = new Map<string, string[]>(); // node -> predecessors (After ref)
108
+
109
+ for (const node of nodes) {
110
+ const nid = node.attrs['id'];
111
+ if (!nid || !layer.has(nid)) {
112
+ errors.push({ file, severity: 'error', message: `<Dag for="${forId}"><Node id="${nid}"> 不是该层的直接下层` });
113
+ continue;
114
+ }
115
+ const afters = childrenByTag(node, 'After').map((a) => a.attrs['ref']).filter(Boolean);
116
+ for (const ref of afters) {
117
+ if (!layer.has(ref)) {
118
+ errors.push({ file, severity: 'error', message: `<Node id="${nid}"><After ref="${ref}"> 不是该层的直接下层` });
119
+ }
120
+ }
121
+ preds.set(nid, afters);
122
+ }
123
+
124
+ // Kahn cycle detection
125
+ const indeg = new Map<string, number>();
126
+ for (const id of ids) indeg.set(id, (preds.get(id) ?? []).filter((p) => ids.includes(p)).length);
127
+ const queue = ids.filter((id) => (indeg.get(id) ?? 0) === 0);
128
+ let visited = 0;
129
+ while (queue.length) {
130
+ const cur = queue.shift()!;
131
+ visited++;
132
+ for (const [node, ps] of preds) {
133
+ if (ps.includes(cur)) {
134
+ indeg.set(node, (indeg.get(node) ?? 1) - 1);
135
+ if (indeg.get(node) === 0) queue.push(node);
136
+ }
137
+ }
138
+ }
139
+ if (visited < ids.length) {
140
+ errors.push({ file, severity: 'error', message: `<Dag for="${forId}"> 存在环(依赖不可拓扑排序)` });
141
+ }
142
+ }
143
+ }
144
+
145
+ // --- §9.5 Hooks -------------------------------------------------------------
146
+
147
+ function validateHooks(track: SpecXmlNode, file: string, errors: ValidationError[], profiles: Set<string> | null): void {
148
+ for (const hook of descendants(track, (n) => n.tag === 'Hook')) {
149
+ const on = hook.attrs['on'];
150
+ if (!on || !HOOK_POINTS.has(on)) {
151
+ errors.push({ file, severity: 'error', message: `<Hook on="${on}"> 非法生命周期点` });
152
+ }
153
+ }
154
+ for (const ac of descendants(track, (n) => n.tag === 'cdt:AttractorCheck')) {
155
+ const use = ac.attrs['use'];
156
+ if (!use) {
157
+ errors.push({ file, severity: 'error', message: `<cdt:AttractorCheck> 缺少 use 属性` });
158
+ } else if (profiles && !profiles.has(use)) {
159
+ errors.push({ file, severity: 'error', message: `<cdt:AttractorCheck use="${use}"> 在 config/attractor-profiles.xml 中找不到对应 profile` });
160
+ }
161
+ }
162
+ for (const gl of descendants(track, (n) => n.tag === 'cdt:GapLoop')) {
163
+ const max = gl.attrs['max-rounds'];
164
+ if (max !== undefined && !/^\d+$/.test(max)) {
165
+ errors.push({ file, severity: 'error', message: `<cdt:GapLoop max-rounds="${max}"> 必须是整数` });
166
+ }
167
+ const onEx = gl.attrs['on-exhausted'];
168
+ if (onEx !== undefined && !ON_EXHAUSTED.has(onEx)) {
169
+ errors.push({ file, severity: 'warning', message: `<cdt:GapLoop on-exhausted="${onEx}"> 非常见取值(block|continue|fail)` });
170
+ }
171
+ const verifyRound = gl.attrs['verify-round'];
172
+ if (verifyRound !== undefined && verifyRound !== 'true' && verifyRound !== 'false') {
173
+ errors.push({ file, severity: 'error', message: `<cdt:GapLoop verify-round="${verifyRound}"> 必须是 true 或 false` });
174
+ }
175
+ }
176
+ }
177
+
178
+ // --- track.xml --------------------------------------------------------------
179
+
180
+ function validateTrackXml(content: string, file: string, errors: ValidationError[], profiles: Set<string> | null): void {
181
+ const root = parse(content, file, errors);
182
+ if (!root) return;
183
+
184
+ if (root.tag !== 'Track') {
185
+ errors.push({ file, severity: 'error', message: `根节点必须是 <Track>(实际:<${root.tag}>)` });
186
+ return;
187
+ }
188
+ if (!root.attrs['id']) errors.push({ file, severity: 'error', message: `<Track> 缺少 id 属性` });
189
+ if (root.attrs['xmlns:cdt'] === undefined) {
190
+ errors.push({ file, severity: 'error', message: `<Track> 未声明 xmlns:cdt 命名空间` });
191
+ }
192
+
193
+ const metadata = firstChild(root, 'Metadata') ?? root;
194
+ const status = firstChild(metadata, 'Status');
195
+ if (status && status.text && !METADATA_STATUS.has(status.text.trim())) {
196
+ errors.push({ file, severity: 'error', message: `<Metadata><Status>${status.text.trim()}</Status> 非法(new|in_progress|completed|cancelled)` });
197
+ }
198
+ const questionMode = firstChild(metadata, 'QuestionMode');
199
+ if (questionMode?.text && !QUESTION_MODE.has(questionMode.text.trim())) {
200
+ errors.push({ file, severity: 'error', message: `<Metadata><QuestionMode>${questionMode.text.trim()}</QuestionMode> 非法(decision-tree)` });
201
+ }
202
+ const questionSeverity = firstChild(metadata, 'QuestionSeverity');
203
+ if (questionSeverity?.text && !QUESTION_SEVERITY.has(questionSeverity.text.trim())) {
204
+ errors.push({ file, severity: 'error', message: `<Metadata><QuestionSeverity>${questionSeverity.text.trim()}</QuestionSeverity> 非法(auto|light|normal|deep)` });
205
+ }
206
+
207
+ const taskSpace = firstChild(root, 'TaskSpace');
208
+ if (!taskSpace) {
209
+ errors.push({ file, severity: 'error', message: `缺少 <TaskSpace>` });
210
+ } else {
211
+ const firstLevel = firstChild(taskSpace, 'SubNodes') ?? taskSpace;
212
+ const phases = childrenByTag(firstLevel, 'TaskGroup');
213
+ if (phases.length === 0) {
214
+ errors.push({ file, severity: 'error', message: `<TaskSpace> 第一层至少需要一个 <TaskGroup>(phase)` });
215
+ }
216
+ const seen = new Set<string>();
217
+ for (const n of descendants(taskSpace, (x) => x.tag === 'TaskGroup' || x.tag === 'Task')) {
218
+ const id = n.attrs['id'];
219
+ if (!id) {
220
+ errors.push({ file, severity: 'error', message: `<${n.tag}> 缺少 id 属性` });
221
+ continue;
222
+ }
223
+ if (seen.has(id)) errors.push({ file, severity: 'error', message: `节点 id 重复:${id}` });
224
+ seen.add(id);
225
+
226
+ const s = n.attrs['status'];
227
+ if (s && !NODE_STATUS.has(s)) {
228
+ errors.push({ file, severity: 'error', message: `<${n.tag} id="${id}"> status="${s}" 非 sparrow 枚举` });
229
+ }
230
+ const cm = n.attrs['cdt:child-mode'];
231
+ if (cm && !CHILD_MODE.has(cm)) {
232
+ errors.push({ file, severity: 'error', message: `<${n.tag} id="${id}"> cdt:child-mode="${cm}" 非法(sequential|dag)` });
233
+ }
234
+ }
235
+ }
236
+
237
+ validateSchedule(root, file, errors);
238
+ validateHooks(root, file, errors, profiles);
239
+ }
240
+
241
+ // --- behavior_deltas/**.xml -------------------------------------------------
242
+
243
+ function collectXml(dir: string): string[] {
244
+ if (!fs.existsSync(dir)) return [];
245
+ const out: string[] = [];
246
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
247
+ const full = path.join(dir, entry.name);
248
+ if (entry.isDirectory()) out.push(...collectXml(full));
249
+ else if (entry.isFile() && entry.name.endsWith('.xml')) out.push(full);
250
+ }
251
+ return out;
252
+ }
253
+
254
+ function validateBehaviorDeltas(trackDir: string, errors: ValidationError[]): number {
255
+ const files = collectXml(path.join(trackDir, 'behavior_deltas'));
256
+ for (const f of files) {
257
+ const root = parse(fs.readFileSync(f, 'utf-8'), f, errors);
258
+ if (!root) continue;
259
+ if (root.tag !== 'behavior-patch') {
260
+ errors.push({ file: f, severity: 'error', message: `根节点必须是 <behavior-patch>(实际:<${root.tag}>)` });
261
+ continue;
262
+ }
263
+ const mutations = descendants(root, (n) => OP_TAGS.has(n.tag));
264
+ if (mutations.length === 0) {
265
+ errors.push({ file: f, severity: 'error', message: `<behavior-patch> 至少需要一个变更(<upsert>|<delete>|<move>)` });
266
+ }
267
+ for (const m of mutations) {
268
+ const sel = m.attrs['selector'];
269
+ if (!sel || !sel.startsWith('behavior://')) {
270
+ errors.push({ file: f, severity: 'error', message: `<${m.tag}> 的 selector 必须是 behavior:// (实际:${sel ?? '缺失'})` });
271
+ }
272
+ }
273
+ }
274
+ return files.length;
275
+ }
276
+
277
+ // --- command ----------------------------------------------------------------
278
+
279
+ function trackIds(): string[] {
280
+ if (!fs.existsSync(TRACKS_DIR)) return [];
281
+ return fs.readdirSync(TRACKS_DIR, { withFileTypes: true })
282
+ .filter((e) => e.isDirectory() && fs.existsSync(path.join(TRACKS_DIR, e.name, 'track.xml')))
283
+ .map((e) => e.name);
284
+ }
285
+
286
+ export async function validateCommand(args: string[]): Promise<void> {
287
+ if (!codumentExists()) {
288
+ console.error('Codument is not initialized. Run codument init first.');
289
+ process.exit(1);
290
+ }
291
+ const { positional } = parseOptions(args);
292
+ const target = positional[0];
293
+ const ids = !target || target === 'all' ? trackIds() : [target];
294
+
295
+ if (ids.length === 0) {
296
+ console.log('No tracks to validate.');
297
+ return;
298
+ }
299
+
300
+ const profiles = loadProfileNames();
301
+ let hadError = false;
302
+
303
+ for (const id of ids) {
304
+ const trackDir = path.join(TRACKS_DIR, id);
305
+ const trackXml = path.join(trackDir, 'track.xml');
306
+ const errors: ValidationError[] = [];
307
+
308
+ if (!fs.existsSync(trackXml)) {
309
+ console.log(`✗ ${id}: track.xml 不存在`);
310
+ hadError = true;
311
+ continue;
312
+ }
313
+ validateTrackXml(fs.readFileSync(trackXml, 'utf-8'), trackXml, errors, profiles);
314
+ const deltaCount = validateBehaviorDeltas(trackDir, errors);
315
+
316
+ const errs = errors.filter((e) => e.severity === 'error');
317
+ const warns = errors.filter((e) => e.severity === 'warning');
318
+ if (errs.length === 0) {
319
+ console.log(`✓ ${id}: track.xml OK${deltaCount ? ` + ${deltaCount} behavior delta(s)` : ''}${warns.length ? ` (${warns.length} warning)` : ''}`);
320
+ } else {
321
+ hadError = true;
322
+ console.log(`✗ ${id}: ${errs.length} error(s)`);
323
+ }
324
+ for (const e of errors) {
325
+ console.log(` ${e.severity === 'error' ? '✗' : '⚠'} [${path.relative(trackDir, e.file)}] ${e.message}`);
326
+ }
327
+ }
328
+
329
+ if (hadError) process.exit(1);
330
+ }
@@ -0,0 +1,68 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import { CODUMENT_DIR } from '../utils';
4
+ import { DEFAULT_THRESHOLDS, type LintThresholds } from './lint';
5
+ import { DEFAULT_POLICY, type MergePolicy, type ConflictType, type Resolve } from './merge';
6
+
7
+ /**
8
+ * codument/config/engineering.xml gate + settings.
9
+ *
10
+ * Default OFF: when the file is absent or `enabled` is not "true", engineering is
11
+ * disabled and all engineering behavior (track delta, archive merge, lint) is skipped.
12
+ * Lightweight regex read (the file is small and flat); no XML dependency.
13
+ */
14
+
15
+ export interface EngineeringConfig {
16
+ enabled: boolean;
17
+ registryDir: string;
18
+ thresholds: LintThresholds;
19
+ mergePolicy: MergePolicy;
20
+ }
21
+
22
+ export function engineeringConfigPath(workspaceDir = '.'): string {
23
+ return path.join(workspaceDir, CODUMENT_DIR, 'config', 'engineering.xml');
24
+ }
25
+
26
+ function matchNum(xml: string, re: RegExp): number | undefined {
27
+ const m = re.exec(xml);
28
+ return m ? Number(m[1]) : undefined;
29
+ }
30
+
31
+ const VALID_RESOLVE = new Set<Resolve>(['human', 'ours', 'theirs', 'base']);
32
+
33
+ export function loadEngineeringConfig(configPath = engineeringConfigPath()): EngineeringConfig {
34
+ const def: EngineeringConfig = {
35
+ enabled: false,
36
+ registryDir: path.join(CODUMENT_DIR, 'engineering'),
37
+ thresholds: { ...DEFAULT_THRESHOLDS },
38
+ mergePolicy: { ...DEFAULT_POLICY },
39
+ };
40
+ if (!fs.existsSync(configPath)) return def;
41
+
42
+ const xml = fs.readFileSync(configPath, 'utf-8');
43
+ const enabled = /<Engineering[^>]*\benabled="true"/.test(xml);
44
+ const maxLines = matchNum(xml, /<Lint[^>]*\bmaxLines="(\d+)"/);
45
+ const maxNodes = matchNum(xml, /<Lint[^>]*\bmaxNodes="(\d+)"/);
46
+
47
+ const mergePolicy: MergePolicy = { ...DEFAULT_POLICY };
48
+ for (const m of xml.matchAll(/<Conflict\s+type="([^"]+)"\s+resolve="([^"]+)"/g)) {
49
+ const type = m[1] as ConflictType;
50
+ const resolve = m[2] as Resolve;
51
+ if (type in mergePolicy && VALID_RESOLVE.has(resolve)) mergePolicy[type] = resolve;
52
+ }
53
+
54
+ return {
55
+ enabled,
56
+ registryDir: def.registryDir,
57
+ thresholds: {
58
+ maxLines: maxLines ?? DEFAULT_THRESHOLDS.maxLines,
59
+ maxNodes: maxNodes ?? DEFAULT_THRESHOLDS.maxNodes,
60
+ },
61
+ mergePolicy,
62
+ };
63
+ }
64
+
65
+ /** True when engineering is enabled in this workspace. */
66
+ export function engineeringEnabled(workspaceDir = '.'): boolean {
67
+ return loadEngineeringConfig(engineeringConfigPath(workspaceDir)).enabled;
68
+ }
@@ -0,0 +1,58 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import { loadEngineeringRegistry, isDataElement, readNodeId } from './registry';
4
+
5
+ /**
6
+ * Fractal-split lint: flag oversized engineering XNL files as candidates for
7
+ * same-name-folder split (multi-file). Heuristic, advisory — the actual split is
8
+ * applied by the model per folder-manifest.md. Thresholds are configurable
9
+ * (defaults below; overridable via codument/config/engineering.xml in P4).
10
+ */
11
+
12
+ export interface LintThresholds {
13
+ /** Max lines before a file is a split candidate. */
14
+ maxLines: number;
15
+ /** Max top-level engineering nodes before a file is a split candidate. */
16
+ maxNodes: number;
17
+ }
18
+
19
+ export const DEFAULT_THRESHOLDS: LintThresholds = {
20
+ maxLines: 400,
21
+ maxNodes: 8,
22
+ };
23
+
24
+ export interface LintFinding {
25
+ file: string;
26
+ lines: number;
27
+ nodeCount: number;
28
+ reasons: string[];
29
+ }
30
+
31
+ /** Lint a engineering registry directory for fractal-split candidates. */
32
+ export function lintEngineeringRegistry(
33
+ dir: string,
34
+ thresholds: LintThresholds = DEFAULT_THRESHOLDS,
35
+ ): LintFinding[] {
36
+ const findings: LintFinding[] = [];
37
+ if (!fs.existsSync(dir)) return findings;
38
+ const registry = loadEngineeringRegistry(dir);
39
+
40
+ for (const [relFile, nodes] of registry.files) {
41
+ const content = fs.readFileSync(path.join(dir, relFile), 'utf-8');
42
+ const lines = content.split('\n').length;
43
+ const nodeCount = nodes.filter((n) => isDataElement(n) && readNodeId(n)).length;
44
+
45
+ const reasons: string[] = [];
46
+ if (lines > thresholds.maxLines) {
47
+ reasons.push(`${lines} lines > ${thresholds.maxLines}`);
48
+ }
49
+ if (nodeCount > thresholds.maxNodes) {
50
+ reasons.push(`${nodeCount} nodes > ${thresholds.maxNodes}`);
51
+ }
52
+ if (reasons.length > 0) {
53
+ findings.push({ file: relFile, lines, nodeCount, reasons });
54
+ }
55
+ }
56
+
57
+ return findings.sort((a, b) => a.file.localeCompare(b.file));
58
+ }
@@ -0,0 +1,172 @@
1
+ import { diffNodes, applyMutations, parsePath } from 'xnl-core';
2
+ import type { XnlNode, XnlMutation, XnlPath, PathItem } from 'xnl-core';
3
+ import { isDataElement, readNodeId } from './registry';
4
+
5
+ /**
6
+ * Node-level three-way merge for engineering deltas (base + ours + theirs), keyed by
7
+ * namespaced node id. Reuses xnl-core diffNodes/applyMutations. Conservative by
8
+ * default: disjoint changes auto-merge; true conflicts are reported (not silently
9
+ * overwritten) unless a per-type policy overrides. See std/spec/engineering-delta.md.
10
+ */
11
+
12
+ export type ConflictType = 'same-field' | 'delete-modify' | 'add-add';
13
+ export type Resolve = 'human' | 'ours' | 'theirs' | 'base';
14
+ export type MergePolicy = Record<ConflictType, Resolve>;
15
+
16
+ export const DEFAULT_POLICY: MergePolicy = {
17
+ 'same-field': 'human',
18
+ 'delete-modify': 'human',
19
+ 'add-add': 'human',
20
+ };
21
+
22
+ export interface MergeConflict {
23
+ id: string;
24
+ type: ConflictType;
25
+ base?: XnlNode;
26
+ ours?: XnlNode;
27
+ theirs?: XnlNode;
28
+ }
29
+
30
+ export interface MergeResult {
31
+ /** Merged node set keyed by id (excludes unresolved human conflicts). */
32
+ merged: Map<string, XnlNode>;
33
+ conflicts: MergeConflict[];
34
+ }
35
+
36
+ /** Index top-level DataElement nodes by id. */
37
+ export function indexById(nodes: XnlNode[]): Map<string, XnlNode> {
38
+ const m = new Map<string, XnlNode>();
39
+ for (const n of nodes) {
40
+ const id = readNodeId(n);
41
+ if (id && isDataElement(n)) m.set(id, n);
42
+ }
43
+ return m;
44
+ }
45
+
46
+ function clone<T>(v: T): T {
47
+ return JSON.parse(JSON.stringify(v)) as T;
48
+ }
49
+
50
+ function nodeEqual(a: XnlNode | undefined, b: XnlNode | undefined): boolean {
51
+ return JSON.stringify(a) === JSON.stringify(b);
52
+ }
53
+
54
+ function mutationsBetween(from: XnlNode, to: XnlNode): XnlMutation[] {
55
+ return diffNodes(from, to, [], { metadataIdMode: 'identity' });
56
+ }
57
+
58
+ function pathItems(p: string | XnlPath): PathItem[] {
59
+ return Array.isArray(p) ? p : parsePath(p);
60
+ }
61
+
62
+ /** Two paths overlap if one is a prefix of (or equal to) the other. */
63
+ function overlaps(a: PathItem[], b: PathItem[]): boolean {
64
+ const n = Math.min(a.length, b.length);
65
+ for (let i = 0; i < n; i++) {
66
+ if (a[i].type !== b[i].type || a[i].value !== b[i].value) return false; // diverge -> disjoint
67
+ }
68
+ return true; // one is a prefix of the other -> overlap
69
+ }
70
+
71
+ function changesAreDisjoint(oursMut: XnlMutation[], theirsMut: XnlMutation[]): boolean {
72
+ for (const o of oursMut) {
73
+ for (const t of theirsMut) {
74
+ if (overlaps(pathItems(o.path), pathItems(t.path))) return false;
75
+ }
76
+ }
77
+ return true;
78
+ }
79
+
80
+ function resolveConflict(
81
+ conflict: MergeConflict,
82
+ policy: MergePolicy,
83
+ merged: Map<string, XnlNode>,
84
+ conflicts: MergeConflict[],
85
+ ): void {
86
+ const choice = policy[conflict.type] ?? 'human';
87
+ switch (choice) {
88
+ case 'ours':
89
+ if (conflict.ours !== undefined) merged.set(conflict.id, conflict.ours);
90
+ return;
91
+ case 'theirs':
92
+ if (conflict.theirs !== undefined) merged.set(conflict.id, conflict.theirs);
93
+ return;
94
+ case 'base':
95
+ if (conflict.base !== undefined) merged.set(conflict.id, conflict.base);
96
+ return;
97
+ case 'human':
98
+ default:
99
+ conflicts.push(conflict); // report; leave unresolved
100
+ return;
101
+ }
102
+ }
103
+
104
+ export function mergeEngineering(
105
+ baseNodes: XnlNode[],
106
+ ourNodes: XnlNode[],
107
+ theirNodes: XnlNode[],
108
+ policy: MergePolicy = DEFAULT_POLICY,
109
+ ): MergeResult {
110
+ const base = indexById(baseNodes);
111
+ const ours = indexById(ourNodes);
112
+ const theirs = indexById(theirNodes);
113
+ const merged = new Map<string, XnlNode>();
114
+ const conflicts: MergeConflict[] = [];
115
+
116
+ const ids = new Set<string>([...base.keys(), ...ours.keys(), ...theirs.keys()]);
117
+
118
+ for (const id of ids) {
119
+ const b = base.get(id);
120
+ const o = ours.get(id);
121
+ const t = theirs.get(id);
122
+
123
+ // Present in all three.
124
+ if (b && o && t) {
125
+ const oChanged = !nodeEqual(b, o);
126
+ const tChanged = !nodeEqual(b, t);
127
+ if (!oChanged && !tChanged) merged.set(id, o);
128
+ else if (oChanged && !tChanged) merged.set(id, o);
129
+ else if (!oChanged && tChanged) merged.set(id, t);
130
+ else if (nodeEqual(o, t)) merged.set(id, o); // same change both sides
131
+ else {
132
+ const oursMut = mutationsBetween(b, o);
133
+ const theirsMut = mutationsBetween(b, t);
134
+ if (changesAreDisjoint(oursMut, theirsMut)) {
135
+ merged.set(id, applyMutations(clone(b), [...oursMut, ...theirsMut], { metadataIdMode: 'identity' }));
136
+ } else {
137
+ resolveConflict({ id, type: 'same-field', base: b, ours: o, theirs: t }, policy, merged, conflicts);
138
+ }
139
+ }
140
+ continue;
141
+ }
142
+
143
+ // Added (not in base).
144
+ if (!b) {
145
+ if (o && !t) merged.set(id, o);
146
+ else if (!o && t) merged.set(id, t);
147
+ else if (o && t) {
148
+ if (nodeEqual(o, t)) merged.set(id, o);
149
+ else resolveConflict({ id, type: 'add-add', ours: o, theirs: t }, policy, merged, conflicts);
150
+ }
151
+ continue;
152
+ }
153
+
154
+ // In base; deleted on at least one side.
155
+ const oDeleted = !o;
156
+ const tDeleted = !t;
157
+ if (oDeleted && tDeleted) continue; // both deleted -> gone
158
+ if (oDeleted && t) {
159
+ // ours deleted; theirs kept/modified
160
+ if (nodeEqual(b, t)) continue; // theirs unchanged -> honor delete
161
+ resolveConflict({ id, type: 'delete-modify', base: b, theirs: t }, policy, merged, conflicts);
162
+ continue;
163
+ }
164
+ if (tDeleted && o) {
165
+ if (nodeEqual(b, o)) continue; // ours unchanged -> honor delete
166
+ resolveConflict({ id, type: 'delete-modify', base: b, ours: o }, policy, merged, conflicts);
167
+ continue;
168
+ }
169
+ }
170
+
171
+ return { merged, conflicts };
172
+ }