@intentset/cli 0.2.0 → 0.4.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/dist/agents.d.ts +5 -0
- package/dist/agents.d.ts.map +1 -0
- package/dist/agents.js +146 -0
- package/dist/agents.js.map +1 -0
- package/dist/commands/context.d.ts +14 -1
- package/dist/commands/context.d.ts.map +1 -1
- package/dist/commands/context.js +34 -1
- package/dist/commands/context.js.map +1 -1
- package/dist/commands/init.d.ts +2 -0
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +44 -10
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/review.d.ts +20 -0
- package/dist/commands/review.d.ts.map +1 -1
- package/dist/commands/review.js +52 -8
- package/dist/commands/review.js.map +1 -1
- package/dist/commands/serve.d.ts.map +1 -1
- package/dist/commands/serve.js +38 -16
- package/dist/commands/serve.js.map +1 -1
- package/dist/git.d.ts +8 -0
- package/dist/git.d.ts.map +1 -1
- package/dist/git.js +15 -0
- package/dist/git.js.map +1 -1
- package/dist/main.d.ts +1 -1
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +25 -13
- package/dist/main.js.map +1 -1
- package/dist/slices.d.ts +24 -0
- package/dist/slices.d.ts.map +1 -0
- package/dist/slices.js +48 -0
- package/dist/slices.js.map +1 -0
- package/package.json +9 -9
- package/src/agents.ts +150 -0
- package/src/commands/context.ts +39 -1
- package/src/commands/init.ts +45 -10
- package/src/commands/review.ts +66 -9
- package/src/commands/serve.ts +34 -14
- package/src/git.ts +15 -0
- package/src/main.ts +33 -13
- package/src/slices.ts +54 -0
package/src/agents.ts
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The agent guide `intentset init` writes to `.intentset/agents.md`: what a
|
|
3
|
+
* coding agent does, before and after it changes code, to keep the model
|
|
4
|
+
* current (Core §10: load the owning slice first, update affected semantics
|
|
5
|
+
* and checks in the same review, never self-approve). A repository points its
|
|
6
|
+
* agents at it from AGENTS.md or CLAUDE.md.
|
|
7
|
+
*
|
|
8
|
+
* Generated rather than copied, so the commands, the required sections and
|
|
9
|
+
* the trailer can never disagree with the toolchain that wrote it.
|
|
10
|
+
*/
|
|
11
|
+
import { ARTIFACT_TYPES, REQUIRED_SECTIONS } from "@intentset/core";
|
|
12
|
+
import { UNCHANGED_TRAILER } from "./git.ts";
|
|
13
|
+
import { TOOL } from "./output.ts";
|
|
14
|
+
|
|
15
|
+
export const AGENT_GUIDE_PATH = ".intentset/agents.md";
|
|
16
|
+
|
|
17
|
+
/** The lines a repository adds so its agents read the guide, by the file that takes each. */
|
|
18
|
+
export const AGENT_POINTERS: Array<[string, string]> = [
|
|
19
|
+
["CLAUDE.md", `@${AGENT_GUIDE_PATH}`],
|
|
20
|
+
["AGENTS.md", `Before changing code, read ${AGENT_GUIDE_PATH} and follow it.`],
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
export function agentGuide(scope: readonly string[]): string {
|
|
24
|
+
const where = scope.map((pattern) => `\`${pattern}\``).join(", ");
|
|
25
|
+
const sections = ARTIFACT_TYPES.map(
|
|
26
|
+
(type) => `| ${type} | ${REQUIRED_SECTIONS[type].map((section) => `## ${section}`).join(", ")} |`,
|
|
27
|
+
);
|
|
28
|
+
return `# Keeping the product model current
|
|
29
|
+
|
|
30
|
+
This repository keeps an Intentset product model: one Markdown file per product behavior, rule, scenario, slice,
|
|
31
|
+
contract, decision, verification and knowledge article, under ${where}. The records say what the product promises,
|
|
32
|
+
which code delivers it and how it is checked. You keep them true as part of every change, in the same commit as the
|
|
33
|
+
code, so that people can review what the product does without reading every line of what changed.
|
|
34
|
+
|
|
35
|
+
Written by \`intentset init\` (${TOOL.name} ${TOOL.version}). To refresh it, delete it and run
|
|
36
|
+
\`npx intentset init --agents\`.
|
|
37
|
+
|
|
38
|
+
## Before you change code
|
|
39
|
+
|
|
40
|
+
Load what the code you are about to change promises:
|
|
41
|
+
|
|
42
|
+
\`\`\`sh
|
|
43
|
+
npx intentset context <a file you will edit>
|
|
44
|
+
npx intentset context <an ID, such as BEH-...>
|
|
45
|
+
\`\`\`
|
|
46
|
+
|
|
47
|
+
The result is the owning slice, its behaviors, rules, scenarios, contracts, decisions and checks, with their paths.
|
|
48
|
+
Keep the change inside that slice, and reach other slices only through the contracts it consumes.
|
|
49
|
+
|
|
50
|
+
## When behavior changes
|
|
51
|
+
|
|
52
|
+
Update the records in the same change as the code.
|
|
53
|
+
|
|
54
|
+
- A changed promise: edit the behavior's Behavior, Preconditions and Outcomes, and the rules and scenarios it
|
|
55
|
+
touches.
|
|
56
|
+
- A new promise: add a behavior record with \`status: draft\`, and add its ID to \`implements\` on the slice that
|
|
57
|
+
delivers it. New rules, scenarios and verifications start as drafts too.
|
|
58
|
+
- New code that no slice claims: add a slice record with \`status: draft\`, its claims and its entrypoints. While a
|
|
59
|
+
slice is a draft, the paths it plans may not exist yet.
|
|
60
|
+
- A changed check: a verification record names the test that checks a claim. Change the test and its record
|
|
61
|
+
together, and add one for a behavior or rule that has none.
|
|
62
|
+
|
|
63
|
+
Write each link once, on the record that points: a behavior is \`governedBy\` its rules, a scenario \`illustrates\`
|
|
64
|
+
a behavior, a slice \`implements\` behaviors, a verification \`verifies\` what it checks. Never write the reverse.
|
|
65
|
+
|
|
66
|
+
## When behavior does not change
|
|
67
|
+
|
|
68
|
+
A refactor changes code and no behavior. Say so in the commit message with a trailer naming each slice:
|
|
69
|
+
|
|
70
|
+
\`\`\`text
|
|
71
|
+
${UNCHANGED_TRAILER}: SLICE-...
|
|
72
|
+
\`\`\`
|
|
73
|
+
|
|
74
|
+
\`intentset review\` lists every slice whose code changed while its records did not, and this trailer is how a
|
|
75
|
+
commit answers it. Use it only when no behavior changed: a reviewer reads it as your claim.
|
|
76
|
+
|
|
77
|
+
## Never
|
|
78
|
+
|
|
79
|
+
- Set a status to approved, implemented or released, or publish knowledge. People approve, and validation passing
|
|
80
|
+
is not approval.
|
|
81
|
+
- Change an ID or reuse a retired one. Moving or renaming a file keeps its ID; splitting a behavior retires the old
|
|
82
|
+
one with \`replacedBy\`.
|
|
83
|
+
- Name an owner, audience, release, role, edition or flag that is not in \`.intentset/registries.yaml\`. Ask
|
|
84
|
+
instead.
|
|
85
|
+
- Commit run evidence (\`.intentset/evidence/\`) or an export. They describe one commit and are stale once it lands.
|
|
86
|
+
- Edit generated output: an export, the Atlas, published pages.
|
|
87
|
+
|
|
88
|
+
## Before you finish
|
|
89
|
+
|
|
90
|
+
\`\`\`sh
|
|
91
|
+
npx intentset validate
|
|
92
|
+
npx intentset architecture check # when .intentset/architecture.yaml exists
|
|
93
|
+
npx intentset review --base main
|
|
94
|
+
\`\`\`
|
|
95
|
+
|
|
96
|
+
Fix every error. Resolve each slice \`review\` lists under "Code changed, records unchanged", by updating its records
|
|
97
|
+
or with the trailer. The architecture check needs TypeScript 7; on an earlier TypeScript, run the toolchain as
|
|
98
|
+
\`npx -p @intentset/cli -p typescript@7 intentset ...\`.
|
|
99
|
+
|
|
100
|
+
## Record format
|
|
101
|
+
|
|
102
|
+
A record is Markdown with YAML frontmatter under \`intentset:\`, and its level-one heading repeats its title. An
|
|
103
|
+
existing record of the same type in this repository is the best template; a new behavior looks like this, one
|
|
104
|
+
recognizable promise including how it fails:
|
|
105
|
+
|
|
106
|
+
\`\`\`markdown
|
|
107
|
+
---
|
|
108
|
+
markset: 0
|
|
109
|
+
intentset:
|
|
110
|
+
spec: '0.1'
|
|
111
|
+
profile: intentset/behavior/0.1
|
|
112
|
+
id: BEH-AREA-NAME
|
|
113
|
+
type: behavior
|
|
114
|
+
title: The promise in a few words, such as Schedule an assessment
|
|
115
|
+
status: draft
|
|
116
|
+
owner: <an owner from the registry>
|
|
117
|
+
visibility: internal
|
|
118
|
+
audiences: [engineering, product]
|
|
119
|
+
parent: CAP-...
|
|
120
|
+
links:
|
|
121
|
+
governedBy: [RULE-...]
|
|
122
|
+
availability:
|
|
123
|
+
products: [PRD-...]
|
|
124
|
+
releases: [<a release from the registry>]
|
|
125
|
+
roles: [<a role>]
|
|
126
|
+
editions: [<an edition>]
|
|
127
|
+
flags: []
|
|
128
|
+
---
|
|
129
|
+
|
|
130
|
+
# The promise in a few words, such as Schedule an assessment
|
|
131
|
+
|
|
132
|
+
## Behavior
|
|
133
|
+
Who acts, and what the system does.
|
|
134
|
+
|
|
135
|
+
## Preconditions
|
|
136
|
+
What must be true first.
|
|
137
|
+
|
|
138
|
+
## Outcomes
|
|
139
|
+
Success: what happens. Failure: what is rejected, and what is left unchanged.
|
|
140
|
+
\`\`\`
|
|
141
|
+
|
|
142
|
+
Each type requires these level-two sections:
|
|
143
|
+
|
|
144
|
+
| Type | Required sections |
|
|
145
|
+
|---|---|
|
|
146
|
+
${sections.join("\n")}
|
|
147
|
+
|
|
148
|
+
The specifications are at https://intentset.org/specifications/.
|
|
149
|
+
`;
|
|
150
|
+
}
|
package/src/commands/context.ts
CHANGED
|
@@ -9,7 +9,12 @@
|
|
|
9
9
|
*
|
|
10
10
|
* `restricted` artifacts are withheld unless asked for, and the report says
|
|
11
11
|
* how many, so a short context never reads as a complete one. Never writes.
|
|
12
|
+
*
|
|
13
|
+
* Given a file rather than an ID, it reads the context of the slice whose
|
|
14
|
+
* claims include the file, since an agent about to edit code knows the file
|
|
15
|
+
* and not the ID of what it promises.
|
|
12
16
|
*/
|
|
17
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
13
18
|
import {
|
|
14
19
|
type Artifact,
|
|
15
20
|
type ContextArtifact,
|
|
@@ -21,8 +26,29 @@ import {
|
|
|
21
26
|
} from "@intentset/core";
|
|
22
27
|
import { count, formatDiagnostic, type Io, json, snapshotLine, TOOL } from "../output.ts";
|
|
23
28
|
import type { Session } from "../session.ts";
|
|
29
|
+
import { claimingSlices } from "../slices.ts";
|
|
24
30
|
import { unresolved, validationLine, validationSummary } from "./shared.ts";
|
|
25
31
|
|
|
32
|
+
/**
|
|
33
|
+
* The artifact a `context` argument names: itself when it is an ID, else the
|
|
34
|
+
* one slice whose claims include it as a file path, relative to the working
|
|
35
|
+
* directory. Several claiming slices are an overlap VSA reports; the caller
|
|
36
|
+
* is asked to choose.
|
|
37
|
+
*/
|
|
38
|
+
export function resolveTarget(
|
|
39
|
+
session: Session,
|
|
40
|
+
argument: string,
|
|
41
|
+
cwd: string,
|
|
42
|
+
): { id: string; path: string | null } | { path: string; slices: string[] } {
|
|
43
|
+
const graph = session.result.graph;
|
|
44
|
+
if (graph.artifacts.has(argument)) return { id: argument, path: null };
|
|
45
|
+
const absolute = isAbsolute(argument) ? argument : resolve(cwd, argument);
|
|
46
|
+
const path = relative(session.repo.root, absolute).split(sep).join("/");
|
|
47
|
+
const slices = claimingSlices(graph, path).map((slice) => slice.meta.id);
|
|
48
|
+
if (slices.length === 1) return { id: slices[0], path };
|
|
49
|
+
return { path, slices };
|
|
50
|
+
}
|
|
51
|
+
|
|
26
52
|
const HEADINGS: Record<ContextRole, string> = {
|
|
27
53
|
start: "Start",
|
|
28
54
|
owner: "Owning slice",
|
|
@@ -39,12 +65,22 @@ const HEADINGS: Record<ContextRole, string> = {
|
|
|
39
65
|
|
|
40
66
|
export function contextCommand(
|
|
41
67
|
session: Session,
|
|
42
|
-
|
|
68
|
+
argument: string,
|
|
43
69
|
includeRestricted: boolean,
|
|
44
70
|
asJson: boolean,
|
|
45
71
|
io: Io,
|
|
46
72
|
): number {
|
|
47
73
|
const { graph } = session.result;
|
|
74
|
+
const target = resolveTarget(session, argument, io.cwd);
|
|
75
|
+
if ("slices" in target && target.slices.length > 1) {
|
|
76
|
+
io.stderr(
|
|
77
|
+
`intentset context: ${target.path} is claimed by ${target.slices.join(" and ")}, an overlap VSA reports; ` +
|
|
78
|
+
"name one of them.\n",
|
|
79
|
+
);
|
|
80
|
+
return 2;
|
|
81
|
+
}
|
|
82
|
+
const via = "id" in target ? target.path : null;
|
|
83
|
+
const id = "id" in target ? target.id : argument;
|
|
48
84
|
const bundle = contextFor(graph, id, { includeRestricted });
|
|
49
85
|
if (bundle === null) {
|
|
50
86
|
const diagnostic = unresolved(id, session);
|
|
@@ -73,6 +109,7 @@ export function contextCommand(
|
|
|
73
109
|
snapshot: session.snapshot,
|
|
74
110
|
validation: validationSummary(session, session.level),
|
|
75
111
|
start: id,
|
|
112
|
+
...(via === null ? {} : { resolvedFrom: via }),
|
|
76
113
|
included: bundle.artifacts.length,
|
|
77
114
|
withheld: bundle.withheld,
|
|
78
115
|
restrictedIncluded: includeRestricted,
|
|
@@ -93,6 +130,7 @@ export function contextCommand(
|
|
|
93
130
|
`# Context for ${id}`,
|
|
94
131
|
"",
|
|
95
132
|
`Bounded engineering context (Core §10) for ${start.meta.type} ${id}, "${start.meta.title}".`,
|
|
133
|
+
...(via === null ? [] : ["", `${via} is claimed by ${id}, so this is that slice's context.`]),
|
|
96
134
|
"",
|
|
97
135
|
`- ${snapshotLine(session.snapshot)}`,
|
|
98
136
|
`- ${validationLine(session, session.level)}`,
|
package/src/commands/init.ts
CHANGED
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `intentset init`: the one command that writes, and it only creates. It
|
|
3
|
-
* writes `.intentset/config.yaml
|
|
4
|
-
* `.intentset/registries.yaml
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
3
|
+
* writes `.intentset/config.yaml`, an empty-but-valid
|
|
4
|
+
* `.intentset/registries.yaml` and `.intentset/agents.md`, the guide coding
|
|
5
|
+
* agents follow to keep the model current, and with `--example` the
|
|
6
|
+
* scheduling example's records under `product/scheduling/` and its
|
|
7
|
+
* registries in place of the empty ones. `--agents` writes the guide alone,
|
|
8
|
+
* for a repository initialized before there was one, with the scope its
|
|
9
|
+
* config already names. Every target is checked before anything is written,
|
|
10
|
+
* and each file is opened with exclusive create, so an existing file is never
|
|
8
11
|
* overwritten, not even by a race.
|
|
9
12
|
*/
|
|
10
13
|
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
11
14
|
import { basename, dirname, join, resolve } from "node:path";
|
|
12
15
|
import { fileURLToPath } from "node:url";
|
|
13
16
|
import { compareStrings, DEFAULT_IGNORE, readConfig, readRegistries } from "@intentset/core";
|
|
17
|
+
import { AGENT_GUIDE_PATH, AGENT_POINTERS, agentGuide } from "../agents.ts";
|
|
14
18
|
import type { Io } from "../output.ts";
|
|
15
19
|
import { CONFIG_PATH } from "../repository.ts";
|
|
16
20
|
|
|
@@ -22,6 +26,8 @@ export interface InitOptions {
|
|
|
22
26
|
root?: string;
|
|
23
27
|
repository?: string;
|
|
24
28
|
example: boolean;
|
|
29
|
+
/** Write only the agent guide. */
|
|
30
|
+
agentsOnly?: boolean;
|
|
25
31
|
}
|
|
26
32
|
|
|
27
33
|
/** YAML double-quoted scalar; the strict reader takes JSON's escapes. */
|
|
@@ -82,6 +88,8 @@ export async function initCommand(options: InitOptions, io: Io): Promise<number>
|
|
|
82
88
|
return 2;
|
|
83
89
|
}
|
|
84
90
|
|
|
91
|
+
if (options.agentsOnly === true) return writeGuideOnly(root, io);
|
|
92
|
+
|
|
85
93
|
// Everything to write, decided before anything is written.
|
|
86
94
|
const files = new Map<string, string>([[CONFIG_PATH, configText(repository)]]);
|
|
87
95
|
let registries = EMPTY_REGISTRIES_TEXT;
|
|
@@ -103,6 +111,7 @@ export async function initCommand(options: InitOptions, io: Io): Promise<number>
|
|
|
103
111
|
}
|
|
104
112
|
}
|
|
105
113
|
files.set(REGISTRIES_PATH, registries);
|
|
114
|
+
files.set(AGENT_GUIDE_PATH, agentGuide([DEFAULT_INIT_SCOPE]));
|
|
106
115
|
|
|
107
116
|
// What init writes must read back clean, or it has handed the user a broken repository.
|
|
108
117
|
const config = readConfig(files.get(CONFIG_PATH) as string, CONFIG_PATH);
|
|
@@ -112,6 +121,28 @@ export async function initCommand(options: InitOptions, io: Io): Promise<number>
|
|
|
112
121
|
return 2;
|
|
113
122
|
}
|
|
114
123
|
|
|
124
|
+
if (!create(root, files, io)) return 2;
|
|
125
|
+
const scope = options.example ? `${files.size - 3} example records in scope` : "an empty scope";
|
|
126
|
+
io.stdout(`Initialized ${quote(repository)} with ${scope}. Next: intentset validate\n`);
|
|
127
|
+
pointers(io);
|
|
128
|
+
return 0;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** `init --agents`: the guide alone, for the scope the repository's config names, or the default with no config. */
|
|
132
|
+
function writeGuideOnly(root: string, io: Io): number {
|
|
133
|
+
let scope = [DEFAULT_INIT_SCOPE];
|
|
134
|
+
const configFile = join(root, CONFIG_PATH);
|
|
135
|
+
if (existsSync(configFile)) {
|
|
136
|
+
const { config, diagnostics } = readConfig(readFileSync(configFile, "utf8"), CONFIG_PATH);
|
|
137
|
+
if (diagnostics.length === 0 && config.scope.length > 0) scope = config.scope;
|
|
138
|
+
}
|
|
139
|
+
if (!create(root, new Map([[AGENT_GUIDE_PATH, agentGuide(scope)]]), io)) return 2;
|
|
140
|
+
pointers(io);
|
|
141
|
+
return 0;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Create every file or none: refuse if any exists, then write each with exclusive create. */
|
|
145
|
+
function create(root: string, files: Map<string, string>, io: Io): boolean {
|
|
115
146
|
const ordered = [...files.keys()].sort(compareStrings);
|
|
116
147
|
const existing = ordered.filter((path) => existsSync(join(root, path)));
|
|
117
148
|
if (existing.length > 0) {
|
|
@@ -119,7 +150,7 @@ export async function initCommand(options: InitOptions, io: Io): Promise<number>
|
|
|
119
150
|
`intentset init: refusing to overwrite ${existing.join(", ")}; init only creates files.\n` +
|
|
120
151
|
"Remove them first if a fresh start is what you want.\n",
|
|
121
152
|
);
|
|
122
|
-
return
|
|
153
|
+
return false;
|
|
123
154
|
}
|
|
124
155
|
for (const path of ordered) {
|
|
125
156
|
const target = join(root, path);
|
|
@@ -128,11 +159,15 @@ export async function initCommand(options: InitOptions, io: Io): Promise<number>
|
|
|
128
159
|
writeFileSync(target, files.get(path) as string, { flag: "wx" });
|
|
129
160
|
} catch (error) {
|
|
130
161
|
io.stderr(`intentset init: could not create ${path}: ${(error as Error).message}\n`);
|
|
131
|
-
return
|
|
162
|
+
return false;
|
|
132
163
|
}
|
|
133
164
|
io.stdout(`created ${path}\n`);
|
|
134
165
|
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** How to point a repository's agents at the guide. init never edits a file it did not create, so it says rather than does. */
|
|
170
|
+
function pointers(io: Io): void {
|
|
171
|
+
io.stdout(`\nPoint your coding agents at ${AGENT_GUIDE_PATH} with one line in the file they read:\n`);
|
|
172
|
+
for (const [file, line] of AGENT_POINTERS) io.stdout(` ${file}: ${line}\n`);
|
|
138
173
|
}
|
package/src/commands/review.ts
CHANGED
|
@@ -7,13 +7,19 @@
|
|
|
7
7
|
* touches, a changed record to its own artifact and a changed source file
|
|
8
8
|
* through the claiming slice to that slice's behaviors, each start's direct
|
|
9
9
|
* dependents shown apart from its candidates. Markdown by default. Read-only.
|
|
10
|
+
*
|
|
11
|
+
* It also lists every slice whose code changed while none of the records that
|
|
12
|
+
* describe it did (Core §10: an agent updates affected semantics in the same
|
|
13
|
+
* review). A refactor changes code and no behavior, so the list is a prompt,
|
|
14
|
+
* not an error: a commit says so with an Intentset-Unchanged trailer, and
|
|
15
|
+
* `--fail-on-drift` fails only on slices no commit has spoken for.
|
|
10
16
|
*/
|
|
11
17
|
import { type Graph, impact, compareStrings } from "@intentset/core";
|
|
12
|
-
import { type Changes, changedFiles, chooseBase } from "../git.ts";
|
|
13
|
-
import { matchGlob } from "../glob.ts";
|
|
18
|
+
import { acknowledgedSlices, type Changes, changedFiles, chooseBase, UNCHANGED_TRAILER } from "../git.ts";
|
|
14
19
|
import { count, formatDiagnostic, type Io, json, snapshotLine, TOOL } from "../output.ts";
|
|
15
20
|
import { levelJson, levelSections } from "../reports.ts";
|
|
16
21
|
import type { Session } from "../session.ts";
|
|
22
|
+
import { activeSlices, claimingSlices, describingRecords, isSliceCode } from "../slices.ts";
|
|
17
23
|
import { pathText, REACHABILITY_NOTE } from "./impact.ts";
|
|
18
24
|
|
|
19
25
|
export interface Attribution {
|
|
@@ -27,9 +33,6 @@ export interface Attribution {
|
|
|
27
33
|
export function attribute(graph: Graph, changed: readonly string[]): { starts: Attribution[]; unattributed: string[] } {
|
|
28
34
|
const byPath = new Map<string, string>();
|
|
29
35
|
for (const [id, artifact] of graph.artifacts) byPath.set(artifact.path, id);
|
|
30
|
-
const slices = [...graph.artifacts.values()]
|
|
31
|
-
.filter((a) => a.meta.type === "slice" && a.meta.status !== "retired" && a.meta.slice !== undefined)
|
|
32
|
-
.sort((a, b) => compareStrings(a.meta.id, b.meta.id));
|
|
33
36
|
const starts = new Map<string, Set<string>>();
|
|
34
37
|
const add = (id: string, reason: string) => {
|
|
35
38
|
const set = starts.get(id) ?? new Set<string>();
|
|
@@ -43,7 +46,7 @@ export function attribute(graph: Graph, changed: readonly string[]): { starts: A
|
|
|
43
46
|
add(id, `${path} is its record`);
|
|
44
47
|
continue;
|
|
45
48
|
}
|
|
46
|
-
const owners =
|
|
49
|
+
const owners = claimingSlices(graph, path);
|
|
47
50
|
let attributed = false;
|
|
48
51
|
for (const slice of owners) {
|
|
49
52
|
for (const behavior of slice.meta.links.implements ?? []) {
|
|
@@ -62,9 +65,46 @@ export function attribute(graph: Graph, changed: readonly string[]): { starts: A
|
|
|
62
65
|
};
|
|
63
66
|
}
|
|
64
67
|
|
|
68
|
+
export interface Drift {
|
|
69
|
+
slice: string;
|
|
70
|
+
title: string;
|
|
71
|
+
/** The slice's changed code: files its source, backend or contract claims match. */
|
|
72
|
+
code: string[];
|
|
73
|
+
/** The records that describe the slice, none of which changed. */
|
|
74
|
+
records: string[];
|
|
75
|
+
/** True when a commit after the base names the slice in an Intentset-Unchanged trailer. */
|
|
76
|
+
acknowledged: boolean;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export const DRIFT_NOTE =
|
|
80
|
+
"Slices whose code changed while none of the records that describe them did: the slice, its behaviors, their " +
|
|
81
|
+
"rules, scenarios and verifications, its contracts and their decisions. If behavior changed, update those records " +
|
|
82
|
+
`in this change. If it did not, say so in a commit with the trailer \`${UNCHANGED_TRAILER}: <slice ID>\`.`;
|
|
83
|
+
|
|
84
|
+
/** Each slice whose code is among the changed files while none of its describing records is, sorted by slice ID. */
|
|
85
|
+
export function drift(graph: Graph, changed: readonly string[], acknowledged: ReadonlySet<string>): Drift[] {
|
|
86
|
+
const records = new Set([...graph.artifacts.values()].map((artifact) => artifact.path));
|
|
87
|
+
const touched = new Set(changed);
|
|
88
|
+
const out: Drift[] = [];
|
|
89
|
+
for (const slice of activeSlices(graph)) {
|
|
90
|
+
const code = changed.filter((path) => !records.has(path) && isSliceCode(slice, path));
|
|
91
|
+
if (code.length === 0) continue;
|
|
92
|
+
const describing = describingRecords(graph, slice);
|
|
93
|
+
if (describing.some((id) => touched.has(graph.artifacts.get(id)?.path ?? ""))) continue;
|
|
94
|
+
out.push({
|
|
95
|
+
slice: slice.meta.id,
|
|
96
|
+
title: slice.meta.title,
|
|
97
|
+
code,
|
|
98
|
+
records: describing,
|
|
99
|
+
acknowledged: acknowledged.has(slice.meta.id),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
|
|
65
105
|
export function reviewCommand(
|
|
66
106
|
session: Session,
|
|
67
|
-
options: { base?: string; levelReason: string },
|
|
107
|
+
options: { base?: string; levelReason: string; failOnDrift?: boolean },
|
|
68
108
|
asJson: boolean,
|
|
69
109
|
io: Io,
|
|
70
110
|
): number {
|
|
@@ -80,6 +120,9 @@ export function reviewCommand(
|
|
|
80
120
|
if ("unavailable" in chosen) changes.baseUnavailable = chosen.unavailable;
|
|
81
121
|
const changed = [...new Set([...changes.committed, ...changes.uncommitted])].sort(compareStrings);
|
|
82
122
|
const { starts, unattributed } = attribute(graph, changed);
|
|
123
|
+
const drifted = drift(graph, changed, new Set(acknowledgedSlices(repo.root, base)));
|
|
124
|
+
const failing = options.failOnDrift === true && drifted.some((item) => !item.acknowledged);
|
|
125
|
+
const exit = session.errors > 0 || failing ? 1 : 0;
|
|
83
126
|
const impacts = starts.map(({ start, because }) => {
|
|
84
127
|
const artifact = graph.artifacts.get(start);
|
|
85
128
|
const report = impact(graph, start);
|
|
@@ -108,10 +151,11 @@ export function reviewCommand(
|
|
|
108
151
|
...levelJson(session),
|
|
109
152
|
note: REACHABILITY_NOTE,
|
|
110
153
|
impact: impacts,
|
|
154
|
+
drift: drifted,
|
|
111
155
|
unattributed,
|
|
112
156
|
}),
|
|
113
157
|
);
|
|
114
|
-
return
|
|
158
|
+
return exit;
|
|
115
159
|
}
|
|
116
160
|
|
|
117
161
|
const baseLine =
|
|
@@ -147,9 +191,22 @@ export function reviewCommand(
|
|
|
147
191
|
if (item.candidates.length === 0) out.push("- none");
|
|
148
192
|
for (const hit of item.candidates) out.push(`- ${hit.id} (${hit.type}): \`${pathText(item.start, hit)}\``);
|
|
149
193
|
}
|
|
194
|
+
const open = drifted.filter((item) => !item.acknowledged).length;
|
|
195
|
+
out.push("", `## Code changed, records unchanged (${drifted.length}, ${open} not acknowledged)`, "", DRIFT_NOTE);
|
|
196
|
+
if (drifted.length === 0) out.push("", "None.");
|
|
197
|
+
for (const item of drifted) {
|
|
198
|
+
out.push("", `### ${item.slice}: ${item.title}`, "");
|
|
199
|
+
out.push(
|
|
200
|
+
item.acknowledged
|
|
201
|
+
? `- Acknowledged: a commit since the base says this changes no behavior (${UNCHANGED_TRAILER}).`
|
|
202
|
+
: "- Not acknowledged: update the records below, or say in a commit that no behavior changed.",
|
|
203
|
+
);
|
|
204
|
+
out.push(...item.code.map((path) => `- Code changed: ${path}`));
|
|
205
|
+
out.push(`- Records describing it, none changed: ${item.records.join(", ")}`);
|
|
206
|
+
}
|
|
150
207
|
out.push("", `## Changed files no artifact accounts for (${unattributed.length})`, "");
|
|
151
208
|
if (unattributed.length === 0) out.push("None.");
|
|
152
209
|
else out.push(...unattributed.map((path) => `- ${path}`));
|
|
153
210
|
io.stdout(`${out.join("\n")}\n`);
|
|
154
|
-
return
|
|
211
|
+
return exit;
|
|
155
212
|
}
|
package/src/commands/serve.ts
CHANGED
|
@@ -65,9 +65,9 @@ export function atlasOf(session: Session): Map<string, string> {
|
|
|
65
65
|
|
|
66
66
|
/**
|
|
67
67
|
* Every file a build read as text, and the directories holding them, absolute:
|
|
68
|
-
* a
|
|
69
|
-
* changes when a file is added to it or removed, which is how
|
|
70
|
-
* noticed.
|
|
68
|
+
* a different modification time or size on any of them means rebuild. A
|
|
69
|
+
* directory's time changes when a file is added to it or removed, which is how
|
|
70
|
+
* a new record is noticed.
|
|
71
71
|
*/
|
|
72
72
|
function watched(session: Session, evidence: readonly string[]): string[] {
|
|
73
73
|
const { repo } = session;
|
|
@@ -93,14 +93,31 @@ function watched(session: Session, evidence: readonly string[]): string[] {
|
|
|
93
93
|
return [...files];
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
96
|
+
/**
|
|
97
|
+
* Each path's modification time and size, or "absent". Compared with itself
|
|
98
|
+
* later rather than with a clock: a wall-clock start time in whole milliseconds
|
|
99
|
+
* is earlier than a file written in the same millisecond, and a file system's
|
|
100
|
+
* clock is coarser than the process's, so comparing the two calls a file
|
|
101
|
+
* written before the build "changed" (the 0.3.0 release run did, on Linux).
|
|
102
|
+
*/
|
|
103
|
+
function stamps(paths: readonly string[]): Map<string, string> {
|
|
104
|
+
const out = new Map<string, string>();
|
|
105
|
+
for (const path of paths) out.set(path, stamp(path));
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function stamp(path: string): string {
|
|
110
|
+
try {
|
|
111
|
+
const stat = statSync(path);
|
|
112
|
+
return `${stat.mtimeMs}:${stat.size}`;
|
|
113
|
+
} catch {
|
|
114
|
+
// Absent: a file the build read was removed, or an optional file never existed.
|
|
115
|
+
return "absent";
|
|
103
116
|
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function changedSince(recorded: ReadonlyMap<string, string>): string | null {
|
|
120
|
+
for (const [path, before] of recorded) if (stamp(path) !== before) return path;
|
|
104
121
|
return null;
|
|
105
122
|
}
|
|
106
123
|
|
|
@@ -118,7 +135,6 @@ export function requestPath(url: string | undefined): string | null {
|
|
|
118
135
|
|
|
119
136
|
export async function serveCommand(options: ServeOptions, sessionOptions: SessionOptions, io: Io): Promise<number> {
|
|
120
137
|
const given = sessionOptions.evidence;
|
|
121
|
-
let builtAt = Date.now();
|
|
122
138
|
const first = openSession("serve", sessionOptions, io);
|
|
123
139
|
if (typeof first === "number") return first;
|
|
124
140
|
let session: Session = first;
|
|
@@ -131,6 +147,7 @@ export async function serveCommand(options: ServeOptions, sessionOptions: Sessio
|
|
|
131
147
|
}
|
|
132
148
|
const evidence = () => evidenceFiles(session.repo.root, io.cwd, given);
|
|
133
149
|
let paths = watched(session, evidence());
|
|
150
|
+
let recorded = stamps(paths);
|
|
134
151
|
const levelLine = `${count(session.result.graph.artifacts.size, "artifact")}, ${count(session.errors, "error")}, ${count(session.warnings, "warning")} at ${session.level}${
|
|
135
152
|
session.evidence === undefined
|
|
136
153
|
? `; no run records were given or found in ${EVIDENCE_DIR}/, so L3 was not checked`
|
|
@@ -141,9 +158,10 @@ export async function serveCommand(options: ServeOptions, sessionOptions: Sessio
|
|
|
141
158
|
|
|
142
159
|
/** Rebuild when a file the last build read has changed; keep serving the last good build if a rebuild fails. */
|
|
143
160
|
const refresh = (): string | null => {
|
|
144
|
-
const changed = changedSince(
|
|
161
|
+
const changed = changedSince(recorded);
|
|
145
162
|
if (changed === null) return null;
|
|
146
|
-
|
|
163
|
+
// Stamped before the build reads anything, so an edit made while it runs shows as a change next time.
|
|
164
|
+
const before = stamps(paths);
|
|
147
165
|
const next = openSession("serve", sessionOptions, io);
|
|
148
166
|
if (typeof next === "number") return "the repository could not be read; the terminal says why";
|
|
149
167
|
try {
|
|
@@ -152,8 +170,10 @@ export async function serveCommand(options: ServeOptions, sessionOptions: Sessio
|
|
|
152
170
|
return `the Atlas could not be built: ${(error as Error).message}`;
|
|
153
171
|
}
|
|
154
172
|
session = next;
|
|
155
|
-
builtAt = started;
|
|
156
173
|
paths = watched(session, evidence());
|
|
174
|
+
const after = stamps(paths);
|
|
175
|
+
for (const [path, value] of before) if (after.has(path)) after.set(path, value);
|
|
176
|
+
recorded = after;
|
|
157
177
|
io.stdout(
|
|
158
178
|
`rebuilt: ${changed.slice(session.repo.root.length + 1) || "."} changed. ${snapshotLine(session.snapshot)}\n`,
|
|
159
179
|
);
|
package/src/git.ts
CHANGED
|
@@ -79,3 +79,18 @@ export function changedFiles(root: string, base: Base | null): Omit<Changes, "ba
|
|
|
79
79
|
uncommitted: [...new Set(uncommitted)].sort(compareStrings),
|
|
80
80
|
};
|
|
81
81
|
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The trailer a commit carries to say it changes a slice's code without
|
|
85
|
+
* changing its behavior: `Intentset-Unchanged: SLICE-A, SLICE-B`. A reviewer
|
|
86
|
+
* reads it in the commit, so the claim is made where it can be questioned.
|
|
87
|
+
*/
|
|
88
|
+
export const UNCHANGED_TRAILER = "Intentset-Unchanged";
|
|
89
|
+
|
|
90
|
+
/** The slice IDs named in an Intentset-Unchanged trailer of any commit after the base, sorted. */
|
|
91
|
+
export function acknowledgedSlices(root: string, base: Base | null): string[] {
|
|
92
|
+
if (base === null) return [];
|
|
93
|
+
const out = git(root, ["log", `--format=%(trailers:key=${UNCHANGED_TRAILER},valueonly)`, `${base.commit}..HEAD`]);
|
|
94
|
+
if (out === null) return [];
|
|
95
|
+
return [...new Set(out.split(/[\s,]+/).filter((id) => id !== ""))].sort(compareStrings);
|
|
96
|
+
}
|