@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.
- package/CHANGELOG.md +52 -0
- package/api-surface.md +2 -0
- package/docs/changelog.md +196 -0
- package/docs/commands.md +32 -0
- package/docs/deployment.md +97 -0
- package/docs/flow/index.md +1 -1
- package/docs/flow/layouts.md +64 -0
- package/docs/testing/index.md +26 -0
- package/package.json +3 -3
- package/src/config.ts +8 -0
- package/src/install/ArchInstallCommand.ts +50 -5
- package/src/install/guidelines.ts +84 -0
- package/src/install/shape.ts +177 -0
- package/src/install/skills.ts +271 -0
- package/src/probe/topics.ts +40 -0
- package/src/provider/ArchProvider.ts +148 -0
package/src/probe/topics.ts
CHANGED
|
@@ -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
|
+
}
|