@osovv/grace-cli 3.2.0 → 3.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/README.md +16 -10
- package/package.json +3 -1
- package/src/grace-lint.ts +0 -8
- package/src/grace.ts +1 -1
- package/src/lint/adapters/base.ts +2 -1
- package/src/lint/adapters/python.ts +264 -0
- package/src/lint/adapters/typescript.ts +2 -0
- package/src/lint/config.ts +18 -4
- package/src/lint/core.ts +36 -62
- package/src/lint/types.ts +2 -6
package/README.md
CHANGED
|
@@ -10,13 +10,13 @@ This repository packages GRACE as reusable skills for coding agents. The current
|
|
|
10
10
|
- knowledge-graph synchronization
|
|
11
11
|
- controller-managed sequential or multi-agent implementation
|
|
12
12
|
|
|
13
|
-
Current packaged version: `3.
|
|
13
|
+
Current packaged version: `3.4.0`
|
|
14
14
|
|
|
15
15
|
## What Changed In This Version
|
|
16
16
|
|
|
17
|
-
-
|
|
18
|
-
-
|
|
19
|
-
-
|
|
17
|
+
- Added a rich Python adapter without `pyright` for `grace lint`.
|
|
18
|
+
- Kept TypeScript/JavaScript on the TypeScript compiler API for exact export analysis.
|
|
19
|
+
- Made adapter failures non-fatal so linting can continue with structural checks and warnings.
|
|
20
20
|
|
|
21
21
|
## Repository Layout
|
|
22
22
|
|
|
@@ -195,6 +195,17 @@ The lint command is role-aware and adapter-aware:
|
|
|
195
195
|
- export-map parity uses language adapters where available
|
|
196
196
|
- file behavior is driven by semantic roles instead of filename masks
|
|
197
197
|
|
|
198
|
+
Current rich adapters:
|
|
199
|
+
|
|
200
|
+
- TypeScript/JavaScript via the TypeScript compiler API
|
|
201
|
+
- Python via the Python standard-library AST, without `pyright`
|
|
202
|
+
|
|
203
|
+
Current adapter behavior:
|
|
204
|
+
|
|
205
|
+
- TypeScript/JavaScript export analysis is treated as exact
|
|
206
|
+
- Python export analysis is exact when `__all__` is explicit, otherwise heuristic
|
|
207
|
+
- other languages still benefit from structural GRACE checks even when rich export analysis is not available yet
|
|
208
|
+
|
|
198
209
|
Optional `MODULE_CONTRACT` fields for linting:
|
|
199
210
|
|
|
200
211
|
```text
|
|
@@ -215,16 +226,11 @@ Optional repository config file:
|
|
|
215
226
|
|
|
216
227
|
```json
|
|
217
228
|
{
|
|
218
|
-
"profile": "auto",
|
|
219
229
|
"ignoredDirs": ["tmp"]
|
|
220
230
|
}
|
|
221
231
|
```
|
|
222
232
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
- `auto` => require `docs/verification-plan.xml` only when the repo already looks verification-aware
|
|
226
|
-
- `current` => require current GRACE artifacts
|
|
227
|
-
- `legacy` => allow older GRACE repos without a verification plan
|
|
233
|
+
`grace lint` is current-only. Older GRACE repositories should fail until they are updated to the current artifact set, especially `docs/verification-plan.xml`.
|
|
228
234
|
|
|
229
235
|
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.
|
|
230
236
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@osovv/grace-cli",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.4.0",
|
|
4
4
|
"description": "GRACE CLI for linting semantic markup, contracts, and GRACE XML artifacts with a Bun-powered grace binary.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://github.com/osovv/grace-marketplace#readme",
|
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
"cli",
|
|
17
17
|
"lint",
|
|
18
18
|
"bun",
|
|
19
|
+
"python",
|
|
20
|
+
"typescript",
|
|
19
21
|
"semantic-markup",
|
|
20
22
|
"knowledge-graph"
|
|
21
23
|
],
|
package/src/grace-lint.ts
CHANGED
|
@@ -6,7 +6,6 @@ import { formatTextReport, isValidTextFormat, lintGraceProject } from "./lint/co
|
|
|
6
6
|
import type { LintOptions, LintResult } from "./lint/types";
|
|
7
7
|
|
|
8
8
|
export type {
|
|
9
|
-
EffectiveProfile,
|
|
10
9
|
GraceLintConfig,
|
|
11
10
|
LanguageAdapter,
|
|
12
11
|
LanguageAnalysis,
|
|
@@ -16,7 +15,6 @@ export type {
|
|
|
16
15
|
LintSeverity,
|
|
17
16
|
MapMode,
|
|
18
17
|
ModuleRole,
|
|
19
|
-
RepoProfile,
|
|
20
18
|
} from "./lint/types";
|
|
21
19
|
|
|
22
20
|
export { formatTextReport, lintGraceProject } from "./lint/core";
|
|
@@ -48,11 +46,6 @@ export const lintCommand = defineCommand({
|
|
|
48
46
|
description: "Output format: text or json",
|
|
49
47
|
default: "text",
|
|
50
48
|
},
|
|
51
|
-
profile: {
|
|
52
|
-
type: "string",
|
|
53
|
-
description: "Lint profile: auto, current, or legacy",
|
|
54
|
-
default: "auto",
|
|
55
|
-
},
|
|
56
49
|
allowMissingDocs: {
|
|
57
50
|
type: "boolean",
|
|
58
51
|
description: "Allow repositories that do not yet have full GRACE docs",
|
|
@@ -67,7 +60,6 @@ export const lintCommand = defineCommand({
|
|
|
67
60
|
|
|
68
61
|
const result = lintGraceProject(String(context.args.path ?? "."), {
|
|
69
62
|
allowMissingDocs: Boolean(context.args.allowMissingDocs),
|
|
70
|
-
profile: String(context.args.profile ?? "auto") as LintOptions["profile"],
|
|
71
63
|
});
|
|
72
64
|
|
|
73
65
|
writeResult(format, result);
|
package/src/grace.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
|
|
3
3
|
import type { LanguageAdapter } from "../types";
|
|
4
|
+
import { createPythonAdapter } from "./python";
|
|
4
5
|
import { createTypeScriptAdapter } from "./typescript";
|
|
5
6
|
|
|
6
|
-
const adapters: LanguageAdapter[] = [createTypeScriptAdapter()];
|
|
7
|
+
const adapters: LanguageAdapter[] = [createTypeScriptAdapter(), createPythonAdapter()];
|
|
7
8
|
|
|
8
9
|
export function getLanguageAdapter(filePath: string) {
|
|
9
10
|
const normalizedPath = path.normalize(filePath);
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import type { LanguageAdapter, LanguageAnalysis } from "../types";
|
|
5
|
+
|
|
6
|
+
const PY_EXTENSIONS = new Set([".py", ".pyi"]);
|
|
7
|
+
const PYTHON_BINARIES = ["python3", "python"];
|
|
8
|
+
|
|
9
|
+
const PYTHON_ANALYZER_SCRIPT = String.raw`
|
|
10
|
+
import ast
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
source = sys.stdin.read()
|
|
16
|
+
file_path = sys.argv[1]
|
|
17
|
+
base_name = os.path.basename(file_path)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def is_public(name):
|
|
21
|
+
return isinstance(name, str) and len(name) > 0 and not name.startswith("_")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def extract_target_names(target):
|
|
25
|
+
if isinstance(target, ast.Name):
|
|
26
|
+
return [target.id]
|
|
27
|
+
if isinstance(target, (ast.Tuple, ast.List)):
|
|
28
|
+
names = []
|
|
29
|
+
for item in target.elts:
|
|
30
|
+
names.extend(extract_target_names(item))
|
|
31
|
+
return names
|
|
32
|
+
return []
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def extract_string_sequence(node):
|
|
36
|
+
if isinstance(node, (ast.List, ast.Tuple, ast.Set)):
|
|
37
|
+
values = []
|
|
38
|
+
for element in node.elts:
|
|
39
|
+
if not isinstance(element, ast.Constant) or not isinstance(element.value, str):
|
|
40
|
+
return None
|
|
41
|
+
values.append(element.value)
|
|
42
|
+
return values
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def is_main_guard(test):
|
|
47
|
+
if not isinstance(test, ast.Compare):
|
|
48
|
+
return False
|
|
49
|
+
if len(test.ops) != 1 or len(test.comparators) != 1:
|
|
50
|
+
return False
|
|
51
|
+
left = test.left
|
|
52
|
+
comparator = test.comparators[0]
|
|
53
|
+
return (
|
|
54
|
+
isinstance(left, ast.Name)
|
|
55
|
+
and left.id == "__name__"
|
|
56
|
+
and isinstance(test.ops[0], ast.Eq)
|
|
57
|
+
and isinstance(comparator, ast.Constant)
|
|
58
|
+
and comparator.value == "__main__"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def imported_name(alias):
|
|
63
|
+
return alias.asname or alias.name.split(".", 1)[0]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
local_public = set()
|
|
67
|
+
imported_public = set()
|
|
68
|
+
explicit_all = None
|
|
69
|
+
has_wildcard_reexport = False
|
|
70
|
+
direct_reexport_count = 0
|
|
71
|
+
local_implementation_count = 0
|
|
72
|
+
uses_test_framework = False
|
|
73
|
+
has_main_entrypoint = False
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
tree = ast.parse(source, filename=file_path)
|
|
77
|
+
except SyntaxError as exc:
|
|
78
|
+
message = f"{exc.msg} at line {exc.lineno}:{exc.offset}" if exc.lineno else exc.msg
|
|
79
|
+
sys.stderr.write(message)
|
|
80
|
+
sys.exit(2)
|
|
81
|
+
|
|
82
|
+
for node in tree.body:
|
|
83
|
+
if isinstance(node, ast.Import):
|
|
84
|
+
for alias in node.names:
|
|
85
|
+
imported = imported_name(alias)
|
|
86
|
+
if is_public(imported):
|
|
87
|
+
imported_public.add(imported)
|
|
88
|
+
if alias.name == "pytest" or alias.name.startswith("unittest"):
|
|
89
|
+
uses_test_framework = True
|
|
90
|
+
continue
|
|
91
|
+
|
|
92
|
+
if isinstance(node, ast.ImportFrom):
|
|
93
|
+
module = node.module or ""
|
|
94
|
+
if module == "pytest" or module.startswith("unittest"):
|
|
95
|
+
uses_test_framework = True
|
|
96
|
+
if any(alias.name == "*" for alias in node.names):
|
|
97
|
+
has_wildcard_reexport = True
|
|
98
|
+
direct_reexport_count += 1
|
|
99
|
+
continue
|
|
100
|
+
public_imports = 0
|
|
101
|
+
for alias in node.names:
|
|
102
|
+
imported = imported_name(alias)
|
|
103
|
+
if is_public(imported):
|
|
104
|
+
imported_public.add(imported)
|
|
105
|
+
public_imports += 1
|
|
106
|
+
if public_imports:
|
|
107
|
+
direct_reexport_count += 1
|
|
108
|
+
continue
|
|
109
|
+
|
|
110
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
111
|
+
local_implementation_count += 1
|
|
112
|
+
if node.name.startswith("test_"):
|
|
113
|
+
uses_test_framework = True
|
|
114
|
+
if is_public(node.name):
|
|
115
|
+
local_public.add(node.name)
|
|
116
|
+
continue
|
|
117
|
+
|
|
118
|
+
if isinstance(node, ast.ClassDef):
|
|
119
|
+
local_implementation_count += 1
|
|
120
|
+
if node.name.startswith("Test"):
|
|
121
|
+
uses_test_framework = True
|
|
122
|
+
if is_public(node.name):
|
|
123
|
+
local_public.add(node.name)
|
|
124
|
+
continue
|
|
125
|
+
|
|
126
|
+
if isinstance(node, ast.Assign):
|
|
127
|
+
for target in node.targets:
|
|
128
|
+
for name in extract_target_names(target):
|
|
129
|
+
if name == "__all__":
|
|
130
|
+
sequence = extract_string_sequence(node.value)
|
|
131
|
+
if sequence is not None:
|
|
132
|
+
explicit_all = sequence
|
|
133
|
+
continue
|
|
134
|
+
if is_public(name):
|
|
135
|
+
local_public.add(name)
|
|
136
|
+
local_implementation_count += 1
|
|
137
|
+
continue
|
|
138
|
+
|
|
139
|
+
if isinstance(node, ast.AnnAssign):
|
|
140
|
+
for name in extract_target_names(node.target):
|
|
141
|
+
if name == "__all__":
|
|
142
|
+
sequence = extract_string_sequence(node.value)
|
|
143
|
+
if sequence is not None:
|
|
144
|
+
explicit_all = sequence
|
|
145
|
+
continue
|
|
146
|
+
if is_public(name):
|
|
147
|
+
local_public.add(name)
|
|
148
|
+
local_implementation_count += 1
|
|
149
|
+
continue
|
|
150
|
+
|
|
151
|
+
if isinstance(node, ast.If) and is_main_guard(node.test):
|
|
152
|
+
has_main_entrypoint = True
|
|
153
|
+
|
|
154
|
+
if explicit_all is not None:
|
|
155
|
+
export_names = sorted({name for name in explicit_all if isinstance(name, str)})
|
|
156
|
+
export_confidence = "exact"
|
|
157
|
+
else:
|
|
158
|
+
export_names = set(local_public)
|
|
159
|
+
if base_name == "__init__.py":
|
|
160
|
+
export_names.update(imported_public)
|
|
161
|
+
export_names = sorted(export_names)
|
|
162
|
+
export_confidence = "heuristic"
|
|
163
|
+
|
|
164
|
+
local_export_count = sum(1 for name in export_names if name in local_public)
|
|
165
|
+
|
|
166
|
+
print(json.dumps({
|
|
167
|
+
"exports": export_names,
|
|
168
|
+
"valueExports": export_names,
|
|
169
|
+
"typeExports": [],
|
|
170
|
+
"exportConfidence": export_confidence,
|
|
171
|
+
"hasDefaultExport": False,
|
|
172
|
+
"hasWildcardReExport": has_wildcard_reexport,
|
|
173
|
+
"hasMainEntrypoint": has_main_entrypoint,
|
|
174
|
+
"directReExportCount": direct_reexport_count,
|
|
175
|
+
"localExportCount": local_export_count,
|
|
176
|
+
"localImplementationCount": local_implementation_count,
|
|
177
|
+
"usesTestFramework": uses_test_framework,
|
|
178
|
+
}))
|
|
179
|
+
`;
|
|
180
|
+
|
|
181
|
+
function createEmptyAnalysis(): LanguageAnalysis {
|
|
182
|
+
return {
|
|
183
|
+
adapterId: "python",
|
|
184
|
+
exports: new Set<string>(),
|
|
185
|
+
valueExports: new Set<string>(),
|
|
186
|
+
typeExports: new Set<string>(),
|
|
187
|
+
exportConfidence: "heuristic",
|
|
188
|
+
hasDefaultExport: false,
|
|
189
|
+
hasWildcardReExport: false,
|
|
190
|
+
hasMainEntrypoint: false,
|
|
191
|
+
directReExportCount: 0,
|
|
192
|
+
localExportCount: 0,
|
|
193
|
+
localImplementationCount: 0,
|
|
194
|
+
usesTestFramework: false,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function normalizeResult(output: string) {
|
|
199
|
+
const parsed = JSON.parse(output) as {
|
|
200
|
+
exports: string[];
|
|
201
|
+
valueExports: string[];
|
|
202
|
+
typeExports: string[];
|
|
203
|
+
exportConfidence: "exact" | "heuristic";
|
|
204
|
+
hasDefaultExport: boolean;
|
|
205
|
+
hasWildcardReExport: boolean;
|
|
206
|
+
hasMainEntrypoint: boolean;
|
|
207
|
+
directReExportCount: number;
|
|
208
|
+
localExportCount: number;
|
|
209
|
+
localImplementationCount: number;
|
|
210
|
+
usesTestFramework: boolean;
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const analysis = createEmptyAnalysis();
|
|
214
|
+
analysis.exports = new Set(parsed.exports ?? []);
|
|
215
|
+
analysis.valueExports = new Set(parsed.valueExports ?? []);
|
|
216
|
+
analysis.typeExports = new Set(parsed.typeExports ?? []);
|
|
217
|
+
analysis.exportConfidence = parsed.exportConfidence ?? "heuristic";
|
|
218
|
+
analysis.hasDefaultExport = Boolean(parsed.hasDefaultExport);
|
|
219
|
+
analysis.hasWildcardReExport = Boolean(parsed.hasWildcardReExport);
|
|
220
|
+
analysis.hasMainEntrypoint = Boolean(parsed.hasMainEntrypoint);
|
|
221
|
+
analysis.directReExportCount = Number(parsed.directReExportCount ?? 0);
|
|
222
|
+
analysis.localExportCount = Number(parsed.localExportCount ?? 0);
|
|
223
|
+
analysis.localImplementationCount = Number(parsed.localImplementationCount ?? 0);
|
|
224
|
+
analysis.usesTestFramework = Boolean(parsed.usesTestFramework);
|
|
225
|
+
return analysis;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function runPythonAnalyzer(filePath: string, text: string) {
|
|
229
|
+
for (const binary of PYTHON_BINARIES) {
|
|
230
|
+
const run = spawnSync(binary, ["-c", PYTHON_ANALYZER_SCRIPT, filePath], {
|
|
231
|
+
input: text,
|
|
232
|
+
encoding: "utf8",
|
|
233
|
+
maxBuffer: 1024 * 1024,
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
if (run.error) {
|
|
237
|
+
const code = (run.error as NodeJS.ErrnoException).code;
|
|
238
|
+
if (code === "ENOENT") {
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
throw run.error;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (run.status === 0) {
|
|
245
|
+
return normalizeResult(run.stdout);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
throw new Error(run.stderr.trim() || run.stdout.trim() || `Python analyzer failed via ${binary}.`);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
throw new Error("Python adapter requires `python3` or `python` on PATH when linting Python files.");
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function createPythonAdapter(): LanguageAdapter {
|
|
255
|
+
return {
|
|
256
|
+
id: "python",
|
|
257
|
+
supports(filePath) {
|
|
258
|
+
return PY_EXTENSIONS.has(path.extname(filePath));
|
|
259
|
+
},
|
|
260
|
+
analyze(filePath, text) {
|
|
261
|
+
return runPythonAnalyzer(filePath, text);
|
|
262
|
+
},
|
|
263
|
+
};
|
|
264
|
+
}
|
|
@@ -73,8 +73,10 @@ export function createTypeScriptAdapter(): LanguageAdapter {
|
|
|
73
73
|
exports: new Set<string>(),
|
|
74
74
|
valueExports: new Set<string>(),
|
|
75
75
|
typeExports: new Set<string>(),
|
|
76
|
+
exportConfidence: "exact",
|
|
76
77
|
hasDefaultExport: false,
|
|
77
78
|
hasWildcardReExport: false,
|
|
79
|
+
hasMainEntrypoint: false,
|
|
78
80
|
directReExportCount: 0,
|
|
79
81
|
localExportCount: 0,
|
|
80
82
|
localImplementationCount: 0,
|
package/src/lint/config.ts
CHANGED
|
@@ -4,7 +4,7 @@ import path from "node:path";
|
|
|
4
4
|
import type { GraceLintConfig, LintIssue } from "./types";
|
|
5
5
|
|
|
6
6
|
const CONFIG_FILE_NAME = ".grace-lint.json";
|
|
7
|
-
const
|
|
7
|
+
const SUPPORTED_KEYS = new Set(["ignoredDirs"]);
|
|
8
8
|
|
|
9
9
|
export function loadGraceLintConfig(projectRoot: string) {
|
|
10
10
|
const configPath = path.join(projectRoot, CONFIG_FILE_NAME);
|
|
@@ -16,12 +16,26 @@ export function loadGraceLintConfig(projectRoot: string) {
|
|
|
16
16
|
const parsed = JSON.parse(readFileSync(configPath, "utf8")) as GraceLintConfig;
|
|
17
17
|
const issues: LintIssue[] = [];
|
|
18
18
|
|
|
19
|
-
if (parsed
|
|
19
|
+
if (!parsed || Array.isArray(parsed) || typeof parsed !== "object") {
|
|
20
20
|
issues.push({
|
|
21
21
|
severity: "error",
|
|
22
|
-
code: "config.invalid-
|
|
22
|
+
code: "config.invalid-shape",
|
|
23
23
|
file: CONFIG_FILE_NAME,
|
|
24
|
-
message:
|
|
24
|
+
message: `${CONFIG_FILE_NAME} must contain a JSON object.`,
|
|
25
|
+
});
|
|
26
|
+
return { config: parsed, issues };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
for (const key of Object.keys(parsed)) {
|
|
30
|
+
if (SUPPORTED_KEYS.has(key)) {
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
issues.push({
|
|
35
|
+
severity: "error",
|
|
36
|
+
code: "config.unknown-key",
|
|
37
|
+
file: CONFIG_FILE_NAME,
|
|
38
|
+
message: `Unsupported key \`${key}\` in ${CONFIG_FILE_NAME}. Supported keys: ignoredDirs.`,
|
|
25
39
|
});
|
|
26
40
|
}
|
|
27
41
|
|
package/src/lint/core.ts
CHANGED
|
@@ -4,7 +4,6 @@ import path from "node:path";
|
|
|
4
4
|
import { loadGraceLintConfig } from "./config";
|
|
5
5
|
import { getLanguageAdapter } from "./adapters/base";
|
|
6
6
|
import type {
|
|
7
|
-
EffectiveProfile,
|
|
8
7
|
GraceLintConfig,
|
|
9
8
|
LanguageAnalysis,
|
|
10
9
|
LintIssue,
|
|
@@ -15,13 +14,9 @@ import type {
|
|
|
15
14
|
ModuleContractInfo,
|
|
16
15
|
ModuleMapItem,
|
|
17
16
|
ModuleRole,
|
|
18
|
-
RepoProfile,
|
|
19
17
|
} from "./types";
|
|
20
18
|
|
|
21
|
-
const
|
|
22
|
-
legacy: ["docs/knowledge-graph.xml", "docs/development-plan.xml"],
|
|
23
|
-
current: ["docs/knowledge-graph.xml", "docs/development-plan.xml", "docs/verification-plan.xml"],
|
|
24
|
-
};
|
|
19
|
+
const REQUIRED_DOCS = ["docs/knowledge-graph.xml", "docs/development-plan.xml", "docs/verification-plan.xml"] as const;
|
|
25
20
|
|
|
26
21
|
const OPTIONAL_PACKET_DOC = "docs/operational-packets.xml";
|
|
27
22
|
const LINT_CONFIG_FILE = ".grace-lint.json";
|
|
@@ -46,6 +41,7 @@ const CODE_EXTENSIONS = new Set([
|
|
|
46
41
|
".mts",
|
|
47
42
|
".cts",
|
|
48
43
|
".py",
|
|
44
|
+
".pyi",
|
|
49
45
|
".go",
|
|
50
46
|
".java",
|
|
51
47
|
".kt",
|
|
@@ -437,6 +433,10 @@ function inferRole(contract: ModuleContractInfo | null, analysis: LanguageAnalys
|
|
|
437
433
|
return "CONFIG";
|
|
438
434
|
}
|
|
439
435
|
|
|
436
|
+
if (analysis?.hasMainEntrypoint) {
|
|
437
|
+
return "SCRIPT";
|
|
438
|
+
}
|
|
439
|
+
|
|
440
440
|
if (analysis && mentionsScript && analysis.exports.size === 0) {
|
|
441
441
|
return "SCRIPT";
|
|
442
442
|
}
|
|
@@ -557,46 +557,6 @@ function lintRequiredPacketSections(result: LintResult, relativePath: string, te
|
|
|
557
557
|
}
|
|
558
558
|
}
|
|
559
559
|
|
|
560
|
-
function resolveProfile(
|
|
561
|
-
requestedProfile: RepoProfile | undefined,
|
|
562
|
-
configProfile: RepoProfile | undefined,
|
|
563
|
-
docs: Record<string, string | null>,
|
|
564
|
-
) {
|
|
565
|
-
const desiredProfile = requestedProfile ?? configProfile ?? "auto";
|
|
566
|
-
if (desiredProfile !== "auto") {
|
|
567
|
-
return desiredProfile;
|
|
568
|
-
}
|
|
569
|
-
|
|
570
|
-
const verificationPlan = docs["docs/verification-plan.xml"];
|
|
571
|
-
if (verificationPlan) {
|
|
572
|
-
return "current" as const;
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
const joinedDocs = `${docs["docs/knowledge-graph.xml"] ?? ""}\n${docs["docs/development-plan.xml"] ?? ""}`;
|
|
576
|
-
return /<verification-ref>|<V-M-/.test(joinedDocs) ? "current" : "legacy";
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
function validateProfileSelection(profile: string | undefined) {
|
|
580
|
-
if (!profile) {
|
|
581
|
-
return null;
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
if (profile === "auto" || profile === "current" || profile === "legacy") {
|
|
585
|
-
return null;
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
return {
|
|
589
|
-
severity: "error",
|
|
590
|
-
code: "config.invalid-profile-selection",
|
|
591
|
-
file: "CLI",
|
|
592
|
-
message: `Unsupported profile \`${profile}\`. Use \`auto\`, \`current\`, or \`legacy\`.`,
|
|
593
|
-
} satisfies LintIssue;
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
function isInvalidProfileIssue(issue: LintIssue) {
|
|
597
|
-
return issue.code === "config.invalid-profile" || issue.code === "config.invalid-profile-selection";
|
|
598
|
-
}
|
|
599
|
-
|
|
600
560
|
function lintExportMapParity(
|
|
601
561
|
result: LintResult,
|
|
602
562
|
relativePath: string,
|
|
@@ -609,6 +569,17 @@ function lintExportMapParity(
|
|
|
609
569
|
return;
|
|
610
570
|
}
|
|
611
571
|
|
|
572
|
+
const exportSeverity = analysis.exportConfidence === "exact" ? "error" : "warning";
|
|
573
|
+
|
|
574
|
+
if (analysis.exportConfidence === "heuristic") {
|
|
575
|
+
addIssue(result, {
|
|
576
|
+
severity: "warning",
|
|
577
|
+
code: "analysis.heuristic-export-surface",
|
|
578
|
+
file: relativePath,
|
|
579
|
+
message: `The ${analysis.adapterId} adapter inferred exports heuristically for this file. Exact MODULE_MAP parity may require explicit file ROLE/MAP_MODE or stronger language-specific export declarations.`,
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
|
|
612
583
|
if (analysis.hasWildcardReExport) {
|
|
613
584
|
addIssue(result, {
|
|
614
585
|
severity: "warning",
|
|
@@ -622,7 +593,7 @@ function lintExportMapParity(
|
|
|
622
593
|
const mappedSymbols = new Set(items.flatMap((item) => (item.symbolName ? [item.symbolName] : [])));
|
|
623
594
|
if (mappedSymbols.size === 0 && analysis.exports.size > 0) {
|
|
624
595
|
addIssue(result, {
|
|
625
|
-
severity:
|
|
596
|
+
severity: exportSeverity,
|
|
626
597
|
code: "markup.module-map-missing-symbol-entries",
|
|
627
598
|
file: relativePath,
|
|
628
599
|
message: "MODULE_MAP should list concrete symbol names when MAP_MODE resolves to EXPORTS.",
|
|
@@ -633,7 +604,7 @@ function lintExportMapParity(
|
|
|
633
604
|
for (const exportName of analysis.exports) {
|
|
634
605
|
if (!mappedSymbols.has(exportName)) {
|
|
635
606
|
addIssue(result, {
|
|
636
|
-
severity:
|
|
607
|
+
severity: exportSeverity,
|
|
637
608
|
code: "markup.module-map-missing-export",
|
|
638
609
|
file: relativePath,
|
|
639
610
|
message: `MODULE_MAP is missing the exported symbol \`${exportName}\`.`,
|
|
@@ -702,7 +673,19 @@ function lintGovernedFile(result: LintResult, root: string, filePath: string, te
|
|
|
702
673
|
const contract = moduleContractSection ? parseModuleContract(moduleContractSection) : null;
|
|
703
674
|
const mapItems = moduleMapSection ? parseModuleMapItems(moduleMapSection) : [];
|
|
704
675
|
const adapter = getLanguageAdapter(filePath);
|
|
705
|
-
|
|
676
|
+
let analysis: LanguageAnalysis | null = null;
|
|
677
|
+
if (adapter) {
|
|
678
|
+
try {
|
|
679
|
+
analysis = adapter.analyze(filePath, text);
|
|
680
|
+
} catch (error) {
|
|
681
|
+
addIssue(result, {
|
|
682
|
+
severity: "warning",
|
|
683
|
+
code: "analysis.adapter-failed",
|
|
684
|
+
file: relativePath,
|
|
685
|
+
message: `${adapter.id} adapter failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
}
|
|
706
689
|
const role = inferRole(contract, analysis);
|
|
707
690
|
const mapMode = inferMapMode(contract, role, mapItems, analysis);
|
|
708
691
|
|
|
@@ -771,11 +754,6 @@ function lintGovernedFile(result: LintResult, root: string, filePath: string, te
|
|
|
771
754
|
export function lintGraceProject(projectRoot: string, options: LintOptions = {}): LintResult {
|
|
772
755
|
const root = path.resolve(projectRoot);
|
|
773
756
|
const { config, issues: configIssues } = loadGraceLintConfig(root);
|
|
774
|
-
const requestedProfileIssue = validateProfileSelection(options.profile as string | undefined);
|
|
775
|
-
const effectiveRequestedProfile = requestedProfileIssue ? undefined : options.profile;
|
|
776
|
-
const effectiveConfigProfile = configIssues.some((issue) => issue.code === "config.invalid-profile")
|
|
777
|
-
? undefined
|
|
778
|
-
: config?.profile;
|
|
779
757
|
|
|
780
758
|
const docs = {
|
|
781
759
|
"docs/knowledge-graph.xml": readTextIfExists(path.join(root, "docs/knowledge-graph.xml")),
|
|
@@ -783,29 +761,26 @@ export function lintGraceProject(projectRoot: string, options: LintOptions = {})
|
|
|
783
761
|
"docs/verification-plan.xml": readTextIfExists(path.join(root, "docs/verification-plan.xml")),
|
|
784
762
|
} satisfies Record<string, string | null>;
|
|
785
763
|
|
|
786
|
-
const profile = resolveProfile(effectiveRequestedProfile, effectiveConfigProfile, docs);
|
|
787
764
|
const result: LintResult = {
|
|
788
765
|
root,
|
|
789
|
-
profile,
|
|
790
766
|
filesChecked: 0,
|
|
791
767
|
governedFiles: 0,
|
|
792
768
|
xmlFilesChecked: 0,
|
|
793
|
-
issues: [...configIssues
|
|
769
|
+
issues: [...configIssues],
|
|
794
770
|
};
|
|
795
771
|
|
|
796
|
-
if (configIssues.some((issue) => issue.severity === "error" && issue.file === LINT_CONFIG_FILE
|
|
772
|
+
if (configIssues.some((issue) => issue.severity === "error" && issue.file === LINT_CONFIG_FILE)) {
|
|
797
773
|
return result;
|
|
798
774
|
}
|
|
799
775
|
|
|
800
|
-
const requiredDocs = REQUIRED_DOCS_BY_PROFILE[profile];
|
|
801
776
|
if (!options.allowMissingDocs) {
|
|
802
|
-
for (const relativePath of
|
|
777
|
+
for (const relativePath of REQUIRED_DOCS) {
|
|
803
778
|
if (!docs[relativePath]) {
|
|
804
779
|
addIssue(result, {
|
|
805
780
|
severity: "error",
|
|
806
781
|
code: "docs.missing-required-artifact",
|
|
807
782
|
file: relativePath,
|
|
808
|
-
message: `Missing required GRACE artifact \`${relativePath}
|
|
783
|
+
message: `Missing required current GRACE artifact \`${relativePath}\`.`,
|
|
809
784
|
});
|
|
810
785
|
}
|
|
811
786
|
}
|
|
@@ -928,7 +903,6 @@ export function formatTextReport(result: LintResult) {
|
|
|
928
903
|
"GRACE Lint Report",
|
|
929
904
|
"=================",
|
|
930
905
|
`Root: ${result.root}`,
|
|
931
|
-
`Profile: ${result.profile}`,
|
|
932
906
|
`Code files checked: ${result.filesChecked}`,
|
|
933
907
|
`Governed files checked: ${result.governedFiles}`,
|
|
934
908
|
`XML files checked: ${result.xmlFilesChecked}`,
|
package/src/lint/types.ts
CHANGED
|
@@ -1,8 +1,5 @@
|
|
|
1
1
|
export type LintSeverity = "error" | "warning";
|
|
2
2
|
|
|
3
|
-
export type RepoProfile = "auto" | "current" | "legacy";
|
|
4
|
-
export type EffectiveProfile = Exclude<RepoProfile, "auto">;
|
|
5
|
-
|
|
6
3
|
export type ModuleRole = "RUNTIME" | "TEST" | "BARREL" | "CONFIG" | "TYPES" | "SCRIPT";
|
|
7
4
|
export type MapMode = "EXPORTS" | "LOCALS" | "SUMMARY" | "NONE";
|
|
8
5
|
|
|
@@ -16,7 +13,6 @@ export type LintIssue = {
|
|
|
16
13
|
|
|
17
14
|
export type LintResult = {
|
|
18
15
|
root: string;
|
|
19
|
-
profile: EffectiveProfile;
|
|
20
16
|
filesChecked: number;
|
|
21
17
|
governedFiles: number;
|
|
22
18
|
xmlFilesChecked: number;
|
|
@@ -25,11 +21,9 @@ export type LintResult = {
|
|
|
25
21
|
|
|
26
22
|
export type LintOptions = {
|
|
27
23
|
allowMissingDocs?: boolean;
|
|
28
|
-
profile?: RepoProfile;
|
|
29
24
|
};
|
|
30
25
|
|
|
31
26
|
export type GraceLintConfig = {
|
|
32
|
-
profile?: RepoProfile;
|
|
33
27
|
ignoredDirs?: string[];
|
|
34
28
|
};
|
|
35
29
|
|
|
@@ -60,8 +54,10 @@ export type LanguageAnalysis = {
|
|
|
60
54
|
exports: Set<string>;
|
|
61
55
|
valueExports: Set<string>;
|
|
62
56
|
typeExports: Set<string>;
|
|
57
|
+
exportConfidence: "exact" | "heuristic";
|
|
63
58
|
hasDefaultExport: boolean;
|
|
64
59
|
hasWildcardReExport: boolean;
|
|
60
|
+
hasMainEntrypoint: boolean;
|
|
65
61
|
directReExportCount: number;
|
|
66
62
|
localExportCount: number;
|
|
67
63
|
localImplementationCount: number;
|