dotdog 0.2.4 → 0.3.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.
Files changed (2) hide show
  1. package/dist/cli.js +257 -18
  2. package/package.json +14 -4
package/dist/cli.js CHANGED
@@ -2537,6 +2537,7 @@ var source_default = chalk;
2537
2537
  import { existsSync as existsSync2, readdirSync as readdirSync2, readFileSync as readFileSync2, mkdirSync, writeFileSync } from "fs";
2538
2538
  import { join as join2 } from "path";
2539
2539
  import { homedir as homedir2 } from "os";
2540
+ import { createHash } from "crypto";
2540
2541
 
2541
2542
  // src/parser.ts
2542
2543
  function parse(source) {
@@ -3094,7 +3095,7 @@ function parseSections2(markdown) {
3094
3095
  }
3095
3096
  var program2 = new Command;
3096
3097
  var pkg = JSON.parse(readFileSync2(new URL("../package.json", import.meta.url), "utf-8"));
3097
- program2.name("spec").alias("dotdog").description("The spec dog — validate, analyze, generate .dog files").version(pkg.version);
3098
+ program2.name("spec").alias("dotdog").description("The spec dog — validate, analyze, generate, simulate .dog files").version(pkg.version);
3098
3099
  program2.command("validate [dir]").action((d = ".") => {
3099
3100
  const dirs = [join2(d, "projects"), join2(d, "specs")];
3100
3101
  let found = false;
@@ -3200,28 +3201,64 @@ program2.command("compile [dir]").option("-o, --output <file>").action((d = ".",
3200
3201
  const pd = join2(dd, p, "specs");
3201
3202
  if (!existsSync2(pd))
3202
3203
  continue;
3203
- const files = readdirSync2(pd).filter((f) => f.endsWith(".dog"));
3204
- const dag = { version: "1.0", project: p, compiled_at: new Date().toISOString(), nodes: [], edges: [], count: files.length };
3204
+ const files = readdirSync2(pd).filter((f) => f.endsWith(".dog")).sort();
3205
+ if (!files.length)
3206
+ continue;
3207
+ found = true;
3208
+ const sources = {};
3209
+ let sourceBytes = 0;
3210
+ const hash = createHash("sha256");
3211
+ for (const f of files) {
3212
+ const content = readFileSync2(join2(pd, f), "utf-8");
3213
+ sources[f] = content;
3214
+ sourceBytes += Buffer.byteLength(content, "utf-8");
3215
+ hash.update(content);
3216
+ hash.update(`
3217
+ `);
3218
+ }
3219
+ const integrity = { sha256: hash.digest("hex"), source_files: files.length, source_bytes: sourceBytes };
3220
+ const sourceTokens = Math.round(sourceBytes / 4);
3221
+ const nodes = [], edges = [];
3205
3222
  for (const f of files) {
3206
- const c = readFileSync2(join2(pd, f), "utf-8");
3207
- const secs = parseSections2(c);
3208
- for (const s of secs) {
3209
- if (s.heading.includes("Entity:") || s.heading.includes("Relationship:")) {
3210
- const isRel = s.heading.includes("Relationship:");
3211
- if (isRel) {
3212
- const parts = s.heading.replace("Relationship:", "").split("→").map((x) => x.trim());
3213
- dag.edges.push({ source: parts[0] || "?", target: parts[1] || "?", file: f, section: s.heading });
3214
- } else {
3215
- dag.nodes.push({ id: s.heading.replace("Entity:", "").trim(), file: f, chars: s.content.length });
3223
+ const ast = parse(sources[f]);
3224
+ for (const section of ast.sections) {
3225
+ for (const block of section.blocks) {
3226
+ if (block.kind === "entity") {
3227
+ nodes.push({
3228
+ id: block.name,
3229
+ type: block.type,
3230
+ description: block.description || "",
3231
+ file: f,
3232
+ properties: Object.keys(block.properties).length,
3233
+ states: block.states || [],
3234
+ chars: section.content?.length || 0
3235
+ });
3236
+ }
3237
+ if (block.kind === "relationship") {
3238
+ edges.push({
3239
+ source: block.source,
3240
+ target: block.target,
3241
+ verb: block.verb,
3242
+ cardinality: block.cardinality,
3243
+ required: block.required,
3244
+ file: f
3245
+ });
3216
3246
  }
3217
3247
  }
3218
3248
  }
3219
3249
  }
3220
- found = true;
3221
- const out = opts.output || join2(pd, "..", `${p}.dag`);
3222
- writeFileSync(out, JSON.stringify(dag, null, 2));
3223
- console.log(source_default.green(` ✓ ${out}`));
3224
- console.log(source_default.gray(` ${dag.nodes.length} nodes, ${dag.edges.length} edges, ${dag.count} files`));
3250
+ const dag = { version: "1.2", project: p, compiled_at: new Date().toISOString(), compiler: `dotdog@${pkg.version}`, integrity, nodes, edges };
3251
+ const dagJson = JSON.stringify(dag);
3252
+ const dagTokens = Math.round(Buffer.byteLength(dagJson, "utf-8") / 4);
3253
+ const savingsPct = sourceTokens > 0 ? Math.round((1 - dagTokens / sourceTokens) * 1000) / 10 : 0;
3254
+ const savingsTokens = sourceTokens - dagTokens;
3255
+ const outPath = opts.output || join2(pd, "..", `${p}.dag`);
3256
+ const report = { ...dag, tokens: { source_total: sourceTokens, dag_total: dagTokens, savings_pct: savingsPct, savings_tokens: savingsTokens } };
3257
+ writeFileSync(outPath, JSON.stringify(report, null, 2));
3258
+ console.log(source_default.green(` ✓ ${outPath}`));
3259
+ console.log(source_default.gray(` ${nodes.length} nodes, ${edges.length} edges, ${files.length} files`));
3260
+ console.log(source_default.gray(` ${sourceTokens} → ${dagTokens} tokens (${savingsPct}% savings, ${savingsTokens} tokens saved)`));
3261
+ console.log(source_default.gray(` sha256: ${integrity.sha256.substring(0, 12)}...`));
3225
3262
  }
3226
3263
  }
3227
3264
  if (!found)
@@ -3259,6 +3296,208 @@ ${out}`);
3259
3296
  }
3260
3297
  });
3261
3298
  program2.command("serve [dir]").description("MCP server — expose .dag graph to AI agents over stdio").action((d = ".") => serve(d));
3299
+ program2.command("analyze [dir]").description("Analyze a spec project — score, gaps, suggestions").option("-p, --project <name>").action((d = ".", opts) => {
3300
+ const dir = resolvePath2(d);
3301
+ const dirs = [join2(dir, "projects"), join2(dir, "specs"), dir];
3302
+ console.log(source_default.bold(`
3303
+ Spec Analysis
3304
+ `));
3305
+ let found = false;
3306
+ for (const dd of dirs) {
3307
+ if (!existsSync2(dd))
3308
+ continue;
3309
+ const projects = readdirSync2(dd, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
3310
+ for (const p of projects) {
3311
+ if (opts.project && p !== opts.project)
3312
+ continue;
3313
+ const pd = join2(dd, p, "specs");
3314
+ if (!existsSync2(pd))
3315
+ continue;
3316
+ const files = readdirSync2(pd).filter((f) => f.endsWith(".dog"));
3317
+ if (!files.length)
3318
+ continue;
3319
+ found = true;
3320
+ console.log(source_default.bold(`
3321
+ ${p}`));
3322
+ console.log(" " + "─".repeat(50));
3323
+ const allEntities = [];
3324
+ const allRelationships = [];
3325
+ const analyses = [];
3326
+ for (const f of files) {
3327
+ const content = readFileSync2(join2(pd, f), "utf-8");
3328
+ const ast = parse(content);
3329
+ const entities = ast.sections.flatMap((s) => s.blocks.filter((b) => b.kind === "entity"));
3330
+ const rels = ast.sections.flatMap((s) => s.blocks.filter((b) => b.kind === "relationship"));
3331
+ allEntities.push(...entities);
3332
+ allRelationships.push(...rels);
3333
+ analyses.push({ file: f, sections: ast.sections.length, size: content.length, entities: entities.length, rels: rels.length });
3334
+ }
3335
+ const missingReq = ["SPEC.dog", "constitution.dog", "data-model.dog"].filter((f) => !files.includes(f));
3336
+ const missingOpt = ["COPY.dog", "plan.dog", "DESIGN-SYSTEM.dog", "INDEX.dog"].filter((f) => !files.includes(f));
3337
+ let score = 100 - missingReq.length * 15 - missingOpt.length * 5;
3338
+ const noDesc = allEntities.filter((e) => !e.description || e.description.length < 10).length;
3339
+ score = Math.max(0, score - noDesc * 3);
3340
+ const noProps = allEntities.filter((e) => Object.keys(e.properties).length === 0).length;
3341
+ score = Math.max(0, score - noProps * 5);
3342
+ const noStates = allEntities.filter((e) => e.states.length === 0).length;
3343
+ score = Math.max(0, score - noStates * 3);
3344
+ console.log(` ${files.length} files | ${score}% complete`);
3345
+ for (const a of analyses) {
3346
+ const detail = a.entities > 0 ? ` (${a.entities} entities, ${a.rels} rels)` : "";
3347
+ console.log(source_default.gray(` ${a.file} — ${a.sections} sections, ${(a.size / 1024).toFixed(1)}KB${detail}`));
3348
+ }
3349
+ const gaps = [];
3350
+ for (const f of missingReq)
3351
+ gaps.push(`\uD83D\uDD34 ${f}: Missing required file`);
3352
+ for (const f of missingOpt)
3353
+ gaps.push(`\uD83D\uDFE1 ${f}: Missing optional file`);
3354
+ const entityNames = new Set(allEntities.map((e) => e.name));
3355
+ for (const e of allEntities) {
3356
+ if (!e.description || e.description.length < 10)
3357
+ gaps.push(`\uD83D\uDFE1 ${e.name}: No description`);
3358
+ if (Object.keys(e.properties).length === 0)
3359
+ gaps.push(`\uD83D\uDFE1 ${e.name}: No properties defined`);
3360
+ if (e.states.length === 0)
3361
+ gaps.push(`\uD83D\uDD35 ${e.name}: No states defined`);
3362
+ }
3363
+ for (const r of allRelationships) {
3364
+ if (r.source && !entityNames.has(r.source))
3365
+ gaps.push(`\uD83D\uDFE1 Relationship: unknown source "${r.source}"`);
3366
+ if (r.target && !entityNames.has(r.target))
3367
+ gaps.push(`\uD83D\uDFE1 Relationship: unknown target "${r.target}"`);
3368
+ }
3369
+ if (gaps.length > 0) {
3370
+ console.log(source_default.bold(`
3371
+ Gaps (${gaps.length})`));
3372
+ for (const g of gaps)
3373
+ console.log(` ${g}`);
3374
+ } else
3375
+ console.log(source_default.green(`
3376
+ No gaps found.`));
3377
+ }
3378
+ }
3379
+ if (!found)
3380
+ console.log(source_default.yellow("No spec projects found. Run: dotdog init <project>"));
3381
+ });
3382
+ program2.command("generate [dir]").description("Generate missing spec files from SPEC.dog").option("-p, --project <name>").action((d = ".", opts) => {
3383
+ const dir = resolvePath2(d);
3384
+ const dirs = [join2(dir, "projects"), join2(dir, "specs"), dir];
3385
+ let specContent = "", specDir = "";
3386
+ for (const dd of dirs) {
3387
+ if (!existsSync2(dd))
3388
+ continue;
3389
+ const projects = readdirSync2(dd, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
3390
+ for (const p of projects) {
3391
+ if (opts.project && p !== opts.project)
3392
+ continue;
3393
+ const pd = join2(dd, p, "specs");
3394
+ const sp = join2(pd, "SPEC.dog");
3395
+ if (existsSync2(sp)) {
3396
+ specContent = readFileSync2(sp, "utf-8");
3397
+ specDir = pd;
3398
+ break;
3399
+ }
3400
+ }
3401
+ if (specContent)
3402
+ break;
3403
+ }
3404
+ if (!specContent) {
3405
+ console.log(source_default.red("No SPEC.dog found. Create one first."));
3406
+ return;
3407
+ }
3408
+ console.log(source_default.bold(`
3409
+ Spec Generator
3410
+ `));
3411
+ console.log(source_default.gray(` Source: ${specDir}/SPEC.dog
3412
+ `));
3413
+ const ast = parse(specContent);
3414
+ const entities = ast.sections.flatMap((s) => s.blocks.filter((b) => b.kind === "entity"));
3415
+ const uiStrings = [];
3416
+ for (const section of ast.sections) {
3417
+ const h = section.heading.toLowerCase();
3418
+ if (h.includes("what the user sees") || h.includes("screen")) {
3419
+ const text = section.blocks.filter((b) => b.kind === "prose").map((b) => b.content).join(`
3420
+ `);
3421
+ for (const m of text.match(/\[([^\]]+)\]/g) || [])
3422
+ uiStrings.push({ screen: section.heading, element: "button", text: m });
3423
+ for (const m of text.match(/"([^"]+)"/g) || [])
3424
+ uiStrings.push({ screen: section.heading, element: "label", text: m });
3425
+ }
3426
+ }
3427
+ if (!existsSync2(join2(specDir, "data-model.dog")) && entities.length > 0) {
3428
+ let dm = `# Data Model
3429
+
3430
+ ## Core Entities
3431
+
3432
+ `;
3433
+ for (const e of entities) {
3434
+ dm += `### Entity: ${e.name}
3435
+
3436
+ ${e.description || "No description."}
3437
+
3438
+ `;
3439
+ dm += "```yaml\n";
3440
+ dm += `entity: ${e.name}
3441
+ `;
3442
+ dm += `type: entity
3443
+ `;
3444
+ dm += `properties:
3445
+ `;
3446
+ for (const [k, v] of Object.entries(e.properties)) {
3447
+ dm += ` ${k}:
3448
+ `;
3449
+ dm += ` type: ${v.type}
3450
+ `;
3451
+ if (v.required)
3452
+ dm += ` required: true
3453
+ `;
3454
+ }
3455
+ if (e.states.length > 0)
3456
+ dm += `states: [${e.states.join(", ")}]
3457
+ `;
3458
+ dm += "```\n\n";
3459
+ }
3460
+ writeFileSync(join2(specDir, "data-model.dog"), dm);
3461
+ console.log(source_default.green(` ✓ data-model.dog (${entities.length} entities)`));
3462
+ }
3463
+ if (!existsSync2(join2(specDir, "COPY.dog")) && uiStrings.length > 0) {
3464
+ let copy = `# App Copy
3465
+
3466
+ | Screen | Element | Copy |
3467
+ |--------|---------|------|
3468
+ `;
3469
+ for (const s of uiStrings)
3470
+ copy += `| ${s.screen} | ${s.element} | ${s.text} |
3471
+ `;
3472
+ writeFileSync(join2(specDir, "COPY.dog"), copy);
3473
+ console.log(source_default.green(` ✓ COPY.dog (${uiStrings.length} strings)`));
3474
+ }
3475
+ if (!existsSync2(join2(specDir, "INDEX.dog"))) {
3476
+ let idx = `# INDEX
3477
+
3478
+ | You are... | Start here | Then... |
3479
+ |------------|-----------|---------|
3480
+ `;
3481
+ idx += `| Developer | SPEC.dog | data-model.dog → plan.dog |
3482
+ `;
3483
+ idx += `| AI agent | data-model.dog | COPY.dog → SPEC.dog |
3484
+ `;
3485
+ idx += `| Designer | SPEC.dog | COPY.dog |
3486
+ `;
3487
+ writeFileSync(join2(specDir, "INDEX.dog"), idx);
3488
+ console.log(source_default.green(" ✓ INDEX.dog"));
3489
+ }
3490
+ console.log(source_default.bold(`
3491
+ Run dotdog validate to verify.
3492
+ `));
3493
+ });
3494
+ program2.command("simulate <scenario>").description("Run a simulation scenario (phase 1 stub)").option("-p, --project <name>", "Project name", "default").action((scenario, opts) => {
3495
+ console.log(source_default.bold(`
3496
+ Simulation: ${scenario} (project: ${opts.project})
3497
+ `));
3498
+ console.log(source_default.gray("Simulation engine — reads SPEC.dog scenarios, walks through steps, checks pre/postconditions."));
3499
+ console.log(source_default.gray("Full engine coming in a future release."));
3500
+ });
3262
3501
  program2.command("staleness [dir]").action((d = ".") => {
3263
3502
  const dir = resolvePath2(d);
3264
3503
  const dirs = [join2(dir, "projects"), join2(dir, "specs"), dir];
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dotdog",
3
- "version": "0.2.4",
4
- "description": "The spec dog \u2014 validate, analyze, parse, and generate .dog spec genome files",
3
+ "version": "0.3.0",
4
+ "description": "The spec dog structured, AI-queryable software specifications. Write .dog specs, compile to .dag graphs, query via MCP. Built for humans and AI agents.",
5
5
  "type": "module",
6
6
  "main": "dist/cli.js",
7
7
  "bin": {
@@ -16,16 +16,26 @@
16
16
  ],
17
17
  "keywords": [
18
18
  "spec",
19
- "dogfood",
20
19
  "specification",
21
20
  "ai",
21
+ "agent",
22
+ "mcp",
22
23
  "documentation",
24
+ "cli",
25
+ "spec-driven-development",
26
+ "graph",
27
+ "markdown",
28
+ "yaml",
23
29
  "dotdog",
30
+ "dag",
24
31
  "dog"
25
32
  ],
26
33
  "license": "MIT",
27
34
  "author": "specdog",
28
- "repository": "github:specdog/dotdog",
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "git+https://github.com/specdog/dotdog.git"
38
+ },
29
39
  "dependencies": {
30
40
  "commander": "^15.0.0",
31
41
  "chalk": "^5.6.0"