Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,7 @@ For path/filename ops use `fio` helpers (`base_name`/`dir_name`/`path_join`/...)

**Complexity/length lint (STYLE037/STYLE038): new code meets both limits from the start.** On a hit in existing code, the suppress-vs-split resolution policy is `skills/style_lint.md` - never force a split on an honest shape.

**Comment hygiene, all languages: `skills/comment_style_hygiene.md` is the rulebook.** For `.das` outside tutorials/examples the headline is that no comment outside the skill's kept set survives to a commit - scaffolding is welcome while you work, and the PR gate drains it (`skills/internal/make_pr.md`, the Comment drain row). For C/C++ it is no NEW comments. The kept sets, teaching-code boundary, naming and code-shape rules all live in the skill; the `style-hygiene-auditor` agent applies them to every PR's new code (mandatory run in `skills/internal/make_pr.md`, findings persuade rather than block).
**Comment hygiene, all languages: `skills/comment_style_hygiene.md` is the rulebook.** For `.das` in a folder whose `.lint_config` arms `force_clean_comments` the headline is that no comment outside the skill's kept set survives to a commit - scaffolding is welcome while you work, and the PR gate drains it (`skills/internal/make_pr.md`, the Comment drain row); elsewhere the kept set still holds and every other `.das` comment answers to the skill's deletion test. For C/C++ it is no NEW comments. The kept sets, teaching-code boundary, naming and code-shape rules all live in the skill; the `style-hygiene-auditor` agent applies them to every PR's new code (mandatory run in `skills/internal/make_pr.md`, findings persuade rather than block).

## Key Directories

Expand Down
4 changes: 4 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -1854,13 +1854,16 @@ install(DIRECTORY ${PROJECT_SOURCE_DIR}/skills/
# skills/daslang_live.md (prefix match on the full path)
REGEX "/skills/internal$" EXCLUDE
REGEX "/skills/daslang$" EXCLUDE
# ruling provenance for this repo's rule documents, not a skill
REGEX "/LAWS\\.md$" EXCLUDE
PATTERN "*.md"
)
# its README is editing rules for this repo's maintainers, meaningless in the SDK
install(DIRECTORY ${PROJECT_SOURCE_DIR}/skills/daslang/
DESTINATION ${DAS_INSTALL_DOCDIR}/skills/daslang
FILES_MATCHING
REGEX "/daslang/README\\.md$" EXCLUDE
REGEX "/LAWS\\.md$" EXCLUDE
PATTERN "*.md"
)
# The review constitution ships with the review skills that route to it.
Expand Down Expand Up @@ -1959,6 +1962,7 @@ install(DIRECTORY ${PROJECT_SOURCE_DIR}/skills/daslang/
DESTINATION .claude/skills/daslang
FILES_MATCHING
REGEX "/daslang/README\\.md$" EXCLUDE
REGEX "/LAWS\\.md$" EXCLUDE
PATTERN "*.md"
)
install(FILES ${PROJECT_SOURCE_DIR}/.claude/agents/dragon.md DESTINATION .claude/agents)
Expand Down
17 changes: 14 additions & 3 deletions ci/check_shipped_skills.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
"""Gate the skills shipped into an SDK bundle.

The FOLDER is the shipping decision: everything under skills/ ships except
skills/internal/, and skills/daslang/ is the standalone language skill. So
skills/internal/ and the NOT_A_SKILL names below, and skills/daslang/ is the
standalone language skill. So
anything a shipped skill says has to be true *from inside the bundle*. A skill
that points at src/, links a file that is not there, or tells the reader to run
bin/Release/daslang.exe is a dead end for an SDK user -- and for Claude running
Expand Down Expand Up @@ -63,6 +64,10 @@
MACHINE_PATH = re.compile(
r"(?:[A-Za-z]:[\\/](?:Users|Work|DASPKG)\b|/home/[A-Za-z0-9_.]+|[\\/]AppData\b)", re.I)

# A .md under skills/ that is not a skill: ruling provenance for the repo's own rule
# documents. The install rules exclude it, so the set compare below must not demand it.
NOT_A_SKILL = {"LAWS.md"}

# nested paths too -- a skills/internal/x.md reference must not sit unmarked in
# a shipped file
SKILL_REF = re.compile(r"skills/((?:[A-Za-z0-9_-]+/)*[A-Za-z0-9_-]+\.md)")
Expand Down Expand Up @@ -220,6 +225,12 @@ def shipped_exists(rel):
continue
path = os.path.join(dirpath, name)
rel = name if rel_dir == "." else rel_dir.replace(os.sep, "/") + "/" + name
# not a skill at any depth -- the install rules drop it, so a bundle
# carrying one means an install regex stopped matching
if name in NOT_A_SKILL:
problems.append(("skills/" + rel, "not a skill",
"provenance for the repo's rule documents; it must not ship"))
continue
if rel_dir == ".":
root_skills.append(name)
scan_file(path, rel, problems,
Expand Down Expand Up @@ -247,8 +258,8 @@ def shipped_exists(rel):
repo_skills = os.path.join(repo_root, "skills")
if os.path.isdir(repo_skills):
for name in sorted(os.listdir(repo_skills)):
if name.endswith(".md") and not os.path.exists(
os.path.join(skills_dir, name)):
if (name.endswith(".md") and name not in NOT_A_SKILL
and not os.path.exists(os.path.join(skills_dir, name))):
problems.append(("repo skills/" + name, "missing from bundle",
"the install rules did not ship it"))

Expand Down
14 changes: 14 additions & 0 deletions ci/test_check_shipped_skills.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,20 @@ def test_repo_skill_missing_from_bundle(self):
write(self.repo, "skills/dropped.md", "# Dropped\n\nprose\n")
self.assert_fires("missing from bundle", "dropped.md")

def test_laws_md_is_not_a_skill(self):
# ruling provenance: the install rules exclude it on purpose, and a bundle
# that ships it anyway is still a defect
write(self.repo, "skills/LAWS.md", "# LAWS\n\n| date | doc | ask |\n")
rc, out = self.run_gate()
self.assertEqual(rc, 0, out)
write(self.bundle, "skills/LAWS.md", "# LAWS\n\n| date | doc | ask |\n")
self.assert_fires("not a skill", "LAWS.md")

def test_nested_laws_md_in_bundle_fires(self):
# a nested one has no trigger row to miss, so only the basename check sees it
write(self.bundle, "skills/daslang/LAWS.md", "# LAWS\n\n| date | doc | ask |\n")
self.assert_fires("not a skill", "daslang/LAWS.md")


if __name__ == "__main__":
unittest.main(verbosity=1)
5 changes: 4 additions & 1 deletion dastest/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,5 +85,8 @@ Not part of the test runner: `dastest/review_gate.das` is the support library fo
contract lives in `REVIEW_COMMON.md` at the repo root, vendored by repos that adopt it). It
provides finding accumulation and the exit verdict (`gate_finding`, `gate_findings`,
`gate_reset`, `gate_verdict`), plus tree-analysis helpers: `das_requires`,
`cmake_command_blocks`, `cmake_list_entries`, `is_kebab_case`, `find_line`. It lives under
`cmake_command_blocks`, `cmake_command_targets`, `cmake_words`, `cmake_args`,
`cmake_list_entries`, `cmake_test_labels`, `cmake_test_commands`, `is_cmake_keyword`,
`is_kebab_case`, `find_line`. The CMake helpers match command names case-insensitively, as
CMake itself does. It lives under
`dastest/` so an installed SDK carries it the same way it carries the test framework.
176 changes: 173 additions & 3 deletions dastest/review_gate.das
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,31 @@ def private is_ident_byte(b : int) : bool {
return is_alnum(b) || b == '_'
}

def private to_lower_byte(b : int) : int {
return b >= 'A' && b <= 'Z' ? b + 32 : b
}

def private lowered_bytes(s : string) : array<int> {
var out : array<int>
peek_data(s) $(d) {
out |> reserve(length(d))
for (i in range(length(d))) {
out |> push(to_lower_byte(int(d[i])))
}
}
return <- out
}

// CMake command names are case-insensitive, so `Add_Test(` opens a block like `add_test(`.
def private matches_lowered(d; at : int; lowered : array<int>) : bool {
let m = length(lowered)
return false if (at + m > length(d))
for (i in range(m)) {
return false if (to_lower_byte(int(d[at + i])) != lowered[i])
}
return true
}

//! `text` with every // comment cut to end-of-line (string-literal-aware, so a "//" inside
//! a quoted URL survives). Use before scanning sources or descriptors: a name mentioned
//! only in a comment must not count.
Expand Down Expand Up @@ -203,9 +228,10 @@ def gate_descriptor_census(descriptor_path : string; census_dirs : array<string>
//! double-quoted strings respected. Each result is the full block text, command name included.
def cmake_command_blocks(text : string; command : string) : array<string> {
var out : array<string>
var inscope needle <- lowered_bytes(command)
peek_data(text) $(d) {
let n = length(d)
let m = length(command)
let m = length(needle)
var i = 0
while (i < n) {
if (int(d[i]) == '#') { // a commented-out invocation must not open a block
Expand All @@ -214,7 +240,7 @@ def cmake_command_blocks(text : string; command : string) : array<string> {
}
continue
}
if (!starts_with(d, i, command)) {
if (!matches_lowered(d, i, needle)) {
i++
continue
}
Expand Down Expand Up @@ -267,11 +293,155 @@ def cmake_command_blocks(text : string; command : string) : array<string> {
return <- out
}

//! Words of CMake text, `#` comments dropped, whitespace treated as a separator. A
//! double-quoted run is one word: quotes removed, backslashes kept as written. `(` and `)`
//! are words of their own, so a caller can see where one command's arguments end.
def cmake_words(text : string) : array<string> {
var out : array<string>
peek_data(text) $(d) {
let n = length(d)
var i = 0
while (i < n) {
let c = int(d[i])
if (c == '#') {
while (i < n && int(d[i]) != '\n') {
i++
}
} elif (c == '"') {
var j = i + 1
while (j < n && int(d[j]) != '"') {
if (int(d[j]) == '\\' && j + 1 < n) {
j++
}
j++
}
out |> push(slice(d, i + 1, j))
i = j + 1
} elif (c == '(' || c == ')') {
out |> push(slice(d, i, i + 1))
i++
} elif (is_white_space(c)) {
i++
} else {
var j = i
while (j < n && !is_word_break_byte(int(d[j]))) {
j++
}
out |> push(slice(d, i, j))
i = j
}
}
}
return <- out
}

def private is_word_break_byte(b : int) : bool {
return is_white_space(b) || b == '(' || b == ')' || b == '#' || b == '"'
}

//! The argument words of one `command(...)` block - the command name and the enclosing
//! parens dropped.
def cmake_args(command_block : string) : array<string> {
var inscope words <- cmake_words(command_block)
var out : array<string>
var depth = 0
out |> reserve(length(words))
for (w in words) {
if (w == "(") {
depth++
continue if (depth == 1)
} elif (w == ")") {
break if (depth == 1)
depth--
}
out |> push(w) if (depth >= 1)
}
return <- out
}

//! True for a word CMake reads as a keyword rather than a value: caps, digits and
//! underscores only, with at least one letter (`PROPERTIES`, `PASS_REGULAR_EXPRESSION`).
def is_cmake_keyword(word : string) : bool {
var letters = 0
var ok = !empty(word)
peek_data(word) $(d) {
for (i in range(length(d))) {
let b = int(d[i])
if (b >= 'A' && b <= 'Z') {
letters++
} elif (!is_number(b) && b != '_') {
ok = false
break
}
}
}
return ok && letters > 0
}

//! Every ctest label the file's `LABELS` properties name, in order, without duplicates.
//! Both spellings count: `LABELS "big;style_lint"` and the `LABELS big style_lint` an
//! unquoted list expands to. Command-agnostic on purpose: `add_test`,
//! `set_tests_properties` and a discovery macro's `PROPERTIES LABELS` all count.
def cmake_test_labels(text : string) : array<string> {
var out : array<string>
var seen : table<string>
var inscope words <- cmake_words(text)
let n = length(words)
var i = 0
while (i < n) {
if (words[i] != "LABELS") {
i++
continue
}
// the value run ends at the next property keyword or at the end of the command
var j = i + 1
while (j < n && words[j] != ")" && words[j] != "(" && !is_cmake_keyword(words[j])) {
for (lab in split(words[j], ";")) {
continue if (empty(lab) || key_exists(seen, lab))
seen |> insert(lab)
out |> push(lab)
}
j++
}
i = j
}
return <- out
}

//! The word after `COMMAND` in every `add_test` block, verbatim - the thing that runs the
//! test, which may be a target name, a `$<TARGET_FILE:x>` expression, or an interpreter.
def cmake_test_commands(text : string) : array<string> {
var out : array<string>
for (blk in cmake_command_blocks(text, "add_test")) {
var inscope args <- cmake_args(blk)
for (i in range(length(args) - 1)) {
if (args[i] == "COMMAND") {
out |> push(args[i + 1])
break
}
}
}
return <- out
}

//! Name of the first argument of every `command(...)` block, in order - the target a
//! `add_dependencies` / `add_custom_target` / `add_executable` block names.
def cmake_command_targets(text : string; command : string) : array<string> {
var out : array<string>
for (blk in cmake_command_blocks(text, command)) {
var inscope args <- cmake_args(blk)
if (!empty(args)) {
out |> push(args[0])
}
}
return <- out
}

//! Entries of a CMake list across its `set(NAME ...)` and `list(APPEND NAME ...)` blocks,
//! in order, `#` comments dropped. `${...}` references come back verbatim.
def cmake_list_entries(text : string; listname : string) : array<string> {
var out : array<string>
for (cmd in ["set", "SET", "list", "LIST"]) {
for (cmd in ["set", "list"]) {
for (blk in cmake_command_blocks(text, cmd)) {
let open = find(blk, "(")
let body = slice(blk, open + 1, length(blk) - 1)
Expand Down
Loading
Loading