Skip to content

Commit 82d57db

Browse files
committed
build: restore boundaries tooling, add test:server and audit melos scripts
- tool/check_boundaries.dart restored from history (typed logger, no stale spec refs) + its unit tests at test/check_boundaries_test.dart - melos: boundaries (unit + scan), test:server (scoped server suite), audit (example SSG build + crawler audit of the output); analyze/format now also cover the root tool/ and test/ dirs; precommit gains boundaries - ci: boundaries step in the core-server job
1 parent 965a9d6 commit 82d57db

4 files changed

Lines changed: 246 additions & 4 deletions

File tree

.github/workflows/ci.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ jobs:
2727
run: dart run melos:melos run format:check
2828
- name: analyze
2929
run: dart run melos:melos run analyze
30+
- name: boundaries
31+
run: dart run melos:melos run boundaries
3032
- name: test (dart)
3133
run: dart run melos:melos run test:dart
3234
- name: golden

pubspec.yaml

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,16 +17,26 @@ dev_dependencies:
1717
melos:
1818
scripts:
1919
analyze:
20-
description: Run `dart analyze` (strict) in every package.
20+
description: Run `dart analyze` (strict) in every package and the repo tooling.
21+
steps:
22+
- analyze:packages
23+
- analyze:tool
24+
25+
analyze:packages:
26+
description: Run `dart analyze` (strict) in every workspace package.
2127
run: dart run melos:melos exec --dir-exists=lib -- dart analyze --fatal-infos --fatal-warnings .
2228

29+
analyze:tool:
30+
description: Run `dart analyze` (strict) on the root tool/ and test/ dirs.
31+
run: dart analyze --fatal-infos --fatal-warnings tool test
32+
2333
format:
2434
description: Format all Dart code in place.
25-
run: dart format packages example
35+
run: dart format packages example tool test
2636

2737
format:check:
2838
description: Verify formatting; non-zero exit if changes needed (CI gate).
29-
run: dart format --output=none --set-exit-if-changed packages example
39+
run: dart format --output=none --set-exit-if-changed packages example tool test
3040

3141
test:dart:
3242
description: Run pure-Dart tests (core/server).
@@ -36,6 +46,10 @@ melos:
3646
description: Run Flutter widget tests.
3747
run: dart run melos:melos exec --flutter --dir-exists=test -- flutter test
3848

49+
test:server:
50+
description: Run only the hydraline_server suite (SSR/delivery invariants).
51+
run: dart run melos:melos exec --scope=hydraline_server -- dart test
52+
3953
test:
4054
description: Run all unit/widget tests.
4155
steps:
@@ -69,9 +83,38 @@ melos:
6983
description: Property/fuzz XSS suite.
7084
run: dart run melos:melos exec --dir-exists=test/security -- dart test --tags security
7185

86+
boundaries:
87+
description: Scan for forbidden imports across package boundaries (I1).
88+
steps:
89+
- boundaries:unit
90+
- boundaries:scan
91+
92+
boundaries:unit:
93+
description: Unit-test the boundary scanner itself.
94+
run: dart test test/check_boundaries_test.dart
95+
96+
boundaries:scan:
97+
description: Fail on forbidden imports in core/server lib/ (I1).
98+
run: dart tool/check_boundaries.dart
99+
100+
audit:
101+
description: Build the example SSG output and audit what a crawler sees.
102+
steps:
103+
- audit:build
104+
- audit:check
105+
106+
audit:build:
107+
description: Generate the example static site into example/TEMP/audit-dist.
108+
run: dart run melos:melos exec --scope=hydraline_example -- dart run hydraline_flutter:build hydraline.routes.yaml TEMP/audit-dist
109+
110+
audit:check:
111+
description: Run the SEO audit CLI against the generated home page.
112+
run: dart run melos:melos exec --scope=hydraline_example -- dart run hydraline:audit TEMP/audit-dist/index.html
113+
72114
precommit:
73-
description: Local superset of blocking CI gates (SC1).
115+
description: Local superset of blocking CI gates.
74116
steps:
75117
- analyze
76118
- format:check
77119
- test
120+
- boundaries

test/check_boundaries_test.dart

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import 'dart:io';
2+
3+
import 'package:test/test.dart';
4+
5+
import '../tool/check_boundaries.dart';
6+
7+
void main() {
8+
group('findForbiddenImports', () {
9+
test('flags a forbidden package:flutter import', () {
10+
const source = "import 'package:flutter/material.dart';\nvoid main() {}";
11+
expect(
12+
findForbiddenImports(source, const ['package:flutter/']),
13+
contains('package:flutter/material.dart'),
14+
);
15+
});
16+
17+
test('flags dart:ui and dart:html', () {
18+
const source = "import 'dart:ui';\nexport 'dart:html';";
19+
expect(
20+
findForbiddenImports(source, const ['dart:ui', 'dart:html']),
21+
containsAll(<String>['dart:ui', 'dart:html']),
22+
);
23+
});
24+
25+
test('allows non-forbidden imports', () {
26+
const source = "import 'dart:async';\nimport 'package:meta/meta.dart';";
27+
expect(
28+
findForbiddenImports(source, const [
29+
'package:flutter/',
30+
'dart:ui',
31+
'dart:html',
32+
]),
33+
isEmpty,
34+
);
35+
});
36+
37+
test('does not false-positive on comments or string literals', () {
38+
const source =
39+
"// import 'package:flutter/material.dart';\n"
40+
"const s = 'package:flutter/x.dart';";
41+
expect(findForbiddenImports(source, const ['package:flutter/']), isEmpty);
42+
});
43+
});
44+
45+
group('runCheck (invariant I1 — negative test)', () {
46+
test(
47+
'returns non-zero when a rule dir contains a forbidden import',
48+
() async {
49+
final tmp = await Directory.systemTemp.createTemp('hydraline_bnd_');
50+
addTearDown(() => tmp.deleteSync(recursive: true));
51+
File(
52+
'${tmp.path}/offender.dart',
53+
).writeAsStringSync("import 'package:flutter/widgets.dart';");
54+
55+
final code = runCheck([
56+
BoundaryRule(
57+
name: 'core',
58+
dir: tmp.path,
59+
forbidden: const ['package:flutter/'],
60+
),
61+
]);
62+
63+
expect(code, isNonZero);
64+
},
65+
);
66+
67+
test('returns zero when every rule dir is clean', () async {
68+
final tmp = await Directory.systemTemp.createTemp('hydraline_bnd_');
69+
addTearDown(() => tmp.deleteSync(recursive: true));
70+
File(
71+
'${tmp.path}/clean.dart',
72+
).writeAsStringSync("import 'dart:async';\nvoid main() {}");
73+
74+
final code = runCheck([
75+
BoundaryRule(
76+
name: 'core',
77+
dir: tmp.path,
78+
forbidden: const ['package:flutter/', 'dart:ui', 'dart:html'],
79+
),
80+
]);
81+
82+
expect(code, isZero);
83+
});
84+
});
85+
}

tool/check_boundaries.dart

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import 'dart:io';
2+
3+
/// A dependency-boundary rule: within [dir], any import/export whose URI starts
4+
/// with one of [forbidden] is a violation of invariant I1.
5+
class BoundaryRule {
6+
const BoundaryRule({
7+
required this.name,
8+
required this.dir,
9+
required this.forbidden,
10+
});
11+
12+
final String name;
13+
final String dir;
14+
final List<String> forbidden;
15+
}
16+
17+
/// A single forbidden-import occurrence.
18+
class Violation {
19+
const Violation({
20+
required this.rule,
21+
required this.file,
22+
required this.import,
23+
});
24+
25+
final String rule;
26+
final String file;
27+
final String import;
28+
29+
@override
30+
String toString() => '[$rule] $file imports $import';
31+
}
32+
33+
final RegExp _directive = RegExp(
34+
r'''^\s*(?:import|export)\s+['"]([^'"]+)['"]''',
35+
multiLine: true,
36+
);
37+
38+
/// Returns the URIs imported/exported by [source] that start with any of the
39+
/// [forbidden] prefixes. Comments and string literals are ignored because only
40+
/// leading `import`/`export` directives are matched.
41+
List<String> findForbiddenImports(String source, List<String> forbidden) {
42+
final hits = <String>[];
43+
for (final match in _directive.allMatches(source)) {
44+
final uri = match.group(1)!;
45+
if (forbidden.any(uri.startsWith)) {
46+
hits.add(uri);
47+
}
48+
}
49+
return hits;
50+
}
51+
52+
/// Scans every `.dart` file under [dir] for imports forbidden by the rule.
53+
List<Violation> scanDirectory(
54+
Directory dir,
55+
List<String> forbidden, {
56+
required String rule,
57+
}) {
58+
if (!dir.existsSync()) {
59+
return const [];
60+
}
61+
final violations = <Violation>[];
62+
for (final entity in dir.listSync(recursive: true)) {
63+
if (entity is! File || !entity.path.endsWith('.dart')) {
64+
continue;
65+
}
66+
final source = entity.readAsStringSync();
67+
for (final uri in findForbiddenImports(source, forbidden)) {
68+
violations.add(Violation(rule: rule, file: entity.path, import: uri));
69+
}
70+
}
71+
return violations;
72+
}
73+
74+
/// Runs all [rules] and returns a process exit code: 0 when clean, 1 otherwise.
75+
int runCheck(List<BoundaryRule> rules, {void Function(String)? log}) {
76+
final void Function(String) report = log ?? (line) => stdout.writeln(line);
77+
final violations = <Violation>[];
78+
for (final rule in rules) {
79+
violations.addAll(
80+
scanDirectory(Directory(rule.dir), rule.forbidden, rule: rule.name),
81+
);
82+
}
83+
if (violations.isEmpty) {
84+
report('boundaries OK: no forbidden imports (I1).');
85+
return 0;
86+
}
87+
report('Dependency boundary violations (I1):');
88+
for (final violation in violations) {
89+
report(' $violation');
90+
}
91+
return 1;
92+
}
93+
94+
/// Boundary rules for the Hydraline workspace (invariant I1):
95+
/// core must not import Flutter, `dart:ui` or `dart:html`;
96+
/// the server must not import Flutter.
97+
const List<BoundaryRule> defaultRules = [
98+
BoundaryRule(
99+
name: 'hydraline (core)',
100+
dir: 'packages/hydraline/lib',
101+
forbidden: ['package:flutter/', 'dart:ui', 'dart:html'],
102+
),
103+
BoundaryRule(
104+
name: 'hydraline_server',
105+
dir: 'packages/hydraline_server/lib',
106+
forbidden: ['package:flutter/'],
107+
),
108+
];
109+
110+
void main() {
111+
exit(runCheck(defaultRules));
112+
}

0 commit comments

Comments
 (0)