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.
- package/LICENSE +21 -0
- package/README.md +403 -0
- package/dist/assess.d.ts +102 -0
- package/dist/assess.js +545 -0
- package/dist/check.d.ts +30 -0
- package/dist/check.js +253 -0
- package/dist/checks.d.ts +15 -0
- package/dist/checks.js +15 -0
- package/dist/cli.d.ts +10 -0
- package/dist/cli.js +231 -0
- package/dist/detect.d.ts +42 -0
- package/dist/detect.js +459 -0
- package/dist/doctor.d.ts +36 -0
- package/dist/doctor.js +297 -0
- package/dist/document.d.ts +30 -0
- package/dist/document.js +72 -0
- package/dist/enforcers.d.ts +6 -0
- package/dist/enforcers.js +307 -0
- package/dist/feedback.d.ts +67 -0
- package/dist/feedback.js +209 -0
- package/dist/files.d.ts +1 -0
- package/dist/files.js +72 -0
- package/dist/init.d.ts +31 -0
- package/dist/init.js +158 -0
- package/dist/mcp.d.ts +13 -0
- package/dist/mcp.js +247 -0
- package/dist/package-info.d.ts +4 -0
- package/dist/package-info.js +6 -0
- package/dist/pipeline.d.ts +82 -0
- package/dist/pipeline.js +265 -0
- package/dist/prompter.d.ts +23 -0
- package/dist/prompter.js +56 -0
- package/dist/render.d.ts +58 -0
- package/dist/render.js +557 -0
- package/dist/report.d.ts +3 -0
- package/dist/report.js +93 -0
- package/dist/routing.d.ts +24 -0
- package/dist/routing.js +121 -0
- package/dist/ruff.d.ts +19 -0
- package/dist/ruff.js +64 -0
- package/dist/standards.d.ts +46 -0
- package/dist/standards.js +130 -0
- package/dist/state.d.ts +22 -0
- package/dist/state.js +56 -0
- package/dist/test-helpers.d.ts +16 -0
- package/dist/test-helpers.js +62 -0
- package/dist/work.d.ts +147 -0
- package/dist/work.js +357 -0
- package/package.json +45 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type KnownMapItemId, type PeerAiConfig, type SkillId, type WorkItem } from "peer-ai-workflow";
|
|
2
|
+
import type { Stage } from "./init.ts";
|
|
3
|
+
export interface RequiredReview {
|
|
4
|
+
skill: SkillId;
|
|
5
|
+
reason: string;
|
|
6
|
+
}
|
|
7
|
+
/** The installed name of each gap's skill, for the gaps whose skill exists. */
|
|
8
|
+
export declare function gapSkills(gaps: KnownMapItemId[], available?: readonly SkillId[]): Partial<Record<KnownMapItemId, string>>;
|
|
9
|
+
/**
|
|
10
|
+
* The reviews a change needs, from the files it touched. `read` gives a file's text, for the
|
|
11
|
+
* checks that look inside: personal fields in a schema, an AI library in code.
|
|
12
|
+
*/
|
|
13
|
+
export declare function reviewsFor(files: string[], config: PeerAiConfig, stage: Stage, read: (file: string) => string, available?: readonly SkillId[], item?: {
|
|
14
|
+
acceptance?: string[] | undefined;
|
|
15
|
+
}): RequiredReview[];
|
|
16
|
+
/** Every file the work has touched: committed since it left the base branch, and not yet committed. */
|
|
17
|
+
export declare function changedFiles(root: string): string[];
|
|
18
|
+
/** The required reviews still to do on a work item, by their installed names, for next_work. */
|
|
19
|
+
export declare function reviewsToDo(item: WorkItem): {
|
|
20
|
+
skill: SkillId;
|
|
21
|
+
use: string;
|
|
22
|
+
reason: string;
|
|
23
|
+
done: boolean;
|
|
24
|
+
}[];
|
package/dist/routing.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// Which skill comes next, so nobody has to remember 29 names (RFC 0004, section 8): the skill that
|
|
2
|
+
// fills each gap on the map, and the reviews a change needs, worked out from the files it touched.
|
|
3
|
+
// Only skills that exist are named or required; the rest follow as they're written.
|
|
4
|
+
import { execFileSync } from "node:child_process";
|
|
5
|
+
import { availableSkills, renderedName } from "peer-ai-skills";
|
|
6
|
+
import { MAP_ITEM_SKILLS } from "peer-ai-workflow";
|
|
7
|
+
import { INFRASTRUCTURE_AS_CODE, MANIFEST, PERSONAL_FIELD, SCHEMA_FILE, TEST_FILE, TRAIT_LIBRARIES, UI_KINDS, snakeCase, } from "./assess.js";
|
|
8
|
+
/** The installed name of each gap's skill, for the gaps whose skill exists. */
|
|
9
|
+
export function gapSkills(gaps, available = availableSkills()) {
|
|
10
|
+
const named = {};
|
|
11
|
+
for (const gap of gaps) {
|
|
12
|
+
const skill = (MAP_ITEM_SKILLS[gap] ?? []).find((candidate) => available.includes(candidate));
|
|
13
|
+
if (skill !== undefined)
|
|
14
|
+
named[gap] = renderedName(skill);
|
|
15
|
+
}
|
|
16
|
+
return named;
|
|
17
|
+
}
|
|
18
|
+
const CODE = /\.(ts|tsx|js|jsx|mjs|cjs|py|go|rb|php|cs|java|kt|kts|swift|dart|rs|ex|exs|vue|svelte|astro|scala|sql|c|cc|cpp|h|m|mm)$/;
|
|
19
|
+
const SCREEN = /\.(tsx|jsx|vue|svelte|astro|html|css|scss|dart|swift)$|(^|\/)res\/layout\//;
|
|
20
|
+
const MIGRATION = /(^|\/)(migrations?|migrate|alembic|drizzle)\/|schema\.prisma$|(^|\/)db\/schema\.(rb|sql)$/i;
|
|
21
|
+
const LOCKFILE = /(^|\/)(package-lock\.json|pnpm-lock\.yaml|yarn\.lock|bun\.lockb?|poetry\.lock|uv\.lock|Pipfile\.lock|Cargo\.lock|go\.sum|Gemfile\.lock|composer\.lock|Podfile\.lock|pubspec\.lock|packages\.lock\.json|gradle\.lockfile)$/;
|
|
22
|
+
const DEPLOYMENT = /(^|\/)(Dockerfile|[^/]*\.Dockerfile|docker-compose[^/]*\.ya?ml|compose\.ya?ml|vercel\.json|netlify\.toml|fly\.toml|render\.yaml|app\.yaml|wrangler\.(toml|jsonc?)|serverless\.ya?ml|Procfile)$|(^|\/)\.github\/workflows\/|\.gitlab-ci\.yml$/;
|
|
23
|
+
const DATA_INVENTORY = /data[-_ ]?inventory/i;
|
|
24
|
+
const API_KINDS = ["http", "graphql", "rpc", "websocket", "events"];
|
|
25
|
+
const aiLibraries = TRAIT_LIBRARIES.find(([trait]) => trait === "ai-features")?.[1];
|
|
26
|
+
/** A pattern's test without the state a global pattern keeps between calls. */
|
|
27
|
+
const matches = (pattern, text) => new RegExp(pattern.source, pattern.flags.replace("g", "")).test(text);
|
|
28
|
+
const inTrack = (file, track) => track.path === undefined || file.startsWith(`${track.path}/`);
|
|
29
|
+
/**
|
|
30
|
+
* The reviews a change needs, from the files it touched. `read` gives a file's text, for the
|
|
31
|
+
* checks that look inside: personal fields in a schema, an AI library in code.
|
|
32
|
+
*/
|
|
33
|
+
export function reviewsFor(files, config, stage, read, available = availableSkills(), item = {}) {
|
|
34
|
+
const tracks = config.tracks.filter((track) => track.status !== "external" && track.status !== "dormant");
|
|
35
|
+
const code = files.filter((file) => CODE.test(file));
|
|
36
|
+
const source = code.filter((file) => !TEST_FILE.test(file));
|
|
37
|
+
const found = [];
|
|
38
|
+
const need = (skill, reason, when) => {
|
|
39
|
+
if (when)
|
|
40
|
+
found.push({ skill, reason });
|
|
41
|
+
};
|
|
42
|
+
need("code-review", "it changes code", code.length > 0);
|
|
43
|
+
need("security-review", `it changes code, at the ${stage} stage`, source.length > 0 && stage !== "prototype");
|
|
44
|
+
const screens = files.filter((file) => SCREEN.test(file) && tracks.some((track) => UI_KINDS.includes(track.kind) && inTrack(file, track)));
|
|
45
|
+
need("accessibility-review", "it changes a screen", screens.length > 0);
|
|
46
|
+
need("design-review", "it changes a screen, and the project has a design", screens.length > 0 && config.design !== undefined);
|
|
47
|
+
const contracts = (config.apis ?? []).flatMap((api) => api.contract?.location ?? []);
|
|
48
|
+
const providers = (config.apis ?? [])
|
|
49
|
+
.filter((api) => API_KINDS.includes(api.kind) && api.providedBy !== undefined)
|
|
50
|
+
.map((api) => api.providedBy);
|
|
51
|
+
const providing = tracks.filter((track) => providers.includes(track.id));
|
|
52
|
+
need("contract-check", "it changes an API or its contract", files.some((file) => contracts.includes(file)) ||
|
|
53
|
+
source.some((file) => providing.some((track) => inTrack(file, track))));
|
|
54
|
+
need("data-migration-review", "it changes a migration", files.some((file) => MIGRATION.test(file)));
|
|
55
|
+
need("dependency-review", "it changes a dependency file or lockfile", files.some((file) => MANIFEST.test(file) || LOCKFILE.test(file)));
|
|
56
|
+
need("compliance-review", "it changes where personal data is kept", files.some((file) => DATA_INVENTORY.test(file) || (SCHEMA_FILE.test(file) && matches(PERSONAL_FIELD, snakeCase(read(file))))));
|
|
57
|
+
need("ai-feature-review", "it changes code that calls an AI model", (config.project.traits ?? []).includes("ai-features") &&
|
|
58
|
+
aiLibraries !== undefined &&
|
|
59
|
+
source.some((file) => matches(aiLibraries, read(file))));
|
|
60
|
+
need("infrastructure-review", "it changes infrastructure or deployment", files.some((file) => INFRASTRUCTURE_AS_CODE.test(file) || DEPLOYMENT.test(file)));
|
|
61
|
+
need("qa-acceptance", "its work item has acceptance criteria, which must all hold before it ships", (item.acceptance?.length ?? 0) > 0 && stage !== "prototype" && files.length > 0);
|
|
62
|
+
need("release-readiness", "it's about to ship, at the production stage", stage === "production" && files.length > 0);
|
|
63
|
+
const choices = config.activities?.verify?.reviews;
|
|
64
|
+
for (const skill of choices?.require ?? []) {
|
|
65
|
+
if (!found.some((review) => review.skill === skill))
|
|
66
|
+
found.push({ skill, reason: "the project's config requires it" });
|
|
67
|
+
}
|
|
68
|
+
const skipped = new Set((choices?.skip ?? []).map((choice) => choice.skill));
|
|
69
|
+
return found.filter((review) => available.includes(review.skill) && !skipped.has(review.skill));
|
|
70
|
+
}
|
|
71
|
+
const git = (root, args) => execFileSync("git", args, { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
72
|
+
/** The branch changes are measured against: origin's default branch, or main, or master. */
|
|
73
|
+
function baseCommit(root) {
|
|
74
|
+
const candidates = [];
|
|
75
|
+
try {
|
|
76
|
+
candidates.push(git(root, ["symbolic-ref", "--short", "-q", "refs/remotes/origin/HEAD"]).trim());
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
// No remote default: fall back to the usual names.
|
|
80
|
+
}
|
|
81
|
+
candidates.push("main", "master");
|
|
82
|
+
for (const candidate of candidates.filter((name) => name !== "")) {
|
|
83
|
+
try {
|
|
84
|
+
return git(root, ["merge-base", "HEAD", candidate]).trim();
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
// Not a branch in this repository; try the next.
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
/** Every file the work has touched: committed since it left the base branch, and not yet committed. */
|
|
93
|
+
export function changedFiles(root) {
|
|
94
|
+
const files = new Set();
|
|
95
|
+
try {
|
|
96
|
+
const base = baseCommit(root);
|
|
97
|
+
if (base !== undefined) {
|
|
98
|
+
for (const file of git(root, ["diff", "--name-only", `${base}...HEAD`]).split("\n"))
|
|
99
|
+
if (file !== "")
|
|
100
|
+
files.add(file);
|
|
101
|
+
}
|
|
102
|
+
for (const line of git(root, ["status", "--porcelain", "--untracked-files=all"]).split("\n")) {
|
|
103
|
+
const path = line.slice(3).split(" -> ").at(-1);
|
|
104
|
+
if (path !== undefined && path !== "")
|
|
105
|
+
files.add(path.replace(/^"|"$/g, ""));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
// Not a git repository: nothing to measure.
|
|
110
|
+
}
|
|
111
|
+
return [...files].sort();
|
|
112
|
+
}
|
|
113
|
+
/** The required reviews still to do on a work item, by their installed names, for next_work. */
|
|
114
|
+
export function reviewsToDo(item) {
|
|
115
|
+
const done = new Set((item.reviews ?? []).map((review) => review.skill));
|
|
116
|
+
return (item.requiredReviews ?? []).map((review) => ({
|
|
117
|
+
...review,
|
|
118
|
+
use: renderedName(review.skill),
|
|
119
|
+
done: done.has(review.skill),
|
|
120
|
+
}));
|
|
121
|
+
}
|
package/dist/ruff.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type AppliedRule } from "peer-ai-standards";
|
|
2
|
+
import type { PeerAiConfig } from "peer-ai-workflow";
|
|
3
|
+
export declare const RUFF_FILE = ".peer-ai/enforce/ruff.toml";
|
|
4
|
+
type Table = Record<string, unknown>;
|
|
5
|
+
/**
|
|
6
|
+
* Ruff's lint settings for these rules, with the settings they read merged in. The rules are added
|
|
7
|
+
* with extend-select, which keeps Ruff's defaults and the project's own choices: a select would
|
|
8
|
+
* replace them.
|
|
9
|
+
*/
|
|
10
|
+
export declare function ruffSettings(rules: readonly AppliedRule[]): {
|
|
11
|
+
lint: Table;
|
|
12
|
+
};
|
|
13
|
+
/** The automatic Ruff rules any part of the project gets, leaving out the ones it set aside. */
|
|
14
|
+
export declare function ruffRules(config: PeerAiConfig): AppliedRule[];
|
|
15
|
+
/** Settings as TOML. */
|
|
16
|
+
export declare const toToml: (settings: Table) => string;
|
|
17
|
+
/** The file render writes, or undefined when no part gets a Ruff rule. */
|
|
18
|
+
export declare function ruffFile(config: PeerAiConfig): string | undefined;
|
|
19
|
+
export {};
|
package/dist/ruff.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Ruff's settings for a project's Python profiles (RFC 0006). Ruff reads settings from a file, not
|
|
2
|
+
// a package, so render writes them to .peer-ai/enforce/ruff.toml and the project's own Ruff
|
|
3
|
+
// settings extend it. Every automatic rule Ruff enforces is selected, with the project's values.
|
|
4
|
+
import { profileRulesFor } from "peer-ai-standards";
|
|
5
|
+
import { stringify } from "smol-toml";
|
|
6
|
+
export const RUFF_FILE = ".peer-ai/enforce/ruff.toml";
|
|
7
|
+
/**
|
|
8
|
+
* Ruff's lint settings for these rules, with the settings they read merged in. The rules are added
|
|
9
|
+
* with extend-select, which keeps Ruff's defaults and the project's own choices: a select would
|
|
10
|
+
* replace them.
|
|
11
|
+
*/
|
|
12
|
+
export function ruffSettings(rules) {
|
|
13
|
+
const codes = new Set();
|
|
14
|
+
const lint = {};
|
|
15
|
+
for (const rule of rules) {
|
|
16
|
+
if (rule.check !== "auto" || rule.enforcer?.tool !== "ruff")
|
|
17
|
+
continue;
|
|
18
|
+
codes.add(rule.enforcer.rule);
|
|
19
|
+
for (const [section, settings] of Object.entries(rule.enforcer.settings ?? {})) {
|
|
20
|
+
lint[section] = { ...lint[section], ...settings };
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return { lint: { "extend-select": [...codes].sort(), ...lint } };
|
|
24
|
+
}
|
|
25
|
+
/** The automatic Ruff rules any part of the project gets, leaving out the ones it set aside. */
|
|
26
|
+
export function ruffRules(config) {
|
|
27
|
+
const setAside = new Set((config.standards?.exceptions ?? []).map((exception) => exception.rule));
|
|
28
|
+
const seen = new Map();
|
|
29
|
+
for (const track of config.tracks) {
|
|
30
|
+
if (track.status === "external")
|
|
31
|
+
continue;
|
|
32
|
+
const rules = profileRulesFor({
|
|
33
|
+
listed: config.standards?.profiles ?? [],
|
|
34
|
+
stage: config.project.stage ?? "mvp",
|
|
35
|
+
traits: config.project.traits ?? [],
|
|
36
|
+
overrides: config.standards?.overrides ?? {},
|
|
37
|
+
...(track.stack === undefined ? {} : { stack: track.stack }),
|
|
38
|
+
...(track.architecture === undefined ? {} : { architecture: track.architecture }),
|
|
39
|
+
});
|
|
40
|
+
for (const rule of rules) {
|
|
41
|
+
if (rule.enforcer?.tool === "ruff" && rule.check === "auto" && !setAside.has(rule.id))
|
|
42
|
+
seen.set(rule.id, rule);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return [...seen.values()];
|
|
46
|
+
}
|
|
47
|
+
/** Settings as TOML. */
|
|
48
|
+
export const toToml = (settings) => stringify(settings);
|
|
49
|
+
/** The file render writes, or undefined when no part gets a Ruff rule. */
|
|
50
|
+
export function ruffFile(config) {
|
|
51
|
+
const rules = ruffRules(config);
|
|
52
|
+
if (rules.length === 0)
|
|
53
|
+
return undefined;
|
|
54
|
+
const ids = rules.map((rule) => `${rule.id} ${rule.enforcer?.tool === "ruff" ? rule.enforcer.rule : ""}`);
|
|
55
|
+
return `${[
|
|
56
|
+
"# Generated by peer-ai render from peer-ai.config.json: the Ruff rules of the project's stack profiles.",
|
|
57
|
+
'# Extend it from your own Ruff settings, such as extend = ".peer-ai/enforce/ruff.toml", and add rules',
|
|
58
|
+
"# of your own with extend-select, not select, which would replace these. Change the config, not this",
|
|
59
|
+
"# file: render writes it again.",
|
|
60
|
+
`# ${ids.join(", ")}`,
|
|
61
|
+
"",
|
|
62
|
+
toToml(ruffSettings(rules)),
|
|
63
|
+
].join("\n")}\n`;
|
|
64
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { type Rule, type Value } from "peer-ai-standards";
|
|
2
|
+
import { type PeerAiConfig } from "peer-ai-workflow";
|
|
3
|
+
type ConfigTrack = PeerAiConfig["tracks"][number];
|
|
4
|
+
/**
|
|
5
|
+
* A rule as an agent needs it while editing: what to do, and the question it will be reviewed by.
|
|
6
|
+
* A stack profile's rule also names the core rule it carries out, and its value for this project.
|
|
7
|
+
*/
|
|
8
|
+
export type RuleForFile = Pick<Rule, "id" | "title" | "rule" | "ask" | "check" | "severity"> & {
|
|
9
|
+
carries?: string;
|
|
10
|
+
value?: Value;
|
|
11
|
+
};
|
|
12
|
+
export interface StandardsForFile {
|
|
13
|
+
file: string;
|
|
14
|
+
stage: "prototype" | "mvp" | "production";
|
|
15
|
+
/** Peer AI's rules that apply to this file. */
|
|
16
|
+
peerAiRules: RuleForFile[];
|
|
17
|
+
/** Rules the project has set aside, with its reasons. */
|
|
18
|
+
setAside: {
|
|
19
|
+
rule: string;
|
|
20
|
+
reason: string;
|
|
21
|
+
}[];
|
|
22
|
+
track?: {
|
|
23
|
+
id: string;
|
|
24
|
+
kind: ConfigTrack["kind"];
|
|
25
|
+
path?: string;
|
|
26
|
+
};
|
|
27
|
+
/** Peer AI's core principles apply unless the config turns them off. */
|
|
28
|
+
core: boolean;
|
|
29
|
+
profiles: string[];
|
|
30
|
+
/** The project's own documents that govern this file, in the order the config lists them. */
|
|
31
|
+
documents: {
|
|
32
|
+
path: string;
|
|
33
|
+
role: "standard" | "addendum" | "checklist";
|
|
34
|
+
}[];
|
|
35
|
+
rules: {
|
|
36
|
+
path: string;
|
|
37
|
+
description?: string;
|
|
38
|
+
}[];
|
|
39
|
+
/** Which side wins a conflict between the project's documents and the core principles. */
|
|
40
|
+
precedence: "project" | "core";
|
|
41
|
+
}
|
|
42
|
+
/** The track whose folder holds the file most closely, or the track at the repository root. */
|
|
43
|
+
export declare function trackFor(config: PeerAiConfig, file: string): ConfigTrack | undefined;
|
|
44
|
+
/** Returns undefined for a file outside the project. */
|
|
45
|
+
export declare function standardsFor(config: PeerAiConfig, root: string, file: string): StandardsForFile | undefined;
|
|
46
|
+
export {};
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// Which standards govern a file: Peer AI's rules for the kind of part it belongs to, at the
|
|
2
|
+
// project's stage and with its traits, and the project's own documents and rules. An agent asks
|
|
3
|
+
// for these before editing a file, instead of loading every rule on every turn.
|
|
4
|
+
import { isAbsolute, relative } from "node:path";
|
|
5
|
+
import { PROFILES, profileRulesFor, rulesFor } from "peer-ai-standards";
|
|
6
|
+
import { DOMAIN_IDS } from "peer-ai-workflow";
|
|
7
|
+
const normalise = (path) => path.replace(/^\.\//, "").replace(/\/+$/, "");
|
|
8
|
+
/** The track whose folder holds the file most closely, or the track at the repository root. */
|
|
9
|
+
export function trackFor(config, file) {
|
|
10
|
+
let best;
|
|
11
|
+
let bestLength = -1;
|
|
12
|
+
for (const track of config.tracks) {
|
|
13
|
+
if (track.status === "external")
|
|
14
|
+
continue;
|
|
15
|
+
const path = track.path === undefined ? "" : normalise(track.path);
|
|
16
|
+
const holds = path === "" || file === path || file.startsWith(`${path}/`);
|
|
17
|
+
if (holds && path.length > bestLength) {
|
|
18
|
+
best = track;
|
|
19
|
+
bestLength = path.length;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return best;
|
|
23
|
+
}
|
|
24
|
+
// The domains that matter for every file, and those added by the kind of part it belongs to.
|
|
25
|
+
// Money, safety-critical and AI rules are always considered, and apply only with their traits.
|
|
26
|
+
const EVERY_FILE = [
|
|
27
|
+
"code-quality",
|
|
28
|
+
"architecture",
|
|
29
|
+
"security",
|
|
30
|
+
"privacy-compliance",
|
|
31
|
+
"testing",
|
|
32
|
+
"money",
|
|
33
|
+
"safety-critical",
|
|
34
|
+
"ai-features",
|
|
35
|
+
];
|
|
36
|
+
// Apps call other services and can hold the only copy of a person's data, so reliability counts
|
|
37
|
+
// for them too; its offline and real-time rules apply only with those traits.
|
|
38
|
+
const UI = ["frontend", "design-accessibility", "performance", "reliability"];
|
|
39
|
+
const SERVER = [
|
|
40
|
+
"backend",
|
|
41
|
+
"api-design",
|
|
42
|
+
"data",
|
|
43
|
+
"system-design",
|
|
44
|
+
"performance",
|
|
45
|
+
"reliability",
|
|
46
|
+
"operations",
|
|
47
|
+
];
|
|
48
|
+
const BY_KIND = {
|
|
49
|
+
web: UI,
|
|
50
|
+
desktop: UI,
|
|
51
|
+
extension: UI,
|
|
52
|
+
mobile: [...UI, "mobile"],
|
|
53
|
+
backend: SERVER,
|
|
54
|
+
data: ["data", "performance", "reliability"],
|
|
55
|
+
infrastructure: ["delivery", "operations", "reliability"],
|
|
56
|
+
library: ["api-design"],
|
|
57
|
+
cli: ["api-design", "reliability"],
|
|
58
|
+
};
|
|
59
|
+
/** The domains for a file: every domain when it belongs to no track or to a kind not listed. */
|
|
60
|
+
function domainsFor(track) {
|
|
61
|
+
const extra = track === undefined ? undefined : BY_KIND[track.kind];
|
|
62
|
+
return extra === undefined ? [...DOMAIN_IDS] : [...new Set([...EVERY_FILE, ...extra])];
|
|
63
|
+
}
|
|
64
|
+
const WHOLE_PROJECT = new Set(PROFILES.filter((profile) => profile.stacks.length === 0).map((profile) => profile.id));
|
|
65
|
+
/** Returns undefined for a file outside the project. */
|
|
66
|
+
export function standardsFor(config, root, file) {
|
|
67
|
+
const path = normalise(isAbsolute(file) ? relative(root, file) : file);
|
|
68
|
+
if (path === ".." || path.startsWith("../") || isAbsolute(path))
|
|
69
|
+
return undefined;
|
|
70
|
+
const track = trackFor(config, path);
|
|
71
|
+
const standards = config.standards;
|
|
72
|
+
const documents = (standards?.documents ?? [])
|
|
73
|
+
.filter((doc) => doc.scope === undefined || (track !== undefined && doc.scope.includes(track.id)))
|
|
74
|
+
.map((doc) => ({ path: doc.path, role: doc.role }));
|
|
75
|
+
const stage = config.project.stage ?? "mvp";
|
|
76
|
+
const exceptions = standards?.exceptions ?? [];
|
|
77
|
+
const setAside = new Set(exceptions.map((exception) => exception.rule));
|
|
78
|
+
const traits = config.project.traits ?? [];
|
|
79
|
+
const domains = domainsFor(track);
|
|
80
|
+
const core = rulesFor({ stage, traits, domains }).map(({ id, title, rule, ask, check, severity }) => ({
|
|
81
|
+
id,
|
|
82
|
+
title,
|
|
83
|
+
rule,
|
|
84
|
+
ask,
|
|
85
|
+
check,
|
|
86
|
+
severity,
|
|
87
|
+
}));
|
|
88
|
+
const profiled = profileRulesFor({
|
|
89
|
+
listed: standards?.profiles ?? [],
|
|
90
|
+
stage,
|
|
91
|
+
traits,
|
|
92
|
+
overrides: standards?.overrides ?? {},
|
|
93
|
+
...(track?.stack === undefined ? {} : { stack: track.stack }),
|
|
94
|
+
...(track?.architecture === undefined ? {} : { architecture: track.architecture }),
|
|
95
|
+
})
|
|
96
|
+
// A profile with no stacks, such as the pipeline's, is about the project as a whole: its rules
|
|
97
|
+
// go with the pipeline's files in .github/, whichever part holds them, and with files outside
|
|
98
|
+
// every part, not with each part's code.
|
|
99
|
+
.filter((rule) => WHOLE_PROJECT.has(rule.profile)
|
|
100
|
+
? track === undefined || path.startsWith(".github/")
|
|
101
|
+
: domains.includes(rule.domain))
|
|
102
|
+
.map(({ id, title, rule, ask, check, severity, carries, value }) => ({
|
|
103
|
+
id,
|
|
104
|
+
title,
|
|
105
|
+
rule,
|
|
106
|
+
ask,
|
|
107
|
+
check,
|
|
108
|
+
severity,
|
|
109
|
+
carries,
|
|
110
|
+
...(value === undefined ? {} : { value }),
|
|
111
|
+
}));
|
|
112
|
+
const peerAiRules = [...core, ...profiled].filter((rule) => !setAside.has(rule.id));
|
|
113
|
+
return {
|
|
114
|
+
file: path,
|
|
115
|
+
stage,
|
|
116
|
+
peerAiRules,
|
|
117
|
+
setAside: exceptions.map((exception) => ({ rule: exception.rule, reason: exception.reason })),
|
|
118
|
+
...(track === undefined
|
|
119
|
+
? {}
|
|
120
|
+
: { track: { id: track.id, kind: track.kind, ...(track.path === undefined ? {} : { path: track.path }) } }),
|
|
121
|
+
core: standards?.core ?? true,
|
|
122
|
+
profiles: standards?.profiles ?? [],
|
|
123
|
+
documents,
|
|
124
|
+
rules: (config.rules ?? []).map((rule) => ({
|
|
125
|
+
path: rule.path,
|
|
126
|
+
...(rule.description === undefined ? {} : { description: rule.description }),
|
|
127
|
+
})),
|
|
128
|
+
precedence: standards?.precedence ?? "project",
|
|
129
|
+
};
|
|
130
|
+
}
|
package/dist/state.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type ProjectMap, type WorkItem } from "peer-ai-workflow";
|
|
2
|
+
import { type Assessment } from "./assess.ts";
|
|
3
|
+
export declare const WORK_DIR = ".peer-ai/work";
|
|
4
|
+
/** On failure, `error` completes a sentence that starts with the file's path. */
|
|
5
|
+
export type Read<T> = {
|
|
6
|
+
ok: true;
|
|
7
|
+
value: T;
|
|
8
|
+
} | {
|
|
9
|
+
ok: false;
|
|
10
|
+
error: string;
|
|
11
|
+
};
|
|
12
|
+
/** The project map, or undefined when there is none yet. */
|
|
13
|
+
export declare function readMap(root: string): Read<ProjectMap> | undefined;
|
|
14
|
+
export interface WorkItemFile {
|
|
15
|
+
/** The path from the project root, such as .peer-ai/work/SHOP-1.json. */
|
|
16
|
+
path: string;
|
|
17
|
+
item: Read<WorkItem>;
|
|
18
|
+
}
|
|
19
|
+
/** Every work item, sorted by file name. A file whose id doesn't match its name is an error. */
|
|
20
|
+
export declare function readWorkItems(root: string): WorkItemFile[];
|
|
21
|
+
/** Items whose status on the map differs from a fresh assessment, as "tests (missing → present)". */
|
|
22
|
+
export declare function mapChanges(map: ProjectMap, assessment: Assessment): string[];
|
package/dist/state.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Reads Peer AI's state files: the project map, and one file per work item. Problems are
|
|
2
|
+
// returned, never thrown, so each command decides how serious they are.
|
|
3
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
4
|
+
import { basename, join } from "node:path";
|
|
5
|
+
import { MAP_ITEM_IDS, validateMap, validateWorkItem } from "peer-ai-workflow";
|
|
6
|
+
import { MAP_FILE } from "./assess.js";
|
|
7
|
+
export const WORK_DIR = ".peer-ai/work";
|
|
8
|
+
function readJson(path) {
|
|
9
|
+
try {
|
|
10
|
+
return { ok: true, value: JSON.parse(readFileSync(path, "utf8")) };
|
|
11
|
+
}
|
|
12
|
+
catch (error) {
|
|
13
|
+
return { ok: false, error: `is not valid JSON: ${error.message}` };
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/** The project map, or undefined when there is none yet. */
|
|
17
|
+
export function readMap(root) {
|
|
18
|
+
const path = join(root, MAP_FILE);
|
|
19
|
+
if (!existsSync(path))
|
|
20
|
+
return undefined;
|
|
21
|
+
const json = readJson(path);
|
|
22
|
+
if (!json.ok)
|
|
23
|
+
return json;
|
|
24
|
+
const result = validateMap(json.value);
|
|
25
|
+
return result.ok ? result : { ok: false, error: `is not valid: ${result.errors.join("; ")}` };
|
|
26
|
+
}
|
|
27
|
+
/** Every work item, sorted by file name. A file whose id doesn't match its name is an error. */
|
|
28
|
+
export function readWorkItems(root) {
|
|
29
|
+
const dir = join(root, WORK_DIR);
|
|
30
|
+
if (statSync(dir, { throwIfNoEntry: false })?.isDirectory() !== true)
|
|
31
|
+
return [];
|
|
32
|
+
return readdirSync(dir)
|
|
33
|
+
.filter((file) => file.endsWith(".json"))
|
|
34
|
+
.sort()
|
|
35
|
+
.map((file) => {
|
|
36
|
+
const path = `${WORK_DIR}/${file}`;
|
|
37
|
+
const json = readJson(join(dir, file));
|
|
38
|
+
if (!json.ok)
|
|
39
|
+
return { path, item: json };
|
|
40
|
+
const result = validateWorkItem(json.value);
|
|
41
|
+
if (!result.ok)
|
|
42
|
+
return { path, item: { ok: false, error: `is not valid: ${result.errors.join("; ")}` } };
|
|
43
|
+
const id = basename(file, ".json");
|
|
44
|
+
if (result.value.id !== id) {
|
|
45
|
+
return {
|
|
46
|
+
path,
|
|
47
|
+
item: { ok: false, error: `has the id "${result.value.id}", but a work item's file is named after its id` },
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
return { path, item: result };
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
/** Items whose status on the map differs from a fresh assessment, as "tests (missing → present)". */
|
|
54
|
+
export function mapChanges(map, assessment) {
|
|
55
|
+
return MAP_ITEM_IDS.filter((id) => map.items[id]?.status !== assessment.items[id].status).map((id) => `${id} (${map.items[id]?.status ?? "not recorded"} → ${assessment.items[id].status})`);
|
|
56
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Output } from "./init.ts";
|
|
2
|
+
import { type Prompter } from "./prompter.ts";
|
|
3
|
+
/** Creates a temporary project from a map of file paths to contents. A trailing "/" makes a folder. */
|
|
4
|
+
export declare function project(files?: Record<string, string>, options?: {
|
|
5
|
+
gitRemote?: string;
|
|
6
|
+
git?: boolean;
|
|
7
|
+
}): string;
|
|
8
|
+
export declare function cleanUp(): void;
|
|
9
|
+
export declare function capture(): Output & {
|
|
10
|
+
lines: string[];
|
|
11
|
+
text: () => string;
|
|
12
|
+
};
|
|
13
|
+
/** A prompter that gives scripted answers in order, or cancels when it reaches "CANCEL". */
|
|
14
|
+
export declare function scripted(answers: unknown[]): Prompter & {
|
|
15
|
+
asked: string[];
|
|
16
|
+
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { Cancelled } from "./prompter.js";
|
|
6
|
+
const created = [];
|
|
7
|
+
/** Creates a temporary project from a map of file paths to contents. A trailing "/" makes a folder. */
|
|
8
|
+
export function project(files = {}, options = {}) {
|
|
9
|
+
const root = mkdtempSync(join(tmpdir(), "peer-ai-"));
|
|
10
|
+
created.push(root);
|
|
11
|
+
for (const [path, content] of Object.entries(files)) {
|
|
12
|
+
if (path.endsWith("/")) {
|
|
13
|
+
mkdirSync(join(root, path), { recursive: true });
|
|
14
|
+
continue;
|
|
15
|
+
}
|
|
16
|
+
mkdirSync(dirname(join(root, path)), { recursive: true });
|
|
17
|
+
writeFileSync(join(root, path), content);
|
|
18
|
+
}
|
|
19
|
+
if (options.gitRemote !== undefined || options.git === true) {
|
|
20
|
+
execFileSync("git", ["init", "-q"], { cwd: root });
|
|
21
|
+
if (options.gitRemote !== undefined)
|
|
22
|
+
execFileSync("git", ["remote", "add", "origin", options.gitRemote], { cwd: root });
|
|
23
|
+
}
|
|
24
|
+
return root;
|
|
25
|
+
}
|
|
26
|
+
export function cleanUp() {
|
|
27
|
+
for (const root of created.splice(0))
|
|
28
|
+
rmSync(root, { recursive: true, force: true });
|
|
29
|
+
}
|
|
30
|
+
export function capture() {
|
|
31
|
+
const lines = [];
|
|
32
|
+
return {
|
|
33
|
+
lines,
|
|
34
|
+
log: (line) => lines.push(line),
|
|
35
|
+
error: (line) => lines.push(line),
|
|
36
|
+
text: () => lines.join("\n"),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/** A prompter that gives scripted answers in order, or cancels when it reaches "CANCEL". */
|
|
40
|
+
export function scripted(answers) {
|
|
41
|
+
const queue = [...answers];
|
|
42
|
+
const asked = [];
|
|
43
|
+
const next = (message) => {
|
|
44
|
+
asked.push(message);
|
|
45
|
+
if (queue.length === 0)
|
|
46
|
+
throw new Error(`no scripted answer for: ${message}`);
|
|
47
|
+
const value = queue.shift();
|
|
48
|
+
if (value === "CANCEL")
|
|
49
|
+
throw new Cancelled();
|
|
50
|
+
return value;
|
|
51
|
+
};
|
|
52
|
+
return {
|
|
53
|
+
asked,
|
|
54
|
+
intro: () => undefined,
|
|
55
|
+
note: () => undefined,
|
|
56
|
+
outro: () => undefined,
|
|
57
|
+
text: (message) => Promise.resolve(next(message)),
|
|
58
|
+
select: (message) => Promise.resolve(next(message)),
|
|
59
|
+
multiselect: (message) => Promise.resolve(next(message)),
|
|
60
|
+
confirm: (message) => Promise.resolve(next(message)),
|
|
61
|
+
};
|
|
62
|
+
}
|