cadet-agent 0.60.0 → 0.63.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/README.md +40 -12
- package/package.json +4 -1
- package/src/cli.mjs +180 -201
- package/src/harness/commands.mjs +23 -20
- package/src/harness/context-protocol.mjs +143 -14
- package/src/harness/csharp.mjs +259 -0
- package/src/harness/diagram.mjs +735 -0
- package/src/harness/gates.mjs +3 -2
- package/src/harness/index.mjs +17 -12
- package/src/harness/noml.mjs +47 -0
- package/src/harness/play.mjs +2 -2
- package/src/harness/reconcile.mjs +34 -2
- package/src/harness/state.mjs +245 -10
- package/src/harness/verification.mjs +6 -12
- package/src/harness/acceptance-form.mjs +0 -281
- package/src/harness/play-form.mjs +0 -193
|
@@ -23,11 +23,13 @@
|
|
|
23
23
|
* rules: what a phase requires, what a record may claim, and when the two disagree.
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
27
|
-
import { join } from 'node:path';
|
|
26
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
27
|
+
import { dirname, join, relative } from 'node:path';
|
|
28
28
|
|
|
29
29
|
import { ContextManifest } from './context.mjs';
|
|
30
30
|
import { sha256, timestamp } from './util.mjs';
|
|
31
|
+
import { buildStateBrief, renderStateBrief } from './state.mjs';
|
|
32
|
+
import { PLANS_DEFAULT_DIR, walkDirs } from './reconcile.mjs';
|
|
31
33
|
|
|
32
34
|
/** The levels a record may carry, strongest first. */
|
|
33
35
|
export const CONTEXT_LEVELS = Object.freeze(['enforced', 'recorded', 'estimated', 'unavailable']);
|
|
@@ -37,6 +39,32 @@ export const CONTEXT_DIR = '.cadet/context';
|
|
|
37
39
|
export const CONTEXT_PLAN_FILE = 'plan.json';
|
|
38
40
|
export const CONTEXT_RECORD_FILE = 'record.json';
|
|
39
41
|
|
|
42
|
+
/**
|
|
43
|
+
* The derived summary of `.cadet/state.json` that tier 0 names, in place of the document.
|
|
44
|
+
*
|
|
45
|
+
* The document is not the current story: it is the current story's live evidence plus lists that
|
|
46
|
+
* only ever grow — one row per work item ever closed, one entry per change checkpoint. In a real
|
|
47
|
+
* consumer it reached 94,547 B, about 31,500 tokens, of which the current story was 31%. This file
|
|
48
|
+
* carries what the phase reads, and nothing else.
|
|
49
|
+
*/
|
|
50
|
+
export const CONTEXT_BRIEF_FILE = 'state-brief.md';
|
|
51
|
+
const STATE_BRIEF = `${CONTEXT_DIR}/${CONTEXT_BRIEF_FILE}`;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Write the tier-0 brief, before a plan names and hashes it.
|
|
55
|
+
*
|
|
56
|
+
* `policy` is passed through so the written brief and the printed one are the SAME rendering. The
|
|
57
|
+
* brief's legal-edge line reports an opt-in gate (reachability, the playthrough, human acceptance,
|
|
58
|
+
* architecture fitness) only when the resolved policy requires it, so a caller that omits the policy
|
|
59
|
+
* writes a brief that under-reports the edge the transition itself would enforce.
|
|
60
|
+
*/
|
|
61
|
+
export function writeStateBrief(targetDir, state, policy = null, docs = null) {
|
|
62
|
+
const path = join(targetDir, CONTEXT_DIR, CONTEXT_BRIEF_FILE);
|
|
63
|
+
mkdirSync(join(targetDir, CONTEXT_DIR), { recursive: true });
|
|
64
|
+
writeFileSync(path, renderStateBrief(buildStateBrief(state, policy, docs)), 'utf-8');
|
|
65
|
+
return path;
|
|
66
|
+
}
|
|
67
|
+
|
|
40
68
|
export class ContextProtocolError extends Error {
|
|
41
69
|
constructor(message, code = 'context-protocol') {
|
|
42
70
|
super(message);
|
|
@@ -85,6 +113,88 @@ function entry(reference, { tier, reason, authority, required }) {
|
|
|
85
113
|
return { reference: normalise(reference), tier, reason, authority, required };
|
|
86
114
|
}
|
|
87
115
|
|
|
116
|
+
/** A repo-relative path with forward slashes, as a plan entry's reference must be. */
|
|
117
|
+
function relativePath(targetDir, abs) {
|
|
118
|
+
return normalise(relative(targetDir, abs));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Where the active work item's own documents are.
|
|
123
|
+
*
|
|
124
|
+
* WHY THIS EXISTS. The plan built these two paths by joining the plans root with the ids from
|
|
125
|
+
* `state.json`, which assumes the PACKAGED layout. A real project nests its plans and names its own
|
|
126
|
+
* folders — this repository keeps `.cadet/agent/project-plans/dolven-tactics-mvp/epics/<epic>/` — so the
|
|
127
|
+
* plan reported the host's own active story as MISSING on every phase of every story, and its epic as
|
|
128
|
+
* absent. A check that fires on a correct project teaches its reader to ignore the output; the
|
|
129
|
+
* reconciler records that same lesson, for this same repository, in its REQUIRED_ARTIFACTS comment.
|
|
130
|
+
*
|
|
131
|
+
* THE RULE, and it is deliberately narrow. Find the story by its own FILENAME, which is what
|
|
132
|
+
* `state.json` already holds, using the reconciler's bounded walk. Accept a match only when the epic id
|
|
133
|
+
* names one of its parent directories, so a same-named file under another epic cannot be taken for it.
|
|
134
|
+
* The story's own directory IS the epic's directory, and the epic document is whatever `epic.md` is
|
|
135
|
+
* called there, because the case varies between projects.
|
|
136
|
+
*
|
|
137
|
+
* TWO MATCHES ARE REPORTED, NOT GUESSED. A duplicate filename comes back in `ambiguous` so the plan can
|
|
138
|
+
* say so, rather than resolving to whichever the filesystem happened to list first.
|
|
139
|
+
*
|
|
140
|
+
* Reads directories and parses nothing. A missing or unreadable tree returns nulls instead of throwing,
|
|
141
|
+
* so the plan still reports the row — as MISSING, which is then true.
|
|
142
|
+
*/
|
|
143
|
+
export function resolveWorkItemDocs(targetDir, state, { plansDir = PLANS_DEFAULT_DIR } = {}) {
|
|
144
|
+
const epicId = state?.activeWorkItem?.epicId || null;
|
|
145
|
+
const storyId = state?.activeWorkItem?.storyId || null;
|
|
146
|
+
if (!epicId || !storyId) return { story: null, epic: null, ambiguous: [], root: plansDir };
|
|
147
|
+
|
|
148
|
+
const epicWanted = String(epicId).toLowerCase();
|
|
149
|
+
// `storyId` is a filename in most documents and a path in a few, so compare on the last segment.
|
|
150
|
+
const wanted = String(storyId).replace(/\\/g, '/').split('/').pop().toLowerCase();
|
|
151
|
+
const root = join(targetDir, plansDir);
|
|
152
|
+
const matches = [];
|
|
153
|
+
|
|
154
|
+
for (const dir of walkDirs(root)) {
|
|
155
|
+
// The epic id must name a directory BETWEEN the plans root and the file. Comparing only the relative
|
|
156
|
+
// part stops a drive letter or a user's own folder name from ever satisfying the test.
|
|
157
|
+
const rel = relative(root, dir).replace(/\\/g, '/').toLowerCase();
|
|
158
|
+
if (!rel.split('/').includes(epicWanted)) continue;
|
|
159
|
+
let entries;
|
|
160
|
+
try {
|
|
161
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
162
|
+
} catch { continue; }
|
|
163
|
+
for (const item of entries) {
|
|
164
|
+
if (item.isFile() && item.name.toLowerCase() === wanted) matches.push(join(dir, item.name));
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (matches.length === 0) return { story: null, epic: null, ambiguous: [], root: plansDir };
|
|
169
|
+
if (matches.length > 1) {
|
|
170
|
+
return { story: null, epic: null, ambiguous: matches.map((p) => relativePath(targetDir, p)), root: plansDir };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const story = matches[0];
|
|
174
|
+
let epic = null;
|
|
175
|
+
try {
|
|
176
|
+
const epicFile = readdirSync(dirname(story), { withFileTypes: true })
|
|
177
|
+
.find((item) => item.isFile() && item.name.toLowerCase() === 'epic.md');
|
|
178
|
+
if (epicFile) epic = join(dirname(story), epicFile.name);
|
|
179
|
+
} catch { /* the epic document is advisory: its absence is reported, not raised */ }
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
story: relativePath(targetDir, story),
|
|
183
|
+
epic: epic ? relativePath(targetDir, epic) : null,
|
|
184
|
+
ambiguous: [],
|
|
185
|
+
root: plansDir,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Why a work-item document could not be resolved, said in the plan's own words. */
|
|
190
|
+
function unresolvedReason(work, storyId) {
|
|
191
|
+
if (work.ambiguous.length > 0) {
|
|
192
|
+
return `${work.ambiguous.length} files named ${storyId} under ${work.root}, so the plan will not `
|
|
193
|
+
+ `guess which one is active: ${work.ambiguous.join(', ')}`;
|
|
194
|
+
}
|
|
195
|
+
return `not found under ${work.root}`;
|
|
196
|
+
}
|
|
197
|
+
|
|
88
198
|
/**
|
|
89
199
|
* What the current phase requires, and what merely helps.
|
|
90
200
|
*
|
|
@@ -92,10 +202,13 @@ function entry(reference, { tier, reason, authority, required }) {
|
|
|
92
202
|
* from state and the framework's own layout rather than from a new policy key, because a policy
|
|
93
203
|
* that restates the framework's folder conventions is another thing to keep in step.
|
|
94
204
|
*/
|
|
95
|
-
export function planEntries({ targetDir, policy, state = null }) {
|
|
205
|
+
export function planEntries({ targetDir, policy, state = null, docs = null }) {
|
|
96
206
|
const phase = state?.session?.currentPhase || 'context-resolution';
|
|
97
207
|
const required = [];
|
|
98
208
|
const advisory = [];
|
|
209
|
+
// Resolved once per plan. A caller may pass what it already resolved, so a plan and the brief it
|
|
210
|
+
// writes do not walk the planning tree twice for the same answer.
|
|
211
|
+
const work = docs || resolveWorkItemDocs(targetDir, state);
|
|
99
212
|
|
|
100
213
|
required.push(entry(KICKOFF, {
|
|
101
214
|
tier: 'tier0', reason: 'always-load: the directive that routes every other decision', authority: 'framework', required: true,
|
|
@@ -103,11 +216,17 @@ export function planEntries({ targetDir, policy, state = null }) {
|
|
|
103
216
|
required.push(entry(HARNESS_CONTRACT, {
|
|
104
217
|
tier: 'tier0', reason: 'the lean runtime contract for gates, evidence and budgets', authority: 'framework', required: true,
|
|
105
218
|
}));
|
|
106
|
-
|
|
107
|
-
required
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
219
|
+
required.push(entry('.cadet/harness.json', {
|
|
220
|
+
tier: 'tier0', reason: 'the resolved policy: budgets, the gate settings and the human-gate rules the phase obeys', authority: 'session', required: true,
|
|
221
|
+
}));
|
|
222
|
+
// THE STATE BRIEF, NOT THE STATE DOCUMENT (0.63.0). The document is not the current story — it
|
|
223
|
+
// holds every work item ever closed and every change checkpoint ever recorded, and it grows without
|
|
224
|
+
// bound. It was 94,547 B, about 31,500 tokens, of which the current story was 31%, and it was
|
|
225
|
+
// re-sent on every turn. The brief carries the phase, the work item, the gates and what is
|
|
226
|
+
// archivable; `state brief` prints it and `harness context plan` writes it here.
|
|
227
|
+
required.push(entry(STATE_BRIEF, {
|
|
228
|
+
tier: 'tier0', reason: 'the session state the phase reads: the phase, the active work item, every gate, and what compaction would archive', authority: 'session', required: true,
|
|
229
|
+
}));
|
|
111
230
|
|
|
112
231
|
// The phase's own instruction: the skill the dispatch table names.
|
|
113
232
|
const skillFile = phase === 'architectureComplete' && policy?.designReview?.enabled !== true
|
|
@@ -123,13 +242,23 @@ export function planEntries({ targetDir, policy, state = null }) {
|
|
|
123
242
|
const epicId = state?.activeWorkItem?.epicId || null;
|
|
124
243
|
const storyId = state?.activeWorkItem?.storyId || null;
|
|
125
244
|
if (epicId && storyId) {
|
|
126
|
-
|
|
127
|
-
|
|
245
|
+
// The resolved path when the tree has the file; the packaged layout's guess otherwise, so an
|
|
246
|
+
// unresolvable story is still reported as MISSING rather than dropped from the required set.
|
|
247
|
+
required.push(entry(work.story || join(PLANS_DEFAULT_DIR, epicId, storyId), {
|
|
248
|
+
tier: 'tier1',
|
|
249
|
+
reason: work.story
|
|
250
|
+
? 'the active story: its status, its acceptance criteria, its reachability'
|
|
251
|
+
: `the active story: its status, its acceptance criteria, its reachability — ${unresolvedReason(work, storyId)}`,
|
|
252
|
+
authority: 'session', required: true,
|
|
128
253
|
}));
|
|
129
254
|
}
|
|
130
255
|
if (epicId) {
|
|
131
|
-
advisory.push(entry(join(
|
|
132
|
-
tier: 'tier1',
|
|
256
|
+
advisory.push(entry(work.epic || join(PLANS_DEFAULT_DIR, epicId, 'Epic.md'), {
|
|
257
|
+
tier: 'tier1',
|
|
258
|
+
reason: work.epic
|
|
259
|
+
? 'the epic this story belongs to'
|
|
260
|
+
: `the epic this story belongs to — ${unresolvedReason(work, storyId)}`,
|
|
261
|
+
authority: 'repository', required: false,
|
|
133
262
|
}));
|
|
134
263
|
}
|
|
135
264
|
|
|
@@ -152,8 +281,8 @@ export function planEntries({ targetDir, policy, state = null }) {
|
|
|
152
281
|
* Build the plan: the entries above, resolved through `ContextManifest` so each carries its hash,
|
|
153
282
|
* size, estimated tokens and budget effect.
|
|
154
283
|
*/
|
|
155
|
-
export function buildContextPlan({ targetDir, policy, state = null } = {}) {
|
|
156
|
-
const { phase, required, advisory } = planEntries({ targetDir, policy, state });
|
|
284
|
+
export function buildContextPlan({ targetDir, policy, state = null, docs = null } = {}) {
|
|
285
|
+
const { phase, required, advisory } = planEntries({ targetDir, policy, state, docs });
|
|
157
286
|
|
|
158
287
|
// A plan is a READING, not a load, and the load bounds must not bound it.
|
|
159
288
|
//
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* C# structure scan for the component-diagram generator.
|
|
3
|
+
*
|
|
4
|
+
* A "package" is a Unity assembly definition (`.asmdef`) — the assembly is the
|
|
5
|
+
* unit a C# project is actually built and referenced in. A "component" is a
|
|
6
|
+
* namespace inside a package: the right granularity for a package-level diagram,
|
|
7
|
+
* where listing types would overflow the page.
|
|
8
|
+
*
|
|
9
|
+
* The scan is deliberately tolerant. A repository with no `.asmdef` files still
|
|
10
|
+
* gets packages (its top-level folders), because the generator must work on a
|
|
11
|
+
* plain C# tree and on this framework source itself, where it is run for its
|
|
12
|
+
* structure alone.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
16
|
+
import { join, dirname, relative, basename } from 'node:path';
|
|
17
|
+
|
|
18
|
+
/** Directory names never walked: build output, caches, and Cadet's own bookkeeping. */
|
|
19
|
+
export const DEFAULT_EXCLUDED_DIRS = Object.freeze([
|
|
20
|
+
'node_modules', '.git', '.vs', '.idea', '.cadet',
|
|
21
|
+
'Library', 'Temp', 'obj', 'bin', 'Logs', 'UserSettings', 'Build', 'Builds', 'MemoryCaptures',
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
function toPosix(path) {
|
|
25
|
+
return String(path).replace(/\\/g, '/');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isInside(child, parent) {
|
|
29
|
+
const c = toPosix(child);
|
|
30
|
+
const p = toPosix(parent);
|
|
31
|
+
return c === p || c.startsWith(`${p}/`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Every file under `rootDir`, skipping excluded directory names. */
|
|
35
|
+
function walkFiles(rootDir, excluded) {
|
|
36
|
+
const out = [];
|
|
37
|
+
const visit = (dir) => {
|
|
38
|
+
let entries;
|
|
39
|
+
try {
|
|
40
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
41
|
+
} catch {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
for (const entry of entries) {
|
|
45
|
+
const full = join(dir, entry.name);
|
|
46
|
+
if (entry.isDirectory()) {
|
|
47
|
+
if (!excluded.has(entry.name)) visit(full);
|
|
48
|
+
} else if (entry.isFile()) {
|
|
49
|
+
out.push(full);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
visit(rootDir);
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The GUID recorded in an `.asmdef.meta`, so `GUID:` references can be resolved. */
|
|
58
|
+
function readMetaGuid(asmdefPath) {
|
|
59
|
+
try {
|
|
60
|
+
const text = readFileSync(`${asmdefPath}.meta`, 'utf-8');
|
|
61
|
+
const m = /guid:\s*([0-9a-fA-F]{32})/.exec(text);
|
|
62
|
+
return m ? m[1].toLowerCase() : null;
|
|
63
|
+
} catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Discover packages under `rootDir`.
|
|
70
|
+
*
|
|
71
|
+
* Returns `{ id, name, dir, asmdefPath, references }`, sorted by id. `references`
|
|
72
|
+
* names the packages this one depends on; a reference Unity stored as a GUID is
|
|
73
|
+
* resolved through the `.asmdef.meta`, and one it stored by name through the
|
|
74
|
+
* assembly name.
|
|
75
|
+
*/
|
|
76
|
+
export function scanPackages(rootDir, { exclude = DEFAULT_EXCLUDED_DIRS } = {}) {
|
|
77
|
+
const excluded = new Set(exclude);
|
|
78
|
+
const files = walkFiles(rootDir, excluded);
|
|
79
|
+
|
|
80
|
+
const byDir = new Map();
|
|
81
|
+
const byName = new Map();
|
|
82
|
+
const byGuid = new Map();
|
|
83
|
+
|
|
84
|
+
for (const asmdefPath of files.filter((f) => f.endsWith('.asmdef'))) {
|
|
85
|
+
let json;
|
|
86
|
+
try {
|
|
87
|
+
json = JSON.parse(readFileSync(asmdefPath, 'utf-8'));
|
|
88
|
+
} catch {
|
|
89
|
+
// A malformed asmdef is still a package boundary; fall back to its folder.
|
|
90
|
+
json = {};
|
|
91
|
+
}
|
|
92
|
+
const dir = dirname(asmdefPath);
|
|
93
|
+
const name = typeof json.name === 'string' && json.name ? json.name : basename(dir);
|
|
94
|
+
const declaredReferences = Array.isArray(json.references)
|
|
95
|
+
? json.references.filter((r) => typeof r === 'string')
|
|
96
|
+
: [];
|
|
97
|
+
const precompiled = Array.isArray(json.precompiledReferences)
|
|
98
|
+
? json.precompiledReferences.filter((r) => typeof r === 'string')
|
|
99
|
+
: [];
|
|
100
|
+
const optional = Array.isArray(json.optionalUnityReferences) ? json.optionalUnityReferences : [];
|
|
101
|
+
const pkg = {
|
|
102
|
+
id: toPosix(name),
|
|
103
|
+
name,
|
|
104
|
+
dir,
|
|
105
|
+
asmdefPath,
|
|
106
|
+
// A Unity test assembly, recognised by what it references rather than by
|
|
107
|
+
// its name. Projects name test assemblies inconsistently, but a test
|
|
108
|
+
// assembly must reference the TestRunner or nunit to run under the Unity
|
|
109
|
+
// Test Framework, so that is the reliable signal.
|
|
110
|
+
isTest: optional.includes('TestAssemblies')
|
|
111
|
+
|| declaredReferences.includes('UnityEngine.TestRunner')
|
|
112
|
+
|| declaredReferences.includes('UnityEditor.TestRunner')
|
|
113
|
+
|| precompiled.includes('nunit.framework.dll'),
|
|
114
|
+
rawReferences: declaredReferences,
|
|
115
|
+
references: [],
|
|
116
|
+
};
|
|
117
|
+
byDir.set(dir, pkg);
|
|
118
|
+
byName.set(name, pkg);
|
|
119
|
+
const guid = readMetaGuid(asmdefPath);
|
|
120
|
+
if (guid) byGuid.set(guid, pkg);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (byDir.size === 0) return fallbackPackages(rootDir, files);
|
|
124
|
+
|
|
125
|
+
for (const pkg of byDir.values()) {
|
|
126
|
+
const resolved = [];
|
|
127
|
+
for (const ref of pkg.rawReferences) {
|
|
128
|
+
const target = ref.startsWith('GUID:')
|
|
129
|
+
? byGuid.get(ref.slice(5).toLowerCase())
|
|
130
|
+
: byName.get(ref);
|
|
131
|
+
if (target && target.dir !== pkg.dir) resolved.push(target.id);
|
|
132
|
+
}
|
|
133
|
+
pkg.references = [...new Set(resolved)].sort();
|
|
134
|
+
delete pkg.rawReferences;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return [...byDir.values()].sort((a, b) => a.id.localeCompare(b.id));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** No `.asmdef` anywhere: each top-level folder that holds C# becomes a package. */
|
|
141
|
+
function fallbackPackages(rootDir, files) {
|
|
142
|
+
const groups = new Map();
|
|
143
|
+
for (const file of files.filter((f) => f.endsWith('.cs'))) {
|
|
144
|
+
const parts = toPosix(relative(rootDir, file)).split('/');
|
|
145
|
+
const key = parts.length > 1 ? parts[0] : '(root)';
|
|
146
|
+
if (!groups.has(key)) groups.set(key, []);
|
|
147
|
+
groups.get(key).push(file);
|
|
148
|
+
}
|
|
149
|
+
return [...groups.keys()].sort().map((name) => ({
|
|
150
|
+
id: toPosix(name),
|
|
151
|
+
name,
|
|
152
|
+
dir: name === '(root)' ? rootDir : join(rootDir, name),
|
|
153
|
+
asmdefPath: null,
|
|
154
|
+
isTest: false,
|
|
155
|
+
references: [],
|
|
156
|
+
}));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The `.cs` files that belong to a package: those under its directory and not
|
|
161
|
+
* under a deeper package's directory. Unity nests assemblies, so a file's
|
|
162
|
+
* owning assembly is its nearest ancestor `.asmdef`.
|
|
163
|
+
*/
|
|
164
|
+
function filesForPackage(pkg, packages, excluded) {
|
|
165
|
+
const nested = packages
|
|
166
|
+
.filter((p) => p.dir !== pkg.dir && isInside(p.dir, pkg.dir))
|
|
167
|
+
.map((p) => p.dir);
|
|
168
|
+
return walkFiles(pkg.dir, excluded)
|
|
169
|
+
.filter((f) => f.endsWith('.cs'))
|
|
170
|
+
.filter((f) => !nested.some((dir) => isInside(dirname(f), dir)))
|
|
171
|
+
.sort();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** The file's declared namespace, or null when it declares none. */
|
|
175
|
+
function readNamespace(file) {
|
|
176
|
+
let text;
|
|
177
|
+
try {
|
|
178
|
+
text = readFileSync(file, 'utf-8');
|
|
179
|
+
} catch {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
const m = /^\s*namespace\s+([A-Za-z_][\w.]*)/m.exec(text);
|
|
183
|
+
return m ? m[1] : null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Group a package's files into components by namespace, falling back to the
|
|
188
|
+
* containing folder for files that declare none. Sorted by id for determinism.
|
|
189
|
+
*/
|
|
190
|
+
export function scanComponents(pkg, { packages = [pkg], exclude = DEFAULT_EXCLUDED_DIRS } = {}) {
|
|
191
|
+
const excluded = new Set(exclude);
|
|
192
|
+
const groups = new Map();
|
|
193
|
+
for (const file of filesForPackage(pkg, packages, excluded)) {
|
|
194
|
+
const namespace = readNamespace(file);
|
|
195
|
+
const key = namespace || toPosix(relative(pkg.dir, dirname(file))) || '(root)';
|
|
196
|
+
if (!groups.has(key)) groups.set(key, { namespace, files: [] });
|
|
197
|
+
groups.get(key).files.push(file);
|
|
198
|
+
}
|
|
199
|
+
return [...groups.entries()]
|
|
200
|
+
.map(([key, group]) => ({
|
|
201
|
+
id: toPosix(key),
|
|
202
|
+
name: key,
|
|
203
|
+
namespace: group.namespace,
|
|
204
|
+
files: group.files.sort(),
|
|
205
|
+
}))
|
|
206
|
+
.sort((a, b) => a.id.localeCompare(b.id));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Best-effort component dependencies from `using` directives.
|
|
211
|
+
*
|
|
212
|
+
* A `using` names a namespace, which is matched to the longest component
|
|
213
|
+
* namespace that is a PREFIX of it: `using A.B.C` maps to a component `A.B` (or
|
|
214
|
+
* `A.B.C` when that exists), because a namespace nested deeper is a different
|
|
215
|
+
* component. The reverse match was a bug the real-project test caught: with both
|
|
216
|
+
* `Dolven.Game.Rendering` and `Dolven.Game.Rendering.Crowd` present, a plain
|
|
217
|
+
* `using Dolven.Game.Rendering;` was attributed to the longer `…Crowd` sibling.
|
|
218
|
+
*
|
|
219
|
+
* This is an approximation by design: it reads declarations, not the compiled
|
|
220
|
+
* type graph, and it says so in the docs.
|
|
221
|
+
*/
|
|
222
|
+
export function componentEdges(components) {
|
|
223
|
+
const byNamespace = new Map(components.filter((c) => c.namespace).map((c) => [c.namespace, c.id]));
|
|
224
|
+
const namespaces = [...byNamespace.keys()];
|
|
225
|
+
|
|
226
|
+
const resolve = (ns) => {
|
|
227
|
+
let best = null;
|
|
228
|
+
for (const candidate of namespaces) {
|
|
229
|
+
if (ns === candidate || ns.startsWith(`${candidate}.`)) {
|
|
230
|
+
if (!best || candidate.length > best.length) best = candidate;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return best ? byNamespace.get(best) : null;
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
const edges = new Map();
|
|
237
|
+
const usingRe = /^\s*using\s+(?:static\s+)?([A-Za-z_][\w.]*)\s*;/gm;
|
|
238
|
+
for (const component of components) {
|
|
239
|
+
for (const file of component.files) {
|
|
240
|
+
let text;
|
|
241
|
+
try {
|
|
242
|
+
text = readFileSync(file, 'utf-8');
|
|
243
|
+
} catch {
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
usingRe.lastIndex = 0;
|
|
247
|
+
let m;
|
|
248
|
+
while ((m = usingRe.exec(text)) !== null) {
|
|
249
|
+
const target = resolve(m[1]);
|
|
250
|
+
if (target && target !== component.id) {
|
|
251
|
+
edges.set(`${component.id}\u0000${target}`, { from: component.id, to: target, kind: 'uses' });
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return [...edges.values()].sort((a, b) => `${a.from}\u0000${a.to}`.localeCompare(`${b.from}\u0000${b.to}`));
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export { toPosix };
|