@arjunkhera/atlas 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,70 +1,74 @@
1
- // One helper change set becomes one pull request, with the fixed label and
2
- // per-change provenance.
1
+ // The tooling change set becomes one pull request, with the fixed label and a
2
+ // source for each change.
3
3
  //
4
- // Milestone M2, slice L2 stage one. The frozen sentence: "Helper pull requests
5
- // carry the fixed label and per-change provenance." The rule behind it is R13
6
- // of the M2 design study's review panel.
4
+ // Stage 1 of the target design retired the five helpers and their phrase
5
+ // router (decided 28 September: code that guesses meaning retires). The
6
+ // tooling writer stays, because it is exact work: it copies two files from
7
+ // the package. The onboarding skill of stage 2 names it; until then the lead
8
+ // skill does.
7
9
  //
8
10
  // The body is built by a pure function, so a test reads it without GitHub. The
9
11
  // git and GitHub calls sit below it, in one place.
10
12
  import { execFileSync } from 'node:child_process';
11
13
  import { childEnvironment } from '../../work/lib/child-env.mjs';
12
14
  import { mkdirSync, writeFileSync } from 'node:fs';
13
- import { dirname, join } from 'node:path';
14
- import { checkChanges, HELPER_LABEL, PROVENANCE_SOURCES } from './helpers.mjs';
15
+ import { dirname, join, posix } from 'node:path';
16
+ import { CHECK_PATH, WORKFLOW_PATH } from '../../shape/check.mjs';
15
17
 
16
- export { HELPER_LABEL, PROVENANCE_SOURCES };
18
+ // Every change names where it came from, and the set is closed.
19
+ export const PROVENANCE_SOURCES = Object.freeze(['owner words', 'repo read', 'task input']);
20
+
21
+ // The one label every tooling pull request carries. A person filters on it.
22
+ export const TOOLING_LABEL = 'atlas-helper';
23
+
24
+ // The only two files the tooling writer may write.
25
+ export const TOOLING_PATHS = Object.freeze([CHECK_PATH, WORKFLOW_PATH]);
26
+
27
+ export class ChangeError extends Error {}
17
28
 
18
29
  // A backtick breaks a table cell the same way a pipe does, and a provenance
19
- // line often carries one. Both are escaped (acceptance review of slice B2).
30
+ // line often carries one. Both are escaped.
20
31
  const escapeCell = (text) => String(text ?? '').replaceAll('|', '\\|').replaceAll('`', '\\`').replaceAll('\n', ' ').trim();
21
32
 
22
- // One request may reach two helpers. "We now deploy prod on Fly" reaches Facts
23
- // and Procedures. It is still ONE pull request: design page D2's rule, carried
24
- // here (architecture section 5.1, rule 1).
25
- export function checkAll(groups) {
26
- if (!Array.isArray(groups) || !groups.length) throw new Error('a pull request needs at least one helper group');
27
- const seen = new Map();
28
- for (const group of groups) {
29
- const helper = checkChanges(group.helperId, group.changes);
30
- if (seen.has(helper.id)) throw new Error(`the ${helper.id} helper appears twice; put its changes in one group`);
31
- seen.set(helper.id, { helper, changes: group.changes });
33
+ // Every change, checked before anything is written. The writer writes only its
34
+ // two files, and every change names its source.
35
+ export function checkChanges(changes) {
36
+ if (!Array.isArray(changes) || !changes.length) throw new ChangeError('a tooling pull request needs at least one change');
37
+ for (const change of changes) {
38
+ const path = posix.normalize(String(change?.path ?? '')).replace(/^\.\//, '');
39
+ if (!path || path === '.') throw new ChangeError('a change has no path');
40
+ if (!TOOLING_PATHS.includes(path)) throw new ChangeError(`the tooling writer writes only ${TOOLING_PATHS.join(' and ')}; it refuses "${path}"`);
41
+ if (typeof change.contents !== 'string') throw new ChangeError(`the change to "${path}" carries no contents`);
42
+ const source = change?.provenance?.source;
43
+ if (!PROVENANCE_SOURCES.includes(source)) throw new ChangeError(`the change to "${path}" names no source; every change names one of ${PROVENANCE_SOURCES.join(', ')}`);
44
+ if (!String(change?.provenance?.detail ?? '').trim()) throw new ChangeError(`the change to "${path}" names the source "${source}" and says nothing about it`);
32
45
  }
33
- return [...seen.values()].sort((a, b) => a.helper.file - b.helper.file);
46
+ return changes;
34
47
  }
35
48
 
36
49
  // The pull request body. Every change is one row, and every row names where the
37
- // change came from. A reader can tell an owner decision from a guess without
38
- // opening the diff.
39
- export function provenanceBody({ helper, helpers, why, changes, groups, repo = null }) {
40
- const parts = groups ?? [{ helper, changes }];
41
- const named = (helpers ?? parts.map((group) => group.helper));
50
+ // change came from.
51
+ export function provenanceBody({ why, changes, repo = null }) {
42
52
  const lines = [
43
- named.length === 1
44
- ? `The \`${named[0].title}\` helper wrote this. It owns ${named[0].ownsText}.`
45
- : `${named.length} helpers wrote this, and it is one pull request because it is one change:\n${named.map((one) => `- \`${one.title}\`, which owns ${one.ownsText}`).join('\n')}`,
53
+ 'The Atlas tooling writer wrote this. It copies the repo check and its workflow from the Atlas package.',
46
54
  '',
47
55
  `**Why.** ${String(why ?? '').trim() || 'Not given.'}`,
48
56
  '',
49
57
  '## What changed, and where each change came from',
50
58
  '',
51
- '| File | Helper | Source | What the source says |',
52
- '|---|---|---|---|',
59
+ '| File | Source | What the source says |',
60
+ '|---|---|---|',
53
61
  ];
54
- for (const group of parts) {
55
- for (const change of group.changes) {
56
- lines.push(`| \`${escapeCell(change.path)}\` | ${escapeCell(group.helper.title)} | ${escapeCell(change.provenance.source)} | ${escapeCell(change.provenance.detail)} |`);
57
- }
58
- }
62
+ for (const change of changes) lines.push(`| \`${escapeCell(change.path)}\` | ${escapeCell(change.provenance.source)} | ${escapeCell(change.provenance.detail)} |`);
59
63
  lines.push(
60
64
  '',
61
65
  '## How to read this',
62
66
  '',
63
- `1. Every file above is instruction class. It steers agents, so a person merges it${repo ? ` in ${repo}` : ''}.`,
67
+ `1. Both files are rule files. They steer agents, so a person merges this${repo ? ` in ${repo}` : ''}.`,
64
68
  '2. An agent made every change. No person edited these files by hand.',
65
69
  `3. "Source" is one of: ${PROVENANCE_SOURCES.join(', ')}.`,
66
70
  '',
67
- `Label: \`${HELPER_LABEL}\`. Authority: Arjunkhera/atlas, amendment A1.6, section 5, slice L2 stage one.`,
71
+ `Label: \`${TOOLING_LABEL}\`.`,
68
72
  '',
69
73
  );
70
74
  return lines.join('\n');
@@ -72,8 +76,8 @@ export function provenanceBody({ helper, helpers, why, changes, groups, repo = n
72
76
 
73
77
  // Write a checked change set into a working tree. It writes files and nothing
74
78
  // else: no branch, no commit, no network. `open` below does the rest.
75
- export function applyChanges(root, helperId, changes) {
76
- const helper = checkChanges(helperId, changes);
79
+ export function applyChanges(root, changes) {
80
+ checkChanges(changes);
77
81
  const written = [];
78
82
  for (const change of changes) {
79
83
  const path = join(root, change.path);
@@ -81,41 +85,36 @@ export function applyChanges(root, helperId, changes) {
81
85
  writeFileSync(path, change.contents);
82
86
  written.push(change.path);
83
87
  }
84
- return { helper, written };
88
+ return { written };
85
89
  }
86
90
 
87
91
  const run = (command, args, cwd) => execFileSync(command, args, { cwd, encoding: 'utf8', timeout: 120_000, maxBuffer: 8 * 1024 * 1024, env: childEnvironment() }).trim();
88
92
 
89
93
  // The whole pull request, live. Needs git and the GitHub command line.
90
94
  //
91
- // It never merges. A person merges every file a helper writes, because every
92
- // one of them steers agents.
93
- export function open({ root, helperId, changes, groups, why, branch, title, base = null, label = HELPER_LABEL, dryRun = false, exec = run }) {
94
- const wanted = groups ?? [{ helperId, changes }];
95
- const checked = checkAll(wanted);
96
- const all = checked.flatMap((group) => group.changes);
97
- // A dry run writes nothing. The first version applied the changes and then
98
- // printed "Nothing was pushed", which was true and misleading (acceptance
99
- // review of batch 16, finding S3-5).
100
- if (!dryRun) for (const group of checked) applyChanges(root, group.helper.id, group.changes);
95
+ // It never merges. A person merges it, because both files steer agents.
96
+ export function open({ root, changes, why, branch, title, base = null, label = TOOLING_LABEL, dryRun = false, exec = run }) {
97
+ checkChanges(changes);
98
+ // A dry run writes nothing.
99
+ if (!dryRun) applyChanges(root, changes);
101
100
  const repo = (() => { try { return exec('gh', ['repo', 'view', '--json', 'nameWithOwner', '--jq', '.nameWithOwner'], root); } catch { return null; } })();
102
- const body = provenanceBody({ groups: checked, why, repo });
101
+ const body = provenanceBody({ why, changes, repo });
103
102
  const onto = base ?? exec('git', ['symbolic-ref', '--short', 'HEAD'], root);
104
- const helper = checked[0].helper;
105
- if (dryRun) return { helper, helpers: checked.map((group) => group.helper), repo, branch, title, body, base: onto, written: all.map((change) => change.path), opened: false };
103
+ const written = changes.map((change) => change.path);
104
+ if (dryRun) return { repo, branch, title, body, base: onto, written, opened: false };
106
105
 
107
106
  exec('git', ['checkout', '-b', branch], root);
108
- exec('git', ['add', '--', ...all.map((change) => change.path)], root);
107
+ exec('git', ['add', '--', ...written], root);
109
108
  exec('git', ['commit', '-m', title, '-m', body], root);
110
109
  exec('git', ['push', '-u', 'origin', branch], root);
111
110
  // A label that does not exist yet makes `gh pr create` fail, so it is made
112
111
  // first. A label that already exists makes that call fail instead, and that
113
112
  // failure is the expected one.
114
- try { exec('gh', ['label', 'create', label, '--description', 'Written by an Atlas helper. A person merges it.', '--color', 'C5A882'], root); } catch { /* it already exists */ }
113
+ try { exec('gh', ['label', 'create', label, '--description', 'Written by the Atlas tooling writer. A person merges it.', '--color', 'C5A882'], root); } catch { /* it already exists */ }
115
114
  // --head names the branch. `git push -u` does not always record the upstream
116
115
  // in a shallow clone, and then `gh pr create` refuses with "you must first
117
116
  // push the current branch" although the branch is already on GitHub
118
117
  // (measured on an Engram clone, 2026-09-20).
119
118
  const url = exec('gh', ['pr', 'create', '--title', title, '--body', body, '--label', label, '--base', onto, '--head', branch], root);
120
- return { helper, helpers: checked.map((group) => group.helper), repo, branch, title, body, base: onto, written: all.map((change) => change.path), opened: true, url };
119
+ return { repo, branch, title, body, base: onto, written, opened: true, url };
121
120
  }
@@ -0,0 +1,51 @@
1
+ {
2
+ "about": "Words that ASD-STE100 does not approve, each with the plain word to use instead. The check flags them and never rewrites. Add a word here with its plain word; remove one only by a decision.",
3
+ "words": {
4
+ "utilize": "use",
5
+ "utilise": "use",
6
+ "leverage": "use",
7
+ "facilitate": "help",
8
+ "commence": "start",
9
+ "terminate": "stop",
10
+ "approximately": "about",
11
+ "additional": "more",
12
+ "additionally": "also",
13
+ "assist": "help",
14
+ "obtain": "get",
15
+ "prior": "before",
16
+ "subsequent": "next",
17
+ "subsequently": "then",
18
+ "numerous": "many",
19
+ "sufficient": "enough",
20
+ "endeavor": "try",
21
+ "attempt": "try",
22
+ "ascertain": "find",
23
+ "demonstrate": "show",
24
+ "indicate": "show",
25
+ "initiate": "start",
26
+ "modify": "change",
27
+ "purchase": "buy",
28
+ "regarding": "about",
29
+ "therefore": "thus",
30
+ "however": "but",
31
+ "furthermore": "also",
32
+ "moreover": "also",
33
+ "nevertheless": "but",
34
+ "whereas": "but",
35
+ "whilst": "while",
36
+ "amongst": "among",
37
+ "simply": "(remove it)",
38
+ "basically": "(remove it)",
39
+ "essentially": "(remove it)",
40
+ "very": "(remove it, or use an exact word)",
41
+ "really": "(remove it)",
42
+ "actually": "(remove it)",
43
+ "etc": "(name each item)",
44
+ "e.g": "for example",
45
+ "i.e": "that is",
46
+ "via": "through",
47
+ "robust": "strong",
48
+ "seamless": "(say what it does)",
49
+ "seamlessly": "(say what it does)"
50
+ }
51
+ }
@@ -0,0 +1,199 @@
1
+ // The STE check. It measures a markdown or HTML page against the limits of
2
+ // ASD-STE100 that code can measure, and flags what breaks them. It never
3
+ // rewrites a word: a person or an agent fixes the text.
4
+ //
5
+ // The limits: 20 words in a sentence of a numbered step, 25 words in any
6
+ // other sentence, 6 sentences in a paragraph, and no word from the list in
7
+ // ste-words.json. Code, block quotes and quoted words are exempt, because
8
+ // they are not the writer's own prose.
9
+ //
10
+ // `--share` adds the privacy filter, with no exemption for quotes: a quoted
11
+ // email address still leaves with the page.
12
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
13
+ import { dirname, extname, join, relative } from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+ import { privacyFindings, loadPrivateTerms } from './privacy.mjs';
16
+
17
+ export const LIMITS = Object.freeze({ step: 20, sentence: 25, paragraph: 6 });
18
+
19
+ const HERE = dirname(fileURLToPath(import.meta.url));
20
+ export const WORDS = Object.freeze(JSON.parse(readFileSync(join(HERE, 'ste-words.json'), 'utf8')).words);
21
+
22
+ // Each listed word, with its common forms.
23
+ function forms(word) {
24
+ const out = new Set([word, `${word}s`, `${word}es`, `${word}d`, `${word}ed`, `${word}ing`]);
25
+ if (word.endsWith('y')) { out.add(`${word.slice(0, -1)}ies`); out.add(`${word.slice(0, -1)}ied`); }
26
+ if (word.endsWith('e')) out.add(`${word.slice(0, -1)}ing`);
27
+ return [...out];
28
+ }
29
+ const FORM_TO_WORD = new Map(Object.keys(WORDS).flatMap((word) => forms(word).map((form) => [form, word])));
30
+
31
+ // ------------------------------------------------------------- reading a page
32
+
33
+ // Code and quoted words become one placeholder word each: they are not the
34
+ // writer's prose, but a name still takes a place in the sentence. The quote
35
+ // marks go with the words they hold.
36
+ function stripInline(text) {
37
+ return text
38
+ .replace(/`[^`]*`/g, ' Code ')
39
+ .replace(/!\[[^\]]*\]\([^)]*\)/g, ' ')
40
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
41
+ .replace(/<https?:[^>]*>/g, ' ')
42
+ .replace(/https?:\/\/\S+/g, ' ')
43
+ .replace(/"[^"\n]*"/g, ' Quote ')
44
+ .replace(/“[^”\n]*”/g, ' Quote ')
45
+ .replace(/(^|[\s(])'[^'\n]+'(?=[\s.,;:)]|$)/g, '$1 Quote ')
46
+ .replace(/\*\*|__|\*|~~/g, '')
47
+ .replace(/<[^>]+>/g, ' ');
48
+ }
49
+
50
+ // Markdown into units: each unit is a paragraph, a list item or a table cell,
51
+ // with the line it starts on and whether it is a numbered step.
52
+ export function markdownUnits(text) {
53
+ const lines = text.split('\n');
54
+ const units = [];
55
+ let current = null;
56
+ let fence = null;
57
+ let comment = false;
58
+ const close = () => { if (current && current.text.trim()) units.push(current); current = null; };
59
+ let index = 0;
60
+ if (lines[0] === '---') {
61
+ const end = lines.indexOf('---', 1);
62
+ if (end > 0) index = end + 1;
63
+ }
64
+ for (; index < lines.length; index += 1) {
65
+ const line = lines[index];
66
+ const number = index + 1;
67
+ const fenceMark = /^\s*(```|~~~)/.exec(line);
68
+ if (fence) { if (fenceMark && fenceMark[1] === fence) fence = null; continue; }
69
+ if (fenceMark) { close(); fence = fenceMark[1]; continue; }
70
+ if (!line.trim()) { close(); continue; }
71
+ if (/^\s*>/.test(line)) { close(); continue; }
72
+ if (comment) { if (line.includes('-->')) comment = false; continue; }
73
+ if (/^\s*<!--/.test(line)) { close(); comment = !line.includes('-->'); continue; }
74
+ if (/^\s*\|/.test(line)) {
75
+ close();
76
+ if (/^\s*\|[\s:|-]+\|\s*$/.test(line)) continue;
77
+ for (const cell of line.split('|').slice(1, -1)) if (cell.trim()) units.push({ line: number, step: false, text: cell });
78
+ continue;
79
+ }
80
+ if (/^\s{0,3}#{1,6}\s/.test(line)) { close(); units.push({ line: number, step: false, text: line.replace(/^\s*#+\s*/, '') }); continue; }
81
+ const numbered = /^\s*\d+[.)]\s+(.*)$/.exec(line);
82
+ const bullet = /^\s*[-*+]\s+(.*)$/.exec(line);
83
+ if (numbered || bullet) { close(); current = { line: number, step: Boolean(numbered), text: (numbered ?? bullet)[1] }; continue; }
84
+ if (current) current.text += ` ${line.trim()}`;
85
+ else current = { line: number, step: false, text: line.trim() };
86
+ }
87
+ close();
88
+ return units.map((unit) => ({ ...unit, text: stripInline(unit.text) }));
89
+ }
90
+
91
+ const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: '\'', nbsp: ' ', mdash: '—', ndash: '–', rsquo: '’', lsquo: '‘', ldquo: '“', rdquo: '”', hellip: '…' };
92
+ const decode = (text) => text.replace(/&(#\d+|#x[0-9a-f]+|[a-z]+);/gi, (whole, name) => {
93
+ if (name[0] === '#') return String.fromCodePoint(name[1].toLowerCase() === 'x' ? parseInt(name.slice(2), 16) : Number(name.slice(1)));
94
+ return ENTITIES[name.toLowerCase()] ?? whole;
95
+ });
96
+
97
+ // HTML into the same units. Each block element is a unit; an item of an
98
+ // ordered list is a step. Script, style, code, pre and quotes are exempt.
99
+ export function htmlUnits(text) {
100
+ // Each replacement keeps its line breaks, so a line number in the body is
101
+ // the line number in the page.
102
+ const blank = (match) => match.replace(/[^\n]/g, ' ');
103
+ const word = (name) => (match) => ` ${name} ${match.replace(/[^\n]/g, '')}`;
104
+ const body = text
105
+ .replace(/<!--[\s\S]*?-->/g, blank)
106
+ .replace(/<(script|style|pre|blockquote|svg|template|title|head)\b[\s\S]*?<\/\1>/gi, blank)
107
+ .replace(/<code\b[\s\S]*?<\/code>/gi, word('Code'))
108
+ .replace(/<q\b[\s\S]*?<\/q>/gi, word('Quote'));
109
+ const lineAt = (offset) => body.slice(0, offset).split('\n').length;
110
+ const units = [];
111
+ const BLOCK = /<(p|li|h[1-6]|td|th|dt|dd|figcaption|caption|summary|label|button)\b[^>]*>([\s\S]*?)<\/\1>/gi;
112
+ for (const match of body.matchAll(BLOCK)) {
113
+ const inner = match[2];
114
+ if (/<(p|li|ul|ol|div|table)\b/i.test(inner)) continue;
115
+ let step = false;
116
+ if (match[1].toLowerCase() === 'li') {
117
+ const before = body.slice(0, match.index);
118
+ const lastOl = before.lastIndexOf('<ol');
119
+ const lastUl = before.lastIndexOf('<ul');
120
+ const closedOl = before.lastIndexOf('</ol');
121
+ step = lastOl > lastUl && lastOl > closedOl;
122
+ }
123
+ const plain = decode(stripInline(inner.replace(/<br\s*\/?>/gi, ' '))).replace(/\s+/g, ' ').trim();
124
+ if (plain) units.push({ line: lineAt(match.index), step, text: plain });
125
+ }
126
+ return units;
127
+ }
128
+
129
+ // ------------------------------------------------------------- measuring
130
+
131
+ // A sentence ends at . ! or ? before a space and a capital, a digit or an
132
+ // opening mark, or at the end of the unit. A colon before a list, and a
133
+ // heading, end nothing.
134
+ export function sentences(text) {
135
+ const clean = text.replace(/\s+/g, ' ').trim();
136
+ if (!clean) return [];
137
+ return clean.split(/(?<=[.!?])\s+(?=[A-Z0-9(“"'])/).map((one) => one.trim()).filter((one) => /[A-Za-z0-9]/.test(one));
138
+ }
139
+
140
+ export const words = (sentence) => sentence.split(/\s+/).filter((token) => /[A-Za-z0-9]/.test(token));
141
+
142
+ export function measure(units) {
143
+ const findings = [];
144
+ for (const unit of units) {
145
+ const list = sentences(unit.text);
146
+ if (list.length > LIMITS.paragraph) findings.push({ line: unit.line, rule: 'paragraph', why: `${list.length} sentences in one paragraph; the limit is ${LIMITS.paragraph}` });
147
+ const limit = unit.step ? LIMITS.step : LIMITS.sentence;
148
+ for (const sentence of list) {
149
+ const count = words(sentence).length;
150
+ if (count > limit) findings.push({ line: unit.line, rule: unit.step ? 'step-sentence' : 'sentence', why: `${count} words in one sentence${unit.step ? ' of a numbered step' : ''}; the limit is ${limit}: "${sentence.split(/\s+/).slice(0, 8).join(' ')} …"` });
151
+ }
152
+ const seen = new Set();
153
+ for (const token of unit.text.toLowerCase().match(/[a-z]+(?:\.[a-z])?/g) ?? []) {
154
+ const word = FORM_TO_WORD.get(token);
155
+ if (word && !seen.has(token)) { seen.add(token); findings.push({ line: unit.line, rule: 'word', why: `"${token}" is not an approved word; use ${WORDS[word]}` }); }
156
+ }
157
+ }
158
+ return findings;
159
+ }
160
+
161
+ // ------------------------------------------------------------- files
162
+
163
+ const READABLE = new Set(['.md', '.markdown', '.html', '.htm']);
164
+ const SKIP = new Set(['node_modules', '.git']);
165
+
166
+ export function pagesUnder(path) {
167
+ if (!existsSync(path)) throw new Error(`${path} does not exist`);
168
+ if (statSync(path).isFile()) return [path];
169
+ const out = [];
170
+ const walk = (dir) => {
171
+ for (const entry of readdirSync(dir).sort()) {
172
+ if (SKIP.has(entry)) continue;
173
+ const full = join(dir, entry);
174
+ if (statSync(full).isDirectory()) walk(full);
175
+ else if (READABLE.has(extname(entry).toLowerCase())) out.push(full);
176
+ }
177
+ };
178
+ walk(path);
179
+ return out;
180
+ }
181
+
182
+ export function checkText(text, { html = false, share = false, terms = [] } = {}) {
183
+ const findings = measure(html ? htmlUnits(text) : markdownUnits(text));
184
+ if (share) findings.push(...privacyFindings(text, { terms }).map((finding) => ({ ...finding, rule: `privacy:${finding.rule}` })));
185
+ return findings.sort((a, b) => a.line - b.line);
186
+ }
187
+
188
+ export function checkPaths(paths, { share = false, terms = null, base = process.cwd() } = {}) {
189
+ const own = terms ?? (share ? loadPrivateTerms() : []);
190
+ const results = [];
191
+ for (const path of paths) {
192
+ for (const file of pagesUnder(path)) {
193
+ const html = /\.html?$/i.test(file);
194
+ const findings = checkText(readFileSync(file, 'utf8'), { html, share, terms: own });
195
+ results.push({ file: relative(base, file) || file, findings });
196
+ }
197
+ }
198
+ return results;
199
+ }
@@ -1,4 +1,4 @@
1
- // The tooling helper. It writes and refreshes the two files Atlas puts in a
1
+ // The tooling writer. It writes and refreshes the two files Atlas puts in a
2
2
  // repository, and it needs no model and no network to do it.
3
3
  //
4
4
  // Milestone M2, slice L2 stage one, done line 1: "The tooling helper writes
@@ -53,7 +53,7 @@ export function isPackageRepo(root, packageRoot) {
53
53
  } catch { return false; }
54
54
  }
55
55
 
56
- // May the helper write into this repository? Returns nothing, or throws a
56
+ // May the tooling writer write into this repository? Returns nothing, or throws a
57
57
  // refusal that says what to do instead.
58
58
  const readWorkflow = (root) => { const full = join(root, WORKFLOW_PATH); return existsSync(full) ? readFileSync(full, 'utf8') : null; };
59
59
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arjunkhera/atlas",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "description": "Atlas: the delivery lifecycle, its crews and the Atlas tools, as a Claude Code plugin for any repository.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",