@luizsantiago/spec-guardrails 3.0.1

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.
Files changed (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +206 -0
  3. package/index.js +335 -0
  4. package/lib/archive.js +208 -0
  5. package/lib/assets.js +145 -0
  6. package/lib/brownfield.js +446 -0
  7. package/lib/config.js +293 -0
  8. package/lib/constants.js +262 -0
  9. package/lib/cursorrules.js +92 -0
  10. package/lib/delta-merge.js +248 -0
  11. package/lib/doctor.js +343 -0
  12. package/lib/download.js +133 -0
  13. package/lib/feature.js +272 -0
  14. package/lib/fs-utils.js +114 -0
  15. package/lib/gates.js +138 -0
  16. package/lib/install.js +140 -0
  17. package/lib/memory.js +34 -0
  18. package/lib/next-steps.js +50 -0
  19. package/lib/presets.js +176 -0
  20. package/lib/project-rules.js +210 -0
  21. package/lib/specs-utils.js +117 -0
  22. package/lib/token-cost.js +124 -0
  23. package/package.json +46 -0
  24. package/rules/engineering-baseline.mdc +56 -0
  25. package/scripts/_common.py +356 -0
  26. package/scripts/analyze_artifacts.py +187 -0
  27. package/scripts/check_commit.py +140 -0
  28. package/scripts/lessons.py +447 -0
  29. package/scripts/loop_plan.py +217 -0
  30. package/scripts/validate_spec.py +345 -0
  31. package/scripts/validate_state.py +385 -0
  32. package/scripts/validate_tasks.py +379 -0
  33. package/skills/agent-architecture.md +221 -0
  34. package/skills/appsec.md +83 -0
  35. package/skills/code-simplify.md +49 -0
  36. package/skills/engineering-standards.md +98 -0
  37. package/skills/git-handoff.md +213 -0
  38. package/skills/qa-strategy.md +83 -0
  39. package/skills/references/analyze.md +56 -0
  40. package/skills/references/archive.md +60 -0
  41. package/skills/references/constitution.md +66 -0
  42. package/skills/references/context-limits.md +73 -0
  43. package/skills/references/converge.md +47 -0
  44. package/skills/references/design.md +88 -0
  45. package/skills/references/discuss.md +68 -0
  46. package/skills/references/explore.md +61 -0
  47. package/skills/references/implement.md +175 -0
  48. package/skills/references/lessons.md +71 -0
  49. package/skills/references/memory.md +98 -0
  50. package/skills/references/project-init.md +62 -0
  51. package/skills/references/quick-mode.md +84 -0
  52. package/skills/references/specify.md +144 -0
  53. package/skills/references/sub-agents.md +117 -0
  54. package/skills/references/tasks.md +178 -0
  55. package/skills/references/validate.md +210 -0
  56. package/skills/security-review.md +120 -0
  57. package/skills/ship-ready.md +50 -0
  58. package/skills/task-graph-engineering.md +180 -0
  59. package/templates/GETTING_STARTED.md +61 -0
  60. package/templates/config.yaml.example +28 -0
  61. package/templates/presets/default.yaml +16 -0
  62. package/templates/presets/node-ts.yaml +22 -0
  63. package/templates/presets/python.yaml +22 -0
package/lib/archive.js ADDED
@@ -0,0 +1,208 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ import { STATE_HEADER } from "./constants.js";
5
+ import {
6
+ domainSpecStub,
7
+ mergeFeatureIntoDomain,
8
+ } from "./delta-merge.js";
9
+ import { ensureDir, readFileSafe, writeFileSafe } from "./fs-utils.js";
10
+ import { runGate } from "./gates.js";
11
+ import {
12
+ featureDir,
13
+ readFeatureArtifact,
14
+ resolveFeatureId,
15
+ } from "./specs-utils.js";
16
+
17
+ const ROADMAP_HEADER = `# Roadmap
18
+
19
+ Track milestones and archived features.
20
+
21
+ ## Completed
22
+
23
+ `;
24
+
25
+ /**
26
+ * @param {string} text
27
+ * @returns {boolean}
28
+ */
29
+ function validationPassed(text) {
30
+ const visible = text.replace(/<!--[\s\S]*?-->/g, "");
31
+ return /\b(?:PASS|PASSED)\b/.test(visible);
32
+ }
33
+
34
+ /**
35
+ * @param {string} cwd
36
+ * @param {string} featureId
37
+ * @param {string} domainRelPath
38
+ * @param {string[]} mergeSummary
39
+ */
40
+ async function updateRoadmap(cwd, featureId, domainRelPath, mergeSummary) {
41
+ const roadmapPath = path.join(cwd, ".specs/project/ROADMAP.md");
42
+ await ensureDir(path.dirname(roadmapPath));
43
+
44
+ let content;
45
+ try {
46
+ content = await readFileSafe(roadmapPath);
47
+ } catch {
48
+ content = ROADMAP_HEADER;
49
+ }
50
+
51
+ if (!content.includes("## Completed")) {
52
+ content = `${content.trimEnd()}\n\n## Completed\n\n`;
53
+ }
54
+
55
+ const date = new Date().toISOString().slice(0, 10);
56
+ const validationLink = `.specs/features/${featureId}/validation.md`;
57
+ let entry = `- **${date}** \`${featureId}\` — archived. Validation: \`${validationLink}\``;
58
+
59
+ if (domainRelPath) {
60
+ entry += `\n - Merged → \`${domainRelPath}\``;
61
+ if (mergeSummary.length) {
62
+ entry += ` (${mergeSummary.join(", ")})`;
63
+ }
64
+ }
65
+
66
+ entry += "\n";
67
+
68
+ if (content.includes(entry.trim())) {
69
+ return { updated: false, path: roadmapPath };
70
+ }
71
+
72
+ const completedHeading = "## Completed";
73
+ const idx = content.indexOf(completedHeading);
74
+ const insertAt = idx + completedHeading.length;
75
+ const before = content.slice(0, insertAt);
76
+ const after = content.slice(insertAt).replace(/^\n*/, "\n\n");
77
+ content = `${before}\n${entry}${after}`;
78
+
79
+ await writeFileSafe(roadmapPath, content);
80
+ return { updated: true, path: roadmapPath };
81
+ }
82
+
83
+ /**
84
+ * @param {string} cwd
85
+ */
86
+ async function resetState(cwd) {
87
+ const statePath = path.join(cwd, ".specs/STATE.md");
88
+ let content;
89
+
90
+ try {
91
+ content = await readFileSafe(statePath);
92
+ } catch {
93
+ content = STATE_HEADER;
94
+ }
95
+
96
+ content = content.replace(/^-\s*Feature:\s*.*$/m, "- Feature: —");
97
+ content = content.replace(/^-\s*Phase:\s*.*$/m, "- Phase: —");
98
+ content = content.replace(/^-\s*Branch:\s*.*$/m, "- Branch: —");
99
+ content = content.replace(
100
+ /^## Next Step \(single item\)\n- \[ \].*$/m,
101
+ "## Next Step (single item)\n- [ ] —",
102
+ );
103
+
104
+ await writeFileSafe(statePath, content);
105
+ }
106
+
107
+ /**
108
+ * Infer domain slug from feature id (003-chat-system → chat-system).
109
+ *
110
+ * @param {string} featureId
111
+ * @returns {string}
112
+ */
113
+ export function inferDomainFromFeature(featureId) {
114
+ const match = /^(\d{3})-(.+)$/.exec(featureId);
115
+ return match ? match[2] : featureId;
116
+ }
117
+
118
+ /**
119
+ * Archive a verified feature: ROADMAP update, optional domain merge, STATE reset.
120
+ *
121
+ * @param {string | undefined} featureArg
122
+ * @param {{
123
+ * cwd?: string,
124
+ * domain?: string,
125
+ * skipVerify?: boolean,
126
+ * skipRoadmap?: boolean,
127
+ * skipState?: boolean,
128
+ * skipDomainMerge?: boolean,
129
+ * }} [options]
130
+ */
131
+ export async function archiveFeature(featureArg, options = {}) {
132
+ const cwd = options.cwd ?? process.cwd();
133
+ const featureId = await resolveFeatureId(featureArg, cwd);
134
+ const featurePath = featureDir(featureId, cwd);
135
+
136
+ try {
137
+ await fs.access(featurePath);
138
+ } catch {
139
+ throw new Error(`Feature directory not found: ${featurePath}`);
140
+ }
141
+
142
+ if (!options.skipVerify) {
143
+ const validationText = await readFeatureArtifact(featureId, cwd, "validation.md").catch(
144
+ () => {
145
+ throw new Error(
146
+ `Missing validation.md for ${featureId}. Run validate-state before archive.`,
147
+ );
148
+ },
149
+ );
150
+
151
+ if (!validationPassed(validationText)) {
152
+ throw new Error(
153
+ `validation.md for ${featureId} does not contain PASS/PASSED. Run validate-state first.`,
154
+ );
155
+ }
156
+
157
+ const gateCode = await runGate("validate-state", [featureId], {
158
+ cwd,
159
+ stdio: "pipe",
160
+ });
161
+ if (gateCode !== 0) {
162
+ throw new Error(`validate-state failed for ${featureId}. Fix gaps before archive.`);
163
+ }
164
+ }
165
+
166
+ const specText = await readFeatureArtifact(featureId, cwd, "spec.md");
167
+ let domainRelPath = "";
168
+ let mergeSummary = [];
169
+
170
+ if (!options.skipDomainMerge) {
171
+ const domain = options.domain ?? inferDomainFromFeature(featureId);
172
+ const domainDir = path.join(cwd, ".specs/domains", domain);
173
+ const domainSpecPath = path.join(domainDir, "spec.md");
174
+ domainRelPath = `.specs/domains/${domain}/spec.md`;
175
+
176
+ await ensureDir(domainDir);
177
+
178
+ let domainSpec;
179
+ try {
180
+ domainSpec = await readFileSafe(domainSpecPath);
181
+ } catch {
182
+ domainSpec = domainSpecStub(domain, featureId);
183
+ }
184
+
185
+ const merged = mergeFeatureIntoDomain(domainSpec, specText);
186
+ mergeSummary = merged.summary;
187
+ await writeFileSafe(domainSpecPath, merged.spec);
188
+ }
189
+
190
+ let roadmap = { updated: false, path: "" };
191
+ if (!options.skipRoadmap) {
192
+ roadmap = await updateRoadmap(cwd, featureId, domainRelPath, mergeSummary);
193
+ }
194
+
195
+ if (!options.skipState) {
196
+ await resetState(cwd);
197
+ }
198
+
199
+ return {
200
+ featureId,
201
+ featureDir: path.join(".specs/features", featureId),
202
+ domainPath: domainRelPath || null,
203
+ mergeSummary,
204
+ roadmapUpdated: roadmap.updated,
205
+ roadmapPath: roadmap.path ? path.relative(cwd, roadmap.path) : null,
206
+ stateReset: !options.skipState,
207
+ };
208
+ }
package/lib/assets.js ADDED
@@ -0,0 +1,145 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ import {
6
+ FALLBACK_REPO_URL,
7
+ PACKAGE_NAME,
8
+ PINNED_REF,
9
+ assertSafeAssetBase,
10
+ resolveAssetOverride,
11
+ resolveAssetUrl,
12
+ } from "./constants.js";
13
+ import { downloadToFile } from "./download.js";
14
+ import { assertSafeWriteTarget } from "./fs-utils.js";
15
+
16
+ const PACKAGE_ROOT = path.resolve(
17
+ path.join(path.dirname(fileURLToPath(import.meta.url)), ".."),
18
+ );
19
+
20
+ /**
21
+ * Absolute path of a guardrails asset shipped inside the npm package.
22
+ *
23
+ * @param {string} remotePath path relative to the repo root (e.g. skills/agent-architecture.md)
24
+ */
25
+ export function packagedAssetPath(remotePath) {
26
+ const resolved = path.resolve(PACKAGE_ROOT, remotePath);
27
+ const rootWithSep = PACKAGE_ROOT.endsWith(path.sep)
28
+ ? PACKAGE_ROOT
29
+ : PACKAGE_ROOT + path.sep;
30
+
31
+ if (resolved !== PACKAGE_ROOT && !resolved.startsWith(rootWithSep)) {
32
+ throw new Error(
33
+ `Refusing packaged asset path outside package root: ${remotePath}`,
34
+ );
35
+ }
36
+
37
+ return resolved;
38
+ }
39
+
40
+ /**
41
+ * Default install copies from the package. A remote fetch happens only when
42
+ * `SPEC_GUARDRAILS_REPO_URL` or `options.repoUrl` is set (forks and the test suite).
43
+ *
44
+ * @param {string} [repoUrl]
45
+ * @returns {{ mode: "package" } | { mode: "remote", repoUrl: string }}
46
+ */
47
+ export function resolveInstallSource(repoUrl) {
48
+ const override = repoUrl ?? resolveAssetOverride();
49
+ if (override) {
50
+ return { mode: "remote", repoUrl: assertSafeAssetBase(override) };
51
+ }
52
+ return { mode: "package" };
53
+ }
54
+
55
+ /**
56
+ * @param {string} remotePath
57
+ * @param {string} destPath
58
+ */
59
+ export async function copyPackagedAsset(remotePath, destPath) {
60
+ const source = packagedAssetPath(remotePath);
61
+
62
+ try {
63
+ await fs.access(source);
64
+ } catch (err) {
65
+ if (err.code === "ENOENT") {
66
+ throw new Error(
67
+ `Packaged guardrails asset missing: ${remotePath}. ` +
68
+ `Reinstall ${PACKAGE_NAME}.`,
69
+ );
70
+ }
71
+ throw err;
72
+ }
73
+
74
+ await assertSafeWriteTarget(destPath);
75
+
76
+ try {
77
+ await fs.copyFile(source, destPath);
78
+ } catch (err) {
79
+ if (err.code === "ENOENT") {
80
+ throw new Error(`cannot write ${destPath} - destination directory missing`);
81
+ }
82
+ if (err.code === "EACCES" || err.code === "EPERM") {
83
+ throw new Error(`Permission denied: cannot write ${destPath}`);
84
+ }
85
+ throw err;
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Download one asset. Pin-tag fallback applies only when fetching the official
91
+ * repo with no override (kept for the remote path; the default install no
92
+ * longer uses it).
93
+ *
94
+ * @param {{ remotePath: string, destPath: string, repoUrl?: string, state: { warned: boolean }, log: (msg: string) => void }} params
95
+ */
96
+ export async function downloadRemoteAsset({
97
+ remotePath,
98
+ destPath,
99
+ repoUrl,
100
+ state,
101
+ log,
102
+ }) {
103
+ try {
104
+ await downloadToFile(resolveAssetUrl(remotePath, repoUrl), destPath);
105
+ return;
106
+ } catch (err) {
107
+ const missingPinnedAsset =
108
+ !repoUrl && /Download failed: 404/.test(err.message);
109
+
110
+ if (!missingPinnedAsset) {
111
+ throw err;
112
+ }
113
+
114
+ if (!state.warned) {
115
+ state.warned = true;
116
+ log(
117
+ `⚠️ Tag ${PINNED_REF} has no published assets yet — ` +
118
+ "falling back to the default branch.",
119
+ );
120
+ }
121
+ }
122
+
123
+ await downloadToFile(
124
+ resolveAssetUrl(remotePath, FALLBACK_REPO_URL),
125
+ destPath,
126
+ );
127
+ }
128
+
129
+ /**
130
+ * @param {{ remotePath: string, destPath: string, source: ReturnType<typeof resolveInstallSource>, state: { warned: boolean }, log: (msg: string) => void }} params
131
+ */
132
+ export async function installAsset({ remotePath, destPath, source, state, log }) {
133
+ if (source.mode === "package") {
134
+ await copyPackagedAsset(remotePath, destPath);
135
+ return;
136
+ }
137
+
138
+ await downloadRemoteAsset({
139
+ remotePath,
140
+ destPath,
141
+ repoUrl: source.repoUrl,
142
+ state,
143
+ log,
144
+ });
145
+ }