Skip to content

Commit 3f761e2

Browse files
feat(dag): populate per-node headlines field (#9)
## Summary Completes the v0 DAG schema: each `dag` node now carries the per-node **`headlines`** array the spec defines (`docs/arghda-spec.adoc` lines 128–135) but the builder previously omitted — the sorted, deduped list of top-level headline theorem names the module declares. ## Design - **Reuses the `unpinned-headline` extractor** — no duplicate parsing. `headline_decls` becomes `pub`, and `dag::build` runs it per node with the configured headline `Regex`. - **`build_dag` gains a `headline_pattern: &str` parameter.** `dag` threads the *resolved* pattern through (built-in default < `.arghda/config.toml` < CLI `--headline-pattern`, via the #8 config plumbing), so the DAG honours the same operator configuration as the lint. - Column-0-only extraction means indented `private` helpers are not surfaced (same export-only filter as the rule). ## Verification - `cargo fmt --check`, `cargo clippy --all-targets -- -D warnings`, `cargo build`, `cargo test` all green (7 groups). - New `tests/fixtures/headlines/` + `dag_populates_node_headlines`: asserts `Thm → ["thm-one","thm-two"]`, that the indented `private helper` is excluded, and the import-only `All` entry surfaces `[]`. - **Live:** `arghda dag tests/fixtures/headlines` emits `"headlines": ["thm-one","thm-two"]` on the `Thm` node and `[]` on `All`. Last of three sequenced engine-polish PRs (3 → 2 → 1): `unused-import` (#7), `.arghda/config.toml` (#8), **DAG `headlines`** (this). With these, the v0 Agda lint set and DAG schema in `arghda-spec.adoc` are complete. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_019GiSiEfgZCte35dyykgBHs --- _Generated by [Claude Code](https://claude.ai/code/session_019GiSiEfgZCte35dyykgBHs)_
2 parents 83e5684 + 73eb383 commit 3f761e2

8 files changed

Lines changed: 86 additions & 19 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@ All notable changes to arghda-core are documented here. The format follows
4242
`<PATH>/.arghda/config.toml` (or an explicit `--config <file>`). Precedence
4343
is built-in default < `config.toml` < CLI `--headline-pattern`. Missing file
4444
⇒ defaults; unknown keys are rejected so typos surface.
45+
- DAG `headlines` field: each `dag` node now carries the sorted, deduped list
46+
of top-level headline theorem names it declares (the spec's per-node
47+
`headlines` array), extracted with the same logic as `unpinned-headline` and
48+
honouring the configured headline pattern. Completes the v0 DAG schema.
4549
- RSR scaffolding: `.machine_readable/6a2/` artefacts, `0-AI-MANIFEST.a2ml`,
4650
`Justfile`, `.well-known/`, and community-health files.
4751
- Content-hash invalidation of `proven`: promotion records a SHA-256 of the

README.md

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,9 @@ record.
3939
- Workspace state machine — transitions are file moves, each logged to
4040
`.arghda/events.jsonl` (`claim`, `promote`, `reject`, `requeue`,
4141
`invalidate`)
42-
- `dag` — emits the dependency-DAG JSON (nodes + import edges + blocked
43-
list) for a source tree: the contract a visual layer consumes
42+
- `dag` — emits the dependency-DAG JSON (nodes — each with its lint status
43+
and declared `headlines` — plus import edges and a blocked list) for a
44+
source tree: the contract a visual layer consumes
4445
- `check` — runs Agda on a file and combines the typecheck verdict with the
4546
lint report (degrades gracefully when `agda` is absent)
4647
- First-class import graph (the `graph` module, lifted out of the orphan rule)
@@ -66,11 +67,8 @@ built-in default < `config.toml` < CLI flag. Current schema:
6667
headline_pattern = "^[a-z][A-Za-z0-9-]*$"
6768
```
6869

69-
Not yet: the DAG `headlines` field (the extractor now exists for
70-
`unpinned-headline`, but the per-node `headlines` array in the DAG schema is
71-
still unpopulated); the Groove service manifest. (`missing-without-k` is
72-
subsumed by `missing-safe-pragma`, which already reports a missing
73-
`--without-K`.)
70+
Not yet: the Groove service manifest. (`missing-without-k` is subsumed by
71+
`missing-safe-pragma`, which already reports a missing `--without-K`.)
7472

7573
## Build
7674

src/dag.rs

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,10 @@
99
1010
use crate::diagnostic::Severity;
1111
use crate::graph::{self, Edge};
12-
use crate::lint::{run_lints, LintContext, LintRule};
12+
use crate::lint::{run_lints, unpinned_headline, LintContext, LintRule};
1313
use crate::timestamp::now_rfc3339;
14-
use anyhow::Result;
14+
use anyhow::{Context, Result};
15+
use regex::Regex;
1516
use serde::Serialize;
1617
use std::collections::{BTreeMap, BTreeSet};
1718
use std::path::{Path, PathBuf};
@@ -31,6 +32,9 @@ pub struct DagNode {
3132
/// `clean` | `warn` | `blocked` (lint-derived; not a proof claim).
3233
pub status: &'static str,
3334
pub lint: LintSummary,
35+
/// Top-level theorem names this module declares that match the headline
36+
/// pattern (sorted, deduped). The spec's per-node `headlines` array.
37+
pub headlines: Vec<String>,
3438
}
3539

3640
/// A module that cannot advance, and why.
@@ -54,13 +58,17 @@ pub struct DagDocument {
5458
}
5559

5660
/// Build the DAG document for the source tree at `include_root`, using
57-
/// `entry_modules` (the union of CI roots) for the orphan-reachability rule
58-
/// and `rules` as the lint pack.
61+
/// `entry_modules` (the union of CI roots) for the orphan-reachability rule,
62+
/// `rules` as the lint pack, and `headline_pattern` (the same regex the
63+
/// `unpinned-headline` rule uses) to populate each node's `headlines` array.
5964
pub fn build(
6065
include_root: &Path,
6166
entry_modules: &[PathBuf],
6267
rules: &[Box<dyn LintRule>],
68+
headline_pattern: &str,
6369
) -> Result<DagDocument> {
70+
let headline_matcher = Regex::new(headline_pattern)
71+
.with_context(|| format!("compiling headline pattern `{headline_pattern}`"))?;
6472
let graph = graph::build(include_root)?;
6573
let ctx = LintContext {
6674
include_root,
@@ -95,11 +103,23 @@ pub fn build(
95103
"clean"
96104
};
97105

106+
// Headline theorem names this module declares (sorted, deduped),
107+
// extracted with the same logic as the `unpinned-headline` rule.
108+
let contents = std::fs::read_to_string(&abs).unwrap_or_default();
109+
let mut headlines: Vec<String> =
110+
unpinned_headline::headline_decls(&contents, &headline_matcher)
111+
.into_iter()
112+
.map(|(name, _line)| name)
113+
.collect();
114+
headlines.sort();
115+
headlines.dedup();
116+
98117
nodes.push(DagNode {
99118
id: gn.id.clone(),
100119
file: gn.file.clone(),
101120
status,
102121
lint: summary,
122+
headlines,
103123
});
104124
}
105125

src/lint/unpinned_headline.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,10 @@ fn collect_pinned(contents: &str, out: &mut BTreeSet<String>) {
211211

212212
/// Top-level (column-0) type-signature names matching `matcher`, paired with
213213
/// the 1-based line they were declared on. `a b c : T` yields each name.
214-
fn headline_decls(contents: &str, matcher: &Regex) -> Vec<(String, usize)> {
214+
///
215+
/// Public so the `dag` builder can populate each node's `headlines` array
216+
/// (the spec's DAG schema) from the same extractor this rule uses.
217+
pub fn headline_decls(contents: &str, matcher: &Regex) -> Vec<(String, usize)> {
215218
let mut out = Vec::new();
216219
let mut in_block_comment = false;
217220

src/main.rs

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,8 @@ fn scan(
174174
unused: bool,
175175
json: bool,
176176
) -> Result<()> {
177-
let (roots, rules) = resolve_roots_and_rules(include_root, entry, headline_pattern, config)?;
177+
let (roots, rules, _cfg) =
178+
resolve_roots_and_rules(include_root, entry, headline_pattern, config)?;
178179
let ctx = LintContext {
179180
include_root,
180181
entry_modules: &roots,
@@ -324,8 +325,9 @@ fn dag(
324325
headline_pattern: Option<&str>,
325326
config: Option<&Path>,
326327
) -> Result<()> {
327-
let (roots, rules) = resolve_roots_and_rules(include_root, entry, headline_pattern, config)?;
328-
let doc = build_dag(include_root, &roots, &rules)?;
328+
let (roots, rules, cfg) =
329+
resolve_roots_and_rules(include_root, entry, headline_pattern, config)?;
330+
let doc = build_dag(include_root, &roots, &rules, &cfg.headline_pattern)?;
329331
println!("{}", serde_json::to_string_pretty(&doc)?);
330332
Ok(())
331333
}
@@ -364,7 +366,7 @@ fn resolve_roots_and_rules(
364366
entry: &[PathBuf],
365367
headline_pattern: Option<&str>,
366368
config: Option<&Path>,
367-
) -> Result<(Vec<PathBuf>, RuleSet)> {
369+
) -> Result<(Vec<PathBuf>, RuleSet, RuleConfig)> {
368370
for e in entry {
369371
if !e.is_file() {
370372
anyhow::bail!("entry module not found: {}", e.display());
@@ -388,7 +390,7 @@ fn resolve_roots_and_rules(
388390
} else {
389391
rules_with_config(&cfg)?
390392
};
391-
Ok((roots, rules))
393+
Ok((roots, rules, cfg))
392394
}
393395

394396
fn transition(workspace: &Path, file: &str, from: State, to: State) -> Result<()> {

tests/dag.rs

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
//! `dag` document construction over the fixtures.
22
3+
use arghda_core::lint::unpinned_headline::DEFAULT_HEADLINE_PATTERN;
34
use arghda_core::{build_dag, default_rules};
45
use std::path::PathBuf;
56

@@ -15,7 +16,7 @@ fn fixture(name: &str) -> PathBuf {
1516
fn dag_over_orphan_fixture_has_expected_shape() {
1617
let root = fixture("orphan");
1718
let roots = [root.join("All.agda")];
18-
let doc = build_dag(&root, &roots, &default_rules()).unwrap();
19+
let doc = build_dag(&root, &roots, &default_rules(), DEFAULT_HEADLINE_PATTERN).unwrap();
1920

2021
// Nodes are deterministic and sorted by module id.
2122
let ids: Vec<&str> = doc.nodes.iter().map(|n| n.id.as_str()).collect();
@@ -53,7 +54,7 @@ fn dag_over_orphan_fixture_has_expected_shape() {
5354
fn dag_over_wellformed_fixture_is_all_clean() {
5455
let root = fixture("wellformed");
5556
let roots = [root.join("All.agda")];
56-
let doc = build_dag(&root, &roots, &default_rules()).unwrap();
57+
let doc = build_dag(&root, &roots, &default_rules(), DEFAULT_HEADLINE_PATTERN).unwrap();
5758

5859
assert_eq!(doc.version, "0.1");
5960
assert!(
@@ -65,3 +66,22 @@ fn dag_over_wellformed_fixture_is_all_clean() {
6566
assert!(doc.edges.iter().any(|e| e.from == "All" && e.to == "Good"));
6667
assert!(doc.edges.iter().any(|e| e.from == "All" && e.to == "Util"));
6768
}
69+
70+
#[test]
71+
fn dag_populates_node_headlines() {
72+
let root = fixture("headlines");
73+
let roots = [root.join("All.agda")];
74+
let doc = build_dag(&root, &roots, &default_rules(), DEFAULT_HEADLINE_PATTERN).unwrap();
75+
76+
// `Thm` declares two top-level headline signatures (sorted, deduped); its
77+
// indented `private` helper is not top-level and is not surfaced.
78+
let thm = doc.nodes.iter().find(|n| n.id == "Thm").unwrap();
79+
assert_eq!(
80+
thm.headlines,
81+
vec!["thm-one".to_string(), "thm-two".to_string()]
82+
);
83+
84+
// The entry module has only imports, so no headlines.
85+
let all = doc.nodes.iter().find(|n| n.id == "All").unwrap();
86+
assert!(all.headlines.is_empty());
87+
}

tests/fixtures/headlines/All.agda

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{-# OPTIONS --safe --without-K #-}
2+
3+
module All where
4+
5+
open import Thm

tests/fixtures/headlines/Thm.agda

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{-# OPTIONS --safe --without-K #-}
2+
3+
module Thm where
4+
5+
-- Two top-level headline signatures the DAG should surface, plus an
6+
-- indented (non-top-level) helper that must NOT be surfaced.
7+
thm-one : Set₁
8+
thm-one = Set
9+
10+
thm-two : Set₁
11+
thm-two = Set
12+
13+
private
14+
helper : Set₁
15+
helper = Set

0 commit comments

Comments
 (0)