@osovv/grace-cli 3.1.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 +200 -0
- package/package.json +50 -0
- package/src/grace-lint.ts +782 -0
- package/src/grace.ts +20 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 GRACE Framework Contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# GRACE Framework - AI Agent Skills
|
|
2
|
+
|
|
3
|
+
**GRACE** (Graph-RAG Anchored Code Engineering) is a methodology for AI-driven code generation with semantic markup, knowledge graphs, contracts, and log-driven verification. Originally created by **Vladimir Ivanov** ([@turboplanner](https://t.me/turboplanner)).
|
|
4
|
+
|
|
5
|
+
This repository packages GRACE as reusable skills for coding agents. The current workflow is opinionated around:
|
|
6
|
+
|
|
7
|
+
- contract-first planning
|
|
8
|
+
- verification-first execution
|
|
9
|
+
- semantic markup for navigation and patching
|
|
10
|
+
- knowledge-graph synchronization
|
|
11
|
+
- controller-managed sequential or multi-agent implementation
|
|
12
|
+
|
|
13
|
+
Current packaged version: `3.1.0`
|
|
14
|
+
|
|
15
|
+
## What Changed In This Version
|
|
16
|
+
|
|
17
|
+
- Added `grace-refactor` for safe rename/move/split/merge/extract workflows with graph and verification synchronization.
|
|
18
|
+
- Added `docs/operational-packets.xml` as the canonical reference for execution packets, graph deltas, verification deltas, and failure packets.
|
|
19
|
+
- Added a Bun-based `grace-lint` CLI built on `citty` for GRACE semantic markup and XML integrity checks.
|
|
20
|
+
|
|
21
|
+
## Repository Layout
|
|
22
|
+
|
|
23
|
+
- `skills/grace/` - Agent Skills format
|
|
24
|
+
- `.claude-plugin/` - Claude Code marketplace packaging
|
|
25
|
+
- `openpackage.yml` - OpenPackage metadata
|
|
26
|
+
|
|
27
|
+
## Installation
|
|
28
|
+
|
|
29
|
+
### Via OpenPackage (recommended)
|
|
30
|
+
|
|
31
|
+
Install the [OpenPackage CLI](https://github.com/enulus/OpenPackage) first (`npm install -g opkg`), then:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
# Install GRACE to your workspace
|
|
35
|
+
opkg install gh@osovv/grace-marketplace
|
|
36
|
+
|
|
37
|
+
# Or install globally
|
|
38
|
+
opkg install gh@osovv/grace-marketplace -g
|
|
39
|
+
|
|
40
|
+
# Install only specific resource types
|
|
41
|
+
opkg install gh@osovv/grace-marketplace -s
|
|
42
|
+
opkg install gh@osovv/grace-marketplace -a
|
|
43
|
+
|
|
44
|
+
# Install to a specific platform
|
|
45
|
+
opkg install gh@osovv/grace-marketplace --platforms claude-code
|
|
46
|
+
opkg install gh@osovv/grace-marketplace --platforms cursor
|
|
47
|
+
opkg install gh@osovv/grace-marketplace --platforms opencode
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### Via Claude Code Plugin Marketplace
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
/plugin marketplace add osovv/grace-marketplace
|
|
54
|
+
/plugin install grace@grace-marketplace
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Via npx skills (Vercel Skills CLI)
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
npx skills add osovv/grace-marketplace
|
|
61
|
+
npx skills add osovv/grace-marketplace -g
|
|
62
|
+
npx skills add osovv/grace-marketplace -a claude-code
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
> Browse more skills at [skills.sh](https://skills.sh)
|
|
66
|
+
|
|
67
|
+
### Via Bun (CLI)
|
|
68
|
+
|
|
69
|
+
Requires `bun` on `PATH`. The published CLI keeps the Bun shebang and installs the `grace` binary.
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
bun add -g @osovv/grace-cli
|
|
73
|
+
grace lint --path /path/to/grace-project
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Via Codex CLI
|
|
77
|
+
|
|
78
|
+
Inside Codex, use the built-in skill installer:
|
|
79
|
+
|
|
80
|
+
```text
|
|
81
|
+
$skill-installer install https://github.com/osovv/grace-marketplace/tree/main/skills/grace/grace-init
|
|
82
|
+
$skill-installer install https://github.com/osovv/grace-marketplace/tree/main/skills/grace/grace-plan
|
|
83
|
+
$skill-installer install https://github.com/osovv/grace-marketplace/tree/main/skills/grace/grace-execute
|
|
84
|
+
$skill-installer install https://github.com/osovv/grace-marketplace/tree/main/skills/grace/grace-multiagent-execute
|
|
85
|
+
$skill-installer install https://github.com/osovv/grace-marketplace/tree/main/skills/grace/grace-refactor
|
|
86
|
+
$skill-installer install https://github.com/osovv/grace-marketplace/tree/main/skills/grace/grace-setup-subagents
|
|
87
|
+
$skill-installer install https://github.com/osovv/grace-marketplace/tree/main/skills/grace/grace-fix
|
|
88
|
+
$skill-installer install https://github.com/osovv/grace-marketplace/tree/main/skills/grace/grace-refresh
|
|
89
|
+
$skill-installer install https://github.com/osovv/grace-marketplace/tree/main/skills/grace/grace-status
|
|
90
|
+
$skill-installer install https://github.com/osovv/grace-marketplace/tree/main/skills/grace/grace-ask
|
|
91
|
+
$skill-installer install https://github.com/osovv/grace-marketplace/tree/main/skills/grace/grace-explainer
|
|
92
|
+
$skill-installer install https://github.com/osovv/grace-marketplace/tree/main/skills/grace/grace-verification
|
|
93
|
+
$skill-installer install https://github.com/osovv/grace-marketplace/tree/main/skills/grace/grace-reviewer
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
After installation, restart Codex to activate the skills.
|
|
97
|
+
|
|
98
|
+
### Via Kilo Code
|
|
99
|
+
|
|
100
|
+
Copy skills to your Kilo Code skills directory:
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
git clone https://github.com/osovv/grace-marketplace
|
|
104
|
+
cp -r grace-marketplace/skills/grace/grace-* ~/.kilocode/skills/
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### Any Agent Skills-compatible agent
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
git clone https://github.com/osovv/grace-marketplace
|
|
111
|
+
cp -r grace-marketplace/skills/grace/grace-* /path/to/your/agent/skills/
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Quick Start
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
# 1. Bootstrap GRACE docs and templates
|
|
118
|
+
/grace-init
|
|
119
|
+
|
|
120
|
+
# 2. Fill requirements.xml and technology.xml
|
|
121
|
+
|
|
122
|
+
# 3. Plan modules, flows, graph, and verification refs
|
|
123
|
+
/grace-plan
|
|
124
|
+
|
|
125
|
+
# 4. Deepen testing, traces, and log-driven evidence
|
|
126
|
+
/grace-verification
|
|
127
|
+
|
|
128
|
+
# 5a. Execute the plan sequentially
|
|
129
|
+
/grace-execute
|
|
130
|
+
|
|
131
|
+
# 5b. Execute in parallel-safe waves
|
|
132
|
+
/grace-multiagent-execute
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
`/grace-multiagent-execute` supports `safe`, `balanced`, and `fast` controller profiles. Use `balanced` by default, `safe` for risky or weakly verified modules, and `fast` only when module-local and wave-level verification are already strong.
|
|
136
|
+
|
|
137
|
+
## Core Artifacts
|
|
138
|
+
|
|
139
|
+
- `docs/requirements.xml` - product intent and use cases
|
|
140
|
+
- `docs/technology.xml` - runtime, tooling, testing, observability, constraints
|
|
141
|
+
- `docs/development-plan.xml` - modules, contracts, flows, phases, execution ownership
|
|
142
|
+
- `docs/verification-plan.xml` - tests, traces, required log markers, and gates
|
|
143
|
+
- `docs/knowledge-graph.xml` - project navigation graph
|
|
144
|
+
- `docs/operational-packets.xml` - canonical execution packet, delta, and failure handoff templates
|
|
145
|
+
|
|
146
|
+
## Skills
|
|
147
|
+
|
|
148
|
+
| Skill | Description |
|
|
149
|
+
|---|---|
|
|
150
|
+
| `grace-init` | Bootstrap GRACE docs, AGENTS, and XML templates |
|
|
151
|
+
| `grace-plan` | Architect modules, flows, knowledge graph, and verification refs |
|
|
152
|
+
| `grace-verification` | Design and maintain tests, traces, and log-driven evidence |
|
|
153
|
+
| `grace-execute` | Execute the full plan sequentially with scoped review and commits |
|
|
154
|
+
| `grace-multiagent-execute` | Execute independent modules in controller-managed parallel waves |
|
|
155
|
+
| `grace-refactor` | Refactor modules safely while keeping contracts, graph, and verification synchronized |
|
|
156
|
+
| `grace-setup-subagents` | Scaffold shell-specific GRACE worker and reviewer presets |
|
|
157
|
+
| `grace-fix` | Debug via semantic navigation, tests, and log markers |
|
|
158
|
+
| `grace-refresh` | Sync shared artifacts with the real codebase |
|
|
159
|
+
| `grace-status` | Project health report across docs, graph, and verification |
|
|
160
|
+
| `grace-ask` | Answer questions with full project context |
|
|
161
|
+
| `grace-explainer` | Complete GRACE methodology reference |
|
|
162
|
+
| `grace-reviewer` | Validate semantic markup, contracts, graph, and verification integrity |
|
|
163
|
+
|
|
164
|
+
## Compatibility
|
|
165
|
+
|
|
166
|
+
| Agent | Installation | Format |
|
|
167
|
+
|---|---|---|
|
|
168
|
+
| **Any (via OpenPackage)** | `opkg install` | OpenPackage (`openpackage.yml`) |
|
|
169
|
+
| **Claude Code** | `/plugin install` or `npx skills add` | Native plugin (`.claude-plugin/`) |
|
|
170
|
+
| **Codex CLI** | `$skill-installer` | Agent Skills (`skills/`) |
|
|
171
|
+
| **Kilo Code** | Copy to `~/.kilocode/skills/` | Agent Skills (`skills/`) |
|
|
172
|
+
| **Cursor, Windsurf, etc.** | `opkg install --platforms <name>` | OpenPackage (`openpackage.yml`) |
|
|
173
|
+
| **Other agents** | Copy to agent's skills directory | Agent Skills (`skills/`) |
|
|
174
|
+
|
|
175
|
+
All skills follow the [Agent Skills](https://agentskills.io) open standard and the [OpenPackage](https://github.com/enulus/OpenPackage) specification.
|
|
176
|
+
|
|
177
|
+
## Development
|
|
178
|
+
|
|
179
|
+
Run the marketplace validator from the repository root:
|
|
180
|
+
|
|
181
|
+
```bash
|
|
182
|
+
bun run ./scripts/validate-marketplace.ts
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Run the GRACE lint CLI against a GRACE project:
|
|
186
|
+
|
|
187
|
+
```bash
|
|
188
|
+
bun install
|
|
189
|
+
bun run grace lint --path /path/to/grace-project
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
The validator checks marketplace/plugin metadata sync, version consistency, required fields, `.claude-plugin` structure, and hardcoded absolute paths. In branch or PR context it scopes validation to changed plugins via `git diff origin/main...HEAD`; otherwise it validates all plugins.
|
|
193
|
+
|
|
194
|
+
## Origin
|
|
195
|
+
|
|
196
|
+
GRACE was designed and battle-tested by Vladimir Ivanov ([@turboplanner](https://t.me/turboplanner)). See the [TurboProject](https://t.me/turboproject) Telegram channel for more on the methodology. This repository extracts GRACE into a standalone, project-agnostic format.
|
|
197
|
+
|
|
198
|
+
## License
|
|
199
|
+
|
|
200
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@osovv/grace-cli",
|
|
3
|
+
"version": "3.1.0",
|
|
4
|
+
"description": "GRACE CLI for linting semantic markup, contracts, and GRACE XML artifacts with a Bun-powered grace binary.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"homepage": "https://github.com/osovv/grace-marketplace#readme",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/osovv/grace-marketplace.git"
|
|
10
|
+
},
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/osovv/grace-marketplace/issues"
|
|
13
|
+
},
|
|
14
|
+
"keywords": [
|
|
15
|
+
"grace",
|
|
16
|
+
"cli",
|
|
17
|
+
"lint",
|
|
18
|
+
"bun",
|
|
19
|
+
"semantic-markup",
|
|
20
|
+
"knowledge-graph"
|
|
21
|
+
],
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"src/grace.ts",
|
|
27
|
+
"src/grace-lint.ts",
|
|
28
|
+
"README.md",
|
|
29
|
+
"LICENSE"
|
|
30
|
+
],
|
|
31
|
+
"type": "module",
|
|
32
|
+
"packageManager": "bun@1.3.8",
|
|
33
|
+
"engines": {
|
|
34
|
+
"bun": ">=1.3.8"
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"grace": "bun run ./src/grace.ts",
|
|
38
|
+
"grace:lint": "bun run ./src/grace.ts lint",
|
|
39
|
+
"lint": "bun run ./src/grace.ts lint",
|
|
40
|
+
"test": "bun test",
|
|
41
|
+
"validate:marketplace": "bun run ./scripts/validate-marketplace.ts",
|
|
42
|
+
"prepublishOnly": "bun test && bun run ./src/grace.ts lint --path . --allow-missing-docs && bun run ./scripts/validate-marketplace.ts"
|
|
43
|
+
},
|
|
44
|
+
"bin": {
|
|
45
|
+
"grace": "./src/grace.ts"
|
|
46
|
+
},
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"citty": "^0.2.2"
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,782 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { defineCommand, type CommandDef, runMain } from "citty";
|
|
6
|
+
|
|
7
|
+
export type LintSeverity = "error" | "warning";
|
|
8
|
+
|
|
9
|
+
export type LintIssue = {
|
|
10
|
+
severity: LintSeverity;
|
|
11
|
+
code: string;
|
|
12
|
+
file: string;
|
|
13
|
+
line?: number;
|
|
14
|
+
message: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type LintResult = {
|
|
18
|
+
root: string;
|
|
19
|
+
filesChecked: number;
|
|
20
|
+
governedFiles: number;
|
|
21
|
+
xmlFilesChecked: number;
|
|
22
|
+
issues: LintIssue[];
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type LintOptions = {
|
|
26
|
+
allowMissingDocs?: boolean;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const REQUIRED_DOCS = [
|
|
30
|
+
"docs/knowledge-graph.xml",
|
|
31
|
+
"docs/development-plan.xml",
|
|
32
|
+
"docs/verification-plan.xml",
|
|
33
|
+
] as const;
|
|
34
|
+
|
|
35
|
+
const OPTIONAL_PACKET_DOC = "docs/operational-packets.xml";
|
|
36
|
+
|
|
37
|
+
const CODE_EXTENSIONS = new Set([
|
|
38
|
+
".js",
|
|
39
|
+
".jsx",
|
|
40
|
+
".ts",
|
|
41
|
+
".tsx",
|
|
42
|
+
".mjs",
|
|
43
|
+
".cjs",
|
|
44
|
+
".mts",
|
|
45
|
+
".cts",
|
|
46
|
+
".py",
|
|
47
|
+
".go",
|
|
48
|
+
".java",
|
|
49
|
+
".kt",
|
|
50
|
+
".rs",
|
|
51
|
+
".rb",
|
|
52
|
+
".php",
|
|
53
|
+
".swift",
|
|
54
|
+
".scala",
|
|
55
|
+
".sql",
|
|
56
|
+
".sh",
|
|
57
|
+
".bash",
|
|
58
|
+
".zsh",
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
const IGNORED_DIRS = new Set([
|
|
62
|
+
".git",
|
|
63
|
+
"node_modules",
|
|
64
|
+
"dist",
|
|
65
|
+
"build",
|
|
66
|
+
"coverage",
|
|
67
|
+
".next",
|
|
68
|
+
".turbo",
|
|
69
|
+
".cache",
|
|
70
|
+
]);
|
|
71
|
+
|
|
72
|
+
const TS_LIKE_EXTENSIONS = new Set([
|
|
73
|
+
".js",
|
|
74
|
+
".jsx",
|
|
75
|
+
".ts",
|
|
76
|
+
".tsx",
|
|
77
|
+
".mjs",
|
|
78
|
+
".cjs",
|
|
79
|
+
".mts",
|
|
80
|
+
".cts",
|
|
81
|
+
]);
|
|
82
|
+
|
|
83
|
+
const UNIQUE_TAG_ANTI_PATTERNS = [
|
|
84
|
+
{
|
|
85
|
+
code: "xml.generic-module-tag",
|
|
86
|
+
regex: /<\/?Module(?=[\s>])/g,
|
|
87
|
+
message: 'Use unique module tags like `<M-AUTH>` instead of generic `<Module ID="...">`.',
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
code: "xml.generic-phase-tag",
|
|
91
|
+
regex: /<\/?Phase(?=[\s>])/g,
|
|
92
|
+
message: 'Use unique phase tags like `<Phase-1>` instead of generic `<Phase number="...">`.',
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
code: "xml.generic-flow-tag",
|
|
96
|
+
regex: /<\/?Flow(?=[\s>])/g,
|
|
97
|
+
message: 'Use unique flow tags like `<DF-LOGIN>` instead of generic `<Flow ID="...">`.',
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
code: "xml.generic-use-case-tag",
|
|
101
|
+
regex: /<\/?UseCase(?=[\s>])/g,
|
|
102
|
+
message: 'Use unique use-case tags like `<UC-001>` instead of generic `<UseCase ID="...">`.',
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
code: "xml.generic-step-tag",
|
|
106
|
+
regex: /<\/?step(?=[\s>])/g,
|
|
107
|
+
message: 'Use unique step tags like `<step-1>` instead of generic `<step order="...">`.',
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
code: "xml.generic-export-tag",
|
|
111
|
+
regex: /<\/?export(?=[\s>])/g,
|
|
112
|
+
message: 'Use unique export tags like `<export-run>` instead of generic `<export name="...">`.',
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
code: "xml.generic-function-tag",
|
|
116
|
+
regex: /<\/?function(?=[\s>])/g,
|
|
117
|
+
message: 'Use unique function tags like `<fn-run>` instead of generic `<function name="...">`.',
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
code: "xml.generic-type-tag",
|
|
121
|
+
regex: /<\/?type(?=[\s>])/g,
|
|
122
|
+
message: 'Use unique type tags like `<type-Result>` instead of generic `<type name="...">`.',
|
|
123
|
+
},
|
|
124
|
+
];
|
|
125
|
+
|
|
126
|
+
const TEXT_FORMAT_OPTIONS = new Set(["text", "json"]);
|
|
127
|
+
|
|
128
|
+
function normalizeRelative(root: string, filePath: string) {
|
|
129
|
+
return path.relative(root, filePath) || ".";
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function lineNumberAt(text: string, index: number) {
|
|
133
|
+
return text.slice(0, index).split("\n").length;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function addIssue(result: LintResult, issue: LintIssue) {
|
|
137
|
+
result.issues.push(issue);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function readTextIfExists(filePath: string) {
|
|
141
|
+
return existsSync(filePath) ? readFileSync(filePath, "utf8") : null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function collectCodeFiles(root: string, currentDir = root): string[] {
|
|
145
|
+
const files: string[] = [];
|
|
146
|
+
const entries = readdirSync(currentDir, { withFileTypes: true });
|
|
147
|
+
|
|
148
|
+
for (const entry of entries) {
|
|
149
|
+
if (entry.isDirectory()) {
|
|
150
|
+
if (IGNORED_DIRS.has(entry.name)) {
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
files.push(...collectCodeFiles(root, path.join(currentDir, entry.name)));
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (!entry.isFile()) {
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const filePath = path.join(currentDir, entry.name);
|
|
163
|
+
if (CODE_EXTENSIONS.has(path.extname(filePath))) {
|
|
164
|
+
files.push(filePath);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return files;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function stripQuotedStrings(text: string) {
|
|
172
|
+
let result = "";
|
|
173
|
+
let quote: '"' | "'" | "`" | null = null;
|
|
174
|
+
let escaped = false;
|
|
175
|
+
|
|
176
|
+
for (const char of text) {
|
|
177
|
+
if (!quote) {
|
|
178
|
+
if (char === '"' || char === "'" || char === "`") {
|
|
179
|
+
quote = char;
|
|
180
|
+
result += " ";
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
result += char;
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (escaped) {
|
|
189
|
+
escaped = false;
|
|
190
|
+
result += char === "\n" ? "\n" : " ";
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (char === "\\") {
|
|
195
|
+
escaped = true;
|
|
196
|
+
result += " ";
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (char === quote) {
|
|
201
|
+
quote = null;
|
|
202
|
+
result += " ";
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
result += char === "\n" ? "\n" : " ";
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return result;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function hasGraceMarkers(text: string) {
|
|
213
|
+
const searchable = stripQuotedStrings(text);
|
|
214
|
+
return searchable.split("\n").some((line) => /^(\s*)(\/\/|#|--|\*)\s*(START_MODULE_CONTRACT|START_MODULE_MAP|START_CONTRACT:|START_BLOCK_|START_CHANGE_SUMMARY)/.test(line));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function ensureSectionPair(
|
|
218
|
+
result: LintResult,
|
|
219
|
+
root: string,
|
|
220
|
+
relativePath: string,
|
|
221
|
+
text: string,
|
|
222
|
+
startMarker: string,
|
|
223
|
+
endMarker: string,
|
|
224
|
+
code: string,
|
|
225
|
+
message: string,
|
|
226
|
+
) {
|
|
227
|
+
const startIndex = text.indexOf(startMarker);
|
|
228
|
+
const endIndex = text.indexOf(endMarker);
|
|
229
|
+
|
|
230
|
+
if (startIndex === -1 || endIndex === -1) {
|
|
231
|
+
addIssue(result, {
|
|
232
|
+
severity: "error",
|
|
233
|
+
code,
|
|
234
|
+
file: relativePath,
|
|
235
|
+
line: startIndex === -1 ? undefined : lineNumberAt(text, startIndex),
|
|
236
|
+
message,
|
|
237
|
+
});
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (startIndex > endIndex) {
|
|
242
|
+
addIssue(result, {
|
|
243
|
+
severity: "error",
|
|
244
|
+
code,
|
|
245
|
+
file: relativePath,
|
|
246
|
+
line: lineNumberAt(text, endIndex),
|
|
247
|
+
message: `${message} Found the end marker before the start marker.`,
|
|
248
|
+
});
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const sectionStart = startIndex + startMarker.length;
|
|
253
|
+
return text.slice(sectionStart, endIndex);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function lintScopedMarkers(
|
|
257
|
+
result: LintResult,
|
|
258
|
+
relativePath: string,
|
|
259
|
+
text: string,
|
|
260
|
+
startRegex: RegExp,
|
|
261
|
+
endRegex: RegExp,
|
|
262
|
+
kind: "block" | "contract",
|
|
263
|
+
) {
|
|
264
|
+
const lines = text.split("\n");
|
|
265
|
+
const stack: Array<{ name: string; line: number }> = [];
|
|
266
|
+
const seen = new Set<string>();
|
|
267
|
+
|
|
268
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
269
|
+
const line = lines[index];
|
|
270
|
+
const startMatch = line.match(startRegex);
|
|
271
|
+
const endMatch = line.match(endRegex);
|
|
272
|
+
|
|
273
|
+
if (startMatch?.[1]) {
|
|
274
|
+
const name = startMatch[1];
|
|
275
|
+
if (kind === "block") {
|
|
276
|
+
if (seen.has(name)) {
|
|
277
|
+
addIssue(result, {
|
|
278
|
+
severity: "error",
|
|
279
|
+
code: "markup.duplicate-block-name",
|
|
280
|
+
file: relativePath,
|
|
281
|
+
line: index + 1,
|
|
282
|
+
message: `Semantic block name \`${name}\` is duplicated in this file.`,
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
seen.add(name);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
stack.push({ name, line: index + 1 });
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (endMatch?.[1]) {
|
|
293
|
+
const name = endMatch[1];
|
|
294
|
+
const active = stack[stack.length - 1];
|
|
295
|
+
|
|
296
|
+
if (!active) {
|
|
297
|
+
addIssue(result, {
|
|
298
|
+
severity: "error",
|
|
299
|
+
code: kind === "block" ? "markup.unmatched-block-end" : "markup.unmatched-contract-end",
|
|
300
|
+
file: relativePath,
|
|
301
|
+
line: index + 1,
|
|
302
|
+
message: `Found an unmatched END marker for \`${name}\`.`,
|
|
303
|
+
});
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (active.name !== name) {
|
|
308
|
+
addIssue(result, {
|
|
309
|
+
severity: "error",
|
|
310
|
+
code: kind === "block" ? "markup.mismatched-block-end" : "markup.mismatched-contract-end",
|
|
311
|
+
file: relativePath,
|
|
312
|
+
line: index + 1,
|
|
313
|
+
message: `Expected END marker for \`${active.name}\`, found \`${name}\` instead.`,
|
|
314
|
+
});
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
stack.pop();
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
for (const active of stack) {
|
|
323
|
+
addIssue(result, {
|
|
324
|
+
severity: "error",
|
|
325
|
+
code: kind === "block" ? "markup.missing-block-end" : "markup.missing-contract-end",
|
|
326
|
+
file: relativePath,
|
|
327
|
+
line: active.line,
|
|
328
|
+
message: `Missing END marker for \`${active.name}\`.`,
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function parseModuleMapEntries(section: string) {
|
|
334
|
+
const entries = new Set<string>();
|
|
335
|
+
const lines = section.split("\n");
|
|
336
|
+
|
|
337
|
+
for (const line of lines) {
|
|
338
|
+
const cleaned = line.replace(/^\s*(\/\/|#|--|\*)?\s*/, "").trim();
|
|
339
|
+
if (!cleaned) {
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const match = cleaned.match(/^([A-Za-z_$][\w$]*)\s+-\s+/);
|
|
344
|
+
if (match?.[1]) {
|
|
345
|
+
entries.add(match[1]);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
return entries;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function extractTypeScriptExports(text: string) {
|
|
353
|
+
const exports = new Set<string>();
|
|
354
|
+
const directPatterns = [
|
|
355
|
+
/^\s*export\s+(?:async\s+)?function\s+([A-Za-z_$][\w$]*)/gm,
|
|
356
|
+
/^\s*export\s+(?:const|let|var)\s+([A-Za-z_$][\w$]*)/gm,
|
|
357
|
+
/^\s*export\s+class\s+([A-Za-z_$][\w$]*)/gm,
|
|
358
|
+
/^\s*export\s+(?:interface|type|enum)\s+([A-Za-z_$][\w$]*)/gm,
|
|
359
|
+
];
|
|
360
|
+
|
|
361
|
+
for (const pattern of directPatterns) {
|
|
362
|
+
for (const match of text.matchAll(pattern)) {
|
|
363
|
+
if (match[1]) {
|
|
364
|
+
exports.add(match[1]);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
for (const match of text.matchAll(/^\s*export\s*\{([^}]+)\}/gm)) {
|
|
370
|
+
const names = match[1]
|
|
371
|
+
.split(",")
|
|
372
|
+
.map((part) => part.trim())
|
|
373
|
+
.filter(Boolean);
|
|
374
|
+
|
|
375
|
+
for (const name of names) {
|
|
376
|
+
const aliasMatch = name.match(/^(?:type\s+)?([A-Za-z_$][\w$]*)(?:\s+as\s+([A-Za-z_$][\w$]*))?$/);
|
|
377
|
+
if (!aliasMatch) {
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
exports.add(aliasMatch[2] ?? aliasMatch[1]);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
return exports;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function lintGovernedFile(result: LintResult, root: string, filePath: string, text: string) {
|
|
389
|
+
const relativePath = normalizeRelative(root, filePath);
|
|
390
|
+
result.governedFiles += 1;
|
|
391
|
+
|
|
392
|
+
const moduleContract = ensureSectionPair(
|
|
393
|
+
result,
|
|
394
|
+
root,
|
|
395
|
+
relativePath,
|
|
396
|
+
text,
|
|
397
|
+
"START_MODULE_CONTRACT",
|
|
398
|
+
"END_MODULE_CONTRACT",
|
|
399
|
+
"markup.missing-module-contract",
|
|
400
|
+
"Governed files must include a paired MODULE_CONTRACT section.",
|
|
401
|
+
);
|
|
402
|
+
const moduleMap = ensureSectionPair(
|
|
403
|
+
result,
|
|
404
|
+
root,
|
|
405
|
+
relativePath,
|
|
406
|
+
text,
|
|
407
|
+
"START_MODULE_MAP",
|
|
408
|
+
"END_MODULE_MAP",
|
|
409
|
+
"markup.missing-module-map",
|
|
410
|
+
"Governed files must include a paired MODULE_MAP section.",
|
|
411
|
+
);
|
|
412
|
+
const changeSummary = ensureSectionPair(
|
|
413
|
+
result,
|
|
414
|
+
root,
|
|
415
|
+
relativePath,
|
|
416
|
+
text,
|
|
417
|
+
"START_CHANGE_SUMMARY",
|
|
418
|
+
"END_CHANGE_SUMMARY",
|
|
419
|
+
"markup.missing-change-summary",
|
|
420
|
+
"Governed files must include a paired CHANGE_SUMMARY section.",
|
|
421
|
+
);
|
|
422
|
+
|
|
423
|
+
lintScopedMarkers(
|
|
424
|
+
result,
|
|
425
|
+
relativePath,
|
|
426
|
+
text,
|
|
427
|
+
/START_CONTRACT:\s*([A-Za-z0-9_$.\-]+)/,
|
|
428
|
+
/END_CONTRACT:\s*([A-Za-z0-9_$.\-]+)/,
|
|
429
|
+
"contract",
|
|
430
|
+
);
|
|
431
|
+
lintScopedMarkers(
|
|
432
|
+
result,
|
|
433
|
+
relativePath,
|
|
434
|
+
text,
|
|
435
|
+
/START_BLOCK_([A-Za-z0-9_]+)/,
|
|
436
|
+
/END_BLOCK_([A-Za-z0-9_]+)/,
|
|
437
|
+
"block",
|
|
438
|
+
);
|
|
439
|
+
|
|
440
|
+
if (moduleContract && !/PURPOSE:|SCOPE:|DEPENDS:|LINKS:/s.test(moduleContract)) {
|
|
441
|
+
addIssue(result, {
|
|
442
|
+
severity: "error",
|
|
443
|
+
code: "markup.incomplete-module-contract",
|
|
444
|
+
file: relativePath,
|
|
445
|
+
message: "MODULE_CONTRACT should include PURPOSE, SCOPE, DEPENDS, and LINKS fields.",
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const moduleMapEntries = moduleMap ? parseModuleMapEntries(moduleMap) : new Set<string>();
|
|
450
|
+
if (moduleMap && moduleMapEntries.size === 0) {
|
|
451
|
+
addIssue(result, {
|
|
452
|
+
severity: "error",
|
|
453
|
+
code: "markup.empty-module-map",
|
|
454
|
+
file: relativePath,
|
|
455
|
+
message: "MODULE_MAP must list at least one exported symbol and description.",
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
if (changeSummary && !/LAST_CHANGE:/s.test(changeSummary)) {
|
|
460
|
+
addIssue(result, {
|
|
461
|
+
severity: "error",
|
|
462
|
+
code: "markup.empty-change-summary",
|
|
463
|
+
file: relativePath,
|
|
464
|
+
message: "CHANGE_SUMMARY must contain at least one LAST_CHANGE entry.",
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
if (TS_LIKE_EXTENSIONS.has(path.extname(filePath))) {
|
|
469
|
+
const actualExports = extractTypeScriptExports(text);
|
|
470
|
+
for (const exportName of actualExports) {
|
|
471
|
+
if (!moduleMapEntries.has(exportName)) {
|
|
472
|
+
addIssue(result, {
|
|
473
|
+
severity: "error",
|
|
474
|
+
code: "markup.module-map-missing-export",
|
|
475
|
+
file: relativePath,
|
|
476
|
+
message: `MODULE_MAP is missing the exported symbol \`${exportName}\`.`,
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
for (const mapEntry of moduleMapEntries) {
|
|
482
|
+
if (!actualExports.has(mapEntry)) {
|
|
483
|
+
addIssue(result, {
|
|
484
|
+
severity: "warning",
|
|
485
|
+
code: "markup.module-map-extra-export",
|
|
486
|
+
file: relativePath,
|
|
487
|
+
message: `MODULE_MAP lists \`${mapEntry}\`, but no matching TypeScript export was found.`,
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function lintUniqueTags(result: LintResult, relativePath: string, text: string) {
|
|
495
|
+
for (const antiPattern of UNIQUE_TAG_ANTI_PATTERNS) {
|
|
496
|
+
for (const match of text.matchAll(antiPattern.regex)) {
|
|
497
|
+
addIssue(result, {
|
|
498
|
+
severity: "error",
|
|
499
|
+
code: antiPattern.code,
|
|
500
|
+
file: relativePath,
|
|
501
|
+
line: match.index === undefined ? undefined : lineNumberAt(text, match.index),
|
|
502
|
+
message: antiPattern.message,
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function extractModuleIds(text: string) {
|
|
509
|
+
return new Set(
|
|
510
|
+
Array.from(text.matchAll(/<(M-[A-Za-z0-9-]+)(?=[\s>])/g), (match) => match[1]),
|
|
511
|
+
);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function extractVerificationIds(text: string) {
|
|
515
|
+
return new Set(
|
|
516
|
+
Array.from(text.matchAll(/<(V-M-[A-Za-z0-9-]+)(?=[\s>])/g), (match) => match[1]),
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function extractVerificationRefs(text: string) {
|
|
521
|
+
return Array.from(text.matchAll(/<verification-ref>\s*([^<\s]+)\s*<\/verification-ref>/g)).map((match) => ({
|
|
522
|
+
value: match[1],
|
|
523
|
+
line: match.index === undefined ? undefined : lineNumberAt(text, match.index),
|
|
524
|
+
}));
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function extractStepRefs(text: string) {
|
|
528
|
+
return Array.from(
|
|
529
|
+
text.matchAll(/<(step-[A-Za-z0-9-]+)([^>]*)>/g),
|
|
530
|
+
(match) => {
|
|
531
|
+
const attrs = match[2] ?? "";
|
|
532
|
+
const moduleMatch = attrs.match(/module="([^"]+)"/);
|
|
533
|
+
const verificationMatch = attrs.match(/verification="([^"]+)"/);
|
|
534
|
+
return {
|
|
535
|
+
stepTag: match[1],
|
|
536
|
+
moduleId: moduleMatch?.[1] ?? null,
|
|
537
|
+
verificationId: verificationMatch?.[1] ?? null,
|
|
538
|
+
line: match.index === undefined ? undefined : lineNumberAt(text, match.index),
|
|
539
|
+
};
|
|
540
|
+
},
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function lintRequiredPacketSections(result: LintResult, relativePath: string, text: string) {
|
|
545
|
+
const requiredTags = [
|
|
546
|
+
"ExecutionPacketTemplate",
|
|
547
|
+
"GraphDeltaTemplate",
|
|
548
|
+
"VerificationDeltaTemplate",
|
|
549
|
+
"FailurePacketTemplate",
|
|
550
|
+
];
|
|
551
|
+
|
|
552
|
+
for (const tagName of requiredTags) {
|
|
553
|
+
const pattern = new RegExp(`<${tagName}(?=[\\s>])`);
|
|
554
|
+
if (!pattern.test(text)) {
|
|
555
|
+
addIssue(result, {
|
|
556
|
+
severity: "error",
|
|
557
|
+
code: "packets.missing-template-section",
|
|
558
|
+
file: relativePath,
|
|
559
|
+
message: `Operational packet reference is missing <${tagName}>.`,
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
export function lintGraceProject(projectRoot: string, options: LintOptions = {}): LintResult {
|
|
566
|
+
const root = path.resolve(projectRoot);
|
|
567
|
+
const result: LintResult = {
|
|
568
|
+
root,
|
|
569
|
+
filesChecked: 0,
|
|
570
|
+
governedFiles: 0,
|
|
571
|
+
xmlFilesChecked: 0,
|
|
572
|
+
issues: [],
|
|
573
|
+
};
|
|
574
|
+
|
|
575
|
+
const docs = Object.fromEntries(
|
|
576
|
+
REQUIRED_DOCS.map((relativePath) => [relativePath, readTextIfExists(path.join(root, relativePath))]),
|
|
577
|
+
) as Record<(typeof REQUIRED_DOCS)[number], string | null>;
|
|
578
|
+
const operationalPackets = readTextIfExists(path.join(root, OPTIONAL_PACKET_DOC));
|
|
579
|
+
|
|
580
|
+
if (!options.allowMissingDocs) {
|
|
581
|
+
for (const relativePath of REQUIRED_DOCS) {
|
|
582
|
+
if (!docs[relativePath]) {
|
|
583
|
+
addIssue(result, {
|
|
584
|
+
severity: "error",
|
|
585
|
+
code: "docs.missing-required-artifact",
|
|
586
|
+
file: relativePath,
|
|
587
|
+
message: `Missing required GRACE artifact \`${relativePath}\`.`,
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
for (const [relativePath, contents] of Object.entries(docs)) {
|
|
594
|
+
if (!contents) {
|
|
595
|
+
continue;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
result.xmlFilesChecked += 1;
|
|
599
|
+
lintUniqueTags(result, relativePath, contents);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
if (operationalPackets) {
|
|
603
|
+
result.xmlFilesChecked += 1;
|
|
604
|
+
lintRequiredPacketSections(result, OPTIONAL_PACKET_DOC, operationalPackets);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
const knowledgeGraph = docs["docs/knowledge-graph.xml"];
|
|
608
|
+
const developmentPlan = docs["docs/development-plan.xml"];
|
|
609
|
+
const verificationPlan = docs["docs/verification-plan.xml"];
|
|
610
|
+
|
|
611
|
+
const graphModuleIds = knowledgeGraph ? extractModuleIds(knowledgeGraph) : new Set<string>();
|
|
612
|
+
const planModuleIds = developmentPlan ? extractModuleIds(developmentPlan) : new Set<string>();
|
|
613
|
+
const verificationIds = verificationPlan ? extractVerificationIds(verificationPlan) : new Set<string>();
|
|
614
|
+
|
|
615
|
+
if (knowledgeGraph && verificationPlan) {
|
|
616
|
+
for (const ref of extractVerificationRefs(knowledgeGraph)) {
|
|
617
|
+
if (!verificationIds.has(ref.value)) {
|
|
618
|
+
addIssue(result, {
|
|
619
|
+
severity: "error",
|
|
620
|
+
code: "graph.missing-verification-entry",
|
|
621
|
+
file: "docs/knowledge-graph.xml",
|
|
622
|
+
line: ref.line,
|
|
623
|
+
message: `Knowledge graph references \`${ref.value}\`, but no matching verification entry exists.`,
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
if (developmentPlan && verificationPlan) {
|
|
630
|
+
for (const ref of extractVerificationRefs(developmentPlan)) {
|
|
631
|
+
if (!verificationIds.has(ref.value)) {
|
|
632
|
+
addIssue(result, {
|
|
633
|
+
severity: "error",
|
|
634
|
+
code: "plan.missing-verification-entry",
|
|
635
|
+
file: "docs/development-plan.xml",
|
|
636
|
+
line: ref.line,
|
|
637
|
+
message: `Development plan references \`${ref.value}\`, but no matching verification entry exists.`,
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
for (const step of extractStepRefs(developmentPlan)) {
|
|
643
|
+
if (step.moduleId && !planModuleIds.has(step.moduleId)) {
|
|
644
|
+
addIssue(result, {
|
|
645
|
+
severity: "error",
|
|
646
|
+
code: "plan.step-missing-module",
|
|
647
|
+
file: "docs/development-plan.xml",
|
|
648
|
+
line: step.line,
|
|
649
|
+
message: `${step.stepTag} references module \`${step.moduleId}\`, but no matching module tag exists in the plan.`,
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
if (step.verificationId && !verificationIds.has(step.verificationId)) {
|
|
654
|
+
addIssue(result, {
|
|
655
|
+
severity: "error",
|
|
656
|
+
code: "plan.step-missing-verification",
|
|
657
|
+
file: "docs/development-plan.xml",
|
|
658
|
+
line: step.line,
|
|
659
|
+
message: `${step.stepTag} references verification entry \`${step.verificationId}\`, but no matching tag exists in verification-plan.xml.`,
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
if (knowledgeGraph && developmentPlan) {
|
|
666
|
+
for (const moduleId of graphModuleIds) {
|
|
667
|
+
if (!planModuleIds.has(moduleId)) {
|
|
668
|
+
addIssue(result, {
|
|
669
|
+
severity: "error",
|
|
670
|
+
code: "graph.module-missing-from-plan",
|
|
671
|
+
file: "docs/knowledge-graph.xml",
|
|
672
|
+
message: `Module \`${moduleId}\` exists in the knowledge graph but not in the development plan.`,
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
for (const moduleId of planModuleIds) {
|
|
678
|
+
if (!graphModuleIds.has(moduleId)) {
|
|
679
|
+
addIssue(result, {
|
|
680
|
+
severity: "error",
|
|
681
|
+
code: "plan.module-missing-from-graph",
|
|
682
|
+
file: "docs/development-plan.xml",
|
|
683
|
+
message: `Module \`${moduleId}\` exists in the development plan but not in the knowledge graph.`,
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
for (const filePath of collectCodeFiles(root)) {
|
|
690
|
+
result.filesChecked += 1;
|
|
691
|
+
const text = readFileSync(filePath, "utf8");
|
|
692
|
+
if (!hasGraceMarkers(text)) {
|
|
693
|
+
continue;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
lintGovernedFile(result, root, filePath, text);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
return result;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
export function formatTextReport(result: LintResult) {
|
|
703
|
+
const errors = result.issues.filter((issue) => issue.severity === "error");
|
|
704
|
+
const warnings = result.issues.filter((issue) => issue.severity === "warning");
|
|
705
|
+
const lines = [
|
|
706
|
+
"GRACE Lint Report",
|
|
707
|
+
"=================",
|
|
708
|
+
`Root: ${result.root}`,
|
|
709
|
+
`Code files checked: ${result.filesChecked}`,
|
|
710
|
+
`Governed files checked: ${result.governedFiles}`,
|
|
711
|
+
`XML files checked: ${result.xmlFilesChecked}`,
|
|
712
|
+
`Issues: ${result.issues.length} (errors: ${errors.length}, warnings: ${warnings.length})`,
|
|
713
|
+
];
|
|
714
|
+
|
|
715
|
+
if (errors.length > 0) {
|
|
716
|
+
lines.push("", "Errors:");
|
|
717
|
+
for (const issue of errors) {
|
|
718
|
+
lines.push(`- [${issue.code}] ${issue.file}${issue.line ? `:${issue.line}` : ""} ${issue.message}`);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
if (warnings.length > 0) {
|
|
723
|
+
lines.push("", "Warnings:");
|
|
724
|
+
for (const issue of warnings) {
|
|
725
|
+
lines.push(`- [${issue.code}] ${issue.file}${issue.line ? `:${issue.line}` : ""} ${issue.message}`);
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
if (result.issues.length === 0) {
|
|
730
|
+
lines.push("", "No GRACE integrity issues found.");
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
return lines.join("\n");
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
export const lintCommand = defineCommand({
|
|
737
|
+
meta: {
|
|
738
|
+
name: "lint",
|
|
739
|
+
description: "Lint GRACE artifacts, XML tag conventions, and semantic markup.",
|
|
740
|
+
},
|
|
741
|
+
args: {
|
|
742
|
+
path: {
|
|
743
|
+
type: "string",
|
|
744
|
+
alias: "p",
|
|
745
|
+
description: "Project root to lint",
|
|
746
|
+
default: ".",
|
|
747
|
+
},
|
|
748
|
+
format: {
|
|
749
|
+
type: "string",
|
|
750
|
+
alias: "f",
|
|
751
|
+
description: "Output format: text or json",
|
|
752
|
+
default: "text",
|
|
753
|
+
},
|
|
754
|
+
allowMissingDocs: {
|
|
755
|
+
type: "boolean",
|
|
756
|
+
description: "Allow repositories that do not yet have full GRACE docs",
|
|
757
|
+
default: false,
|
|
758
|
+
},
|
|
759
|
+
},
|
|
760
|
+
async run(context) {
|
|
761
|
+
const format = String(context.args.format ?? "text");
|
|
762
|
+
if (!TEXT_FORMAT_OPTIONS.has(format)) {
|
|
763
|
+
throw new Error(`Unsupported format \`${format}\`. Use \`text\` or \`json\`.`);
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
const result = lintGraceProject(String(context.args.path ?? "."), {
|
|
767
|
+
allowMissingDocs: Boolean(context.args.allowMissingDocs),
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
if (format === "json") {
|
|
771
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
772
|
+
} else {
|
|
773
|
+
process.stdout.write(`${formatTextReport(result)}\n`);
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
process.exitCode = result.issues.some((issue) => issue.severity === "error") ? 1 : 0;
|
|
777
|
+
},
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
if (import.meta.main) {
|
|
781
|
+
await runMain(lintCommand as CommandDef);
|
|
782
|
+
}
|
package/src/grace.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
import { defineCommand, type CommandDef, runMain } from "citty";
|
|
4
|
+
|
|
5
|
+
import { lintCommand } from "./grace-lint";
|
|
6
|
+
|
|
7
|
+
const main = defineCommand({
|
|
8
|
+
meta: {
|
|
9
|
+
name: "grace",
|
|
10
|
+
version: "3.1.0",
|
|
11
|
+
description: "GRACE CLI for linting semantic markup and GRACE project artifacts.",
|
|
12
|
+
},
|
|
13
|
+
subCommands: {
|
|
14
|
+
lint: lintCommand,
|
|
15
|
+
},
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
if (import.meta.main) {
|
|
19
|
+
await runMain(main as CommandDef);
|
|
20
|
+
}
|