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.
- package/LICENSE +21 -0
- package/README.md +262 -0
- package/package.json +63 -0
- package/src/cli/commands/archive.ts +519 -0
- package/src/cli/commands/decisions.ts +123 -0
- package/src/cli/commands/engineering.ts +105 -0
- package/src/cli/commands/init.ts +54 -0
- package/src/cli/commands/list.ts +73 -0
- package/src/cli/commands/modeling.ts +105 -0
- package/src/cli/commands/show.ts +238 -0
- package/src/cli/commands/status.ts +140 -0
- package/src/cli/commands/upgrade-track.ts +385 -0
- package/src/cli/commands/upgrade-workspace.ts +138 -0
- package/src/cli/commands/validate.ts +330 -0
- package/src/cli/engineering/config.ts +68 -0
- package/src/cli/engineering/lint.ts +58 -0
- package/src/cli/engineering/merge.ts +172 -0
- package/src/cli/engineering/registry.ts +230 -0
- package/src/cli/engineering/schema.ts +126 -0
- package/src/cli/engineering/validate.ts +286 -0
- package/src/cli/index.ts +136 -0
- package/src/cli/modeling/config.ts +68 -0
- package/src/cli/modeling/lint.ts +58 -0
- package/src/cli/modeling/merge.ts +172 -0
- package/src/cli/modeling/registry.ts +229 -0
- package/src/cli/modeling/schema.ts +160 -0
- package/src/cli/modeling/validate.ts +282 -0
- package/src/cli/utils/index.ts +941 -0
- package/src/cli/utils/install.ts +291 -0
- package/src/cli/utils/spec-xml.ts +673 -0
- package/src/cli/utils/track-time.ts +75 -0
- package/src/cli/utils/vfs.ts +102 -0
- package/src/templates/codument/README.md +59 -0
- package/src/templates/codument/attractors/product.md +17 -0
- package/src/templates/codument/attractors/project.md +10 -0
- package/src/templates/codument/backlog/README.md +33 -0
- package/src/templates/codument/config/attractor-profiles.xml +31 -0
- package/src/templates/codument/config/engineering.xml +22 -0
- package/src/templates/codument/config/modeling.xml +22 -0
- package/src/templates/codument/config/operation-hooks.xml +55 -0
- package/src/templates/codument/memory/README.md +13 -0
- package/src/templates/codument/missions/README.md +125 -0
- package/src/templates/codument/sop/README.md +14 -0
- package/src/templates/codument/std/AGENTS.md +82 -0
- package/src/templates/codument/std/attractors/depa-attractor.md +572 -0
- package/src/templates/codument/std/attractors/knowledge-tiers.md +128 -0
- package/src/templates/codument/std/attractors/model-driven-docs.md +293 -0
- package/src/templates/codument/std/attractors/project-memory.md +48 -0
- package/src/templates/codument/std/docs-impl-fractal/index.md +110 -0
- package/src/templates/codument/std/docs-modeling-fractal/index.md +156 -0
- package/src/templates/codument/std/kernel-pointer.md +19 -0
- package/src/templates/codument/std/operations/README.md +30 -0
- package/src/templates/codument/std/operations/_operation-spec.md +41 -0
- package/src/templates/codument/std/operations/archive-mission.md +66 -0
- package/src/templates/codument/std/operations/archive-track.md +238 -0
- package/src/templates/codument/std/operations/artifact-sync.md +172 -0
- package/src/templates/codument/std/operations/discuss-phase.md +214 -0
- package/src/templates/codument/std/operations/discuss.md +87 -0
- package/src/templates/codument/std/operations/docs-bootstrap.md +148 -0
- package/src/templates/codument/std/operations/gap-loop.md +301 -0
- package/src/templates/codument/std/operations/impl-mission.md +167 -0
- package/src/templates/codument/std/operations/impl-quick.md +79 -0
- package/src/templates/codument/std/operations/impl-track.md +537 -0
- package/src/templates/codument/std/operations/migrate.md +337 -0
- package/src/templates/codument/std/operations/plan-mission.md +230 -0
- package/src/templates/codument/std/operations/plan-track-wave.md +231 -0
- package/src/templates/codument/std/operations/plan-track.md +579 -0
- package/src/templates/codument/std/operations/revise-track.md +136 -0
- package/src/templates/codument/std/operations/validate.md +339 -0
- package/src/templates/codument/std/operations/verify.md +184 -0
- package/src/templates/codument/std/root-agents.md +39 -0
- package/src/templates/codument/std/sop/questioning.md +98 -0
- package/src/templates/codument/std/sop/tdd.md +26 -0
- package/src/templates/codument/std/sop/validation.md +25 -0
- package/src/templates/codument/std/sop/wave-exec.md +42 -0
- package/src/templates/codument/std/sop/workflow.md +35 -0
- package/src/templates/codument/std/spec/behavior-delta.md +36 -0
- package/src/templates/codument/std/spec/behavior-registry.md +42 -0
- package/src/templates/codument/std/spec/engineering-delta.md +68 -0
- package/src/templates/codument/std/spec/engineering-node-schema.md +86 -0
- package/src/templates/codument/std/spec/engineering-registry.md +82 -0
- package/src/templates/codument/std/spec/flow-notation.md +93 -0
- package/src/templates/codument/std/spec/folder-manifest.md +99 -0
- package/src/templates/codument/std/spec/mission-xml-spec.md +249 -0
- package/src/templates/codument/std/spec/modeling-delta.md +85 -0
- package/src/templates/codument/std/spec/modeling-node-schema.md +183 -0
- package/src/templates/codument/std/spec/modeling-registry.md +49 -0
- package/src/templates/codument/std/spec/track-xml-spec.md +272 -0
- package/src/templates/codument/std/spec/xnl-format.md +301 -0
- package/src/templates/codument/workflows/README.md +15 -0
- package/src/templates/manifest.ts +177 -0
- package/src/templates/skills/README.md +38 -0
- package/src/templates/skills/codument-archive/SKILL.md +17 -0
- package/src/templates/skills/codument-archive-mission/SKILL.md +17 -0
- package/src/templates/skills/codument-archive-track/SKILL.md +17 -0
- package/src/templates/skills/codument-artifact-sync/SKILL.md +17 -0
- package/src/templates/skills/codument-code-quality-score/SKILL.md +67 -0
- package/src/templates/skills/codument-decision-tree/SKILL.md +40 -0
- package/src/templates/skills/codument-discuss/SKILL.md +17 -0
- package/src/templates/skills/codument-discuss-phase/SKILL.md +17 -0
- package/src/templates/skills/codument-docs-bootstrap/SKILL.md +17 -0
- package/src/templates/skills/codument-gap-loop/SKILL.md +17 -0
- package/src/templates/skills/codument-impl-mission/SKILL.md +17 -0
- package/src/templates/skills/codument-impl-quick/SKILL.md +17 -0
- package/src/templates/skills/codument-impl-track/SKILL.md +17 -0
- package/src/templates/skills/codument-implement/SKILL.md +14 -0
- package/src/templates/skills/codument-migrate/SKILL.md +17 -0
- package/src/templates/skills/codument-modeling-engineering-e2e/SKILL.md +74 -0
- package/src/templates/skills/codument-plan-mission/SKILL.md +17 -0
- package/src/templates/skills/codument-plan-track/SKILL.md +17 -0
- package/src/templates/skills/codument-plan-track-wave/SKILL.md +17 -0
- package/src/templates/skills/codument-revise-track/SKILL.md +17 -0
- package/src/templates/skills/codument-track/SKILL.md +14 -0
- package/src/templates/skills/codument-validate/SKILL.md +17 -0
- package/src/templates/skills/codument-verify/SKILL.md +17 -0
- package/src/types/text-assets.d.ts +9 -0
- package/src/version.ts +1 -0
package/src/cli/index.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { listCommand } from './commands/list';
|
|
3
|
+
import { showCommand } from './commands/show';
|
|
4
|
+
import { validateCommand } from './commands/validate';
|
|
5
|
+
import { statusCommand } from './commands/status';
|
|
6
|
+
import { initCommand } from './commands/init';
|
|
7
|
+
import { archiveCommand } from './commands/archive';
|
|
8
|
+
import { upgradeWorkspaceCommand } from './commands/upgrade-workspace';
|
|
9
|
+
import { upgradeTrackCommand } from './commands/upgrade-track';
|
|
10
|
+
import { modelingCommand } from './commands/modeling';
|
|
11
|
+
import { engineeringCommand } from './commands/engineering';
|
|
12
|
+
import { decisionsCommand } from './commands/decisions';
|
|
13
|
+
import { setWorkspaceDir } from './utils';
|
|
14
|
+
import { VERSION } from '../version';
|
|
15
|
+
|
|
16
|
+
function helpText(): string {
|
|
17
|
+
return `
|
|
18
|
+
codument v${VERSION} - Spec-driven development tool for AI coding assistants
|
|
19
|
+
|
|
20
|
+
Usage:
|
|
21
|
+
codument <command> [options]
|
|
22
|
+
|
|
23
|
+
Commands:
|
|
24
|
+
init Initialize Codument in the current project
|
|
25
|
+
upgrade-workspace Upgrade workspace Codument files to latest
|
|
26
|
+
upgrade-track Upgrade a track plan.xml to wave-capable format
|
|
27
|
+
list List active tracks or specs
|
|
28
|
+
show [item] Show details of a track or spec
|
|
29
|
+
validate [item] Validate track or spec format
|
|
30
|
+
archive <id> Archive a completed track
|
|
31
|
+
status Show project status overview
|
|
32
|
+
modeling lint Flag oversized modeling XNL files for fractal split
|
|
33
|
+
modeling validate Validate modeling XNL (syntax + schema + hierarchy)
|
|
34
|
+
engineering lint Flag oversized engineering XNL files for fractal split
|
|
35
|
+
engineering validate Validate engineering XNL (syntax + schema + hierarchy)
|
|
36
|
+
decisions validate Validate track decisions.md metadata
|
|
37
|
+
|
|
38
|
+
Options:
|
|
39
|
+
-h, --help Show this help message
|
|
40
|
+
-v, --version Show version number
|
|
41
|
+
-w, --workspace-dir Set workspace directory (default: current directory)
|
|
42
|
+
|
|
43
|
+
Examples:
|
|
44
|
+
codument init --agent=codex,claude,eidolon,sparrow # Install skills into the target skills dir
|
|
45
|
+
codument init --skills-dir=.claude/skills --force
|
|
46
|
+
codument upgrade-workspace --agent=codex,claude,eidolon,sparrow # Refresh std + selected agent skills
|
|
47
|
+
codument list # List active tracks
|
|
48
|
+
codument show add-user-auth # Show track details
|
|
49
|
+
codument validate # Validate all tracks (track.xml + behavior deltas)
|
|
50
|
+
codument validate add-user-auth # Validate one track
|
|
51
|
+
codument status # Show project status
|
|
52
|
+
`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function main() {
|
|
56
|
+
const args = process.argv.slice(2);
|
|
57
|
+
|
|
58
|
+
// Parse global options first
|
|
59
|
+
let workspaceDir: string | undefined;
|
|
60
|
+
const filteredArgs: string[] = [];
|
|
61
|
+
|
|
62
|
+
for (let i = 0; i < args.length; i++) {
|
|
63
|
+
if (args[i] === '-w' || args[i] === '--workspace-dir') {
|
|
64
|
+
workspaceDir = args[i + 1];
|
|
65
|
+
i++; // Skip the value
|
|
66
|
+
} else {
|
|
67
|
+
filteredArgs.push(args[i]);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Set workspace directory if provided
|
|
72
|
+
if (workspaceDir) {
|
|
73
|
+
setWorkspaceDir(workspaceDir);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (filteredArgs.length === 0 || filteredArgs[0] === '-h' || filteredArgs[0] === '--help') {
|
|
77
|
+
console.log(helpText());
|
|
78
|
+
process.exit(0);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (filteredArgs[0] === '-v' || filteredArgs[0] === '--version') {
|
|
82
|
+
console.log(`codument v${VERSION}`);
|
|
83
|
+
process.exit(0);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const command = filteredArgs[0];
|
|
87
|
+
const commandArgs = filteredArgs.slice(1);
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
switch (command) {
|
|
91
|
+
case 'init':
|
|
92
|
+
await initCommand(commandArgs);
|
|
93
|
+
break;
|
|
94
|
+
case 'list':
|
|
95
|
+
await listCommand(commandArgs);
|
|
96
|
+
break;
|
|
97
|
+
case 'show':
|
|
98
|
+
await showCommand(commandArgs);
|
|
99
|
+
break;
|
|
100
|
+
case 'validate':
|
|
101
|
+
await validateCommand(commandArgs);
|
|
102
|
+
break;
|
|
103
|
+
case 'archive':
|
|
104
|
+
await archiveCommand(commandArgs);
|
|
105
|
+
break;
|
|
106
|
+
case 'status':
|
|
107
|
+
await statusCommand(commandArgs);
|
|
108
|
+
break;
|
|
109
|
+
case 'upgrade-workspace':
|
|
110
|
+
await upgradeWorkspaceCommand(commandArgs);
|
|
111
|
+
break;
|
|
112
|
+
case 'upgrade-track':
|
|
113
|
+
await upgradeTrackCommand(commandArgs);
|
|
114
|
+
break;
|
|
115
|
+
case 'modeling':
|
|
116
|
+
await modelingCommand(commandArgs);
|
|
117
|
+
break;
|
|
118
|
+
case 'engineering':
|
|
119
|
+
await engineeringCommand(commandArgs);
|
|
120
|
+
break;
|
|
121
|
+
case 'decisions':
|
|
122
|
+
await decisionsCommand(commandArgs);
|
|
123
|
+
break;
|
|
124
|
+
default:
|
|
125
|
+
console.error(`Unknown command: ${command}`);
|
|
126
|
+
console.log(helpText());
|
|
127
|
+
process.exit(1);
|
|
128
|
+
}
|
|
129
|
+
process.exit(0);
|
|
130
|
+
} catch (error) {
|
|
131
|
+
console.error('Error:', error instanceof Error ? error.message : error);
|
|
132
|
+
process.exit(1);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
main();
|
|
@@ -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/modeling.xml gate + settings.
|
|
9
|
+
*
|
|
10
|
+
* Default OFF: when the file is absent or `enabled` is not "true", modeling is
|
|
11
|
+
* disabled and all modeling 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 ModelingConfig {
|
|
16
|
+
enabled: boolean;
|
|
17
|
+
registryDir: string;
|
|
18
|
+
thresholds: LintThresholds;
|
|
19
|
+
mergePolicy: MergePolicy;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function modelingConfigPath(workspaceDir = '.'): string {
|
|
23
|
+
return path.join(workspaceDir, CODUMENT_DIR, 'config', 'modeling.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 loadModelingConfig(configPath = modelingConfigPath()): ModelingConfig {
|
|
34
|
+
const def: ModelingConfig = {
|
|
35
|
+
enabled: false,
|
|
36
|
+
registryDir: path.join(CODUMENT_DIR, 'modeling'),
|
|
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 = /<Modeling[^>]*\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 modeling is enabled in this workspace. */
|
|
66
|
+
export function modelingEnabled(workspaceDir = '.'): boolean {
|
|
67
|
+
return loadModelingConfig(modelingConfigPath(workspaceDir)).enabled;
|
|
68
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import { loadModelingRegistry, isDataElement, readNodeId } from './registry';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Fractal-split lint: flag oversized modeling 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/modeling.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 modeling 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 modeling registry directory for fractal-split candidates. */
|
|
32
|
+
export function lintModelingRegistry(
|
|
33
|
+
dir: string,
|
|
34
|
+
thresholds: LintThresholds = DEFAULT_THRESHOLDS,
|
|
35
|
+
): LintFinding[] {
|
|
36
|
+
const findings: LintFinding[] = [];
|
|
37
|
+
if (!fs.existsSync(dir)) return findings;
|
|
38
|
+
const registry = loadModelingRegistry(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 modeling 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/modeling-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 mergeModeling(
|
|
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
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import { parseXnl, stringifyLineBlock, wordToString, XnlParseError } from 'xnl-core';
|
|
4
|
+
import type { XnlNode, DataElementNode, XnlWord } from 'xnl-core';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* codument modeling registry adapter.
|
|
8
|
+
*
|
|
9
|
+
* The registry is a plain, host-git-versioned working tree of `.xnl` files under
|
|
10
|
+
* `codument/modeling/<plane>/<context>/...`. We load/save via xnl-core (parse +
|
|
11
|
+
* lineBlock formatter) and index nodes by their namespaced id. No parallel vcs repo
|
|
12
|
+
* is persisted; node-level 3-way merge (see merge.ts) uses xnl-vfs ephemerally.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export interface ModelingNodeRef {
|
|
16
|
+
/** Namespaced id, e.g. `resource.skill_tool` or `domain.resource.skill_tool`. */
|
|
17
|
+
id: string;
|
|
18
|
+
node: DataElementNode;
|
|
19
|
+
/** Path relative to the modeling dir, e.g. `domain/resource/index.xnl`. */
|
|
20
|
+
file: string;
|
|
21
|
+
/** `modeling://<plane>/<context>/<name>` derived from the file path + id. */
|
|
22
|
+
uri: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ModelingRegistry {
|
|
26
|
+
dir: string;
|
|
27
|
+
/** relPath -> top-level nodes of that file (preserves order for save). */
|
|
28
|
+
files: Map<string, XnlNode[]>;
|
|
29
|
+
/** namespaced id -> ref. */
|
|
30
|
+
index: Map<string, ModelingNodeRef>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function isDataElement(node: XnlNode | undefined): node is DataElementNode {
|
|
34
|
+
return Boolean(node && typeof node === 'object' && (node as DataElementNode).kind === 'DataElement');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Read a node's namespaced id from `#word` or `metadata.id`. */
|
|
38
|
+
export function readNodeId(node: XnlNode): string | undefined {
|
|
39
|
+
if (!isDataElement(node)) return undefined;
|
|
40
|
+
const fromWord = wordToString((node as { id?: XnlWord }).id);
|
|
41
|
+
if (fromWord) return fromWord;
|
|
42
|
+
const metaId = node.metadata?.id;
|
|
43
|
+
if (typeof metaId === 'string') return metaId;
|
|
44
|
+
if (metaId && typeof metaId === 'object' && (metaId as XnlWord).kind === 'Word') {
|
|
45
|
+
return wordToString(metaId as XnlWord);
|
|
46
|
+
}
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Last segment of a namespaced id (`a.b.name` -> `name`). */
|
|
51
|
+
export function nodeName(id: string): string {
|
|
52
|
+
const parts = id.split('.');
|
|
53
|
+
return parts[parts.length - 1];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Namespace prefix of a namespaced id (`a.b.name` -> `a.b`, `name` -> ``).
|
|
58
|
+
* The namespace is the id minus its trailing `name` segment; a bare name yields
|
|
59
|
+
* the empty string. Used by id↔path alignment and modeling-uri derivation.
|
|
60
|
+
*/
|
|
61
|
+
export function idNamespace(id: string): string {
|
|
62
|
+
const parts = id.split('.');
|
|
63
|
+
return parts.slice(0, -1).join('.');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Build `modeling://<plane>/<context>/<name>` from a file path and node id. */
|
|
67
|
+
export function modelingUri(relFile: string, id: string): string {
|
|
68
|
+
const segs = relFile.split(path.sep).filter(Boolean);
|
|
69
|
+
const plane = segs[0] ?? 'domain';
|
|
70
|
+
const context = segs[1] ?? plane;
|
|
71
|
+
return `modeling://${plane}/${context}/${nodeName(id)}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function isHidden(name: string): boolean {
|
|
75
|
+
return name.startsWith('.');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function walkXnlFiles(dir: string, base: string, out: string[]): void {
|
|
79
|
+
if (!fs.existsSync(dir)) return;
|
|
80
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
81
|
+
if (isHidden(entry.name)) continue; // skip .node-meta, .tmp, .xnl-vcs, etc.
|
|
82
|
+
const abs = path.join(dir, entry.name);
|
|
83
|
+
if (entry.isDirectory()) {
|
|
84
|
+
walkXnlFiles(abs, base, out);
|
|
85
|
+
} else if (entry.isFile() && entry.name.endsWith('.xnl')) {
|
|
86
|
+
out.push(path.relative(base, abs));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Load the modeling registry from a working tree directory. */
|
|
92
|
+
export function loadModelingRegistry(dir: string): ModelingRegistry {
|
|
93
|
+
const files = new Map<string, XnlNode[]>();
|
|
94
|
+
const index = new Map<string, ModelingNodeRef>();
|
|
95
|
+
const relFiles: string[] = [];
|
|
96
|
+
walkXnlFiles(dir, dir, relFiles);
|
|
97
|
+
relFiles.sort();
|
|
98
|
+
|
|
99
|
+
for (const relFile of relFiles) {
|
|
100
|
+
const content = fs.readFileSync(path.join(dir, relFile), 'utf-8');
|
|
101
|
+
const nodes = parseXnl(content, { textBlockStyle: true }).nodes;
|
|
102
|
+
files.set(relFile, nodes);
|
|
103
|
+
for (const node of nodes) {
|
|
104
|
+
const id = readNodeId(node);
|
|
105
|
+
if (!id || !isDataElement(node)) continue;
|
|
106
|
+
if (index.has(id)) {
|
|
107
|
+
throw new Error(
|
|
108
|
+
`Duplicate modeling node id '${id}' in '${relFile}' and '${index.get(id)!.file}'`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
index.set(id, { id, node, file: relFile, uri: modelingUri(relFile, id) });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return { dir, files, index };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* A non-fatal load issue collected by {@link loadModelingRegistrySafe}: an XNL
|
|
120
|
+
* parse error (`syntax`) or a cross-file duplicate id (`duplicate-id`). The
|
|
121
|
+
* validate engine maps these onto its own finding shape.
|
|
122
|
+
*/
|
|
123
|
+
export interface LoadIssue {
|
|
124
|
+
kind: 'syntax' | 'duplicate-id';
|
|
125
|
+
/** Path relative to the registry dir. */
|
|
126
|
+
file: string;
|
|
127
|
+
/** 1-based line, when known (parse errors carry one). */
|
|
128
|
+
line?: number;
|
|
129
|
+
message: string;
|
|
130
|
+
/** For duplicate ids: the other file that already defined the id. */
|
|
131
|
+
otherFile?: string;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export interface SafeRegistryResult {
|
|
135
|
+
registry: ModelingRegistry;
|
|
136
|
+
issues: LoadIssue[];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Load the modeling registry without throwing. Files that fail XNL parsing are
|
|
141
|
+
* skipped (their error is collected as a `syntax` issue); duplicate ids are
|
|
142
|
+
* collected as `duplicate-id` issues (first definition wins in the index)
|
|
143
|
+
* instead of aborting the load. The non-safe {@link loadModelingRegistry}
|
|
144
|
+
* keeps its throwing contract for existing callers (lint, merge, archive).
|
|
145
|
+
*/
|
|
146
|
+
export function loadModelingRegistrySafe(dir: string): SafeRegistryResult {
|
|
147
|
+
const files = new Map<string, XnlNode[]>();
|
|
148
|
+
const index = new Map<string, ModelingNodeRef>();
|
|
149
|
+
const issues: LoadIssue[] = [];
|
|
150
|
+
const relFiles: string[] = [];
|
|
151
|
+
walkXnlFiles(dir, dir, relFiles);
|
|
152
|
+
relFiles.sort();
|
|
153
|
+
|
|
154
|
+
for (const relFile of relFiles) {
|
|
155
|
+
const content = fs.readFileSync(path.join(dir, relFile), 'utf-8');
|
|
156
|
+
let nodes: XnlNode[];
|
|
157
|
+
try {
|
|
158
|
+
nodes = parseXnl(content, { textBlockStyle: true }).nodes;
|
|
159
|
+
} catch (err) {
|
|
160
|
+
if (err instanceof XnlParseError) {
|
|
161
|
+
issues.push({ kind: 'syntax', file: relFile, line: err.line, message: err.message });
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
throw err;
|
|
165
|
+
}
|
|
166
|
+
files.set(relFile, nodes);
|
|
167
|
+
for (const node of nodes) {
|
|
168
|
+
const id = readNodeId(node);
|
|
169
|
+
if (!id || !isDataElement(node)) continue;
|
|
170
|
+
const existing = index.get(id);
|
|
171
|
+
if (existing) {
|
|
172
|
+
issues.push({
|
|
173
|
+
kind: 'duplicate-id',
|
|
174
|
+
file: relFile,
|
|
175
|
+
otherFile: existing.file,
|
|
176
|
+
message: `Duplicate modeling node id '${id}' in '${relFile}' and '${existing.file}'`,
|
|
177
|
+
});
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
index.set(id, { id, node, file: relFile, uri: modelingUri(relFile, id) });
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return { registry: { dir, files, index }, issues };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export interface SerializeOptions {
|
|
188
|
+
/**
|
|
189
|
+
* Marker factory for markerless text blocks. Defaults to xnl-core's ULID factory,
|
|
190
|
+
* which preserves XNL text nodes' escape-free property (a markerless `?>` block
|
|
191
|
+
* gets a stable unique marker on first write, then round-trips unchanged).
|
|
192
|
+
* Tests may inject a deterministic factory; production MUST keep the ULID default.
|
|
193
|
+
*/
|
|
194
|
+
textMarkerFactory?: () => string;
|
|
195
|
+
/**
|
|
196
|
+
* Block text style (default true for modeling files): render each text element's
|
|
197
|
+
* content on its own indented line(s) between `<tag ?m>` and `</?m>`, so registry
|
|
198
|
+
* files stay readable and diff-friendly. Paired with `parseXnl(.., {textBlockStyle})`.
|
|
199
|
+
*/
|
|
200
|
+
textBlockStyle?: boolean;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Serialize top-level nodes of a file to XNL (lineBlock pretty, block text style). */
|
|
204
|
+
export function serializeModelingFile(nodes: XnlNode[], opts: SerializeOptions = {}): string {
|
|
205
|
+
return nodes.map((n) => stringifyLineBlock(n, { textBlockStyle: true, ...opts })).join('\n\n') + '\n';
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Write one modeling file back to the working tree. */
|
|
209
|
+
export function saveModelingFile(
|
|
210
|
+
dir: string,
|
|
211
|
+
relFile: string,
|
|
212
|
+
nodes: XnlNode[],
|
|
213
|
+
opts: SerializeOptions = {},
|
|
214
|
+
): void {
|
|
215
|
+
const abs = path.join(dir, relFile);
|
|
216
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
217
|
+
fs.writeFileSync(abs, serializeModelingFile(nodes, opts), 'utf-8');
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Save the whole registry back to its working tree. */
|
|
221
|
+
export function saveModelingRegistry(
|
|
222
|
+
registry: ModelingRegistry,
|
|
223
|
+
targetDir = registry.dir,
|
|
224
|
+
opts: SerializeOptions = {},
|
|
225
|
+
): void {
|
|
226
|
+
for (const [relFile, nodes] of registry.files) {
|
|
227
|
+
saveModelingFile(targetDir, relFile, nodes, opts);
|
|
228
|
+
}
|
|
229
|
+
}
|