peer-ai 1.0.0-next.0

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.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +403 -0
  3. package/dist/assess.d.ts +102 -0
  4. package/dist/assess.js +545 -0
  5. package/dist/check.d.ts +30 -0
  6. package/dist/check.js +253 -0
  7. package/dist/checks.d.ts +15 -0
  8. package/dist/checks.js +15 -0
  9. package/dist/cli.d.ts +10 -0
  10. package/dist/cli.js +231 -0
  11. package/dist/detect.d.ts +42 -0
  12. package/dist/detect.js +459 -0
  13. package/dist/doctor.d.ts +36 -0
  14. package/dist/doctor.js +297 -0
  15. package/dist/document.d.ts +30 -0
  16. package/dist/document.js +72 -0
  17. package/dist/enforcers.d.ts +6 -0
  18. package/dist/enforcers.js +307 -0
  19. package/dist/feedback.d.ts +67 -0
  20. package/dist/feedback.js +209 -0
  21. package/dist/files.d.ts +1 -0
  22. package/dist/files.js +72 -0
  23. package/dist/init.d.ts +31 -0
  24. package/dist/init.js +158 -0
  25. package/dist/mcp.d.ts +13 -0
  26. package/dist/mcp.js +247 -0
  27. package/dist/package-info.d.ts +4 -0
  28. package/dist/package-info.js +6 -0
  29. package/dist/pipeline.d.ts +82 -0
  30. package/dist/pipeline.js +265 -0
  31. package/dist/prompter.d.ts +23 -0
  32. package/dist/prompter.js +56 -0
  33. package/dist/render.d.ts +58 -0
  34. package/dist/render.js +557 -0
  35. package/dist/report.d.ts +3 -0
  36. package/dist/report.js +93 -0
  37. package/dist/routing.d.ts +24 -0
  38. package/dist/routing.js +121 -0
  39. package/dist/ruff.d.ts +19 -0
  40. package/dist/ruff.js +64 -0
  41. package/dist/standards.d.ts +46 -0
  42. package/dist/standards.js +130 -0
  43. package/dist/state.d.ts +22 -0
  44. package/dist/state.js +56 -0
  45. package/dist/test-helpers.d.ts +16 -0
  46. package/dist/test-helpers.js +62 -0
  47. package/dist/work.d.ts +147 -0
  48. package/dist/work.js +357 -0
  49. package/package.json +45 -0
package/dist/assess.js ADDED
@@ -0,0 +1,545 @@
1
+ // peer-ai assess: reads a repository and records, for each item on the project map, whether
2
+ // it is present, partial, missing or not applicable, with the files that prove it. Items found
3
+ // by reading code rather than a document are marked inferred, for a person to confirm.
4
+ // Gaps are ranked by the project's stage: a prototype needs almost nothing, production a lot.
5
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
6
+ import { dirname, join } from "node:path";
7
+ import { MAP_ITEM_IDS, TRAITS, readConfig, validateMap, } from "peer-ai-workflow";
8
+ import { PROFILES, profilesForPart } from "peer-ai-standards";
9
+ import { CONFIG_FILE, detect, detectDelivery, detectTracks } from "./detect.js";
10
+ import { listRepoFiles } from "./files.js";
11
+ export const MAP_FILE = ".peer-ai/map.json";
12
+ export const MAP_SCHEMA_URL = "https://raw.githubusercontent.com/AbuMahir980/peer-ai/main/packages/workflow/schemas/map.schema.json";
13
+ /** What each stage needs. Items not applicable to a project are never required. */
14
+ export const REQUIRED = {
15
+ prototype: [],
16
+ mvp: ["requirements", "api-contract", "ci", "tests", "threat-model", "data-inventory", "docs"],
17
+ production: [
18
+ "requirements",
19
+ "api-contract",
20
+ "ci",
21
+ "tests",
22
+ "threat-model",
23
+ "data-inventory",
24
+ "docs",
25
+ "architecture",
26
+ "specs",
27
+ "data-model",
28
+ "dpia",
29
+ "design",
30
+ "standards",
31
+ "environments",
32
+ "infrastructure",
33
+ "observability",
34
+ "slos",
35
+ "runbooks",
36
+ "load-testing",
37
+ ],
38
+ };
39
+ export const NEXT_STAGE = {
40
+ prototype: "mvp",
41
+ mvp: "production",
42
+ production: undefined,
43
+ };
44
+ export const UI_KINDS = ["web", "mobile", "desktop", "extension"];
45
+ export const MANIFEST = /(^|\/)(package\.json|requirements[^/]*\.txt|pyproject\.toml|pubspec\.yaml|Gemfile|go\.mod|Cargo\.toml|composer\.json|build\.gradle(\.kts)?|pom\.xml|[^/]+\.csproj|Directory\.Packages\.props|Package\.swift|Podfile|mix\.exs)$/;
46
+ // Where a project describes its data: migrations and schema files, and the model or entity
47
+ // classes that ORMs in any language read, such as models/order.py, app/Models/Order.php,
48
+ // Entities/Order.cs or order.entity.ts.
49
+ export const SCHEMA_FILE = /(^|\/)(migrations?|migrate|alembic|prisma|drizzle|supabase|db|database)\/.*\.(sql|prisma|py|ts|js|rb|php|cs|go|java|kt|rs|exs?)$|\.sql$|schema\.prisma$|(^|\/)(models?|entities|entity|schemas?)\/[^/]+\.(py|ts|js|rb|php|cs|go|java|kt|rs|swift|ex)$|(^|\/)models?\.(py|ts|js|go|rs)$|\.(entity|model|schema)\.[cm]?[jt]s$/i;
50
+ // Field names are matched as whole words, where an underscore also separates words, so a prefixed
51
+ // name such as recipient_phone counts but iphone doesn't. camelCase names are split into words
52
+ // first (see snakeCase), so recipientPhone and phoneNumber count too.
53
+ export const PERSONAL_FIELD = /(?<![a-z0-9])(email|phone(?:_number)?|mobile_number|date_of_birth|dob|birth_?date|home_address|address(?:_line_?\d)?|post_?code|zip_?code|bvn|nin|ssn|national_id|passport(?:_number)?|ip_address|latitude|longitude)(?![a-z0-9])/gi;
54
+ const CARD_FIELD = /(?<![a-z0-9])(card_?number|card_no|pan|cvv2?|cvc|card_expiry)(?![a-z0-9])/gi;
55
+ const PAYMENT_PROVIDER = /\b(stripe|paystack|flutterwave|braintree|adyen|razorpay|paypal|squareup|mollie|monnify|interswitch)\b/gi;
56
+ const OBSERVABILITY = /(@sentry\/[\w-]+|\bsentry[\w-]*|@opentelemetry\/[\w-]+|\bopentelemetry[\w-]*|\bdd-trace\b|\bdatadog\b|\bnewrelic\b|\bprom-client\b|\bprometheus[\w-]*|\bpino\b|\bwinston\b|\bstructlog\b|\bloguru\b|\blogfire\b|go\.uber\.org\/zap|\bzerolog\b|\bmicrometer[\w-]*|\bserilog[\w.]*|\bmonolog\b|\blograge\b|\btracing-subscriber\b)/gi;
57
+ const LOCAL_SCHEMA_FILE = /(^|\/)(db|database|schema|storage|store|models?)\.(ts|tsx|js|mjs)$/i;
58
+ const LOCAL_SCHEMA = /\.stores\(\s*\{|indexedDB\.open\(|\bopenDB\(|\bappSchema\(|CREATE TABLE/;
59
+ export const INFRASTRUCTURE_AS_CODE = /\.tf$|\.tf\.json$|\.bicep$|\.cfn\.(ya?ml|json)$|(^|\/)(Pulumi\.ya?ml|cdk\.json|Chart\.yaml|kustomization\.ya?ml|serverless\.ya?ml|samconfig\.toml)$/;
60
+ // Libraries that suggest a trait, found by name in dependency files. A suggestion is only that:
61
+ // the evidence is shown, and a person decides.
62
+ export const TRAIT_LIBRARIES = [
63
+ [
64
+ "offline",
65
+ /(workbox[\w-]*|vite-plugin-pwa|next-pwa|@serwist\/[\w-]+|\bdexie\b|\brxdb\b|\bpouchdb[\w-]*|@nozbe\/watermelondb|\bsqflite\b|\bdrift\b|androidx\.room)/gi,
66
+ ],
67
+ [
68
+ "real-time",
69
+ /(@nestjs\/websockets|socket\.io[\w-]*|\bwebsockets?\b|gorilla\/websocket|\bpusher[\w-]*|\bably\b|@supabase\/realtime-js|\bsignalr\b|\bactioncable\b|\bcentrifuge[\w-]*)/gi,
70
+ ],
71
+ [
72
+ "uploads",
73
+ /(\bmulter\b|\bbusboy\b|\bformidable\b|@fastify\/multipart|python-multipart|\buploadthing\b|react-dropzone|@uppy\/[\w-]+|\bfilepond\b|expo-image-picker|expo-document-picker|\bimage_picker\b|\bfile_picker\b|\bcarrierwave\b|\bshrine\b|\bcloudinary\b)/gi,
74
+ ],
75
+ [
76
+ "ai-features",
77
+ /(@anthropic-ai\/sdk|\banthropic\b|@ai-sdk\/[\w-]+|\bopenai\b|@langchain\/[\w-]+|\blangchain[\w-]*|llama[-_]?index|@google\/(?:generative-ai|genai)|google-(?:generativeai|genai)|@mistralai\/[\w-]+|\bmistralai\b|groq-sdk|\bollama\b|\breplicate\b|\bhuggingface[\w-]*|semantickernel|spring-ai[\w-]*|langchain4j)/gi,
78
+ ],
79
+ ];
80
+ const SERVICE_WORKER = /(^|\/)(service-worker|sw)\.[cm]?[jt]s$/;
81
+ const TENANT_FIELD = /(?<![a-z0-9])(tenant_?id|organi[sz]ation_?id|org_?id|workspace_?id)(?![a-z0-9])/gi;
82
+ const SAFETY_FIELD = /(?<![a-z0-9])(allerg(?:en|ens|y|ies)|medications?|dosage|contraindications?|blood_type|diagnos[ie]s)(?![a-z0-9])/gi;
83
+ // A copy of the v0 playbook, which 1.0 replaces, recognised by its setup files. Its templates
84
+ // would otherwise read as the project's own requirements, specs and standards.
85
+ export const LEGACY_PLAYBOOK = "peer-ai/";
86
+ export const LEGACY_MARKERS = ["peer-ai/shared/00-setup.md", "peer-ai/phase-config.json"];
87
+ export const TEST_FILE = /(^|\/)(tests?|Tests|__tests__|integration_test|spec|e2e)\/|(^|\/)[^/]+\.Tests?\/|[._-](test|spec)\.[cm]?[jt]sx?$|_test\.(go|dart|py|exs)$|(^|\/)test_[^/]+\.py$|_spec\.rb$|(Tests?|Spec)\.(swift|kt|java|cs|php)$/;
88
+ // A written test strategy: a start on the tests item, never a substitute for tests.
89
+ const TEST_STRATEGY = /(^|\/)docs\/[^/]*test[-_]?strategy[^/]*\.md$/i;
90
+ const clip = (text, max = 200) => (text.length <= max ? text : `${text.slice(0, max - 1)}…`);
91
+ const unique = (values) => [...new Set(values)];
92
+ function matching(ctx, pattern) {
93
+ return ctx.files.filter((file) => pattern.test(file));
94
+ }
95
+ /** Up to five pieces of evidence, preferring the folder when many files share one. */
96
+ function evidence(files, collapseTo) {
97
+ const shown = collapseTo === undefined ? files : files.map((file) => collapseTo.exec(file)?.[0] ?? file);
98
+ return unique(shown).sort().slice(0, 5);
99
+ }
100
+ /**
101
+ * Splits camelCase and PascalCase names into words the way snake_case already is, so phoneNumber
102
+ * reads as phone_Number and IPAddress as IP_Address. A lone leading "i", as in iPhone, stays part
103
+ * of its word.
104
+ */
105
+ export function snakeCase(text) {
106
+ return text.replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2").replace(/(?<=[a-z0-9])(?<!(?:^|[^A-Za-z0-9])i)(?=[A-Z])/g, "_");
107
+ }
108
+ function scan(ctx, files, pattern, prepare = (text) => text) {
109
+ const found = new Map();
110
+ for (const file of files) {
111
+ for (const match of prepare(ctx.read(file)).matchAll(pattern)) {
112
+ const name = match[1]?.toLowerCase() ?? match[0].toLowerCase();
113
+ if (!found.has(name))
114
+ found.set(name, file);
115
+ }
116
+ }
117
+ return [...found].map(([name, file]) => ({ name, file }));
118
+ }
119
+ export function collectSignals(ctx) {
120
+ const schemaFiles = matching(ctx, SCHEMA_FILE);
121
+ const manifests = matching(ctx, MANIFEST);
122
+ return {
123
+ personalData: scan(ctx, schemaFiles, PERSONAL_FIELD, snakeCase),
124
+ cardData: scan(ctx, schemaFiles, CARD_FIELD, snakeCase),
125
+ paymentProviders: scan(ctx, manifests, PAYMENT_PROVIDER).map((finding) => finding.name),
126
+ };
127
+ }
128
+ function trackEvidence(tracks) {
129
+ return tracks.map((track) => track.path ?? ".");
130
+ }
131
+ /**
132
+ * Traits the code suggests, with what suggested each. Traits the config already declares are left
133
+ * out. Several apps on one backend count, even when the backend lives in another repository.
134
+ */
135
+ function suggestTraits(ctx, signals, tracks) {
136
+ const found = new Map();
137
+ const suggest = (trait, evidence) => {
138
+ if (evidence !== undefined && !found.has(trait))
139
+ found.set(trait, evidence);
140
+ };
141
+ const first = (findings) => findings[0] === undefined ? undefined : `${findings[0].name} in ${findings[0].file}`;
142
+ const schemaFiles = matching(ctx, SCHEMA_FILE);
143
+ const manifests = matching(ctx, MANIFEST);
144
+ if (signals.paymentProviders.length > 0)
145
+ suggest("money", `payment provider ${signals.paymentProviders.join(", ")}`);
146
+ suggest("money", first(signals.cardData));
147
+ suggest("safety-critical", first(scan(ctx, schemaFiles, SAFETY_FIELD, snakeCase)));
148
+ const apps = tracks.filter((track) => UI_KINDS.includes(track.kind) && track.status !== "dormant");
149
+ if (apps.length > 1 && tracks.some((track) => track.kind === "backend")) {
150
+ suggest("several-audiences", `${String(apps.length)} apps (${apps.map((track) => track.id).join(", ")}) share a backend`);
151
+ }
152
+ suggest("several-audiences", first(scan(ctx, schemaFiles, TENANT_FIELD, snakeCase)));
153
+ const worker = matching(ctx, SERVICE_WORKER)[0];
154
+ suggest("offline", worker === undefined ? undefined : `a service worker, ${worker}`);
155
+ for (const [trait, pattern] of TRAIT_LIBRARIES)
156
+ suggest(trait, first(scan(ctx, manifests, pattern)));
157
+ const declared = ctx.config?.project.traits ?? [];
158
+ return TRAITS.flatMap((trait) => {
159
+ const evidence = found.get(trait);
160
+ return evidence === undefined || declared.includes(trait) ? [] : [{ trait, evidence }];
161
+ });
162
+ }
163
+ const present = (files) => ({ status: "present", evidence: files });
164
+ const RULES = {
165
+ requirements: (ctx) => {
166
+ const docs = matching(ctx, /(^|\/)(PRODUCT|PRD|REQUIREMENTS|BRIEF)([^a-z/][^/]*)?\.md$|(^|\/)docs\/[^/]*(requirement|product-brief|prd|brief)[^/]*\.md$/i);
167
+ return docs.length > 0 ? present(evidence(docs)) : { status: "missing" };
168
+ },
169
+ architecture: (ctx) => {
170
+ const docs = matching(ctx, /(^|\/)ARCHITECTURE\.md$|(^|\/)docs\/[^/]*architecture[^/]*\.md$|(^|\/)(adr|adrs|decisions)\/[^/]+\.md$/i);
171
+ if (docs.length > 0)
172
+ return present(evidence(docs, /^(.*\/)?(adr|adrs|decisions)\//i));
173
+ if (ctx.tracks.length === 0)
174
+ return { status: "missing" };
175
+ const parts = ctx.tracks.map((track) => `${track.id} (${track.kind})`).join(", ");
176
+ return {
177
+ status: "partial",
178
+ evidence: trackEvidence(ctx.tracks),
179
+ inferred: true,
180
+ note: clip(`No architecture document. From the code: ${String(ctx.tracks.length)} parts: ${parts}.`),
181
+ };
182
+ },
183
+ "threat-model": (ctx) => {
184
+ const docs = matching(ctx, /threat[-_ ]?model/i);
185
+ return docs.length > 0 ? present(evidence(docs)) : { status: "missing" };
186
+ },
187
+ specs: (ctx) => {
188
+ const docs = matching(ctx, /(^|\/)docs\/.*spec[^/]*\.md$|(^|\/)specs?\/[^/]+\.md$|^[^/]*\b(spec|specs|specification)\b[^/]*\.md$/i);
189
+ return docs.length > 0 ? present(evidence(docs)) : { status: "missing" };
190
+ },
191
+ "api-contract": (ctx) => {
192
+ const configured = (ctx.config?.apis ?? [])
193
+ .map((api) => api.contract?.location)
194
+ .filter((location) => location !== undefined && existsSync(join(ctx.root, location)));
195
+ const files = matching(ctx, /(^|\/)(openapi|swagger|asyncapi)[^/]*\.(json|ya?ml)$|\.proto$|\.graphqls?$|(^|\/)docs\/[^/]*api-contract[^/]*\.md$/i);
196
+ const found = unique([...configured, ...files]);
197
+ if (found.length > 0)
198
+ return present(evidence(found));
199
+ const own = ctx.tracks.map((track) => track.id);
200
+ const apis = ctx.config?.apis ?? [];
201
+ const providesApi = ctx.tracks.some((track) => track.kind === "backend") ||
202
+ apis.some((api) => api.providedBy !== undefined && own.includes(api.providedBy));
203
+ if (!providesApi) {
204
+ const statusOf = new Map((ctx.config?.tracks ?? []).map((track) => [track.id, track.status]));
205
+ const providedWhile = (status) => apis
206
+ .filter((api) => api.providedBy !== undefined && statusOf.get(api.providedBy) === status)
207
+ .map((api) => api.id);
208
+ const elsewhere = providedWhile("external");
209
+ const later = providedWhile("dormant");
210
+ const note = elsewhere.length > 0
211
+ ? `Provided by another repository, where its contract lives: ${elsewhere.join(", ")}.`
212
+ : later.length > 0
213
+ ? `Provided by a part that hasn't started yet: ${later.join(", ")}.`
214
+ : "No API is provided by this repository.";
215
+ return { status: "not-applicable", note: clip(note), inferred: true };
216
+ }
217
+ return {
218
+ status: "missing",
219
+ note: "No contract file found. Frameworks such as NestJS, FastAPI and ASP.NET Core can generate one: commit it, or set its location in peer-ai.config.json.",
220
+ };
221
+ },
222
+ "data-model": (ctx) => {
223
+ const files = matching(ctx, /(^|\/)(migrations?|alembic|drizzle)\/|(^|\/)db\/migrate\/|schema\.prisma$|(^|\/)db\/schema\.(rb|sql)$|(^|\/)supabase\/migrations\//i);
224
+ // A written data model counts too, such as one designed before any migration exists.
225
+ const written = matching(ctx, /(^|\/)docs\/[^/]*data[-_]?model[^/]*\.md$/i);
226
+ if (files.length + written.length > 0) {
227
+ return present(evidence([...files, ...written], /^(.*\/)?(migrations?|alembic|drizzle|supabase\/migrations|db\/migrate)\/|^.*schema\.(prisma|rb|sql)$/i));
228
+ }
229
+ // An offline-first or mobile app keeps its database on the device, defined in code rather than
230
+ // in migration files: Dexie or idb for IndexedDB, WatermelonDB, or SQLite tables.
231
+ const onDevice = matching(ctx, LOCAL_SCHEMA_FILE).filter((file) => LOCAL_SCHEMA.test(ctx.read(file)));
232
+ if (onDevice.length > 0) {
233
+ return { ...present(evidence(onDevice)), note: "A database on the device, defined in code." };
234
+ }
235
+ if (ctx.tracks.some((track) => track.kind === "backend"))
236
+ return { status: "missing" };
237
+ return { status: "not-applicable", note: "No database found in this repository.", inferred: true };
238
+ },
239
+ "data-inventory": (ctx, signals) => {
240
+ const configured = ctx.config?.compliance?.dataInventory;
241
+ const docs = matching(ctx, /data[-_ ]?inventory/i);
242
+ if (configured !== undefined && existsSync(join(ctx.root, configured)))
243
+ return present([configured]);
244
+ if (docs.length > 0)
245
+ return present(evidence(docs));
246
+ if (signals.personalData.length === 0) {
247
+ return {
248
+ status: "not-applicable",
249
+ note: "No personal-data fields found in the schema or migrations.",
250
+ inferred: true,
251
+ };
252
+ }
253
+ const names = signals.personalData.map((finding) => finding.name).join(", ");
254
+ return {
255
+ status: "missing",
256
+ note: clip(`Personal data in ${String(signals.personalData.length)} fields (${names}), and no inventory of it.`),
257
+ };
258
+ },
259
+ dpia: (ctx, signals) => {
260
+ const docs = matching(ctx, /dpia|data[-_ ]protection[-_ ]impact|impact[-_ ]assessment/i);
261
+ if (docs.length > 0)
262
+ return present(evidence(docs));
263
+ if (signals.personalData.length === 0) {
264
+ return {
265
+ status: "not-applicable",
266
+ note: "No personal-data fields found in the schema or migrations.",
267
+ inferred: true,
268
+ };
269
+ }
270
+ return { status: "missing" };
271
+ },
272
+ design: (ctx) => {
273
+ const reference = ctx.config?.design?.reference;
274
+ if (reference !== undefined && (/^https?:\/\//.test(reference) || existsSync(join(ctx.root, reference)))) {
275
+ return present([reference]);
276
+ }
277
+ const files = matching(ctx, /(^|\/)(design|designs|design_handoff[^/]*|mockups?|figma)\/|(^|\/)(design-)?tokens\.(json|ts|js|css)$|(^|\/)docs\/[^/]*design[-_]?system[^/]*\.md$/i);
278
+ if (files.length > 0)
279
+ return present(evidence(files, /^(.*\/)?(design|designs|design_handoff[^/]*|mockups?|figma)\//i));
280
+ if (ctx.tracks.some((track) => UI_KINDS.includes(track.kind)))
281
+ return { status: "missing" };
282
+ return { status: "not-applicable", note: "No user interface found.", inferred: true };
283
+ },
284
+ standards: (ctx) => {
285
+ const documents = (ctx.config?.standards?.documents ?? []).map((document) => document.path);
286
+ const written = matching(ctx, /(^|\/)docs\/.*(standard|coding-rules|conventions|style-guide).*\.md$|(^|\/)(STANDARDS|CONVENTIONS|STYLEGUIDE)[^/]*\.md$/i);
287
+ if (documents.length + written.length > 0)
288
+ return present(evidence([...documents, ...written]));
289
+ const linting = matching(ctx, /(^|\/)(eslint\.config\.[cm]?[jt]s|\.eslintrc[^/]*|biome\.jsonc?|ruff\.toml|\.flake8|\.pylintrc|\.golangci\.ya?ml|analysis_options\.yaml|\.rubocop\.yml|detekt\.yml|checkstyle[^/]*\.xml|\.swiftlint\.ya?ml|phpstan\.neon(\.dist)?|\.php-cs-fixer(\.dist)?\.php|clippy\.toml|\.credo\.exs)$/);
290
+ if (linting.length > 0) {
291
+ return {
292
+ status: "partial",
293
+ evidence: evidence(linting),
294
+ note: "Linting is configured, but there is no written standard.",
295
+ };
296
+ }
297
+ return { status: "missing" };
298
+ },
299
+ ci: (ctx) => {
300
+ // A pipeline in the repository counts whatever the config says: the config may predate it.
301
+ const found = detectDelivery(ctx.root);
302
+ if (found !== undefined)
303
+ return present([found.pipeline]);
304
+ const configured = ctx.config?.delivery;
305
+ if (configured?.ci === "existing")
306
+ return present([configured.pipeline ?? CONFIG_FILE]);
307
+ return { status: "missing" };
308
+ },
309
+ environments: (ctx) => {
310
+ if ((ctx.config?.environments ?? []).length > 0)
311
+ return present([CONFIG_FILE]);
312
+ const files = matching(ctx, /(^|\/)\.env\.(staging|production|prod)(\.example)?$|(^|\/)(environments|envs)\/|[._-](staging|production|prod)\.(ya?ml|json|toml|tfvars)$/i);
313
+ if (files.length > 0)
314
+ return present(evidence(files));
315
+ const deployed = ctx.tracks.filter((track) => track.deploy !== undefined);
316
+ if (deployed.length > 0) {
317
+ return {
318
+ status: "partial",
319
+ evidence: trackEvidence(deployed),
320
+ note: "Parts deploy, but no environments are defined.",
321
+ };
322
+ }
323
+ return { status: "missing" };
324
+ },
325
+ tests: (ctx) => {
326
+ const testFiles = matching(ctx, TEST_FILE);
327
+ const strategy = matching(ctx, TEST_STRATEGY);
328
+ const noTests = strategy.length > 0
329
+ ? { status: "partial", evidence: evidence(strategy), note: "A test strategy, but no tests yet." }
330
+ : { status: "missing" };
331
+ const parts = ctx.tracks.filter((track) => track.kind !== "infrastructure" && ["active", "frozen"].includes(track.status));
332
+ if (parts.length === 0)
333
+ return testFiles.length > 0 ? present(evidence(testFiles)) : noTests;
334
+ const inside = (track) => testFiles.filter((file) => track.path === undefined || file.startsWith(`${track.path}/`));
335
+ const tested = parts.filter((track) => inside(track).length > 0);
336
+ if (tested.length === 0)
337
+ return noTests;
338
+ if (tested.length === parts.length)
339
+ return present(trackEvidence(tested));
340
+ const untested = parts.filter((track) => !tested.includes(track)).map((track) => track.id);
341
+ return {
342
+ status: "partial",
343
+ evidence: trackEvidence(tested),
344
+ note: clip(`Tests in ${String(tested.length)} of ${String(parts.length)} parts; none in: ${untested.join(", ")}.`),
345
+ };
346
+ },
347
+ "load-testing": (ctx) => {
348
+ const files = matching(ctx, /(^|\/)(k6|load[-_]?tests?|perf)\/|\.k6\.[jt]s$|(^|\/)locustfile\.py$|artillery[^/]*\.ya?ml$|\.jmx$|k6-summary/i);
349
+ return files.length > 0 ? present(evidence(files)) : { status: "missing" };
350
+ },
351
+ infrastructure: (ctx) => {
352
+ const infra = ctx.tracks.filter((track) => track.kind === "infrastructure");
353
+ if (infra.length > 0)
354
+ return present(trackEvidence(infra));
355
+ const iac = matching(ctx, INFRASTRUCTURE_AS_CODE);
356
+ if (iac.length > 0)
357
+ return present(evidence(iac.map((file) => dirname(file))));
358
+ const deployed = ctx.tracks.filter((track) => track.deploy !== undefined);
359
+ if (deployed.length > 0) {
360
+ const targets = unique(deployed.map((track) => track.deploy)).join(", ");
361
+ return {
362
+ status: "partial",
363
+ evidence: trackEvidence(deployed),
364
+ note: clip(`Deployed through platform configuration (${targets}), with no infrastructure as code.`),
365
+ };
366
+ }
367
+ return { status: "missing" };
368
+ },
369
+ observability: (ctx) => {
370
+ const manifests = matching(ctx, MANIFEST);
371
+ const found = scan(ctx, manifests, OBSERVABILITY);
372
+ if (found.length === 0)
373
+ return { status: "missing" };
374
+ return {
375
+ status: "present",
376
+ evidence: evidence(found.map((finding) => finding.file)),
377
+ note: clip(`Found: ${unique(found.map((finding) => finding.name)).join(", ")}.`),
378
+ };
379
+ },
380
+ slos: (ctx) => {
381
+ const files = matching(ctx, /(^|\/)[^/]*\bslos?\b[^/]*\.(md|ya?ml|json)$|service[-_ ]level/i);
382
+ return files.length > 0 ? present(evidence(files)) : { status: "missing" };
383
+ },
384
+ runbooks: (ctx) => {
385
+ const files = matching(ctx, /runbook/i);
386
+ return files.length > 0 ? present(evidence(files, /^(.*\/)?runbooks?\//i)) : { status: "missing" };
387
+ },
388
+ docs: (ctx) => {
389
+ const readme = matching(ctx, /^README(\.md)?$/i);
390
+ const docs = matching(ctx, /^docs\/.*\.md$/i);
391
+ if (readme.length > 0 && docs.length > 0)
392
+ return present([...readme, "docs/"]);
393
+ if (readme.length > 0)
394
+ return { status: "partial", evidence: readme, note: "A README, but no docs folder." };
395
+ return { status: "missing" };
396
+ },
397
+ };
398
+ function tracksFromConfig(config) {
399
+ return config.tracks.map((track) => ({
400
+ id: track.id,
401
+ kind: track.kind,
402
+ ...(track.path === undefined ? {} : { path: track.path }),
403
+ ...(track.deploy === undefined ? {} : { deploy: track.deploy.target }),
404
+ status: track.status,
405
+ }));
406
+ }
407
+ export function assess(root, config, stage) {
408
+ const cache = new Map();
409
+ const read = (file) => {
410
+ const cached = cache.get(file);
411
+ if (cached !== undefined)
412
+ return cached;
413
+ const full = join(root, file);
414
+ const text = existsSync(full) && statSync(full).size <= 1_000_000 ? readFileSync(full, "utf8") : "";
415
+ cache.set(file, text);
416
+ return text;
417
+ };
418
+ const detected = config === undefined ? detect(root) : undefined;
419
+ const tracks = config === undefined
420
+ ? (detected?.tracks ?? []).map((track) => ({
421
+ id: track.id,
422
+ kind: track.kind,
423
+ ...(track.path === undefined ? {} : { path: track.path }),
424
+ ...(track.deploy === undefined ? {} : { deploy: track.deploy }),
425
+ status: "active",
426
+ }))
427
+ : tracksFromConfig(config);
428
+ const allFiles = listRepoFiles(root);
429
+ const legacyPlaybook = allFiles.some((file) => LEGACY_MARKERS.includes(file));
430
+ const files = legacyPlaybook ? allFiles.filter((file) => !file.startsWith(LEGACY_PLAYBOOK)) : allFiles;
431
+ // Parts in another repository, and parts not started yet, are listed but create no requirements.
432
+ const own = tracks.filter((track) => track.status !== "external" && track.status !== "dormant");
433
+ const ctx = { root, files, tracks: own, config, read };
434
+ const signals = collectSignals(ctx);
435
+ const items = Object.fromEntries(MAP_ITEM_IDS.map((id) => [id, RULES[id](ctx, signals)]));
436
+ return {
437
+ name: config?.project.name ?? detected?.name ?? "",
438
+ stage,
439
+ tracks,
440
+ items,
441
+ signals,
442
+ suggestedTraits: suggestTraits(ctx, signals, tracks),
443
+ suggestedProfiles: suggestProfiles(root, config, detected),
444
+ suggestedStacks: suggestStacks(root, config),
445
+ legacyPlaybook,
446
+ };
447
+ }
448
+ /**
449
+ * Profiles that fit each part's stack, from the config or, where it names none, from detection.
450
+ * Only the most specific fits: a profile another suggested or listed one extends isn't repeated.
451
+ */
452
+ function suggestProfiles(root, config, detected) {
453
+ const listed = config?.standards?.profiles ?? [];
454
+ const found = detected?.tracks ?? detectTracks(root, config?.project.name ?? "");
455
+ const parts = (config?.tracks ?? found)
456
+ .filter((track) => !("status" in track) || track.status !== "external")
457
+ .map((track) => ({
458
+ id: track.id,
459
+ stack: track.stack ?? found.find((other) => (other.path ?? ".") === (track.path ?? "."))?.stack ?? [],
460
+ }));
461
+ const suggestions = new Map();
462
+ for (const part of parts) {
463
+ const covered = new Set(profilesForPart(listed, { stack: part.stack }).map((profile) => profile.id));
464
+ const fits = PROFILES.filter((profile) => profile.stacks.some((tag) => part.stack.includes(tag)) && !covered.has(profile.id));
465
+ // The profiles each fit builds on, without itself: a fit another fit builds on isn't repeated.
466
+ const builtOn = new Set(fits.flatMap((fit) => profilesForPart([fit.id], {})
467
+ .filter((base) => base.id !== fit.id)
468
+ .map((base) => base.id)));
469
+ for (const profile of fits) {
470
+ if (builtOn.has(profile.id) || suggestions.has(profile.id))
471
+ continue;
472
+ const tag = profile.stacks.find((each) => part.stack.includes(each));
473
+ if (tag !== undefined)
474
+ suggestions.set(profile.id, `${part.id} is tagged ${tag}`);
475
+ }
476
+ }
477
+ return [...suggestions].map(([profile, evidence]) => ({ profile, evidence }));
478
+ }
479
+ /** The stack detection finds in each part the config lists without one. */
480
+ function suggestStacks(root, config) {
481
+ if (config === undefined)
482
+ return [];
483
+ const found = detectTracks(root, config.project.name);
484
+ return config.tracks.flatMap((track) => {
485
+ if (track.stack !== undefined || track.status === "external")
486
+ return [];
487
+ const stack = found.find((other) => (other.path ?? ".") === (track.path ?? "."))?.stack ?? [];
488
+ return stack.length === 0 ? [] : [{ track: track.id, stack }];
489
+ });
490
+ }
491
+ export function gaps(assessment, stage) {
492
+ return REQUIRED[stage].filter((id) => ["missing", "partial"].includes(assessment.items[id].status));
493
+ }
494
+ export function toMap(assessment, now) {
495
+ const at = now.toISOString();
496
+ const map = {
497
+ $schema: MAP_SCHEMA_URL,
498
+ version: 1,
499
+ assessedAt: at,
500
+ items: Object.fromEntries(MAP_ITEM_IDS.map((id) => {
501
+ const item = assessment.items[id];
502
+ return [
503
+ id,
504
+ {
505
+ status: item.status,
506
+ ...(item.evidence === undefined ? {} : { evidence: item.evidence }),
507
+ ...(item.inferred === true ? { inferred: true } : {}),
508
+ ...(item.note === undefined ? {} : { note: item.note }),
509
+ checkedAt: at,
510
+ },
511
+ ];
512
+ })),
513
+ };
514
+ const result = validateMap(map);
515
+ if (!result.ok)
516
+ throw new Error(`assess built an invalid project map: ${result.errors.join("; ")}`);
517
+ return result.value;
518
+ }
519
+ /** Reads peer-ai.config.json, following a relative `extends`. Returns errors instead of throwing. */
520
+ export const loadConfig = readConfig;
521
+ export function runAssess(options, out, report) {
522
+ const { config, errors } = loadConfig(options.cwd);
523
+ if (errors !== undefined) {
524
+ out.error(`${CONFIG_FILE} is not valid:`);
525
+ for (const error of errors)
526
+ out.error(` ${error}`);
527
+ return 2;
528
+ }
529
+ const stage = options.target ?? config?.project.stage ?? "mvp";
530
+ const assessment = assess(options.cwd, config, stage);
531
+ const map = toMap(assessment, options.now ?? new Date());
532
+ if (options.json)
533
+ out.log(JSON.stringify(map, null, 2));
534
+ else
535
+ for (const line of report(assessment, stage))
536
+ out.log(line);
537
+ if (!options.dryRun) {
538
+ const path = join(options.cwd, MAP_FILE);
539
+ mkdirSync(dirname(path), { recursive: true });
540
+ writeFileSync(path, `${JSON.stringify(map, null, 2)}\n`);
541
+ if (!options.json)
542
+ out.log(`Wrote ${MAP_FILE}.`);
543
+ }
544
+ return 0;
545
+ }
@@ -0,0 +1,30 @@
1
+ import { type PeerAiConfig, type WorkItem } from "peer-ai-workflow";
2
+ import { type Assessment } from "./assess.ts";
3
+ import { type Check } from "./checks.ts";
4
+ import type { Output, Stage } from "./init.ts";
5
+ export interface Verdict {
6
+ name: string;
7
+ stage: Stage;
8
+ ok: boolean;
9
+ checks: Check[];
10
+ /** What doctor would fail on, which fails the build too (RFC 0007), and a count of its warnings. */
11
+ setup: Check[];
12
+ }
13
+ /**
14
+ * The setup checks doctor makes: every failure, and one line counting the warnings. The skills
15
+ * are left out, since CI never has them.
16
+ */
17
+ export declare function setupChecks(root: string, nodeVersion?: string, today?: Date): Check[];
18
+ /**
19
+ * Whether a work item may be at ship or done. `items` are the project's other work items, for its
20
+ * dependencies (RFC 0005): an item can't ship before the items it depends on have.
21
+ */
22
+ export declare function gateWorkItem(item: WorkItem, config: PeerAiConfig, stage: Stage, assessment: Assessment, items?: WorkItem[]): Check[];
23
+ export declare function evaluate(root: string, config: PeerAiConfig): Verdict;
24
+ export declare function formatVerdict(verdict: Verdict): string[];
25
+ export interface CheckOptions {
26
+ cwd: string;
27
+ json: boolean;
28
+ }
29
+ /** Exit code 0 when it passes, even with warnings; 1 when it fails; 2 without a valid config. */
30
+ export declare function runCheck(options: CheckOptions, out: Output): number;