From a0ff6870b89aa46a1167b2aa527d66a02702a5c8 Mon Sep 17 00:00:00 2001 From: crimson-knight Date: Sun, 19 Apr 2026 10:15:49 -0400 Subject: [PATCH 1/6] Clarify shards compatibility story --- README.md | 8 ++++++++ src/amber_cli/generators/native_app.cr | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 0b14ea0..325e24b 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,14 @@ sudo mv amber amber-lsp /usr/local/bin/ **Windows:** Use WSL2 or a virtual machine. Native Windows support is not currently available. +### Package Manager Compatibility + +Amber CLI is designed to work with upstream `crystal-lang/shards`, and we also +validate it against the additive fork currently distributed as `shards-alpha`. +The goal is straightforward: standard Amber workflows should continue to work +with plain `shards` commands, while compatible forks can add tooling without +changing how a new Amber project gets started. + ### Create Your First App ```bash diff --git a/src/amber_cli/generators/native_app.cr b/src/amber_cli/generators/native_app.cr index ca07901..9d51818 100644 --- a/src/amber_cli/generators/native_app.cr +++ b/src/amber_cli/generators/native_app.cr @@ -243,7 +243,7 @@ all: macos # --- First-time setup --- setup: - shards-alpha install || shards install || true + shards install || shards-alpha install || true @# crystal-audio shard name has a hyphen but source uses underscore @# Crystal's require resolution needs the underscore directory @if [ ! -e lib/crystal_audio ]; then \\ From 8781d5b8811ea39f7e39575c7ddbbede80fe7962 Mon Sep 17 00:00:00 2001 From: crimson-knight Date: Mon, 1 Jun 2026 10:33:55 -0400 Subject: [PATCH 2/6] amber-lsp: add FSDD rules (method type signature, doc block, process-manager structure, story grammar) Four diagnostics rules enforcing the FSDD/Amber V2 standards: full method type interfaces, mandatory doc blocks, process-manager structure (typed initialize + perform), and feature-story grammar validity. Build 0/0, 44 specs pass. Co-Authored-By: Claude Opus 4.8 --- .../amber_lsp/fsdd/doc_block_required_spec.cr | 112 +++++++++++++++ .../fsdd/method_type_signature_spec.cr | 103 ++++++++++++++ .../fsdd/process_manager_structure_spec.cr | 130 ++++++++++++++++++ spec/amber_lsp/fsdd/story_grammar_spec.cr | 114 +++++++++++++++ src/amber_lsp.cr | 1 + .../rules/fsdd/doc_block_required_rule.cr | 91 ++++++++++++ .../rules/fsdd/method_type_signature_rule.cr | 92 +++++++++++++ .../fsdd/process_manager_structure_rule.cr | 66 +++++++++ .../rules/fsdd/story_grammar_rule.cr | 101 ++++++++++++++ 9 files changed, 810 insertions(+) create mode 100644 spec/amber_lsp/fsdd/doc_block_required_spec.cr create mode 100644 spec/amber_lsp/fsdd/method_type_signature_spec.cr create mode 100644 spec/amber_lsp/fsdd/process_manager_structure_spec.cr create mode 100644 spec/amber_lsp/fsdd/story_grammar_spec.cr create mode 100644 src/amber_lsp/rules/fsdd/doc_block_required_rule.cr create mode 100644 src/amber_lsp/rules/fsdd/method_type_signature_rule.cr create mode 100644 src/amber_lsp/rules/fsdd/process_manager_structure_rule.cr create mode 100644 src/amber_lsp/rules/fsdd/story_grammar_rule.cr diff --git a/spec/amber_lsp/fsdd/doc_block_required_spec.cr b/spec/amber_lsp/fsdd/doc_block_required_spec.cr new file mode 100644 index 0000000..dd68d95 --- /dev/null +++ b/spec/amber_lsp/fsdd/doc_block_required_spec.cr @@ -0,0 +1,112 @@ +require "../spec_helper" +require "../../../src/amber_lsp/rules/fsdd/doc_block_required_rule" + +describe AmberLSP::Rules::FSDD::DocBlockRequiredRule do + before_each do + AmberLSP::Rules::RuleRegistry.clear + AmberLSP::Rules::RuleRegistry.register(AmberLSP::Rules::FSDD::DocBlockRequiredRule.new) + end + + describe "#check" do + it "flags a public def without a doc comment" do + rule = AmberLSP::Rules::FSDD::DocBlockRequiredRule.new + diagnostics = rule.check("src/models/user.cr", "def foo : String\nend\n") + diagnostics.size.should eq(1) + diagnostics[0].code.should eq("fsdd/doc-block-required") + diagnostics[0].severity.should eq(AmberLSP::Rules::Severity::Information) + diagnostics[0].message.should contain("foo") + end + + it "flags a public class without a doc comment" do + rule = AmberLSP::Rules::FSDD::DocBlockRequiredRule.new + diagnostics = rule.check("src/models/user.cr", "class User\nend\n") + diagnostics.size.should eq(1) + diagnostics[0].message.should contain("User") + end + + it "stays quiet when a doc comment immediately precedes a public def" do + rule = AmberLSP::Rules::FSDD::DocBlockRequiredRule.new + content = "# Returns a greeting\ndef foo : String\nend\n" + diagnostics = rule.check("src/models/user.cr", content) + diagnostics.should be_empty + end + + it "stays quiet when a doc comment immediately precedes a public class" do + rule = AmberLSP::Rules::FSDD::DocBlockRequiredRule.new + content = "# User model\nclass User\nend\n" + diagnostics = rule.check("src/models/user.cr", content) + diagnostics.should be_empty + end + + it "stays quiet on private def" do + rule = AmberLSP::Rules::FSDD::DocBlockRequiredRule.new + diagnostics = rule.check("src/models/user.cr", "private def foo : String\nend\n") + diagnostics.should be_empty + end + + it "stays quiet on protected def" do + rule = AmberLSP::Rules::FSDD::DocBlockRequiredRule.new + diagnostics = rule.check("src/models/user.cr", "protected def foo : String\nend\n") + diagnostics.should be_empty + end + + it "stays quiet on a def preceded by a standalone private keyword" do + rule = AmberLSP::Rules::FSDD::DocBlockRequiredRule.new + content = "private\n\ndef foo : String\nend\n" + diagnostics = rule.check("src/models/user.cr", content) + diagnostics.should be_empty + end + + it "flags multiple undocumented public methods in one file" do + rule = AmberLSP::Rules::FSDD::DocBlockRequiredRule.new + content = <<-CRYSTAL + def foo : String + "foo" + end + + def bar : Int32 + 42 + end + CRYSTAL + diagnostics = rule.check("src/models/user.cr", content) + diagnostics.size.should eq(2) + end + + it "flags only the undocumented method when another method has a doc comment" do + rule = AmberLSP::Rules::FSDD::DocBlockRequiredRule.new + content = <<-CRYSTAL + # Documented + def good : String + "ok" + end + + def bad : Int32 + 42 + end + CRYSTAL + diagnostics = rule.check("src/models/user.cr", content) + diagnostics.size.should eq(1) + diagnostics[0].message.should contain("bad") + end + + it "stays quiet on an abstract class preceded by a doc comment" do + rule = AmberLSP::Rules::FSDD::DocBlockRequiredRule.new + content = "# Base rule\nabstract class BaseRule\nend\n" + diagnostics = rule.check("src/models/user.cr", content) + diagnostics.should be_empty + end + + it "flags an abstract def without a doc comment" do + rule = AmberLSP::Rules::FSDD::DocBlockRequiredRule.new + diagnostics = rule.check("src/models/user.cr", "abstract def perform : Void\n") + diagnostics.size.should eq(1) + diagnostics[0].message.should contain("perform") + end + + it "produces no diagnostics for an empty file" do + rule = AmberLSP::Rules::FSDD::DocBlockRequiredRule.new + diagnostics = rule.check("src/models/user.cr", "") + diagnostics.should be_empty + end + end +end diff --git a/spec/amber_lsp/fsdd/method_type_signature_spec.cr b/spec/amber_lsp/fsdd/method_type_signature_spec.cr new file mode 100644 index 0000000..408faa7 --- /dev/null +++ b/spec/amber_lsp/fsdd/method_type_signature_spec.cr @@ -0,0 +1,103 @@ +require "../spec_helper" +require "../../../src/amber_lsp/rules/fsdd/method_type_signature_rule" + +describe AmberLSP::Rules::FSDD::MethodTypeSignatureRule do + before_each do + AmberLSP::Rules::RuleRegistry.clear + AmberLSP::Rules::RuleRegistry.register(AmberLSP::Rules::FSDD::MethodTypeSignatureRule.new) + end + + describe "#check" do + it "flags a method with an untyped parameter" do + rule = AmberLSP::Rules::FSDD::MethodTypeSignatureRule.new + diagnostics = rule.check("src/models/user.cr", "def foo(x)\nend\n") + diagnostics.size.should eq(1) + diagnostics[0].code.should eq("fsdd/method-type-signature") + diagnostics[0].severity.should eq(AmberLSP::Rules::Severity::Warning) + diagnostics[0].message.should contain("foo") + diagnostics[0].message.should contain("untyped parameter") + end + + it "flags a method with typed params but no return type" do + rule = AmberLSP::Rules::FSDD::MethodTypeSignatureRule.new + diagnostics = rule.check("src/models/user.cr", "def bar(x : Int32)\nend\n") + diagnostics.size.should eq(1) + diagnostics[0].code.should eq("fsdd/method-type-signature") + diagnostics[0].message.should contain("bar") + diagnostics[0].message.should contain("missing return type") + end + + it "stays quiet on a fully typed method" do + rule = AmberLSP::Rules::FSDD::MethodTypeSignatureRule.new + diagnostics = rule.check("src/models/user.cr", "def baz(x : Int32) : String\nend\n") + diagnostics.should be_empty + end + + it "flags a method with no params and no return type" do + rule = AmberLSP::Rules::FSDD::MethodTypeSignatureRule.new + diagnostics = rule.check("src/models/user.cr", "def greet\nend\n") + diagnostics.size.should eq(1) + diagnostics[0].message.should contain("missing return type") + end + + it "stays quiet on a no-param method with return type" do + rule = AmberLSP::Rules::FSDD::MethodTypeSignatureRule.new + diagnostics = rule.check("src/models/user.cr", "def count : Int32\nend\n") + diagnostics.should be_empty + end + + it "flags both issues when params are untyped AND return type is missing" do + rule = AmberLSP::Rules::FSDD::MethodTypeSignatureRule.new + diagnostics = rule.check("src/services/calculator.cr", "def add(a, b)\nend\n") + diagnostics.size.should eq(1) + diagnostics[0].message.should contain("untyped parameter") + diagnostics[0].message.should contain("missing return type") + end + + it "stays quiet on a method with multiple typed params and return type" do + rule = AmberLSP::Rules::FSDD::MethodTypeSignatureRule.new + content = "def add(a : Int32, b : Int32) : Int32\nend\n" + diagnostics = rule.check("src/services/calculator.cr", content) + diagnostics.should be_empty + end + + it "skips initialize methods" do + rule = AmberLSP::Rules::FSDD::MethodTypeSignatureRule.new + diagnostics = rule.check("src/models/user.cr", "def initialize(@name : String)\nend\n") + diagnostics.should be_empty + end + + it "flags a method with a mix of typed and untyped params" do + rule = AmberLSP::Rules::FSDD::MethodTypeSignatureRule.new + content = "def process(name, count : Int32) : Bool\nend\n" + diagnostics = rule.check("src/models/user.cr", content) + diagnostics.size.should eq(1) + diagnostics[0].message.should contain("untyped parameter") + end + + it "handles multiple methods in one file" do + rule = AmberLSP::Rules::FSDD::MethodTypeSignatureRule.new + content = <<-CRYSTAL + def foo(x : Int32) : String + x.to_s + end + + def bar(y) + y + end + + def qux + 42 + end + CRYSTAL + diagnostics = rule.check("src/models/user.cr", content) + diagnostics.size.should eq(2) + end + + it "produces no diagnostics for an empty file" do + rule = AmberLSP::Rules::FSDD::MethodTypeSignatureRule.new + diagnostics = rule.check("src/models/user.cr", "") + diagnostics.should be_empty + end + end +end diff --git a/spec/amber_lsp/fsdd/process_manager_structure_spec.cr b/spec/amber_lsp/fsdd/process_manager_structure_spec.cr new file mode 100644 index 0000000..196c571 --- /dev/null +++ b/spec/amber_lsp/fsdd/process_manager_structure_spec.cr @@ -0,0 +1,130 @@ +require "../spec_helper" +require "../../../src/amber_lsp/rules/fsdd/process_manager_structure_rule" + +describe AmberLSP::Rules::FSDD::ProcessManagerStructureRule do + before_each do + AmberLSP::Rules::RuleRegistry.clear + AmberLSP::Rules::RuleRegistry.register(AmberLSP::Rules::FSDD::ProcessManagerStructureRule.new) + end + + describe "#check" do + it "returns empty for files outside process_managers or processes directories" do + rule = AmberLSP::Rules::FSDD::ProcessManagerStructureRule.new + diagnostics = rule.check("src/models/user.cr", "class Foo\nend\n") + diagnostics.should be_empty + end + + it "flags a class missing initialize" do + rule = AmberLSP::Rules::FSDD::ProcessManagerStructureRule.new + content = <<-CRYSTAL + class Billing::LockAccounts + def perform : Void + end + end + CRYSTAL + diagnostics = rule.check("src/process_managers/billing/lock_accounts.cr", content) + diagnostics.size.should eq(1) + diagnostics[0].code.should eq("fsdd/process-manager-structure") + diagnostics[0].severity.should eq(AmberLSP::Rules::Severity::Warning) + diagnostics[0].message.should contain("LockAccounts") + diagnostics[0].message.should contain("missing initialize with typed parameters") + end + + it "flags a class missing a public perform or call method" do + rule = AmberLSP::Rules::FSDD::ProcessManagerStructureRule.new + content = <<-CRYSTAL + class Billing::LockAccounts + def initialize(@account_id : Int32) + end + end + CRYSTAL + diagnostics = rule.check("src/process_managers/billing/lock_accounts.cr", content) + diagnostics.size.should eq(1) + diagnostics[0].message.should contain("missing public perform or call method") + end + + it "reports both issues when both are missing" do + rule = AmberLSP::Rules::FSDD::ProcessManagerStructureRule.new + content = "class Billing::LockAccounts\nend\n" + diagnostics = rule.check("src/process_managers/billing/lock_accounts.cr", content) + diagnostics.size.should eq(1) + diagnostics[0].message.should contain("missing initialize with typed parameters") + diagnostics[0].message.should contain("missing public perform or call method") + end + + it "stays quiet when class has typed initialize and public perform" do + rule = AmberLSP::Rules::FSDD::ProcessManagerStructureRule.new + content = <<-CRYSTAL + class Billing::LockAccounts + def initialize(@account_id : Int32, @reason : String) + end + + def perform : Void + end + end + CRYSTAL + diagnostics = rule.check("src/process_managers/billing/lock_accounts.cr", content) + diagnostics.should be_empty + end + + it "stays quiet when class uses call instead of perform" do + rule = AmberLSP::Rules::FSDD::ProcessManagerStructureRule.new + content = <<-CRYSTAL + class Users::CreateUser + def initialize(@email : String) + end + + def call : Void + end + end + CRYSTAL + diagnostics = rule.check("src/process_managers/users/create_user.cr", content) + diagnostics.should be_empty + end + + it "flags a class whose initialize has no typed parameters" do + rule = AmberLSP::Rules::FSDD::ProcessManagerStructureRule.new + content = <<-CRYSTAL + class Billing::LockAccounts + def initialize(account_id, reason) + end + + def perform : Void + end + end + CRYSTAL + diagnostics = rule.check("src/process_managers/billing/lock_accounts.cr", content) + diagnostics.size.should eq(1) + diagnostics[0].message.should contain("missing initialize with typed parameters") + end + + it "flags a class whose perform method is private" do + rule = AmberLSP::Rules::FSDD::ProcessManagerStructureRule.new + content = <<-CRYSTAL + class Billing::LockAccounts + def initialize(@account_id : Int32) + end + + private def perform : Void + end + end + CRYSTAL + diagnostics = rule.check("src/process_managers/billing/lock_accounts.cr", content) + diagnostics.size.should eq(1) + diagnostics[0].message.should contain("missing public perform or call method") + end + + it "also applies to files under processes/ directory" do + rule = AmberLSP::Rules::FSDD::ProcessManagerStructureRule.new + content = <<-CRYSTAL + class Billing::RetryPayments + def perform : Void + end + end + CRYSTAL + diagnostics = rule.check("src/processes/billing/retry_payments.cr", content) + diagnostics.size.should eq(1) + diagnostics[0].message.should contain("missing initialize with typed parameters") + end + end +end diff --git a/spec/amber_lsp/fsdd/story_grammar_spec.cr b/spec/amber_lsp/fsdd/story_grammar_spec.cr new file mode 100644 index 0000000..9157744 --- /dev/null +++ b/spec/amber_lsp/fsdd/story_grammar_spec.cr @@ -0,0 +1,114 @@ +require "../spec_helper" +require "../../../src/amber_lsp/rules/fsdd/story_grammar_rule" + +describe AmberLSP::Rules::FSDD::StoryGrammarRule do + before_each do + AmberLSP::Rules::RuleRegistry.clear + AmberLSP::Rules::RuleRegistry.register(AmberLSP::Rules::FSDD::StoryGrammarRule.new) + end + + describe "#check" do + it "fires on a story block missing the action verb" do + rule = AmberLSP::Rules::FSDD::StoryGrammarRule.new + content = "# As a user\n# views the dashboard\n" + diagnostics = rule.check("src/process_managers/users/create_user.cr", content) + diagnostics.size.should eq(1) + diagnostics[0].code.should eq("fsdd/story-grammar") + diagnostics[0].severity.should eq(AmberLSP::Rules::Severity::Warning) + diagnostics[0].message.should contain("missing action verb") + end + + it "fires on a story block missing the model name" do + rule = AmberLSP::Rules::FSDD::StoryGrammarRule.new + content = "# As a user, I want to GET something from the list\n" + diagnostics = rule.check("src/process_managers/users/create_user.cr", content) + diagnostics.size.should eq(1) + diagnostics[0].message.should contain("missing capitalized Model name") + end + + it "fires on a story block missing both verb and model" do + rule = AmberLSP::Rules::FSDD::StoryGrammarRule.new + content = "# As a user\n" + diagnostics = rule.check("src/process_managers/users/create_user.cr", content) + diagnostics.size.should eq(1) + diagnostics[0].message.should contain("missing action verb") + diagnostics[0].message.should contain("missing capitalized Model name") + end + + it "stays quiet on a complete persona story with GET verb and model" do + rule = AmberLSP::Rules::FSDD::StoryGrammarRule.new + content = "# As an Admin, I want to GET Users so I can manage the Account\n" + diagnostics = rule.check("src/process_managers/users/create_user.cr", content) + diagnostics.should be_empty + end + + it "stays quiet on a complete persona story with POST verb" do + rule = AmberLSP::Rules::FSDD::StoryGrammarRule.new + content = "# As an Admin, I want to POST a new User to the system\n" + diagnostics = rule.check("src/process_managers/users/create_user.cr", content) + diagnostics.should be_empty + end + + it "stays quiet on a complete scheduling story using perform verb" do + rule = AmberLSP::Rules::FSDD::StoryGrammarRule.new + content = "# At midnight on Monday, perform RetryPayments for all pending Orders\n" + diagnostics = rule.check("src/process_managers/users/create_user.cr", content) + diagnostics.should be_empty + end + + it "stays quiet on a recurring story with do verb" do + rule = AmberLSP::Rules::FSDD::StoryGrammarRule.new + content = "# Every night, do LockExpiredAccounts for all Users\n" + diagnostics = rule.check("src/process_managers/users/create_user.cr", content) + diagnostics.should be_empty + end + + it "stays quiet on a comment block with no story initiator" do + rule = AmberLSP::Rules::FSDD::StoryGrammarRule.new + content = "# Private helper that validates inputs\n# Returns nil if invalid\n" + diagnostics = rule.check("src/process_managers/users/create_user.cr", content) + diagnostics.should be_empty + end + + it "stays quiet on regular code with no comments" do + rule = AmberLSP::Rules::FSDD::StoryGrammarRule.new + content = "def perform : Void\n validate\nend\n" + diagnostics = rule.check("src/process_managers/users/create_user.cr", content) + diagnostics.should be_empty + end + + it "flags only the incomplete story block in a mixed file" do + rule = AmberLSP::Rules::FSDD::StoryGrammarRule.new + content = <<-CRYSTAL + # Regular comment about the class + class Foo + # As a user + def perform : Void + end + end + CRYSTAL + diagnostics = rule.check("src/process_managers/users/create_user.cr", content) + diagnostics.size.should eq(1) + diagnostics[0].message.should contain("missing action verb") + end + + it "stays quiet on a complete multi-line story block" do + rule = AmberLSP::Rules::FSDD::StoryGrammarRule.new + content = <<-CRYSTAL + # As an Admin, I want to DELETE a User + # so that I can manage the Account membership + # views the user management page after successful deletion + def destroy : Void + end + CRYSTAL + diagnostics = rule.check("src/process_managers/users/create_user.cr", content) + diagnostics.should be_empty + end + + it "produces no diagnostics for an empty file" do + rule = AmberLSP::Rules::FSDD::StoryGrammarRule.new + diagnostics = rule.check("src/process_managers/users/create_user.cr", "") + diagnostics.should be_empty + end + end +end diff --git a/src/amber_lsp.cr b/src/amber_lsp.cr index 472f055..0621acc 100644 --- a/src/amber_lsp.cr +++ b/src/amber_lsp.cr @@ -17,6 +17,7 @@ require "./amber_lsp/rules/file_naming/*" require "./amber_lsp/rules/routing/*" require "./amber_lsp/rules/specs/*" require "./amber_lsp/rules/sockets/*" +require "./amber_lsp/rules/fsdd/*" require "./amber_lsp/rules/custom_rule" require "./amber_lsp/document_store" require "./amber_lsp/project_context" diff --git a/src/amber_lsp/rules/fsdd/doc_block_required_rule.cr b/src/amber_lsp/rules/fsdd/doc_block_required_rule.cr new file mode 100644 index 0000000..4754c64 --- /dev/null +++ b/src/amber_lsp/rules/fsdd/doc_block_required_rule.cr @@ -0,0 +1,91 @@ +module AmberLSP::Rules::FSDD + class DocBlockRequiredRule < AmberLSP::Rules::BaseRule + CLASS_RE = /^\s*(abstract\s+)?class\s+/ + DEF_RE = /^\s*(abstract\s+)?def\s+/ + PRIV_RE = /^\s*(private|protected)\s+(abstract\s+)?(def|class)\s+/ + NAME_RE = /\b(?:class|def)\s+(?:self\.)?(\w+[?!]?)/ + + def id : String + "fsdd/doc-block-required" + end + + def description : String + "Every public class and public method must have a preceding doc comment" + end + + def default_severity : AmberLSP::Rules::Severity + Severity::Information + end + + def applies_to : Array(String) + ["src/**"] + end + + def check(file_path : String, content : String) : Array(Diagnostic) + diagnostics = [] of Diagnostic + lines = content.lines + + # prev_nb[i] = index of the previous non-blank line before line i, or -1 + prev_nb = Array(Int32).new(lines.size, -1) + last_non_blank = -1 + lines.each_with_index do |line, i| + prev_nb[i] = last_non_blank + last_non_blank = i unless line.strip.empty? + end + + lines.each_with_index do |line, i| + next if line.strip.empty? + + is_class = CLASS_RE.matches?(line) + is_def = DEF_RE.matches?(line) + next unless is_class || is_def + + # Skip singleton class (class << self) + next if /^\s*class\s+<= 0 + prev_stripped = lines[prev_idx].strip + # Preceded by standalone private / protected + next if prev_stripped == "private" || prev_stripped == "protected" + has_doc = prev_stripped.starts_with?("#") + else + has_doc = false + end + + next if has_doc + + name_match = NAME_RE.match(line) + if nm = name_match + name = nm[1] + start_char = (nm.begin(1) || 0).to_i32 + end_char = (nm.end(1) || line.size).to_i32 + else + name = is_class ? "class" : "method" + start_char = 0 + end_char = 0 + end + + kind = is_class ? "Class" : "Method" + + diagnostics << Diagnostic.new( + range: TextRange.new( + Position.new(i.to_i32, start_char), + Position.new(i.to_i32, end_char) + ), + severity: default_severity, + code: id, + message: "#{kind} '#{name}' is missing a doc comment" + ) + end + + diagnostics + end + end +end + +AmberLSP::Rules::RuleRegistry.register(AmberLSP::Rules::FSDD::DocBlockRequiredRule.new) diff --git a/src/amber_lsp/rules/fsdd/method_type_signature_rule.cr b/src/amber_lsp/rules/fsdd/method_type_signature_rule.cr new file mode 100644 index 0000000..6932486 --- /dev/null +++ b/src/amber_lsp/rules/fsdd/method_type_signature_rule.cr @@ -0,0 +1,92 @@ +module AmberLSP::Rules::FSDD + # Crystal lifecycle methods that conventionally omit return type annotations. + SKIP_METHODS = {"initialize", "finalize"} + + class MethodTypeSignatureRule < AmberLSP::Rules::BaseRule + def id : String + "fsdd/method-type-signature" + end + + def description : String + "All method definitions must have fully typed signatures: typed parameters and an explicit return type (FSDD verbose + fully-typed standard)" + end + + def default_severity : AmberLSP::Rules::Severity + Severity::Warning + end + + def applies_to : Array(String) + ["src/**"] + end + + def check(file_path : String, content : String) : Array(Diagnostic) + diagnostics = [] of Diagnostic + + # Group 1: leading whitespace + "def ", Group 2: method name, Group 3: rest of signature + def_pattern = /^(\s*def\s+)(\w+[!?]?)(.*)/ + + content.each_line.with_index do |line, line_number| + match = def_pattern.match(line) + next unless match + + method_name = match[2] + rest = match[3] + + next if SKIP_METHODS.includes?(method_name) + + has_untyped_params = false + has_return_type = false + + if rest.includes?("(") + paren_start = rest.index('(') + paren_end = rest.rindex(')') + + # Multi-line signatures (no closing ) on this line) are skipped — known limitation + next unless paren_start && paren_end && paren_end > paren_start + + params_str = rest[paren_start + 1...paren_end] + after_paren = rest[paren_end + 1..] + + has_return_type = !!(/\s*:\s*\w/.match(after_paren)) + has_untyped_params = untyped_params?(params_str) + else + # No parens: "def foo : ReturnType" or "def foo" (no params) + has_return_type = !!(/\s*:\s*\w/.match(rest)) + end + + issues = [] of String + issues << "untyped parameter(s)" if has_untyped_params + issues << "missing return type" unless has_return_type + next if issues.empty? + + name_start = (match.begin(2) || 0).to_i32 + name_end = (match.end(2) || line.size).to_i32 + + diagnostics << Diagnostic.new( + range: TextRange.new( + Position.new(line_number.to_i32, name_start), + Position.new(line_number.to_i32, name_end) + ), + severity: default_severity, + code: id, + message: "Method '#{method_name}' has incomplete type signature: #{issues.join(", ")}" + ) + end + + diagnostics + end + + private def untyped_params?(params_str : String) : Bool + return false if params_str.strip.empty? + params_str.split(",").any? do |param| + param = param.strip + next false if param.empty? + # Splat/double-splat/block params are exempt (typed differently in Crystal) + next false if param.starts_with?("*") || param.starts_with?("&") + !param.includes?(":") + end + end + end +end + +AmberLSP::Rules::RuleRegistry.register(AmberLSP::Rules::FSDD::MethodTypeSignatureRule.new) diff --git a/src/amber_lsp/rules/fsdd/process_manager_structure_rule.cr b/src/amber_lsp/rules/fsdd/process_manager_structure_rule.cr new file mode 100644 index 0000000..5c94503 --- /dev/null +++ b/src/amber_lsp/rules/fsdd/process_manager_structure_rule.cr @@ -0,0 +1,66 @@ +module AmberLSP::Rules::FSDD + class ProcessManagerStructureRule < AmberLSP::Rules::BaseRule + def id : String + "fsdd/process-manager-structure" + end + + def description : String + "Process manager classes must define an initialize with typed parameters and a public perform or call method" + end + + def default_severity : AmberLSP::Rules::Severity + Severity::Warning + end + + def applies_to : Array(String) + ["src/**/process_managers/**", "src/**/processes/**"] + end + + def check(file_path : String, content : String) : Array(Diagnostic) + return [] of Diagnostic unless file_path.includes?("process_managers/") || file_path.includes?("processes/") + + diagnostics = [] of Diagnostic + lines = content.lines + + # def initialize with at least one typed parameter: matches ( ... : ... ) + init_typed_re = /^\s*def\s+initialize\s*\([^)]*:[^)]*\)/ + # Public def perform or call; private def perform|call is excluded + perform_re = /^\s*def\s+(perform|call)\b/ + private_perform_re = /^\s*private\s+def\s+(perform|call)\b/ + + has_typed_init = lines.any? { |line| init_typed_re.matches?(line) } + has_public_perform = lines.any? do |line| + perform_re.matches?(line) && !private_perform_re.matches?(line) + end + + return [] of Diagnostic if has_typed_init && has_public_perform + + content.each_line.with_index do |line, line_number| + match = /^\s*class\s+(\w[\w:]*)/.match(line) + next unless match + + class_name = match[1] + start_char = (match.begin(1) || 0).to_i32 + end_char = (match.end(1) || line.size).to_i32 + + issues = [] of String + issues << "missing initialize with typed parameters" unless has_typed_init + issues << "missing public perform or call method" unless has_public_perform + + diagnostics << Diagnostic.new( + range: TextRange.new( + Position.new(line_number.to_i32, start_char), + Position.new(line_number.to_i32, end_char) + ), + severity: default_severity, + code: id, + message: "Process manager '#{class_name}': #{issues.join(", ")}" + ) + end + + diagnostics + end + end +end + +AmberLSP::Rules::RuleRegistry.register(AmberLSP::Rules::FSDD::ProcessManagerStructureRule.new) diff --git a/src/amber_lsp/rules/fsdd/story_grammar_rule.cr b/src/amber_lsp/rules/fsdd/story_grammar_rule.cr new file mode 100644 index 0000000..67669de --- /dev/null +++ b/src/amber_lsp/rules/fsdd/story_grammar_rule.cr @@ -0,0 +1,101 @@ +module AmberLSP::Rules::FSDD + class StoryGrammarRule < AmberLSP::Rules::BaseRule + STORY_INITIATOR_RE = /\b(As a|At |Every )/ + ACTION_VERB_RE = /\b(GET|POST|PUT|PATCH|DELETE|perform|do)\b/ + STORY_INITIATOR_WORDS = {"As", "At", "Every"} + MODEL_NAME_RE = /\b[A-Z][a-z][a-zA-Z0-9]*\b/ + + def id : String + "fsdd/story-grammar" + end + + def description : String + "Feature story doc comments must include an action verb and a capitalized Model name or process reference" + end + + def default_severity : AmberLSP::Rules::Severity + Severity::Warning + end + + def applies_to : Array(String) + ["src/**"] + end + + def check(file_path : String, content : String) : Array(Diagnostic) + diagnostics = [] of Diagnostic + lines = content.lines + + in_block = false + block_lines = [] of String + block_start = 0 + + lines.each_with_index do |line, i| + is_comment = line.strip.starts_with?("#") + + if is_comment + unless in_block + in_block = true + block_start = i + block_lines = [] of String + end + block_lines << line.strip.lchop("#").strip + elsif in_block + in_block = false + emit_story_diagnostics(block_lines, block_start, diagnostics) + end + end + + emit_story_diagnostics(block_lines, block_start, diagnostics) if in_block + + diagnostics + end + + private def emit_story_diagnostics( + block_lines : Array(String), + block_start : Int32, + diagnostics : Array(Diagnostic) + ) : Nil + block_text = block_lines.join(" ") + return unless STORY_INITIATOR_RE.matches?(block_text) + + issues = [] of String + issues << "missing action verb (GET, POST, PUT, PATCH, DELETE, perform, or do)" unless ACTION_VERB_RE.matches?(block_text) + issues << "missing capitalized Model name or process reference" unless has_model_name?(block_text) + return if issues.empty? + + # Point to the line that contains the story initiator + story_line = block_start + block_lines.each_with_index do |line_content, bi| + if STORY_INITIATOR_RE.matches?(line_content) + story_line = block_start + bi + break + end + end + + diagnostics << Diagnostic.new( + range: TextRange.new( + Position.new(story_line, 0), + Position.new(story_line, 0) + ), + severity: default_severity, + code: id, + message: "Story comment: #{issues.join("; ")}" + ) + end + + # Returns true if text contains a PascalCase word that looks like a model + # or process name (4+ chars, not a story initiator word like "As"/"At"/"Every"). + private def has_model_name?(text : String) : Bool + pos = 0 + while (m = MODEL_NAME_RE.match(text, pos)) + word = m[0] + return true if word.size >= 4 && !STORY_INITIATOR_WORDS.includes?(word) + new_pos = (m.end(0) || pos + 1).to_i32 + pos = new_pos > pos ? new_pos : pos + 1 + end + false + end + end +end + +AmberLSP::Rules::RuleRegistry.register(AmberLSP::Rules::FSDD::StoryGrammarRule.new) From 9f170cc06d76915e27c431299c51722974f6f6da Mon Sep 17 00:00:00 2001 From: crimson-knight Date: Mon, 1 Jun 2026 13:01:07 -0400 Subject: [PATCH 3/6] =?UTF-8?q?amber=20docs:fsdd=20=E2=80=94=20FSDD=20stat?= =?UTF-8?q?ic=20help-doc=20site=20generator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parses crystal docs JSON (real signatures + FSDD doc-comment sections), applies a version ramp, renders a self-contained static HTML site to FSDD_docs/html/. The shared JSON layer the LSP also consumes. Build 0/0, 36 specs. (also: Time.monotonic→instant warning fix) Co-Authored-By: Claude Opus 4.8 --- spec/fsdd_docs/parser_spec.cr | 272 ++++++++++++++++++ spec/fsdd_docs/version_ramp_spec.cr | 191 ++++++++++++ src/amber_cli.cr | 2 + src/amber_cli/commands/docs_fsdd.cr | 150 ++++++++++ src/amber_cli/fsdd_docs/parser.cr | 244 ++++++++++++++++ src/amber_cli/fsdd_docs/renderer.cr | 93 ++++++ src/amber_cli/fsdd_docs/templates/index.ecr | 36 +++ .../fsdd_docs/templates/type_page.ecr | 104 +++++++ src/amber_cli/fsdd_docs/version_ramp.cr | 104 +++++++ src/amber_cli/helpers/process_runner.cr | 4 +- 10 files changed, 1198 insertions(+), 2 deletions(-) create mode 100644 spec/fsdd_docs/parser_spec.cr create mode 100644 spec/fsdd_docs/version_ramp_spec.cr create mode 100644 src/amber_cli/commands/docs_fsdd.cr create mode 100644 src/amber_cli/fsdd_docs/parser.cr create mode 100644 src/amber_cli/fsdd_docs/renderer.cr create mode 100644 src/amber_cli/fsdd_docs/templates/index.ecr create mode 100644 src/amber_cli/fsdd_docs/templates/type_page.ecr create mode 100644 src/amber_cli/fsdd_docs/version_ramp.cr diff --git a/spec/fsdd_docs/parser_spec.cr b/spec/fsdd_docs/parser_spec.cr new file mode 100644 index 0000000..5c3f6ad --- /dev/null +++ b/spec/fsdd_docs/parser_spec.cr @@ -0,0 +1,272 @@ +require "spec" +require "json" +require "../../src/amber_cli/fsdd_docs/parser" + +FSDD_FIXTURE_JSON = <<-JSON + { + "repository_name": "TestProject", + "body": "", + "program": { + "html_id": "", + "path": "", + "kind": "module", + "full_name": "TestProject", + "name": "TestProject", + "abstract": false, + "types": [ + { + "html_id": "auth-service", + "name": "AuthService", + "full_name": "AuthService", + "kind": "class", + "summary": "Authentication service", + "doc": "**Personas:** Developer, QA\\n**Requires:** User model exists\\n**Since:** v1.0\\n**Ramp:** Basic → Advanced\\n**Use:** `authenticate(email, password)`\\n**Result:** Returns JWT token on success\\n\\nRefined story: As a Developer, I want to authenticate users so they can access protected resources.", + "locations": [{"filename": "src/auth_service.cr", "line_number": 1, "url": null}], + "instance_methods": [ + { + "html_id": "authenticate", + "name": "authenticate", + "abstract": false, + "args_string": "(email : String, password : String)", + "doc": "**Personas:** Developer\\n**Use:** `service.authenticate(email, password)`\\n**Result:** Returns Bool", + "summary": "Authenticate a user by email and password", + "location": {"filename": "src/auth_service.cr", "line_number": 10, "url": null}, + "def": { + "name": "authenticate", + "args": [], + "return_type": "Bool", + "visibility": "Public", + "body": "" + } + }, + { + "html_id": "logout", + "name": "logout", + "abstract": false, + "args_string": "(session_id : String)", + "doc": null, + "summary": null, + "location": {"filename": "src/auth_service.cr", "line_number": 20, "url": null}, + "def": { + "name": "logout", + "args": [], + "return_type": "Nil", + "visibility": "Public", + "body": "" + } + } + ], + "class_methods": [ + { + "html_id": "create", + "name": "create", + "abstract": false, + "args_string": "(config : Hash(String, String))", + "doc": null, + "summary": null, + "location": {"filename": "src/auth_service.cr", "line_number": 5, "url": null}, + "def": { + "name": "create", + "args": [], + "return_type": "AuthService", + "visibility": "Public", + "body": "" + } + } + ], + "types": [] + }, + { + "html_id": "plain-module", + "name": "Helpers", + "full_name": "Helpers", + "kind": "module", + "summary": "Utility helpers", + "doc": null, + "locations": [], + "instance_methods": [], + "class_methods": [], + "types": [] + } + ] + } + } + JSON + +describe AmberCLI::FSDDDocs::Parser do + describe ".parse_json" do + it "returns a list of ParsedType from valid JSON" do + json = JSON.parse(FSDD_FIXTURE_JSON) + types = AmberCLI::FSDDDocs::Parser.parse_json(json) + types.size.should eq(2) + end + + it "captures the type name, full_name, and kind" do + json = JSON.parse(FSDD_FIXTURE_JSON) + types = AmberCLI::FSDDDocs::Parser.parse_json(json) + auth = types.find { |t| t.name == "AuthService" } + auth.should_not be_nil + auth = auth.not_nil! + auth.full_name.should eq("AuthService") + auth.kind.should eq("class") + end + + it "captures the summary" do + json = JSON.parse(FSDD_FIXTURE_JSON) + types = AmberCLI::FSDDDocs::Parser.parse_json(json) + auth = types.find { |t| t.name == "AuthService" }.not_nil! + auth.summary.should eq("Authentication service") + end + + it "captures source locations" do + json = JSON.parse(FSDD_FIXTURE_JSON) + types = AmberCLI::FSDDDocs::Parser.parse_json(json) + auth = types.find { |t| t.name == "AuthService" }.not_nil! + auth.locations.should eq(["src/auth_service.cr:1"]) + end + + it "parses instance methods" do + json = JSON.parse(FSDD_FIXTURE_JSON) + types = AmberCLI::FSDDDocs::Parser.parse_json(json) + auth = types.find { |t| t.name == "AuthService" }.not_nil! + instance_methods = auth.methods.select { |m| !m.is_class_method } + instance_methods.size.should eq(2) + names = instance_methods.map(&.name) + names.should contain("authenticate") + names.should contain("logout") + end + + it "parses class methods" do + json = JSON.parse(FSDD_FIXTURE_JSON) + types = AmberCLI::FSDDDocs::Parser.parse_json(json) + auth = types.find { |t| t.name == "AuthService" }.not_nil! + class_methods = auth.methods.select(&.is_class_method) + class_methods.size.should eq(1) + class_methods.first.name.should eq("create") + end + + it "captures method args_string and return_type" do + json = JSON.parse(FSDD_FIXTURE_JSON) + types = AmberCLI::FSDDDocs::Parser.parse_json(json) + auth = types.find { |t| t.name == "AuthService" }.not_nil! + m = auth.methods.find { |m| m.name == "authenticate" }.not_nil! + m.args_string.should eq("(email : String, password : String)") + m.return_type.should eq("Bool") + m.visibility.should eq("Public") + end + + it "builds the full method signature" do + json = JSON.parse(FSDD_FIXTURE_JSON) + types = AmberCLI::FSDDDocs::Parser.parse_json(json) + auth = types.find { |t| t.name == "AuthService" }.not_nil! + m = auth.methods.find { |m| m.name == "authenticate" }.not_nil! + m.signature.should eq("authenticate(email : String, password : String) : Bool") + end + + it "captures method source location" do + json = JSON.parse(FSDD_FIXTURE_JSON) + types = AmberCLI::FSDDDocs::Parser.parse_json(json) + auth = types.find { |t| t.name == "AuthService" }.not_nil! + m = auth.methods.find { |m| m.name == "authenticate" }.not_nil! + m.location.should eq("src/auth_service.cr:10") + end + + it "handles missing program gracefully" do + json = JSON.parse("{}") + types = AmberCLI::FSDDDocs::Parser.parse_json(json) + types.should be_empty + end + + it "generates a safe slug by replacing :: with -" do + json = JSON.parse(<<-JSON2) + {"repository_name":"T","body":"","program":{"types":[{"name":"Foo","full_name":"AmberCLI::Commands::Foo","kind":"class","summary":null,"doc":null,"locations":[],"instance_methods":[],"class_methods":[],"types":[]}]}} + JSON2 + types = AmberCLI::FSDDDocs::Parser.parse_json(json) + types.first.slug.should eq("AmberCLI-Commands-Foo") + end + end + + describe ".extract_fsdd_sections" do + it "extracts all FSDD labels from a doc comment" do + doc = "**Personas:** Developer, QA\n**Requires:** User model\n**Since:** v1.0\n**Ramp:** Basic → Advanced\n**Use:** `authenticate(email, password)`\n**Result:** Returns Bool\n\nRefined story: As a Developer, I want to do something." + sections = AmberCLI::FSDDDocs::Parser.extract_fsdd_sections(doc) + sections.personas.should eq("Developer, QA") + sections.requires.should eq("User model") + sections.since_version.should eq("v1.0") + sections.ramp.should eq("Basic → Advanced") + sections.use.should eq("`authenticate(email, password)`") + sections.result.should eq("Returns Bool") + sections.story.should_not be_nil + sections.story.not_nil!.should contain("As a Developer") + end + + it "returns empty FSDDSections for nil doc" do + sections = AmberCLI::FSDDDocs::Parser.extract_fsdd_sections(nil) + sections.has_fsdd_content?.should be_false + end + + it "returns empty FSDDSections for empty doc" do + sections = AmberCLI::FSDDDocs::Parser.extract_fsdd_sections("") + sections.has_fsdd_content?.should be_false + end + + it "returns nil for labels not present in doc" do + doc = "**Personas:** Developer" + sections = AmberCLI::FSDDDocs::Parser.extract_fsdd_sections(doc) + sections.personas.should eq("Developer") + sections.requires.should be_nil + sections.since_version.should be_nil + sections.ramp.should be_nil + sections.use.should be_nil + sections.result.should be_nil + sections.story.should be_nil + end + + it "detects has_fsdd_content? correctly" do + doc = "**Personas:** Developer" + sections = AmberCLI::FSDDDocs::Parser.extract_fsdd_sections(doc) + sections.has_fsdd_content?.should be_true + end + + it "extracts the Refined story block" do + doc = "Some preamble.\n\nRefined story: As a Developer, I want to authenticate users so they can access protected resources." + sections = AmberCLI::FSDDDocs::Parser.extract_fsdd_sections(doc) + sections.story.should_not be_nil + sections.story.not_nil!.should contain("As a Developer") + end + + it "extracts summary as first non-label line" do + doc = "Handles user authentication.\n**Personas:** Developer" + sections = AmberCLI::FSDDDocs::Parser.extract_fsdd_sections(doc) + sections.summary.should eq("Handles user authentication.") + end + + it "skips label lines when extracting summary" do + doc = "**Personas:** Developer\nHandles user authentication." + sections = AmberCLI::FSDDDocs::Parser.extract_fsdd_sections(doc) + sections.summary.should eq("Handles user authentication.") + end + end + + describe "ParsedMethod#kind_label" do + it "returns 'instance' for instance methods" do + sections = AmberCLI::FSDDDocs::FSDDSections.new + m = AmberCLI::FSDDDocs::ParsedMethod.new("foo", "()", "Nil", "Public", nil, nil, sections, false) + m.kind_label.should eq("instance") + end + + it "returns 'class' for class methods" do + sections = AmberCLI::FSDDDocs::FSDDSections.new + m = AmberCLI::FSDDDocs::ParsedMethod.new("foo", "()", "Nil", "Public", nil, nil, sections, true) + m.kind_label.should eq("class") + end + end + + describe "ParsedType#slug" do + it "converts namespaced name to flat slug" do + types = AmberCLI::FSDDDocs::Parser.parse_json(JSON.parse(FSDD_FIXTURE_JSON)) + auth = types.find { |t| t.name == "AuthService" }.not_nil! + auth.slug.should eq("AuthService") + end + end +end diff --git a/spec/fsdd_docs/version_ramp_spec.cr b/spec/fsdd_docs/version_ramp_spec.cr new file mode 100644 index 0000000..1cd798b --- /dev/null +++ b/spec/fsdd_docs/version_ramp_spec.cr @@ -0,0 +1,191 @@ +require "spec" +require "json" +require "file_utils" +require "../../src/amber_cli/fsdd_docs/version_ramp" + +private def with_tempdir(&) + dir = File.join(Dir.tempdir, "fsdd_ramp_test_#{Random::Secure.hex(6)}") + Dir.mkdir_p(dir) + begin + yield dir + ensure + FileUtils.rm_rf(dir) + end +end + +# Minimal Crystal docs JSON with one type and one method. +private def docs_json_for(type_name : String, method_name : String) : String + <<-JSON + { + "repository_name": "T","body": "", + "program": { + "types": [{ + "name": "#{type_name}", + "full_name": "#{type_name}", + "kind": "class", + "summary": null, + "doc": null, + "locations": [], + "instance_methods": [{ + "name": "#{method_name}", + "args_string": "()", + "doc": null, + "summary": null, + "location": null, + "def": { "name": "#{method_name}", "args": [], "return_type": "Nil", "visibility": "Public", "body": "" } + }], + "class_methods": [], + "types": [] + }] + } + } + JSON +end + +describe AmberCLI::FSDDDocs::VersionRamp do + describe ".empty" do + it "returns a VersionRamp with no versions and no entries" do + ramp = AmberCLI::FSDDDocs::VersionRamp.empty + ramp.versions.should be_empty + ramp.entries.should be_empty + end + + it "returns '' for ramp_symbol on empty ramp" do + ramp = AmberCLI::FSDDDocs::VersionRamp.empty + ramp.ramp_symbol("SomeType", "1.0.0").should eq("") + end + + it "returns nil for ramp_for on empty ramp" do + ramp = AmberCLI::FSDDDocs::VersionRamp.empty + ramp.ramp_for("SomeType").should be_nil + end + end + + describe ".load" do + it "returns empty ramp when versions.json is absent" do + with_tempdir do |dir| + ramp = AmberCLI::FSDDDocs::VersionRamp.load(dir) + ramp.versions.should be_empty + end + end + + it "loads versions from versions.json" do + with_tempdir do |dir| + File.write(File.join(dir, "versions.json"), %([\"1.0.0\", \"2.0.0\"])) + # No json sub-dirs, so entries will be empty but versions loaded + ramp = AmberCLI::FSDDDocs::VersionRamp.load(dir) + ramp.versions.should eq(["1.0.0", "2.0.0"]) + end + end + + it "builds entries from per-version index.json files" do + with_tempdir do |dir| + File.write(File.join(dir, "versions.json"), %([\"1.0.0\"])) + json_dir = File.join(dir, "json", "1.0.0") + Dir.mkdir_p(json_dir) + File.write(File.join(json_dir, "index.json"), docs_json_for("AuthService", "authenticate")) + + ramp = AmberCLI::FSDDDocs::VersionRamp.load(dir) + ramp.entries.has_key?("AuthService").should be_true + ramp.entries.has_key?("AuthService#authenticate").should be_true + end + end + + it "tracks the first version an entry appeared" do + with_tempdir do |dir| + File.write(File.join(dir, "versions.json"), %([\"1.0.0\", \"2.0.0\"])) + + dir_v1 = File.join(dir, "json", "1.0.0") + Dir.mkdir_p(dir_v1) + File.write(File.join(dir_v1, "index.json"), docs_json_for("AuthService", "authenticate")) + + dir_v2 = File.join(dir, "json", "2.0.0") + Dir.mkdir_p(dir_v2) + File.write(File.join(dir_v2, "index.json"), docs_json_for("AuthService", "authenticate")) + + ramp = AmberCLI::FSDDDocs::VersionRamp.load(dir) + entry = ramp.ramp_for("AuthService") + entry.should_not be_nil + entry.not_nil!.first_version.should eq("1.0.0") + end + end + end + + describe "#ramp_symbol" do + it "returns ↑ when entry is new in current version" do + with_tempdir do |dir| + File.write(File.join(dir, "versions.json"), %([\"1.0.0\", \"2.0.0\"])) + + # Only in v2 + dir_v2 = File.join(dir, "json", "2.0.0") + Dir.mkdir_p(dir_v2) + File.write(File.join(dir_v2, "index.json"), docs_json_for("NewFeature", "go")) + + ramp = AmberCLI::FSDDDocs::VersionRamp.load(dir) + ramp.ramp_symbol("NewFeature", "2.0.0").should eq("↑") + end + end + + it "returns ↓ when entry was removed in current version" do + with_tempdir do |dir| + File.write(File.join(dir, "versions.json"), %([\"1.0.0\", \"2.0.0\"])) + + # Only in v1 + dir_v1 = File.join(dir, "json", "1.0.0") + Dir.mkdir_p(dir_v1) + File.write(File.join(dir_v1, "index.json"), docs_json_for("OldFeature", "gone")) + + ramp = AmberCLI::FSDDDocs::VersionRamp.load(dir) + ramp.ramp_symbol("OldFeature", "2.0.0").should eq("↓") + end + end + + it "returns empty string when entry is stable across versions" do + with_tempdir do |dir| + File.write(File.join(dir, "versions.json"), %([\"1.0.0\", \"2.0.0\"])) + + ["1.0.0", "2.0.0"].each do |v| + vdir = File.join(dir, "json", v) + Dir.mkdir_p(vdir) + File.write(File.join(vdir, "index.json"), docs_json_for("StableType", "stable")) + end + + ramp = AmberCLI::FSDDDocs::VersionRamp.load(dir) + ramp.ramp_symbol("StableType", "2.0.0").should eq("") + end + end + + it "returns empty string when current_version is nil" do + ramp = AmberCLI::FSDDDocs::VersionRamp.empty + ramp.ramp_symbol("SomeType", nil).should eq("") + end + + it "returns empty string when entry is not found" do + with_tempdir do |dir| + File.write(File.join(dir, "versions.json"), %([\"1.0.0\"])) + ramp = AmberCLI::FSDDDocs::VersionRamp.load(dir) + ramp.ramp_symbol("NonExistentType", "1.0.0").should eq("") + end + end + end + + describe "#first_appeared" do + it "returns the first version as 'vX.Y.Z'" do + with_tempdir do |dir| + File.write(File.join(dir, "versions.json"), %([\"1.0.0\", \"2.0.0\"])) + + dir_v1 = File.join(dir, "json", "1.0.0") + Dir.mkdir_p(dir_v1) + File.write(File.join(dir_v1, "index.json"), docs_json_for("MyType", "method")) + + ramp = AmberCLI::FSDDDocs::VersionRamp.load(dir) + ramp.since_label("MyType").should eq("v1.0.0") + end + end + + it "returns nil for unknown key" do + ramp = AmberCLI::FSDDDocs::VersionRamp.empty + ramp.since_label("Unknown").should be_nil + end + end +end diff --git a/src/amber_cli.cr b/src/amber_cli.cr index 9542a23..dd7e4e5 100644 --- a/src/amber_cli.cr +++ b/src/amber_cli.cr @@ -25,6 +25,7 @@ require "./amber_cli/commands/plugin" require "./amber_cli/commands/pipelines" require "./amber_cli/commands/generate" require "./amber_cli/commands/setup_lsp" +require "./amber_cli/commands/docs_fsdd" backend = Log::IOBackend.new backend.formatter = Log::Formatter.new do |entry, io| @@ -75,6 +76,7 @@ module AmberCLI plugin (pl) Generate application plugins pipelines Show application pipelines and plugs setup:lsp (lsp) Set up Amber LSP for Claude Code integration + docs:fsdd Generate FSDD static HTML doc site from crystal docs JSON Options: --version, -v Show version number diff --git a/src/amber_cli/commands/docs_fsdd.cr b/src/amber_cli/commands/docs_fsdd.cr new file mode 100644 index 0000000..537c353 --- /dev/null +++ b/src/amber_cli/commands/docs_fsdd.cr @@ -0,0 +1,150 @@ +require "../core/base_command" +require "../fsdd_docs/renderer" + +module AmberCLI::Commands + # The `docs:fsdd` command generates a static FSDD help-doc site from Crystal + # docs JSON. It reads `FSDD_docs/json//index.json` (generating it via + # `crystal docs` if absent), applies the version ramp across all stored versions, + # and writes a self-contained static HTML site to `FSDD_docs/html/`. + # + # ## Usage + # ``` + # amber docs:fsdd [OPTIONS] + # ``` + # + # ## Examples + # ``` + # amber docs:fsdd + # amber docs:fsdd --repo /path/to/project --version 2.1.0 + # ``` + class DocsFSDDCommand < AmberCLI::Core::BaseCommand + getter repo_option : String = Dir.current + getter version_option : String? = nil + + def help_description : String + <<-HELP + Generate a static FSDD help-doc site from Crystal docs JSON + + Usage: amber docs:fsdd [OPTIONS] + + This command: + 1. Runs `crystal docs --format=json` (or reads FSDD_docs/json//index.json) + 2. Parses FSDD doc-comment sections (Personas, Requires, Since, Ramp, Use, Result, Story) + 3. Computes a version ramp across all stored versions + 4. Renders a static HTML site to FSDD_docs/html/ + + Options: + --repo=DIR Crystal project root (default: current directory) + --version=VER Version label to use (default: read from shard.yml or "snapshot") + HELP + end + + def setup_command_options + option_parser.separator "" + option_parser.separator "Options:" + + option_parser.on("--repo=DIR", "Crystal project root (default: current directory)") do |dir| + @repo_option = dir + end + + option_parser.on("--version=VER", "Version label (default: read from shard.yml or 'snapshot')") do |v| + @version_option = v + end + end + + def execute + repo = File.expand_path(repo_option) + + unless Dir.exists?(repo) + error "Repo directory not found: #{repo}" + exit(1) + end + + version = version_option || read_shard_version(repo) || "snapshot" + info "Generating FSDD docs for version #{version} in #{repo}" + + fsdd_docs_dir = File.join(repo, "FSDD_docs") + json_dir = File.join(fsdd_docs_dir, "json", version) + json_path = File.join(json_dir, "index.json") + output_dir = File.join(fsdd_docs_dir, "html") + + unless File.exists?(json_path) + generate_crystal_docs(repo, json_dir, json_path) + else + info "Using existing docs JSON: #{json_path}" + end + + update_versions_json(fsdd_docs_dir, version) + + info "Loading version ramp..." + ramp = FSDDDocs::VersionRamp.load(fsdd_docs_dir) + + info "Parsing #{json_path}..." + types = FSDDDocs::Parser.parse_file(json_path) + info "Found #{types.size} types" + + info "Rendering to #{output_dir}..." + renderer = FSDDDocs::Renderer.new(types, ramp, output_dir, current_version: version) + renderer.render + + success "FSDD docs generated: #{output_dir}" + info " #{types.size} type pages + index.html + style.css" + end + + private def read_shard_version(repo : String) : String? + shard_path = File.join(repo, "shard.yml") + return nil unless File.exists?(shard_path) + File.read(shard_path).each_line do |line| + if line.starts_with?("version:") + parts = line.split(":", 2) + return parts[1].strip if parts.size == 2 + end + end + nil + end + + private def generate_crystal_docs(repo : String, json_dir : String, json_path : String) : Nil + info "Running crystal docs in #{repo}..." + Dir.mkdir_p(json_dir) + + result = Process.run( + "crystal", + ["docs", "--output=#{json_dir}"], + chdir: repo, + output: Process::Redirect::Inherit, + error: Process::Redirect::Inherit + ) + + unless result.success? + error "crystal docs failed (exit #{result.exit_code})" + exit(1) + end + + unless File.exists?(json_path) + error "crystal docs did not produce index.json at #{json_path}" + exit(1) + end + + success "crystal docs complete: #{json_path}" + end + + private def update_versions_json(fsdd_docs_dir : String, version : String) : Nil + versions_path = File.join(fsdd_docs_dir, "versions.json") + Dir.mkdir_p(fsdd_docs_dir) + + versions = if File.exists?(versions_path) + JSON.parse(File.read(versions_path)).as_a.map(&.as_s) + else + [] of String + end + + unless versions.includes?(version) + versions << version + File.write(versions_path, versions.to_json) + info "Updated versions.json: #{versions.inspect}" + end + end + end +end + +AmberCLI::Core::CommandRegistry.register("docs:fsdd", ["fsdd-docs"], AmberCLI::Commands::DocsFSDDCommand) diff --git a/src/amber_cli/fsdd_docs/parser.cr b/src/amber_cli/fsdd_docs/parser.cr new file mode 100644 index 0000000..1a5c262 --- /dev/null +++ b/src/amber_cli/fsdd_docs/parser.cr @@ -0,0 +1,244 @@ +require "json" + +module AmberCLI::FSDDDocs + class FSDDSections + getter personas : String? + getter requires : String? + getter since_version : String? + getter ramp : String? + getter use : String? + getter result : String? + getter story : String? + getter summary : String? + + def initialize( + @personas = nil, + @requires = nil, + @since_version = nil, + @ramp = nil, + @use = nil, + @result = nil, + @story = nil, + @summary = nil + ) + end + + def has_fsdd_content? : Bool + [personas, requires, since_version, ramp, use, result, story].any? + end + end + + class ParsedMethod + getter name : String + getter args_string : String + getter return_type : String? + getter visibility : String + getter location : String? + getter doc : String? + getter fsdd : FSDDSections + getter is_class_method : Bool + + def initialize( + @name, + @args_string, + @return_type, + @visibility, + @location, + @doc, + @fsdd, + @is_class_method + ) + end + + def signature : String + rt = return_type + if rt && !rt.empty? + "#{name}#{args_string} : #{rt}" + else + "#{name}#{args_string}" + end + end + + def kind_label : String + is_class_method ? "class" : "instance" + end + end + + class ParsedType + getter name : String + getter full_name : String + getter kind : String + getter summary : String? + getter doc : String? + getter fsdd : FSDDSections + getter locations : Array(String) + getter methods : Array(ParsedMethod) + + def initialize( + @name, + @full_name, + @kind, + @summary, + @doc, + @fsdd, + @locations, + @methods + ) + end + + # Filename-safe slug: replaces :: with - for flat output + def slug : String + full_name.gsub("::", "-") + end + + def primary_location : String? + locations.first? + end + end + + class Parser + def self.parse_file(path : String) : Array(ParsedType) + parse_json(JSON.parse(File.read(path))) + end + + def self.parse_json(json : JSON::Any) : Array(ParsedType) + program = json["program"]? + return [] of ParsedType unless program + + types_json = program["types"]? + return [] of ParsedType unless types_json + + result = [] of ParsedType + types_json.as_a.each do |type_json| + result.concat(parse_type_recursive(type_json)) + end + result + end + + def self.extract_fsdd_sections(doc : String?) : FSDDSections + return FSDDSections.new unless doc && !doc.empty? + + FSDDSections.new( + personas: extract_label(doc, "Personas"), + requires: extract_label(doc, "Requires"), + since_version: extract_label(doc, "Since"), + ramp: extract_label(doc, "Ramp"), + use: extract_label(doc, "Use"), + result: extract_label(doc, "Result"), + story: extract_story_block(doc), + summary: extract_summary(doc) + ) + end + + private def self.parse_type_recursive(type_json : JSON::Any) : Array(ParsedType) + result = [] of ParsedType + + name = type_json["name"]?.try(&.as_s?) || "" + full_name = type_json["full_name"]?.try(&.as_s?) || name + kind = type_json["kind"]?.try(&.as_s?) || "class" + summary = type_json["summary"]?.try(&.as_s?) + doc = type_json["doc"]?.try(&.as_s?) + + locations = [] of String + if locs_json = type_json["locations"]?.try(&.as_a?) + locs_json.each do |loc| + if loc_h = loc.as_h? + fn = loc_h["filename"]?.try(&.as_s?) + ln = loc_h["line_number"]?.try(&.as_i?) + locations << "#{fn}:#{ln}" if fn && ln + end + end + end + + methods = [] of ParsedMethod + + if im_json = type_json["instance_methods"]? + im_json.as_a.each do |m| + methods << parse_method(m, is_class_method: false) + end + end + + if cm_json = type_json["class_methods"]? + cm_json.as_a.each do |m| + methods << parse_method(m, is_class_method: true) + end + end + + fsdd = extract_fsdd_sections(doc) + result << ParsedType.new(name, full_name, kind, summary, doc, fsdd, locations, methods) + + if nested_json = type_json["types"]? + nested_json.as_a.each do |nested| + result.concat(parse_type_recursive(nested)) + end + end + + result + end + + private def self.parse_method(m : JSON::Any, is_class_method : Bool) : ParsedMethod + name = m["name"]?.try(&.as_s?) || "" + args_string = m["args_string"]?.try(&.as_s?) || "()" + doc = m["doc"]?.try(&.as_s?) + + def_h = m["def"]?.try(&.as_h?) + return_type = def_h.try { |d| d["return_type"]?.try(&.as_s?) } + visibility = def_h.try { |d| d["visibility"]?.try(&.as_s?) } || "Public" + + location : String? = nil + if loc_h = m["location"]?.try(&.as_h?) + fn = loc_h["filename"]?.try(&.as_s?) + ln = loc_h["line_number"]?.try(&.as_i?) + location = "#{fn}:#{ln}" if fn && ln + end + + fsdd = extract_fsdd_sections(doc) + ParsedMethod.new(name, args_string, return_type, visibility, location, doc, fsdd, is_class_method) + end + + private def self.extract_label(text : String, label : String) : String? + text.each_line do |line| + if line.includes?("**#{label}:**") + parts = line.split("**#{label}:**", 2) + if parts.size == 2 + val = parts[1].strip + return val unless val.empty? + end + end + end + nil + end + + private def self.extract_story_block(text : String) : String? + idx = text.index("Refined story") + return nil unless idx + + start = idx + "Refined story".size + # Skip colon and whitespace + while start < text.size + ch = text[start] + break unless ch == ':' || ch == ' ' || ch == '\n' || ch == '\r' + start += 1 + end + + remaining = text[start..] + val = if end_idx = remaining.index(/\*\*\w+:\*\*/) + remaining[0...end_idx].strip + else + remaining.strip + end + + val.empty? ? nil : val + end + + private def self.extract_summary(text : String) : String? + text.each_line do |line| + stripped = line.strip + next if stripped.empty? + next if stripped.starts_with?("**") + return stripped + end + nil + end + end +end diff --git a/src/amber_cli/fsdd_docs/renderer.cr b/src/amber_cli/fsdd_docs/renderer.cr new file mode 100644 index 0000000..2977e42 --- /dev/null +++ b/src/amber_cli/fsdd_docs/renderer.cr @@ -0,0 +1,93 @@ +require "ecr" +require "html" +require "./parser" +require "./version_ramp" + +module AmberCLI::FSDDDocs + CSS_CONTENT = <<-CSS + *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + body { font: 15px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif; color: #24292f; background: #f6f8fa; } + a { color: #0969da; } + a:hover { text-decoration: underline; } + code, pre { font-family: "SFMono-Regular",Consolas,monospace; } + nav { background: #24292f; color: #e6edf3; padding: 12px 24px; display: flex; align-items: center; gap: 16px; flex-wrap: wrap; } + nav .nav-title { font-size: 1.1rem; font-weight: 600; } + nav a { color: #79c0ff; text-decoration: none; } + nav a:hover { text-decoration: underline; } + .ver { background: #388bfd; color: #fff; padding: 2px 8px; border-radius: 10px; font-size: .8rem; font-weight: 600; } + main { max-width: 960px; margin: 0 auto; padding: 24px; } + h2 { font-size: 1rem; border-bottom: 1px solid #d0d7de; padding-bottom: 6px; margin: 20px 0 12px; } + .badge { display: inline-block; padding: 2px 7px; border-radius: 4px; font-size: .72rem; font-weight: 700; text-transform: uppercase; } + .badge.class { background: #ddf4ff; color: #0550ae; } + .badge.module { background: #fff8c5; color: #9a6700; } + .badge.struct { background: #ffd8d3; color: #82071e; } + .badge.enum { background: #e8d5ff; color: #6e40c9; } + .type-list { list-style: none; } + .type-item { background: #fff; border: 1px solid #d0d7de; border-radius: 6px; padding: 10px 14px; margin-bottom: 8px; } + .type-link { display: flex; align-items: center; gap: 8px; text-decoration: none; color: #0969da; font-weight: 600; } + .type-link .type-name { font-weight: 600; } + .type-link:hover .type-name { text-decoration: underline; } + .summary { font-size: .85rem; color: #57606a; margin-top: 4px; } + .fsdd-marker { font-size: .7rem; font-weight: 700; color: #0969da; background: #ddf4ff; padding: 1px 5px; border-radius: 3px; text-transform: uppercase; } + .ramp { display: flex; gap: 14px; align-items: center; background: #f0f7ff; border: 1px solid #cce5ff; border-radius: 6px; padding: 7px 14px; margin: 14px 0; font-size: .85rem; flex-wrap: wrap; } + .sym { font-size: 1rem; font-weight: 700; color: #0969da; } + .fsdd { background: #f6f8fa; border: 1px solid #d0d7de; border-radius: 6px; padding: 10px 14px; margin: 12px 0; } + .fsdd-row { display: flex; gap: 10px; margin: 3px 0; font-size: .875rem; align-items: baseline; } + .lbl { font-weight: 600; color: #57606a; min-width: 70px; flex-shrink: 0; } + .story-block { margin-top: 8px; padding-top: 8px; border-top: 1px solid #d0d7de; font-style: italic; font-size: .875rem; white-space: pre-wrap; } + .method { background: #fff; border: 1px solid #d0d7de; border-radius: 6px; padding: 10px 14px; margin-bottom: 10px; } + .msig { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; margin-bottom: 4px; } + .mkind { font-size: .7rem; font-weight: 700; text-transform: uppercase; color: #57606a; background: #f6f8fa; padding: 2px 5px; border-radius: 3px; border: 1px solid #d0d7de; } + .mname { font-family: "SFMono-Regular",Consolas,monospace; font-size: .9rem; color: #0969da; background: #f6f8fa; padding: 2px 6px; border-radius: 3px; } + .mloc { font-size: .75rem; color: #57606a; margin: 4px 0; } + CSS + + class IndexView + getter types : Array(ParsedType) + getter ramp : VersionRamp + getter current_version : String? + + def initialize(@types : Array(ParsedType), @ramp : VersionRamp, @current_version : String?) + end + + ECR.def_to_s "#{__DIR__}/templates/index.ecr" + end + + class TypeView + getter type : ParsedType + getter ramp : VersionRamp + getter current_version : String? + + def initialize(@type : ParsedType, @ramp : VersionRamp, @current_version : String?) + end + + ECR.def_to_s "#{__DIR__}/templates/type_page.ecr" + end + + class Renderer + def initialize( + @types : Array(ParsedType), + @ramp : VersionRamp, + @output_dir : String, + @current_version : String? = nil + ) + end + + def render : Nil + Dir.mkdir_p(@output_dir) + File.write(File.join(@output_dir, "style.css"), CSS_CONTENT) + render_index + @types.each { |t| render_type(t) } + end + + private def render_index : Nil + html = IndexView.new(@types, @ramp, @current_version).to_s + File.write(File.join(@output_dir, "index.html"), html) + end + + private def render_type(type : ParsedType) : Nil + html = TypeView.new(type, @ramp, @current_version).to_s + File.write(File.join(@output_dir, "#{type.slug}.html"), html) + end + end +end diff --git a/src/amber_cli/fsdd_docs/templates/index.ecr b/src/amber_cli/fsdd_docs/templates/index.ecr new file mode 100644 index 0000000..23ac88d --- /dev/null +++ b/src/amber_cli/fsdd_docs/templates/index.ecr @@ -0,0 +1,36 @@ + + + + + + FSDD Docs + + + + +
+

Types (<%= types.size %>)

+ +
+ + diff --git a/src/amber_cli/fsdd_docs/templates/type_page.ecr b/src/amber_cli/fsdd_docs/templates/type_page.ecr new file mode 100644 index 0000000..a658807 --- /dev/null +++ b/src/amber_cli/fsdd_docs/templates/type_page.ecr @@ -0,0 +1,104 @@ + + + + + + <%= HTML.escape(type.full_name) %> — FSDD Docs + + + + +
+ + <% + type_key = type.full_name + ramp_entry = ramp.ramp_for(type_key) + sym = ramp.ramp_symbol(type_key, current_version) + %> + <% if ramp_entry || current_version %> +
+ <% if re = ramp_entry %> + First appeared: v<%= HTML.escape(re.first_version) %> + <% end %> + <% if v = current_version %> + Current: v<%= HTML.escape(v) %> + <% end %> + <% unless sym.empty? %> + <%= HTML.escape(sym) %> + <% end %> +
+ <% end %> + + <% if loc = type.primary_location %> +

Source: <%= HTML.escape(loc) %>

+ <% end %> + + <% fsdd = type.fsdd %> + <% if fsdd.has_fsdd_content? %> +
+ <% if p = fsdd.personas %>
Personas<%= HTML.escape(p) %>
<% end %> + <% if r = fsdd.requires %>
Requires<%= HTML.escape(r) %>
<% end %> + <% if s = fsdd.since_version %>
Since<%= HTML.escape(s) %>
<% end %> + <% if r = fsdd.ramp %>
Ramp<%= HTML.escape(r) %>
<% end %> + <% if u = fsdd.use %>
Use<%= HTML.escape(u) %>
<% end %> + <% if r = fsdd.result %>
Result<%= HTML.escape(r) %>
<% end %> + <% if st = fsdd.story %> +
Refined story: +<%= HTML.escape(st) %>
+ <% end %> +
+ <% end %> + + <% public_methods = type.methods.select { |m| m.visibility == "Public" } %> + <% unless public_methods.empty? %> +
+

Methods

+ <% public_methods.each do |m| %> + <% + mkey_sep = m.is_class_method ? "." : "#" + mkey = "#{type.full_name}#{mkey_sep}#{m.name}" + msym = ramp.ramp_symbol(mkey, current_version) + mramp = ramp.ramp_for(mkey) + %> +
+
+ <%= HTML.escape(m.kind_label) %> + <%= HTML.escape(m.signature) %> + <% unless msym.empty? %><%= HTML.escape(msym) %><% end %> +
+ <% if mloc = m.location %> +

Source: <%= HTML.escape(mloc) %>

+ <% end %> + <% if mre = mramp %> +

First appeared: v<%= HTML.escape(mre.first_version) %>

+ <% end %> + <% mfsdd = m.fsdd %> + <% if mfsdd.has_fsdd_content? %> +
+ <% if p = mfsdd.personas %>
Personas<%= HTML.escape(p) %>
<% end %> + <% if r = mfsdd.requires %>
Requires<%= HTML.escape(r) %>
<% end %> + <% if s = mfsdd.since_version %>
Since<%= HTML.escape(s) %>
<% end %> + <% if r = mfsdd.ramp %>
Ramp<%= HTML.escape(r) %>
<% end %> + <% if u = mfsdd.use %>
Use<%= HTML.escape(u) %>
<% end %> + <% if r = mfsdd.result %>
Result<%= HTML.escape(r) %>
<% end %> + <% if st = mfsdd.story %> +
Refined story: +<%= HTML.escape(st) %>
+ <% end %> +
+ <% end %> +
+ <% end %> +
+ <% end %> + +
+ + diff --git a/src/amber_cli/fsdd_docs/version_ramp.cr b/src/amber_cli/fsdd_docs/version_ramp.cr new file mode 100644 index 0000000..3d2d769 --- /dev/null +++ b/src/amber_cli/fsdd_docs/version_ramp.cr @@ -0,0 +1,104 @@ +require "./parser" + +module AmberCLI::FSDDDocs + class VersionEntry + getter key : String + getter versions : Array(String) + getter first_version : String + + def initialize(@key : String, @versions : Array(String)) + @first_version = @versions.first? || "" + end + + def present_in?(version : String) : Bool + @versions.includes?(version) + end + end + + class VersionRamp + getter versions : Array(String) + getter entries : Hash(String, VersionEntry) + + def initialize(@versions : Array(String), @entries : Hash(String, VersionEntry)) + end + + def self.empty : VersionRamp + new([] of String, {} of String => VersionEntry) + end + + def self.load(fsdd_docs_dir : String) : VersionRamp + versions_path = File.join(fsdd_docs_dir, "versions.json") + versions = if File.exists?(versions_path) + JSON.parse(File.read(versions_path)).as_a.map(&.as_s) + else + [] of String + end + + return empty if versions.empty? + + all_keys = Hash(String, Array(String)).new + + versions.each do |v| + json_path = File.join(fsdd_docs_dir, "json", v, "index.json") + next unless File.exists?(json_path) + + types = Parser.parse_file(json_path) + types.each do |t| + type_key = t.full_name + (all_keys[type_key] ||= [] of String) << v + + t.methods.each do |m| + sep = m.is_class_method ? "." : "#" + method_key = "#{t.full_name}#{sep}#{m.name}" + (all_keys[method_key] ||= [] of String) << v + end + end + end + + entries = all_keys.transform_values { |vs| VersionEntry.new(vs.first? || "", vs) } + new(versions, entries) + end + + def ramp_for(key : String) : VersionEntry? + entries[key]? + end + + def first_appeared(key : String) : String? + entries[key]?.try(&.first_version) + end + + # Returns ↑ if newly added in current_version, ↓ if removed, "" otherwise. + def ramp_symbol(key : String, current_version : String?) : String + return "" unless current_version + entry = entries[key]? + return "" unless entry + + current_idx = versions.index(current_version) + return "" unless current_idx + + is_in_current = entry.present_in?(current_version) + + if current_idx > 0 + prev_version = versions[current_idx - 1] + is_in_prev = entry.present_in?(prev_version) + + if is_in_current && !is_in_prev + "↑" + elsif !is_in_current && is_in_prev + "↓" + else + "" + end + else + "" + end + end + + # Human-readable "Since: v1.0" label for a key. + def since_label(key : String) : String? + entry = entries[key]? + return nil unless entry + "v#{entry.first_version}" + end + end +end diff --git a/src/amber_cli/helpers/process_runner.cr b/src/amber_cli/helpers/process_runner.cr index e9fa9e0..34c5ea0 100644 --- a/src/amber_cli/helpers/process_runner.cr +++ b/src/amber_cli/helpers/process_runner.cr @@ -121,11 +121,11 @@ module Sentry ok_to_run = true else log :run, "Building..." - time = Time.monotonic + time = Time.instant build_result = Amber::CLI::Helpers.run(build_command_run) exit 1 unless build_result.is_a? Process::Status if build_result.success? - log :run, "Compiled in #{(Time.monotonic - time)}" + log :run, "Compiled in #{(Time.instant - time)}" stop_processes("run") if @app_running ok_to_run = true elsif !@app_running # first run From 549dd8286aff9a2e0e6128671bb3d524c0d3d647 Mon Sep 17 00:00:00 2001 From: crimson-knight Date: Sat, 1 Aug 2026 14:36:17 -0400 Subject: [PATCH 4/6] amber-lsp: add scripts/lsp_smoke.rb live-fire stdio proof The spec/amber_lsp/ suite drives AmberLSP::Server in-process over an IO::Memory pair. That proves the code but not the artifact: it cannot catch a stale bin/amber-lsp, a broken build, or a server that goes silent instead of answering. scripts/lsp_smoke.rb spawns the real executable, builds a throwaway Amber-shaped fixture project (shard.yml WITH an `amber` dependency -- ProjectContext.detect keeps the server silent without one), runs a real framed initialize/initialized/didOpen session, and asserts on the textDocument/publishDiagnostics notifications that come back: >= 1 diagnostic for the violating controller, exactly 0 for the clean one. Exit codes are deliberately three-valued: 0 pass, 1 expectations not met, 2 could-not-measure (no binary, handshake failure, timeout). A timeout is never reported as a pass -- a silent server is not a clean server. Ruby 2.6 / stdlib only so it runs under macOS system ruby with no setup. Verified against crystal-alpha 1.21.0 (Crystal 1.21.0 [9c1e8ec64]). --- scripts/lsp_smoke.rb | 290 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 290 insertions(+) create mode 100755 scripts/lsp_smoke.rb diff --git a/scripts/lsp_smoke.rb b/scripts/lsp_smoke.rb new file mode 100755 index 0000000..559fd4e --- /dev/null +++ b/scripts/lsp_smoke.rb @@ -0,0 +1,290 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# lsp_smoke.rb — live-fire proof that the built `amber-lsp` binary really +# speaks LSP over stdio and really publishes diagnostics. +# +# The Crystal specs under spec/amber_lsp/ exercise the server class in-process +# with an IO::Memory pair. That proves the code, not the binary: it cannot +# catch a stale binary on disk, a broken build, or a server that hangs instead +# of answering. This script spawns the ACTUAL executable, drives a real framed +# stdio session against a throwaway Amber-shaped project, and asserts on the +# `textDocument/publishDiagnostics` notifications that come back. +# +# scripts/lsp_smoke.rb # uses ./bin/amber-lsp +# scripts/lsp_smoke.rb --server /path/to/amber-lsp +# scripts/lsp_smoke.rb --timeout 20 --keep # keep the fixture project +# +# Exit 0 = the violating fixture produced >= 1 diagnostic AND the clean fixture +# produced exactly 0. Exit 1 = live-fire expectations not met. Exit 2 = could +# not run at all (no binary, handshake failure, timeout). A timeout is never +# reported as a pass — "I could not measure it" is not "it is clean". +# +# Ruby 2.6 compatible on purpose: it must run under macOS system ruby with no +# gems, so it works anywhere the binary does. + +require "json" +require "fileutils" +require "tmpdir" + +module LSPSmoke + EXIT_OK = 0 + EXIT_FAILED = 1 + EXIT_CANNOT = 2 + + # LSP DiagnosticSeverity + SEVERITY = { 1 => "error", 2 => "warning", 3 => "info", 4 => "hint" }.freeze + + # Reads `Content-Length: N\r\n\r\n` frames off a pipe under a deadline. + # Buffered + non-blocking so a server that goes quiet times out instead of + # wedging the script forever. + class FrameReader + def initialize(io) + @io = io + @buf = String.new.force_encoding(Encoding::BINARY) + end + + def read_frame(deadline) + loop do + frame = take_frame + return frame if frame + return nil unless fill(deadline) + end + end + + private + + def take_frame + idx = @buf.index("\r\n\r\n") + return nil unless idx + + header = @buf[0, idx] + length = header[/Content-Length:\s*(\d+)/i, 1] + raise "malformed LSP header: #{header.inspect}" if length.nil? + + length = length.to_i + total = idx + 4 + length + return nil if @buf.bytesize < total + + body = @buf.byteslice(idx + 4, length) + @buf = @buf.byteslice(total, @buf.bytesize - total) + body.force_encoding(Encoding::UTF_8) + end + + def fill(deadline) + remaining = deadline - Time.now + return false if remaining <= 0 + return false unless IO.select([@io], nil, nil, remaining) + + @buf << @io.read_nonblock(65_536) + true + rescue IO::WaitReadable + true + rescue EOFError, Errno::EIO + false + end + end + + module_function + + def frame(message) + json = JSON.generate(message) + "Content-Length: #{json.bytesize}\r\n\r\n#{json}" + end + + # A minimal project the LSP will actually accept: ProjectContext.detect only + # switches diagnostics on when shard.yml has an `amber` DEPENDENCY. Without + # it the server stays silent and every file looks clean — the exact false + # green this script exists to make impossible. + def build_fixture(dir) + FileUtils.mkdir_p(File.join(dir, "src", "controllers")) + FileUtils.mkdir_p(File.join(dir, "spec", "controllers")) + + File.write(File.join(dir, "shard.yml"), <<~YAML) + name: lsp_smoke_app + version: 0.1.0 + dependencies: + amber: + github: amberframework/amber + YAML + + # Violating: class name does not end in Controller (amber/controller-naming, + # error severity) and the action never renders (amber/action-return-type). + File.write(File.join(dir, "src", "controllers", "users_controller.cr"), <<~CRYSTAL) + class UsersHandler < Amber::Controller::Base + def index + users = ["Alice", "Bob"] + end + end + CRYSTAL + File.write(File.join(dir, "spec", "controllers", "users_controller_spec.cr"), + "# spec placeholder\n") + + # Clean: correct suffix, renders, documented, fully typed. + File.write(File.join(dir, "src", "controllers", "posts_controller.cr"), <<~CRYSTAL) + # Serves the blog post pages. + class PostsController < Amber::Controller::Base + # Renders the list of posts. + def index : String + render("index.ecr") + end + end + CRYSTAL + File.write(File.join(dir, "spec", "controllers", "posts_controller_spec.cr"), + "# spec placeholder\n") + end + + def session(server_bin, dir, timeout) + root_uri = "file://#{dir}" + bad_uri = "file://#{dir}/src/controllers/users_controller.cr" + good_uri = "file://#{dir}/src/controllers/posts_controller.cr" + + messages = [ + frame("jsonrpc" => "2.0", "id" => 1, "method" => "initialize", + "params" => { "rootUri" => root_uri, "capabilities" => {} }), + frame("jsonrpc" => "2.0", "method" => "initialized", "params" => {}), + frame("jsonrpc" => "2.0", "method" => "textDocument/didOpen", + "params" => { "textDocument" => { + "uri" => bad_uri, "languageId" => "crystal", "version" => 1, + "text" => File.read(File.join(dir, "src", "controllers", "users_controller.cr")) + } }), + frame("jsonrpc" => "2.0", "method" => "textDocument/didOpen", + "params" => { "textDocument" => { + "uri" => good_uri, "languageId" => "crystal", "version" => 1, + "text" => File.read(File.join(dir, "src", "controllers", "posts_controller.cr")) + } }), + frame("jsonrpc" => "2.0", "id" => 2, "method" => "shutdown"), + frame("jsonrpc" => "2.0", "method" => "exit") + ] + + published = {} + initialized = false + + io = IO.popen([server_bin], "r+", err: File::NULL) + begin + io.binmode + io.write(messages.join) + io.flush + + reader = FrameReader.new(io) + deadline = Time.now + timeout + + while published.size < 2 || !initialized + raw = reader.read_frame(deadline) + break if raw.nil? + + msg = JSON.parse(raw) + initialized = true if msg["id"] == 1 && msg.key?("result") + next unless msg["method"] == "textDocument/publishDiagnostics" + + published[msg["params"]["uri"]] = msg + end + ensure + begin + io.close + rescue StandardError + nil + end + end + + { initialized: initialized, published: published, bad_uri: bad_uri, good_uri: good_uri } + end + + def describe(diagnostics) + diagnostics.map do |d| + line = d["range"]["start"]["line"].to_i + 1 + sev = SEVERITY.fetch(d["severity"].to_i, d["severity"].to_s) + " #{sev} L#{line} [#{d['code']}] #{d['message']}" + end + end + + def main(argv) + server = File.join(Dir.pwd, "bin", "amber-lsp") + timeout = 15 + keep = false + + until argv.empty? + case (arg = argv.shift) + when "--server" then server = argv.shift.to_s + when "--timeout" then timeout = argv.shift.to_i + when "--keep" then keep = true + when "-h", "--help" + puts "usage: lsp_smoke.rb [--server PATH] [--timeout SECONDS] [--keep]" + return EXIT_OK + else + warn "lsp_smoke: unknown argument #{arg.inspect}" + return EXIT_CANNOT + end + end + + unless File.file?(server) && File.executable?(server) + warn "lsp_smoke: no executable server at #{server}" + warn "lsp_smoke: build it first — CRYSTAL=crystal-alpha shards build amber-lsp" + return EXIT_CANNOT + end + + dir = Dir.mktmpdir("amber_lsp_smoke") + begin + build_fixture(dir) + puts "server: #{server}" + puts "fixture: #{dir}" + puts + + result = session(server, dir, timeout) + + unless result[:initialized] + warn "lsp_smoke: never received a response to `initialize` within #{timeout}s — handshake FAILED." + return EXIT_CANNOT + end + + bad = result[:published][result[:bad_uri]] + good = result[:published][result[:good_uri]] + + if bad.nil? || good.nil? + missing = [] + missing << "violating fixture" if bad.nil? + missing << "clean fixture" if good.nil? + warn "lsp_smoke: no publishDiagnostics for #{missing.join(' and ')} within #{timeout}s." + warn "lsp_smoke: a silent server is NOT a clean server — treating as could-not-measure." + return EXIT_CANNOT + end + + bad_diags = bad["params"]["diagnostics"] + good_diags = good["params"]["diagnostics"] + + puts "--- publishDiagnostics: VIOLATING fixture (src/controllers/users_controller.cr) ---" + puts JSON.pretty_generate(bad) + puts describe(bad_diags) + puts + puts "--- publishDiagnostics: CLEAN fixture (src/controllers/posts_controller.cr) ---" + puts JSON.pretty_generate(good) + puts describe(good_diags) + puts + + ok = true + if bad_diags.empty? + warn "FAIL: violating fixture produced 0 diagnostics (expected >= 1)." + ok = false + end + unless good_diags.empty? + warn "FAIL: clean fixture produced #{good_diags.size} diagnostic(s) (expected 0)." + ok = false + end + + if ok + puts "LIVE-FIRE OK: violating=#{bad_diags.size} diagnostic(s), clean=0." + EXIT_OK + else + EXIT_FAILED + end + ensure + FileUtils.rm_rf(dir) unless keep + puts "fixture kept at #{dir}" if keep + end + rescue StandardError => e + warn "lsp_smoke: could not run (#{e.class}: #{e.message})" + EXIT_CANNOT + end +end + +exit LSPSmoke.main(ARGV) if $PROGRAM_NAME == __FILE__ From 7761452c90a652a558cbdd87db2ac0f0058dc5d2 Mon Sep 17 00:00:00 2001 From: crimson-knight Date: Sat, 1 Aug 2026 14:55:57 -0400 Subject: [PATCH 5/6] shard.yml: pin ameba to the 1.7 dev line so `shards build` works on Crystal 1.21 Crystal 1.21 removed Crystal::Lexer#next_string_array_token. Every released ameba up to 1.6.4 calls it unguarded from src/ameba/tokenizer.cr:88, so on 1.21 `shards install` dies inside ameba's own postinstall -- before a single line of this project is compiled. The symptom looks like "amber-lsp does not build on 1.21"; it is not. amber-lsp compiles clean and always did. The failing target was ameba, a development dependency. Error: undefined method 'next_string_array_token' for Crystal::Lexer in lib/ameba/src/ameba/tokenizer.cr:88 ameba fixed it in 4057e0c5e "Support Crystal v1.21.0+" (2026-05-04), guarding the call with `lexer.responds_to?(:next_string_array_token)`. That commit is reachable only from the untagged 1.7 line, so `~> 1.6.4` cannot get there. Pinned by SHA rather than by the `v1.7.0-dev` tag it currently points at, because a `-dev` tag is a moving pointer. Switch to `~> 1.7` when 1.7.0 is tagged. Before: CRYSTAL=crystal-alpha shards build amber-lsp -> exit 1 After: CRYSTAL=crystal-alpha shards build amber-lsp -> exit 0 I: Installing ameba (1.7.0-dev at 34a3de4) KNOWN SIDE EFFECT: ameba 1.7.0-dev dropped both `postinstall` and its `executables:` list, so `shards install` no longer produces bin/ameba. Any tooling that shells out to /bin/ameba (our fleet gate does, and skips silently when it is absent) goes quiet until it is rebuilt. `shards build ameba` does NOT do it -- ameba is not a target of this shard.yml. Use: crystal-alpha build lib/ameba/src/cli.cr -o bin/ameba --- shard.yml | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/shard.yml b/shard.yml index 77d07d9..6d443ad 100644 --- a/shard.yml +++ b/shard.yml @@ -48,6 +48,20 @@ dependencies: version: ~> 1.2.2 development_dependencies: + # Pinned to the 1.7 dev line rather than `~> 1.6.4`, because Crystal 1.21 + # REMOVED Crystal::Lexer#next_string_array_token and every released ameba up + # to 1.6.4 calls it unguarded from src/ameba/tokenizer.cr:88. That makes + # `shards install` (and therefore `shards build `) impossible to + # finish on 1.21: it dies inside ameba's own postinstall, before a single + # line of this project is compiled. + # + # ameba fixed it in 4057e0c5e "Support Crystal v1.21.0+" (2026-05-04), which + # guards the call with `lexer.responds_to?(:next_string_array_token)`. That + # commit is reachable only from the untagged 1.7 line. The SHA below is what + # the `v1.7.0-dev` tag points at today; pinned by SHA and not by that tag + # because a `-dev` tag is a moving pointer and this needs to be reproducible. + # + # TODO: switch to `version: ~> 1.7` once ameba 1.7.0 is actually tagged. ameba: github: crystal-ameba/ameba - version: ~> 1.6.4 + commit: 34a3de4598aa009c878a1812bb018f0deaf15126 From 553c6bb1915343e13a7f930de5012843f1ff23b5 Mon Sep 17 00:00:00 2001 From: crimson-knight Date: Sat, 1 Aug 2026 14:57:14 -0400 Subject: [PATCH 6/6] spec: the e2e "fixed" fixture must satisfy OUR OWN FSDD rules (dogfood ruling) agent_e2e_spec asserted that after the agent fixes the file, diagnostics are empty. They were not: the fixture drew 2x fsdd/doc-block-required and 1x fsdd/method-type-signature. This was never Crystal 1.21 drift -- the spec last changed in 06cdee5 and the FSDD rules landed later in 8781d5b, so the new rules correctly fire on a fixture written before they existed. Two ways to make it green: turn the FSDD rules off by default, or fix the fixture. Owner's standing directive decides it -- we need to be eating our own dog food. The rules STAY ON BY DEFAULT; the fixture was wrong, not the rules. So the corrected code now carries a doc comment on the class and on the action and an explicit return type, and "clean" in this spec means clean by our own conventions rather than merely free of the two violations the fixture was originally written to demonstrate. Anything less and this spec would quietly assert that our own conventions are optional. 253 examples, 0 failures, 0 errors, 0 pending --- spec/amber_lsp/integration/agent_e2e_spec.cr | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/spec/amber_lsp/integration/agent_e2e_spec.cr b/spec/amber_lsp/integration/agent_e2e_spec.cr index 912b3a7..213269e 100644 --- a/spec/amber_lsp/integration/agent_e2e_spec.cr +++ b/spec/amber_lsp/integration/agent_e2e_spec.cr @@ -121,9 +121,18 @@ describe "Agent E2E: LSP diagnostic feedback loop" do # The corrected code: # - Renamed "UsersHandler" → "UsersController" (fixes controller-naming) # - Added render call in index (fixes action-return-type) + # - Doc comment on the class and the action (fixes fsdd/doc-block-required) + # - Explicit return type on the action (fixes fsdd/method-type-signature) + # + # The FSDD rules are on by default and they apply to us too. "Clean" in + # this spec means clean by OUR OWN conventions, not merely free of the two + # violations the fixture was originally written to demonstrate — otherwise + # this spec would quietly assert that our conventions are optional. fixed_code = <<-CRYSTAL + # Serves the user-facing account pages. class UsersController < Amber::Controller::Base - def index + # Renders the list of users. + def index : String users = ["Alice", "Bob"] render("index.ecr") end