cadet-agent 0.56.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.
@@ -35,6 +35,10 @@ export const COMMANDS = {
35
35
  unattended: true,
36
36
  },
37
37
 
38
+ 'state brief': {
39
+ mutates: false,
40
+ summary: 'Print the tier-0 summary of state.json: phase, active work item, every gate, and what compaction would archive.',
41
+ },
38
42
  'state validate': {
39
43
  mutates: false,
40
44
  summary: 'Validate .cadet/state.json against the current schema.',
@@ -141,7 +145,7 @@ export const COMMANDS = {
141
145
  'harness context plan': {
142
146
  mutates: true,
143
147
  summary: 'Write the context plan for the current phase: what must be loaded, and why.',
144
- writes: ['.cadet/context/plan.json'],
148
+ writes: ['.cadet/context/plan.json', '.cadet/context/state-brief.md'],
145
149
  unattended: true,
146
150
  },
147
151
  'harness context record': {
@@ -165,13 +169,13 @@ export const COMMANDS = {
165
169
  writes: ['.cadet/runs/**', '.cadet/state.json'],
166
170
  unattended: true,
167
171
  },
168
- 'harness acceptance-form': {
172
+ 'harness verify-play': {
169
173
  mutates: true,
170
- summary: 'Write a human-acceptance form for an epic, pre-filled from state.',
171
- // It writes exactly one file, and only when that file does not exist: a form is a
172
- // person's worksheet once they have touched it, and a regeneration would discard
173
- // what they wrote. It never writes state — recording the gate is `harness confirm`.
174
- writes: ['.cadet/agent/project-plans/**'],
174
+ summary: 'Verify a story\'s Play: declaration; record the gate for a deferral, refuse it for a playable story.',
175
+ // Same posture as verify-reachability: it records evidence for its gate, so it writes
176
+ // the ledger and state. It writes no artifact of its own — the declaration lives in the
177
+ // story, and a playable story's record comes from the person's own answer instead.
178
+ writes: ['.cadet/runs/**', '.cadet/state.json'],
175
179
  unattended: true,
176
180
  },
177
181
  'harness report': {
@@ -206,6 +210,23 @@ export const COMMANDS = {
206
210
  mutates: false,
207
211
  summary: 'Report available CLI/Unity/MCP/hook/token/cost telemetry.',
208
212
  },
213
+ 'harness diagram': {
214
+ mutates: true,
215
+ summary: 'Render UML component diagrams per package, coloured by workflow progress.',
216
+ // The tree is fully generated: one .noml + .svg per (package × epic), plus an
217
+ // index.html per package and at the root. `--out` may redirect it, but the
218
+ // default is the declared path, which is what the guard keys on.
219
+ writes: ['.cadet/diagrams/**'],
220
+ unattended: true,
221
+ },
222
+ 'harness verify-diagrams': {
223
+ mutates: false,
224
+ summary: 'Check the committed diagram tree against a freshly computed model.',
225
+ // Read-only by construction: it regenerates the model in memory and compares
226
+ // it with the files on disk. It does not write the tree — a check that
227
+ // repaired the thing it checks would prove nothing — and `harness reconcile`
228
+ // calls the same function, so the two cannot disagree.
229
+ },
209
230
  'harness cleanup': {
210
231
  mutates: true,
211
232
  summary: 'Apply the retention policy to .cadet/runs/, deleting run records.',
@@ -229,6 +250,63 @@ export function readOnlyCommands() {
229
250
  return Object.entries(COMMANDS).filter(([, c]) => !c.mutates).map(([k]) => k);
230
251
  }
231
252
 
253
+ /** Repository-relative form of a path: forward slashes, no leading `./`. */
254
+ function normaliseRepoPath(path) {
255
+ return String(path).replace(/\\/g, '/').replace(/^\.\//, '');
256
+ }
257
+
258
+ /**
259
+ * Does one of a command's `writes` patterns cover a path?
260
+ *
261
+ * The shipped table uses exactly three shapes, and the matcher supports those and no more:
262
+ * an exact path (`AGENTS.md`), a directory subtree (`<dir>/**`), and a root-level suffix
263
+ * wildcard (`*.coverage.json`). `**` crosses directory boundaries and `*` does not — the
264
+ * distinction a shell makes, and the reason a `*` that crossed directories would refuse a
265
+ * file the command never touches.
266
+ *
267
+ * Wildcards are replaced FIRST, with placeholders that survive the escape pass. Doing it the
268
+ * other way round turns `**` into a literal `\*\*` and every declaration stops matching — the
269
+ * defect this function shipped with for one run, caught by the generated test below it.
270
+ *
271
+ * Absolute paths are deliberately not translated: `--files` carries repository-relative paths
272
+ * by contract, so an absolute path is a different mistake and not this function's to guess at.
273
+ */
274
+ function writePatternMatches(pattern, path) {
275
+ const escaped = normaliseRepoPath(pattern)
276
+ .replace(/\*\*/g, '\u0000')
277
+ .replace(/\*/g, '\u0001')
278
+ .replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
279
+ .replace(/\u0000/g, '.*')
280
+ .replace(/\u0001/g, '[^/]*');
281
+
282
+ return new RegExp(`^${escaped}$`).test(normaliseRepoPath(path));
283
+ }
284
+
285
+ /**
286
+ * The `--files` entries a command declares it WRITES, and which must therefore never be
287
+ * bound as its evidence.
288
+ *
289
+ * Why this exists: a record's `inputTreeHash` covers the files it binds, and the command
290
+ * then writes its own outputs — the run ledger and `.cadet/state.json`. Binding one of those
291
+ * makes the record stale at the instant it is created, because the write it records changes
292
+ * a file the hash covers. Measured on a consumer on 2026-10-01: a `storyTrackingUpdated`
293
+ * record that bound the story, the epic and `.cadet/state.json` was refused by the very next
294
+ * `state transition --dry-run` with "input tree hash changed since the evidence was
295
+ * recorded", and the gate had to be re-recorded twice before the boundary was allowed.
296
+ *
297
+ * The answer is a refusal rather than a filter. Filtering would leave the record claiming a
298
+ * coverage it does not have, and the caller asked for a binding that provably cannot hold —
299
+ * the same choice `AGENT_OWNED_GATES` and the swallowed `--command` flag both resolved the
300
+ * loud way. It is derived from the registry rather than hardcoded, so a new command's
301
+ * outputs are covered the moment it is registered.
302
+ */
303
+ export function selfBoundFiles(commandKey, files = []) {
304
+ const writes = COMMANDS[commandKey]?.writes || [];
305
+ if (writes.length === 0 || !Array.isArray(files) || files.length === 0) return [];
306
+
307
+ return files.filter((file) => writes.some((pattern) => writePatternMatches(pattern, file)));
308
+ }
309
+
232
310
  /**
233
311
  * Resolve the command key for a parsed invocation.
234
312
  *
@@ -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);
@@ -66,8 +94,8 @@ export const PHASE_SKILL = Object.freeze({
66
94
  closed: 'Resume.md',
67
95
  });
68
96
 
69
- /** The runtime contract every phase reads. */
70
- const HARNESS_CONTRACT = '.cadet/agent/core/Harness.md';
97
+ /** The lean runtime contract every phase reads. */
98
+ const HARNESS_CONTRACT = '.cadet/agent/core/HarnessRuntime.md';
71
99
  const KICKOFF = '.cadet/agent/core/cadet-agent.md';
72
100
 
73
101
  const normalise = (path) => String(path).replace(/\\/g, '/').replace(/^\.\//, '');
@@ -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,22 +202,31 @@ 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,
102
215
  }));
103
216
  required.push(entry(HARNESS_CONTRACT, {
104
- tier: 'tier0', reason: 'the runtime contract for gates, evidence and budgets', authority: 'framework', required: true,
217
+ tier: 'tier0', reason: 'the lean runtime contract for gates, evidence and budgets', authority: 'framework', required: true,
218
+ }));
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,
105
229
  }));
106
- for (const ref of ['.cadet/harness.json', '.cadet/state.json']) {
107
- required.push(entry(ref, {
108
- tier: 'tier0', reason: 'the active policy and the session state the phase reads', authority: 'session', required: true,
109
- }));
110
- }
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
- required.push(entry(join('.cadet/agent/project-plans', epicId, storyId), {
127
- tier: 'tier1', reason: 'the active story: its status, its acceptance criteria, its reachability', authority: 'session', required: true,
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('.cadet/agent/project-plans', epicId, 'Epic.md'), {
132
- tier: 'tier1', reason: 'the epic this story belongs to', authority: 'repository', required: false,
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 };