@aiwg/cli 2026.9.7 → 2026.9.10
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.
- package/bin/aiwg.mjs +55 -10
- package/dist/src/artifacts/cli.js +20 -8
- package/dist/src/artifacts/corpus-views/taxonomies.js +1 -1
- package/dist/src/artifacts/index-builder.js +43 -1
- package/dist/src/artifacts/query-engine.js +7 -0
- package/dist/src/cli/handlers/help.js +6 -5
- package/dist/src/cli/handlers/init.js +7 -2
- package/dist/src/cli/handlers/installation.js +102 -2
- package/dist/src/cli/handlers/mc.js +87 -17
- package/dist/src/cli/handlers/ralph.js +8 -8
- package/dist/src/cli/handlers/refresh.js +67 -7
- package/dist/src/cli/handlers/repo-access.js +155 -4
- package/dist/src/cli/handlers/setup.js +5 -5
- package/dist/src/cli/handlers/steward.js +39 -2
- package/dist/src/cli/handlers/use.js +13 -4
- package/dist/src/cli/handlers/version.js +49 -14
- package/dist/src/cli/handlers/workspace-context.js +8 -0
- package/dist/src/cli/services/deployment-verification.js +71 -10
- package/dist/src/cli/watch-service.js +43 -3
- package/dist/src/config/aiwg-config.js +95 -3
- package/dist/src/config/cli.js +16 -1
- package/dist/src/config/gitignore.js +5 -0
- package/dist/src/dataset/fortemi-capability.d.ts +70 -0
- package/dist/src/dataset/fortemi-capability.js +211 -0
- package/dist/src/dataset/fortemi-dataset-execution.d.ts +9 -0
- package/dist/src/dataset/fortemi-dataset-execution.js +49 -8
- package/dist/src/dataset/fortemi-run-receipt.d.ts +1 -0
- package/dist/src/dataset/fortemi-run-receipt.js +2 -19
- package/dist/src/dataset/fortemi-schema-path.d.ts +7 -0
- package/dist/src/dataset/fortemi-schema-path.js +24 -0
- package/dist/src/dataset/index.d.ts +1 -0
- package/dist/src/dataset/index.js +1 -0
- package/dist/src/extensions/claude-hooks-installer.js +22 -6
- package/dist/src/extensions/project-local-doctor.js +15 -1
- package/dist/src/lint/cli.js +5 -1
- package/dist/src/lint/reporters.js +17 -0
- package/dist/src/lint/runner.js +297 -4
- package/dist/src/policy/authorization.js +1 -1
- package/dist/src/smiths/context-pipeline/workspace-context.js +138 -49
- package/package.json +1 -1
- package/schemas/dataset/fortemi-capability-validation/1.0.1/authority.json +61 -0
- package/schemas/dataset/fortemi-capability-validation/1.0.1/capability.schema.json +469 -0
- package/schemas/security/threat-assessment-report.v1.schema.json +2 -1
- package/tools/agents/deploy-agents.mjs +8 -0
- package/tools/agents/providers/base.mjs +261 -16
- package/tools/agents/providers/claude.mjs +61 -4
- package/tools/agents/providers/codex.mjs +9 -0
- package/tools/skills/deploy-skills-codex.mjs +121 -7
package/dist/src/lint/runner.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* @issue #810
|
|
8
8
|
*/
|
|
9
9
|
import fs from 'fs';
|
|
10
|
+
import { spawnSync } from 'child_process';
|
|
10
11
|
import fsp from 'fs/promises';
|
|
11
12
|
import path from 'path';
|
|
12
13
|
import { glob } from 'glob';
|
|
@@ -29,6 +30,165 @@ function parseFrontmatter(content) {
|
|
|
29
30
|
return result;
|
|
30
31
|
}
|
|
31
32
|
const DEFAULT_REFERENCE_PATTERN = '\\bREF-\\d{3,}\\b';
|
|
33
|
+
/**
|
|
34
|
+
* Phrases that mark an identifier as deliberately absent (#2555). A mention
|
|
35
|
+
* next to one of these documents a gap rather than pointing at a document, so
|
|
36
|
+
* it is not an unresolved reference.
|
|
37
|
+
*/
|
|
38
|
+
export const DEFAULT_ABSENCE_MARKERS = [
|
|
39
|
+
'\\bunallocated\\b',
|
|
40
|
+
'\\bnot in corpus\\b',
|
|
41
|
+
'\\bskipped\\b',
|
|
42
|
+
'\\bretired\\b',
|
|
43
|
+
'\\bwithdrawn\\b',
|
|
44
|
+
'\\bdeliberately (?:absent|unallocated|skipped)\\b',
|
|
45
|
+
'\\bnever (?:allocated|assigned)\\b',
|
|
46
|
+
'\\bdeduplicat',
|
|
47
|
+
];
|
|
48
|
+
// Deliberately NOT markers: "does not exist", "missing", "no longer exists".
|
|
49
|
+
// Those are how a broken reference is described, not how a corpus records an
|
|
50
|
+
// intentional gap, and treating them as markers would suppress the finding this
|
|
51
|
+
// check exists for.
|
|
52
|
+
/**
|
|
53
|
+
* Verification targets — things the inducting agent could have checked.
|
|
54
|
+
*
|
|
55
|
+
* Requiring one of these is what separates "the agent skipped a cheap check"
|
|
56
|
+
* from "the paper's own claim is unverified". Only the first is a provenance
|
|
57
|
+
* gap; the second is legitimate analysis, and #2523 explicitly does not ask
|
|
58
|
+
* agents to stop declaring limitations. Validated against a 2,544-reference
|
|
59
|
+
* corpus, where grammar-only matching made ~45% of hits paper-claim prose
|
|
60
|
+
* ("scaling behavior above 7B is unverified", "Not confirmed (34% vs 51%)").
|
|
61
|
+
*/
|
|
62
|
+
/**
|
|
63
|
+
* Split a markdown line into clauses.
|
|
64
|
+
*
|
|
65
|
+
* Corpus prose keeps whole paragraphs, bullet bodies and changelog table rows on
|
|
66
|
+
* a single line, so "same line" is far too coarse a scope for relating an
|
|
67
|
+
* unperformed action to its target. Sentence and cell boundaries are the unit
|
|
68
|
+
* that actually corresponds to one statement.
|
|
69
|
+
*/
|
|
70
|
+
function splitClauses(line) {
|
|
71
|
+
return line
|
|
72
|
+
.split(/(?<=[.;:!?])\s+|\s+\u2014\s+|\s+--\s+|\|/g)
|
|
73
|
+
.map((c) => c.trim())
|
|
74
|
+
.filter(Boolean);
|
|
75
|
+
}
|
|
76
|
+
export const DEFAULT_VERIFICATION_TARGETS = [
|
|
77
|
+
'openreview',
|
|
78
|
+
'(?:acl )?anthology',
|
|
79
|
+
'proceedings',
|
|
80
|
+
'camera[- ]ready',
|
|
81
|
+
'published version',
|
|
82
|
+
'\\bPMLR\\b',
|
|
83
|
+
'\\bDBLP\\b',
|
|
84
|
+
'\\bOpenAlex\\b',
|
|
85
|
+
'semantic scholar',
|
|
86
|
+
'\\bpubpeer\\b',
|
|
87
|
+
'\\bunpaywall\\b',
|
|
88
|
+
'retraction|correction notice|expression of concern',
|
|
89
|
+
'citation (?:census|count)',
|
|
90
|
+
'influential citation',
|
|
91
|
+
'\\bPDF\\b',
|
|
92
|
+
'full[- ]text',
|
|
93
|
+
'\\be-?print\\b',
|
|
94
|
+
'(?:code|project|dataset|repository|repo)\\s+(?:page|url|link|release|availability)',
|
|
95
|
+
'\\bvenue\\b',
|
|
96
|
+
'\\bacceptance\\b',
|
|
97
|
+
'source[_ ]type',
|
|
98
|
+
];
|
|
99
|
+
/**
|
|
100
|
+
* Phrases that assert a check was not performed. Drawn from real induction
|
|
101
|
+
* output — each of these has appeared in a corpus reference doc (#2523).
|
|
102
|
+
* Only counted when a verification target appears on the same line.
|
|
103
|
+
*/
|
|
104
|
+
export const DEFAULT_UNCERTAINTY_PATTERNS = [
|
|
105
|
+
'(?:was|were|is|are) not (?:retrieved|run|performed|attempted|fetched|queried|checked|acquired|probed|verified|confirmed)',
|
|
106
|
+
'not (?:retrieved|run|performed|attempted|fetched|queried|checked|acquired|probed|verified|confirmed)\\b',
|
|
107
|
+
// Requires the action to be stated as unperformed. Without the trailing verb
|
|
108
|
+
// this matched bare "no search" in unrelated prose and scope statements like
|
|
109
|
+
// "no exhaustive census is claimed", neither of which is a skipped check.
|
|
110
|
+
'no (?:\\w+[ -]){0,4}(?:quer(?:y|ies)|search|census|fetch|check|lookup|probe)(?:es|s)?\\s+(?:was |were )?(?:performed|run|attempted|made|conducted|executed)',
|
|
111
|
+
'\\b(?:is|remains) unverified\\b',
|
|
112
|
+
'\\bunconfirmed\\b',
|
|
113
|
+
'rests on .{0,60} rather than an independent',
|
|
114
|
+
];
|
|
115
|
+
/**
|
|
116
|
+
* A dated outcome closes an uncertainty (#2523's "records an outcome with a
|
|
117
|
+
* date"). The vocabulary is deliberately wider than a verb+date pair: the
|
|
118
|
+
* retraction convention recommended alongside this rule writes
|
|
119
|
+
* `~~<limitation>~~ **Done YYYY-MM-DD (<what closed it>).**`, which no
|
|
120
|
+
* verb+date pattern matches, so a correctly dated retraction was rejected for
|
|
121
|
+
* its shape (#2556).
|
|
122
|
+
*/
|
|
123
|
+
export const DATED_OUTCOME_PATTERN = '(?:\\b(?:done|resolved|completed|closed|addressed|fixed|verified|retrieved|archived|queried|fetched|checked|probed|confirmed)\\b[^\\n]{0,60}?\\d{4}-\\d{2}-\\d{2}|\\d{4}-\\d{2}-\\d{2}[^\\n]{0,30}?\\b(?:done|resolved|completed|closed|addressed|fixed)\\b)';
|
|
124
|
+
/**
|
|
125
|
+
* The closure half of {@link DATED_OUTCOME_PATTERN}. Only explicit closure
|
|
126
|
+
* verbs count outside a struck span: a date sitting anywhere near an action
|
|
127
|
+
* verb ("...was not retrieved at induction. Induction ran 2026-09-01.") is not
|
|
128
|
+
* an outcome, and treating it as one would discharge the very statements this
|
|
129
|
+
* rule exists to catch.
|
|
130
|
+
*/
|
|
131
|
+
export const CLOSURE_OUTCOME_PATTERN = '(?:\\b(?:done|resolved|completed|closed|addressed|fixed)\\b[^\\n]{0,60}?\\d{4}-\\d{2}-\\d{2}|\\d{4}-\\d{2}-\\d{2}[^\\n]{0,30}?\\b(?:done|resolved|completed|closed|addressed|fixed)\\b)';
|
|
132
|
+
/**
|
|
133
|
+
* Spans struck through with `~~…~~` are, by the markup's own meaning, no longer
|
|
134
|
+
* asserted. When a dated outcome follows the span on the same line, the pair is
|
|
135
|
+
* a completed retraction however long the struck text is — the length of the
|
|
136
|
+
* struck span was the hidden variable behind #2556.
|
|
137
|
+
*/
|
|
138
|
+
function retractedWithDatedOutcome(line) {
|
|
139
|
+
const dated = new RegExp(DATED_OUTCOME_PATTERN, 'i');
|
|
140
|
+
const strikethrough = /~~([\s\S]*?)~~/g;
|
|
141
|
+
let match;
|
|
142
|
+
while ((match = strikethrough.exec(line)) !== null) {
|
|
143
|
+
if (dated.test(line.slice(match.index + match[0].length)))
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
/** Character offsets of every `~~…~~` span on a line. */
|
|
149
|
+
function strikethroughSpans(line) {
|
|
150
|
+
const spans = [];
|
|
151
|
+
const strikethrough = /~~([\s\S]*?)~~/g;
|
|
152
|
+
let match;
|
|
153
|
+
while ((match = strikethrough.exec(line)) !== null) {
|
|
154
|
+
spans.push({ start: match.index, end: match.index + match[0].length });
|
|
155
|
+
}
|
|
156
|
+
return spans;
|
|
157
|
+
}
|
|
158
|
+
export const DEFAULT_OBSTACLE_PATTERNS = [
|
|
159
|
+
'\\bHTTP\\s?[45]\\d{2}\\b',
|
|
160
|
+
'\\b(?:401|403|404|429|451|503)\\b',
|
|
161
|
+
'paywall',
|
|
162
|
+
'closed[- ]access',
|
|
163
|
+
'requires? (?:a )?(?:credential|token|API key|subscription|login|account)',
|
|
164
|
+
'\\b(?:HF_TOKEN|API[_ ]KEY)\\b',
|
|
165
|
+
'rate[- ]limit',
|
|
166
|
+
'anti[- ]bot',
|
|
167
|
+
'cloudflare',
|
|
168
|
+
'captcha',
|
|
169
|
+
'endpoint unknown',
|
|
170
|
+
'no (?:known )?endpoint',
|
|
171
|
+
// Obstacle vocabulary observed in a real corpus: these are named obstacles,
|
|
172
|
+
// so the statement is already a real outcome rather than a silent skip.
|
|
173
|
+
'proof[- ]of[- ]work',
|
|
174
|
+
'challenge (?:artifact|page|response)',
|
|
175
|
+
'returned challenge',
|
|
176
|
+
'\\bgated\\b',
|
|
177
|
+
'did not render',
|
|
178
|
+
'green OA',
|
|
179
|
+
// Explicit scope declarations: saying what is deliberately not claimed is an
|
|
180
|
+
// outcome, unlike omitting the check and not saying so.
|
|
181
|
+
'is not (?:claimed|asserted)',
|
|
182
|
+
'NOT (?:recorded|asserted) as',
|
|
183
|
+
'evidence boundary',
|
|
184
|
+
'completion_evidence',
|
|
185
|
+
'status:\\s*(?:incomplete|blocked)',
|
|
186
|
+
'\\b(?:queried|fetched|checked|probed|confirmed|resolved)\\s+(?:on\\s+)?\\d{4}-\\d{2}-\\d{2}',
|
|
187
|
+
'\\bdeferred\\b.{0,40}\\b(?:because|since|due to)\\b',
|
|
188
|
+
// An explicit dated closure ("**Done 2026-09-12 (post-induction audit).**"),
|
|
189
|
+
// which the verb+date pattern above does not match (#2556).
|
|
190
|
+
CLOSURE_OUTCOME_PATTERN,
|
|
191
|
+
];
|
|
32
192
|
/**
|
|
33
193
|
* Build a target-wide artifact ID index once per lint run.
|
|
34
194
|
*
|
|
@@ -117,11 +277,19 @@ function runCheck(check, content, frontmatter, filePath, targetDir, allFiles, re
|
|
|
117
277
|
const refPattern = check.referencePattern || DEFAULT_REFERENCE_PATTERN;
|
|
118
278
|
const regex = new RegExp(refPattern, 'g');
|
|
119
279
|
const lines = content.split('\n');
|
|
280
|
+
const absence = (check.absenceMarkers ?? DEFAULT_ABSENCE_MARKERS).map((p) => new RegExp(p, 'i'));
|
|
120
281
|
for (let i = 0; i < lines.length; i++) {
|
|
121
282
|
let match;
|
|
122
283
|
while ((match = regex.exec(lines[i])) !== null) {
|
|
123
284
|
const refId = match[0];
|
|
124
285
|
const basePath = check.basePath || '.aiwg/research/findings';
|
|
286
|
+
// A corpus documents its deliberate gaps ("REF-2464 remains
|
|
287
|
+
// unallocated after deduplication"). Reading that sentence as a
|
|
288
|
+
// dangling reference left only two ways to silence an error: delete
|
|
289
|
+
// accurate documentation, or allocate a REF that is deliberately
|
|
290
|
+
// absent (#2555).
|
|
291
|
+
if (absence.some((re) => re.test(lines[i])))
|
|
292
|
+
continue;
|
|
125
293
|
if (!referenceIndex.has(refId) && !resolvesFromBasePath(refId, targetDir, basePath)) {
|
|
126
294
|
diagnostics.push({
|
|
127
295
|
ruleId: '',
|
|
@@ -136,6 +304,59 @@ function runCheck(check, content, frontmatter, filePath, targetDir, allFiles, re
|
|
|
136
304
|
}
|
|
137
305
|
break;
|
|
138
306
|
}
|
|
307
|
+
case 'unregistered-uncertainty': {
|
|
308
|
+
// An uncertainty written only into prose is invisible to the verification
|
|
309
|
+
// contract: it was never a declared check, so it never surfaces as
|
|
310
|
+
// `incomplete` and the "never report skipped verification as success" rule
|
|
311
|
+
// is never violated. Flag the bare form; accept it once a specific
|
|
312
|
+
// obstacle is named or an outcome is recorded (#2523).
|
|
313
|
+
const uncertainty = (check.uncertaintyPatterns ?? DEFAULT_UNCERTAINTY_PATTERNS)
|
|
314
|
+
.map((p) => new RegExp(p, 'i'));
|
|
315
|
+
const targets = (check.verificationTargets ?? DEFAULT_VERIFICATION_TARGETS)
|
|
316
|
+
.map((p) => new RegExp(p, 'i'));
|
|
317
|
+
const obstacle = (check.obstaclePatterns ?? DEFAULT_OBSTACLE_PATTERNS)
|
|
318
|
+
.map((p) => new RegExp(p, 'i'));
|
|
319
|
+
const within = check.obstacleWithinLines ?? 2;
|
|
320
|
+
const lines = content.split('\n');
|
|
321
|
+
for (let i = 0; i < lines.length; i++) {
|
|
322
|
+
// Both conditions must hold in the SAME CLAUSE: an unperformed action
|
|
323
|
+
// AND something the agent could have acted on. Without the target, the
|
|
324
|
+
// match is as likely to be the paper's own limitation, which must stay
|
|
325
|
+
// untouched. Without clause scoping, a long markdown line relates two
|
|
326
|
+
// unrelated clauses — real corpus prose puts whole paragraphs and
|
|
327
|
+
// changelog tables on one line, which produced most false positives.
|
|
328
|
+
const clause = splitClauses(lines[i]).find((c) => uncertainty.some((re) => re.test(c)) && targets.some((re) => re.test(c)));
|
|
329
|
+
if (!clause)
|
|
330
|
+
continue;
|
|
331
|
+
// A struck span followed by a dated outcome is a completed retraction,
|
|
332
|
+
// whatever its length. Clause scoping otherwise ends the window before
|
|
333
|
+
// the `**Done <date>**` that closes it, so the recommended convention
|
|
334
|
+
// failed for two-sentence struck text and passed for one (#2556).
|
|
335
|
+
const clauseAt = lines[i].indexOf(clause);
|
|
336
|
+
const struck = clauseAt >= 0 && strikethroughSpans(lines[i])
|
|
337
|
+
.some((span) => clauseAt >= span.start && clauseAt < span.end);
|
|
338
|
+
if (struck && retractedWithDatedOutcome(lines[i]))
|
|
339
|
+
continue;
|
|
340
|
+
// Look in the matching line and the following `within` lines: the
|
|
341
|
+
// obstacle normally sits in the same sentence or the next one.
|
|
342
|
+
const window = lines.slice(i, i + within + 1).join(' ');
|
|
343
|
+
if (obstacle.some((re) => re.test(window)))
|
|
344
|
+
continue;
|
|
345
|
+
diagnostics.push({
|
|
346
|
+
ruleId: '',
|
|
347
|
+
ruleName: '',
|
|
348
|
+
// Left unset so the rule's declared severity applies (see runRule).
|
|
349
|
+
// This is a prose heuristic, so the shipped rule declares `warn`; an
|
|
350
|
+
// operator can raise it to error once a corpus is clean.
|
|
351
|
+
severity: undefined,
|
|
352
|
+
file: filePath,
|
|
353
|
+
line: i + 1,
|
|
354
|
+
message: `Uncertainty stated without a named obstacle or recorded outcome: ${clause.trim().slice(0, 160)}`,
|
|
355
|
+
fix: 'Resolve it if it costs about one request against a known endpoint, or name the specific obstacle (HTTP status, credential required, rate limited, paywalled) so it lands as incomplete/blocked rather than narrative. If the check has since been done, retract the statement with the dated form — `~~<original limitation>~~ **Done YYYY-MM-DD (<what closed it>).** <evidence>` — which closes it whatever the length of the struck text.',
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
break;
|
|
359
|
+
}
|
|
139
360
|
case 'pattern-match': {
|
|
140
361
|
if (!check.pattern)
|
|
141
362
|
break;
|
|
@@ -223,16 +444,44 @@ function runCheck(check, content, frontmatter, filePath, targetDir, allFiles, re
|
|
|
223
444
|
}
|
|
224
445
|
return diagnostics;
|
|
225
446
|
}
|
|
447
|
+
/**
|
|
448
|
+
* Candidate paths a rule glob may be written against (#2555).
|
|
449
|
+
*
|
|
450
|
+
* `collectFiles` walks with `cwd: targetDir`, so files arrive target-relative
|
|
451
|
+
* (`REF-001.md`) while rule globs are written repo-relative
|
|
452
|
+
* (`documentation/references/**\/*.md`). Narrowing the target to the very
|
|
453
|
+
* directory a rule names therefore matched nothing, and the run reported PASS
|
|
454
|
+
* with every finding unreported. Matching against the target-relative path and
|
|
455
|
+
* each ancestor-prefixed form makes a glob resolve the same way wherever the
|
|
456
|
+
* walk is rooted.
|
|
457
|
+
*/
|
|
458
|
+
export function ruleMatchCandidates(file, targetDir) {
|
|
459
|
+
const normalizedFile = file.replace(/\\/g, '/');
|
|
460
|
+
const candidates = [normalizedFile];
|
|
461
|
+
const segments = path.resolve(targetDir).replace(/\\/g, '/').split('/').filter(Boolean);
|
|
462
|
+
let prefix = '';
|
|
463
|
+
for (let i = segments.length - 1; i >= 0 && segments.length - i <= 12; i -= 1) {
|
|
464
|
+
prefix = prefix ? `${segments[i]}/${prefix}` : `${segments[i]}/`;
|
|
465
|
+
candidates.push(`${prefix}${normalizedFile}`);
|
|
466
|
+
}
|
|
467
|
+
return candidates;
|
|
468
|
+
}
|
|
469
|
+
/** True when a rule's glob selects this file, wherever the walk was rooted. */
|
|
470
|
+
export function ruleAppliesToFile(ruleGlob, file, targetDir) {
|
|
471
|
+
return ruleMatchCandidates(file, targetDir).some((candidate) => minimatch(candidate, ruleGlob));
|
|
472
|
+
}
|
|
226
473
|
/**
|
|
227
474
|
* Run a single rule against all matching files in the target
|
|
228
475
|
*/
|
|
229
476
|
async function runRule(rule, targetDir, allFiles, referenceIndex) {
|
|
230
477
|
const diagnostics = [];
|
|
478
|
+
RULE_APPLIED.delete(rule.id);
|
|
231
479
|
// Filter files matching the rule's glob
|
|
232
480
|
const ruleGlob = rule.appliesTo.glob;
|
|
233
|
-
const matchingFiles = allFiles.filter(f =>
|
|
481
|
+
const matchingFiles = allFiles.filter(f => ruleAppliesToFile(ruleGlob, f, targetDir));
|
|
234
482
|
if (matchingFiles.length === 0)
|
|
235
483
|
return diagnostics;
|
|
484
|
+
RULE_APPLIED.add(rule.id);
|
|
236
485
|
// Handle id-unique check at the ruleset level
|
|
237
486
|
const uniqueCheck = rule.checks.find(c => c.type === 'id-unique');
|
|
238
487
|
if (uniqueCheck) {
|
|
@@ -278,10 +527,12 @@ async function runRule(rule, targetDir, allFiles, referenceIndex) {
|
|
|
278
527
|
}
|
|
279
528
|
return diagnostics;
|
|
280
529
|
}
|
|
530
|
+
/** Rule ids that selected at least one file in the current run (#2555). */
|
|
531
|
+
const RULE_APPLIED = new Set();
|
|
281
532
|
/**
|
|
282
533
|
* Collect all files under a target directory
|
|
283
534
|
*/
|
|
284
|
-
async function collectFiles(targetDir, recursive) {
|
|
535
|
+
async function collectFiles(targetDir, recursive, respectGitignore = true) {
|
|
285
536
|
const pattern = recursive ? '**/*' : '*';
|
|
286
537
|
try {
|
|
287
538
|
const files = await glob(pattern, {
|
|
@@ -289,12 +540,44 @@ async function collectFiles(targetDir, recursive) {
|
|
|
289
540
|
nodir: true,
|
|
290
541
|
dot: false,
|
|
291
542
|
});
|
|
292
|
-
|
|
543
|
+
const linted = files.filter(f => f.endsWith('.md') || f.endsWith('.yaml') || f.endsWith('.yml') || f.endsWith('.json'));
|
|
544
|
+
return respectGitignore ? dropGitignored(targetDir, linted) : linted;
|
|
293
545
|
}
|
|
294
546
|
catch {
|
|
295
547
|
return [];
|
|
296
548
|
}
|
|
297
549
|
}
|
|
550
|
+
/**
|
|
551
|
+
* Drop files git ignores (#2555).
|
|
552
|
+
*
|
|
553
|
+
* Generated trees are regenerated, not authored: linting `indices/` reported an
|
|
554
|
+
* error in text that documents a deliberate gap. `git check-ignore` is asked
|
|
555
|
+
* once for the whole set so the semantics are git's own (negations, nested
|
|
556
|
+
* `.gitignore`, `core.excludesFile`) rather than a re-implementation. Outside a
|
|
557
|
+
* repository, or without git, every file is kept.
|
|
558
|
+
*/
|
|
559
|
+
function dropGitignored(targetDir, files) {
|
|
560
|
+
if (files.length === 0)
|
|
561
|
+
return files;
|
|
562
|
+
try {
|
|
563
|
+
const result = spawnSync('git', ['check-ignore', '--stdin'], {
|
|
564
|
+
cwd: targetDir,
|
|
565
|
+
input: `${files.join('\n')}\n`,
|
|
566
|
+
encoding: 'utf8',
|
|
567
|
+
});
|
|
568
|
+
// 0 = some paths ignored, 1 = none ignored, anything else = not a repo or
|
|
569
|
+
// git unavailable, in which case nothing is filtered.
|
|
570
|
+
if (result.error || (result.status !== 0 && result.status !== 1))
|
|
571
|
+
return files;
|
|
572
|
+
const ignored = new Set((result.stdout ?? '').split('\n').map((line) => line.trim()).filter(Boolean));
|
|
573
|
+
if (ignored.size === 0)
|
|
574
|
+
return files;
|
|
575
|
+
return files.filter((file) => !ignored.has(file));
|
|
576
|
+
}
|
|
577
|
+
catch {
|
|
578
|
+
return files;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
298
581
|
/**
|
|
299
582
|
* Auto-detect which rulesets apply to a target path
|
|
300
583
|
*
|
|
@@ -326,12 +609,17 @@ function autoDetectRulesets(target, available) {
|
|
|
326
609
|
*/
|
|
327
610
|
export async function runLint(targetDir, rulesets, options = {}) {
|
|
328
611
|
const recursive = options.recursive ?? true;
|
|
329
|
-
const allFiles = await collectFiles(targetDir, recursive);
|
|
612
|
+
const allFiles = await collectFiles(targetDir, recursive, options.respectGitignore ?? true);
|
|
330
613
|
const referenceIndex = buildReferenceIndex(allFiles);
|
|
331
614
|
const allDiagnostics = [];
|
|
615
|
+
const inapplicableRules = [];
|
|
616
|
+
let rulesSelected = 0;
|
|
332
617
|
for (const ruleset of rulesets) {
|
|
333
618
|
for (const rule of ruleset.rules) {
|
|
619
|
+
rulesSelected += 1;
|
|
334
620
|
const diagnostics = await runRule(rule, targetDir, allFiles, referenceIndex);
|
|
621
|
+
if (!RULE_APPLIED.has(rule.id))
|
|
622
|
+
inapplicableRules.push(rule.id);
|
|
335
623
|
allDiagnostics.push(...diagnostics);
|
|
336
624
|
}
|
|
337
625
|
}
|
|
@@ -356,6 +644,11 @@ export async function runLint(targetDir, rulesets, options = {}) {
|
|
|
356
644
|
warnings,
|
|
357
645
|
infos,
|
|
358
646
|
passed,
|
|
647
|
+
// A run where no rule selected a file must never read the same as a clean
|
|
648
|
+
// run (#2555). Reporters surface this; callers can gate on it.
|
|
649
|
+
rulesSelected,
|
|
650
|
+
rulesApplied: rulesSelected - inapplicableRules.length,
|
|
651
|
+
inapplicableRules,
|
|
359
652
|
},
|
|
360
653
|
timestamp: new Date().toISOString(),
|
|
361
654
|
};
|
|
@@ -113,7 +113,7 @@ export function evaluateAuthorization(model, request) {
|
|
|
113
113
|
}
|
|
114
114
|
export function validateAuthorization(model) {
|
|
115
115
|
if (!model)
|
|
116
|
-
return [{ severity: 'warning', code: 'authorization-missing', message: 'No normalized authorization block;
|
|
116
|
+
return [{ severity: 'warning', code: 'authorization-missing', message: 'No normalized authorization block; default deny applies. Write one with `aiwg steward permissions migrate --apply`.' }];
|
|
117
117
|
const out = [];
|
|
118
118
|
if (model.version !== '1')
|
|
119
119
|
out.push({ severity: 'error', code: 'version', message: `Unsupported authorization version: ${model.version}` });
|