prae-cli 0.1.1 → 0.2.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.
@@ -0,0 +1,275 @@
1
+ import { readFileSync, statSync } from "node:fs";
2
+ import { basename, dirname, join, resolve } from "node:path";
3
+ import { createHash } from "node:crypto";
4
+
5
+ //#region ../instructions/src/parse.ts
6
+ const HEADING = /^(#{1,6})\s+(.*)$/;
7
+ const BULLET = /^\s*(?:[-*+]|\d+[.)])\s+(.*)$/;
8
+ const FENCE = /^\s*(?:```|~~~)/;
9
+ const COMMENT_OPEN = /^\s*<!--/;
10
+ const COMMENT_CLOSE = /-->/;
11
+ /** How far a continuation line must be indented to belong to the bullet above. */
12
+ const CONTINUATION = /^\s{1,}\S/;
13
+ /** Trim, drop emphasis markers that carry no meaning for a reviewer. */
14
+ function clean(text) {
15
+ return text.trim().replace(/\s+/g, " ");
16
+ }
17
+ /** The parse itself. No Node, so this runs anywhere. */
18
+ function parseStatements(text, format = "markdown") {
19
+ const lines = text.split("\n");
20
+ if (format === "text") {
21
+ const statements = [];
22
+ for (const [index, raw] of lines.entries()) {
23
+ const line = clean(raw);
24
+ if (line === "" || line.startsWith("#")) continue;
25
+ statements.push({
26
+ text: line,
27
+ line: index + 1,
28
+ kind: "bullet",
29
+ headingPath: []
30
+ });
31
+ }
32
+ return {
33
+ format,
34
+ text,
35
+ sections: [{
36
+ headingPath: [],
37
+ level: 0,
38
+ statements
39
+ }]
40
+ };
41
+ }
42
+ const sections = [];
43
+ let path = [];
44
+ let current = {
45
+ level: 0,
46
+ statements: []
47
+ };
48
+ let currentPath = [];
49
+ let fenced = false;
50
+ let commented = false;
51
+ /** An open bullet, which an indented line below it continues. */
52
+ let bulletText;
53
+ /** Consecutive unindented prose lines, which are one wrapped sentence. */
54
+ let paragraph;
55
+ const push = (held, kind) => {
56
+ if (held === void 0) return;
57
+ const joined = clean(held.text.join(" "));
58
+ if (joined !== "") current.statements.push({
59
+ text: joined,
60
+ line: held.line,
61
+ kind,
62
+ headingPath: currentPath
63
+ });
64
+ };
65
+ const flush = () => {
66
+ push(bulletText, "bullet");
67
+ bulletText = void 0;
68
+ push(paragraph, "paragraph");
69
+ paragraph = void 0;
70
+ };
71
+ const flushSection = () => {
72
+ flush();
73
+ if (current.heading === void 0 && current.statements.length === 0) return;
74
+ sections.push({
75
+ ...current.heading === void 0 ? {} : { heading: current.heading },
76
+ headingPath: currentPath,
77
+ level: current.level,
78
+ statements: current.statements
79
+ });
80
+ };
81
+ for (const [index, raw] of lines.entries()) {
82
+ if (commented) {
83
+ if (COMMENT_CLOSE.test(raw)) commented = false;
84
+ continue;
85
+ }
86
+ if (COMMENT_OPEN.test(raw)) {
87
+ flush();
88
+ if (!COMMENT_CLOSE.test(raw)) commented = true;
89
+ continue;
90
+ }
91
+ if (FENCE.test(raw)) {
92
+ flush();
93
+ fenced = !fenced;
94
+ continue;
95
+ }
96
+ if (fenced) continue;
97
+ const heading = HEADING.exec(raw);
98
+ if (heading?.[1] !== void 0 && heading[2] !== void 0) {
99
+ flushSection();
100
+ const level = heading[1].length;
101
+ const title = clean(heading[2]);
102
+ path = [...path.slice(0, level - 1), title];
103
+ currentPath = path;
104
+ current = {
105
+ heading: title,
106
+ level,
107
+ statements: []
108
+ };
109
+ continue;
110
+ }
111
+ const bullet = BULLET.exec(raw);
112
+ if (bullet?.[1] !== void 0) {
113
+ flush();
114
+ bulletText = {
115
+ text: [bullet[1]],
116
+ line: index + 1
117
+ };
118
+ continue;
119
+ }
120
+ if (clean(raw) === "") {
121
+ flush();
122
+ continue;
123
+ }
124
+ if (bulletText !== void 0 && CONTINUATION.test(raw)) {
125
+ bulletText.text.push(raw);
126
+ continue;
127
+ }
128
+ push(bulletText, "bullet");
129
+ bulletText = void 0;
130
+ if (paragraph === void 0) paragraph = {
131
+ text: [raw],
132
+ line: index + 1
133
+ };
134
+ else paragraph.text.push(raw);
135
+ }
136
+ flushSection();
137
+ return {
138
+ format,
139
+ text,
140
+ sections
141
+ };
142
+ }
143
+ /** Every statement in the document, in file order. */
144
+ function statementsOf(doc) {
145
+ return doc.sections.flatMap((section) => section.statements);
146
+ }
147
+
148
+ //#endregion
149
+ //#region ../instructions/src/node.ts
150
+ /** Parse an instruction file and hash it. */
151
+ function parseInstructions(text, format = "markdown") {
152
+ return {
153
+ ...parseStatements(text, format),
154
+ hash: createHash("sha256").update(text).digest("hex")
155
+ };
156
+ }
157
+
158
+ //#endregion
159
+ //#region ../instructions/src/discover.ts
160
+ /** The formats each recognised filename is read as. */
161
+ const FORMATS = {
162
+ "AGENTS.md": "markdown",
163
+ "CLAUDE.md": "markdown",
164
+ ".cursorrules": "text",
165
+ ".windsurfrules": "text"
166
+ };
167
+ const DEFAULT_CANDIDATES = Object.keys(FORMATS);
168
+ const DEFAULT_ROOT_MARKERS = [
169
+ ".git",
170
+ "package.json",
171
+ "pyproject.toml",
172
+ "go.mod"
173
+ ];
174
+ const DEFAULT_MAX_BYTES = 256 * 1024;
175
+ const DEFAULT_MAX_DEPTH = 24;
176
+ function exists(path) {
177
+ try {
178
+ statSync(path);
179
+ return true;
180
+ } catch {
181
+ return false;
182
+ }
183
+ }
184
+ /**
185
+ * Walk up from `from`, collecting instruction files, outermost first.
186
+ *
187
+ * A file larger than `maxBytes` is skipped rather than truncated: half an
188
+ * instruction file is worse than none, because the half that is missing is
189
+ * invisible to whoever reads the result.
190
+ */
191
+ function discover(from, options = {}) {
192
+ const candidates = options.candidates ?? DEFAULT_CANDIDATES;
193
+ const markers = options.rootMarkers ?? DEFAULT_ROOT_MARKERS;
194
+ const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
195
+ const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
196
+ const dirs = [];
197
+ let dir = resolve(from);
198
+ for (let depth = 0; depth < maxDepth; depth += 1) {
199
+ dirs.push(dir);
200
+ if (markers.some((marker) => exists(join(dir, marker)))) break;
201
+ const parent = dirname(dir);
202
+ if (parent === dir) break;
203
+ dir = parent;
204
+ }
205
+ const found = [];
206
+ for (const directory of dirs.reverse()) for (const name of candidates) {
207
+ const path = join(directory, name);
208
+ if (!exists(path)) continue;
209
+ let size;
210
+ try {
211
+ size = statSync(path).size;
212
+ } catch {
213
+ continue;
214
+ }
215
+ if (size > maxBytes) continue;
216
+ let text;
217
+ try {
218
+ text = readFileSync(path, "utf8");
219
+ } catch {
220
+ continue;
221
+ }
222
+ if (text.trim() === "") continue;
223
+ found.push({
224
+ ...parseInstructions(text, FORMATS[name] ?? "markdown"),
225
+ path,
226
+ name,
227
+ layer: "project",
228
+ depth: found.length
229
+ });
230
+ }
231
+ return found;
232
+ }
233
+
234
+ //#endregion
235
+ //#region ../instructions/src/index.ts
236
+ /**
237
+ * Read every instruction file that governs work at `from`.
238
+ *
239
+ * Ordered least specific to most: operator files first, then project files
240
+ * outermost-in. A reader looking at the stack can see which layer a sentence
241
+ * came from without opening anything.
242
+ */
243
+ function ingest(from, options = {}) {
244
+ const operator = [];
245
+ for (const path of options.operator ?? []) {
246
+ let text;
247
+ try {
248
+ text = readFileSync(path, "utf8");
249
+ } catch {
250
+ continue;
251
+ }
252
+ if (text.trim() === "") continue;
253
+ const name = basename(path);
254
+ operator.push({
255
+ ...parseInstructions(text, FORMATS[name] ?? "markdown"),
256
+ path,
257
+ name,
258
+ layer: "operator",
259
+ depth: operator.length
260
+ });
261
+ }
262
+ const files = [...operator, ...discover(from, options)];
263
+ return {
264
+ files,
265
+ statements: files.flatMap((file) => statementsOf(file).map((statement) => ({
266
+ ...statement,
267
+ path: file.path,
268
+ layer: file.layer
269
+ }))),
270
+ hash: createHash("sha256").update(files.map((file) => `${file.layer}:${file.path}:${file.hash}`).join("\n")).digest("hex")
271
+ };
272
+ }
273
+
274
+ //#endregion
275
+ export { DEFAULT_CANDIDATES, DEFAULT_ROOT_MARKERS, FORMATS, discover, ingest, parseInstructions, parseStatements, statementsOf };
@@ -0,0 +1,153 @@
1
+ //#region ../extract/src/index.ts
2
+ /** Backticked or quoted literals, which is where a real path or host appears. */
3
+ function literals(text) {
4
+ const found = /* @__PURE__ */ new Set();
5
+ for (const match of text.matchAll(/`([^`]+)`|"([^"]+)"|'([^']+)'/g)) {
6
+ const value = (match[1] ?? match[2] ?? match[3] ?? "").trim();
7
+ if (value !== "") found.add(value);
8
+ }
9
+ return [...found];
10
+ }
11
+ const PATH_LIKE = /^[./~]|\/$|^[\w.-]+\/|\*\*/;
12
+ const HOST_LIKE = /^(?:https?:\/\/)?([a-z0-9-]+\.)+[a-z]{2,}(?:\/|$)/i;
13
+ function paths(text) {
14
+ return literals(text).filter((value) => PATH_LIKE.test(value) && !HOST_LIKE.test(value)).map((value) => value.endsWith("/") ? `${value}**` : value);
15
+ }
16
+ function hosts(text) {
17
+ return literals(text).filter((value) => HOST_LIKE.test(value)).map((value) => value.replace(/^https?:\/\//, "").replace(/\/.*$/, ""));
18
+ }
19
+ /** `PRAE-P01` — proposals are numbered apart from anything hand-written. */
20
+ function id(index) {
21
+ return `PRAE-P${String(index + 1).padStart(2, "0")}`;
22
+ }
23
+ /**
24
+ * The readings, kept few on purpose.
25
+ *
26
+ * Each one is a phrasing whose meaning is not in doubt *and* which names what
27
+ * it applies to. Adding a looser pattern here trades precision for recall, and
28
+ * the whole value of a proposal is that a reviewer can agree with it in a
29
+ * second without opening the file.
30
+ */
31
+ const READINGS = [
32
+ {
33
+ test: /\bwrites?\b[^.]*\b(?:stay|remain|live|only|confined)\b[^.]*\b(?:in|inside|within|under)\b/i,
34
+ confidence: "high",
35
+ why: "names where writes are allowed, so anything outside is not",
36
+ build: (found) => found.paths.length === 0 ? void 0 : {
37
+ title: "Writes stay in the workspace",
38
+ when: {
39
+ capability: "fs:write",
40
+ path: { outside: found.paths }
41
+ },
42
+ effect: {
43
+ kind: "deny",
44
+ because: `writes outside ${found.paths.join(", ")} are not granted in this pack`
45
+ }
46
+ }
47
+ },
48
+ {
49
+ test: /\b(?:never|do not|don't|no)\b[^.]*\bwrit\w*\b[^.]*\b(?:outside|beyond|above)\b/i,
50
+ confidence: "high",
51
+ why: "forbids writing outside the paths it names",
52
+ build: (found) => found.paths.length === 0 ? void 0 : {
53
+ title: "Writes stay in the workspace",
54
+ when: {
55
+ capability: "fs:write",
56
+ path: { outside: found.paths }
57
+ },
58
+ effect: {
59
+ kind: "deny",
60
+ because: `writes outside ${found.paths.join(", ")} are not granted in this pack`
61
+ }
62
+ }
63
+ },
64
+ {
65
+ test: /\b(?:never|do not|don't|no)\b[^.]*\b(?:read|open|access|touch)\b/i,
66
+ confidence: "high",
67
+ why: "forbids reading the paths it names",
68
+ build: (found) => found.paths.length === 0 ? void 0 : {
69
+ title: "Secrets are not readable",
70
+ when: {
71
+ capability: "fs:read",
72
+ path: { inside: found.paths }
73
+ },
74
+ effect: {
75
+ kind: "deny",
76
+ because: `reads of ${found.paths.join(", ")} are not granted in this pack`
77
+ }
78
+ }
79
+ },
80
+ {
81
+ test: /\bonly\b[^.]*\b(?:fetch|request|call|download)\b[^.]*\bfrom\b/i,
82
+ confidence: "high",
83
+ why: "names the only hosts allowed, so any other host is not",
84
+ build: (found) => found.hosts.length === 0 ? void 0 : {
85
+ title: "Fetches stay on listed hosts",
86
+ when: {
87
+ capability: "net:fetch",
88
+ host: { notIn: found.hosts }
89
+ },
90
+ effect: {
91
+ kind: "deny",
92
+ because: `this host is not on the pack allowlist (${found.hosts.join(", ")})`
93
+ }
94
+ }
95
+ },
96
+ {
97
+ test: /\b(?:never|do not|don't|no)\b[^.]*\b(?:fetch|request|download|call)\b/i,
98
+ confidence: "medium",
99
+ why: "forbids reaching the hosts it names, though the phrasing is broad",
100
+ build: (found) => found.hosts.length === 0 ? void 0 : {
101
+ title: "Listed hosts are refused",
102
+ when: {
103
+ capability: "net:fetch",
104
+ host: { in: found.hosts }
105
+ },
106
+ effect: {
107
+ kind: "deny",
108
+ because: `${found.hosts.join(", ")} is refused by this pack`
109
+ }
110
+ }
111
+ }
112
+ ];
113
+ /** Read statements and propose rules. Deterministic; no model involved. */
114
+ function extract(statements, options = {}) {
115
+ const proposals = [];
116
+ const skipped = [];
117
+ for (const statement of statements) {
118
+ const found = {
119
+ paths: paths(statement.text),
120
+ hosts: hosts(statement.text)
121
+ };
122
+ let proposed = false;
123
+ for (const reading of READINGS) {
124
+ if (!reading.test.test(statement.text)) continue;
125
+ const built = reading.build(found);
126
+ if (built === void 0) continue;
127
+ const effect = options.as === void 0 ? built.effect : {
128
+ kind: options.as,
129
+ because: built.effect.because
130
+ };
131
+ proposals.push({
132
+ rule: {
133
+ id: id(proposals.length),
134
+ ...built,
135
+ effect
136
+ },
137
+ from: statement,
138
+ confidence: reading.confidence,
139
+ why: reading.why
140
+ });
141
+ proposed = true;
142
+ break;
143
+ }
144
+ if (!proposed) skipped.push(statement);
145
+ }
146
+ return {
147
+ proposals,
148
+ skipped
149
+ };
150
+ }
151
+
152
+ //#endregion
153
+ export { extract };