gentle-pi 0.3.3 → 0.3.5
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/README.md +77 -16
- package/assets/agents/sdd-apply.md +4 -5
- package/assets/agents/sdd-archive.md +127 -9
- package/assets/agents/sdd-design.md +2 -4
- package/assets/agents/sdd-explore.md +2 -4
- package/assets/agents/sdd-init.md +2 -4
- package/assets/agents/sdd-onboard.md +2 -4
- package/assets/agents/sdd-proposal.md +2 -4
- package/assets/agents/sdd-spec.md +143 -9
- package/assets/agents/sdd-sync.md +104 -0
- package/assets/agents/sdd-tasks.md +2 -3
- package/assets/agents/sdd-verify.md +4 -5
- package/assets/chains/sdd-full.chain.md +11 -2
- package/assets/chains/sdd-verify.chain.md +11 -2
- package/assets/orchestrator.md +14 -13
- package/extensions/gentle-ai.ts +114 -21
- package/extensions/skill-registry.ts +62 -103
- package/lib/openspec-deltas.ts +156 -0
- package/lib/openspec-guardrails.ts +99 -0
- package/lib/sdd-preflight.ts +11 -5
- package/package.json +1 -1
- package/scripts/verify-package-files.mjs +12 -0
- package/skills/gentle-ai/SKILL.md +1 -1
- package/skills/judgment-day/SKILL.md +1 -1
- package/skills/judgment-day/references/prompts-and-formats.md +6 -6
- package/skills/skill-registry/SKILL.md +51 -0
- package/tests/openspec-deltas.test.ts +209 -0
- package/tests/openspec-guardrails.test.ts +71 -0
- package/tests/runtime-harness.mjs +84 -18
- package/tests/skill-registry.test.ts +93 -55
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { parseDeltaSpec } from "./openspec-deltas.ts";
|
|
4
|
+
|
|
5
|
+
export interface DomainCollision {
|
|
6
|
+
change: string;
|
|
7
|
+
path: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface LegacyFlatSpecWarning {
|
|
11
|
+
change: string;
|
|
12
|
+
path: string;
|
|
13
|
+
hasDomainSpecs: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface LargeModifiedRequirement {
|
|
17
|
+
name: string;
|
|
18
|
+
lineCount: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface DestructiveDeltaReport {
|
|
22
|
+
destructive: boolean;
|
|
23
|
+
removedRequirements: string[];
|
|
24
|
+
largeModifiedRequirements: LargeModifiedRequirement[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface DestructiveDeltaOptions {
|
|
28
|
+
largeModifiedLineThreshold?: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function safeDirectories(path: string): string[] {
|
|
32
|
+
try {
|
|
33
|
+
return readdirSync(path).filter((entry) => {
|
|
34
|
+
try {
|
|
35
|
+
return statSync(join(path, entry)).isDirectory();
|
|
36
|
+
} catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
} catch {
|
|
41
|
+
return [];
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function hasAnyDomainSpec(specsDir: string): boolean {
|
|
46
|
+
for (const domain of safeDirectories(specsDir)) {
|
|
47
|
+
if (existsSync(join(specsDir, domain, "spec.md"))) return true;
|
|
48
|
+
}
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function detectActiveDomainCollisions(
|
|
53
|
+
cwd: string,
|
|
54
|
+
changeName: string,
|
|
55
|
+
domain: string,
|
|
56
|
+
): DomainCollision[] {
|
|
57
|
+
const changesDir = join(cwd, "openspec", "changes");
|
|
58
|
+
const collisions: DomainCollision[] = [];
|
|
59
|
+
for (const change of safeDirectories(changesDir)) {
|
|
60
|
+
if (change === "archive" || change === changeName) continue;
|
|
61
|
+
const path = join(changesDir, change, "specs", domain, "spec.md");
|
|
62
|
+
if (existsSync(path)) collisions.push({ change, path });
|
|
63
|
+
}
|
|
64
|
+
return collisions;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function detectLegacyFlatSpec(
|
|
68
|
+
cwd: string,
|
|
69
|
+
changeName: string,
|
|
70
|
+
): LegacyFlatSpecWarning | undefined {
|
|
71
|
+
const changeDir = join(cwd, "openspec", "changes", changeName);
|
|
72
|
+
const path = join(changeDir, "spec.md");
|
|
73
|
+
if (!existsSync(path)) return undefined;
|
|
74
|
+
return {
|
|
75
|
+
change: changeName,
|
|
76
|
+
path,
|
|
77
|
+
hasDomainSpecs: hasAnyDomainSpec(join(changeDir, "specs")),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function analyzeDeltaDestructiveness(
|
|
82
|
+
deltaMarkdown: string,
|
|
83
|
+
options: DestructiveDeltaOptions = {},
|
|
84
|
+
): DestructiveDeltaReport {
|
|
85
|
+
const threshold = options.largeModifiedLineThreshold ?? 40;
|
|
86
|
+
const delta = parseDeltaSpec(deltaMarkdown);
|
|
87
|
+
const removedRequirements = delta.removed.map((block) => block.name);
|
|
88
|
+
const largeModifiedRequirements = delta.modified
|
|
89
|
+
.map((block) => ({
|
|
90
|
+
name: block.name,
|
|
91
|
+
lineCount: block.content.split("\n").length,
|
|
92
|
+
}))
|
|
93
|
+
.filter((block) => block.lineCount >= threshold);
|
|
94
|
+
return {
|
|
95
|
+
destructive: removedRequirements.length > 0 || largeModifiedRequirements.length > 0,
|
|
96
|
+
removedRequirements,
|
|
97
|
+
largeModifiedRequirements,
|
|
98
|
+
};
|
|
99
|
+
}
|
package/lib/sdd-preflight.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
2
3
|
import { dirname, join } from "node:path";
|
|
3
4
|
import { fileURLToPath } from "node:url";
|
|
4
5
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
@@ -6,6 +7,10 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
|
|
|
6
7
|
const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
7
8
|
const ASSETS_DIR = join(PACKAGE_ROOT, "assets");
|
|
8
9
|
|
|
10
|
+
function gentlePiAgentHome(): string {
|
|
11
|
+
return process.env.GENTLE_PI_AGENT_HOME ?? join(homedir(), ".pi", "agent");
|
|
12
|
+
}
|
|
13
|
+
|
|
9
14
|
export type SddExecutionMode = "interactive" | "auto";
|
|
10
15
|
export type SddArtifactStore = "openspec" | "engram" | "both";
|
|
11
16
|
export type SddChainedPrStrategy =
|
|
@@ -89,22 +94,23 @@ function copyDirectoryFiles(
|
|
|
89
94
|
}
|
|
90
95
|
|
|
91
96
|
export function installSddAssets(
|
|
92
|
-
|
|
97
|
+
_cwd: string,
|
|
93
98
|
force: boolean,
|
|
94
99
|
): { agents: number; chains: number; support: number; skipped: number } {
|
|
100
|
+
const agentHome = gentlePiAgentHome();
|
|
95
101
|
const agents = copyDirectoryFiles(
|
|
96
102
|
join(ASSETS_DIR, "agents"),
|
|
97
|
-
join(
|
|
103
|
+
join(agentHome, "agents"),
|
|
98
104
|
force,
|
|
99
105
|
);
|
|
100
106
|
const chains = copyDirectoryFiles(
|
|
101
107
|
join(ASSETS_DIR, "chains"),
|
|
102
|
-
join(
|
|
108
|
+
join(agentHome, "chains"),
|
|
103
109
|
force,
|
|
104
110
|
);
|
|
105
111
|
const support = copyDirectoryFiles(
|
|
106
112
|
join(ASSETS_DIR, "support"),
|
|
107
|
-
join(
|
|
113
|
+
join(agentHome, "gentle-ai", "support"),
|
|
108
114
|
force,
|
|
109
115
|
);
|
|
110
116
|
return {
|
|
@@ -248,7 +254,7 @@ export async function ensureSddPreflight(
|
|
|
248
254
|
`Artifacts: ${prefs.artifactStore}`,
|
|
249
255
|
`PR chaining: ${prefs.chainedPrStrategy}`,
|
|
250
256
|
`Review budget: ${prefs.reviewBudgetLines} changed lines`,
|
|
251
|
-
`
|
|
257
|
+
`Global SDD assets ready: ${result.agents} agent(s), ${result.chains} chain(s), ${result.support} support file(s), ${result.skipped} already present.`,
|
|
252
258
|
modelRoutingLine,
|
|
253
259
|
].join("\n"),
|
|
254
260
|
modelResult.invalidPath ? "warning" : "info",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gentle-pi",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.5",
|
|
4
4
|
"description": "Turn Pi into el Gentleman: a senior-architect development harness with SDD/OpenSpec, subagents, strict TDD evidence, review guardrails, and skill discovery.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -6,9 +6,21 @@ import { fileURLToPath } from "node:url";
|
|
|
6
6
|
const root = join(fileURLToPath(new URL("..", import.meta.url)));
|
|
7
7
|
|
|
8
8
|
const requiredPaths = [
|
|
9
|
+
"assets/agents/sdd-apply.md",
|
|
10
|
+
"assets/agents/sdd-archive.md",
|
|
11
|
+
"assets/agents/sdd-design.md",
|
|
12
|
+
"assets/agents/sdd-explore.md",
|
|
9
13
|
"assets/agents/sdd-init.md",
|
|
14
|
+
"assets/agents/sdd-proposal.md",
|
|
15
|
+
"assets/agents/sdd-spec.md",
|
|
16
|
+
"assets/agents/sdd-sync.md",
|
|
17
|
+
"assets/agents/sdd-tasks.md",
|
|
18
|
+
"assets/agents/sdd-verify.md",
|
|
19
|
+
"assets/chains/sdd-full.chain.md",
|
|
10
20
|
"assets/chains/sdd-plan.chain.md",
|
|
21
|
+
"assets/chains/sdd-verify.chain.md",
|
|
11
22
|
"assets/support/strict-tdd.md",
|
|
23
|
+
"assets/support/strict-tdd-verify.md",
|
|
12
24
|
"extensions/gentle-ai.ts",
|
|
13
25
|
"extensions/sdd-init.ts",
|
|
14
26
|
"extensions/skill-registry.ts",
|
|
@@ -55,4 +55,4 @@ Hard delegation triggers:
|
|
|
55
55
|
- **Incident rule**: after wrong cwd, accidental worktree/repo mutation, merge recovery, confusing test command, or environment workaround, run fresh audit.
|
|
56
56
|
- **Long-session rule**: after roughly 20 tool calls, 5 exploratory reads, or 2 non-mechanical edits with no delegation and accumulating complexity, pause and choose a subagent or justify not doing so.
|
|
57
57
|
|
|
58
|
-
The package
|
|
58
|
+
The package ensures SDD agents and chains are available as global Pi runtime assets. Project-local SDD files are overrides/debug copies only. Use `/gentle-ai:install-sdd --force` only for recovery or intentional global refresh.
|
|
@@ -13,7 +13,7 @@ Load this skill only when the user explicitly asks for Judgment Day, dual/advers
|
|
|
13
13
|
|
|
14
14
|
## Hard Rules
|
|
15
15
|
|
|
16
|
-
- Resolve project skills before launching agents: read skill registry, match
|
|
16
|
+
- Resolve project skills before launching agents: read skill registry, match indexed paths by target files/task, and pass the same `Skills to load before work` block into both judge prompts and fix prompts.
|
|
17
17
|
- Launch **two blind judges in parallel** with identical target and criteria; never review the code yourself.
|
|
18
18
|
- Wait for both judges before synthesis; never accept a partial verdict.
|
|
19
19
|
- Classify warnings as `WARNING (real)` only if normal intended use can trigger them; otherwise downgrade to INFO as `WARNING (theoretical)`.
|
|
@@ -8,8 +8,8 @@ You are an adversarial code reviewer. Your ONLY job is to find problems.
|
|
|
8
8
|
## Target
|
|
9
9
|
{files, feature, architecture, component}
|
|
10
10
|
|
|
11
|
-
##
|
|
12
|
-
{matching
|
|
11
|
+
## Skills to load before work
|
|
12
|
+
{matching SKILL.md paths, if available}
|
|
13
13
|
|
|
14
14
|
## Review Criteria
|
|
15
15
|
- Correctness: logical errors and behavior mismatches
|
|
@@ -33,7 +33,7 @@ WARNING rule: normal intended use can trigger it → `WARNING (real)`; contrived
|
|
|
33
33
|
|
|
34
34
|
If clean: `VERDICT: CLEAN — No issues found.`
|
|
35
35
|
|
|
36
|
-
Always end with: `Skill Resolution: {injected|fallback-registry|fallback-path|none} — {details}`.
|
|
36
|
+
Always end with: `Skill Resolution: {paths-injected|fallback-registry|fallback-path|none} — {details}`.
|
|
37
37
|
```
|
|
38
38
|
|
|
39
39
|
## Fix Agent Prompt
|
|
@@ -44,8 +44,8 @@ You are a surgical fix agent. Apply ONLY the confirmed issues listed below.
|
|
|
44
44
|
## Confirmed Issues to Fix
|
|
45
45
|
{confirmed findings table}
|
|
46
46
|
|
|
47
|
-
##
|
|
48
|
-
{matching
|
|
47
|
+
## Skills to load before work
|
|
48
|
+
{matching SKILL.md paths, if available}
|
|
49
49
|
|
|
50
50
|
## Instructions
|
|
51
51
|
- Fix only confirmed issues.
|
|
@@ -54,7 +54,7 @@ You are a surgical fix agent. Apply ONLY the confirmed issues listed below.
|
|
|
54
54
|
- If fixing a repeated pattern in touched files, fix all occurrences of that same pattern.
|
|
55
55
|
- Return changed file, line, and fix summary.
|
|
56
56
|
|
|
57
|
-
End with: `Skill Resolution: {injected|fallback-registry|fallback-path|none} — {details}`.
|
|
57
|
+
End with: `Skill Resolution: {paths-injected|fallback-registry|fallback-path|none} — {details}`.
|
|
58
58
|
```
|
|
59
59
|
|
|
60
60
|
## Verdict Table
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: skill-registry
|
|
3
|
+
description: "Trigger: update skills, skill registry, actualizar skills, after skill changes. Index available skills by trigger and path."
|
|
4
|
+
license: MIT
|
|
5
|
+
metadata:
|
|
6
|
+
author: gentleman-programming
|
|
7
|
+
version: "1.0"
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## Activation Contract
|
|
11
|
+
|
|
12
|
+
Use this skill after installing, removing, creating, moving, or renaming skills, or when a delegator needs a fresh skill index.
|
|
13
|
+
|
|
14
|
+
## Hard Rules
|
|
15
|
+
|
|
16
|
+
- The registry is an index, not a compiler or summary. `SKILL.md` remains the source of truth.
|
|
17
|
+
- Do not generate or inject compact rules by default; preserve author intent by passing exact skill paths to subagents.
|
|
18
|
+
- Always write `.atl/skill-registry.md` regardless of SDD persistence mode.
|
|
19
|
+
- Save the registry to Engram as `topic_key: skill-registry` when available, with `capture_prompt: false`.
|
|
20
|
+
- Skip `sdd-*`, `_shared`, and `skill-registry`; deduplicate by skill name, preferring project-level skills over user-level skills.
|
|
21
|
+
- Add `.atl/` to `.gitignore` when possible unless explicitly disabled.
|
|
22
|
+
|
|
23
|
+
## Decision Gates
|
|
24
|
+
|
|
25
|
+
| Situation | Action |
|
|
26
|
+
| --- | --- |
|
|
27
|
+
| Same skill exists globally and in project | Keep the project-level skill |
|
|
28
|
+
| Same skill exists in multiple global locations | Keep the first source in scan order |
|
|
29
|
+
| No skills found | Write an empty registry so agents stop searching blindly |
|
|
30
|
+
| Agent will delegate work | Select matching registry rows and pass their `SKILL.md` paths |
|
|
31
|
+
|
|
32
|
+
## Execution Steps
|
|
33
|
+
|
|
34
|
+
1. Scan all known user and project skill directories for `*/SKILL.md`.
|
|
35
|
+
2. Read frontmatter only as needed to extract `name` and `description` trigger text.
|
|
36
|
+
3. Render `.atl/skill-registry.md` with scanned sources, registry contract, skill name, trigger/description, scope, and exact path.
|
|
37
|
+
4. Persist to Engram when available using `title: skill-registry`, `topic_key: skill-registry`, `type: config`, and `capture_prompt: false`.
|
|
38
|
+
5. Return the registry path, skill count, cache status, and whether Engram was updated.
|
|
39
|
+
|
|
40
|
+
## Output Contract
|
|
41
|
+
|
|
42
|
+
Return:
|
|
43
|
+
- Project name and `.atl/skill-registry.md` path.
|
|
44
|
+
- Number of indexed skills.
|
|
45
|
+
- Whether the cache was hit or regenerated.
|
|
46
|
+
- Any skipped or duplicate skills when relevant.
|
|
47
|
+
|
|
48
|
+
## References
|
|
49
|
+
|
|
50
|
+
- `docs/skill-style-guide.md` — how skills should be authored before indexing.
|
|
51
|
+
- `skills/_shared/skill-resolver.md` — how delegators use the index.
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
applyDeltaSpec,
|
|
5
|
+
parseDeltaSpec,
|
|
6
|
+
parseRequirementBlocks,
|
|
7
|
+
} from "../lib/openspec-deltas.ts";
|
|
8
|
+
|
|
9
|
+
const canonicalSpec = `# Example Specification
|
|
10
|
+
|
|
11
|
+
## Purpose
|
|
12
|
+
|
|
13
|
+
Example domain.
|
|
14
|
+
|
|
15
|
+
## Requirements
|
|
16
|
+
|
|
17
|
+
### Requirement: Existing Behavior
|
|
18
|
+
|
|
19
|
+
The system MUST keep existing behavior.
|
|
20
|
+
|
|
21
|
+
#### Scenario: Happy path
|
|
22
|
+
|
|
23
|
+
- GIVEN an existing condition
|
|
24
|
+
- WHEN the action runs
|
|
25
|
+
- THEN existing behavior is preserved
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
### Requirement: Deprecated Behavior
|
|
30
|
+
|
|
31
|
+
The system MUST support old behavior.
|
|
32
|
+
|
|
33
|
+
#### Scenario: Old path
|
|
34
|
+
|
|
35
|
+
- GIVEN an old condition
|
|
36
|
+
- WHEN the action runs
|
|
37
|
+
- THEN old behavior is preserved
|
|
38
|
+
`;
|
|
39
|
+
|
|
40
|
+
const deltaSpec = `# Delta for Example
|
|
41
|
+
|
|
42
|
+
## ADDED Requirements
|
|
43
|
+
|
|
44
|
+
### Requirement: New Behavior
|
|
45
|
+
|
|
46
|
+
The system MUST support new behavior.
|
|
47
|
+
|
|
48
|
+
#### Scenario: New path
|
|
49
|
+
|
|
50
|
+
- GIVEN a new condition
|
|
51
|
+
- WHEN the action runs
|
|
52
|
+
- THEN new behavior is available
|
|
53
|
+
|
|
54
|
+
## MODIFIED Requirements
|
|
55
|
+
|
|
56
|
+
### Requirement: Existing Behavior
|
|
57
|
+
|
|
58
|
+
The system MUST keep existing behavior and report audit evidence.
|
|
59
|
+
(Previously: existing behavior did not report audit evidence)
|
|
60
|
+
|
|
61
|
+
#### Scenario: Happy path
|
|
62
|
+
|
|
63
|
+
- GIVEN an existing condition
|
|
64
|
+
- WHEN the action runs
|
|
65
|
+
- THEN existing behavior is preserved
|
|
66
|
+
- AND audit evidence is recorded
|
|
67
|
+
|
|
68
|
+
## REMOVED Requirements
|
|
69
|
+
|
|
70
|
+
### Requirement: Deprecated Behavior
|
|
71
|
+
|
|
72
|
+
(Reason: old behavior is no longer supported)
|
|
73
|
+
`;
|
|
74
|
+
|
|
75
|
+
test("parseRequirementBlocks extracts requirement blocks with names", () => {
|
|
76
|
+
const blocks = parseRequirementBlocks(canonicalSpec);
|
|
77
|
+
|
|
78
|
+
assert.deepEqual(
|
|
79
|
+
blocks.map((block) => block.name),
|
|
80
|
+
["Existing Behavior", "Deprecated Behavior"],
|
|
81
|
+
);
|
|
82
|
+
assert.match(blocks[0].content, /Scenario: Happy path/);
|
|
83
|
+
assert.match(blocks[1].content, /old behavior/i);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("parseDeltaSpec extracts ADDED, MODIFIED, and REMOVED sections", () => {
|
|
87
|
+
const delta = parseDeltaSpec(deltaSpec);
|
|
88
|
+
|
|
89
|
+
assert.deepEqual(
|
|
90
|
+
delta.added.map((block) => block.name),
|
|
91
|
+
["New Behavior"],
|
|
92
|
+
);
|
|
93
|
+
assert.deepEqual(
|
|
94
|
+
delta.modified.map((block) => block.name),
|
|
95
|
+
["Existing Behavior"],
|
|
96
|
+
);
|
|
97
|
+
assert.deepEqual(
|
|
98
|
+
delta.removed.map((block) => block.name),
|
|
99
|
+
["Deprecated Behavior"],
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("applyDeltaSpec applies ADDED, MODIFIED, and REMOVED while preserving unrelated content", () => {
|
|
104
|
+
const result = applyDeltaSpec(canonicalSpec, deltaSpec);
|
|
105
|
+
|
|
106
|
+
assert.match(result, /### Requirement: New Behavior/);
|
|
107
|
+
assert.match(result, /audit evidence is recorded/);
|
|
108
|
+
assert.doesNotMatch(result, /### Requirement: Deprecated Behavior/);
|
|
109
|
+
assert.match(result, /# Example Specification/);
|
|
110
|
+
assert.match(result, /## Purpose/);
|
|
111
|
+
assert.match(result, /## Requirements/);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("applyDeltaSpec preserves sections after Requirements when appending ADDED", () => {
|
|
115
|
+
const result = applyDeltaSpec(
|
|
116
|
+
`${canonicalSpec}\n## Notes\n\nKeep this section.\n`,
|
|
117
|
+
`# Delta
|
|
118
|
+
|
|
119
|
+
## ADDED Requirements
|
|
120
|
+
|
|
121
|
+
### Requirement: New Behavior
|
|
122
|
+
|
|
123
|
+
The system MUST support new behavior.
|
|
124
|
+
`,
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
assert.match(result, /### Requirement: New Behavior[\s\S]*\n\n## Notes\n\nKeep this section\./);
|
|
128
|
+
assert.doesNotMatch(result, /Behavior## Notes/);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("applyDeltaSpec does not duplicate separators between multiple ADDED requirements", () => {
|
|
132
|
+
const result = applyDeltaSpec(
|
|
133
|
+
canonicalSpec,
|
|
134
|
+
`# Delta
|
|
135
|
+
|
|
136
|
+
## ADDED Requirements
|
|
137
|
+
|
|
138
|
+
### Requirement: First New Behavior
|
|
139
|
+
|
|
140
|
+
The system MUST support the first behavior.
|
|
141
|
+
|
|
142
|
+
---
|
|
143
|
+
|
|
144
|
+
### Requirement: Second New Behavior
|
|
145
|
+
|
|
146
|
+
The system MUST support the second behavior.
|
|
147
|
+
`,
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
assert.match(result, /### Requirement: First New Behavior[\s\S]*---[\s\S]*### Requirement: Second New Behavior/);
|
|
151
|
+
assert.doesNotMatch(result, /---\n\n---/);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("applyDeltaSpec rejects MODIFIED requirements that do not exist", () => {
|
|
155
|
+
assert.throws(
|
|
156
|
+
() =>
|
|
157
|
+
applyDeltaSpec(
|
|
158
|
+
canonicalSpec,
|
|
159
|
+
`# Delta
|
|
160
|
+
|
|
161
|
+
## MODIFIED Requirements
|
|
162
|
+
|
|
163
|
+
### Requirement: Missing Behavior
|
|
164
|
+
|
|
165
|
+
The system MUST fail.
|
|
166
|
+
`,
|
|
167
|
+
),
|
|
168
|
+
/missing canonical requirement.*Missing Behavior/i,
|
|
169
|
+
);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("applyDeltaSpec rejects REMOVED requirements that do not exist", () => {
|
|
173
|
+
assert.throws(
|
|
174
|
+
() =>
|
|
175
|
+
applyDeltaSpec(
|
|
176
|
+
canonicalSpec,
|
|
177
|
+
`# Delta
|
|
178
|
+
|
|
179
|
+
## REMOVED Requirements
|
|
180
|
+
|
|
181
|
+
### Requirement: Missing Behavior
|
|
182
|
+
|
|
183
|
+
(Reason: already absent)
|
|
184
|
+
`,
|
|
185
|
+
),
|
|
186
|
+
/missing canonical requirement.*Missing Behavior/i,
|
|
187
|
+
);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("applyDeltaSpec rejects duplicate operations for the same requirement", () => {
|
|
191
|
+
assert.throws(
|
|
192
|
+
() =>
|
|
193
|
+
parseDeltaSpec(`# Delta
|
|
194
|
+
|
|
195
|
+
## ADDED Requirements
|
|
196
|
+
|
|
197
|
+
### Requirement: Same Behavior
|
|
198
|
+
|
|
199
|
+
The system MUST do one thing.
|
|
200
|
+
|
|
201
|
+
## REMOVED Requirements
|
|
202
|
+
|
|
203
|
+
### Requirement: Same Behavior
|
|
204
|
+
|
|
205
|
+
(Reason: conflict)
|
|
206
|
+
`),
|
|
207
|
+
/duplicate delta operation.*Same Behavior/i,
|
|
208
|
+
);
|
|
209
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { mkdtemp } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import test from "node:test";
|
|
7
|
+
import {
|
|
8
|
+
analyzeDeltaDestructiveness,
|
|
9
|
+
detectActiveDomainCollisions,
|
|
10
|
+
detectLegacyFlatSpec,
|
|
11
|
+
} from "../lib/openspec-guardrails.ts";
|
|
12
|
+
|
|
13
|
+
test("detectActiveDomainCollisions finds other active changes touching the same domain", async () => {
|
|
14
|
+
const cwd = await mkdtemp(join(tmpdir(), "gentle-pi-guardrails-"));
|
|
15
|
+
mkdirSync(join(cwd, "openspec/changes/current/specs/sdd-openspec"), { recursive: true });
|
|
16
|
+
mkdirSync(join(cwd, "openspec/changes/other/specs/sdd-openspec"), { recursive: true });
|
|
17
|
+
mkdirSync(join(cwd, "openspec/changes/archive/2026-01-01-old/specs/sdd-openspec"), { recursive: true });
|
|
18
|
+
writeFileSync(join(cwd, "openspec/changes/current/specs/sdd-openspec/spec.md"), "# Current\n");
|
|
19
|
+
writeFileSync(join(cwd, "openspec/changes/other/specs/sdd-openspec/spec.md"), "# Other\n");
|
|
20
|
+
writeFileSync(join(cwd, "openspec/changes/archive/2026-01-01-old/specs/sdd-openspec/spec.md"), "# Old\n");
|
|
21
|
+
|
|
22
|
+
const collisions = detectActiveDomainCollisions(cwd, "current", "sdd-openspec");
|
|
23
|
+
|
|
24
|
+
assert.deepEqual(collisions.map((collision) => collision.change), ["other"]);
|
|
25
|
+
assert.match(collisions[0].path, /openspec\/changes\/other\/specs\/sdd-openspec\/spec\.md$/);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("detectLegacyFlatSpec warns when a flat change spec exists without domain specs", async () => {
|
|
29
|
+
const cwd = await mkdtemp(join(tmpdir(), "gentle-pi-legacy-flat-"));
|
|
30
|
+
mkdirSync(join(cwd, "openspec/changes/legacy-change"), { recursive: true });
|
|
31
|
+
writeFileSync(join(cwd, "openspec/changes/legacy-change/spec.md"), "# Legacy\n");
|
|
32
|
+
|
|
33
|
+
assert.deepEqual(detectLegacyFlatSpec(cwd, "legacy-change"), {
|
|
34
|
+
change: "legacy-change",
|
|
35
|
+
path: join(cwd, "openspec/changes/legacy-change/spec.md"),
|
|
36
|
+
hasDomainSpecs: false,
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("detectLegacyFlatSpec reports domain specs when both old and new layouts exist", async () => {
|
|
41
|
+
const cwd = await mkdtemp(join(tmpdir(), "gentle-pi-legacy-both-"));
|
|
42
|
+
mkdirSync(join(cwd, "openspec/changes/mixed/specs/domain"), { recursive: true });
|
|
43
|
+
writeFileSync(join(cwd, "openspec/changes/mixed/spec.md"), "# Legacy\n");
|
|
44
|
+
writeFileSync(join(cwd, "openspec/changes/mixed/specs/domain/spec.md"), "# Domain\n");
|
|
45
|
+
|
|
46
|
+
assert.equal(detectLegacyFlatSpec(cwd, "mixed")?.hasDomainSpecs, true);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("analyzeDeltaDestructiveness reports removed and large modified requirements", () => {
|
|
50
|
+
const report = analyzeDeltaDestructiveness(
|
|
51
|
+
`# Delta
|
|
52
|
+
|
|
53
|
+
## MODIFIED Requirements
|
|
54
|
+
|
|
55
|
+
### Requirement: Big Replacement
|
|
56
|
+
|
|
57
|
+
${Array.from({ length: 15 }, (_, index) => `Line ${index + 1}`).join("\n")}
|
|
58
|
+
|
|
59
|
+
## REMOVED Requirements
|
|
60
|
+
|
|
61
|
+
### Requirement: Removed Behavior
|
|
62
|
+
|
|
63
|
+
(Reason: no longer supported)
|
|
64
|
+
`,
|
|
65
|
+
{ largeModifiedLineThreshold: 10 },
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
assert.equal(report.destructive, true);
|
|
69
|
+
assert.deepEqual(report.removedRequirements, ["Removed Behavior"]);
|
|
70
|
+
assert.deepEqual(report.largeModifiedRequirements.map((item) => item.name), ["Big Replacement"]);
|
|
71
|
+
});
|