@zerotal/arch 1.7.5 → 1.8.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.
@@ -304,3 +304,43 @@ export async function installedPackages(root = process.cwd()): Promise<Installed
304
304
  function controllerLabel(rawName: string): string {
305
305
  return rawName.startsWith("FileRoute<") ? "file" : rawName;
306
306
  }
307
+
308
+ /**
309
+ * The `@zerotal/*` packages this app *declares*, with their installed versions.
310
+ *
311
+ * {@link installedPackages} answers "what is on disk here", which is the right
312
+ * answer for a version report and the wrong one for guidance. Two things get into
313
+ * `node_modules/@zerotal` besides an app's own dependencies: transitive ones, and
314
+ * whatever an install layout decides to hoist — and layouts disagree. The same
315
+ * commit of one app resolved seventeen packages on a developer's machine and
316
+ * eleven on its server, because the server hoisted the shared ones to the
317
+ * workspace root instead.
318
+ *
319
+ * That is not a cosmetic difference when the list decides what an agent is told.
320
+ * `@zerotal/queue` arriving as a transitive dependency does not mean the app runs
321
+ * jobs, and a block explaining where jobs live is confidently wrong for an app
322
+ * that has none. It also made the generated file unstable between machines, so a
323
+ * check comparing it against the project could not tell drift from a difference
324
+ * of layout.
325
+ *
326
+ * Direct dependencies only, which is both deterministic and the honest reading of
327
+ * "what this app has": you import what you declare.
328
+ */
329
+ export async function declaredPackages(root = process.cwd()): Promise<InstalledPackage[]> {
330
+ let declared: Set<string>;
331
+ try {
332
+ const manifest = (await Bun.file(`${root}/package.json`).json()) as {
333
+ dependencies?: Record<string, string>;
334
+ devDependencies?: Record<string, string>;
335
+ };
336
+ declared = new Set([
337
+ ...Object.keys(manifest.dependencies ?? {}),
338
+ ...Object.keys(manifest.devDependencies ?? {}),
339
+ ]);
340
+ } catch {
341
+ // No manifest to read — every installed package is as good a guess as any.
342
+ return installedPackages(root);
343
+ }
344
+
345
+ return (await installedPackages(root)).filter((pkg) => declared.has(pkg.name));
346
+ }
@@ -1,5 +1,6 @@
1
1
  import { ServiceProvider } from "@zerotal/core";
2
2
  import type { AppEnvironment, DoctorCheck } from "@zerotal/core";
3
+ import type { ProjectShape } from "../install/shape.ts";
3
4
  import { SERVER_ENTRY_PATH } from "../install/mcpConfig.ts";
4
5
 
5
6
  /**
@@ -96,6 +97,153 @@ export class ArchProvider extends ServiceProvider {
96
97
  }
97
98
  },
98
99
  },
100
+ {
101
+ id: "arch-agents-current",
102
+ label: "Agent instructions",
103
+ run: async () => agentsFileCheck(process.cwd()),
104
+ },
99
105
  ];
100
106
  }
101
107
  }
108
+
109
+ /**
110
+ * Is the generated block still true?
111
+ *
112
+ * It was a description of the framework, which changed about as often as the
113
+ * framework did. It is now also a description of *this project* — which packages
114
+ * are installed, who owns the schema, which strictness flags are on — and every
115
+ * one of those moves without anyone thinking about `AGENTS.md`. Add a migration
116
+ * directory, turn `synchronize` off, install a package, upgrade the framework:
117
+ * the file still reads as current and is quietly describing the app you had.
118
+ *
119
+ * That is worse than having no file. Guidance nobody wrote is obviously absent;
120
+ * guidance that is confidently out of date gets followed.
121
+ *
122
+ * A warning rather than a failure: a stale instruction file misleads a person or
123
+ * an agent, and does not stop the application working. `doctor` earns the right
124
+ * to gate a deploy by failing only for things that would break one.
125
+ *
126
+ * Takes its root rather than reading `process.cwd()`, so it is testable without
127
+ * moving the process into a fixture.
128
+ *
129
+ * @internal
130
+ */
131
+ export async function agentsFileCheck(
132
+ root: string,
133
+ ): Promise<{ status: "ok" | "warn"; message: string; fix?: string }> {
134
+ const file = Bun.file(`${root}/AGENTS.md`);
135
+ if (!(await file.exists())) {
136
+ return {
137
+ status: "warn",
138
+ message:
139
+ "No AGENTS.md, so an agent working here has no instructions and none of the " +
140
+ "project-specific facts the MCP tools cannot infer.",
141
+ fix: "bun zt arch:install",
142
+ };
143
+ }
144
+
145
+ const [{ buildGuidelines }, { detectShape }, { declaredPackages }, markers] = await Promise.all([
146
+ import("../install/guidelines.ts"),
147
+ import("../install/shape.ts"),
148
+ import("../probe/topics.ts"),
149
+ import("../install/markers.ts"),
150
+ ]);
151
+
152
+ const current = _blockOf(await file.text(), markers.BLOCK_START, markers.BLOCK_END);
153
+ if (current === undefined) {
154
+ return {
155
+ status: "warn",
156
+ message:
157
+ "AGENTS.md has no generated block, so nothing here describes the framework or how " +
158
+ "this app is set up.",
159
+ fix: "bun zt arch:install",
160
+ };
161
+ }
162
+
163
+ const packages = (await declaredPackages(root)).map((pkg) => pkg.name);
164
+ const shape = await detectShape(root);
165
+ const expected = buildGuidelines({
166
+ packages,
167
+ serverName: await _serverName(root),
168
+ shape,
169
+ });
170
+
171
+ if (current.trim() !== expected.trim()) {
172
+ return {
173
+ status: "warn",
174
+ message:
175
+ "AGENTS.md describes a different project than this one — its packages, its setup or " +
176
+ "its framework version have moved since it was written.",
177
+ fix: "bun zt arch:update",
178
+ };
179
+ }
180
+
181
+ // Skills rot the same way and are easier to miss: nothing reads them until an
182
+ // agent decides one is relevant, and by then it is being followed.
183
+ const stale = await _staleSkills(root, packages, shape);
184
+ if (stale.length > 0) {
185
+ return {
186
+ status: "warn",
187
+ message: `AGENTS.md is current, but ${stale.length} skill file(s) are not: ${stale.join(", ")}.`,
188
+ fix: "bun zt arch:update",
189
+ };
190
+ }
191
+
192
+ return { status: "ok", message: "AGENTS.md and skills match this project" };
193
+ }
194
+
195
+ /** Skill files that are missing, or generated and no longer what they would be. */
196
+ async function _staleSkills(
197
+ root: string,
198
+ packages: string[],
199
+ shape: ProjectShape,
200
+ ): Promise<string[]> {
201
+ const { selectSkills, renderSkill, skillPaths, SKILL_MARKER } =
202
+ await import("../install/skills.ts");
203
+ const { detectAgents } = await import("../install/detect.ts");
204
+ const { agents } = await detectAgents(root);
205
+
206
+ const stale: string[] = [];
207
+ for (const skill of selectSkills(packages, shape)) {
208
+ for (const path of skillPaths(skill.name, agents)) {
209
+ const file = Bun.file(`${root}/${path}`);
210
+ if (!(await file.exists())) {
211
+ stale.push(path);
212
+ continue;
213
+ }
214
+ const text = await file.text();
215
+ // A file somebody took ownership of is theirs, current or not.
216
+ if (!text.includes(SKILL_MARKER)) continue;
217
+ if (text !== renderSkill(skill, shape)) stale.push(path);
218
+ }
219
+ }
220
+ return stale;
221
+ }
222
+
223
+ /** The generated body, unfenced, or `undefined` when there is no block. */
224
+ function _blockOf(text: string, start: string, end: string): string | undefined {
225
+ const from = text.indexOf(start);
226
+ const to = text.indexOf(end);
227
+ if (from === -1 || to === -1 || to < from) return undefined;
228
+ return text.slice(from + start.length, to);
229
+ }
230
+
231
+ /**
232
+ * The key the server is registered under, read back from `.mcp.json`.
233
+ *
234
+ * The block names it, so comparing against a guessed name would report every
235
+ * project that renamed its server as permanently stale. Read rather than
236
+ * `require`d, which would cache the first answer and miss a rename.
237
+ */
238
+ async function _serverName(root: string): Promise<string> {
239
+ try {
240
+ const document = (await Bun.file(`${root}/.mcp.json`).json()) as {
241
+ mcpServers?: Record<string, unknown>;
242
+ servers?: Record<string, unknown>;
243
+ };
244
+ const names = Object.keys(document.mcpServers ?? document.servers ?? {});
245
+ return names[0] ?? "zerotal";
246
+ } catch {
247
+ return "zerotal";
248
+ }
249
+ }